docguard-cli 0.31.0 → 0.32.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.
@@ -27,14 +27,24 @@
27
27
  * mode (b) ("literal deleted but logic remains") is bounded by compound
28
28
  * shape + the two-revision gate.
29
29
  * All findings are confidence:'low' / soft — "verify", never a hard failure.
30
+ *
31
+ * REF002 (ADR citations, the code→doc direction): a code comment citing
32
+ * `ADR-NNN` is a reference into the docs — if no ADR document defines that
33
+ * number, the citation is stale (renumbered, removed, or never written).
34
+ * ADRs have no external registry, so a missing number is a real signal.
35
+ * RFC citations are deliberately OUT of scope: `RFC 793` in a comment almost
36
+ * always cites the IETF registry (external, unverifiable) and would
37
+ * false-positive on every network stack. Numbers compare as integers, so
38
+ * `ADR-00NN` in code matches `ADR-NN` in docs.
30
39
  */
31
40
 
32
41
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
33
- import { resolve, extname } from 'node:path';
42
+ import { resolve, extname, relative, basename } from 'node:path';
34
43
  import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
35
- import { walkFiles } from '../shared-ignore.mjs';
44
+ import { walkFiles, isNonProductPath } from '../shared-ignore.mjs';
36
45
  import { readScannable } from '../shared-source.mjs';
37
- import { mkFinding, resultFromFindings } from '../findings.mjs';
46
+ import { resolveDocDirs } from '../shared.mjs';
47
+ import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
38
48
 
