diff --git a/lib/docs-audit-apply.ts b/lib/docs-audit-apply.ts index cc9f6f0ce..ee42a3a1d 100644 --- a/lib/docs-audit-apply.ts +++ b/lib/docs-audit-apply.ts @@ -2,7 +2,8 @@ * docs-audit-apply — gstack-docs-candidate apply: the parent's mechanical half of the * Step 14.5 packet audit (lib/review-packet-docs.ts). The read-only reader writes one * JSON finding per line and a LAST-line object; apply makes each \`edit\` whose \`old\` - * text occurs exactly once in a doc the candidate lists, refuses protected, generated, + * text occurs exactly once in a doc the candidate lists (an edit whose \`new\` text the doc + * already holds once, with no \`old\` outside it, is already applied and skipped), refuses protected, generated, * out-of-tree and read-only-mode edits as blockers, turns \`block\` findings into * blockers and \`note\` findings into decisions, and prints the audit result in the * schema a /document-release child returns (ship/sections/documentation.md). @@ -53,6 +54,8 @@ export function applyDocsFindings(root: string, candidate: { audit_id: string; m if (!file.startsWith(realRoot + path.sep)) return refuse('resolves outside the repository'); const entry = edited.get(f.path) ?? (() => { const before = fs.readFileSync(file, 'utf8'); return { before, now: before, edits: 0 }; })(); if (GENERATED.test(entry.before)) return refuse('generated file; edit its template'); + const count = (s: string, sub: string) => s.split(sub).length - 1; + if (f.new && count(entry.now, f.new) === 1 && count(entry.now, f.old) === count(f.new, f.old)) return; const at = entry.now.indexOf(f.old); if (at < 0 || entry.now.indexOf(f.old, at + 1) >= 0) return refuse(at < 0 ? 'old text not found' : 'old text occurs more than once'); entry.now = entry.now.slice(0, at) + f.new + entry.now.slice(at + f.old.length); diff --git a/test/review-packet-docs.test.ts b/test/review-packet-docs.test.ts index 384e184a9..484080230 100644 --- a/test/review-packet-docs.test.ts +++ b/test/review-packet-docs.test.ts @@ -189,3 +189,30 @@ describe('docs audit apply: generated files behind frontmatter', () => { } finally { fs.rmSync(root, { recursive: true, force: true }); } }); }); + +describe('docs audit apply: applying the same findings twice', () => { + // Value: protects=a second apply of the same reader output is a no-op reported current, so an edit whose new text appends to its old text is not appended again (#3633 Step 14.5: the HANDOFF_PIN_MOVED sentence landed twice in docs/troubleshooting.md); fails_when=apply re-applies an edit whose new text the file already holds, or reports an applied replace edit as a blocker; + // why_new=GSTA-167 /ship run on #3633; seam=none; no_claim=edits whose new text appears elsewhere in the doc + test('the second apply leaves the file as after one apply and reports current with no blocker', async () => { + const { applyDocsFindings } = await import('../lib/docs-audit-apply'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-twice-')); + try { + fs.mkdirSync(path.join(root, 'docs')); + fs.writeFileSync(path.join(root, 'docs', 'fix.md'), 'Regenerate the handoff or stops.\nRun --fast.\n'); + const out = [ + JSON.stringify({ path: 'docs/fix.md', action: 'edit', kind: 'stale', summary: 'append', old: 'or stops.', new: 'or stops. The reconcile stops the run.' }), + JSON.stringify({ path: 'docs/fix.md', action: 'edit', kind: 'stale', summary: 'rename', old: '--fast', new: '--quick' }), + JSON.stringify({ schema_version: 1, audit_id: 'a1', documentation_section: 'Docs.' }), + ].join('\n'); + const cand = { audit_id: 'a1', mode: 'write', docs: ['docs/fix.md'] }; + const first = applyDocsFindings(root, cand, out); + expect(first.result).toMatchObject({ status: 'updated', files_updated: ['docs/fix.md'], blockers: [] }); + const once = fs.readFileSync(path.join(root, 'docs', 'fix.md'), 'utf8'); + expect(once).toBe('Regenerate the handoff or stops. The reconcile stops the run.\nRun --quick.\n'); + const second = applyDocsFindings(root, cand, out); + expect(second.exit).toBe(0); + expect(second.result).toMatchObject({ status: 'current', files_updated: [], blockers: [] }); + expect(fs.readFileSync(path.join(root, 'docs', 'fix.md'), 'utf8')).toBe(once); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); +});