docguard-cli 0.27.0 → 0.28.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 +3 -2
- package/cli/commands/explain.mjs +8 -6
- package/cli/commands/guard.mjs +13 -7
- package/cli/commands/init.mjs +23 -1
- package/cli/commands/sync-tests.mjs +272 -0
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/verify.mjs +67 -0
- package/cli/docguard.mjs +31 -3
- package/cli/scanners/semantic-claims.mjs +154 -0
- package/cli/validators/api-surface.mjs +75 -9
- package/cli/validators/architecture.mjs +25 -13
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- 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/package.json +1 -1
- package/templates/ENVIRONMENT.md.template +5 -0
- package/templates/REQUIREMENTS.md.template +2 -0
- package/templates/SECURITY.md.template +6 -1
- package/templates/TEST-SPEC.md.template +5 -0
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
|
|
|
63
63
|
|
|
64
64
|
```mermaid
|
|
65
65
|
graph TD
|
|
66
|
-
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (
|
|
66
|
+
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (17)"]
|
|
67
67
|
Commands --> guard["guard"]
|
|
68
68
|
Commands --> generate["generate"]
|
|
69
69
|
Commands --> score["score"]
|
|
@@ -250,7 +250,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
|
|
|
250
250
|
|
|
251
251
|
## Usage
|
|
252
252
|
|
|
253
|
-
DocGuard ships **
|
|
253
|
+
DocGuard ships **17 commands** (the "Daily 5" + 12 situational tools, including the zero-install `demo`). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Eight v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
|
|
254
254
|
|
|
255
255
|
**The Daily 5** — what you'll reach for 95% of the time:
|
|
256
256
|
|
|
@@ -274,6 +274,7 @@ DocGuard ships **16 commands** (the "Daily 5" + 11 situational tools, including
|
|
|
274
274
|
| `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) |
|
|
275
275
|
| `agent` | One-shot agent task graph — ordered, pre-filled code-truth, per-task verify (`--format json`) |
|
|
276
276
|
| `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
|
|
277
|
+
| `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 |
|
|
277
278
|
| `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
|
|
278
279
|
| `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
|
|
279
280
|
| `memory --diff` | Drill into which specific claims don't match code |
|
package/cli/commands/explain.mjs
CHANGED
|
@@ -111,13 +111,14 @@ const EXPLAINERS = {
|
|
|
111
111
|
},
|
|
112
112
|
freshness: {
|
|
113
113
|
title: 'Freshness — docs updated alongside code',
|
|
114
|
-
what: 'For each canonical doc, counts code commits since
|
|
114
|
+
what: 'For each canonical doc it picks a "last updated" date by PRECEDENCE, then counts code commits since (>10 = stale): (1) an explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` marker — a human review signal git cannot see, so it WINS; (2) `<!-- docguard:status approved -->`; (3) the git commit date; (4) for an uncommitted file with no marker, it asks you to commit OR add a marker. CHANGELOG.md follows the SAME precedence — a marker satisfies it before any commit, which matters in a pre-commit edit/review loop. `docguard init` now stamps a `last-reviewed` marker into every canonical doc so freshness is marker-based and consistent from day one.',
|
|
115
115
|
why: 'Docs drift silently. This validator surfaces the drift before it becomes invisible.',
|
|
116
116
|
triggers: [
|
|
117
|
-
['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose
|
|
117
|
+
['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose and update (or add) the `<!-- docguard:last-reviewed YYYY-MM-DD -->` marker.'],
|
|
118
|
+
['not yet committed to git', 'A canonical doc has no git history and no marker. Commit it, or add `<!-- docguard:last-reviewed YYYY-MM-DD -->` (or `<!-- docguard:status approved -->`) so freshness is satisfiable before the commit.'],
|
|
118
119
|
['DRIFT-LOG.md may be stale', 'DRIFT comments in code outpaced log entries. Add the entries.'],
|
|
119
120
|
],
|
|
120
|
-
example: 'ARCHITECTURE.md last committed within 10 code commits',
|
|
121
|
+
example: 'ARCHITECTURE.md carries `<!-- docguard:last-reviewed 2026-06-19 -->` (or was committed within 10 code commits)',
|
|
121
122
|
standard: 'CDD principle: docs and code commit together',
|
|
122
123
|
},
|
|
123
124
|
traceability: {
|
|
@@ -134,12 +135,13 @@ const EXPLAINERS = {
|
|
|
134
135
|
standard: 'ISO/IEC/IEEE 29148 (requirements traceability)',
|
|
135
136
|
},
|
|
136
137
|
apiSurface: {
|
|
137
|
-
title: 'API-Surface — endpoints in code match API-REFERENCE.md',
|
|
138
|
-
what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs.',
|
|
139
|
-
why: 'Documented but missing endpoints are dead links. Endpoints in code that aren\'t documented are invisible.',
|
|
138
|
+
title: 'API-Surface — endpoints in code match API-REFERENCE.md (and the spec matches the routes)',
|
|
139
|
+
what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs. When an OpenAPI spec exists it is the authoritative surface — so it ALSO diffs the spec against the actually-registered code routes, catching a spec that declares a phantom endpoint (the doc reconciles clean against a wrong spec otherwise). That spec-vs-route check is conservative: it only runs when code routes are actually scannable.',
|
|
140
|
+
why: 'Documented but missing endpoints are dead links. Endpoints in code that aren\'t documented are invisible. And a spec nobody implements is a lie the doc check can\'t see.',
|
|
140
141
|
triggers: [
|
|
141
142
|
['documented but absent', 'API-REFERENCE.md lists an endpoint that scanRoutes() can\'t find. Remove or fix the doc; `fix --write` removes when marked.'],
|
|
142
143
|
['present but undocumented', 'A route exists in code but API-REFERENCE.md doesn\'t list it. Add it.'],
|
|
144
|
+
['declares', 'The OpenAPI spec declares an endpoint that no Express/Fastify/etc. route registers in code — i.e. "declares METHOD /path but no route registers it". Either implement the route or remove the phantom endpoint from the spec (the API-REFERENCE doc reconciles clean against the spec, so this is the only check that catches it).'],
|
|
143
145
|
],
|
|
144
146
|
example: 'GET /api/users in src/routes/users.ts AND in API-REFERENCE.md\'s Endpoints table',
|
|
145
147
|
standard: 'OpenAPI 3.1',
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -462,12 +462,17 @@ export function runGuard(projectDir, config, flags) {
|
|
|
462
462
|
|
|
463
463
|
// ── JSON output ──
|
|
464
464
|
if (flags.format === 'json') {
|
|
465
|
-
console.log(JSON.stringify(data, null, 2));
|
|
466
465
|
// Use severity-aware effective counts for exit code; raw counts stay in the JSON
|
|
467
466
|
// for display tools that want to show the full picture.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
467
|
+
const code = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
468
|
+
// v0.28: set exitCode + return instead of process.exit(). A large JSON
|
|
469
|
+
// payload (>~8 KB) written to a PIPE flushes asynchronously; an immediate
|
|
470
|
+
// process.exit() truncates it mid-string, so a CI consumer parsing stdout
|
|
471
|
+
// gets "Unterminated string in JSON" on exactly the big reports that matter.
|
|
472
|
+
// Returning lets Node drain stdout and exit naturally with process.exitCode.
|
|
473
|
+
process.exitCode = code;
|
|
474
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
475
|
+
return;
|
|
471
476
|
}
|
|
472
477
|
|
|
473
478
|
// ── Text output ──
|
|
@@ -707,7 +712,8 @@ export function runGuard(projectDir, config, flags) {
|
|
|
707
712
|
}
|
|
708
713
|
|
|
709
714
|
// v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
715
|
+
// v0.28: exitCode + return (not process.exit) so the buffered text output
|
|
716
|
+
// flushes to a pipe before the process exits — same truncation fix as the
|
|
717
|
+
// JSON path above.
|
|
718
|
+
process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
713
719
|
}
|
package/cli/commands/init.mjs
CHANGED
|
@@ -78,6 +78,20 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
78
78
|
const __dirname = dirname(__filename);
|
|
79
79
|
const TEMPLATES_DIR = resolve(__dirname, '../../templates');
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* v0.28 (field report #11): inject a `<!-- docguard:last-reviewed DATE -->`
|
|
83
|
+
* marker right after the first H1, so a canonical doc has a freshness signal the
|
|
84
|
+
* Freshness validator reads directly (not git mtime). No-op if one is present.
|
|
85
|
+
*/
|
|
86
|
+
function stampLastReviewed(content, date) {
|
|
87
|
+
const marker = `<!-- docguard:last-reviewed ${date} -->`;
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
const h1 = lines.findIndex(l => /^#\s/.test(l));
|
|
90
|
+
if (h1 === -1) return `${marker}\n\n${content}`;
|
|
91
|
+
lines.splice(h1 + 1, 0, '', marker);
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
// ── Readline helper ──────────────────────────────────────────────────────
|
|
82
96
|
|
|
83
97
|
function askQuestion(prompt) {
|
|
@@ -263,7 +277,15 @@ export async function runInit(projectDir, config, flags) {
|
|
|
263
277
|
if (existsSync(templatePath)) {
|
|
264
278
|
const content = readFileSync(templatePath, 'utf-8');
|
|
265
279
|
const today = new Date().toISOString().split('T')[0];
|
|
266
|
-
|
|
280
|
+
let processed = content.replace(/YYYY-MM-DD/g, today);
|
|
281
|
+
// v0.28 (field report #11): every canonical doc must ship with a freshness
|
|
282
|
+
// marker so the Freshness validator is marker-based (consistent across docs,
|
|
283
|
+
// and satisfiable in a pre-commit review loop) rather than silently falling
|
|
284
|
+
// back to git mtime. Templates now all carry one; this is the belt-and-
|
|
285
|
+
// suspenders guarantee for any future template that forgets.
|
|
286
|
+
if (mapping.dest.startsWith('docs-canonical/') && !/docguard:last-reviewed/.test(processed)) {
|
|
287
|
+
processed = stampLastReviewed(processed, today);
|
|
288
|
+
}
|
|
267
289
|
writeFileSync(destPath, processed, 'utf-8');
|
|
268
290
|
created.push(mapping.dest);
|
|
269
291
|
console.log(` ${c.green}✅${c.reset} Created: ${c.cyan}${mapping.dest}${c.reset}`);
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docguard sync --tests` — reconcile the TEST-SPEC Source-to-Test Map from disk.
|
|
3
|
+
*
|
|
4
|
+
* Background (LLM field report #10): the source→test table in TEST-SPEC.md is
|
|
5
|
+
* hand-maintained, so plain `docguard sync` (which only refreshes
|
|
6
|
+
* docguard:generated code-truth SECTIONS) reports "nothing drifted" even when the
|
|
7
|
+
* table has a ghost service (source deleted), a ghost test (test file deleted),
|
|
8
|
+
* and N services that gained tests. The Test-Spec validator already detects the
|
|
9
|
+
* ghosts; this writes the reconciliation back.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY — this edits a human-curated table, so it does ONLY the two unambiguous
|
|
12
|
+
* operations and previews by default (`--write` applies):
|
|
13
|
+
* - REMOVE a row whose SOURCE file no longer exists on disk (ghost service).
|
|
14
|
+
* - APPEND a row for a co-located source↔test pair found on disk but absent
|
|
15
|
+
* from the table (newly-covered service).
|
|
16
|
+
* Ghost TEST references (source still exists, test file gone) are REPORTED but
|
|
17
|
+
* never auto-edited — blanking a hand-maintained status/notes cell is too
|
|
18
|
+
* destructive, and the Test-Spec validator already warns on them.
|
|
19
|
+
*
|
|
20
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { c } from '../shared.mjs';
|
|
26
|
+
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
27
|
+
|
|
28
|
+
const TEST_SPEC_DOC = 'docs-canonical/TEST-SPEC.md';
|
|
29
|
+
const CODE_EXT = /\.[cm]?[jt]sx?$/;
|
|
30
|
+
const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
31
|
+
const WALK_SKIP = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.next', '__pycache__', '.venv', 'vendor']);
|
|
32
|
+
|
|
33
|
+
// ── On-disk discovery ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function walkCodeFiles(projectDir, config) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const visit = (absDir, relDir) => {
|
|
38
|
+
let entries;
|
|
39
|
+
try { entries = readdirSync(absDir, { withFileTypes: true }); } catch { return; }
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.name.startsWith('.')) continue;
|
|
42
|
+
if (WALK_SKIP.has(e.name)) continue;
|
|
43
|
+
const relPath = relDir ? `${relDir}/${e.name}` : e.name;
|
|
44
|
+
if (e.isDirectory()) { visit(resolve(absDir, e.name), relPath); continue; }
|
|
45
|
+
if (!CODE_EXT.test(e.name)) continue;
|
|
46
|
+
if (shouldIgnore(relPath, config)) continue;
|
|
47
|
+
out.push(relPath);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
visit(resolve(projectDir), '');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
55
|
+
const baseOf = (p) => (p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p);
|
|
56
|
+
const stemOf = (b) => b.replace(CODE_EXT, '').replace(/\.(test|spec)$/, '');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Discover co-located source↔test pairs on disk. A test counts as covering a
|
|
60
|
+
* source when their stems match AND the test sits in the same directory or a
|
|
61
|
+
* sibling `__tests__/`. Conservative by design — cross-directory basename
|
|
62
|
+
* collisions (two `index.ts`) would pollute the table, so they're excluded.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Array<{ source: string, test: string }>}
|
|
65
|
+
*/
|
|
66
|
+
export function discoverTestPairs(projectDir, config = {}) {
|
|
67
|
+
const files = walkCodeFiles(projectDir, config);
|
|
68
|
+
const tests = files.filter((f) => TEST_RE.test(f));
|
|
69
|
+
const sources = files.filter((f) => !TEST_RE.test(f));
|
|
70
|
+
const pairs = [];
|
|
71
|
+
for (const src of sources) {
|
|
72
|
+
const sDir = dirOf(src);
|
|
73
|
+
const sStem = stemOf(baseOf(src));
|
|
74
|
+
const match = tests.find((t) => {
|
|
75
|
+
if (stemOf(baseOf(t)) !== sStem) return false;
|
|
76
|
+
const tDir = dirOf(t);
|
|
77
|
+
return tDir === sDir || tDir === `${sDir}/__tests__` || (sDir === '' && tDir === '__tests__');
|
|
78
|
+
});
|
|
79
|
+
if (match) pairs.push({ source: src, test: match });
|
|
80
|
+
}
|
|
81
|
+
return pairs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Table parsing (mirrors test-spec.mjs column detection) ─────────────────
|
|
85
|
+
|
|
86
|
+
const isPathLike = (v) => !!v && !/\s/.test(v) && (/[\\/]/.test(v) || /\.[A-Za-z0-9]{1,6}$/.test(v));
|
|
87
|
+
const splitRow = (line) => {
|
|
88
|
+
const parts = line.split('|');
|
|
89
|
+
parts.shift();
|
|
90
|
+
parts.pop();
|
|
91
|
+
return parts.map((s) => s.trim());
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Locate the Source-to-Test Map table and classify its rows against disk.
|
|
96
|
+
* @returns {null | { headerLine, sepLine, sourceIdx, testIdxs, ncols, keep:string[],
|
|
97
|
+
* removed:object[], ghostTests:object[], blockStart, blockEnd }}
|
|
98
|
+
*/
|
|
99
|
+
function parseMapTable(content, projectDir) {
|
|
100
|
+
const sectionRe = /## (?:Service-to-Test Map|Source-to-Test Map)[\s\S]*?(?=\n## |$)/;
|
|
101
|
+
const m = sectionRe.exec(content);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const sectionStart = m.index;
|
|
104
|
+
const sectionText = m[0];
|
|
105
|
+
const sectionLines = sectionText.split('\n');
|
|
106
|
+
|
|
107
|
+
// Find the FIRST pipe table inside the section (header + separator + rows).
|
|
108
|
+
let headerLineIdx = -1;
|
|
109
|
+
for (let i = 0; i < sectionLines.length - 1; i++) {
|
|
110
|
+
if (sectionLines[i].trim().startsWith('|') && /^\s*\|[\s|:-]+\|\s*$/.test(sectionLines[i + 1])) {
|
|
111
|
+
headerLineIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (headerLineIdx === -1) return null;
|
|
116
|
+
|
|
117
|
+
const header = splitRow(sectionLines[headerLineIdx]).map((h) => h.toLowerCase());
|
|
118
|
+
const ncols = header.length;
|
|
119
|
+
let sourceIdx = header.findIndex((h) => /\bsource\b/.test(h));
|
|
120
|
+
if (sourceIdx < 0) sourceIdx = 0;
|
|
121
|
+
let statusIdx = header.findIndex((h) => /\bstatus\b/.test(h));
|
|
122
|
+
if (statusIdx < 0) statusIdx = ncols - 1;
|
|
123
|
+
let testIdxs = header.map((h, i) => (/\btest\b|\be2e\b/.test(h) ? i : -1)).filter((i) => i >= 0 && i !== sourceIdx && i !== statusIdx);
|
|
124
|
+
if (testIdxs.length === 0) { const fb = sourceIdx === 1 ? 0 : 1; if (fb !== statusIdx && fb < ncols) testIdxs = [fb]; }
|
|
125
|
+
|
|
126
|
+
// Walk data rows after the separator until the table ends (a non-pipe line).
|
|
127
|
+
const keep = []; // raw row lines to retain
|
|
128
|
+
const removed = []; // { source } ghost-source rows dropped
|
|
129
|
+
const ghostTests = []; // { source, test } source exists but a test ref is gone
|
|
130
|
+
const documentedSources = new Set();
|
|
131
|
+
let dataEndIdx = headerLineIdx + 2;
|
|
132
|
+
for (let i = headerLineIdx + 2; i < sectionLines.length; i++) {
|
|
133
|
+
const line = sectionLines[i];
|
|
134
|
+
if (!line.trim().startsWith('|')) break;
|
|
135
|
+
dataEndIdx = i + 1;
|
|
136
|
+
const cells = splitRow(line);
|
|
137
|
+
const rawSource = (cells[sourceIdx] || '').replace(/`/g, '').trim();
|
|
138
|
+
// Template/example/placeholder rows are left untouched.
|
|
139
|
+
if (!rawSource || rawSource.startsWith('<!--') || rawSource.startsWith('*') || !isPathLike(rawSource)) {
|
|
140
|
+
keep.push(line);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!existsSync(resolve(projectDir, rawSource))) {
|
|
144
|
+
removed.push({ source: rawSource });
|
|
145
|
+
continue; // drop ghost-source row
|
|
146
|
+
}
|
|
147
|
+
documentedSources.add(rawSource);
|
|
148
|
+
// Source exists — report (don't edit) any dead test reference.
|
|
149
|
+
for (const ti of testIdxs) {
|
|
150
|
+
const t = (cells[ti] || '').replace(/`/g, '').trim();
|
|
151
|
+
if (isPathLike(t) && !existsSync(resolve(projectDir, t))) ghostTests.push({ source: rawSource, test: t });
|
|
152
|
+
}
|
|
153
|
+
keep.push(line);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
sectionStart,
|
|
158
|
+
headerAbsLine: headerLineIdx,
|
|
159
|
+
sepLine: sectionLines[headerLineIdx + 1],
|
|
160
|
+
headerLine: sectionLines[headerLineIdx],
|
|
161
|
+
sourceIdx, testIdxs, statusIdx, ncols,
|
|
162
|
+
keep, removed, ghostTests, documentedSources,
|
|
163
|
+
// absolute char offsets of the table block within `content`
|
|
164
|
+
blockStartLine: headerLineIdx,
|
|
165
|
+
blockEndLine: dataEndIdx,
|
|
166
|
+
sectionLines,
|
|
167
|
+
sectionTextStart: sectionStart,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compute the reconciliation. Pure: returns the diff + the rewritten content.
|
|
173
|
+
* @returns {{ applicable:boolean, removed:object[], added:object[], ghostTests:object[], newContent:string|null, reason?:string }}
|
|
174
|
+
*/
|
|
175
|
+
export function reconcileTestMap(content, projectDir, config) {
|
|
176
|
+
const parsed = parseMapTable(content, projectDir);
|
|
177
|
+
if (!parsed) {
|
|
178
|
+
return { applicable: false, removed: [], added: [], ghostTests: [], newContent: null, reason: 'no Source-to-Test Map table found' };
|
|
179
|
+
}
|
|
180
|
+
const pairs = discoverTestPairs(projectDir, config);
|
|
181
|
+
const added = pairs.filter((p) => !parsed.documentedSources.has(p.source));
|
|
182
|
+
|
|
183
|
+
// Build the new table block: header, separator, kept rows, appended rows.
|
|
184
|
+
const newRowFor = ({ source, test }) => {
|
|
185
|
+
const cells = new Array(parsed.ncols).fill('—');
|
|
186
|
+
cells[parsed.sourceIdx] = `\`${source}\``;
|
|
187
|
+
if (parsed.testIdxs.length) cells[parsed.testIdxs[0]] = `\`${test}\``;
|
|
188
|
+
cells[parsed.statusIdx] = '⚠️ auto-added — verify';
|
|
189
|
+
return `| ${cells.join(' | ')} |`;
|
|
190
|
+
};
|
|
191
|
+
const addedRows = added.map(newRowFor);
|
|
192
|
+
const newBlock = [parsed.headerLine, parsed.sepLine, ...parsed.keep, ...addedRows].join('\n');
|
|
193
|
+
|
|
194
|
+
// Splice the new block back into the original section text, then back into content.
|
|
195
|
+
const sectionLines = parsed.sectionLines.slice();
|
|
196
|
+
const before = sectionLines.slice(0, parsed.blockStartLine);
|
|
197
|
+
const after = sectionLines.slice(parsed.blockEndLine);
|
|
198
|
+
const newSection = [...before, newBlock, ...after].join('\n');
|
|
199
|
+
const oldSection = parsed.sectionLines.join('\n');
|
|
200
|
+
const newContent = content.slice(0, parsed.sectionTextStart) + newSection + content.slice(parsed.sectionTextStart + oldSection.length);
|
|
201
|
+
|
|
202
|
+
const changed = parsed.removed.length > 0 || added.length > 0;
|
|
203
|
+
return {
|
|
204
|
+
applicable: true,
|
|
205
|
+
removed: parsed.removed,
|
|
206
|
+
added,
|
|
207
|
+
ghostTests: parsed.ghostTests,
|
|
208
|
+
newContent: changed ? newContent : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export function runSyncTests(projectDir, config, flags) {
|
|
215
|
+
const apply = !!flags.write;
|
|
216
|
+
const isJson = flags.format === 'json';
|
|
217
|
+
const docPath = resolve(projectDir, TEST_SPEC_DOC);
|
|
218
|
+
|
|
219
|
+
if (!existsSync(docPath)) {
|
|
220
|
+
if (isJson) { console.log(JSON.stringify({ applicable: false, reason: 'TEST-SPEC.md not present' }, null, 2)); return; }
|
|
221
|
+
console.log(`${c.yellow}TEST-SPEC.md not found — run ${c.cyan}docguard init${c.yellow} first.${c.reset}\n`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const content = readFileSync(docPath, 'utf-8');
|
|
226
|
+
const r = reconcileTestMap(content, projectDir, config);
|
|
227
|
+
|
|
228
|
+
if (isJson) {
|
|
229
|
+
console.log(JSON.stringify({
|
|
230
|
+
applicable: r.applicable, applied: apply && !!r.newContent,
|
|
231
|
+
removed: r.removed, added: r.added, ghostTests: r.ghostTests, reason: r.reason || null,
|
|
232
|
+
}, null, 2));
|
|
233
|
+
if (apply && r.newContent) writeFileSync(docPath, r.newContent, 'utf-8');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`${c.bold}🔄 DocGuard Sync --tests — ${config.projectName}${c.reset}`);
|
|
238
|
+
console.log(`${c.dim} ${TEST_SPEC_DOC} · ${apply ? 'Applying' : 'Dry run (use --write to apply)'}${c.reset}\n`);
|
|
239
|
+
|
|
240
|
+
if (!r.applicable) {
|
|
241
|
+
console.log(` ${c.yellow}No Source-to-Test Map table found in TEST-SPEC.md.${c.reset}`);
|
|
242
|
+
console.log(` ${c.dim}Add a "## Source-to-Test Map" table (col 1 = source, last col = status), then re-run.${c.reset}\n`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (r.removed.length === 0 && r.added.length === 0 && r.ghostTests.length === 0) {
|
|
247
|
+
console.log(` ${c.green}✅ Source-to-Test Map matches disk — nothing to reconcile.${c.reset}\n`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (r.removed.length) {
|
|
252
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? '✅ Removed' : '• Remove'} ${r.removed.length} ghost-source row(s) (source file deleted):${c.reset}`);
|
|
253
|
+
for (const x of r.removed) console.log(` ${c.dim}- ${x.source}${c.reset}`);
|
|
254
|
+
}
|
|
255
|
+
if (r.added.length) {
|
|
256
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? '✅ Added' : '• Add'} ${r.added.length} newly-covered source(s):${c.reset}`);
|
|
257
|
+
for (const x of r.added) console.log(` ${c.dim}+ ${x.source} → ${x.test}${c.reset}`);
|
|
258
|
+
}
|
|
259
|
+
if (r.ghostTests.length) {
|
|
260
|
+
console.log(` ${c.yellow}⚠ ${r.ghostTests.length} ghost test reference(s) (source exists, test file gone) — fix by hand:${c.reset}`);
|
|
261
|
+
for (const x of r.ghostTests) console.log(` ${c.dim}~ ${x.source} → ${x.test} (missing)${c.reset}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (apply && r.newContent) {
|
|
265
|
+
writeFileSync(docPath, r.newContent, 'utf-8');
|
|
266
|
+
console.log(`\n ${c.green}↻ ${TEST_SPEC_DOC} updated. Review the ⚠️ auto-added rows, then ${c.cyan}docguard guard${c.green}.${c.reset}\n`);
|
|
267
|
+
} else if (!apply) {
|
|
268
|
+
console.log(`\n ${c.dim}Apply: ${c.cyan}docguard sync --tests --write${c.reset}\n`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log('');
|
|
271
|
+
}
|
|
272
|
+
}
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { c } from '../shared.mjs';
|
|
|
20
20
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
21
21
|
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
22
22
|
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
23
|
+
import { runSyncTests } from './sync-tests.mjs';
|
|
23
24
|
|
|
24
25
|
function gitChangedFiles(projectDir, since) {
|
|
25
26
|
const run = (args) => {
|
|
@@ -77,6 +78,11 @@ function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export function runSync(projectDir, config, flags) {
|
|
81
|
+
// v0.28 (field report #10): `--tests` reconciles the hand-maintained TEST-SPEC
|
|
82
|
+
// Source-to-Test Map from disk (ghost-source removal + new co-located pairs) —
|
|
83
|
+
// a distinct path from the generated code-truth section refresh below.
|
|
84
|
+
if (flags.tests) return runSyncTests(projectDir, config, flags);
|
|
85
|
+
|
|
80
86
|
const plan = buildMemoryPlan(projectDir, config);
|
|
81
87
|
const apply = !!flags.write;
|
|
82
88
|
const isJson = flags.format === 'json';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify Command — `docguard verify --semantic` (LLM field report #5).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the semantic claims in the canonical docs (documented numbers, limits,
|
|
5
|
+
* and enums) as a structured verification task list for the agent to check
|
|
6
|
+
* against the code. DocGuard does the deterministic discovery; the LLM does the
|
|
7
|
+
* judgment — the same division of labour as `docguard agent`.
|
|
8
|
+
*
|
|
9
|
+
* Read-only. JSON is the machine artifact (the agent-executable task list);
|
|
10
|
+
* text is the human summary.
|
|
11
|
+
*
|
|
12
|
+
* docguard verify [--semantic] [--format json]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { c } from '../shared.mjs';
|
|
16
|
+
import { detectAgentMode } from '../ensure-skills.mjs';
|
|
17
|
+
import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
|
|
18
|
+
|
|
19
|
+
export function runVerify(projectDir, config, flags) {
|
|
20
|
+
const isJson = flags.format === 'json';
|
|
21
|
+
const claims = extractSemanticClaims(projectDir, config);
|
|
22
|
+
const tasks = buildSemanticVerifyTasks(claims);
|
|
23
|
+
|
|
24
|
+
if (isJson) {
|
|
25
|
+
console.log(JSON.stringify({
|
|
26
|
+
command: 'verify --semantic',
|
|
27
|
+
project: config.projectName,
|
|
28
|
+
claimCount: tasks.length,
|
|
29
|
+
// How to act on this: each task is a claim to confirm against the code.
|
|
30
|
+
howToVerify: 'For each task, read the cited code (or grep for the constant/config), compare it to the documented value, and report any mismatch with both values. DocGuard cannot judge these — they require reading the code.',
|
|
31
|
+
tasks,
|
|
32
|
+
}, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`${c.bold}🔬 DocGuard Verify — semantic claims${c.reset}`);
|
|
37
|
+
console.log(`${c.dim} ${config.projectName} · documented numbers / limits / enums to check against code${c.reset}\n`);
|
|
38
|
+
|
|
39
|
+
if (tasks.length === 0) {
|
|
40
|
+
console.log(` ${c.green}✅ No semantic claims found in the canonical docs.${c.reset}`);
|
|
41
|
+
console.log(` ${c.dim}(Looks for numbers with units — days/ms/req-s/GSIs/roles/… — and status/enum lists.)${c.reset}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Group by doc for a readable summary.
|
|
46
|
+
const byDoc = new Map();
|
|
47
|
+
for (const t of tasks) {
|
|
48
|
+
if (!byDoc.has(t.doc)) byDoc.set(t.doc, []);
|
|
49
|
+
byDoc.get(t.doc).push(t);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
|
|
53
|
+
for (const [doc, ts] of byDoc) {
|
|
54
|
+
console.log(` ${c.bold}${doc}${c.reset}`);
|
|
55
|
+
for (const t of ts) {
|
|
56
|
+
const val = t.kind === 'enum' ? `enum ${t.value}` : `${t.value}${t.unit ? ` ${t.unit}` : ''}`;
|
|
57
|
+
const cited = t.citedCode ? `${c.cyan}${t.citedCode}${c.reset}` : `${c.dim}(no cited code — grep for it)${c.reset}`;
|
|
58
|
+
console.log(` ${c.yellow}•${c.reset} L${t.line} ${c.dim}${t.section ? `[${t.section}] ` : ''}${c.reset}${c.bold}${val}${c.reset} → check ${cited}`);
|
|
59
|
+
}
|
|
60
|
+
console.log('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mode = detectAgentMode(projectDir);
|
|
64
|
+
const cmd = mode === 'llm' ? '/docguard.verify' : 'docguard verify --semantic --format json';
|
|
65
|
+
console.log(` ${c.dim}This is the highest-value bug class and DocGuard can't judge it — an agent must.${c.reset}`);
|
|
66
|
+
console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then read each cited file and confirm the value.${c.reset}\n`);
|
|
67
|
+
}
|
package/cli/docguard.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { runUpgrade } from './commands/upgrade.mjs';
|
|
|
44
44
|
import { runImpact } from './commands/impact.mjs';
|
|
45
45
|
import { runExplain } from './commands/explain.mjs';
|
|
46
46
|
import { runFeedback } from './commands/feedback.mjs';
|
|
47
|
+
import { runVerify } from './commands/verify.mjs';
|
|
47
48
|
import { runMemory } from './commands/memory.mjs';
|
|
48
49
|
import { runDemo } from './commands/demo.mjs';
|
|
49
50
|
import { runAgent } from './commands/agent.mjs';
|
|
@@ -87,6 +88,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
87
88
|
${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
|
|
88
89
|
${c.green}agent${c.reset} One-shot agent task graph — ordered tasks, pre-filled code-truth, per-task verify (${c.cyan}--format json${c.reset})
|
|
89
90
|
${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
|
|
91
|
+
${c.green}verify${c.reset} Extract documented numbers/limits/enums for an agent to check vs code (${c.cyan}--semantic${c.reset})
|
|
90
92
|
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
91
93
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
92
94
|
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
|
|
@@ -232,13 +234,14 @@ const COMMAND_HELP = {
|
|
|
232
234
|
examples: ['docguard diff', 'docguard diff --since HEAD~5'],
|
|
233
235
|
},
|
|
234
236
|
sync: {
|
|
235
|
-
summary: 'Refresh code-truth doc sections (preview by default).',
|
|
236
|
-
usage: 'docguard sync [--write] [--since <ref>]',
|
|
237
|
+
summary: 'Refresh code-truth doc sections (preview by default). `--tests` reconciles the TEST-SPEC Source-to-Test Map from disk.',
|
|
238
|
+
usage: 'docguard sync [--write] [--since <ref>] [--tests]',
|
|
237
239
|
flags: [
|
|
238
240
|
['--write', 'Apply the refresh (default is a dry-run preview)'],
|
|
239
241
|
['--since <ref>', 'Only sync sections whose source files changed since <ref>'],
|
|
242
|
+
['--tests', 'Reconcile the TEST-SPEC Source-to-Test Map: drop ghost-source rows, append newly-covered source↔test pairs (report ghost tests). Pair with --write to apply.'],
|
|
240
243
|
],
|
|
241
|
-
examples: ['docguard sync', 'docguard sync --write'],
|
|
244
|
+
examples: ['docguard sync', 'docguard sync --write', 'docguard sync --tests', 'docguard sync --tests --write'],
|
|
242
245
|
},
|
|
243
246
|
fix: {
|
|
244
247
|
summary: 'Generate AI fix instructions for docs (or apply deterministic fixes).',
|
|
@@ -287,6 +290,15 @@ const COMMAND_HELP = {
|
|
|
287
290
|
flags: [['--format json', 'Machine-readable list of reportable findings + URLs']],
|
|
288
291
|
examples: ['docguard feedback'],
|
|
289
292
|
},
|
|
293
|
+
verify: {
|
|
294
|
+
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.',
|
|
295
|
+
usage: 'docguard verify [--semantic] [--format json]',
|
|
296
|
+
flags: [
|
|
297
|
+
['--semantic', 'Extract documented numbers/limits/enums to verify against code (the current — and default — mode)'],
|
|
298
|
+
['--format json', 'Machine-readable task list (the agent-executable artifact)'],
|
|
299
|
+
],
|
|
300
|
+
examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
|
|
301
|
+
},
|
|
290
302
|
};
|
|
291
303
|
|
|
292
304
|
function printCommandHelp(command) {
|
|
@@ -355,6 +367,14 @@ async function main() {
|
|
|
355
367
|
flags.auto = true;
|
|
356
368
|
} else if (args[i] === '--write') {
|
|
357
369
|
flags.write = true;
|
|
370
|
+
} else if (args[i] === '--tests') {
|
|
371
|
+
// v0.28 (field report #10): `docguard sync --tests` reconciles the
|
|
372
|
+
// TEST-SPEC Source-to-Test Map from disk.
|
|
373
|
+
flags.tests = true;
|
|
374
|
+
} else if (args[i] === '--semantic') {
|
|
375
|
+
// v0.28 (field report #5): `docguard verify --semantic` extracts
|
|
376
|
+
// documented numbers/enums/limits for the agent to check against code.
|
|
377
|
+
flags.semantic = true;
|
|
358
378
|
} else if (args[i] === '--plan') {
|
|
359
379
|
flags.plan = true;
|
|
360
380
|
} else if (args[i] === '--since' && args[i + 1]) {
|
|
@@ -518,6 +538,8 @@ async function main() {
|
|
|
518
538
|
// feedback only writes its own .docguard/feedback/ — it must NOT scaffold
|
|
519
539
|
// skills or touch source, so it's gated out of ensureSkills like the rest.
|
|
520
540
|
'feedback',
|
|
541
|
+
// verify only reads docs and emits a task list — pure report.
|
|
542
|
+
'verify',
|
|
521
543
|
]);
|
|
522
544
|
|
|
523
545
|
// Silent auto-check: install skills/commands if missing. Skip entirely in
|
|
@@ -663,6 +685,12 @@ async function main() {
|
|
|
663
685
|
// redacted, capped GitHub issue URL. Opt-in; nothing filed automatically.
|
|
664
686
|
runFeedback(projectDir, config, flags);
|
|
665
687
|
break;
|
|
688
|
+
case 'verify':
|
|
689
|
+
// v0.28 (field report #5): extract documented numbers/limits/enums as a
|
|
690
|
+
// verification task list for the agent to check against code (semantic
|
|
691
|
+
// drift — the class regex/AST can't see). Read-only.
|
|
692
|
+
runVerify(projectDir, config, flags);
|
|
693
|
+
break;
|
|
666
694
|
case 'memory':
|
|
667
695
|
runMemory(projectDir, config, flags);
|
|
668
696
|
break;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic claim extractor (LLM field report #5).
|
|
3
|
+
*
|
|
4
|
+
* The highest-value class of doc bug is SEMANTIC: a documented number/enum/limit
|
|
5
|
+
* that no longer matches the code — DLP retention "30 days" vs code 730, a status
|
|
6
|
+
* enum "PENDING/IDLE" vs "WAITING", "100/min" vs "500 req/s", "29+ roles" vs 44,
|
|
7
|
+
* "4 GSIs" vs 6. Regex/AST can't judge these (the doc value and the code value
|
|
8
|
+
* are both just numbers), so they slip through every deterministic validator.
|
|
9
|
+
*
|
|
10
|
+
* DocGuard is zero-dependency and does NOT call an LLM itself. So this is an
|
|
11
|
+
* EXTRACTOR: it surfaces the verifiable claims — value, unit, doc:line, section,
|
|
12
|
+
* and the nearest cited code path — as a structured task list. The agent running
|
|
13
|
+
* `docguard verify --semantic` does the actual comparison against the code. This
|
|
14
|
+
* mirrors the `docguard agent` task-graph: deterministic discovery, LLM judgment.
|
|
15
|
+
*
|
|
16
|
+
* Precision over recall: a number is only a claim when it carries a recognized
|
|
17
|
+
* unit (days/ms/req-s/GSIs/roles/…); an enum only when it's a list of 2+
|
|
18
|
+
* UPPER_SNAKE tokens in a status/state/enum context. Bare version strings, dates,
|
|
19
|
+
* and prose numbers are ignored.
|
|
20
|
+
*
|
|
21
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { resolve, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
// Numbers are only claims when adjacent to a recognized unit.
|
|
28
|
+
const NUMBER_PATTERNS = [
|
|
29
|
+
{ kind: 'duration', re: /\b(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\b/gi },
|
|
30
|
+
{ kind: 'rate', re: /\b(\d+)\s*(?:\/|\bper\b|\breq(?:uests?)?\s*\/?)\s*(s|sec|seconds?|min|minutes?|hours?|h)\b/gi },
|
|
31
|
+
{ kind: 'count', re: /\b(\d+)\s*\+?\s*(GSIs?|LSIs?|indexes|indices|roles?|permissions?|scopes?|tables?|queues?|topics?|buckets?|endpoints?|routes?|validators?|columns?|fields?|shards?|partitions?|replicas?|retries|workers?|threads?|connections?)\b/gi },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// A list of 2+ UPPER_SNAKE tokens separated by / , | or "or" — an enum claim,
|
|
35
|
+
// but only when the line or its heading reads like a status/state/enum context.
|
|
36
|
+
const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
|
|
37
|
+
const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
|
|
38
|
+
|
|
39
|
+
// A code path mentioned in or near the claim — the agent's starting point.
|
|
40
|
+
const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
|
|
41
|
+
|
|
42
|
+
const MAX_CLAIMS = 80;
|
|
43
|
+
|
|
44
|
+
/** Canonical docs + the root docs where limits/counts commonly live. */
|
|
45
|
+
function claimSourceDocs(projectDir) {
|
|
46
|
+
const docs = [];
|
|
47
|
+
const canonical = resolve(projectDir, 'docs-canonical');
|
|
48
|
+
if (existsSync(canonical)) {
|
|
49
|
+
try {
|
|
50
|
+
for (const f of readdirSync(canonical)) {
|
|
51
|
+
if (f.toLowerCase().endsWith('.md')) docs.push(`docs-canonical/${f}`);
|
|
52
|
+
}
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
for (const root of ['README.md', 'AGENTS.md']) {
|
|
56
|
+
if (existsSync(resolve(projectDir, root))) docs.push(root);
|
|
57
|
+
}
|
|
58
|
+
return docs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True if a line is inside a fenced code block (toggled by the caller). */
|
|
62
|
+
function findCitedCode(lines, idx) {
|
|
63
|
+
// Search the claim line first, then the immediately adjacent lines. A tight
|
|
64
|
+
// window avoids cross-attributing a path from an unrelated nearby claim (e.g.
|
|
65
|
+
// a rate limit grabbing the retention doc's cited file three lines up).
|
|
66
|
+
for (let d = 0; d <= 1; d++) {
|
|
67
|
+
for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
|
|
68
|
+
if (j < 0 || j >= lines.length) continue;
|
|
69
|
+
const m = CITED_CODE_RE.exec(lines[j]);
|
|
70
|
+
if (m) return m[2] ? `${m[1]}:${m[2]}` : m[1];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract semantic claims from a project's canonical docs.
|
|
78
|
+
* @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
|
|
79
|
+
*/
|
|
80
|
+
export function extractSemanticClaims(projectDir, config = {}) {
|
|
81
|
+
const claims = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
|
|
84
|
+
for (const doc of claimSourceDocs(projectDir)) {
|
|
85
|
+
let content;
|
|
86
|
+
try { content = readFileSync(resolve(projectDir, doc), 'utf-8'); } catch { continue; }
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
let section = '';
|
|
89
|
+
let inFence = false;
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
const line = lines[i];
|
|
93
|
+
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
|
|
94
|
+
if (inFence) continue; // numbers in code samples are examples, not claims
|
|
95
|
+
const h = line.match(/^#{1,6}\s+(.*)$/);
|
|
96
|
+
if (h) { section = h[1].trim(); continue; }
|
|
97
|
+
|
|
98
|
+
const lineNo = i + 1;
|
|
99
|
+
const push = (claim) => {
|
|
100
|
+
const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
|
|
101
|
+
if (seen.has(key)) return;
|
|
102
|
+
seen.add(key);
|
|
103
|
+
claims.push({ doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (const { kind, re } of NUMBER_PATTERNS) {
|
|
107
|
+
re.lastIndex = 0;
|
|
108
|
+
let m;
|
|
109
|
+
while ((m = re.exec(line)) !== null) {
|
|
110
|
+
push({ kind: 'number', subkind: kind, value: m[1], unit: m[2].toLowerCase() });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (ENUM_CONTEXT_RE.test(line) || ENUM_CONTEXT_RE.test(section)) {
|
|
115
|
+
ENUM_LIST_RE.lastIndex = 0;
|
|
116
|
+
let m;
|
|
117
|
+
while ((m = ENUM_LIST_RE.exec(line)) !== null) {
|
|
118
|
+
// Skip all-caps acronym runs joined by slash that are really one token.
|
|
119
|
+
const values = m[0].split(/\s*(?:\/|,|\||\bor\b)\s*/).filter(Boolean);
|
|
120
|
+
if (values.length >= 2) push({ kind: 'enum', subkind: 'enum-list', value: values.join('/'), unit: null });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (claims.length >= MAX_CLAIMS) return claims;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return claims;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Turn extracted claims into agent-executable verification tasks (one per claim).
|
|
132
|
+
* Pure — reused by the command and any task-graph consumer.
|
|
133
|
+
*/
|
|
134
|
+
export function buildSemanticVerifyTasks(claims) {
|
|
135
|
+
return claims.map((c, i) => {
|
|
136
|
+
const where = c.citedCode ? ` Start at the cited code: ${c.citedCode}.` : ' No code path is cited nearby — grep the codebase for the relevant constant/config.';
|
|
137
|
+
const what = c.kind === 'enum'
|
|
138
|
+
? `the enum/status set "${c.value}"`
|
|
139
|
+
: `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
|
|
140
|
+
return {
|
|
141
|
+
id: `verify.semantic.${i + 1}`,
|
|
142
|
+
doc: c.doc,
|
|
143
|
+
line: c.line,
|
|
144
|
+
section: c.section,
|
|
145
|
+
kind: c.kind,
|
|
146
|
+
value: c.value,
|
|
147
|
+
unit: c.unit,
|
|
148
|
+
citedCode: c.citedCode,
|
|
149
|
+
claim: c.text,
|
|
150
|
+
instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
|
|
151
|
+
confidence: 'requires-human',
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -208,10 +208,56 @@ export function computeApiSurfaceDrift(projectDir, config) {
|
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* v0.28 (field report #4): diff the OpenAPI spec against the routes actually
|
|
213
|
+
* REGISTERED in code. When a spec exists, resolveApiSurface treats it as ground
|
|
214
|
+
* truth and the API-REFERENCE doc reconciles against it — so a spec that declares
|
|
215
|
+
* a phantom endpoint (no Express/Fastify route registers it) passes doc-vs-spec
|
|
216
|
+
* clean while the spec itself is wrong. This catches that.
|
|
217
|
+
*
|
|
218
|
+
* Conservative on purpose: only runs when code routes are actually scannable.
|
|
219
|
+
* If the scanner finds zero routes (unsupported framework, dynamically-registered
|
|
220
|
+
* routes), we can't tell "no route" from "scanner blind", so we skip rather than
|
|
221
|
+
* flag every spec endpoint as phantom. Reuses compareEndpoints so path-param /
|
|
222
|
+
* mount-prefix normalization matches the rest of the validator.
|
|
223
|
+
*
|
|
224
|
+
* @returns {{ applicable:boolean, specPath:string|null, routeCount:number,
|
|
225
|
+
* matched:object[], specDeclaredNoRoute:object[], reason?:string }}
|
|
226
|
+
*/
|
|
227
|
+
export function computeSpecVsRouteDrift(projectDir, config) {
|
|
228
|
+
const specs = findAllOpenApiSpecs(projectDir, config);
|
|
229
|
+
if (specs.length === 0) {
|
|
230
|
+
return { applicable: false, specPath: null, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no openapi spec' };
|
|
231
|
+
}
|
|
232
|
+
const spec = specs[0]; // authoritative (sourceRoot first, root last)
|
|
233
|
+
const framework = detectFramework(projectDir, config);
|
|
234
|
+
const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
|
|
235
|
+
if (routes.length === 0) {
|
|
236
|
+
return { applicable: false, specPath: spec.relPath, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no routes scannable' };
|
|
237
|
+
}
|
|
238
|
+
// documentedButAbsent = in the SPEC (first arg) but absent from the ROUTES
|
|
239
|
+
// (second arg) = spec-declares-but-no-route.
|
|
240
|
+
const cmp = compareEndpoints(
|
|
241
|
+
spec.endpoints.map(e => ({ method: e.method, path: e.path })),
|
|
242
|
+
routes.map(r => ({ method: r.method, path: r.path }))
|
|
243
|
+
);
|
|
244
|
+
return {
|
|
245
|
+
applicable: true,
|
|
246
|
+
specPath: spec.relPath,
|
|
247
|
+
routeCount: routes.length,
|
|
248
|
+
matched: cmp.matched,
|
|
249
|
+
specDeclaredNoRoute: cmp.documentedButAbsent,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
211
253
|
export function validateApiSurface(projectDir, config) {
|
|
212
254
|
const errors = [];
|
|
213
255
|
const warnings = [];
|
|
214
256
|
const fixes = [];
|
|
257
|
+
const trim = (arr) => {
|
|
258
|
+
const shown = arr.slice(0, MAX_REPORTED);
|
|
259
|
+
return { shown, extra: arr.length - shown.length };
|
|
260
|
+
};
|
|
215
261
|
|
|
216
262
|
// v0.14-P2: when --changed-only scoping is active and NONE of the changed
|
|
217
263
|
// files look like route/spec/controller files, this validator has nothing
|
|
@@ -256,19 +302,39 @@ export function validateApiSurface(projectDir, config) {
|
|
|
256
302
|
);
|
|
257
303
|
}
|
|
258
304
|
|
|
305
|
+
// ── #4: spec declares an endpoint with no registered route ──
|
|
306
|
+
// Independent of the API-REFERENCE doc — it checks the spec against code, so it
|
|
307
|
+
// runs even when no doc exists. Conservative (only when routes are scannable).
|
|
308
|
+
const specRoute = computeSpecVsRouteDrift(projectDir, config);
|
|
309
|
+
let specRouteTotal = 0;
|
|
310
|
+
let specRoutePassed = 0;
|
|
311
|
+
if (specRoute.applicable) {
|
|
312
|
+
specRouteTotal = specRoute.matched.length + specRoute.specDeclaredNoRoute.length;
|
|
313
|
+
specRoutePassed = specRoute.matched.length;
|
|
314
|
+
if (specRoute.specDeclaredNoRoute.length) {
|
|
315
|
+
const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
|
|
316
|
+
for (const e of shown) {
|
|
317
|
+
warnings.push(
|
|
318
|
+
`OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
|
|
319
|
+
`the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (extra > 0) warnings.push(`…and ${extra} more spec-declared endpoint(s) with no registered route`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
259
326
|
if (!drift.applicable) {
|
|
260
|
-
// Nothing to validate against the API-REFERENCE doc
|
|
261
|
-
|
|
327
|
+
// Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
|
|
328
|
+
// check above may still have produced findings.
|
|
329
|
+
return {
|
|
330
|
+
errors, warnings, passed: specRoutePassed, total: specRouteTotal, fixes,
|
|
331
|
+
authoritativeSpec: drift.source || specRoute.specPath,
|
|
332
|
+
};
|
|
262
333
|
}
|
|
263
334
|
|
|
264
335
|
const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
|
|
265
|
-
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
|
|
266
|
-
const passed = matched.length;
|
|
267
|
-
|
|
268
|
-
const trim = (arr) => {
|
|
269
|
-
const shown = arr.slice(0, MAX_REPORTED);
|
|
270
|
-
return { shown, extra: arr.length - shown.length };
|
|
271
|
-
};
|
|
336
|
+
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length + specRouteTotal;
|
|
337
|
+
const passed = matched.length + specRoutePassed;
|
|
272
338
|
|
|
273
339
|
// documented-but-absent → deterministic remove-endpoint fixes
|
|
274
340
|
if (documentedButAbsent.length) {
|
|
@@ -97,11 +97,11 @@ function validateConfigLayers(projectDir, config, layers, results) {
|
|
|
97
97
|
const relPath = relative(projectDir, file);
|
|
98
98
|
const imports = extractImports(content);
|
|
99
99
|
|
|
100
|
-
for (const
|
|
101
|
-
if (!
|
|
100
|
+
for (const { spec } of imports) {
|
|
101
|
+
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
102
102
|
|
|
103
103
|
for (const forbiddenDir of layer.forbidden) {
|
|
104
|
-
if (
|
|
104
|
+
if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
|
|
105
105
|
results.total++;
|
|
106
106
|
results.errors.push(
|
|
107
107
|
`${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`
|
|
@@ -135,14 +135,19 @@ function buildImportGraph(projectDir, config) {
|
|
|
135
135
|
|
|
136
136
|
const resolvedImports = [];
|
|
137
137
|
for (const imp of imports) {
|
|
138
|
-
if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
|
|
138
|
+
if (!imp.spec.startsWith('.') && !imp.spec.startsWith('/')) continue;
|
|
139
139
|
|
|
140
140
|
// Resolve relative imports
|
|
141
141
|
const fromDir = dirname(file);
|
|
142
|
-
const resolved = resolveImport(fromDir, imp, projectDir);
|
|
142
|
+
const resolved = resolveImport(fromDir, imp.spec, projectDir);
|
|
143
143
|
if (resolved) {
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic });
|
|
145
|
+
// v0.28 (field report #2): a dynamic `await import()` does NOT create a
|
|
146
|
+
// load-time edge — it's the canonical way to BREAK an import cycle. So
|
|
147
|
+
// it's excluded from the cycle-detection adjacency (fileMap) while still
|
|
148
|
+
// recorded in graph.edges for layer-boundary checks (an import is still
|
|
149
|
+
// an import for layering).
|
|
150
|
+
if (!imp.dynamic) resolvedImports.push(resolved);
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -153,26 +158,33 @@ function buildImportGraph(projectDir, config) {
|
|
|
153
158
|
return graph;
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Extract a file's imports as `{ spec, dynamic }`. `dynamic:true` marks a
|
|
163
|
+
* runtime `import('…')` — which does NOT create a load-time dependency edge and
|
|
164
|
+
* is the canonical way to break an import cycle (field report #2). ES `import …
|
|
165
|
+
* from` and CommonJS `require()` are load-time (static).
|
|
166
|
+
*/
|
|
156
167
|
function extractImports(content) {
|
|
157
168
|
const imports = [];
|
|
158
169
|
|
|
159
|
-
// ES module imports
|
|
170
|
+
// ES module imports (static, load-time). `import\s+` requires whitespace after
|
|
171
|
+
// `import`, so it never matches a dynamic `import(` call.
|
|
160
172
|
const esImportRegex = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
161
173
|
let match;
|
|
162
174
|
while ((match = esImportRegex.exec(content)) !== null) {
|
|
163
|
-
imports.push(match[1]);
|
|
175
|
+
imports.push({ spec: match[1], dynamic: false });
|
|
164
176
|
}
|
|
165
177
|
|
|
166
|
-
// Dynamic imports
|
|
178
|
+
// Dynamic imports (runtime — NOT a load-time cycle edge)
|
|
167
179
|
const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
168
180
|
while ((match = dynamicRegex.exec(content)) !== null) {
|
|
169
|
-
imports.push(match[1]);
|
|
181
|
+
imports.push({ spec: match[1], dynamic: true });
|
|
170
182
|
}
|
|
171
183
|
|
|
172
|
-
// CommonJS require
|
|
184
|
+
// CommonJS require (static, load-time)
|
|
173
185
|
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
174
186
|
while ((match = requireRegex.exec(content)) !== null) {
|
|
175
|
-
imports.push(match[1]);
|
|
187
|
+
imports.push({ spec: match[1], dynamic: false });
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
return imports;
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.28.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.28.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.28.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Fix Skill
|
|
15
15
|
|
|
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
|
|
|
7
7
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
8
8
|
metadata:
|
|
9
9
|
author: docguard
|
|
10
|
-
version: 0.
|
|
10
|
+
version: 0.28.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.28.0 -->
|
|
14
14
|
|
|
15
15
|
# DocGuard Guard Skill
|
|
16
16
|
|
|
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.28.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.28.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Review Skill
|
|
15
15
|
|
|
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.28.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.28.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
|
|
|
4
4
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
5
5
|
metadata:
|
|
6
6
|
author: docguard
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.28.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
|
-
<!-- docguard:version: 0.
|
|
10
|
+
<!-- docguard:version: 0.28.0 -->
|
|
11
11
|
|
|
12
12
|
# DocGuard Sync Skill
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Environment & Configuration
|
|
2
2
|
|
|
3
|
+
<!-- docguard:version 0.1.0 -->
|
|
4
|
+
<!-- docguard:status draft -->
|
|
5
|
+
<!-- docguard:last-reviewed YYYY-MM-DD -->
|
|
6
|
+
<!-- docguard:owner @your-github-username -->
|
|
7
|
+
|
|
3
8
|
> **Canonical document** — Design intent. This file documents everything needed to run this project.
|
|
4
9
|
> Last updated: YYYY-MM-DD
|
|
5
10
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- docguard:version 0.1.0 -->
|
|
4
4
|
<!-- docguard:status draft -->
|
|
5
|
+
<!-- docguard:last-reviewed YYYY-MM-DD -->
|
|
6
|
+
<!-- docguard:owner @your-github-username -->
|
|
5
7
|
|
|
6
8
|
> Tracks functional requirements, non-functional requirements, and success criteria.
|
|
7
9
|
> Use requirement IDs (FR-001, NFR-001, SC-001) for traceability back to code and tests.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Security
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- docguard:version 0.1.0 -->
|
|
4
|
+
<!-- docguard:status draft -->
|
|
5
|
+
<!-- docguard:last-reviewed YYYY-MM-DD -->
|
|
6
|
+
<!-- docguard:owner @your-github-username -->
|
|
7
|
+
|
|
8
|
+
> **Canonical document** — Design intent. This file defines the security model.
|
|
4
9
|
> Last updated: YYYY-MM-DD
|
|
5
10
|
|
|
6
11
|
---
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Test Specification
|
|
2
2
|
|
|
3
|
+
<!-- docguard:version 0.1.0 -->
|
|
4
|
+
<!-- docguard:status draft -->
|
|
5
|
+
<!-- docguard:last-reviewed YYYY-MM-DD -->
|
|
6
|
+
<!-- docguard:owner @your-github-username -->
|
|
7
|
+
|
|
3
8
|
> **Canonical document** — Design intent. This file declares what tests MUST exist.
|
|
4
9
|
> Last updated: YYYY-MM-DD
|
|
5
10
|
|