docguard-cli 0.37.1 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +7 -5
  2. package/cli/commands/feedback.mjs +147 -6
  3. package/cli/commands/reconcile.mjs +42 -0
  4. package/cli/commands/retire.mjs +11 -15
  5. package/cli/commands/specs.mjs +198 -3
  6. package/cli/commands/sync.mjs +16 -44
  7. package/cli/docguard.mjs +51 -6
  8. package/cli/feedback-fixture.mjs +188 -0
  9. package/cli/scanners/document-lifecycle.mjs +19 -43
  10. package/cli/scanners/lifecycle-context.mjs +50 -0
  11. package/cli/scanners/reconciliation.mjs +141 -0
  12. package/cli/scanners/requirement-evidence.mjs +41 -8
  13. package/cli/scanners/retirement-manifest.mjs +52 -0
  14. package/cli/scanners/spec-registry.mjs +36 -9
  15. package/cli/shared-sync-scope.mjs +35 -0
  16. package/cli/validators/security.mjs +5 -4
  17. package/cli/validators/traceability.mjs +2 -1
  18. package/cli/writers/file-transaction.mjs +85 -0
  19. package/cli/writers/spec-outcomes.mjs +27 -0
  20. package/extensions/spec-kit-docguard/README.md +6 -2
  21. package/extensions/spec-kit-docguard/commands/complete.md +38 -0
  22. package/extensions/spec-kit-docguard/extension.yml +20 -4
  23. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  24. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  25. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  26. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/templates/extensions.yml +16 -0
  29. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  30. package/package.json +2 -1
  31. package/schemas/docguard-benchmark.schema.json +84 -0
  32. package/schemas/docguard-feedback-fixture.schema.json +54 -0
  33. package/schemas/docguard-specs.schema.json +19 -2
  34. package/templates/ci/github-actions.yml +1 -1
  35. package/templates/feedback-fixture.json +18 -0
@@ -12,6 +12,7 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
12
12
  *
13
13
  * Default is a DRY RUN (preview); `--write` applies. `--since <ref>` adds the
14
14
  * git diff as context. Only edits docguard:generated docs unless `--force`.
15
+ * @implements docguard.document-lifecycle#FR-010
15
16
  */
16
17
 
17
18
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -22,6 +23,7 @@ import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
22
23
  import { getSection, replaceSection } from '../writers/sections.mjs';
23
24
  import { hasGeneratedMarker } from '../writers/api-reference.mjs';
24
25
  import { runSyncTests } from './sync-tests.mjs';
26
+ import { sectionTouchedByChanges } from '../shared-sync-scope.mjs';
25
27
 
