From 39db16a6534adfca415a5c3fa07acf8d702272d9 Mon Sep 17 00:00:00 2001 From: Twisted Fate Date: Tue, 15 Sep 2026 08:40:46 +0800 Subject: [PATCH] fix(#4678): resolve :LINE citations against their underlying files (#4719) * fix(#4678): check :LINE citations instead of dropping or misreporting them cmdVerifyReferences had two opposite failures on citations carrying a trailing line suffix. The backtick extractor anchored the closing backtick right after the extension, so `src/foo.ts:42` matched neither regex and was silently dropped -- an all-line-numbered document reported {valid:true, found:0, missing:[], total:0}, indistinguishable from one with no citations. The @-extractor treated ':' as a legal path character, so @src/foo.ts:42 was probed with the suffix glued on and a resolvable file was reported missing. Strip the :N / :N-M suffix (stripLineSuffix) before existsSync in both loops -- for filesystem resolution only; found/missing keep reporting the original citation text -- and let the backtick regex accept the optional suffix so those citations are counted at all. URL skip, template-placeholder skip, dedup, ~/ expansion and the output contract are unchanged. Whether a line number past EOF counts as missing is an open design question and stays out of scope. * chore(#4678): backfill changeset pr field with PR number The fragment shipped as the documented pr: 0 placeholder; the merge gate requires pr > 0 before a fragment can land, so backfill 4719 now that the PR number exists. --------- Co-authored-by: TwistedRiCen <16397953+TwistedRiCen@users.noreply.github.com> Co-authored-by: Tom Boucher --- .changeset/sturdy-ravens-hum.md | 5 ++++ src/verify.cts | 17 +++++++---- tests/verify.test.cjs | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 .changeset/sturdy-ravens-hum.md diff --git a/.changeset/sturdy-ravens-hum.md b/.changeset/sturdy-ravens-hum.md new file mode 100644 index 000000000..1eab5df8d --- /dev/null +++ b/.changeset/sturdy-ravens-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4719 +--- +**`verify references` no longer mishandles `:LINE` citations** — backtick citations with a line suffix (e.g. `src/foo.ts:42`) are now checked instead of silently dropped, and @-citations with a line suffix resolve against the underlying file instead of being reported missing (#4678). diff --git a/src/verify.cts b/src/verify.cts index 9d7b4a275..87fe17cd9 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -1312,6 +1312,12 @@ function cmdVerifyPhaseCompleteness(cwd: string, phase: string, raw: boolean): v ); } +// #4678: citations may carry a trailing line suffix (":42", ":1-20") that +// describes a location inside the file, not part of the path itself. +function stripLineSuffix(ref: string): string { + return ref.replace(/:\d+(?:-\d+)?$/, ''); +} + function cmdVerifyReferences(cwd: string, filePath: string, raw: boolean): void { if (!filePath) { error('file path required'); @@ -1329,9 +1335,10 @@ function cmdVerifyReferences(cwd: string, filePath: string, raw: boolean): void const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || []; for (const ref of atRefs) { const cleanRef = ref.slice(1); - const resolved = cleanRef.startsWith('~/') - ? path.join(process.env['HOME'] || '', cleanRef.slice(2)) - : path.join(cwd, cleanRef); + const fsRef = stripLineSuffix(cleanRef); + const resolved = fsRef.startsWith('~/') + ? path.join(process.env['HOME'] || '', fsRef.slice(2)) + : path.join(cwd, fsRef); if (fs.existsSync(resolved)) { found.push(cleanRef); } else { @@ -1339,12 +1346,12 @@ function cmdVerifyReferences(cwd: string, filePath: string, raw: boolean): void } } - const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || []; + const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10}(?::\d+(?:-\d+)?)?)`/g) || []; for (const ref of backtickRefs) { const cleanRef = ref.slice(1, -1); if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue; if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; - const resolved = path.join(cwd, cleanRef); + const resolved = path.join(cwd, stripLineSuffix(cleanRef)); if (fs.existsSync(resolved)) { found.push(cleanRef); } else { diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index 0d0fa3629..2847971bc 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -1397,6 +1397,58 @@ describe('verify references command', () => { assert.strictEqual(output.total, 0, `Expected total 0 (template skipped): ${JSON.stringify(output)}`); }); + test('#4678: line-numbered citations are checked, not dropped or misreported', () => { + fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");\n'); + fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'helper.js'), 'module.exports = {};\n'); + const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md'); + fs.writeFileSync(filePath, [ + '- `src/gone.ts:99`', + '- `src/utils/helper.js:7`', + '- @src/app.js:42', + '- @src/gone.ts:1', + '- `src/utils/helper.js`', + '- `src/gone.ts`', + '- @src/app.js', + '', + ].join('\n')); + + const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // Every citation must land in exactly one bucket: the three gone.ts citations + // (with and without the line suffix, in both citation styles) are missing; + // the rest resolve. + assert.strictEqual(output.total, 7, `Expected total 7: ${JSON.stringify(output)}`); + assert.strictEqual(output.found, 4, `Expected found 4: ${JSON.stringify(output)}`); + assert.ok( + output.missing.includes('src/gone.ts:99'), + `Expected missing to keep the original citation text "src/gone.ts:99": ${JSON.stringify(output.missing)}` + ); + assert.ok( + output.missing.includes('src/gone.ts:1'), + `Expected missing to keep the original citation text "src/gone.ts:1": ${JSON.stringify(output.missing)}` + ); + assert.ok( + output.missing.includes('src/gone.ts'), + `Expected missing to include "src/gone.ts": ${JSON.stringify(output.missing)}` + ); + assert.strictEqual(output.valid, false, 'should be invalid'); + }); + + test('#4678: an all-missing line-numbered document is not reported valid', () => { + const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md'); + fs.writeFileSync(filePath, ['- `src/gone.ts:99`', '- `src/also-gone.ts:1-20`', ''].join('\n')); + + const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.total, 2, `Expected total 2: ${JSON.stringify(output)}`); + assert.strictEqual(output.missing.length, 2, `Expected both citations missing: ${JSON.stringify(output)}`); + assert.strictEqual(output.valid, false, `Must not report valid: ${JSON.stringify(output)}`); + }); + test('returns error for nonexistent file', () => { const result = runGsdTools('verify references .planning/phases/01-test/nonexistent.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`);