mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-09 17:45:58 +00:00
Address CodeRabbit re-review on #3771: rewriteRuleLinks now tracks fenced code blocks (``` and ~~~) line by line and only rewrites links outside them, so Markdown examples shown inside fences stay verbatim. One shipped rule contains such an example, so this changes installed output today. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
137 lines
4.7 KiB
JavaScript
137 lines
4.7 KiB
JavaScript
'use strict';
|
|
|
|
// GitHub Copilot loads path-specific instructions from
|
|
// `.github/instructions/**/*.instructions.md`, where the `applyTo` frontmatter
|
|
// key carries a comma-separated glob list. ECC rules express the same scoping
|
|
// with the Claude Code convention, a YAML `paths:` list, and rules without
|
|
// frontmatter apply to every prompt. The transform maps one model onto the
|
|
// other: `paths:` becomes `applyTo:`, and a rule without `paths` becomes
|
|
// `applyTo: "**"` so it keeps applying globally.
|
|
//
|
|
// Relative Markdown links between rules are renamed to their installed
|
|
// `.instructions.md` names; links that leave the rule set (docs, README,
|
|
// external URLs) are kept as-is.
|
|
//
|
|
// The transform is an allowlist: only `paths` is read, so frontmatter keys
|
|
// added to ECC rules later cannot silently leak into the Copilot copy.
|
|
|
|
const path = require('path');
|
|
|
|
const GLOBAL_APPLY_TO = '**';
|
|
|
|
function splitFrontmatter(source, label) {
|
|
const text = String(source || '');
|
|
|
|
// An immediately empty block (`---\n---\n`) never reaches the YAML loader:
|
|
// the delimiters are stripped and the rule is treated as global.
|
|
const emptyBlock = text.match(/^---\r?\n---\r?\n/);
|
|
if (emptyBlock) {
|
|
return { frontmatter: {}, body: text.slice(emptyBlock[0].length) };
|
|
}
|
|
|
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
// A whitespace-only block is empty metadata, not a YAML document.
|
|
if (match[1].trim() === '') {
|
|
return { frontmatter: {}, body: text.slice(match[0].length) };
|
|
}
|
|
|
|
// Keep YAML loading behind the transform boundary. Public help commands load
|
|
// the installer graph without executing a transform, including in hermetic
|
|
// packed-artifact checks where runtime dependencies are intentionally absent.
|
|
const frontmatter = require('js-yaml').load(match[1]);
|
|
if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
throw new Error(`Cannot adapt Copilot rule ${label}: frontmatter must be an object`);
|
|
}
|
|
|
|
return {
|
|
frontmatter,
|
|
body: text.slice(match[0].length),
|
|
};
|
|
}
|
|
|
|
function serializeApplyTo(globs) {
|
|
return require('js-yaml')
|
|
.dump({ applyTo: globs }, { lineWidth: -1, noRefs: true })
|
|
.trimEnd();
|
|
}
|
|
|
|
// Markdown links with a compact target (no title), e.g. [text](../a/b.md#x).
|
|
const MARKDOWN_LINK_TARGET = /(\]\()([^)\s]+)(\))/g;
|
|
|
|
// Rewrite relative links that resolve inside the installed rule set to their
|
|
// `.instructions.md` names. Everything else — links escaping `rules/`,
|
|
// absolute or external targets, anchors, `README.md` (not installed), and
|
|
// anything inside fenced code blocks (examples must stay verbatim) — is left
|
|
// unchanged.
|
|
function rewriteRuleLinks(body, label) {
|
|
const normalizedLabel = String(label || '').replace(/\\/g, '/');
|
|
if (!/\.md$/i.test(normalizedLabel)) {
|
|
return body;
|
|
}
|
|
const sourceDir = path.posix.dirname(normalizedLabel);
|
|
|
|
let inFence = false;
|
|
return body.split('\n').map(line => {
|
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
inFence = !inFence;
|
|
return line;
|
|
}
|
|
if (inFence) {
|
|
return line;
|
|
}
|
|
return line.replace(MARKDOWN_LINK_TARGET, (link, open, target, close) => {
|
|
const anchorIndex = target.indexOf('#');
|
|
const targetPath = anchorIndex === -1 ? target : target.slice(0, anchorIndex);
|
|
const anchor = anchorIndex === -1 ? '' : target.slice(anchorIndex);
|
|
|
|
if (!/\.md$/i.test(targetPath)) {
|
|
return link;
|
|
}
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(targetPath) || targetPath.startsWith('/')) {
|
|
return link;
|
|
}
|
|
if (/^readme\.md$/i.test(path.posix.basename(targetPath))) {
|
|
return link;
|
|
}
|
|
|
|
const resolved = path.posix.normalize(path.posix.join(sourceDir, targetPath));
|
|
if (!resolved.startsWith('rules/')) {
|
|
return link;
|
|
}
|
|
|
|
return `${open}${targetPath.replace(/\.md$/i, '.instructions.md')}${anchor}${close}`;
|
|
});
|
|
}).join('\n');
|
|
}
|
|
|
|
function adaptCopilotRule(source, label = '<unknown>') {
|
|
const parsed = splitFrontmatter(source, label);
|
|
|
|
if (!parsed) {
|
|
return `---\napplyTo: "${GLOBAL_APPLY_TO}"\n---\n${rewriteRuleLinks(String(source || ''), label)}`;
|
|
}
|
|
|
|
const { frontmatter } = parsed;
|
|
const body = rewriteRuleLinks(parsed.body, label);
|
|
const paths = frontmatter.paths;
|
|
|
|
if (paths === undefined) {
|
|
return `---\napplyTo: "${GLOBAL_APPLY_TO}"\n---\n${body}`;
|
|
}
|
|
|
|
if (!Array.isArray(paths) || paths.length === 0 || paths.some(glob => typeof glob !== 'string' || glob.trim() === '')) {
|
|
throw new Error(`Cannot adapt Copilot rule ${label}: "paths" must be a non-empty list of glob strings`);
|
|
}
|
|
|
|
return `---\n${serializeApplyTo(paths.map(glob => glob.trim()).join(','))}\n---\n${body}`;
|
|
}
|
|
|
|
module.exports = {
|
|
adaptCopilotRule,
|
|
GLOBAL_APPLY_TO,
|
|
};
|