26
28
  function gitChangedFiles(projectDir, since) {
27
29
  const run = (args) => {
@@ -44,39 +46,6 @@ function gitChangedFiles(projectDir, since) {
44
46
  * The predicates are matched against project-relative POSIX paths (the form
45
47
  * `git diff --name-only` returns).
46
48
  */
47
- const SECTION_FILE_MATCHERS = {
48
- 'tech-stack': (p) => /package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|pom\.xml$|Gemfile$/.test(p),
49
- 'frontend-modules': (p) => /(^|\/)(src\/)?(stores|hooks|contexts|features)\//.test(p),
50
- 'endpoints-table': (p) => /(^|\/)(routes|controllers|handlers|app\/api)\//.test(p)
51
- || /\.(yaml|yml|json)$/i.test(p) && /openapi|swagger/i.test(p),
52
- 'entities-table': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
53
- || /\.prisma$/.test(p),
54
- 'relationships': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
55
- || /\.prisma$/.test(p),
56
- 'screens-table': (p) => /(^|\/)(screens|pages|app)\//.test(p)
57
- || /\.(tsx|jsx)$/.test(p),
58
- 'flows': (p) => /(^|\/)(screens|pages|app|routes)\//.test(p),
59
- 'integrations-table':(p) => /package\.json$|pyproject\.toml$|requirements.*\.txt$|Cargo\.toml$/.test(p),
60
- 'features-table': (p) => /(^|\/)(features|domains)\//.test(p),
61
- 'features': (p) => /(^|\/)(features|domains)\//.test(p),
62
- 'env-vars-table': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p)
63
- || /\.(ts|tsx|js|jsx|mjs|py|go|rs|java|kt|rb)$/.test(p), // any code may use env
64
- 'setup': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p),
65
- };
66
-
67
- /**
68
- * Decide whether a given code-truth section should be re-synced based on the
69
- * set of changed files. Returns true when:
70
- * - changedFiles is null/empty (no scope info → sync everything), OR
71
- * - any changed file matches the section's known source patterns, OR
72
- * - the section has no matcher registered (unknown → conservative: sync)
73
- */
74
- function sectionTouchedByChanges(sectionId, changedFiles) {
75
- if (!changedFiles || changedFiles.length === 0) return true;
76
- const matcher = SECTION_FILE_MATCHERS[sectionId];
77
- if (!matcher) return true; // unknown section → don't accidentally skip it
78
- return changedFiles.some(matcher);
79
- }
80
49
 
81
50
  export function runSync(projectDir, config, flags) {
82
51
  if (flags.write) assertDefaultDocWrites(config);
@@ -144,18 +113,20 @@ export function runSync(projectDir, config, flags) {
144
113
  if (apply && docChanged) writeFileSync(full, content, 'utf-8');
145
114
  }
146
115
 
116
+ const result = {
117
+ project: config.projectName,
118
+ since: flags.since || null,
119
+ changedFiles: changed,
120
+ applied: apply,
121
+ updates,
122
+ reviews,
123
+ skipped,
124
+ timestamp: new Date().toISOString(),
125
+ };
126
+ if (flags.silent) return result;
147
127
  if (isJson) {
148
- console.log(JSON.stringify({
149
- project: config.projectName,
150
- since: flags.since || null,
151
- changedFiles: changed,
152
- applied: apply,
153
- updates,
154
- reviews,
155
- skipped,
156
- timestamp: new Date().toISOString(),
157
- }, null, 2));
158
- return;
128
+ console.log(JSON.stringify(result, null, 2));
129
+ return result;
159
130
  }
160
131
 
161
132
  console.log(`${c.bold}🔄 DocGuard Sync — ${config.projectName}${c.reset}`);
@@ -184,4 +155,5 @@ export function runSync(projectDir, config, flags) {
184
155
  for (const s of skipped) console.log(` ${c.dim}- ${s.doc}: ${s.reason}${c.reset}`);
185
156
  console.log('');
186
157
  }
158
+ return result;
187
159
  }
package/cli/docguard.mjs CHANGED
@@ -13,6 +13,8 @@ import { assertDefaultDocWrites } from './shared-doc-roles.mjs';
13
13
  * npx docguard-cli --help — Show help
14
14
  *
15
15
  * @see https://github.com/raccioly/docguard
16
+ * @implements docguard.document-lifecycle#FR-012
17
+ * @implements docguard.document-lifecycle#FR-020
16
18
  */
17
19
 
18
20
  import { readFileSync, existsSync } from 'node:fs';
@@ -53,6 +55,7 @@ import { runAgent } from './commands/agent.mjs';
53
55
  import { runMcp } from './commands/mcp.mjs';
54
56
  import { runArchive } from './commands/retire.mjs';
55
57
  import { runSpecs } from './commands/specs.mjs';
58
+ import { runReconcile } from './commands/reconcile.mjs';
56
59
  import { ensureSkills } from './ensure-skills.mjs';
57
60
 
58
61
  // ── Shared constants (imported to break circular dependencies) ──────────
@@ -101,6 +104,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
101
104
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
102
105
  ${c.green}retire${c.reset} Remove reviewed docs from active AI context (${c.cyan}--plan${c.reset}; explicit ${c.cyan}--write --path${c.reset})
103
106
  ${c.green}specs${c.reset} Track spec lifecycle and evidence (${c.cyan}--check|--write${c.reset}; ${c.cyan}preflight --path <spec>${c.reset})
107
+ ${c.green}reconcile${c.reset} Classify code/spec changes since a Git ref before changing intent
104
108
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
105
109
  ${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
106
110
  ${c.green}watch${c.reset} Live mode: re-run guard on file changes
@@ -297,10 +301,10 @@ const COMMAND_HELP = {
297
301
  examples: ['docguard memory', 'docguard memory --diff'],
298
302
  },
299
303
  feedback: {
300
- summary: 'Review detection feedback locally. Select any finding with --code or --all, check duplicates, and prepare a metadata-only issue URL. Nothing is submitted automatically.',
301
- usage: 'docguard feedback [--code <CODE> | --all] [--preview] [--format json]',
302
- flags: [['--code <CODE>', 'Select a finding regardless of confidence'], ['--all', 'Select every active finding'], ['--preview', 'Skip local feedback-record writes'], ['--format json', 'Machine-readable selected findings and issue/search URLs']],
303
- examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --all --format json'],
304
+ summary: 'Review detection feedback locally, or validate and reduce a synthetic fixture manifest. Duplicate searches cover open and closed work; nothing is submitted automatically.',
305
+ usage: 'docguard feedback [--code <CODE> | --all] [--classification <class>] [--fixture-manifest <path> [--reduce] [--contribution <path>]] [--preview] [--format json]',
306
+ flags: [['--code <CODE>', 'Select a finding regardless of confidence'], ['--all', 'Select every active finding'], ['--classification <class>', 'false_positive, false_negative, unsupported_syntax, ambiguous, or policy_disagreement'], ['--fixture-manifest <path>', 'Validate a reviewed synthetic fixture and opposite control'], ['--reduce', 'Deterministically reduce a reproducing fixture'], ['--contribution <path>', 'Write a test-only contribution when required evidence is present'], ['--preview', 'Skip local writes and return contribution text inline'], ['--format json', 'Machine-readable evidence and issue/search URLs']],
307
+ examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --fixture-manifest feedback.json --reduce --preview --format json'],
304
308
  },
305
309
  verify: {
306
310
  summary: 'Extract the semantic claims in your canonical docs — documented numbers, limits, and enums (retention days, rate limits, GSI/role counts, status enums) — as a verification task list the agent checks against the code. This is the highest-value bug class (a doc value that drifted from code) and the one regex/AST cannot judge. DocGuard finds the claims; the LLM confirms them.',
@@ -331,15 +335,32 @@ const COMMAND_HELP = {
331
335
  },
332
336
  specs: {
333
337
  summary: 'Maintain the deterministic spec lifecycle and evidence registry.',
334
- usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] [--format json]',
338
+ usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] | docguard specs complete --id <spec-id> [--since <ref>] [--write --reason <text>]',
335
339
  flags: [
336
340
  ['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent'],
337
341
  ['--write', 'Refresh observed evidence while preserving reviewed lifecycle fields'],
338
342
  ['preflight', 'Brief prior specs, or gate a generated draft with --path'],
343
+ ['complete', 'Plan or apply the implemented→verified evidence transaction'],
344
+ ['--id <spec-id>', 'Immutable spec identity to complete'],
345
+ ['--since <ref>', 'First reconciliation baseline when none is recorded'],
346
+ ['--reason <text>', 'Reviewed implementation outcome required for completion writes'],
347
+ ['--deviation <text>', 'Accepted deviation to record; repeatable'],
348
+ ['--successor <id>', 'Approved current successor spec to record'],
339
349
  ['--path <spec>', 'Generated spec to compare against current lifecycle state'],
340
350
  ['--format json', 'Machine-readable registry or preflight result'],
341
351
  ],
342
- examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight', 'docguard specs preflight --path specs/007-feature/spec.md'],
352
+ examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight --path specs/007-feature/spec.md', 'docguard specs complete --id acme.feature --since main --write --reason "Reviewed implementation"'],
353
+ },
354
+ reconcile: {
355
+ summary: 'Classify changed implementation facts, approved intent, decisions, and unsupported evidence.',
356
+ usage: 'docguard reconcile --since <ref> [--check|--write] [--format json]',
357
+ flags: [
358
+ ['--since <ref>', 'Required Git baseline for the review graph'],
359
+ ['--check', 'Exit 2 while reviewed reconciliation remains'],
360
+ ['--write', 'Apply only deterministic generated-section refreshes'],
361
+ ['--format json', 'Machine-readable nodes, edges, classifications, and write plan'],
362
+ ],
363
+ examples: ['docguard reconcile --since main --format json', 'docguard reconcile --since HEAD~1 --write'],
343
364
  },
344
365
  };
