Merge #586: reuse shared fence parsing in the reference-link validator

This commit is contained in:
Addy Osmani authored and GitHub committed 2026-09-20 12:26:50 -07:00
commit 970b644d75
3 files changed
+60 -41

No files matched your search

+2 -1
View File
@@ -304,11 +304,12 @@ function lintSkill(dirName, skillsDir, knownSkills) {
return lintSkillContent(dirName, content, knownSkills);
}
// Export only the linting functions. The policy collections (REQUIRED_SECTIONS,
// Export only functions. The policy collections (REQUIRED_SECTIONS,
// SECTION_EXEMPT_SKILLS, SKILL_REF_PATTERNS, and the regexes) stay private so a
// test or future consumer cannot mutate shared state and change lint results for
// the rest of the process. Exercise the rules through these functions.
module.exports = {
stripFencedCodeBlocks,
parseFrontmatter,
extractSkillReferences,
lintSkillContent,
+55
View File
@@ -17,6 +17,8 @@ function makeSandbox() {
const scriptsDir = path.join(root, 'scripts');
fs.mkdirSync(scriptsDir, { recursive: true });
fs.copyFileSync(VALIDATOR, path.join(scriptsDir, 'validate-reference-links.js'));
fs.mkdirSync(path.join(scriptsDir, 'lib'));
fs.copyFileSync(path.join(__dirname, 'lib', 'skill-lint.js'), path.join(scriptsDir, 'lib', 'skill-lint.js'));
sandboxes.push(root);
return root;
}
@@ -224,3 +226,56 @@ test('a real broken link outside any fence is still reported', () => {
assert.match(result.stdout, /1 skills checked — 1 error\(s\) — FAILED/);
assert.match(result.stdout, /L5: references\/definition-of-done\.md/);
});
for (const [ending, newline] of [['LF', '\n'], ['CRLF', '\r\n']]) {
test(`inline backticks do not hide subsequent broken links (${ending})`, () => {
const root = makeSandbox();
writeFile(root, 'skills/example/SKILL.md', [
'```js``` is inline code, not a fence opener.',
'See [missing](references/missing.md).',
'',
].join(newline));
const result = run(root);
assert.equal(result.status, 1, result.stdout + result.stderr);
assert.match(result.stdout, /L2: references\/missing\.md/);
assert.match(result.stdout, /1 skills checked — 1 error\(s\) — FAILED/);
});
for (const marker of ['```', '~~~']) {
test(`trailing text does not close a ${marker} fence (${ending})`, () => {
const root = makeSandbox();
writeFile(root, 'skills/example/SKILL.md', [
marker + 'markdown',
marker + ' still part of the example',
'[example](references/example-only.md)',
marker + ' \t',
'[real link](references/missing.md)',
'',
].join(newline));
const result = run(root);
assert.equal(result.status, 1, result.stdout + result.stderr);
assert.match(result.stdout, /L5: references\/missing\.md/);
assert.match(result.stdout, /1 skills checked — 1 error\(s\) — FAILED/);
assert.doesNotMatch(result.stdout, /example-only\.md/);
});
}
}
test('tilde fence info strings may contain backticks', () => {
const root = makeSandbox();
writeFile(root, 'skills/example/SKILL.md', [
'~~~example `code`',
'[example](references/example-only.md)',
'~~~',
'',
].join('\n'));
const result = run(root);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /1 skills checked — 0 error\(s\) — PASSED/);
});
+3 -40
View File
@@ -38,6 +38,7 @@
const fs = require('fs');
const path = require('path');
const { stripFencedCodeBlocks } = require('./lib/skill-lint');
const ROOT = path.resolve(__dirname, '..');
const SKILLS_DIR = path.join(ROOT, 'skills');
@@ -47,50 +48,12 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
// non-path character so `myreferences/x.md` does not match.
const REFERENCE_LINK_RE = /(?<![A-Za-z0-9._/-])((?:\.\.\/)*references\/[A-Za-z0-9._-]+\.md)/g;
/**
* Line indices (0-based) that sit inside a fenced code block.
*
* Scans line by line rather than matching one regex over the document, so the
* three fence forms CommonMark allows are all handled: tilde fences, fences
* indented up to three spaces, and a closing fence longer than its opener. A
* fence closes only on the same marker character repeated at least as many
* times, so a ``` line inside a ~~~ block does not end it.
*
* Deliberately local rather than shared with `scripts/lib/skill-lint.js`:
* that module's `stripFencedCodeBlocks` is a single regex with the gaps
* described in #437, and #444 is already rewriting it. Duplicating a correct
* scanner here is the smaller cost than forking a rewrite in flight; once
* #444 lands, the two should collapse into one helper under `scripts/lib/`.
*/
function fencedLineNumbers(lines) {
const fenced = new Set();
let fence = null;
lines.forEach((line, i) => {
const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (fence) {
fenced.add(i);
if (marker && marker[1][0] === fence.marker && marker[1].length >= fence.length) {
fence = null;
}
return;
}
if (marker) {
fence = { marker: marker[1][0], length: marker[1].length };
fenced.add(i);
}
});
return fenced;
}
function findViolations(skillDir, skillFile) {
const violations = [];
const lines = fs.readFileSync(skillFile, 'utf8').split(/\r?\n/);
const fenced = fencedLineNumbers(lines);
// Share the linter's fence rules; blanked lines preserve diagnostic positions.
const lines = stripFencedCodeBlocks(fs.readFileSync(skillFile, 'utf8')).split('\n');
lines.forEach((line, i) => {
if (fenced.has(i)) return;
for (const match of line.matchAll(REFERENCE_LINK_RE)) {
const link = match[1];
if (!fs.existsSync(path.resolve(skillDir, link))) {