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.
- package/README.md +7 -5
- package/cli/commands/feedback.mjs +147 -6
- package/cli/commands/reconcile.mjs +42 -0
- package/cli/commands/retire.mjs +11 -15
- package/cli/commands/specs.mjs +198 -3
- package/cli/commands/sync.mjs +16 -44
- package/cli/docguard.mjs +51 -6
- package/cli/feedback-fixture.mjs +188 -0
- package/cli/scanners/document-lifecycle.mjs +19 -43
- package/cli/scanners/lifecycle-context.mjs +50 -0
- package/cli/scanners/reconciliation.mjs +141 -0
- package/cli/scanners/requirement-evidence.mjs +41 -8
- package/cli/scanners/retirement-manifest.mjs +52 -0
- package/cli/scanners/spec-registry.mjs +36 -9
- package/cli/shared-sync-scope.mjs +35 -0
- package/cli/validators/security.mjs +5 -4
- package/cli/validators/traceability.mjs +2 -1
- package/cli/writers/file-transaction.mjs +85 -0
- package/cli/writers/spec-outcomes.mjs +27 -0
- package/extensions/spec-kit-docguard/README.md +6 -2
- package/extensions/spec-kit-docguard/commands/complete.md +38 -0
- package/extensions/spec-kit-docguard/extension.yml +20 -4
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/extensions.yml +16 -0
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +2 -1
- package/schemas/docguard-benchmark.schema.json +84 -0
- package/schemas/docguard-feedback-fixture.schema.json +54 -0
- package/schemas/docguard-specs.schema.json +19 -2
- package/templates/ci/github-actions.yml +1 -1
- package/templates/feedback-fixture.json +18 -0
package/README.md
CHANGED
|
@@ -69,13 +69,13 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
|
|
|
69
69
|
|
|
70
70
|
```mermaid
|
|
71
71
|
graph TD
|
|
72
|
-
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (
|
|
72
|
+
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (23)"]
|
|
73
73
|
Commands --> guard["guard"]
|
|
74
74
|
Commands --> generate["generate"]
|
|
75
75
|
Commands --> score["score"]
|
|
76
76
|
Commands --> diagnose["diagnose"]
|
|
77
77
|
Commands --> setup["setup wizard"]
|
|
78
|
-
Commands --> other["diff · init · fix · trace · impact · sync · retire · specs<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
|
|
78
|
+
Commands --> other["diff · init · fix · trace · impact · sync · reconcile · retire · specs<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
|
|
79
79
|
|
|
80
80
|
guard --> Validators["Validators (29)"]
|
|
81
81
|
generate --> Scanners["Scanners (4)<br/>routes · schemas · doc-tools · speckit"]
|
|
@@ -268,7 +268,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
|
|
|
268
268
|
|
|
269
269
|
## Usage
|
|
270
270
|
|
|
271
|
-
DocGuard ships **
|
|
271
|
+
DocGuard ships **23 commands** (the "Daily 5" + 18 situational tools, including lifecycle reconciliation, retirement and spec tracking, the zero-install `demo`, the `mcp` server, and the `ci` pipeline gate). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Legacy command forms remain compatible until v1.0 and print their replacements.
|
|
272
272
|
|
|
273
273
|
**The Daily 5** — what you'll reach for 95% of the time:
|
|
274
274
|
|
|
@@ -294,8 +294,10 @@ DocGuard ships **22 commands** (the "Daily 5" + 17 situational tools, including
|
|
|
294
294
|
| `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
|
|
295
295
|
| `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
|
|
296
296
|
| `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands — plus clustered rule pairs as agent judgment tasks |
|
|
297
|
-
| `feedback` |
|
|
297
|
+
| `feedback` | Review any finding or a synthetic false-positive/false-negative/unsupported fixture; verify its opposite control, reduce it deterministically, search open and closed duplicates, and optionally emit a test-only contribution. Nothing is submitted automatically. |
|
|
298
298
|
| `retire` | Find completed or superseded planning material (`--plan`/`--check`; `--fail-on-warning` gates advisory candidates) and explicitly remove clean tracked documentation from active AI context. `.docguard-archive.json` records recovery metadata and retired requirement identities, and `--retention-ref` proves the source revision remains reachable. This is separate from the Spec Kit Archive extension, which consolidates feature documents. |
|
|
299
|
+
| `reconcile` | Build a read-only code↔spec review graph since a Git ref. Classifies mechanical facts, approved intent, decisions, unrelated changes, and unsupported evidence; `--write` applies only mechanical generated-section refreshes. |
|
|
300
|
+
| `specs` | Maintain the versioned spec registry, preflight new specs, and apply evidence-gated completion transactions with bounded outcomes and active-context regeneration. Verified living specs can record later reviewed maintenance without reopening or duplicating the specification. |
|
|
299
301
|
| `specs --check` / `specs --write` | Validate or refresh `.docguard-specs.json`, the byte-stable index of immutable spec IDs, reviewed lifecycle/lineage/scope, artifact digests, task state, explicitly scoped test evidence, and archive tombstones. Refreshes preserve the reviewed block. |
|
|
300
302
|
| `specs preflight [--path <spec>]` | Before specification, print current spec lifecycle and evidence. Before planning, check the generated draft for structural blockers and report semantic overlap as review-only evidence. |
|
|
301
303
|
| `mcp` | MCP server — exposes guard/score/explain/verify/report/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
|
|
@@ -333,7 +335,7 @@ Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with a
|
|
|
333
335
|
| `--dir <path>` | Project directory (default: `.`) | All |
|
|
334
336
|
| `--verbose` | Show detailed output | All |
|
|
335
337
|
| `--quiet` / `-q` | Suppress banner — for hooks, CI loops, scripts | All |
|
|
336
|
-
| `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain, retire, specs |
|
|
338
|
+
| `--format json` | Machine-readable output (clean JSON, no ANSI bleed) | guard, score, diff, trace, diagnose, memory, impact, explain, reconcile, retire, specs |
|
|
337
339
|
| `--format sarif` | SARIF 2.1.0 output — findings as rules/results for GitHub Code Scanning and SARIF dashboards | guard |
|
|
338
340
|
| `--format junit` | JUnit XML output — one testcase per validator, for GitLab CI (`artifacts:reports:junit`), Jenkins, Azure DevOps, CircleCI | guard |
|
|
339
341
|
| `--update-baseline` | Adopt DocGuard on a legacy repo without a red day one: freeze today's findings into a committed `.docguard.baseline.json`; guard/ci then gate only NEW drift. Suppression is always visible ("N pre-existing finding(s) suppressed"), and `--no-baseline` shows the full picture | guard |
|
|
@@ -12,15 +12,23 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Nothing is transmitted automatically. This command never scaffolds skills
|
|
14
14
|
* or edits source files. Zero npm dependencies — pure Node.js built-ins.
|
|
15
|
+
* @implements docguard.precision-evidence-loop#FR-012
|
|
16
|
+
* @implements docguard.precision-evidence-loop#FR-015
|
|
17
|
+
* @implements docguard.precision-evidence-loop#FR-016
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
18
|
-
import { resolve, dirname } from 'node:path';
|
|
20
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { resolve, dirname, join, relative, sep } from 'node:path';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
19
23
|
import { fileURLToPath } from 'node:url';
|
|
20
24
|
import { c } from '../shared.mjs';
|
|
21
25
|
import { CODES } from '../findings.mjs';
|
|
22
26
|
import { safeWrite } from '../writers/generate-io.mjs';
|
|
23
27
|
import { runGuardInternal } from './guard.mjs';
|
|
28
|
+
import {
|
|
29
|
+
buildTestOnlyContribution, feedbackFindingIdentity, feedbackSearchUrls, parseFeedbackFixture,
|
|
30
|
+
reduceFixtureDeterministically,
|
|
31
|
+
} from '../feedback-fixture.mjs';
|
|
24
32
|
|
|
25
33
|
const _PKG = JSON.parse(
|
|
26
34
|
readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8')
|
|
@@ -39,16 +47,20 @@ function shortId(str) {
|
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/** Shared output deliberately excludes every source-derived string. */
|
|
42
|
-
export function buildIssueUrl(finding) {
|
|
50
|
+
export function buildIssueUrl(finding, context = null) {
|
|
43
51
|
const code = Object.hasOwn(CODES, finding.code || '') ? finding.code : 'FINDING';
|
|
44
52
|
const validator = CODES[code]?.validator || 'unknown';
|
|
45
|
-
const
|
|
53
|
+
const classification = context?.classification || 'false_positive';
|
|
54
|
+
const title = `[feedback] ${code} (${validator}): ${classification.replaceAll('_', ' ')}`;
|
|
46
55
|
const confidence = ['high', 'medium', 'low'].includes(finding.confidence) ? finding.confidence : 'unknown';
|
|
47
56
|
const body = [
|
|
48
57
|
`DocGuard v${CLI_VERSION}`,
|
|
49
58
|
`- Code: ${code}`,
|
|
50
59
|
`- Validator: ${validator}`,
|
|
51
60
|
`- Confidence: ${confidence}`,
|
|
61
|
+
`- Classification: ${classification}`,
|
|
62
|
+
...(context?.parserTier ? [`- Parser tier: ${context.parserTier}`] : []),
|
|
63
|
+
...(context?.duplicateIdentity ? [`- Duplicate identity: ${context.duplicateIdentity}`] : []),
|
|
52
64
|
'',
|
|
53
65
|
'Expected behavior:',
|
|
54
66
|
'Actual behavior:',
|
|
@@ -60,12 +72,139 @@ export function buildIssueUrl(finding) {
|
|
|
60
72
|
'Generated by docguard feedback. No project paths, messages, source code, or secret values are included.',
|
|
61
73
|
].join('\n');
|
|
62
74
|
const url = `${ISSUES_BASE}/new?labels=docguard-feedback&title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
|
63
|
-
const query = `repo:raccioly/docguard ${code}`;
|
|
75
|
+
const query = context?.duplicateIdentity ? `repo:raccioly/docguard "${context.duplicateIdentity}"` : `repo:raccioly/docguard ${code}`;
|
|
64
76
|
const searchUrl = `https://github.com/search?q=${encodeURIComponent(query)}&type=issues`;
|
|
65
77
|
return { url: url.length <= URL_CAP ? url : `${ISSUES_BASE}/new`, title, searchUrl };
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
const FEEDBACK_CLASSES = new Set(['false_positive', 'false_negative', 'unsupported_syntax', 'ambiguous', 'policy_disagreement']);
|
|
81
|
+
|
|
82
|
+
function projectFile(projectDir, value, label) {
|
|
83
|
+
if (typeof value !== 'string' || !value || value.includes('\\')) throw new Error(`${label} must be a project-relative path.`);
|
|
84
|
+
const root = resolve(projectDir);
|
|
85
|
+
const path = resolve(root, value);
|
|
86
|
+
const rel = relative(root, path);
|
|
87
|
+
if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || rel.split(sep).some(part => ['.git', '.local'].includes(part.toLowerCase()))) {
|
|
88
|
+
throw new Error(`${label} must remain in the project and outside protected directories.`);
|
|
89
|
+
}
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function evaluateFileSet(manifest, fileSet) {
|
|
94
|
+
const root = mkdtempSync(join(tmpdir(), 'docguard-feedback-fixture-'));
|
|
95
|
+
try {
|
|
96
|
+
for (const file of fileSet.files) {
|
|
97
|
+
const target = resolve(root, file.path);
|
|
98
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
99
|
+
writeFileSync(target, file.content, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
const report = runGuardInternal(root, manifest.config);
|
|
102
|
+
const validator = (report.validators || []).find(item => item.key === manifest.detector.validator);
|
|
103
|
+
return {
|
|
104
|
+
identities: (report.findings || []).filter(item => item.code === manifest.detector.code).map(feedbackFindingIdentity).sort(),
|
|
105
|
+
applicability: validator?.applicability?.status || 'unknown',
|
|
106
|
+
};
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(root, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function predicateMatches(manifest, result) {
|
|
113
|
+
if (manifest.interestingness.predicate === 'finding_present') return result.identities.includes(manifest.expectedIdentity);
|
|
114
|
+
if (manifest.interestingness.predicate === 'finding_absent') return !result.identities.includes(manifest.expectedIdentity);
|
|
115
|
+
return result.applicability === 'unsupported';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function oppositeMatches(manifest, result) {
|
|
119
|
+
if (manifest.interestingness.predicate === 'finding_present') return result.identities.includes(manifest.expectedIdentity);
|
|
120
|
+
if (manifest.interestingness.predicate === 'finding_absent') return !result.identities.includes(manifest.expectedIdentity);
|
|
121
|
+
return result.applicability === 'checked';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function runFixtureFeedback(projectDir, flags) {
|
|
125
|
+
let manifest;
|
|
126
|
+
try {
|
|
127
|
+
const manifestPath = projectFile(projectDir, flags.fixtureManifest, '--fixture-manifest');
|
|
128
|
+
if (!existsSync(manifestPath) || lstatSync(manifestPath).isSymbolicLink()) throw new Error('Fixture manifest must be a regular non-symlink file.');
|
|
129
|
+
const text = readFileSync(manifestPath, 'utf8');
|
|
130
|
+
if (Buffer.byteLength(text) > 262_144) throw new Error('Fixture manifest exceeds 256 KiB.');
|
|
131
|
+
manifest = parseFeedbackFixture(JSON.parse(text));
|
|
132
|
+
if (flags.classification && flags.classification !== manifest.classification) throw new Error('--classification disagrees with the fixture manifest.');
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.log(JSON.stringify({ status: 'ERROR', error: error.message }, null, 2));
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const initial = evaluateFileSet(manifest, manifest.fixture);
|
|
140
|
+
const control = evaluateFileSet(manifest, manifest.oppositeControl);
|
|
141
|
+
const reproduced = predicateMatches(manifest, initial);
|
|
142
|
+
const controlConfirmed = oppositeMatches(manifest, control);
|
|
143
|
+
if (!reproduced || !controlConfirmed) {
|
|
144
|
+
console.log(JSON.stringify({
|
|
145
|
+
status: 'NOT_REPRODUCED', classification: manifest.classification,
|
|
146
|
+
reproductionConfirmed: reproduced, controlConfirmed,
|
|
147
|
+
message: 'The explicit predicate and opposite control must both reproduce before reduction or contribution.',
|
|
148
|
+
}, null, 2));
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let reduction = null;
|
|
154
|
+
if (flags.reduce) {
|
|
155
|
+
reduction = reduceFixtureDeterministically(manifest, candidate => predicateMatches(candidate, evaluateFileSet(candidate, candidate.fixture)));
|
|
156
|
+
manifest = reduction.manifest;
|
|
157
|
+
}
|
|
158
|
+
const searches = feedbackSearchUrls(manifest, ISSUES_BASE);
|
|
159
|
+
const issue = buildIssueUrl({ code: manifest.detector.code, confidence: 'unknown' }, {
|
|
160
|
+
classification: manifest.classification, parserTier: manifest.parserTier, duplicateIdentity: searches.identity,
|
|
161
|
+
});
|
|
162
|
+
const feedbackDir = resolve(projectDir, '.docguard', 'feedback');
|
|
163
|
+
let record = null;
|
|
164
|
+
let contribution = null;
|
|
165
|
+
let contributionPreview = null;
|
|
166
|
+
try {
|
|
167
|
+
if (!flags.preview) {
|
|
168
|
+
record = resolve(feedbackDir, `${searches.identity}.fixture.json`);
|
|
169
|
+
safeWrite(record, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
170
|
+
}
|
|
171
|
+
if (flags.contribution) {
|
|
172
|
+
if (!/^tests\/[^/]+\.test\.mjs$/.test(flags.contribution)) {
|
|
173
|
+
throw new Error('--contribution must be a direct tests/<name>.test.mjs path.');
|
|
174
|
+
}
|
|
175
|
+
contributionPreview = buildTestOnlyContribution(manifest);
|
|
176
|
+
if (!flags.preview) {
|
|
177
|
+
contribution = projectFile(projectDir, flags.contribution, '--contribution');
|
|
178
|
+
safeWrite(contribution, contributionPreview);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.log(JSON.stringify({ status: 'ERROR', error: error.message }, null, 2));
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const output = {
|
|
187
|
+
status: 'READY', preview: Boolean(flags.preview), classification: manifest.classification,
|
|
188
|
+
reproductionConfirmed: true, controlConfirmed: true, reduction: reduction ? { status: reduction.status, attempts: reduction.attempts } : null,
|
|
189
|
+
duplicateIdentity: searches.identity, searchUrls: searches, issueUrl: issue.url,
|
|
190
|
+
record: record ? relative(projectDir, record).split(sep).join('/') : null,
|
|
191
|
+
contribution: contribution ? relative(projectDir, contribution).split(sep).join('/') : null,
|
|
192
|
+
contributionPreview: flags.preview && flags.contribution ? contributionPreview : undefined,
|
|
193
|
+
};
|
|
194
|
+
console.log(JSON.stringify(output, null, 2));
|
|
195
|
+
}
|
|
196
|
+
|
|
68
197
|
export function runFeedback(projectDir, config, flags) {
|
|
198
|
+
if (flags.fixtureManifest) return runFixtureFeedback(projectDir, flags);
|
|
199
|
+
const classification = flags.classification || 'false_positive';
|
|
200
|
+
if (!FEEDBACK_CLASSES.has(classification) || classification === 'false_negative' || classification === 'unsupported_syntax') {
|
|
201
|
+
const error = classification === 'false_negative' || classification === 'unsupported_syntax'
|
|
202
|
+
? 'This classification requires --fixture-manifest with an explicit expected identity and opposite control.'
|
|
203
|
+
: 'Unknown feedback classification.';
|
|
204
|
+
if (flags.format === 'json') console.log(JSON.stringify({ error, reportable: [] })); else console.error(error);
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
69
208
|
const data = runGuardInternal(projectDir, config);
|
|
70
209
|
const selectedCode = typeof flags.code === 'string' ? flags.code.toUpperCase() : null;
|
|
71
210
|
if (flags.code !== undefined && (!selectedCode || !Object.hasOwn(CODES, selectedCode))) {
|
|
@@ -93,7 +232,7 @@ export function runFeedback(projectDir, config, flags) {
|
|
|
93
232
|
const feedbackDir = resolve(projectDir, '.docguard', 'feedback');
|
|
94
233
|
const items = reportable.map((f) => {
|
|
95
234
|
const id = shortId(`${f.code}|${f.location || f.message}`);
|
|
96
|
-
const { url, title, searchUrl } = buildIssueUrl(f);
|
|
235
|
+
const { url, title, searchUrl } = buildIssueUrl(f, { classification });
|
|
97
236
|
const fileName = `${(f.code || 'finding').toLowerCase()}-${id}.json`;
|
|
98
237
|
const filePath = resolve(feedbackDir, fileName);
|
|
99
238
|
return { finding: f, id, url, title, searchUrl, fileName, filePath, saved: false, error: null };
|
|
@@ -105,6 +244,7 @@ export function runFeedback(projectDir, config, flags) {
|
|
|
105
244
|
if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
|
|
106
245
|
safeWrite(it.filePath, JSON.stringify({
|
|
107
246
|
capturedBy: `docguard feedback (v${CLI_VERSION})`,
|
|
247
|
+
classification,
|
|
108
248
|
finding: it.finding,
|
|
109
249
|
issueUrl: it.url,
|
|
110
250
|
searchUrl: it.searchUrl,
|
|
@@ -123,6 +263,7 @@ export function runFeedback(projectDir, config, flags) {
|
|
|
123
263
|
console.log(JSON.stringify({
|
|
124
264
|
version: CLI_VERSION,
|
|
125
265
|
preview: Boolean(flags.preview),
|
|
266
|
+
classification,
|
|
126
267
|
reportable: items.map(it => ({
|
|
127
268
|
code: it.finding.code,
|
|
128
269
|
location: it.finding.location,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review code/spec changes before any documentation intent is changed.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-010
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-012
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { buildReconciliationPlan } from '../scanners/reconciliation.mjs';
|
|
8
|
+
import { runSync } from './sync.mjs';
|
|
9
|
+
|
|
10
|
+
function printPlan(result) {
|
|
11
|
+
console.log(`Reconciliation: ${result.status}`);
|
|
12
|
+
console.log(`Revision: ${result.baseRevision || 'unknown'} → ${result.revision || 'unknown'}`);
|
|
13
|
+
for (const item of result.classifications) {
|
|
14
|
+
console.log(` [${item.confidence}] ${item.path || item.kind}: ${item.disposition}`);
|
|
15
|
+
}
|
|
16
|
+
if (result.writes.length) console.log(`Mechanical write available: ${result.writes[0].command}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runReconcile(projectDir, config, flags = {}) {
|
|
20
|
+
try {
|
|
21
|
+
if (flags.write && flags.check) throw new Error('Use either --check or --write, not both.');
|
|
22
|
+
const plan = buildReconciliationPlan(projectDir, config, flags.since);
|
|
23
|
+
let mechanical = null;
|
|
24
|
+
if (flags.write) {
|
|
25
|
+
if (plan.status === 'UNSUPPORTED' || plan.status === 'BLOCKED') {
|
|
26
|
+
throw new Error('Reconciliation write refused because evidence coverage is incomplete or the registry is invalid.');
|
|
27
|
+
}
|
|
28
|
+
mechanical = runSync(projectDir, config, { ...flags, write: true, silent: true });
|
|
29
|
+
}
|
|
30
|
+
const result = { command: 'reconcile', ...plan, applied: Boolean(flags.write), mechanical };
|
|
31
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
32
|
+
else printPlan(result);
|
|
33
|
+
if (flags.check && !['READY'].includes(result.status)) process.exitCode = 2;
|
|
34
|
+
return result;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const result = { command: 'reconcile', status: 'ERROR', error: error.message };
|
|
37
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
else console.error(`Error: ${error.message}`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/cli/commands/retire.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Historical prose leaves the working tree so agents cannot mistake it for
|
|
5
5
|
* current intent. Git remains the content store; a compact manifest records
|
|
6
6
|
* why each path left and how to restore it.
|
|
7
|
+
* @implements docguard.document-lifecycle#FR-013
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import {
|
|
@@ -12,11 +13,10 @@ import {
|
|
|
12
13
|
readFileSync,
|
|
13
14
|
realpathSync,
|
|
14
15
|
rmSync,
|
|
15
|
-
unlinkSync,
|
|
16
16
|
} from 'node:fs';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
|
|
19
|
-
import {
|
|
19
|
+
import { commitFileTransaction } from '../writers/file-transaction.mjs';
|
|
20
20
|
import { scanDocumentLifecycle } from '../scanners/document-lifecycle.mjs';
|
|
21
21
|
import { parseSpecId, readSpecRegistry } from '../scanners/spec-registry.mjs';
|
|
22
22
|
import {
|
|
@@ -279,19 +279,15 @@ function writeArchive(projectDir, config, flags) {
|
|
|
279
279
|
|
|
280
280
|
const next = { ...manifest, entries: [...manifest.entries, ...entries] };
|
|
281
281
|
assertArchivable(projectDir, files, config);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
spawnSync('git', ['restore', `--source=${commit}`, '--', ...removed], { cwd: projectDir });
|
|
292
|
-
}
|
|
293
|
-
throw error;
|
|
294
|
-
}
|
|
282
|
+
commitFileTransaction([
|
|
283
|
+
{ path: resolve(projectDir, MANIFEST_PATH), content: `${JSON.stringify(next, null, 2)}\n` },
|
|
284
|
+
...files.map(file => ({ path: resolve(projectDir, file), content: null })),
|
|
285
|
+
], {
|
|
286
|
+
validate: () => {
|
|
287
|
+
const written = loadManifest(projectDir);
|
|
288
|
+
if (written.entries.length !== next.entries.length) throw new Error('recovery manifest did not commit completely');
|
|
289
|
+
},
|
|
290
|
+
});
|
|
295
291
|
|
|
296
292
|
const directories = [...new Set(files.map(dirname))]
|
|
297
293
|
.filter(path => path !== '.')
|
package/cli/commands/specs.mjs
CHANGED
|
@@ -1,14 +1,201 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Manage and preflight the deterministic spec lifecycle registry.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-016
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-017
|
|
5
|
+
* @implements docguard.document-lifecycle#FR-020
|
|
6
|
+
*/
|
|
2
7
|
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
11
|
import { resolve } from 'node:path';
|
|
4
12
|
import { safeWrite } from '../writers/generate-io.mjs';
|
|
5
|
-
import {
|
|
13
|
+
import { commitFileTransaction } from '../writers/file-transaction.mjs';
|
|
14
|
+
import { appendImplementationOutcome } from '../writers/spec-outcomes.mjs';
|
|
15
|
+
import { serializeLifecycleContext } from '../scanners/lifecycle-context.mjs';
|
|
16
|
+
import { buildReconciliationPlan } from '../scanners/reconciliation.mjs';
|
|
17
|
+
import { preflightSpec, projectSpecRegistry, readSpecRegistry, SPEC_REGISTRY_PATH } from '../scanners/spec-registry.mjs';
|
|
18
|
+
import { runGuardInternal } from './guard.mjs';
|
|
19
|
+
|
|
20
|
+
const CONTEXT_PATH = '.docguard/current-context.json';
|
|
21
|
+
const digest = content => `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
22
|
+
|
|
23
|
+
export function normalizeExternalDeliveryStatus(value) {
|
|
24
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[ -]+/g, '_');
|
|
25
|
+
return new Map([
|
|
26
|
+
['planned', 'planned'], ['in_progress', 'in_progress'], ['started', 'in_progress'],
|
|
27
|
+
['implemented', 'implemented'], ['complete', 'implemented'], ['completed', 'implemented'], ['done', 'implemented'],
|
|
28
|
+
['verified', 'verified'], ['validated', 'verified'], ['released', 'released'], ['shipped', 'released'],
|
|
29
|
+
]).get(normalized) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function headRevision(projectDir) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function trackedDirty(projectDir) {
|
|
39
|
+
try {
|
|
40
|
+
return execFileSync('git', ['status', '--porcelain=v1', '--untracked-files=no'], {
|
|
41
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
42
|
+
}).trim();
|
|
43
|
+
} catch { return 'git-unavailable'; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function archiveReadiness(spec, targetVerified = false) {
|
|
47
|
+
if (!spec) return { status: 'BLOCKED', reason: 'Spec identity is unresolved.' };
|
|
48
|
+
const model = spec.reviewed.lifecycle.persistenceModel;
|
|
49
|
+
const delivery = targetVerified ? 'verified' : spec.reviewed.lifecycle.delivery;
|
|
50
|
+
if (model === 'living') return { status: 'KEEP_CURRENT', reason: 'Living specs remain in active context after verification.' };
|
|
51
|
+
if (!model) return { status: 'REVIEW', reason: 'Choose a persistence model before retiring the verified spec.' };
|
|
52
|
+
if (delivery !== 'verified' && delivery !== 'released') return { status: 'BLOCKED', reason: 'Spec must be verified before retirement.' };
|
|
53
|
+
if (model === 'flow_forward' && !spec.reviewed.relations.supersededBy.length) {
|
|
54
|
+
return { status: 'BLOCKED', reason: 'Flow-forward retirement requires a reviewed successor.' };
|
|
55
|
+
}
|
|
56
|
+
return { status: 'READY', reason: 'Run the reviewed spec retirement flow after the verified state is committed and retained.' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function completionTransition(spec) {
|
|
60
|
+
if (spec?.reviewed.lifecycle.delivery === 'verified') return 'verified→verified';
|
|
61
|
+
return spec?.reviewed.lifecycle.delivery === 'in_progress'
|
|
62
|
+
? 'in_progress→implemented→verified'
|
|
63
|
+
: 'implemented→verified';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function planSpecCompletion(projectDir, config, flags, options = {}) {
|
|
67
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
68
|
+
const loaded = readSpecRegistry(projectDir);
|
|
69
|
+
const revision = headRevision(projectDir);
|
|
70
|
+
const spec = projection.registry.specs.find(entry => entry.specId === flags.id);
|
|
71
|
+
const blockers = [...projection.issues];
|
|
72
|
+
if (!flags.id) blockers.push({ code: 'SPC001', message: 'Completion requires --id <spec-id>.' });
|
|
73
|
+
if (!loaded.exists || loaded.error || !projection.current) blockers.push({ code: 'SPC001', message: 'Committed registry must be current before completion.' });
|
|
74
|
+
if (!spec) blockers.push({ code: 'SPC001', message: `Unknown spec ID: ${flags.id || '<missing>'}.` });
|
|
75
|
+
if (!revision) blockers.push({ code: 'SPC001', message: 'Completion requires a resolvable Git HEAD.' });
|
|
76
|
+
if (trackedDirty(projectDir)) blockers.push({ code: 'SPC001', message: 'Completion requires a clean tracked working tree at the recorded revision.' });
|
|
77
|
+
|
|
78
|
+
let reconcile = null;
|
|
79
|
+
if (spec) {
|
|
80
|
+
const maintenance = spec.reviewed.lifecycle.delivery === 'verified'
|
|
81
|
+
&& spec.reviewed.lifecycle.persistenceModel === 'living';
|
|
82
|
+
if (spec.reviewed.lifecycle.approval !== 'approved') blockers.push({ code: 'SPC002', message: 'Only an approved spec can become verified.' });
|
|
83
|
+
if (!['in_progress', 'implemented'].includes(spec.reviewed.lifecycle.delivery) && !maintenance) {
|
|
84
|
+
blockers.push({ code: 'SPC002', message: `Expected delivery=in_progress, implemented, or verified with persistenceModel=living; found ${spec.reviewed.lifecycle.delivery}/${spec.reviewed.lifecycle.persistenceModel || 'unset'}.` });
|
|
85
|
+
}
|
|
86
|
+
const tasks = spec.observed.taskCompletion;
|
|
87
|
+
if (!tasks.total || tasks.checked !== tasks.total) blockers.push({ code: 'SPC003', message: `All tasks must be checked (${tasks.checked}/${tasks.total}).` });
|
|
88
|
+
if (spec.observed.implementationEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified source implementation annotation is required.' });
|
|
89
|
+
if (spec.observed.testEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified test annotation is required.' });
|
|
90
|
+
const covered = new Set([...spec.observed.implementationEvidence, ...spec.observed.testEvidence].map(item => item.requirementId));
|
|
91
|
+
const missing = spec.intent.requirements
|
|
92
|
+
.map(identity => identity.slice(identity.lastIndexOf('#') + 1))
|
|
93
|
+
.filter(id => !covered.has(id));
|
|
94
|
+
if (missing.length) blockers.push({ code: 'SPC004', message: `Qualified implementation or test evidence is missing for: ${missing.join(', ')}.` });
|
|
95
|
+
if (spec.reviewed.scope.canonicalDocs.length === 0) blockers.push({ code: 'SPC005', message: 'Completion requires at least one reviewed affected canonical document.' });
|
|
96
|
+
for (const path of spec.reviewed.scope.canonicalDocs) {
|
|
97
|
+
if (!existsSync(resolve(projectDir, path))) blockers.push({ code: 'SPC005', message: `Affected canonical document is missing: ${path}.` });
|
|
98
|
+
}
|
|
99
|
+
const since = flags.since || spec.reviewed.reconciliation.lastReviewedRevision;
|
|
100
|
+
if (!since) blockers.push({ code: 'SPC006', message: 'Completion requires --since <review-baseline> for the first reconciliation.' });
|
|
101
|
+
else {
|
|
102
|
+
reconcile = buildReconciliationPlan(projectDir, config, since);
|
|
103
|
+
if (reconcile.status === 'UNSUPPORTED' || reconcile.status === 'BLOCKED') blockers.push({ code: 'SPC006', message: 'Reconciliation coverage is unsupported or blocked.' });
|
|
104
|
+
const unresolved = reconcile.classifications.filter(item => item.disposition === 'unsupported_or_ambiguous');
|
|
105
|
+
if (unresolved.length) blockers.push({ code: 'SPC006', message: `Unresolved changed files: ${unresolved.map(item => item.path).join(', ')}.` });
|
|
106
|
+
if (maintenance) {
|
|
107
|
+
const reviewable = reconcile.classifications.filter(item =>
|
|
108
|
+
item.specs.includes(spec.specId)
|
|
109
|
+
&& ['source', 'test', 'canonical_doc', 'decision'].includes(item.kind));
|
|
110
|
+
if (revision === spec.reviewed.reconciliation.lastReviewedRevision || reviewable.length === 0) {
|
|
111
|
+
blockers.push({ code: 'SPC006', message: 'Living-spec maintenance requires a new linked source, test, canonical-document, or decision change since the last reviewed revision.' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const guard = options.guardResult || runGuardInternal(projectDir, config);
|
|
117
|
+
if ((guard.errors || 0) > 0) blockers.push({ code: 'SPC007', message: `Guard has ${guard.errors} error(s).` });
|
|
118
|
+
return {
|
|
119
|
+
status: blockers.length ? 'BLOCKED' : 'READY',
|
|
120
|
+
specId: flags.id || null,
|
|
121
|
+
revision,
|
|
122
|
+
transition: completionTransition(spec),
|
|
123
|
+
blockers,
|
|
124
|
+
reconciliation: reconcile,
|
|
125
|
+
evidence: spec ? [...new Set([
|
|
126
|
+
...spec.observed.implementationEvidence.map(item => item.file),
|
|
127
|
+
...spec.observed.testEvidence.map(item => item.file),
|
|
128
|
+
...spec.reviewed.scope.canonicalDocs,
|
|
129
|
+
])].sort() : [],
|
|
130
|
+
context: CONTEXT_PATH,
|
|
131
|
+
archiveReadiness: archiveReadiness(spec, true),
|
|
132
|
+
guard: { status: guard.status, errors: guard.errors, warnings: guard.warnings },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function completeSpec(projectDir, config, flags, options = {}) {
|
|
137
|
+
const plan = planSpecCompletion(projectDir, config, flags, options);
|
|
138
|
+
if (plan.status !== 'READY') return { command: 'complete', ...plan, applied: false };
|
|
139
|
+
if (!flags.write) return { command: 'complete', ...plan, applied: false };
|
|
140
|
+
const reason = String(flags.reason || '').replace(/\s+/g, ' ').trim();
|
|
141
|
+
if (!reason || reason.length > 500) throw new Error('Completion --write requires --reason with 1-500 characters.');
|
|
142
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
143
|
+
const spec = projection.registry.specs.find(entry => entry.specId === flags.id);
|
|
144
|
+
const specPath = resolve(projectDir, spec.path);
|
|
145
|
+
const successor = flags.successor
|
|
146
|
+
? projection.registry.specs.find(entry => entry.specId === flags.successor) : null;
|
|
147
|
+
if (flags.successor && (!successor || successor.reviewed.lifecycle.context !== 'current'
|
|
148
|
+
|| successor.reviewed.lifecycle.approval !== 'approved')) {
|
|
149
|
+
throw new Error('Completion successor must be an approved current spec ID.');
|
|
150
|
+
}
|
|
151
|
+
const deviations = [...new Set(flags.deviations || [])]
|
|
152
|
+
.map(item => String(item).replace(/\s+/g, ' ').trim())
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
if (deviations.length > 20 || deviations.some(item => item.length > 500)) {
|
|
155
|
+
throw new Error('Completion accepts at most 20 deviations of 1-500 characters.');
|
|
156
|
+
}
|
|
157
|
+
const outcome = {
|
|
158
|
+
revision: plan.revision,
|
|
159
|
+
reason,
|
|
160
|
+
evidence: plan.evidence,
|
|
161
|
+
deviations,
|
|
162
|
+
successor: flags.successor || null,
|
|
163
|
+
};
|
|
164
|
+
const specContent = appendImplementationOutcome(readFileSync(specPath, 'utf8'), outcome);
|
|
165
|
+
spec.reviewed.lifecycle.delivery = 'verified';
|
|
166
|
+
spec.reviewed.reconciliation.lastReviewedRevision = plan.revision;
|
|
167
|
+
spec.reviewed.reconciliation.outcomes = [...spec.reviewed.reconciliation.outcomes, outcome].slice(-20);
|
|
168
|
+
const artifact = spec.observed.artifacts.find(item => item.path === spec.path);
|
|
169
|
+
if (artifact) artifact.digest = digest(specContent);
|
|
170
|
+
projection.registry.schemaVersion = 2;
|
|
171
|
+
const registryContent = `${JSON.stringify(projection.registry, null, 2)}\n`;
|
|
172
|
+
const contextContent = serializeLifecycleContext(projectDir, projection.registry, plan.revision);
|
|
173
|
+
commitFileTransaction([
|
|
174
|
+
{ path: specPath, content: specContent },
|
|
175
|
+
{ path: resolve(projectDir, SPEC_REGISTRY_PATH), content: registryContent },
|
|
176
|
+
{ path: resolve(projectDir, CONTEXT_PATH), content: contextContent },
|
|
177
|
+
], {
|
|
178
|
+
validate: () => {
|
|
179
|
+
const next = projectSpecRegistry(projectDir, config);
|
|
180
|
+
if (next.issues.length || !next.current) throw new Error('completed registry does not match the resulting repository');
|
|
181
|
+
const context = JSON.parse(readFileSync(resolve(projectDir, CONTEXT_PATH), 'utf8'));
|
|
182
|
+
if (context.generatedFrom !== plan.revision) throw new Error('active context revision mismatch');
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return { command: 'complete', ...plan, status: 'VERIFIED', applied: true, outcome };
|
|
186
|
+
}
|
|
6
187
|
|
|
7
188
|
function printIssues(issues) {
|
|
8
189
|
for (const issue of issues) console.log(` ${issue.code} ${issue.path}: ${issue.message}`);
|
|
9
190
|
}
|
|
10
191
|
|
|
11
192
|
function printResult(result) {
|
|
193
|
+
if (result.command === 'complete') {
|
|
194
|
+
console.log(`Spec completion: ${result.status}`);
|
|
195
|
+
console.log(`${result.specId || '<missing>'}: ${result.transition}`);
|
|
196
|
+
if (result.blockers?.length) printIssues(result.blockers.map(issue => ({ path: result.specId || SPEC_REGISTRY_PATH, ...issue })));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
12
199
|
if (result.command === 'preflight') {
|
|
13
200
|
console.log(`Spec preflight: ${result.status}`);
|
|
14
201
|
console.log(`Current specs: ${result.briefing.length}`);
|
|
@@ -34,7 +221,7 @@ function printResult(result) {
|
|
|
34
221
|
export function runSpecs(projectDir, config, flags = {}) {
|
|
35
222
|
try {
|
|
36
223
|
const action = flags.args?.[0] || null;
|
|
37
|
-
if (action &&
|
|
224
|
+
if (action && !['preflight', 'complete'].includes(action)) throw new Error(`Unknown specs action: ${action}`);
|
|
38
225
|
if (flags.check && flags.write) throw new Error('Use either --check or --write, not both.');
|
|
39
226
|
|
|
40
227
|
if (action === 'preflight') {
|
|
@@ -46,6 +233,14 @@ export function runSpecs(projectDir, config, flags = {}) {
|
|
|
46
233
|
return result;
|
|
47
234
|
}
|
|
48
235
|
|
|
236
|
+
if (action === 'complete') {
|
|
237
|
+
const result = completeSpec(projectDir, config, flags);
|
|
238
|
+
if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
|
|
239
|
+
else printResult(result);
|
|
240
|
+
if (result.status === 'BLOCKED') process.exitCode = 2;
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
49
244
|
const projection = projectSpecRegistry(projectDir, config);
|
|
50
245
|
if (flags.write && projection.issues.length > 0) {
|
|
51
246
|
throw new Error(`Registry refresh refused: ${projection.issues.map(issue => issue.message).join(' ')}`);
|