39
49
  const CODE_EXT = new Set([
40
50
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java',
@@ -51,17 +61,93 @@ function isCodeIdentifier(s) {
51
61
  return COMPOUND.test(s);
52
62
  }
53
63
 
54
- // One pass over the current source tree → Set of every whole identifier present.
55
- function buildHeadIdentifierSet(projectDir) {
56
- const set = new Set();
57
- walkFiles(projectDir, (full) => {
58
- if (!CODE_EXT.has(extname(full))) return;
59
- const content = readScannable(full);
60
- if (!content) return;
61
- const m = content.match(IDENT);
62
- if (m) for (const id of m) set.add(id);
63
- });
64
- return set;
64
+ // ── REF002 helpers ──────────────────────────────────────────────────────────
65
+
66
+ // Uppercase-only by design: teams cite decision records as "ADR-NNN" / "ADR NN";
67
+ // lowercase "adr" is too often an abbreviation for something else.
68
+ const ADR_CITE_SRC = String.raw`\bADR[- ]?(\d{1,5})\b`;
69
+
70
+ /**
71
+ * Index of the comment portion of a code line, or -1 when the line has no
72
+ * comment. A citation only counts when it sits inside a comment — `ADR-NN`
73
+ * in a string literal or identifier is data, not a citation. Whole-line
74
+ * comments (incl. `*` block-comment continuations) count from column 0;
75
+ * trailing comments are recognised by their marker. ` # ` requires spacing so
76
+ * `"#fff"`-style literals don't read as Python/shell comments.
77
+ */
78
+ function commentIndex(line) {
79
+ const t = line.trimStart();
80
+ if (/^(\/\/|\/\*|\*|#|--|<!--)/.test(t)) return 0;
81
+ let idx = -1;
82
+ for (const marker of ['//', '/*', '<!--', ' # ', ' -- ']) {
83
+ const i = line.indexOf(marker);
84
+ if (i >= 0 && (idx < 0 || i < idx)) idx = i;
85
+ }
86
+ return idx;
87
+ }
88
+
89
+ /** Collect ADR citations found in comments of one source file. */
90
+ function collectAdrCitations(content, relPath, out) {
91
+ const lines = content.split('\n');
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const line = lines[i];
94
+ if (!line.includes('ADR')) continue;
95
+ const ci = commentIndex(line);
96
+ if (ci < 0) continue;
97
+ const re = new RegExp(ADR_CITE_SRC, 'g'); // local: shared stateful g-regexes are a footgun
98
+ let m;
99
+ while ((m = re.exec(line)) !== null) {
100
+ if (m.index < ci) continue;
101
+ if (lineSuppresses('REF002', line, lines[i - 1] || '')) continue;
102
+ out.push({ num: parseInt(m[1], 10), raw: m[0], file: relPath, line: i + 1 });
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Discover which ADR numbers the project's docs actually define.
109
+ * Sources, in decreasing specificity:
110
+ * - files/dirs named after ADRs (`ADR.md`, `docs/adr/`, `docs/decisions/`):
111
+ * every ADR-NNN mention counts, plus madr-style numeric filenames
112
+ * (`0001-use-postgres.md`);
113
+ * - any other markdown in a doc home: heading lines only (`## ADR-NNN: …`),
114
+ * so a prose mention ("see ADR-NN") never counts as a definition.
115
+ */
116
+ function collectAdrNumbers(projectDir, config) {
117
+ const nums = new Set();
118
+ const addAll = (text) => {
119
+ for (const m of text.matchAll(new RegExp(ADR_CITE_SRC, 'g'))) nums.add(parseInt(m[1], 10));
120
+ };
121
+ const files = new Set();
122
+ try {
123
+ for (const f of readdirSync(projectDir)) {
124
+ if (f.endsWith('.md')) files.add(resolve(projectDir, f));
125
+ }
126
+ } catch { /* unreadable root */ }
127
+ for (const d of resolveDocDirs(projectDir, config)) {
128
+ const abs = resolve(projectDir, d);
129
+ if (!existsSync(abs)) continue;
130
+ walkFiles(abs, (full) => { if (full.endsWith('.md')) files.add(full); });
131
+ }
132
+ for (const full of files) {
133
+ let content;
134
+ try { content = readFileSync(full, 'utf-8'); } catch { continue; }
135
+ const base = basename(full);
136
+ const norm = full.replace(/\\/g, '/');
137
+ const isAdrHome = /adr/i.test(base) || /\/(adrs?|decisions?)\//i.test(norm);
138
+ const numericName = base.match(/^(\d{1,5})[-_.]/);
139
+ if (isAdrHome && numericName) nums.add(parseInt(numericName[1], 10));
140
+ const adrInName = base.match(/adr[-_ ]?(\d{1,5})/i);
141
+ if (adrInName) nums.add(parseInt(adrInName[1], 10));
142
+ if (isAdrHome) {
143
+ addAll(content);
144
+ } else {
145
+ for (const line of content.split('\n')) {
146
+ if (/^#{1,6}\s/.test(line)) addAll(line);
147
+ }
148
+ }
149
+ }
150
+ return nums;
65
151
  }
66
152
 
67
153
  function extractRefs(content) {
@@ -100,16 +186,44 @@ function indexDocs(projectDir) {
100
186
  export function validateReferenceExistence(projectDir, config = {}) {
101
187
  const cfg = config.referenceExistence || {};
102
188
  const maxRefsPerDoc = cfg.maxRefsPerDoc || 80;
189
+ const adrEnabled = cfg.adrCitations !== false;
103
190
 
104
191
  if (!isGitRepo(projectDir)) {
105
192
  return resultFromFindings([], { passed: 0, total: 0, applicable: false });
106
193
  }
107
194
  const docs = indexDocs(projectDir);
108
- if (docs.length === 0 || docs.every(d => d.refs.length === 0)) {
195
+ const needIds = docs.some(d => d.refs.length > 0);
196
+ if (!needIds && !adrEnabled) {
197
+ return resultFromFindings([], { passed: 0, total: 0, applicable: false });
198
+ }
199
+
200
+ // ONE walk over the source tree serves both checks: the REF001 identifier
201
+ // set and the REF002 ADR-citation scan. Each part is skipped when its check
202
+ // has nothing to do, so the walk stays as cheap as before for either alone.
203
+ const headIds = new Set();
204
+ const citations = [];
205
+ walkFiles(projectDir, (full) => {
206
+ if (!CODE_EXT.has(extname(full))) return;
207
+ const content = readScannable(full);
208
+ if (!content) return;
209
+ if (needIds) {
210
+ const m = content.match(IDENT);
211
+ if (m) for (const id of m) headIds.add(id);
212
+ }
213
+ if (adrEnabled && content.includes('ADR')) {
214
+ // Tests/fixtures/examples cite ADRs as fixture data, not as real
215
+ // citations — same non-product scoping the surface scanners use.
216
+ const rel = relative(projectDir, full);
217
+ if (!isNonProductPath(rel.replace(/\\/g, '/'), config)) {
218
+ collectAdrCitations(content, rel, citations);
219
+ }
220
+ }
221
+ });
222
+
223
+ if (!needIds && citations.length === 0) {
109
224
  return resultFromFindings([], { passed: 0, total: 0, applicable: false });
110
225
  }
111
226
 
112
- const headIds = buildHeadIdentifierSet(projectDir); // cheap gate, built once
113
227
  const revPresence = new Map(); // `${sym}\0${rev}` → bool
114
228
  // Authoritative HEAD absence (covers dot-dirs the walk skipped). Cached.
115
229
  const headAbsent = new Map();
@@ -147,9 +261,49 @@ export function validateReferenceExistence(projectDir, config = {}) {
147
261
  }
148
262
  }
149
263
 
264
+ // ── REF002: every distinct cited ADR number is one check ──
265
+ const MAX_ADR_FINDINGS = 10; // calm cap — a flood means a systemic numbering change, not 40 separate problems
266
+ let adrDistinct = 0;
267
+ let adrMissing = 0;
268
+ if (adrEnabled && citations.length > 0) {
269
+ const known = collectAdrNumbers(projectDir, config);
270
+ const byNum = new Map(); // num → [{raw, file, line}]
271
+ for (const c of citations) {
272
+ if (!byNum.has(c.num)) byNum.set(c.num, []);
273
+ byNum.get(c.num).push(c);
274
+ }
275
+ adrDistinct = byNum.size;
276
+ for (const [num, locs] of byNum) {
277
+ if (known.has(num)) continue;
278
+ adrMissing++;
279
+ if (adrMissing > MAX_ADR_FINDINGS) continue;
280
+ const first = locs[0];
281
+ const where = `${first.file}:${first.line}${locs.length > 1 ? ` (+${locs.length - 1} more)` : ''}`;
282
+ const message = known.size > 0
283
+ ? `Code cites ${first.raw} (${where}) but no ADR document defines that number — renumbered, removed, or never written.`
284
+ : `Code cites ${first.raw} (${where}) but the repo has no ADR documents — the decision record it points to is missing.`;
285
+ findings.push(mkFinding({
286
+ code: 'REF002',
287
+ validator: 'reference-existence',
288
+ severity: 'warn',
289
+ confidence: 'low',
290
+ message,
291
+ location: { file: first.file, line: first.line },
292
+ suggestion: {
293
+ summary: known.size > 0
294
+ ? `Fix the number or write the missing ADR entry (or suppress with // docguard:ignore REF002 on the citation line).`
295
+ : `Create an ADR doc (docguard init writes templates/ADR.md) or suppress with // docguard:ignore REF002.`,
296
+ },
297
+ }));
298
+ }
299
+ }
300
+
301
+ // A missing number beyond the finding cap is still a failed check.
302
+ const total = totalChecked + adrDistinct;
303
+ const overCap = Math.max(0, adrMissing - MAX_ADR_FINDINGS);
150
304
  const res = resultFromFindings(findings, {
151
- passed: totalChecked - findings.length,
152
- total: totalChecked,
305
+ passed: total - findings.length - overCap,
306
+ total,
153
307
  applicable: true,
154
308
  });
155
309
  res.absentAtHead = absentAtHead; // instrumentation: proves the pipeline reaches the rev check
@@ -21,6 +21,56 @@ import { mkFinding, resultFromFindings } from '../findings.mjs';
21
21
  import { tokenize } from '../shared-diff.mjs';
22
22
  import { rankBySimilarity } from '../shared-ir.mjs';
23
23
 
24
+ /**
25
+ * Optional graphify interop (github.com/Graphify-Labs/graphify, MIT).
26
+ * Teams that commit `graphify-out/graph.json` already carry a knowledge graph
27
+ * whose CODE side is deterministic tree-sitter extraction. If it's there, its
28
+ * doc↔code edges are one more linkage-evidence source for traceability.
29
+ *
30
+ * Trust rules (the determinism anchor):
31
+ * - ONLY edges tagged confidence:"EXTRACTED" count — INFERRED/AMBIGUOUS
32
+ * edges can come from graphify's LLM pass and must not vouch for a doc.
33
+ * - Evidence-only: this can turn a would-be "unlinked doc" into a pass;
34
+ * it never produces a finding.
35
+ * - Zero-dep: one JSON read. Any parse/shape mismatch → null (no evidence).
36
+ *
37
+ * @returns {Map<string, Set<string>>|null} doc basename → linked code files
38
+ */
39
+ function loadGraphifyDocLinks(projectDir) {
40
+ const p = resolve(projectDir, 'graphify-out', 'graph.json');
41
+ if (!existsSync(p)) return null;
42
+ try {
43
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
44
+ const nodes = Array.isArray(raw.nodes) ? raw.nodes : [];
45
+ // networkx serializes edges as "links"; older graphify exports used "edges".
46
+ const links = Array.isArray(raw.links) ? raw.links
47
+ : Array.isArray(raw.edges) ? raw.edges : [];
48
+ const nodeFile = new Map(); // node id → source_file
49
+ for (const n of nodes) {
50
+ if (n && n.id !== undefined && typeof n.source_file === 'string') {
51
+ nodeFile.set(n.id, n.source_file);
52
+ }
53
+ }
54
+ const docLinks = new Map();
55
+ for (const l of links) {
56
+ if (!l || l.confidence !== 'EXTRACTED') continue;
57
+ const sf = nodeFile.get(l.source);
58
+ const tf = nodeFile.get(l.target);
59
+ if (!sf || !tf) continue;
60
+ for (const [a, b] of [[sf, tf], [tf, sf]]) {
61
+ if (a.endsWith('.md') && !b.endsWith('.md') && isTraceableSource(b)) {
62
+ const doc = basename(a);
63
+ if (!docLinks.has(doc)) docLinks.set(doc, new Set());
64
+ docLinks.get(doc).add(b);
65
+ }
66
+ }
67
+ }
68
+ return docLinks.size > 0 ? docLinks : null;
69
+ } catch {
70
+ return null; // malformed graph = no evidence, never a finding
71
+ }
72
+ }
73
+
24
74
  // IR soft-link recovery (feat 5): tokenize test files once so an untraced
25
75
  // requirement can be matched to the test that most likely already covers it
26
76
  // (TF-IDF cosine, VSM). Capped so a huge test suite can't blow up guard.
@@ -98,6 +148,10 @@ export function validateTraceability(projectDir, config) {
98
148
  const projectFiles = [];
99
149
  scanDir(projectDir, projectDir, projectFiles);
100
150
 
151
+ // Optional graphify interop: a committed knowledge graph is one more
152
+ // deterministic evidence source for doc↔code linkage.
153
+ const graphifyLinks = loadGraphifyDocLinks(projectDir);
154
+
101
155
  // Scan source files for `// @doc <filename>.md` annotations. An annotation
102
156
  // is an explicit author signal that a source file documents (or is
103
157
  // documented by) a canonical doc. It is the user-facing escape hatch when
@@ -149,6 +203,15 @@ export function validateTraceability(projectDir, config) {
149
203
  }
150
204
  }
151
205
 
206
+ // Graphify interop: an EXTRACTED doc↔code edge in a committed
207
+ // graphify-out/graph.json is author-grade linkage evidence (the graph's
208
+ // code side is deterministic AST extraction). Only trusted when at least
209
+ // one linked code file still exists — a stale graph must not vouch.
210
+ if (!hasSource && graphifyLinks && graphifyLinks.has(docName)) {
211
+ hasSource = [...graphifyLinks.get(docName)]
212
+ .some(f => existsSync(resolve(projectDir, f)) || existsSync(f));
213
+ }
214
+
152
215
  if (hasSource) {
153
216
  passed++;
154
217
  } else {
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.31.0"
6
+ version: "0.32.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.31.0
9
+ version: 0.32.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.31.0 -->
12
+ <!-- docguard:version: 0.32.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.31.0
10
+ version: 0.32.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.31.0 -->
13
+ <!-- docguard:version: 0.32.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.31.0
9
+ version: 0.32.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.31.0 -->
12
+ <!-- docguard:version: 0.32.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.31.0
9
+ version: 0.32.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.31.0 -->
12
+ <!-- docguard:version: 0.32.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.31.0
7
+ version: 0.32.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.31.0 -->
10
+ <!-- docguard:version: 0.32.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {