docguard-cli 0.33.0 → 0.34.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/PRIVACY.md +45 -0
- package/README.md +16 -0
- package/cli/commands/hooks.mjs +7 -9
- package/cli/commands/impact.mjs +12 -12
- package/cli/commands/init.mjs +4 -7
- package/cli/commands/llms.mjs +9 -19
- package/cli/commands/memory.mjs +7 -8
- package/cli/commands/score.mjs +17 -24
- package/cli/commands/trace.mjs +10 -11
- package/cli/scanners/agent-readability.mjs +6 -10
- package/cli/scanners/semantic-claims.mjs +7 -13
- package/cli/shared-ignore.mjs +53 -0
- package/cli/validator-markers.mjs +7 -11
- package/cli/validators/api-doc-smells.mjs +11 -8
- package/cli/validators/cross-reference.mjs +4 -15
- package/cli/validators/diff-suspicion.mjs +5 -9
- package/cli/validators/doc-quality.mjs +9 -20
- package/cli/validators/docs-coverage.mjs +2 -9
- package/cli/validators/docs-sync.mjs +6 -8
- package/cli/validators/generated-staleness.mjs +28 -25
- package/cli/validators/reference-existence.mjs +4 -7
- package/cli/validators/traceability.mjs +23 -26
- package/docs/faq.md +19 -0
- 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 +4 -3
package/PRIVACY.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Privacy Policy — DocGuard
|
|
2
|
+
|
|
3
|
+
**Effective: 2026-07-16**
|
|
4
|
+
|
|
5
|
+
DocGuard is a local-first command-line tool. This policy is short because the
|
|
6
|
+
honest answer is short: **DocGuard collects nothing.**
|
|
7
|
+
|
|
8
|
+
## What DocGuard does with your data
|
|
9
|
+
|
|
10
|
+
- **All analysis runs locally.** Validators, scoring, reports, the MCP server —
|
|
11
|
+
everything reads files on your machine and writes output to your machine.
|
|
12
|
+
Nothing is uploaded, sampled, or "improved" with your code or docs.
|
|
13
|
+
- **No telemetry, no analytics, no crash reporting.** There is no phone-home
|
|
14
|
+
code path. The deterministic core makes no network calls at all.
|
|
15
|
+
- **No accounts.** DocGuard has no sign-up, no API keys of its own, and no
|
|
16
|
+
server-side component operated by us.
|
|
17
|
+
|
|
18
|
+
## The explicit, user-initiated exceptions
|
|
19
|
+
|
|
20
|
+
Three commands can *prepare* outbound actions — each is opt-in, visible, and
|
|
21
|
+
executed by you or your own tooling, never silently by DocGuard:
|
|
22
|
+
|
|
23
|
+
| Command | What happens |
|
|
24
|
+
|---------|--------------|
|
|
25
|
+
| `docguard feedback` | Builds a **prefilled GitHub issue URL** (redacted and length-capped) and saves a local record. Nothing is sent unless you open the URL and submit it yourself. |
|
|
26
|
+
| `docguard upgrade --pr` / `impact --prs` | Shell out to **your** locally-authenticated `gh` CLI to interact with **your** repositories. DocGuard never holds credentials. |
|
|
27
|
+
| `docguard mcp --transport http` | Serves read-only tools over HTTP. Binds to loopback by default; binding a non-loopback address **refuses to start** without an `--api-key`. |
|
|
28
|
+
|
|
29
|
+
## Data written to disk (yours, locally)
|
|
30
|
+
|
|
31
|
+
State lives under `.docguard/` in your repo (fix history, score history,
|
|
32
|
+
caches) and `.docguard.baseline.json` if you create one. All of it is plain
|
|
33
|
+
text, in your repository, under your version control — delete it any time.
|
|
34
|
+
|
|
35
|
+
## Dependencies
|
|
36
|
+
|
|
37
|
+
One pinned runtime dependency (`@babel/parser`). The npm package is published
|
|
38
|
+
from GitHub Actions with provenance attestation, so you can verify the tarball
|
|
39
|
+
was built from this repository.
|
|
40
|
+
|
|
41
|
+
## Changes & contact
|
|
42
|
+
|
|
43
|
+
Changes to this policy land in this file with a dated entry in
|
|
44
|
+
[CHANGELOG.md](CHANGELOG.md). Questions: open an issue at
|
|
45
|
+
<https://github.com/raccioly/docguard/issues> (see [SUPPORT.md](SUPPORT.md)).
|
package/README.md
CHANGED
|
@@ -454,6 +454,15 @@ DocGuard ships **18 professional templates** with metadata, badges, and revision
|
|
|
454
454
|
|
|
455
455
|
## 🤖 AI Agent Support
|
|
456
456
|
|
|
457
|
+
### One-click MCP install
|
|
458
|
+
|
|
459
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=docguard&config=eyJjb21tYW5kIjogIm5weCIsICJhcmdzIjogWyIteSIsICJkb2NndWFyZC1jbGkiLCAibWNwIl19)
|
|
460
|
+
[](vscode:mcp/install?%7B%22name%22%3A%22docguard%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22docguard-cli%22%2C%22mcp%22%5D%7D)
|
|
461
|
+
|
|
462
|
+
- **Claude Code**: `claude mcp add docguard -- npx docguard-cli mcp`
|
|
463
|
+
- **Claude Desktop**: download `docguard-v<version>.mcpb` from the [latest release](https://github.com/raccioly/docguard/releases/latest) and drag it into Settings → Extensions — you'll be asked which project folder to analyze. No npm, no JSON editing.
|
|
464
|
+
- **Anything MCP**: DocGuard is a verified namespace on the [official MCP registry](https://registry.modelcontextprotocol.io/v0/servers?search=docguard) (`io.github.raccioly/docguard`).
|
|
465
|
+
|
|
457
466
|
DocGuard works with **every major AI coding agent**. All canonical docs are plain markdown — no vendor lock-in.
|
|
458
467
|
|
|
459
468
|
| Agent | Compatibility | Auto-Generate Config |
|
|
@@ -769,6 +778,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#research--academic-credits) for full citat
|
|
|
769
778
|
|
|
770
779
|
---
|
|
771
780
|
|
|
781
|
+
## 🔒 Privacy & Supply Chain
|
|
782
|
+
|
|
783
|
+
DocGuard is local-first: no telemetry, no analytics, no phone-home — the full
|
|
784
|
+
(short) policy is in [PRIVACY.md](PRIVACY.md). npm releases are published with
|
|
785
|
+
[provenance attestation](https://docs.npmjs.com/generating-provenance-statements),
|
|
786
|
+
so you can verify each tarball was built by GitHub Actions from this repository.
|
|
787
|
+
|
|
772
788
|
## 📄 License
|
|
773
789
|
|
|
774
790
|
[MIT](LICENSE) — Free to use, modify, and distribute.
|
package/cli/commands/hooks.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Creates git hooks that run guard/score before commits.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync
|
|
6
|
+
import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
// v0.16-P3: managed-block markers. Letting users extend the hook with their
|
|
9
9
|
// own commands (data-file guards, lint checks, etc.) without us clobbering
|
|
@@ -57,6 +57,7 @@ function spliceManagedBlock(existing, newBody) {
|
|
|
57
57
|
import { resolve, relative, basename } from 'node:path';
|
|
58
58
|
import { c } from '../shared.mjs';
|
|
59
59
|
import { getHooksDir } from '../shared-git.mjs';
|
|
60
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
60
61
|
|
|
61
62
|
const HOOKS = {
|
|
62
63
|
'pre-commit': {
|
|
@@ -488,14 +489,11 @@ function docsReferencingFile(projectDir, base) {
|
|
|
488
489
|
if (readFileSync(full, 'utf-8').includes(base)) docs.push(name);
|
|
489
490
|
} catch { /* unreadable */ }
|
|
490
491
|
};
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
} catch { /* unreadable dir */ }
|
|
498
|
-
}
|
|
492
|
+
// Recursive — a nested doc mentioning the edited file must still trigger
|
|
493
|
+
// the post-commit nudge. `name` stays the bare basename to match this
|
|
494
|
+
// function's pre-existing flat-tree display (the nudge message lists
|
|
495
|
+
// "ARCHITECTURE.md, SECURITY.md", not full nested paths).
|
|
496
|
+
for (const doc of listCanonicalDocs(projectDir)) check(basename(doc.rel), doc.abs);
|
|
499
497
|
for (const a of NUDGE_AGENT_FILES) {
|
|
500
498
|
const p = resolve(projectDir, a);
|
|
501
499
|
if (existsSync(p)) check(a, p);
|
package/cli/commands/impact.mjs
CHANGED
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
* @req SC-S11-009 — docs referencing an importer of a changed file are flagged as indirect
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import { existsSync, readFileSync
|
|
43
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
44
44
|
import { resolve, basename } from 'node:path';
|
|
45
45
|
import { execFileSync } from 'node:child_process';
|
|
46
46
|
|
|
47
47
|
import { c } from '../shared.mjs';
|
|
48
48
|
import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
49
49
|
import { buildImportGraph } from '../validators/architecture.mjs';
|
|
50
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* File extensions we consider "code" for the purposes of impact analysis.
|
|
@@ -254,19 +255,18 @@ export function runImpact(projectDir, config, flags) {
|
|
|
254
255
|
|
|
255
256
|
// Index canonical docs once, PLUS root agent-instruction files (they name
|
|
256
257
|
// code and other docs, so they belong in both code→doc and doc→doc analysis).
|
|
257
|
-
const
|
|
258
|
-
const docsIndex = new Map(); // docName → lines[]
|
|
258
|
+
const docsIndex = new Map(); // docName (basename) → lines[]
|
|
259
259
|
const agentDocs = new Set(); // which indexed docs are agent-instruction files
|
|
260
|
-
|
|
260
|
+
// Recursive — a nested canonical doc must be indexed too. Keyed by basename
|
|
261
|
+
// (not full path) to match this file's existing basename-based lookups
|
|
262
|
+
// (`docsIndex.has(basename(f))`, `indexBasenames`); a doc in a subfolder
|
|
263
|
+
// with a name clashing an already-indexed doc will overwrite it in the Map,
|
|
264
|
+
// same pre-existing behavior as a canonical doc clashing an agent doc name.
|
|
265
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
261
266
|
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const content = readFileSync(resolve(docsDir, f), 'utf-8');
|
|
266
|
-
docsIndex.set(f, content.split('\n'));
|
|
267
|
-
} catch { /* skip unreadable */ }
|
|
268
|
-
}
|
|
269
|
-
} catch { /* skip if dir unreadable */ }
|
|
267
|
+
const content = readFileSync(doc.abs, 'utf-8');
|
|
268
|
+
docsIndex.set(basename(doc.rel), content.split('\n'));
|
|
269
|
+
} catch { /* skip unreadable */ }
|
|
270
270
|
}
|
|
271
271
|
for (const a of AGENT_FILES) {
|
|
272
272
|
const p = resolve(projectDir, a);
|
package/cli/commands/init.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
13
13
|
import { resolve, dirname } from 'node:path';
|
|
14
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { createInterface } from 'node:readline';
|
|
16
17
|
import { execSync } from 'node:child_process';
|
|
@@ -135,13 +136,9 @@ function shouldRunGenerate(projectDir, flags) {
|
|
|
135
136
|
if (flags.fix) return false; // --fix = deterministic create-missing-from-templates (headless)
|
|
136
137
|
|
|
137
138
|
// If canonical docs already exist, this is a re-init, not a first-run.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const entries = readdirSync(canonicalDir).filter(f => f.endsWith('.md'));
|
|
142
|
-
if (entries.length > 0) return false;
|
|
143
|
-
} catch { /* fall through */ }
|
|
144
|
-
}
|
|
139
|
+
// Recursive — a project whose only canonical docs are nested must still be
|
|
140
|
+
// detected as already-initialized, or the wizard re-triggers on every run.
|
|
141
|
+
if (listCanonicalDocs(projectDir).length > 0) return false;
|
|
145
142
|
|
|
146
143
|
// Existing-code signals: any of cli/, src/, lib/, app/ as a directory.
|
|
147
144
|
const codeDirs = ['cli', 'src', 'lib', 'app'];
|
package/cli/commands/llms.mjs
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* - `docguard guard` validates llms.txt exists and is current
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { resolve, join, basename } from 'node:path';
|
|
20
20
|
import { c } from '../shared.mjs';
|
|
21
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
21
22
|
|
|
22
23
|
// ──── Doc descriptions for llms.txt ────
|
|
23
24
|
const DOC_DESCRIPTIONS = {
|
|
@@ -58,18 +59,12 @@ export function generateLlmsTxt(projectDir, config) {
|
|
|
58
59
|
}
|
|
59
60
|
lines.push('');
|
|
60
61
|
|
|
61
|
-
// ── Canonical Docs ──
|
|
62
|
-
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
62
|
+
// ── Canonical Docs ── (recursive — a nested doc still belongs in llms.txt)
|
|
63
63
|
const existingDocs = [];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
for (const entry of entries) {
|
|
69
|
-
const desc = DOC_DESCRIPTIONS[entry] || `${entry.replace('.md', '')} documentation`;
|
|
70
|
-
existingDocs.push({ path: `docs-canonical/${entry}`, name: entry, desc });
|
|
71
|
-
}
|
|
72
|
-
} catch { /* ignore */ }
|
|
64
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
65
|
+
const entry = basename(doc.rel);
|
|
66
|
+
const desc = DOC_DESCRIPTIONS[entry] || `${entry.replace('.md', '')} documentation`;
|
|
67
|
+
existingDocs.push({ path: doc.rel, name: entry, desc });
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
if (existingDocs.length > 0) {
|
|
@@ -160,13 +155,8 @@ export function generateLlmsFullTxt(projectDir, config) {
|
|
|
160
155
|
|
|
161
156
|
// Same doc discovery as the index form: canonical docs + present optional docs.
|
|
162
157
|
const docPaths = [];
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
for (const entry of readdirSync(docsDir).filter(f => f.endsWith('.md')).sort()) {
|
|
167
|
-
docPaths.push({ path: `docs-canonical/${entry}`, desc: DOC_DESCRIPTIONS[entry] || null });
|
|
168
|
-
}
|
|
169
|
-
} catch { /* ignore */ }
|
|
158
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
159
|
+
docPaths.push({ path: doc.rel, desc: DOC_DESCRIPTIONS[basename(doc.rel)] || null });
|
|
170
160
|
}
|
|
171
161
|
for (const [file, desc] of Object.entries(OPTIONAL_DOCS)) {
|
|
172
162
|
if (existsSync(resolve(projectDir, file))) docPaths.push({ path: file, desc });
|
package/cli/commands/memory.mjs
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* Zero NPM dependencies. Pure orchestration of existing diff helpers.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, readFileSync,
|
|
24
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { resolve } from 'node:path';
|
|
26
26
|
import { c } from '../shared.mjs';
|
|
27
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
27
28
|
import { diffRoutes, diffEntities, diffEnvVars, diffTechStack } from './diff.mjs';
|
|
28
29
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
29
30
|
import { runGuardInternal } from './guard.mjs';
|
|
@@ -97,19 +98,17 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
97
98
|
lines.push(`- Tests: ${plan.surface.tests.totalFiles} files, ${plan.surface.tests.totalCases} cases`);
|
|
98
99
|
lines.push('');
|
|
99
100
|
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
101
|
+
const canonicalDocs = listCanonicalDocs(projectDir);
|
|
102
|
+
if (canonicalDocs.length > 0) {
|
|
102
103
|
lines.push('## Canonical docs');
|
|
103
104
|
lines.push('');
|
|
104
|
-
|
|
105
|
-
try { entries = readdirSync(docsDir).filter(f => f.endsWith('.md')).sort(); } catch { /* ignore */ }
|
|
106
|
-
for (const doc of entries) {
|
|
105
|
+
for (const doc of canonicalDocs) {
|
|
107
106
|
let reviewed = '';
|
|
108
107
|
try {
|
|
109
|
-
const m = readFileSync(
|
|
108
|
+
const m = readFileSync(doc.abs, 'utf-8').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
|
|
110
109
|
if (m) reviewed = ` (last-reviewed ${m[1]})`;
|
|
111
110
|
} catch { /* ignore */ }
|
|
112
|
-
lines.push(`-
|
|
111
|
+
lines.push(`- ${doc.rel}${reviewed}`);
|
|
113
112
|
}
|
|
114
113
|
lines.push('');
|
|
115
114
|
}
|
package/cli/commands/score.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { runGuardInternal } from './guard.mjs';
|
|
|
12
12
|
import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
13
13
|
import { assessAgentReadability } from '../scanners/agent-readability.mjs';
|
|
14
14
|
import { loadHistory, sparkline } from '../writers/history.mjs';
|
|
15
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
@@ -424,16 +425,16 @@ export function computeAlcoaCompliance(projectDir, config, scores) {
|
|
|
424
425
|
// 1. Attributable — Can we trace who wrote/reviewed docs?
|
|
425
426
|
const hasGit = existsSync(resolve(projectDir, '.git'));
|
|
426
427
|
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
428
|
+
// Recursive — a nested canonical tree is still the canonical tree. A flat
|
|
429
|
+
// read scored ALCOA against an empty doc set on those projects.
|
|
430
|
+
const canonicalDocs = listCanonicalDocs(projectDir);
|
|
427
431
|
let hasReviewedMeta = false;
|
|
428
|
-
|
|
432
|
+
for (const doc of canonicalDocs) {
|
|
429
433
|
try {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
hasReviewedMeta = true;
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
434
|
+
const content = readFileSync(doc.abs, 'utf-8');
|
|
435
|
+
if (content.includes('docguard:last-reviewed') || content.includes('last-reviewed')) {
|
|
436
|
+
hasReviewedMeta = true;
|
|
437
|
+
break;
|
|
437
438
|
}
|
|
438
439
|
} catch { /* ignore */ }
|
|
439
440
|
}
|
|
@@ -457,16 +458,13 @@ export function computeAlcoaCompliance(projectDir, config, scores) {
|
|
|
457
458
|
|
|
458
459
|
// 3. Contemporaneous — Are docs kept current?
|
|
459
460
|
let freshnessMet = true;
|
|
460
|
-
|
|
461
|
+
for (const doc of canonicalDocs) {
|
|
461
462
|
try {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
freshnessMet = false;
|
|
468
|
-
break;
|
|
469
|
-
}
|
|
463
|
+
const stat_ = statSync(doc.abs);
|
|
464
|
+
const daysSinceModified = (Date.now() - stat_.mtimeMs) / (1000 * 60 * 60 * 24);
|
|
465
|
+
if (daysSinceModified > 30) {
|
|
466
|
+
freshnessMet = false;
|
|
467
|
+
break;
|
|
470
468
|
}
|
|
471
469
|
} catch { /* ignore */ }
|
|
472
470
|
}
|
|
@@ -1079,13 +1077,8 @@ function getSuggestion(category, score, details) {
|
|
|
1079
1077
|
*/
|
|
1080
1078
|
function estimateDocTax(projectDir, config, scores) {
|
|
1081
1079
|
// Count tracked docs
|
|
1082
|
-
|
|
1083
|
-
let docCount =
|
|
1084
|
-
if (existsSync(canonicalDir)) {
|
|
1085
|
-
try {
|
|
1086
|
-
docCount = readdirSync(canonicalDir).filter(f => f.endsWith('.md')).length;
|
|
1087
|
-
} catch { /* ignore */ }
|
|
1088
|
-
}
|
|
1080
|
+
// Recursive — nested canonical docs cost maintenance too, so they count.
|
|
1081
|
+
let docCount = listCanonicalDocs(projectDir).length;
|
|
1089
1082
|
// Add root tracking files
|
|
1090
1083
|
if (existsSync(resolve(projectDir, 'CHANGELOG.md'))) docCount++;
|
|
1091
1084
|
if (existsSync(resolve(projectDir, 'DRIFT-LOG.md'))) docCount++;
|
package/cli/commands/trace.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
10
10
|
import { resolve, join, extname, basename, relative, dirname } from 'node:path';
|
|
11
11
|
import { c } from '../shared.mjs';
|
|
12
12
|
import { detectSpecKit } from '../scanners/speckit.mjs';
|
|
13
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
13
14
|
|
|
14
15
|
const IGNORE_DIRS = new Set([
|
|
15
16
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -74,11 +75,11 @@ export function runTraceReverse(projectDir, config, flags) {
|
|
|
74
75
|
const stem = base.replace(/\.[^.]+$/, '');
|
|
75
76
|
|
|
76
77
|
const matches = []; // { doc, line, content, kind }
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// Recursive — a nested doc mentioning this file must still be found, or
|
|
79
|
+
// `trace --reverse` falsely reports "no canonical doc references this".
|
|
80
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
80
81
|
let content;
|
|
81
|
-
try { content = readFileSync(
|
|
82
|
+
try { content = readFileSync(doc.abs, 'utf-8'); } catch { continue; }
|
|
82
83
|
const lines = content.split('\n');
|
|
83
84
|
for (let i = 0; i < lines.length; i++) {
|
|
84
85
|
const line = lines[i];
|
|
@@ -87,7 +88,7 @@ export function runTraceReverse(projectDir, config, flags) {
|
|
|
87
88
|
else if (line.includes(base)) kind = 'basename';
|
|
88
89
|
else if (new RegExp(`\`${escapeRegex(stem)}\``).test(line)) kind = 'module';
|
|
89
90
|
if (kind) {
|
|
90
|
-
matches.push({ doc:
|
|
91
|
+
matches.push({ doc: doc.rel, line: i + 1, content: line.trim(), kind });
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -155,13 +156,11 @@ export function runTrace(projectDir, config, flags) {
|
|
|
155
156
|
);
|
|
156
157
|
|
|
157
158
|
// ── 2. Inventory canonical docs ──
|
|
159
|
+
// NOTE: currently unused downstream (outputText ignores its 3rd param) —
|
|
160
|
+
// kept for API stability and future use. Recursive so it isn't ANOTHER
|
|
161
|
+
// silent flat-read landmine if something starts consuming it.
|
|
158
162
|
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
159
|
-
const canonicalDocs =
|
|
160
|
-
if (existsSync(docsDir)) {
|
|
161
|
-
for (const f of readdirSync(docsDir)) {
|
|
162
|
-
if (f.endsWith('.md')) canonicalDocs.push(f);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
163
|
+
const canonicalDocs = listCanonicalDocs(projectDir).map(d => d.rel);
|
|
165
164
|
|
|
166
165
|
// ── 3. Scan project files ──
|
|
167
166
|
const projectFiles = [];
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* read that.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
16
|
import { resolve, dirname } from 'node:path';
|
|
17
17
|
import { loadIgnorePatterns } from '../shared.mjs';
|
|
18
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
18
19
|
|
|
19
20
|
/** chars/4 — the standard rough token estimate; consistency matters more than precision. */
|
|
20
21
|
const estTokens = (s) => Math.ceil(s.length / 4);
|
|
@@ -30,19 +31,14 @@ function readIfExists(path) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function canonicalDocs(projectDir) {
|
|
33
|
-
const dir = resolve(projectDir, 'docs-canonical');
|
|
34
|
-
if (!existsSync(dir)) return [];
|
|
35
34
|
// Honor .docguardignore — an excluded doc (e.g. a historical audit) must
|
|
36
35
|
// not drag down the readability metrics either (same rule as the
|
|
37
36
|
// semantic-claim extractor, bug-212).
|
|
38
37
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
|
|
44
|
-
.filter(d => d.content !== null);
|
|
45
|
-
} catch { return []; }
|
|
38
|
+
// Recursive: docs in subfolders are canonical docs and are scored as such.
|
|
39
|
+
return listCanonicalDocs(projectDir, { isIgnored })
|
|
40
|
+
.map(d => ({ name: d.rel, content: readIfExists(d.abs) }))
|
|
41
|
+
.filter(d => d.content !== null);
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* Zero npm dependencies — pure Node.js built-ins.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, readFileSync
|
|
25
|
-
import { resolve
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
26
|
import { loadIgnorePatterns } from '../shared.mjs';
|
|
27
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
27
28
|
|
|
28
29
|
// Numbers are only claims when adjacent to a recognized unit.
|
|
29
30
|
const NUMBER_PATTERNS = [
|
|
@@ -55,17 +56,10 @@ function claimSourceDocs(projectDir) {
|
|
|
55
56
|
// "unverified claims" pool either — it inflated the count and buried the
|
|
56
57
|
// claims that ARE actionable (bug-212).
|
|
57
58
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for (const f of readdirSync(canonical)) {
|
|
63
|
-
if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
|
|
64
|
-
docs.push(`docs-canonical/${f}`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
} catch { /* ignore */ }
|
|
68
|
-
}
|
|
59
|
+
// Recursive — nested canonical docs make claims too. The ignore predicate is
|
|
60
|
+
// applied per-doc inside the helper against the full relative path, so a
|
|
61
|
+
// pattern like `docs-canonical/99-archive/**` still excludes a subtree.
|
|
62
|
+
const docs = listCanonicalDocs(projectDir, { isIgnored }).map(d => d.rel);
|
|
69
63
|
for (const root of ['README.md', 'AGENTS.md']) {
|
|
70
64
|
if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
|
|
71
65
|
}
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -350,6 +350,59 @@ export function walkFiles(dir, callback, opts = {}) {
|
|
|
350
350
|
return complete;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Enumerate the project's canonical docs — RECURSIVELY.
|
|
355
|
+
*
|
|
356
|
+
* Five call sites used to do this by hand with a flat
|
|
357
|
+
* `readdirSync('docs-canonical').filter(f => f.endsWith('.md'))`. That works
|
|
358
|
+
* only for a flat tree; a project that groups its canonical docs in subfolders
|
|
359
|
+
* (`docs-canonical/01-architecture/MODULE-MAP.md` — a common convention once a
|
|
360
|
+
* repo has more than a handful) was invisible to ALL of them. The failure was
|
|
361
|
+
* silent and pointed the wrong way: docs-sync flagged every service as
|
|
362
|
+
* undocumented while the docs sat right there, `docguard:validator … n/a`
|
|
363
|
+
* markers in nested docs were ignored, and readability/freshness scored an
|
|
364
|
+
* empty set. This is the single shared implementation — same rule as
|
|
365
|
+
* `walkFiles`: exactly one correct way to enumerate, so the answers can't drift.
|
|
366
|
+
*
|
|
367
|
+
* Contract:
|
|
368
|
+
* - Recursive; delegates traversal to `walkFiles`, so `ignoreDirs` and the
|
|
369
|
+
* skip-dot-directories rule apply. A dot-FILE ending in `.md` is kept, to
|
|
370
|
+
* preserve the flat behavior these call sites had.
|
|
371
|
+
* - `.md` matching is case-INSENSITIVE. Three of the five original call sites
|
|
372
|
+
* lowercased and two did not; one tool must not hold two opinions about
|
|
373
|
+
* what a canonical doc is. The inclusive reading wins.
|
|
374
|
+
* - Sorted by relative path, so output is deterministic across platforms.
|
|
375
|
+
* - NEVER throws. An unreadable subtree yields the files that were readable.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} projectDir - Project root (absolute)
|
|
378
|
+
* @param {{dirName?: string, isIgnored?: ((relPath: string) => boolean) | null}} [opts]
|
|
379
|
+
* `isIgnored` — optional `.docguardignore` predicate from the caller (see
|
|
380
|
+
* `loadIgnorePatterns` in shared.mjs). Passed in rather than imported so this
|
|
381
|
+
* module stays a leaf with no local imports.
|
|
382
|
+
* @returns {Array<{abs: string, rel: string}>} `rel` is project-relative POSIX
|
|
383
|
+
*/
|
|
384
|
+
export function listCanonicalDocs(projectDir, opts = {}) {
|
|
385
|
+
const { dirName = 'docs-canonical', isIgnored = null } = opts;
|
|
386
|
+
const root = resolvePath(projectDir, dirName);
|
|
387
|
+
if (!existsSync(root)) return [];
|
|
388
|
+
|
|
389
|
+
const isMarkdown = (name) => name.toLowerCase().endsWith('.md');
|
|
390
|
+
const out = [];
|
|
391
|
+
walkFiles(root, (abs) => {
|
|
392
|
+
if (!isMarkdown(abs)) return;
|
|
393
|
+
const rel = relPosix(projectDir, abs);
|
|
394
|
+
if (isIgnored && isIgnored(rel)) return;
|
|
395
|
+
out.push({ abs, rel });
|
|
396
|
+
}, {
|
|
397
|
+
// Keep dot-FILES that are markdown (old flat behavior included them);
|
|
398
|
+
// dot-DIRECTORIES still get skipped, since the predicate only matches `.md`.
|
|
399
|
+
keepDot: isMarkdown,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
353
406
|
/**
|
|
354
407
|
* Count files under `projectDir` matching an anchored glob (project-relative).
|
|
355
408
|
* The code-truth side of `config.collections` (metrics-consistency).
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* Zero NPM dependencies — pure Node.js built-ins.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync
|
|
24
|
-
import { resolve
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { listCanonicalDocs } from './shared-ignore.mjs';
|
|
25
26
|
|
|
26
27
|
// `<!-- docguard:validator <key> n/a [— reason] -->`
|
|
27
28
|
// Separator before the reason may be —, :, or one-or-more hyphens. Reason
|
|
@@ -30,15 +31,10 @@ const MARKER_RE = /<!--\s*docguard:validator\s+([A-Za-z0-9_-]+)\s+n\/a\b\s*(?:[
|
|
|
30
31
|
|
|
31
32
|
/** Files where a validator marker is honored — the docs humans actually read. */
|
|
32
33
|
function markerSourceFiles(projectDir) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const f of readdirSync(canonicalDir)) {
|
|
38
|
-
if (f.toLowerCase().endsWith('.md')) files.push(join(canonicalDir, f));
|
|
39
|
-
}
|
|
40
|
-
} catch { /* ignore */ }
|
|
41
|
-
}
|
|
34
|
+
// Recursive: a marker declared in docs-canonical/01-architecture/FOO.md must
|
|
35
|
+
// be honored too — a flat read silently dropped the suppression and the
|
|
36
|
+
// validator ran anyway, which reads as "DocGuard ignored my n/a".
|
|
37
|
+
const files = listCanonicalDocs(projectDir).map(d => d.abs);
|
|
42
38
|
for (const root of ['AGENTS.md', 'README.md', 'CLAUDE.md']) {
|
|
43
39
|
const p = resolve(projectDir, root);
|
|
44
40
|
if (existsSync(p)) files.push(p);
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* All findings confidence:'low' / soft — a nudge to right-size the doc.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { basename } from 'node:path';
|
|
25
25
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
26
27
|
|
|
27
28
|
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
28
29
|
// A heading that documents an API/code unit — NOT a prose section heading.
|
|
@@ -94,18 +95,20 @@ export function validateApiDocSmells(projectDir, config = {}) {
|
|
|
94
95
|
const lazyMax = Number.isInteger(cfg.lazyMaxWords) ? cfg.lazyMaxWords : 6;
|
|
95
96
|
const bloatedMin = Number.isInteger(cfg.bloatedMinWords) ? cfg.bloatedMinWords : 300;
|
|
96
97
|
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
98
|
+
const docFiles = listCanonicalDocs(projectDir); // recursive
|
|
99
|
+
if (docFiles.length === 0) {
|
|
99
100
|
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
100
101
|
}
|
|
101
|
-
let docFiles = [];
|
|
102
|
-
try { docFiles = readdirSync(docsDir).filter(f => f.endsWith('.md')); } catch { /* skip */ }
|
|
103
102
|
|
|
104
103
|
const findings = [];
|
|
105
104
|
let unitCount = 0;
|
|
106
|
-
for (const
|
|
105
|
+
for (const doc of docFiles) {
|
|
107
106
|
let content;
|
|
108
|
-
try { content = readFileSync(
|
|
107
|
+
try { content = readFileSync(doc.abs, 'utf-8'); } catch { continue; }
|
|
108
|
+
// `f` stays the bare basename — matches this validator's pre-existing
|
|
109
|
+
// flat-tree message/location format exactly (both were already bare
|
|
110
|
+
// filenames, not project-relative paths, before this fix).
|
|
111
|
+
const f = basename(doc.rel);
|
|
109
112
|
const units = extractUnits(content);
|
|
110
113
|
for (const u of units) {
|
|
111
114
|
unitCount++;
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
* @req SC-K7-004 — code-fenced examples don't trigger false positives
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { existsSync, readFileSync
|
|
37
|
-
import { resolve,
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { resolve, dirname, basename, relative } from 'node:path';
|
|
38
38
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
39
39
|
import { resolveDocDirs } from '../shared.mjs';
|
|
40
|
-
import { walkFiles } from '../shared-ignore.mjs';
|
|
40
|
+
import { walkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Slugify a heading the way GitHub's markdown anchors work.
|
|
@@ -323,18 +323,7 @@ function resolveTarget(sourcePath, targetRel, projectDir) {
|
|
|
323
323
|
* validators — `docs-canonical/`, root tracking files, and AGENTS.md.
|
|
324
324
|
*/
|
|
325
325
|
function collectCanonicalDocs(projectDir) {
|
|
326
|
-
const docs =
|
|
327
|
-
const cdir = resolve(projectDir, 'docs-canonical');
|
|
328
|
-
if (existsSync(cdir)) {
|
|
329
|
-
try {
|
|
330
|
-
for (const f of readdirSync(cdir)) {
|
|
331
|
-
if (f.endsWith('.md')) {
|
|
332
|
-
const p = join(cdir, f);
|
|
333
|
-
if (statSync(p).isFile()) docs.push(p);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
} catch {}
|
|
337
|
-
}
|
|
326
|
+
const docs = listCanonicalDocs(projectDir).map(d => d.abs); // recursive
|
|
338
327
|
// Standard root-level docs that are commonly cross-referenced. We index
|
|
339
328
|
// them so links like [CONTRIBUTING.md](CONTRIBUTING.md#some-section) can
|
|
340
329
|
// resolve. The list is conservative — adding everything would pull in
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* or no code change carries removed tokens, so it stays silent off-CI.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { existsSync, readFileSync
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { resolve, basename } from 'node:path';
|
|
23
23
|
import { isGitRepo, getDiffText } from '../shared-git.mjs';
|
|
24
24
|
import { parseUnifiedDiff, removedTokens, tokenize, tokenOverlap } from '../shared-diff.mjs';
|
|
25
25
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
26
27
|
|
|
27
28
|
const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
|
|
28
29
|
|
|
@@ -64,14 +65,9 @@ function indexDocs(projectDir) {
|
|
|
64
65
|
docs.set(name, { lines: content.split('\n'), tokens: tokenize(content) });
|
|
65
66
|
} catch { /* skip unreadable */ }
|
|
66
67
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (const f of readdirSync(docsDir)) {
|
|
71
|
-
if (f.endsWith('.md')) add(f, resolve(docsDir, f));
|
|
72
|
-
}
|
|
73
|
-
} catch { /* skip */ }
|
|
74
|
-
}
|
|
68
|
+
// Recursive. Keyed by bare basename — matches this validator's pre-existing
|
|
69
|
+
// flat-tree DSP001 message format ("ARCHITECTURE.md describes...").
|
|
70
|
+
for (const doc of listCanonicalDocs(projectDir)) add(basename(doc.rel), doc.abs);
|
|
75
71
|
// Agent-instruction files are documentation too — they routinely name code.
|
|
76
72
|
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
77
73
|
const p = resolve(projectDir, agent);
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* built-ins reading files only.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync,
|
|
24
|
-
import { resolve, join, extname, relative } from 'node:path';
|
|
23
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import { resolve, join, extname, relative, basename } from 'node:path';
|
|
25
25
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
26
27
|
|
|
27
28
|
// ──── Metric Thresholds ────
|
|
28
29
|
// These define "good" vs "warning" boundaries for each metric.
|
|
@@ -446,24 +447,12 @@ function getGradeLabel(grade) {
|
|
|
446
447
|
* Collect all markdown files in docs-canonical/ directory.
|
|
447
448
|
*/
|
|
448
449
|
function getCanonicalDocs(projectDir) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const entries = readdirSync(docsDir);
|
|
456
|
-
for (const entry of entries) {
|
|
457
|
-
if (extname(entry).toLowerCase() === '.md') {
|
|
458
|
-
docs.push({
|
|
459
|
-
name: entry,
|
|
460
|
-
path: join(docsDir, entry),
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
} catch {
|
|
465
|
-
// Directory read failed silently
|
|
466
|
-
}
|
|
450
|
+
// Recursive. `name` stays the bare basename (not the full nested path) to
|
|
451
|
+
// match this function's pre-existing flat-tree display format exactly —
|
|
452
|
+
// messages read "ARCHITECTURE.md: ..." not "docs-canonical/x/ARCHITECTURE.md: ...".
|
|
453
|
+
// Same collision tradeoff as elsewhere: two nested docs sharing a basename
|
|
454
|
+
// are indistinguishable by name (pre-existing risk, not new).
|
|
455
|
+
const docs = listCanonicalDocs(projectDir).map(d => ({ name: basename(d.rel), path: d.abs }));
|
|
467
456
|
|
|
468
457
|
// Also check README.md at project root
|
|
469
458
|
const readmePath = resolve(projectDir, 'README.md');
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
22
22
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
23
23
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
24
|
-
import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
24
|
+
import { shouldIgnore, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
25
25
|
import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
|
|
26
26
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
27
27
|
|
|
@@ -508,14 +508,7 @@ function collectDocContent(projectDir) {
|
|
|
508
508
|
if (existsSync(p)) docPaths.push(p);
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
-
const
|
|
512
|
-
if (existsSync(canonDir)) {
|
|
513
|
-
try {
|
|
514
|
-
for (const entry of readdirSync(canonDir)) {
|
|
515
|
-
if (entry.endsWith('.md')) docPaths.push(resolve(canonDir, entry));
|
|
516
|
-
}
|
|
517
|
-
} catch { /* skip */ }
|
|
518
|
-
}
|
|
511
|
+
for (const doc of listCanonicalDocs(projectDir)) docPaths.push(doc.abs); // recursive
|
|
519
512
|
|
|
520
513
|
const extDir = resolve(projectDir, 'extensions');
|
|
521
514
|
if (existsSync(extDir)) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
11
11
|
import { resolve, join, extname, basename } from 'node:path';
|
|
12
12
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
13
|
-
import { relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
13
|
+
import { relPosix, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
14
14
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
15
15
|
|
|
16
16
|
const IGNORE_DIRS = new Set([
|
|
@@ -71,15 +71,13 @@ export function validateDocsSync(projectDir, config) {
|
|
|
71
71
|
let passed = 0;
|
|
72
72
|
let total = 0;
|
|
73
73
|
|
|
74
|
-
// Load all canonical doc content for checking
|
|
75
|
-
|
|
74
|
+
// Load all canonical doc content for checking. Recursive — docs grouped in
|
|
75
|
+
// subfolders (docs-canonical/01-architecture/…) count as canonical too; a
|
|
76
|
+
// flat read made every service they documented look undocumented.
|
|
76
77
|
let canonicalContent = '';
|
|
77
|
-
|
|
78
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
78
79
|
try {
|
|
79
|
-
|
|
80
|
-
for (const f of files) {
|
|
81
|
-
canonicalContent += readFileSync(resolve(canonicalDir, f), 'utf-8') + '\n';
|
|
82
|
-
}
|
|
80
|
+
canonicalContent += readFileSync(doc.abs, 'utf-8') + '\n';
|
|
83
81
|
} catch {
|
|
84
82
|
// Skip if can't read
|
|
85
83
|
}
|
|
@@ -29,6 +29,7 @@ import { resolve, basename, join } from 'node:path';
|
|
|
29
29
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
30
30
|
import { getSection } from '../writers/sections.mjs';
|
|
31
31
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
32
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* v0.18-P1 fast-path: cheap pre-flight to detect whether ANY canonical doc
|
|
@@ -41,34 +42,36 @@ import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
|
41
42
|
*/
|
|
42
43
|
function _quickScan(projectDir) {
|
|
43
44
|
const out = { hasMarkers: false, hasDrafts: false };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// Recursive for docs-canonical/ — a marker in a nested doc must still be
|
|
46
|
+
// found, or this pre-flight false-negatives and skips the ENTIRE validator
|
|
47
|
+
// (buildMemoryPlan never even runs), silently disabling drift detection.
|
|
48
|
+
// projectDir itself stays a SHALLOW, non-recursive scan by design — it's
|
|
49
|
+
// here only to catch root-level README.md/AGENTS.md, not to walk the repo.
|
|
50
|
+
const candidateFiles = listCanonicalDocs(projectDir).map(d => d.abs);
|
|
51
|
+
try {
|
|
52
|
+
for (const entry of readdirSync(projectDir)) {
|
|
53
|
+
if (entry.endsWith('.md')) candidateFiles.push(join(projectDir, entry));
|
|
54
|
+
}
|
|
55
|
+
} catch { /* ignore */ }
|
|
56
|
+
|
|
48
57
|
// We only need a single match in any file to know the validator has work.
|
|
49
58
|
// Short-circuit aggressively: stop the moment we find either signal.
|
|
50
|
-
for (const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!out.hasMarkers && /<!--\s*docguard:section\s+[^>]*source=code/i.test(content)) {
|
|
65
|
-
out.hasMarkers = true;
|
|
66
|
-
}
|
|
67
|
-
if (!out.hasDrafts && /(?:^---\s*\n[\s\S]*?\bstatus:\s*draft\b[\s\S]*?\n---|<!--\s*status:\s*draft\s*-->)/im.test(content)) {
|
|
68
|
-
out.hasDrafts = true;
|
|
69
|
-
}
|
|
70
|
-
if (out.hasMarkers && out.hasDrafts) return out;
|
|
59
|
+
for (const full of candidateFiles) {
|
|
60
|
+
// Skip very large files quickly — for canonical docs, > 200 KB is unusual
|
|
61
|
+
// and almost certainly not the marker-heavy file we're looking for.
|
|
62
|
+
let stat;
|
|
63
|
+
try { stat = statSync(full); } catch { continue; }
|
|
64
|
+
if (!stat.isFile()) continue;
|
|
65
|
+
if (stat.size > 200_000) continue;
|
|
66
|
+
let content;
|
|
67
|
+
try { content = readFileSync(full, 'utf-8'); } catch { continue; }
|
|
68
|
+
if (!out.hasMarkers && /<!--\s*docguard:section\s+[^>]*source=code/i.test(content)) {
|
|
69
|
+
out.hasMarkers = true;
|
|
70
|
+
}
|
|
71
|
+
if (!out.hasDrafts && /(?:^---\s*\n[\s\S]*?\bstatus:\s*draft\b[\s\S]*?\n---|<!--\s*status:\s*draft\s*-->)/im.test(content)) {
|
|
72
|
+
out.hasDrafts = true;
|
|
71
73
|
}
|
|
74
|
+
if (out.hasMarkers && out.hasDrafts) return out;
|
|
72
75
|
}
|
|
73
76
|
return out;
|
|
74
77
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
42
42
|
import { resolve, extname, relative, basename } from 'node:path';
|
|
43
43
|
import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
|
|
44
|
-
import { walkFiles, isNonProductPath } from '../shared-ignore.mjs';
|
|
44
|
+
import { walkFiles, isNonProductPath, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
45
45
|
import { readScannable } from '../shared-source.mjs';
|
|
46
46
|
import { resolveDocDirs } from '../shared.mjs';
|
|
47
47
|
import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
|
|
@@ -170,12 +170,9 @@ function indexDocs(projectDir) {
|
|
|
170
170
|
docs.push({ name, path: full, refs: extractRefs(content) });
|
|
171
171
|
} catch { /* skip */ }
|
|
172
172
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
for (const f of readdirSync(docsDir)) if (f.endsWith('.md')) push(f, resolve(docsDir, f));
|
|
177
|
-
} catch { /* skip */ }
|
|
178
|
-
}
|
|
173
|
+
// Recursive. `name` stays the bare basename — matches this validator's
|
|
174
|
+
// pre-existing flat-tree message/location format exactly.
|
|
175
|
+
for (const doc of listCanonicalDocs(projectDir)) push(basename(doc.rel), doc.abs);
|
|
179
176
|
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
180
177
|
const p = resolve(projectDir, agent);
|
|
181
178
|
if (existsSync(p)) push(agent, p);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
18
18
|
import { TRACE_MAP, TEST_PATTERNS, isTraceableSource } from '../shared-trace-patterns.mjs';
|
|
19
|
-
import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
19
|
+
import { walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
20
20
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
21
|
import { tokenize } from '../shared-diff.mjs';
|
|
22
22
|
import { rankBySimilarity } from '../shared-ir.mjs';
|
|
@@ -231,21 +231,25 @@ export function validateTraceability(projectDir, config) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// ── Detect orphaned files (exist but not required) ──
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
234
|
+
// Recursive — a nested stray doc must be visible too. TRACE_MAP/requiredDocs
|
|
235
|
+
// matching stays keyed by bare basename (TRACE_MAP's own keys are
|
|
236
|
+
// conventional top-level names); `location` uses the real path so the
|
|
237
|
+
// finding points at the actual file instead of a fabricated flat one — for
|
|
238
|
+
// a flat tree `doc.rel` already equals the old `docs-canonical/${docFile}`
|
|
239
|
+
// template exactly, so this is a no-op on the flat case.
|
|
240
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
241
|
+
const docFile = basename(doc.rel);
|
|
242
|
+
if (!requiredDocs.has(docFile) && TRACE_MAP[docFile]) {
|
|
243
|
+
findings.push(mkFinding({
|
|
244
|
+
code: 'TRC003',
|
|
245
|
+
validator: 'traceability',
|
|
246
|
+
severity: 'warn',
|
|
247
|
+
message: `${docFile} — file exists in docs-canonical/ but is not in your requiredFiles config. Consider deleting it or adding it to .docguard.json requiredFiles.canonical`,
|
|
248
|
+
location: doc.rel,
|
|
249
|
+
suggestion: { kind: 'review', text: 'Delete the doc, or add it to requiredFiles.canonical in .docguard.json so it gets validated' },
|
|
250
|
+
}));
|
|
247
251
|
}
|
|
248
|
-
}
|
|
252
|
+
}
|
|
249
253
|
|
|
250
254
|
// ── Part 2: Requirement ID Traceability (V-Model) ──
|
|
251
255
|
const reqResult = validateRequirementTraceability(projectDir, config, projectFiles);
|
|
@@ -427,17 +431,10 @@ function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
|
427
431
|
function getRequirementDocPaths(projectDir, config) {
|
|
428
432
|
const paths = [];
|
|
429
433
|
|
|
430
|
-
// docs-canonical/ directory
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
for (const f of readdirSync(docsDir)) {
|
|
435
|
-
if (extname(f).toLowerCase() === '.md') {
|
|
436
|
-
paths.push(join(docsDir, f));
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
} catch { /* ignore */ }
|
|
440
|
-
}
|
|
434
|
+
// docs-canonical/ directory — recursive. Consumer re-derives the display
|
|
435
|
+
// path via relative(projectDir, docPath), so nested docs already report
|
|
436
|
+
// their real path with no further change needed there.
|
|
437
|
+
for (const doc of listCanonicalDocs(projectDir)) paths.push(doc.abs);
|
|
441
438
|
|
|
442
439
|
// Root-level docs
|
|
443
440
|
const rootDocs = ['REQUIREMENTS.md', 'spec.md', 'README.md'];
|
package/docs/faq.md
CHANGED
|
@@ -119,6 +119,25 @@ Exit code 0 = pass, 1 = fail. Use `--threshold` to set minimum score.
|
|
|
119
119
|
|
|
120
120
|
Yes — DocGuard ships a template at `templates/ci/github-actions.yml`. Copy it to `.github/workflows/` or use the reusable action in `action.yml`.
|
|
121
121
|
|
|
122
|
+
### Claude (or my AI agent) says DocGuard is "unknown" and wants a legitimacy check. Why?
|
|
123
|
+
|
|
124
|
+
That's the agent's default posture toward **any** third-party MCP server or
|
|
125
|
+
npx package — MCP servers run code with your permissions, so unknown ones get
|
|
126
|
+
a trust prompt. It's not a DocGuard-specific flag. What DocGuard provides so
|
|
127
|
+
the check can pass:
|
|
128
|
+
|
|
129
|
+
- **npm provenance** — releases are signed by GitHub Actions (Sigstore), so
|
|
130
|
+
the tarball is verifiably built from this repository.
|
|
131
|
+
- **Official MCP registry** — `io.github.raccioly/docguard` is a verified
|
|
132
|
+
namespace at registry.modelcontextprotocol.io.
|
|
133
|
+
- **Read-only annotations** — every MCP tool declares `readOnlyHint`; the
|
|
134
|
+
server never writes or reaches the network.
|
|
135
|
+
- **[PRIVACY.md](../PRIVACY.md)** — no telemetry, no data collection.
|
|
136
|
+
|
|
137
|
+
To stop the prompt on your own machines: approve with "Always allow", or
|
|
138
|
+
pre-trust it for a whole team via a project-scoped `.mcp.json` /
|
|
139
|
+
enterprise managed-settings allowlist.
|
|
140
|
+
|
|
122
141
|
### Does DocGuard block commits?
|
|
123
142
|
|
|
124
143
|
Only if you install hooks (`docguard hooks`). Without hooks, it's advisory only.
|
|
@@ -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.34.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.34.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.34.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.34.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.34.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.34.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.34.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.34.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.34.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.34.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
|
-
<!-- docguard:version: 0.
|
|
10
|
+
<!-- docguard:version: 0.34.0 -->
|
|
11
11
|
|
|
12
12
|
# DocGuard Sync Skill
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docguard-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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": {
|
|
7
|
-
"docguard": "
|
|
7
|
+
"docguard": "cli/docguard.mjs"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"docguard": "node cli/docguard.mjs",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/raccioly/docguard"
|
|
39
|
+
"url": "git+https://github.com/raccioly/docguard.git"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/raccioly/docguard#readme",
|
|
42
42
|
"mcpName": "io.github.raccioly/docguard",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"docs/",
|
|
58
58
|
"schemas/",
|
|
59
59
|
"STANDARD.md",
|
|
60
|
+
"PRIVACY.md",
|
|
60
61
|
"PHILOSOPHY.md",
|
|
61
62
|
"README.md",
|
|
62
63
|
"LICENSE"
|