From 42d4c6085f069009bd459c8d991e900b734f08ee Mon Sep 17 00:00:00 2001 From: ayobamiseun Date: Sun, 20 Sep 2026 18:37:17 +0100 Subject: [PATCH] fix(validator): share fence parsing to preserve reference-link checks Reuse the skill linter's fence parser so inline backticks cannot hide broken references and trailing text cannot close a code fence. Cover both failures with LF and CRLF regression cases, preserving diagnostic line numbers. --- scripts/lib/skill-lint.js | 3 +- scripts/validate-reference-links-test.js | 55 ++++++++++++++++++++++++ scripts/validate-reference-links.js | 43 ++---------------- 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/scripts/lib/skill-lint.js b/scripts/lib/skill-lint.js index b7a03e17..fa2aad58 100644 --- a/scripts/lib/skill-lint.js +++ b/scripts/lib/skill-lint.js @@ -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, diff --git a/scripts/validate-reference-links-test.js b/scripts/validate-reference-links-test.js index 93c41cb3..a720fec2 100644 --- a/scripts/validate-reference-links-test.js +++ b/scripts/validate-reference-links-test.js @@ -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/); +}); diff --git a/scripts/validate-reference-links.js b/scripts/validate-reference-links.js index 70927faa..3e69a08e 100644 --- a/scripts/validate-reference-links.js +++ b/scripts/validate-reference-links.js @@ -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 = /(? { - 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))) {