345
366
 
@@ -589,12 +610,33 @@ async function main() {
589
610
  } else if (args[i] === '--retention-ref' && args[i + 1]) {
590
611
  flags.retentionRef = args[i + 1];
591
612
  i++;
613
+ } else if (args[i] === '--id' && args[i + 1]) {
614
+ flags.id = args[i + 1];
615
+ i++;
616
+ } else if (args[i] === '--deviation' && args[i + 1]) {
617
+ flags.deviations = flags.deviations || [];
618
+ flags.deviations.push(args[i + 1]);
619
+ i++;
620
+ } else if (args[i] === '--successor' && args[i + 1]) {
621
+ flags.successor = args[i + 1];
622
+ i++;
592
623
  } else if (args[i] === '--code') {
593
624
  flags.code = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
594
625
  } else if (args[i] === '--all') {
595
626
  flags.all = true;
596
627
  } else if (args[i] === '--preview') {
597
628
  flags.preview = true;
629
+ } else if (args[i] === '--classification' && args[i + 1]) {
630
+ flags.classification = args[i + 1].replaceAll('-', '_');
631
+ i++;
632
+ } else if (args[i] === '--fixture-manifest' && args[i + 1]) {
633
+ flags.fixtureManifest = args[i + 1];
634
+ i++;
635
+ } else if (args[i] === '--reduce') {
636
+ flags.reduce = true;
637
+ } else if (args[i] === '--contribution' && args[i + 1]) {
638
+ flags.contribution = args[i + 1];
639
+ i++;
598
640
  } else if (args[i] === '--signals') {
599
641
  flags.signals = true;
600
642
  } else if (args[i] === '--debate') {
@@ -890,6 +932,9 @@ async function main() {
890
932
  case 'specs':
891
933
  runSpecs(projectDir, config, flags);
892
934
  break;
935
+ case 'reconcile':
936
+ runReconcile(projectDir, config, flags);
937
+ break;
893
938
  case 'demo':
894
939
  // v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
895
940
  // fixture (templates/demo-fixture/) and prints curated drift findings
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Strict, privacy-bounded feedback fixtures and deterministic reduction.
3
+ * @implements docguard.precision-evidence-loop#FR-011
4
+ * @implements docguard.precision-evidence-loop#FR-013
5
+ * @implements docguard.precision-evidence-loop#FR-014
6
+ * @implements docguard.precision-evidence-loop#FR-015
7
+ * @implements docguard.precision-evidence-loop#FR-016
8
+ */
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import { extname, isAbsolute } from 'node:path';
12
+
13
+ export const FEEDBACK_SCHEMA_URL = 'https://raccioly.github.io/docguard/schemas/docguard-feedback-fixture.schema.json';
14
+ const CLASSIFICATIONS = new Set(['false_positive', 'false_negative', 'unsupported_syntax', 'ambiguous', 'policy_disagreement']);
15
+ const PARSER_TIERS = new Set(['js-ast', 'py-ast', 'regex-fallback', 'fallback-language', 'mixed', 'not-applicable']);
16
+ const PREDICATES = new Set(['finding_present', 'finding_absent', 'validator_unsupported']);
17
+ const ROOT_KEYS = new Set(['$schema', 'schemaVersion', 'classification', 'detector', 'parserTier', 'config', 'expectedIdentity', 'interestingness', 'fixture', 'oppositeControl', 'provenance', 'contribution']);
18
+
19
+ function object(value, label) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
21
+ return value;
22
+ }
23
+
24
+ function exactKeys(value, keys, label) {
25
+ const unknown = Object.keys(value).filter(key => !keys.has(key));
26
+ if (unknown.length) throw new Error(`${label} has unknown field(s): ${unknown.join(', ')}.`);
27
+ }
28
+
29
+ function safePath(value, label) {
30
+ if (typeof value !== 'string' || !value || value.includes('\\') || isAbsolute(value)) throw new Error(`${label} must be a POSIX relative path.`);
31
+ const parts = value.split('/');
32
+ if (parts.some(part => !part || part === '.' || part === '..' || ['.git', '.local'].includes(part.toLowerCase()))) {
33
+ throw new Error(`${label} enters a protected or escaping path.`);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ function validateConfig(value, label = 'config', depth = 0) {
39
+ if (depth > 6) throw new Error(`${label} is too deeply nested.`);
40
+ if (value === null || typeof value === 'boolean' || Number.isFinite(value)) return;
41
+ if (typeof value === 'string') {
42
+ if (value.length > 10_000 || value.includes('\0') || isAbsolute(value) || /(?:^|[/\\])\.\.(?:[/\\]|$)/.test(value) || value.includes('.local') || value.includes('.git')) {
43
+ throw new Error(`${label} contains an unsafe value.`);
44
+ }
45
+ return;
46
+ }
47
+ if (Array.isArray(value)) {
48
+ if (value.length > 100) throw new Error(`${label} is too large.`);
49
+ value.forEach((item, index) => validateConfig(item, `${label}[${index}]`, depth + 1));
50
+ return;
51
+ }
52
+ object(value, label);
53
+ const keys = Object.keys(value);
54
+ if (keys.length > 100 || keys.some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) throw new Error(`${label} has unsafe keys.`);
55
+ for (const key of keys) validateConfig(value[key], `${label}.${key}`, depth + 1);
56
+ }
57
+
58
+ function parseFiles(value, label) {
59
+ const holder = object(value, label);
60
+ exactKeys(holder, new Set(['files']), label);
61
+ if (!Array.isArray(holder.files) || holder.files.length < 1 || holder.files.length > 16) throw new Error(`${label}.files requires 1-16 files.`);
62
+ let bytes = 0;
63
+ const seen = new Set();
64
+ const files = holder.files.map((entry, index) => {
65
+ object(entry, `${label}.files[${index}]`);
66
+ exactKeys(entry, new Set(['path', 'content']), `${label}.files[${index}]`);
67
+ const path = safePath(entry.path, `${label}.files[${index}].path`);
68
+ if (seen.has(path)) throw new Error(`${label}.files contains duplicate path ${path}.`);
69
+ seen.add(path);
70
+ if (typeof entry.content !== 'string' || entry.content.includes('\0')) throw new Error(`${label}.files[${index}].content must be text.`);
71
+ bytes += Buffer.byteLength(entry.content);
72
+ return { path, content: entry.content };
73
+ });
74
+ if (bytes > 131_072) throw new Error(`${label} exceeds 128 KiB.`);
75
+ return { files };
76
+ }
77
+
78
+ function parseContribution(value) {
79
+ if (value === undefined) return null;
80
+ const contribution = object(value, 'contribution');
81
+ exactKeys(contribution, new Set(['testOnly', 'scopeDocumented', 'benchmarkDelta']), 'contribution');
82
+ const delta = object(contribution.benchmarkDelta, 'contribution.benchmarkDelta');
83
+ exactKeys(delta, new Set(['falsePositives', 'falseNegatives', 'unsupportedCases', 'abstainedSupportedCases']), 'contribution.benchmarkDelta');
84
+ for (const [key, count] of Object.entries(delta)) {
85
+ if (!Number.isInteger(count) || count < 0) throw new Error(`contribution.benchmarkDelta.${key} must be a non-negative integer.`);
86
+ }
87
+ return { testOnly: contribution.testOnly === true, scopeDocumented: contribution.scopeDocumented === true, benchmarkDelta: delta };
88
+ }
89
+
90
+ export function parseFeedbackFixture(value) {
91
+ const root = object(value, 'feedback fixture');
92
+ exactKeys(root, ROOT_KEYS, 'feedback fixture');
93
+ if (root.$schema !== FEEDBACK_SCHEMA_URL || root.schemaVersion !== 1) throw new Error('Feedback fixture uses an unsupported schema contract.');
94
+ if (!CLASSIFICATIONS.has(root.classification)) throw new Error('feedback fixture classification is invalid.');
95
+ if (!PARSER_TIERS.has(root.parserTier)) throw new Error('feedback fixture parserTier is invalid.');
96
+ const detector = object(root.detector, 'detector');
97
+ exactKeys(detector, new Set(['code', 'validator']), 'detector');
98
+ if (!/^[A-Z]{3}\d{3}$/.test(detector.code) || !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(detector.validator)) throw new Error('detector code or validator is invalid.');
99
+ const match = String(root.expectedIdentity || '').match(/^([A-Z]{3}\d{3})@(.+)$/);
100
+ if (!match || match[1] !== detector.code) throw new Error('expectedIdentity must use the detector code.');
101
+ const expectedPath = safePath(match[2], 'expectedIdentity path');
102
+ const interestingness = object(root.interestingness, 'interestingness');
103
+ exactKeys(interestingness, new Set(['predicate']), 'interestingness');
104
+ if (!PREDICATES.has(interestingness.predicate)) throw new Error('interestingness.predicate is invalid.');
105
+ const fixture = parseFiles(root.fixture, 'fixture');
106
+ const oppositeControl = parseFiles(root.oppositeControl, 'oppositeControl');
107
+ const fixturePaths = fixture.files.map(file => file.path).sort();
108
+ const controlPaths = oppositeControl.files.map(file => file.path).sort();
109
+ if (JSON.stringify(fixturePaths) !== JSON.stringify(controlPaths) || !fixturePaths.includes(expectedPath)) {
110
+ throw new Error('fixture and oppositeControl must share paths and include the expectedIdentity path.');
111
+ }
112
+ const provenance = object(root.provenance, 'provenance');
113
+ exactKeys(provenance, new Set(['synthetic', 'redactionAttested']), 'provenance');
114
+ if (provenance.synthetic !== true || provenance.redactionAttested !== true) throw new Error('Synthetic provenance and reviewed redaction must both be attested.');
115
+ validateConfig(root.config);
116
+ return {
117
+ $schema: FEEDBACK_SCHEMA_URL, schemaVersion: 1, classification: root.classification,
118
+ detector: { code: detector.code, validator: detector.validator }, parserTier: root.parserTier,
119
+ config: root.config, expectedIdentity: `${detector.code}@${expectedPath}`,
120
+ interestingness: { predicate: interestingness.predicate }, fixture, oppositeControl,
121
+ provenance: { synthetic: true, redactionAttested: true }, contribution: parseContribution(root.contribution),
122
+ };
123
+ }
124
+
125
+ function shape(files) {
126
+ return files.map(file => {
127
+ const normalized = file.content
128
+ .replace(/(['"`])(?:\\.|(?!\1).)*\1/g, ' STRING ')
129
+ .replace(/\b\d+(?:\.\d+)?\b/g, ' NUMBER ')
130
+ .replace(/\b[A-Za-z_$][\w$]*\b/g, ' ID ')
131
+ .replace(/\s+/g, ' ').trim();
132
+ return `${extname(file.path).toLowerCase()}:${normalized}`;
133
+ }).sort().join('|');
134
+ }
135
+
136
+ export function feedbackDuplicateIdentity(manifest) {
137
+ const input = [manifest.detector.code, manifest.classification, manifest.parserTier, shape(manifest.fixture.files)].join('|');
138
+ return `dgf-${createHash('sha256').update(input).digest('hex').slice(0, 16)}`;
139
+ }
140
+
141
+ export function feedbackFindingIdentity(finding) {
142
+ const raw = typeof finding.location === 'string' ? finding.location : finding.location?.file || '<project>';
143
+ return `${finding.code}@${raw.replace(/:\d+(?::\d+)?$/, '').replaceAll('\\', '/')}`;
144
+ }
145
+
146
+ export function feedbackSearchUrls(manifest, issuesBase = 'https://github.com/raccioly/docguard/issues') {
147
+ const repository = issuesBase.replace(/^https:\/\/github\.com\//, '').replace(/\/issues.*$/, '');
148
+ const identity = feedbackDuplicateIdentity(manifest);
149
+ const build = state => `https://github.com/search?q=${encodeURIComponent(`repo:${repository} "${identity}" state:${state}`)}&type=issues`;
150
+ return { identity, all: build('open').replace(encodeURIComponent(' state:open'), ''), open: build('open'), closed: build('closed') };
151
+ }
152
+
153
+ export function reduceFixtureDeterministically(manifest, interesting, maxAttempts = 100) {
154
+ let current = structuredClone(manifest);
155
+ let attempts = 0;
156
+ if (!interesting(current)) return { status: 'NOT_REPRODUCED', attempts, manifest: current };
157
+ for (let fileIndex = 0; fileIndex < current.fixture.files.length && attempts < maxAttempts; fileIndex++) {
158
+ let changed = true;
159
+ while (changed && attempts < maxAttempts) {
160
+ changed = false;
161
+ const lines = current.fixture.files[fileIndex].content.split('\n');
162
+ if (lines.length <= 1) break;
163
+ for (let line = 0; line < lines.length && attempts < maxAttempts; line++) {
164
+ const candidate = structuredClone(current);
165
+ candidate.fixture.files[fileIndex].content = lines.filter((_, index) => index !== line).join('\n');
166
+ attempts++;
167
+ if (interesting(candidate)) { current = candidate; changed = true; break; }
168
+ }
169
+ }
170
+ }
171
+ return { status: attempts >= maxAttempts ? 'REDUCED_LIMIT' : 'REDUCED', attempts, manifest: current };
172
+ }
173
+
174
+ export function assertContributionReady(manifest) {
175
+ if (!['false_positive', 'false_negative', 'unsupported_syntax'].includes(manifest.classification)) {
176
+ throw new Error('Ambiguous and policy-disagreement fixtures require adjudication before a test contribution.');
177
+ }
178
+ if (!manifest.contribution?.testOnly || !manifest.contribution.scopeDocumented) {
179
+ throw new Error('Contribution requires testOnly and scopeDocumented attestations plus benchmarkDelta.');
180
+ }
181
+ return true;
182
+ }
183
+
184
+ export function buildTestOnlyContribution(manifest) {
185
+ assertContributionReady(manifest);
186
+ const encoded = JSON.stringify(manifest);
187
+ return `import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { runGuardInternal } from '../cli/commands/guard.mjs';\nimport { feedbackFindingIdentity } from '../cli/feedback-fixture.mjs';\n\nconst manifest = ${encoded};\nfunction run(files) {\n const root = mkdtempSync(join(tmpdir(), 'docguard-contribution-'));\n try {\n for (const file of files) { const target = join(root, file.path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, file.content); }\n return runGuardInternal(root, manifest.config);\n } finally { rmSync(root, { recursive: true, force: true }); }\n}\n\ntest('${manifest.detector.code} ${manifest.classification} synthetic reproduction (${feedbackDuplicateIdentity(manifest)})', () => {\n const fixture = run(manifest.fixture.files);\n const control = run(manifest.oppositeControl.files);\n const fixtureHas = fixture.findings.some(finding => feedbackFindingIdentity(finding) === manifest.expectedIdentity);\n const controlHas = control.findings.some(finding => feedbackFindingIdentity(finding) === manifest.expectedIdentity);\n const fixtureApplicability = fixture.validators.find(item => item.key === manifest.detector.validator)?.applicability?.status;\n const controlApplicability = control.validators.find(item => item.key === manifest.detector.validator)?.applicability?.status;\n ${manifest.classification === 'false_positive' ? 'assert.equal(fixtureHas, false); assert.equal(controlHas, true);' : manifest.classification === 'false_negative' ? 'assert.equal(fixtureHas, true); assert.equal(controlHas, false);' : `assert.notEqual(fixtureApplicability, 'unsupported'); assert.equal(controlApplicability, 'checked'); assert.equal(controlHas, false);`}\n});\n`;
188
+ }
@@ -7,53 +7,28 @@ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { dirname, resolve } from 'node:path';
9
9
  import { shouldIgnore } from '../shared-ignore.mjs';
10
+ import { readSpecRegistry } from './spec-registry.mjs';
11
+ import { readRetirementManifest } from './retirement-manifest.mjs';
10
12
 
11
13
  const RETIRED_STATUSES = new Set(['archived', 'deprecated', 'obsolete', 'superseded']);
12
14
  const COMPLETION_STATUSES = new Set(['complete', 'completed']);
13
15
  const EXCLUDED_PREFIXES = ['.git', '.local', '.docguard'];
14
- const MANIFEST_PATH = '.docguard-archive.json';
16
+ const SUPPRESSIBLE_DELIVERY = new Set(['verified', 'released']);
15
17
 
16
- export function readRetirementManifest(projectDir) {
17
- const path = resolve(projectDir, MANIFEST_PATH);
18
- if (!existsSync(path)) return { ok: true, paths: new Set(), entries: [], retention: null, error: null };
19
- try {
20
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
21
- if (parsed?.schemaVersion !== 1 || parsed?.strategy !== 'git-history' || !Array.isArray(parsed.entries)) {
22
- throw new Error('unsupported schema');
23
- }
24
- const globalRecovery = parsed.retention?.recoverability === 'verified'
25
- && typeof parsed.retention?.ref === 'string'
26
- && /^(?:sha1|sha256)$/.test(parsed.retention?.objectFormat || '');
27
- for (const entry of parsed.entries) {
28
- const path = entry?.path;
29
- const entryRecovery = entry?.recoverability === 'verified'
30
- && typeof entry?.retentionRef === 'string'
31
- && /^(?:sha1|sha256)$/.test(entry?.objectFormat || '');
32
- if (typeof path !== 'string' || !path || path.startsWith('/')
33
- || path.replaceAll('\\', '/').split('/').includes('..')
34
- || typeof entry?.archivedFrom !== 'string'
35
- || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.archivedFrom)
36
- || typeof entry?.blob !== 'string'
37
- || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.blob)
38
- || typeof entry?.reason !== 'string' || !entry.reason.trim()
39
- || (entry?.requirementIds !== undefined && (!Array.isArray(entry.requirementIds)
40
- || entry.requirementIds.some(id => typeof id !== 'string' || !id || id.length > 128 || /[\s#\0]/.test(id))))
41
- || (entry?.specId !== undefined && (typeof entry.specId !== 'string'
42
- || !/^[a-z0-9][a-z0-9._-]{2,127}$/.test(entry.specId)))
43
- || (!globalRecovery && !entryRecovery)) {
44
- throw new Error(`invalid recovery entry for ${path || '<unknown path>'}`);
45
- }
46
- }
47
- return {
48
- ok: true,
49
- paths: new Set(parsed.entries.map(entry => entry.path)),
50
- entries: parsed.entries,
51
- retention: parsed.retention || null,
52
- error: null,
53
- };
54
- } catch (error) {
55
- return { ok: false, paths: new Set(), entries: [], retention: null, error: `${MANIFEST_PATH}: ${error.message}` };
56
- }
18
+ /**
19
+ * Read only the narrow registry state needed to avoid asking users to retire a
20
+ * verified living specification. Any malformed or unknown shape fails closed:
21
+ * the ordinary lifecycle review signal remains visible.
22
+ * @implements docguard.document-lifecycle#FR-002
23
+ */
24
+ function readVerifiedLivingSpecPaths(projectDir) {
25
+ const registry = readSpecRegistry(projectDir);
26
+ if (!registry.exists || registry.error) return new Set();
27
+ return new Set(registry.value.specs
28
+ .filter(spec => spec.reviewed.lifecycle.context === 'current'
29
+ && spec.reviewed.lifecycle.persistenceModel === 'living'
30
+ && SUPPRESSIBLE_DELIVERY.has(spec.reviewed.lifecycle.delivery))
31
+ .map(spec => spec.path));
57
32
  }
58
33
 
59
34
  function trackedFiles(projectDir) {
@@ -110,6 +85,7 @@ export function scanDocumentLifecycle(projectDir, config = {}) {
110
85
  };
111
86
  }
112
87
  const tracked = new Set(inventory.files);
88
+ const verifiedLivingSpecs = readVerifiedLivingSpecPaths(projectDir);
113
89
  const markdown = inventory.files.filter(path => /\.md$/i.test(path));
114
90
  const candidates = [];
115
91
  const unreadable = [];
@@ -159,7 +135,7 @@ export function scanDocumentLifecycle(projectDir, config = {}) {
159
135
  }
160
136
  if (path.startsWith('specs/') && /(?:^|\/)spec\.md$/i.test(path)) {
161
137
  const tasks = completedTaskSignal(projectDir, path, content, tracked);
162
- if (tasks) {
138
+ if (tasks && !verifiedLivingSpecs.has(path)) {
163
139
  candidates.push({
164
140
  code: 'DLC002',
165
141
  path: dirname(path),
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Deterministic active-intent projection for AI tools and lifecycle hooks.
3
+ * @implements docguard.document-lifecycle#FR-014
4
+ * @implements docguard.document-lifecycle#FR-017
5
+ */
6
+
7
+ import { createHash } from 'node:crypto';
8
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
9
+ import { relative, resolve, sep } from 'node:path';
10
+
11
+ const digest = content => `sha256:${createHash('sha256').update(content).digest('hex')}`;
12
+ const posix = path => path.split(sep).join('/');
13
+
14
+ function safeDigest(projectDir, path) {
15
+ if (!path || path.split(/[\\/]/).includes('.local')) return null;
16
+ try {
17
+ const root = realpathSync(projectDir);
18
+ const absolute = resolve(projectDir, path);
19
+ const real = realpathSync(absolute);
20
+ if (!existsSync(absolute) || !lstatSync(absolute).isFile() || lstatSync(absolute).isSymbolicLink()
21
+ || (real !== root && !real.startsWith(`${root}${sep}`))) return null;
22
+ return digest(readFileSync(absolute));
23
+ } catch { return null; }
24
+ }
25
+
26
+ export function buildLifecycleContext(projectDir, registry, revision) {
27
+ const specs = registry.specs
28
+ .filter(spec => spec.reviewed.lifecycle.context === 'current'
29
+ && spec.reviewed.lifecycle.approval === 'approved')
30
+ .map(spec => ({
31
+ specId: spec.specId,
32
+ path: posix(relative(projectDir, resolve(projectDir, spec.path))),
33
+ digest: spec.observed.artifacts.find(artifact => artifact.path === spec.path)?.digest || safeDigest(projectDir, spec.path),
34
+ delivery: spec.reviewed.lifecycle.delivery,
35
+ requirements: [...spec.intent.requirements].sort(),
36
+ canonicalDocs: spec.reviewed.scope.canonicalDocs.map(path => ({ path, digest: safeDigest(projectDir, path) })),
37
+ }))
38
+ .sort((a, b) => a.specId.localeCompare(b.specId));
39
+ return {
40
+ schemaVersion: 1,
41
+ generatedFrom: revision,
42
+ assurance: 'pointers-and-content-hashes-only',
43
+ factualAccuracy: null,
44
+ specs,
45
+ };
46
+ }
47
+
48
+ export function serializeLifecycleContext(projectDir, registry, revision) {
49
+ return `${JSON.stringify(buildLifecycleContext(projectDir, registry, revision), null, 2)}\n`;
50
+ }