docguard-cli 0.21.0 → 0.22.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 +20 -7
- package/cli/commands/explain.mjs +11 -0
- package/cli/commands/guard.mjs +2 -0
- package/cli/commands/init.mjs +13 -8
- package/cli/commands/memory.mjs +11 -1
- package/cli/commands/upgrade.mjs +12 -0
- package/cli/docguard.mjs +1 -1
- package/cli/ensure-skills.mjs +50 -8
- package/cli/scanners/routes.mjs +8 -2
- package/cli/shared-source.mjs +9 -0
- package/cli/validators/docs-diff.mjs +18 -3
- package/cli/validators/environment.mjs +13 -0
- package/cli/validators/freshness.mjs +33 -3
- package/cli/validators/surface-sync.mjs +365 -0
- package/cli/validators/traceability.mjs +65 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- 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 +1 -1
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +26 -0
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
> **The enforcement layer for Spec-Driven Development.**
|
|
4
4
|
> Validate. Score. Enforce. Ship documentation that AI agents can actually use.
|
|
5
5
|
|
|
6
|
+
[](https://github.com/raccioly/docguard/actions/workflows/ci.yml)
|
|
6
7
|
[](https://www.npmjs.com/package/docguard-cli)
|
|
8
|
+
[](https://www.npmjs.com/package/docguard-cli)
|
|
7
9
|
[](https://pypi.org/project/docguard-cli/)
|
|
8
10
|
[](https://opensource.org/licenses/MIT)
|
|
9
11
|
[](https://nodejs.org)
|
|
@@ -18,6 +20,8 @@
|
|
|
18
20
|
> ```
|
|
19
21
|
> Runs against a baked-in sample project with intentional drift and shows you the findings + a clear path to fixing them.
|
|
20
22
|
|
|
23
|
+

|
|
24
|
+
|
|
21
25
|
---
|
|
22
26
|
|
|
23
27
|
## Table of Contents
|
|
@@ -67,7 +71,7 @@ graph TD
|
|
|
67
71
|
Commands --> setup["setup wizard"]
|
|
68
72
|
Commands --> other["diff · init · fix · trace · impact · sync<br/>explain · memory · upgrade · agents · hooks · badge · ci · watch"]
|
|
69
73
|
|
|
70
|
-
guard --> Validators["Validators (
|
|
74
|
+
guard --> Validators["Validators (24)"]
|
|
71
75
|
generate --> Scanners["Scanners (4)<br/>routes · schemas · doc-tools · speckit"]
|
|
72
76
|
score --> Scoring["Weighted Scoring<br/>8 categories"]
|
|
73
77
|
diagnose --> Validators
|
|
@@ -95,7 +99,7 @@ graph TD
|
|
|
95
99
|
Recent highlights across the v0.16 → v0.19 line:
|
|
96
100
|
|
|
97
101
|
- **`docguard explain <validator>`** — `docguard explain freshness` prints purpose, rules, common
|
|
98
|
-
failures, and fix recipes for any of the
|
|
102
|
+
failures, and fix recipes for any of the 24 validators. No need to dig into source.
|
|
99
103
|
- **`docguard memory --diff`** — surface what changed in your canonical docs between two refs
|
|
100
104
|
(`HEAD~10..HEAD` by default). Great for code review and changelog drafting.
|
|
101
105
|
- **`docguard score --diff`** — see exactly which validators moved the score up or down between
|
|
@@ -237,7 +241,7 @@ DocGuard is a [community extension](https://github.com/github/spec-kit/blob/main
|
|
|
237
241
|
specify extension add docguard
|
|
238
242
|
```
|
|
239
243
|
|
|
240
|
-
This installs DocGuard's slash commands (`/docguard.guard`, `/docguard.review`, `/docguard.fix`, `/docguard.
|
|
244
|
+
This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/docguard.review`, `/docguard.fix`, `/docguard.update`) into your AI agent's command palette.
|
|
241
245
|
|
|
242
246
|
---
|
|
243
247
|
|
|
@@ -250,7 +254,7 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
|
|
|
250
254
|
| Command | What It Does |
|
|
251
255
|
|:--------|:-------------|
|
|
252
256
|
| `init` | Bootstrap a project (`--wizard` for interactive · `--with <name>` for scaffolders) |
|
|
253
|
-
| `guard` | Validate against canonical docs —
|
|
257
|
+
| `guard` | Validate against canonical docs — 24 validators |
|
|
254
258
|
| `diff` | Show gaps between docs and code (`--since <ref>` for impact mode) |
|
|
255
259
|
| `sync` | Refresh code-truth doc sections — keeps memory always up to date |
|
|
256
260
|
| `score` | CDD maturity score (0-100; `--diff` for delta between refs) |
|
|
@@ -259,6 +263,7 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
|
|
|
259
263
|
|
|
260
264
|
| Command | Purpose |
|
|
261
265
|
|:--------|:--------|
|
|
266
|
+
| `demo` | Zero-install showcase — runs guard against a baked-in drifting fixture (`npx docguard-cli demo`) |
|
|
262
267
|
| `diagnose` | AI orchestrator — guard → emit fix prompts in one command |
|
|
263
268
|
| `fix` | Generate AI fix instructions for specific docs (`--doc <name> --format prompt`) |
|
|
264
269
|
| `fix --write` | Apply deterministic fixes (no AI — version bumps, counts, anchors, sections) |
|
|
@@ -343,7 +348,7 @@ $ npx docguard-cli generate
|
|
|
343
348
|
|
|
344
349
|
## 🔍 Validators
|
|
345
350
|
|
|
346
|
-
DocGuard runs **
|
|
351
|
+
DocGuard runs **24 automated validators** on every `guard` check. Every one is **language-aware** as of v0.16 — patterns for Python (`test_*.py`), Rust (`tests/*.rs`), Go (`*_test.go`), Java (`*Test.java`), Ruby (`*_spec.rb`), PHP, and JS/TS all match.
|
|
347
352
|
|
|
348
353
|
| # | Validator | What It Checks | Default |
|
|
349
354
|
|:--|:----------|:--------------|:--------|
|
|
@@ -370,6 +375,7 @@ DocGuard runs **23 automated validators** on every `guard` check. Every one is *
|
|
|
370
375
|
| 21 | **Generated-Staleness** | `source=code` sections match scanner output; `status: draft` doc age | ✅ On |
|
|
371
376
|
| 22 | **Canonical-Sync** | DocGuard's own README count claims match code-truth (DocGuard repo only — N/A elsewhere) | ✅ On |
|
|
372
377
|
| 23 | **Metrics-Consistency** | Hardcoded numbers match actual counts | ✅ On |
|
|
378
|
+
| 24 | **Surface-Sync** | Item-level enumerable drift — names in doc tables/lists (commands, validators, etc.) match code-truth (opt-in via `surfaceSync.surfaces`; N/A unless configured) | ✅ On |
|
|
373
379
|
|
|
374
380
|
**Per-validator controls** (in `.docguard.json`):
|
|
375
381
|
```json
|
|
@@ -437,10 +443,11 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
|
|
|
437
443
|
|
|
438
444
|
| Command | What It Does |
|
|
439
445
|
|:--------|:-------------|
|
|
440
|
-
| `/docguard.
|
|
446
|
+
| `/docguard.init` | Initialize Canonical-Driven Development in a new or existing project |
|
|
447
|
+
| `/docguard.guard` | Run quality validation — check all 24 validators |
|
|
441
448
|
| `/docguard.review` | Analyze doc quality and suggest improvements |
|
|
442
449
|
| `/docguard.fix` | Generate targeted fix prompts for specific issues |
|
|
443
|
-
| `/docguard.
|
|
450
|
+
| `/docguard.update` | Update canonical docs after code changes — detect drift and sync documentation |
|
|
444
451
|
|
|
445
452
|
These commands are installed into your AI agent's command directory:
|
|
446
453
|
|
|
@@ -657,6 +664,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#research--academic-credits) for full citat
|
|
|
657
664
|
|
|
658
665
|
---
|
|
659
666
|
|
|
667
|
+
## ⭐ Star History
|
|
668
|
+
|
|
669
|
+
[](https://star-history.com/#raccioly/docguard&Date)
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
660
673
|
## 📄 License
|
|
661
674
|
|
|
662
675
|
[MIT](LICENSE) — Free to use, modify, and distribute.
|
package/cli/commands/explain.mjs
CHANGED
|
@@ -150,6 +150,17 @@ const EXPLAINERS = {
|
|
|
150
150
|
example: 'AGENTS.md says "22 validators" and `docguard guard` shows 22 active validators',
|
|
151
151
|
standard: 'CDD principle: documented metrics match reality',
|
|
152
152
|
},
|
|
153
|
+
surfaceSync: {
|
|
154
|
+
title: 'Surface-Sync — every code-derived list entry is documented',
|
|
155
|
+
what: 'Item-level check (complementing canonical-sync\'s count-level check). For each configured surface (e.g. commands → `cli/commands/*.mjs`), compares the discovered files against the names appearing in table rows / bullet lists in target docs (README.md, AGENTS.md). Warns when a code item is missing from the doc, or a doc item is missing from code.',
|
|
156
|
+
why: 'Counts can match while lists drift — README claimed "14 commands" while the table only listed 13 (demo was missing). Count validators celebrated; users hit "command not found" anyway. This check catches that case.',
|
|
157
|
+
triggers: [
|
|
158
|
+
['Surface "X" drift: N in code but missing from README.md', 'Add the missing items to the relevant table / bullet list in the target doc. Or, if intentional (deprecation alias, scaffolder behind --with), add the names to the surface\'s `ignore` list in .docguard.json.'],
|
|
159
|
+
['Surface "X" drift: N listed in README.md but not found in code', 'A documented item no longer exists in code. Either remove it from the doc (it was deleted) or restore the file/command (it was deleted by mistake).'],
|
|
160
|
+
],
|
|
161
|
+
example: '`cli/commands/demo.mjs` exists and `| `demo` | Zero-install preview |` appears in README.md\'s commands table',
|
|
162
|
+
standard: 'CDD principle: documented surfaces match implemented surfaces',
|
|
163
|
+
},
|
|
153
164
|
crossReference: {
|
|
154
165
|
title: 'Cross-Reference — internal markdown links resolve',
|
|
155
166
|
what: 'Scans canonical docs for `[text](./OTHER.md#anchor)` and `#anchor` links. Verifies the target file exists and the anchor matches a heading.',
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -141,6 +141,7 @@ import { validateTodoTracking } from '../validators/todo-tracking.mjs';
|
|
|
141
141
|
import { validateSchemaSync } from '../validators/schema-sync.mjs';
|
|
142
142
|
import { validateSpecKitIntegration } from '../validators/spec-kit.mjs';
|
|
143
143
|
import { validateCanonicalSync } from '../validators/canonical-sync.mjs';
|
|
144
|
+
import { validateSurfaceSync } from '../validators/surface-sync.mjs';
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
147
|
* Internal guard — returns structured data, no console output, no process.exit.
|
|
@@ -215,6 +216,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
215
216
|
{ key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
|
|
216
217
|
{ key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
|
|
217
218
|
{ key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
|
|
219
|
+
{ key: 'surfaceSync', name: 'Surface-Sync', fn: () => validateSurfaceSync(projectDir, config) },
|
|
218
220
|
// Metrics-Consistency runs post-loop (needs guard results)
|
|
219
221
|
];
|
|
220
222
|
|
package/cli/commands/init.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { createInterface } from 'node:readline';
|
|
16
16
|
import { execSync } from 'node:child_process';
|
|
17
17
|
import { c, PROFILES } from '../shared.mjs';
|
|
18
|
-
import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent } from '../ensure-skills.mjs';
|
|
18
|
+
import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent, safeSpawnSpecify } from '../ensure-skills.mjs';
|
|
19
19
|
|
|
20
20
|
// v0.20: scaffolder names that can be passed via `init --with <name>` and
|
|
21
21
|
// dispatched to the corresponding standalone runner. Each name maps to its
|
|
@@ -378,17 +378,22 @@ poetry.lock
|
|
|
378
378
|
} else if (specKitAvailable && !specKitInitialized) {
|
|
379
379
|
console.log(`\n ${c.bold}🌱 Spec Kit Integration${c.reset}`);
|
|
380
380
|
|
|
381
|
-
// Detect which AI agent is in use (matches spec-kit's --ai flag)
|
|
381
|
+
// Detect which AI agent is in use (matches spec-kit's --ai flag).
|
|
382
|
+
// v0.21.1 (issue #190): the returned value is allowlist-validated inside
|
|
383
|
+
// getDetectedAgent, so an attacker-controlled `.specify/init-options.json`
|
|
384
|
+
// can no longer inject shell metacharacters here.
|
|
382
385
|
const detectedAgent = detectAIAgent(projectDir);
|
|
383
|
-
const
|
|
384
|
-
?
|
|
385
|
-
: '--ai generic --ai-commands-dir .agent/commands/';
|
|
386
|
+
const aiArgs = detectedAgent
|
|
387
|
+
? ['--ai', detectedAgent]
|
|
388
|
+
: ['--ai', 'generic', '--ai-commands-dir', '.agent/commands/'];
|
|
386
389
|
|
|
387
390
|
console.log(` ${c.dim}Running specify init (agent: ${detectedAgent || 'generic'})...${c.reset}`);
|
|
388
391
|
try {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
+
// v0.21.1 (issue #190): execFileSync via safeSpawnSpecify — args pass
|
|
393
|
+
// through as an array, no shell interpolation.
|
|
394
|
+
const scriptArgs = process.platform === 'win32' ? ['--script', 'ps'] : ['--script', 'sh'];
|
|
395
|
+
safeSpawnSpecify(
|
|
396
|
+
['init', '--here', '--force', ...aiArgs, '--ai-skills', '--ignore-agent-tools', '--no-git', ...scriptArgs],
|
|
392
397
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30000 }
|
|
393
398
|
);
|
|
394
399
|
console.log(` ${c.green}✅${c.reset} Spec Kit initialized ${c.dim}(.specify/, spec-kit skills, agent: ${detectedAgent || 'generic'})${c.reset}`);
|
package/cli/commands/memory.mjs
CHANGED
|
@@ -84,8 +84,18 @@ export function runMemory(projectDir, config, flags) {
|
|
|
84
84
|
// ── Text output ──
|
|
85
85
|
console.log(`${c.bold}🧠 DocGuard Memory${c.reset} ${c.dim}— ${config.projectName}${c.reset}\n`);
|
|
86
86
|
|
|
87
|
+
// Label is `Claim match rate` (not `Accuracy`) to disambiguate from the
|
|
88
|
+
// score-level `Accuracy` metric in `docguard score`, which is a weighted
|
|
89
|
+
// average across the entire accuracy axis (apiSurface, environment, drift,
|
|
90
|
+
// metadata-sync, etc.) and uses a different denominator. Both numbers are
|
|
91
|
+
// legitimate, but the same word was pointing at two different calculations
|
|
92
|
+
// — users saw "Accuracy 0%" here and "Accuracy 93%" in `score` on the same
|
|
93
|
+
// project and reasonably read it as a contradiction. This metric is the
|
|
94
|
+
// strict per-claim ratio (matched claims / total checked claims, restricted
|
|
95
|
+
// to domains that actually have any claims).
|
|
87
96
|
const accColor = overallAccuracy >= 90 ? c.green : overallAccuracy >= 70 ? c.yellow : c.red;
|
|
88
|
-
console.log(` ${c.bold}
|
|
97
|
+
console.log(` ${c.bold}Claim match rate:${c.reset} ${accColor}${overallAccuracy}%${c.reset} ${c.dim}(${totalMatched}/${totalChecks} doc claims match code)${c.reset}`);
|
|
98
|
+
console.log(` ${c.dim} ↳ See ${c.cyan}docguard score${c.dim} for the weighted accuracy axis across all domains.${c.reset}\n`);
|
|
89
99
|
|
|
90
100
|
if (totalChecks === 0) {
|
|
91
101
|
console.log(` ${c.dim}No applicable domains found — add canonical docs (API-REFERENCE.md, DATA-MODEL.md, ENVIRONMENT.md) and rerun.${c.reset}`);
|
package/cli/commands/upgrade.mjs
CHANGED
|
@@ -274,6 +274,18 @@ export async function runUpgrade(projectDir, _config, flags) {
|
|
|
274
274
|
process.exit(1);
|
|
275
275
|
}
|
|
276
276
|
console.log(` ${c.green}✓ CLI upgraded.${c.reset}`);
|
|
277
|
+
// Validator-count drift: a CLI upgrade often adds or removes validators,
|
|
278
|
+
// which changes the "N validators" / "N checks" totals that Metrics-
|
|
279
|
+
// Consistency scans for in markdown. Without a clear nudge here, a
|
|
280
|
+
// previously-green project goes red on the very next `docguard guard`
|
|
281
|
+
// for a reason the user did not cause. The fix is mechanical (replace
|
|
282
|
+
// hardcoded counts via fix --write) but the user has to know to run it.
|
|
283
|
+
// We don't auto-run fix --write from this process — the just-installed
|
|
284
|
+
// binary has the new validator list, but THIS process is still the OLD
|
|
285
|
+
// binary, so running fix here would use stale counts.
|
|
286
|
+
console.log(` ${c.yellow}ℹ Validator/check totals may have shifted with this upgrade.${c.reset}`);
|
|
287
|
+
console.log(` Run ${c.cyan}docguard fix --write${c.reset} ${c.dim}to refresh hardcoded counts in your docs${c.reset}`);
|
|
288
|
+
console.log(` ${c.dim}(picks up the new totals from the just-installed CLI).${c.reset}`);
|
|
277
289
|
}
|
|
278
290
|
|
|
279
291
|
if (schemaBehind && projectSchema) {
|
package/cli/docguard.mjs
CHANGED
|
@@ -230,7 +230,7 @@ const _KNOWN_VALIDATORS = [
|
|
|
230
230
|
'security', 'architecture', 'freshness', 'traceability', 'docsDiff',
|
|
231
231
|
'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
|
|
232
232
|
'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
|
|
233
|
-
'metricsConsistency',
|
|
233
|
+
'canonicalSync', 'surfaceSync', 'metricsConsistency',
|
|
234
234
|
];
|
|
235
235
|
|
|
236
236
|
function _kebabToCamel(k) {
|
package/cli/ensure-skills.mjs
CHANGED
|
@@ -13,9 +13,32 @@
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
14
14
|
import { resolve, dirname } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { execSync } from 'node:child_process';
|
|
16
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
17
17
|
import { c } from './shared.mjs';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* v0.21.1 (security): cross-platform safe spawn for the `specify` CLI.
|
|
21
|
+
*
|
|
22
|
+
* On POSIX, runs the `specify` binary directly with argv passed as an array
|
|
23
|
+
* — no shell interpolation possible. On Windows, the equivalent is via
|
|
24
|
+
* `cmd.exe /c specify.cmd ...` since `specify` is shipped as a .cmd shim by
|
|
25
|
+
* `pip install`. Args are still passed as an array so cmd.exe doesn't
|
|
26
|
+
* re-parse them.
|
|
27
|
+
*
|
|
28
|
+
* Replaces the pre-v0.21.1 pattern of `execSync(\`specify init ... \${flag} ...\`)`
|
|
29
|
+
* which was shell-interpolated and vulnerable to command injection via
|
|
30
|
+
* `.specify/init-options.json`'s `ai` field (issue #190).
|
|
31
|
+
*/
|
|
32
|
+
export function safeSpawnSpecify(args, opts) {
|
|
33
|
+
if (!Array.isArray(args)) {
|
|
34
|
+
throw new TypeError('safeSpawnSpecify(args, opts): args must be an array');
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
return execFileSync('cmd.exe', ['/c', 'specify.cmd', ...args], opts);
|
|
38
|
+
}
|
|
39
|
+
return execFileSync('specify', args, opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
43
|
const __dirname = dirname(__filename);
|
|
21
44
|
|
|
@@ -77,12 +100,27 @@ export function detectAgentMode(projectDir) {
|
|
|
77
100
|
* @param {string} projectDir - The project root directory
|
|
78
101
|
* @returns {string | null}
|
|
79
102
|
*/
|
|
103
|
+
// v0.21.1 (security): allowlist for the spec-kit --ai flag value. Source
|
|
104
|
+
// values come from `.specify/init-options.json` which is attacker-writable
|
|
105
|
+
// in any compromised project. Without this filter, a value like
|
|
106
|
+
// `"claude; touch /tmp/pwned;"` would shell-execute on every `docguard init`.
|
|
107
|
+
//
|
|
108
|
+
// Set conservatively from spec-kit's published agent list. New agents
|
|
109
|
+
// require a code change to be accepted — by design.
|
|
110
|
+
const VALID_AI_AGENT = /^[a-zA-Z0-9_-]{1,32}$/;
|
|
111
|
+
|
|
80
112
|
export function getDetectedAgent(projectDir) {
|
|
81
113
|
const initOptions = resolve(projectDir, '.specify', 'init-options.json');
|
|
82
114
|
if (existsSync(initOptions)) {
|
|
83
115
|
try {
|
|
84
116
|
const opts = JSON.parse(readFileSync(initOptions, 'utf-8'));
|
|
85
|
-
|
|
117
|
+
const ai = opts.ai;
|
|
118
|
+
if (typeof ai !== 'string') return null;
|
|
119
|
+
// v0.21.1 (issue #190): reject anything outside the allowlist. Without
|
|
120
|
+
// this, a malicious `.specify/init-options.json` could inject shell
|
|
121
|
+
// metacharacters through to the `specify init` exec call.
|
|
122
|
+
if (!VALID_AI_AGENT.test(ai)) return null;
|
|
123
|
+
return ai;
|
|
86
124
|
} catch { /* ignore */ }
|
|
87
125
|
}
|
|
88
126
|
return null;
|
|
@@ -193,13 +231,17 @@ export function ensureSpecKit(projectDir, flags = {}) {
|
|
|
193
231
|
console.log(` ${c.cyan}🌱 Spec Kit detected — auto-initializing SDD workflow...${c.reset}`);
|
|
194
232
|
}
|
|
195
233
|
try {
|
|
234
|
+
// v0.21.1 (issue #190): switched from shell-interpolated execSync to
|
|
235
|
+
// execFileSync via safeSpawnSpecify. detectAIAgent now also enforces
|
|
236
|
+
// the [a-zA-Z0-9_-]{1,32} allowlist on values read from .specify/
|
|
237
|
+
// init-options.json — defense in depth.
|
|
196
238
|
const detectedAgent = detectAIAgent(projectDir);
|
|
197
|
-
const
|
|
198
|
-
?
|
|
199
|
-
: '--ai generic --ai-commands-dir .agent/commands/';
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
239
|
+
const aiArgs = detectedAgent
|
|
240
|
+
? ['--ai', detectedAgent]
|
|
241
|
+
: ['--ai', 'generic', '--ai-commands-dir', '.agent/commands/'];
|
|
242
|
+
const scriptArgs = process.platform === 'win32' ? ['--script', 'ps'] : ['--script', 'sh'];
|
|
243
|
+
safeSpawnSpecify(
|
|
244
|
+
['init', '--here', '--force', ...aiArgs, '--ai-skills', '--ignore-agent-tools', '--no-git', ...scriptArgs],
|
|
203
245
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30000 }
|
|
204
246
|
);
|
|
205
247
|
if (!silent) {
|
package/cli/scanners/routes.mjs
CHANGED
|
@@ -108,8 +108,14 @@ function scanNextJsRoutes(dir) {
|
|
|
108
108
|
const content = readFileSafe(filePath);
|
|
109
109
|
if (!content) return;
|
|
110
110
|
|
|
111
|
-
// Path from directory structure
|
|
112
|
-
|
|
111
|
+
// Path from directory structure. The HTTP base in Next.js App Router is
|
|
112
|
+
// `/api/...` — Next strips everything up to and including the `app/`
|
|
113
|
+
// segment. Compute the relative path from the directory ABOVE `api/`
|
|
114
|
+
// so both `app/api` (no src layout) and `src/app/api` (src layout)
|
|
115
|
+
// produce `/api/<segments>`. Previously `appDir.split('/')[0]` stripped
|
|
116
|
+
// only `src/` for the src layout, leaking `app/` into the emitted path.
|
|
117
|
+
const apiBase = appDir.slice(0, appDir.lastIndexOf('/'));
|
|
118
|
+
const relDir = relative(resolve(dir, apiBase), dirname(filePath));
|
|
113
119
|
const apiPath = '/' + relDir
|
|
114
120
|
.replace(/\\/g, '/')
|
|
115
121
|
.replace(/\[\.\.\.(\w+)\]/g, ':$1*') // Catch-all [...slug]
|
package/cli/shared-source.mjs
CHANGED
|
@@ -217,6 +217,15 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
217
217
|
new RegExp(`process\\.env\\.${NAME}`, 'g'),
|
|
218
218
|
new RegExp(`process\\.env\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
|
|
219
219
|
new RegExp(`import\\.meta\\.env\\.${NAME}`, 'g'),
|
|
220
|
+
// Python: `os.environ["X"]`, `os.environ.get("X")`, `os.getenv("X")`. The
|
|
221
|
+
// `explain` command and ENVIRONMENT.md templates have always told users
|
|
222
|
+
// these forms are scanned, but the implementation only handled JS. On a
|
|
223
|
+
// Python project this caused every documented env var to be reported as
|
|
224
|
+
// "in docs, not in code" — a silent 0% accuracy. Patterns cover bracket
|
|
225
|
+
// access, .get(), and the standalone os.getenv() function.
|
|
226
|
+
new RegExp(`os\\.environ\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
|
|
227
|
+
new RegExp(`os\\.environ\\.get\\s*\\(\\s*['"]${NAME}['"]`, 'g'),
|
|
228
|
+
new RegExp(`os\\.getenv\\s*\\(\\s*['"]${NAME}['"]`, 'g'),
|
|
220
229
|
];
|
|
221
230
|
// Vite injects these at build time; they are not user-set env vars.
|
|
222
231
|
const VITE_INTRINSICS = new Set(['DEV', 'PROD', 'MODE', 'BASE_URL', 'SSR']);
|
|
@@ -42,6 +42,17 @@ export function validateDocsDiff(projectDir, config) {
|
|
|
42
42
|
diffTests(projectDir, config),
|
|
43
43
|
];
|
|
44
44
|
|
|
45
|
+
// Limit how many offending names are inlined in a single warning — keeps
|
|
46
|
+
// the line readable on terminals while still naming the specific files so
|
|
47
|
+
// the warning is actually actionable. Without these names the user gets a
|
|
48
|
+
// bare count ("1 documented but not found in code") with no path to debug.
|
|
49
|
+
const MAX_INLINE = 5;
|
|
50
|
+
const fmtList = (arr) => {
|
|
51
|
+
const shown = arr.slice(0, MAX_INLINE).map(v => `\`${v}\``).join(', ');
|
|
52
|
+
const extra = arr.length - MAX_INLINE;
|
|
53
|
+
return extra > 0 ? `${shown} (+${extra} more)` : shown;
|
|
54
|
+
};
|
|
55
|
+
|
|
45
56
|
for (const result of checks) {
|
|
46
57
|
if (!result) continue;
|
|
47
58
|
|
|
@@ -53,9 +64,13 @@ export function validateDocsDiff(projectDir, config) {
|
|
|
53
64
|
passed++;
|
|
54
65
|
} else {
|
|
55
66
|
const parts = [];
|
|
56
|
-
if (undocumented > 0)
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
if (undocumented > 0) {
|
|
68
|
+
parts.push(`${undocumented} in code but not documented: ${fmtList(result.onlyInCode)}`);
|
|
69
|
+
}
|
|
70
|
+
if (stale > 0) {
|
|
71
|
+
parts.push(`${stale} documented but not found in code: ${fmtList(result.onlyInDocs)}`);
|
|
72
|
+
}
|
|
73
|
+
warnings.push(`${result.title} drift: ${parts.join('; ')}`);
|
|
59
74
|
}
|
|
60
75
|
}
|
|
61
76
|
|
|
@@ -64,6 +64,19 @@ export function validateEnvironment(projectDir, config) {
|
|
|
64
64
|
if (SYSTEM.has(m[1])) continue; // v0.16-P4: prose mentions of system vars are not docs
|
|
65
65
|
documented.add(m[1]);
|
|
66
66
|
}
|
|
67
|
+
// Also extract markdown table rows where the first column is a bare env
|
|
68
|
+
// var name (no backticks). Real-world ENVIRONMENT.md docs frequently use
|
|
69
|
+
// pipe tables with un-backticked names — the backtick-only regex above
|
|
70
|
+
// silently misses every suffixed variant (DYNAMODB_TABLE_JOBS,
|
|
71
|
+
// DYNAMODB_TABLE_SOURCES, …) and they get reported as undocumented.
|
|
72
|
+
// Match `| VAR_NAME |` anywhere on the line; require the row to also
|
|
73
|
+
// contain a second `|` (real table row), not a stray pipe in prose.
|
|
74
|
+
const tableRe = /^\s*\|\s*([A-Z][A-Z0-9_]*[A-Z0-9])\s*\|/gm;
|
|
75
|
+
while ((m = tableRe.exec(content)) !== null) {
|
|
76
|
+
if (m[1].length < 3) continue;
|
|
77
|
+
if (SYSTEM.has(m[1])) continue;
|
|
78
|
+
documented.add(m[1]);
|
|
79
|
+
}
|
|
67
80
|
for (const envFile of ['.env.example', '.env.template']) {
|
|
68
81
|
const p = resolve(projectDir, envFile);
|
|
69
82
|
if (!existsSync(p)) continue;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* but the code has already been implemented and committed.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, extname } from 'node:path';
|
|
11
11
|
import { execSync, execFileSync } from 'node:child_process';
|
|
12
12
|
|
|
@@ -34,6 +34,26 @@ const IGNORE_DIRS = new Set([
|
|
|
34
34
|
'templates', 'configs', 'Research',
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Read the `<!-- docguard:last-reviewed YYYY-MM-DD -->` header from a doc file.
|
|
39
|
+
* Returns the parsed Date when present, null otherwise (file missing, header
|
|
40
|
+
* absent, or date unparseable). The header is the authoritative review date
|
|
41
|
+
* — it represents an explicit human review action that `git log` cannot see
|
|
42
|
+
* (e.g., the reviewer read the file, confirmed it still matches reality, and
|
|
43
|
+
* stamped the header without touching content, so there is no commit to find).
|
|
44
|
+
*/
|
|
45
|
+
function readLastReviewedDate(absPath) {
|
|
46
|
+
try {
|
|
47
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
48
|
+
const m = content.match(/<!--\s*docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})\s*-->/);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const d = new Date(m[1] + 'T00:00:00Z');
|
|
51
|
+
return isNaN(d.getTime()) ? null : d;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
/**
|
|
38
58
|
* Get the last git commit date for a file.
|
|
39
59
|
* Returns null if the file isn't tracked or git isn't available.
|
|
@@ -170,7 +190,15 @@ export function validateFreshness(dir, config) {
|
|
|
170
190
|
const docPath = resolve(dir, docFile);
|
|
171
191
|
if (!existsSync(docPath)) continue;
|
|
172
192
|
|
|
173
|
-
|
|
193
|
+
// Prefer the explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` header
|
|
194
|
+
// over the git commit date. A reviewer who reads a doc and stamps the
|
|
195
|
+
// header without changing content has signaled "I confirmed this is still
|
|
196
|
+
// current" — git log cannot see that signal, so it would falsely flag the
|
|
197
|
+
// doc as stale despite the explicit review. Fall back to git log only when
|
|
198
|
+
// the header is absent. Symmetric with the ALCOA+ "review metadata present"
|
|
199
|
+
// check in score.mjs, which reads the same header.
|
|
200
|
+
const reviewedDate = readLastReviewedDate(docPath);
|
|
201
|
+
const docDate = reviewedDate || getLastGitDate(docFile, dir);
|
|
174
202
|
if (!docDate) {
|
|
175
203
|
// File exists but isn't tracked in git yet
|
|
176
204
|
results.push({
|
|
@@ -212,7 +240,9 @@ export function validateFreshness(dir, config) {
|
|
|
212
240
|
// ── 2. Check CHANGELOG.md was updated in the last 5 code commits ──
|
|
213
241
|
const changelogPath = resolve(dir, config.requiredFiles?.changelog || 'CHANGELOG.md');
|
|
214
242
|
if (existsSync(changelogPath)) {
|
|
215
|
-
const changelogDate =
|
|
243
|
+
const changelogDate =
|
|
244
|
+
readLastReviewedDate(changelogPath) ||
|
|
245
|
+
getLastGitDate(config.requiredFiles?.changelog || 'CHANGELOG.md', dir);
|
|
216
246
|
if (changelogDate && latestCodeDate) {
|
|
217
247
|
const daysDiff = Math.floor((latestCodeDate - changelogDate) / (1000 * 60 * 60 * 24));
|
|
218
248
|
if (daysDiff > 7) {
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface-Sync Validator — item-level drift detection for enumerable surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Complements canonical-sync (which checks NUMERIC count claims) by checking
|
|
5
|
+
* that each individual ITEM in a code-derived list is actually documented as
|
|
6
|
+
* a list entry in the target markdown file(s). Catches the "demo command
|
|
7
|
+
* exists in code, the count matches, but it's missing from the README's
|
|
8
|
+
* command table" class of drift — invisible to count-based checks.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: canonical-sync was passing 3/3 on a docguard repo whose
|
|
11
|
+
* README's command table omitted `demo` entirely. The count matched ("14
|
|
12
|
+
* commands" = 14 user-facing commands in --help) but the table only listed
|
|
13
|
+
* 13 of them. Without an item-level check, a doc can be silently wrong while
|
|
14
|
+
* every count-based validator celebrates. That's exactly the credibility hit
|
|
15
|
+
* the user reported.
|
|
16
|
+
*
|
|
17
|
+
* How it works: for each configured surface (commands, validators, slash
|
|
18
|
+
* commands, templates, anything enumerable), we:
|
|
19
|
+
* 1. Discover the code-truth set from a glob pattern + basename extractor
|
|
20
|
+
* 2. For each target doc, parse all markdown tables and bullet lists,
|
|
21
|
+
* collecting backticked tokens that look like identifiers from the
|
|
22
|
+
* FIRST column / item position only (not arbitrary prose mentions)
|
|
23
|
+
* 3. Diff the two sets and warn on items present in code but absent from
|
|
24
|
+
* the doc, OR present in the doc but missing from code (likely
|
|
25
|
+
* removed / renamed)
|
|
26
|
+
*
|
|
27
|
+
* Scoped to list contexts on purpose: scanning every backtick would produce
|
|
28
|
+
* false positives every time someone wrote `--verbose` or `process.env.X`
|
|
29
|
+
* in prose. A table row or bullet item is a deliberate inventory signal —
|
|
30
|
+
* docguard treats those as the doc's authoritative list, ignores the rest.
|
|
31
|
+
*
|
|
32
|
+
* Default: N/A. The validator is OFF unless the project's .docguard.json
|
|
33
|
+
* declares at least one surface under `validators.surfaceSync.surfaces`.
|
|
34
|
+
* This keeps the upgrade path safe for projects that don't opt in.
|
|
35
|
+
*
|
|
36
|
+
* Config shape (in .docguard.json):
|
|
37
|
+
* {
|
|
38
|
+
* "validators": { "surfaceSync": true },
|
|
39
|
+
* "surfaceSync": {
|
|
40
|
+
* "surfaces": [
|
|
41
|
+
* {
|
|
42
|
+
* "name": "commands",
|
|
43
|
+
* "glob": "cli/commands/*.mjs",
|
|
44
|
+
* "extract": "basename-no-ext", // "basename" or "basename-no-ext"
|
|
45
|
+
* "ignore": ["setup", "impact"], // known aliases / non-public items
|
|
46
|
+
* "docs": ["README.md"]
|
|
47
|
+
* }
|
|
48
|
+
* ]
|
|
49
|
+
* }
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* Severity: MEDIUM. Wrong-list drift is real but rarely a build-breaker —
|
|
53
|
+
* users can still read the doc and discover the missing surface via --help.
|
|
54
|
+
* Demoting to LOW makes drift invisible; promoting to HIGH would conflict
|
|
55
|
+
* with the existing reality that many projects ship slightly-stale READMEs.
|
|
56
|
+
*
|
|
57
|
+
* Zero NPM runtime dependencies — pure Node.js built-ins only.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
61
|
+
import { resolve, join, basename, extname, relative, dirname } from 'node:path';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Expand a simple glob (only supports `*` at the leaf segment level —
|
|
65
|
+
* sufficient for `cli/commands/*.mjs`, `templates/*.md.template`, etc.).
|
|
66
|
+
* Recursive `**` is NOT supported by design — surface lists live in
|
|
67
|
+
* known shallow directories; deep recursion would be a configuration
|
|
68
|
+
* smell, not a feature.
|
|
69
|
+
*/
|
|
70
|
+
function expandGlob(projectDir, glob) {
|
|
71
|
+
const slash = glob.lastIndexOf('/');
|
|
72
|
+
const dir = slash >= 0 ? glob.slice(0, slash) : '.';
|
|
73
|
+
const pattern = slash >= 0 ? glob.slice(slash + 1) : glob;
|
|
74
|
+
const absDir = resolve(projectDir, dir);
|
|
75
|
+
if (!existsSync(absDir)) return [];
|
|
76
|
+
|
|
77
|
+
// Convert glob to regex: escape regex specials, then `*` → `.*`
|
|
78
|
+
const rx = new RegExp('^' + pattern
|
|
79
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
80
|
+
.replace(/\*/g, '.*') + '$');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return readdirSync(absDir)
|
|
84
|
+
.filter(name => rx.test(name))
|
|
85
|
+
.map(name => join(absDir, name))
|
|
86
|
+
.filter(full => {
|
|
87
|
+
try { return statSync(full).isFile(); } catch { return false; }
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract the surface name from a file path according to the configured rule.
|
|
96
|
+
* `basename` → keep the full filename: `guard.mjs`
|
|
97
|
+
* `basename-no-ext` → strip the single trailing extension: `guard`
|
|
98
|
+
*
|
|
99
|
+
* `basename-no-ext` strips ONLY the last extension on purpose. For files like
|
|
100
|
+
* `ARCHITECTURE.md.template`, `basename-no-ext` returns `ARCHITECTURE.md` —
|
|
101
|
+
* which is the canonical doc name the template generates. If the user wants
|
|
102
|
+
* the bare `ARCHITECTURE`, they can use a custom extractor (not yet supported).
|
|
103
|
+
*/
|
|
104
|
+
function applyExtractor(filePath, extractor) {
|
|
105
|
+
const base = basename(filePath);
|
|
106
|
+
if (extractor === 'basename') return base;
|
|
107
|
+
if (extractor === 'basename-no-ext') {
|
|
108
|
+
const ext = extname(base);
|
|
109
|
+
return ext ? base.slice(0, -ext.length) : base;
|
|
110
|
+
}
|
|
111
|
+
// Unknown extractor → fall back to bare basename. Don't throw — a future
|
|
112
|
+
// CLI version might introduce more extractors, and we want old configs to
|
|
113
|
+
// degrade gracefully rather than crash a guard run.
|
|
114
|
+
return base;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Slice a markdown document down to a single section, identified by a heading
|
|
119
|
+
* substring match (case-insensitive). The slice runs from that heading to the
|
|
120
|
+
* next heading of equal or higher level (so a `##` section ends at the next
|
|
121
|
+
* `##` or `#`, but `###` subsections are included).
|
|
122
|
+
*
|
|
123
|
+
* Returns the original `content` if `heading` is falsy. Returns an empty string
|
|
124
|
+
* if the heading isn't found — callers treat that as "section absent, no
|
|
125
|
+
* documented tokens here," which surfaces as a "missing-from-doc" warning for
|
|
126
|
+
* every code item (correctly: the section the user said to scope to does not
|
|
127
|
+
* exist in this doc).
|
|
128
|
+
*/
|
|
129
|
+
function sliceSection(content, heading) {
|
|
130
|
+
if (!heading) return content;
|
|
131
|
+
const lines = content.split('\n');
|
|
132
|
+
const needle = String(heading).toLowerCase().replace(/^#+\s*/, '').trim();
|
|
133
|
+
if (!needle) return content;
|
|
134
|
+
|
|
135
|
+
// Find the start: any heading line whose text (after stripping `#`s) contains
|
|
136
|
+
// the configured heading. Substring match keeps the config tolerant of
|
|
137
|
+
// emoji prefixes and trailing decorations in the actual document.
|
|
138
|
+
let startIdx = -1;
|
|
139
|
+
let startLevel = 0;
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
const m = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
142
|
+
if (!m) continue;
|
|
143
|
+
const headingText = m[2].toLowerCase();
|
|
144
|
+
if (headingText.includes(needle)) {
|
|
145
|
+
startIdx = i;
|
|
146
|
+
startLevel = m[1].length;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (startIdx === -1) return '';
|
|
151
|
+
|
|
152
|
+
// Find the end: next heading of <= startLevel depth.
|
|
153
|
+
let endIdx = lines.length;
|
|
154
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
155
|
+
const m = lines[i].match(/^(#{1,6})\s+/);
|
|
156
|
+
if (m && m[1].length <= startLevel) {
|
|
157
|
+
endIdx = i;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return lines.slice(startIdx, endIdx).join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse a markdown file (or a slice of one) and return the set of tokens
|
|
166
|
+
* found in "list contexts" — table rows (first column) and bullet items.
|
|
167
|
+
* Backtick-wrapped only. This deliberately ignores backticks in prose,
|
|
168
|
+
* code blocks, and headings.
|
|
169
|
+
*
|
|
170
|
+
* Code blocks (```...```) are stripped first so that a fenced shell example
|
|
171
|
+
* like `npx docguard-cli demo` cannot inflate the documented set with the
|
|
172
|
+
* `demo` token. Tables and bullets remain the only authoritative inventory.
|
|
173
|
+
*/
|
|
174
|
+
function extractDocumentedTokens(content) {
|
|
175
|
+
const tokens = new Set();
|
|
176
|
+
// Strip fenced code blocks — shell examples, json snippets, mermaid blocks,
|
|
177
|
+
// and any inline reference inside them is NOT a list entry.
|
|
178
|
+
const stripped = content.replace(/```[\s\S]*?```/g, '');
|
|
179
|
+
|
|
180
|
+
// Normalize a captured token. Strips a leading `docguard ` or `/` (common
|
|
181
|
+
// doc conventions for command tables and slash-command lists), reduces
|
|
182
|
+
// multi-word forms to the first token, and lower-cases — downstream
|
|
183
|
+
// comparison is case-insensitive so README's `**API-Surface**` matches
|
|
184
|
+
// code-truth `api-surface`.
|
|
185
|
+
const normalize = (raw) => {
|
|
186
|
+
if (!raw) return '';
|
|
187
|
+
const cleaned = String(raw).trim()
|
|
188
|
+
.replace(/^docguard\s+/i, '')
|
|
189
|
+
.replace(/^\//, '');
|
|
190
|
+
const firstToken = cleaned.split(/\s+/)[0];
|
|
191
|
+
return firstToken.toLowerCase();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Pattern A: backticked token in a list context.
|
|
195
|
+
// - Start of a table row: `| `name` | … |`
|
|
196
|
+
// - Numbered-cell table row: `| 1 | `name` | … |`
|
|
197
|
+
// - Bullet list item: `- `name` …`
|
|
198
|
+
// Restricting to these contexts keeps prose backticks out of the
|
|
199
|
+
// documented set; a backticked `--verbose` mid-paragraph is not a list
|
|
200
|
+
// entry and must not inflate the set.
|
|
201
|
+
const backtickListRe =
|
|
202
|
+
/(?:^\s*\|\s*(?:\d+\s*\|\s*)?|^\s*[-*+]\s+)`([^`\n]+)`/gim;
|
|
203
|
+
let m;
|
|
204
|
+
while ((m = backtickListRe.exec(stripped)) !== null) {
|
|
205
|
+
const t = normalize(m[1]);
|
|
206
|
+
if (t) tokens.add(t);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Pattern B: bolded token in a table row. Matches the validators-style
|
|
210
|
+
// tables that use `| N | **Name** | description |` — backticks alone
|
|
211
|
+
// miss every entry in those tables. Restricted to lines starting with
|
|
212
|
+
// `|` so prose-level **bold** is not pulled in.
|
|
213
|
+
const boldRowRe = /^\s*\|.*?\*\*([^*\n]+)\*\*/gim;
|
|
214
|
+
while ((m = boldRowRe.exec(stripped)) !== null) {
|
|
215
|
+
const t = normalize(m[1]);
|
|
216
|
+
if (t) tokens.add(t);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return tokens;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate surface drift for a single surface against its target docs.
|
|
224
|
+
* Returns warnings, fixes, passed count, and total count.
|
|
225
|
+
*/
|
|
226
|
+
function checkSurface(projectDir, surface) {
|
|
227
|
+
const out = { warnings: [], fixes: [], passed: 0, total: 0 };
|
|
228
|
+
const name = surface.name || 'unnamed';
|
|
229
|
+
const extractor = surface.extract || 'basename-no-ext';
|
|
230
|
+
const ignore = new Set(surface.ignore || []);
|
|
231
|
+
const docs = Array.isArray(surface.docs) && surface.docs.length > 0
|
|
232
|
+
? surface.docs
|
|
233
|
+
: ['README.md'];
|
|
234
|
+
|
|
235
|
+
// Discover code-truth set from glob.
|
|
236
|
+
if (!surface.glob || typeof surface.glob !== 'string') {
|
|
237
|
+
out.warnings.push(
|
|
238
|
+
`surfaceSync: surface "${name}" has no \`glob\` — skipping. Add a glob like "cli/commands/*.mjs".`
|
|
239
|
+
);
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
const files = expandGlob(projectDir, surface.glob);
|
|
243
|
+
// Lower-case code-truth to keep comparison case-insensitive — README
|
|
244
|
+
// commonly uses display-cased identifiers (`**API-Surface**`, `Freshness`)
|
|
245
|
+
// while file basenames are lowercase (`api-surface.mjs`). The `ignore`
|
|
246
|
+
// list is matched against the case-folded form, so users can write
|
|
247
|
+
// either `["Setup"]` or `["setup"]` and it works.
|
|
248
|
+
const ignoreLower = new Set([...ignore].map(s => String(s).toLowerCase()));
|
|
249
|
+
const codeTruth = new Set(
|
|
250
|
+
files
|
|
251
|
+
.map(f => applyExtractor(f, extractor).toLowerCase())
|
|
252
|
+
.filter(token => !ignoreLower.has(token))
|
|
253
|
+
);
|
|
254
|
+
if (codeTruth.size === 0) {
|
|
255
|
+
// No items discovered — surface is N/A for this project. Don't warn
|
|
256
|
+
// (the user has explicitly enabled the surface, but the glob found
|
|
257
|
+
// nothing — maybe they renamed a directory; surface up the info but
|
|
258
|
+
// do NOT escalate into a check failure).
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// For each target doc, compute documented set and diff.
|
|
263
|
+
for (const docRel of docs) {
|
|
264
|
+
const docPath = resolve(projectDir, docRel);
|
|
265
|
+
if (!existsSync(docPath)) {
|
|
266
|
+
// Doc doesn't exist — silently skip; another validator (structure)
|
|
267
|
+
// covers missing-doc cases.
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let content;
|
|
272
|
+
try {
|
|
273
|
+
content = readFileSync(docPath, 'utf-8');
|
|
274
|
+
} catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Optional section scope: restrict scanning to a single heading's body.
|
|
279
|
+
// Without this, every backticked first-column token in the doc is
|
|
280
|
+
// pooled into one "documented" set — and a commands surface ends up
|
|
281
|
+
// matching against the validators table too, producing cross-table
|
|
282
|
+
// false positives. Section-scoping is the user's per-surface override
|
|
283
|
+
// when one doc lists multiple surfaces.
|
|
284
|
+
const scope = surface.section
|
|
285
|
+
? sliceSection(content, surface.section)
|
|
286
|
+
: content;
|
|
287
|
+
|
|
288
|
+
const documented = extractDocumentedTokens(scope);
|
|
289
|
+
out.total++;
|
|
290
|
+
|
|
291
|
+
const missingFromDoc = [...codeTruth].filter(t => !documented.has(t));
|
|
292
|
+
// missingFromCode tells you about items the doc lists that don't exist
|
|
293
|
+
// in code — usually removed or renamed surfaces. Filter through `ignore`
|
|
294
|
+
// too so deprecation aliases listed in the doc don't fire warnings.
|
|
295
|
+
const missingFromCode = [...documented]
|
|
296
|
+
.filter(t => !codeTruth.has(t) && !ignoreLower.has(t))
|
|
297
|
+
// Only consider tokens that match the surface naming convention to
|
|
298
|
+
// avoid pulling in unrelated backticked items from a different table
|
|
299
|
+
// that happens to be in the same doc.
|
|
300
|
+
.filter(t => surfaceNameLooksValid(t));
|
|
301
|
+
|
|
302
|
+
if (missingFromDoc.length === 0 && missingFromCode.length === 0) {
|
|
303
|
+
out.passed++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const parts = [];
|
|
308
|
+
if (missingFromDoc.length > 0) {
|
|
309
|
+
const shown = missingFromDoc.slice(0, 8).map(t => `\`${t}\``).join(', ');
|
|
310
|
+
const extra = missingFromDoc.length - 8;
|
|
311
|
+
const tail = extra > 0 ? ` (+${extra} more)` : '';
|
|
312
|
+
parts.push(`${missingFromDoc.length} in code but missing from ${docRel}: ${shown}${tail}`);
|
|
313
|
+
}
|
|
314
|
+
if (missingFromCode.length > 0) {
|
|
315
|
+
const shown = missingFromCode.slice(0, 8).map(t => `\`${t}\``).join(', ');
|
|
316
|
+
const extra = missingFromCode.length - 8;
|
|
317
|
+
const tail = extra > 0 ? ` (+${extra} more)` : '';
|
|
318
|
+
parts.push(`${missingFromCode.length} listed in ${docRel} but not found in code: ${shown}${tail}`);
|
|
319
|
+
}
|
|
320
|
+
out.warnings.push(`Surface "${name}" drift: ${parts.join('; ')}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Loose sanity filter for tokens before reporting them as "missing from code".
|
|
328
|
+
* Real surface names are short identifiers; if a regex catches something
|
|
329
|
+
* that looks like prose, drop it silently.
|
|
330
|
+
*/
|
|
331
|
+
function surfaceNameLooksValid(t) {
|
|
332
|
+
return typeof t === 'string'
|
|
333
|
+
&& t.length >= 2
|
|
334
|
+
&& t.length <= 64
|
|
335
|
+
&& /^[a-z][a-z0-9_.-]*$/i.test(t);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Public entry point. Returns N/A when no surfaces are configured — by
|
|
340
|
+
* design, this validator is opt-in. Projects that don't declare surfaces
|
|
341
|
+
* see zero noise.
|
|
342
|
+
*/
|
|
343
|
+
export function validateSurfaceSync(projectDir, config) {
|
|
344
|
+
const surfaceCfg = (config && config.surfaceSync && Array.isArray(config.surfaceSync.surfaces))
|
|
345
|
+
? config.surfaceSync.surfaces
|
|
346
|
+
: [];
|
|
347
|
+
|
|
348
|
+
const result = { errors: [], warnings: [], fixes: [], passed: 0, total: 0 };
|
|
349
|
+
|
|
350
|
+
if (surfaceCfg.length === 0) {
|
|
351
|
+
// No surfaces configured → N/A. The validator infrastructure surfaces
|
|
352
|
+
// this as "nothing to validate" rather than a fail.
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const surface of surfaceCfg) {
|
|
357
|
+
const r = checkSurface(projectDir, surface);
|
|
358
|
+
result.warnings.push(...r.warnings);
|
|
359
|
+
result.fixes.push(...r.fixes);
|
|
360
|
+
result.passed += r.passed;
|
|
361
|
+
result.total += r.total;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
@@ -64,6 +64,10 @@ const TRACE_MAP = {
|
|
|
64
64
|
'API-REFERENCE.md': {
|
|
65
65
|
sourcePatterns: [
|
|
66
66
|
{ label: 'Route handlers', glob: /(routes?|controllers?|handlers?)\// },
|
|
67
|
+
// Next.js App Router (and Pages Router) — `app/api/`, `src/app/api/`,
|
|
68
|
+
// `pages/api/`, `src/pages/api/`. Without this, a perfectly-populated
|
|
69
|
+
// Next.js API tree gets reported as "API-REFERENCE.md — unlinked doc".
|
|
70
|
+
{ label: 'Next.js API routes', glob: /(^|\/)(app|pages)\/api\// },
|
|
67
71
|
{ label: 'OpenAPI spec', glob: /(openapi|swagger)\.(json|ya?ml)/ },
|
|
68
72
|
{ label: 'API middleware', glob: /middleware\// },
|
|
69
73
|
],
|
|
@@ -115,6 +119,15 @@ export function validateTraceability(projectDir, config) {
|
|
|
115
119
|
const projectFiles = [];
|
|
116
120
|
scanDir(projectDir, projectDir, projectFiles);
|
|
117
121
|
|
|
122
|
+
// Scan source files for `// @doc <filename>.md` annotations. An annotation
|
|
123
|
+
// is an explicit author signal that a source file documents (or is
|
|
124
|
+
// documented by) a canonical doc. It is the user-facing escape hatch when
|
|
125
|
+
// a project's directory layout doesn't match the built-in TRACE_MAP globs
|
|
126
|
+
// (e.g. a route file outside any `routes/` / `app/api/` tree). The
|
|
127
|
+
// annotation is also shown in templates and templates/commands docs, so
|
|
128
|
+
// users have been told it works — actually honoring it is the fix here.
|
|
129
|
+
const docAnnotations = scanDocAnnotations(projectFiles, projectDir);
|
|
130
|
+
|
|
118
131
|
// ── Part 1: Source Traceability (existing) ──
|
|
119
132
|
for (const [docName, traceInfo] of Object.entries(TRACE_MAP)) {
|
|
120
133
|
// Skip docs not in the user's required list
|
|
@@ -129,6 +142,14 @@ export function validateTraceability(projectDir, config) {
|
|
|
129
142
|
continue;
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
// Explicit `// @doc <docName>` annotation counts as a link regardless of
|
|
146
|
+
// whether the file path matches any built-in pattern. Checked first so
|
|
147
|
+
// path-pattern misses don't drown out explicit author intent.
|
|
148
|
+
if (docAnnotations.has(docName) && docAnnotations.get(docName).size > 0) {
|
|
149
|
+
passed++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
132
153
|
// Count matching source files
|
|
133
154
|
// ⚡ Bolt: Fast early return using .some() instead of .filter()
|
|
134
155
|
let hasSource = false;
|
|
@@ -354,6 +375,50 @@ function getRequirementDocPaths(projectDir, config) {
|
|
|
354
375
|
|
|
355
376
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
356
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Scan code files for `// @doc <name>.md` annotations. Returns a Map from
|
|
380
|
+
* canonical doc basename → Set of source file paths that annotate it.
|
|
381
|
+
*
|
|
382
|
+
* Annotation forms accepted:
|
|
383
|
+
* - `// @doc API-REFERENCE.md`
|
|
384
|
+
* - `// @doc docs-canonical/API-REFERENCE.md` (basename is what matters)
|
|
385
|
+
* - `/* @doc API-REFERENCE.md */` (block comment, single line)
|
|
386
|
+
* - `# @doc API-REFERENCE.md` (Python / Ruby comment style)
|
|
387
|
+
*
|
|
388
|
+
* Only the basename of the referenced doc is keyed; callers compare against
|
|
389
|
+
* the doc basename (e.g. `API-REFERENCE.md`). We cap how many files we open
|
|
390
|
+
* to keep this scan O(N) and stop reading the rest of a file after the
|
|
391
|
+
* first 4 KB — annotations belong at the top of a file by convention, and
|
|
392
|
+
* reading every byte of every source file just to find a header comment
|
|
393
|
+
* would balloon scan time on large monorepos.
|
|
394
|
+
*/
|
|
395
|
+
function scanDocAnnotations(projectFiles, projectDir) {
|
|
396
|
+
const map = new Map();
|
|
397
|
+
const annotationRe = /(?:\/\/|\/\*|#)\s*@doc\s+(\S+\.md)/g;
|
|
398
|
+
const CODE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.java']);
|
|
399
|
+
const HEAD_BYTES = 4096;
|
|
400
|
+
|
|
401
|
+
for (const relPath of projectFiles) {
|
|
402
|
+
const ext = extname(relPath);
|
|
403
|
+
if (!CODE_EXT.has(ext)) continue;
|
|
404
|
+
const full = resolve(projectDir, relPath);
|
|
405
|
+
let content;
|
|
406
|
+
try { content = readFileSync(full, 'utf-8'); } catch { continue; }
|
|
407
|
+
// Annotations live near the top of the file. Slicing avoids reading
|
|
408
|
+
// megabytes of bundled / minified output looking for a header comment.
|
|
409
|
+
const head = content.length > HEAD_BYTES ? content.slice(0, HEAD_BYTES) : content;
|
|
410
|
+
if (!head.includes('@doc')) continue;
|
|
411
|
+
annotationRe.lastIndex = 0;
|
|
412
|
+
let m;
|
|
413
|
+
while ((m = annotationRe.exec(head)) !== null) {
|
|
414
|
+
const docName = basename(m[1]);
|
|
415
|
+
if (!map.has(docName)) map.set(docName, new Set());
|
|
416
|
+
map.get(docName).add(relPath);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return map;
|
|
420
|
+
}
|
|
421
|
+
|
|
357
422
|
function scanDir(rootDir, dir, files) {
|
|
358
423
|
let entries;
|
|
359
424
|
try { entries = readdirSync(dir); } catch { return; }
|
package/docs/quickstart.md
CHANGED
|
@@ -68,7 +68,7 @@ diagnose → AI reads prompts → AI fixes docs → guard verifies
|
|
|
68
68
|
## Verify
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
npx docguard-cli guard # Pass/fail check (
|
|
71
|
+
npx docguard-cli guard # Pass/fail check (24 validators)
|
|
72
72
|
npx docguard-cli score # 0-100 maturity score
|
|
73
73
|
```
|
|
74
74
|
|
|
@@ -4,7 +4,7 @@ Enterprise-grade Canonical-Driven Development (CDD) enforcement and **AI-readabl
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **24 Validators** — Structure, Security, Doc Quality, Test-Spec, Drift-Comments, API-Surface, Freshness, Cross-Reference, and 13 more
|
|
8
8
|
- **Language-agnostic** — JS/TS, Python, Rust, Go, Java/Kotlin, Ruby, PHP, C#. Polyglot/monorepo-aware.
|
|
9
9
|
- **AI-powered Generate** — `generate --plan` builds the code-truth skeleton in `<!-- docguard:section -->` markers and emits a structured agent task manifest; the AI writes the prose.
|
|
10
10
|
- **Always up to date** — `sync` surgically refreshes code-truth doc sections in place, **preserves human prose**, flags prose for agent review.
|
|
@@ -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.22.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with 19 automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. Zero NPM runtime dependencies."
|
|
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.22.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.22.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.22.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.22.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.22.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.22.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.22.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.22.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,7 +4,7 @@ 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.22.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -102,6 +102,8 @@
|
|
|
102
102
|
"specKit": { "type": "boolean" },
|
|
103
103
|
"crossReference": { "type": "boolean" },
|
|
104
104
|
"generatedStaleness":{ "type": "boolean" },
|
|
105
|
+
"canonicalSync": { "type": "boolean" },
|
|
106
|
+
"surfaceSync": { "type": "boolean" },
|
|
105
107
|
"metricsConsistency":{ "type": "boolean" }
|
|
106
108
|
},
|
|
107
109
|
"additionalProperties": false
|
|
@@ -149,6 +151,30 @@
|
|
|
149
151
|
}
|
|
150
152
|
},
|
|
151
153
|
"additionalProperties": true
|
|
154
|
+
},
|
|
155
|
+
"surfaceSync": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"description": "Surface-Sync validator: item-level drift between code-derived enumerables (commands, validators, templates) and the lists in target docs (README.md, AGENTS.md). Default: N/A unless `surfaces` is declared.",
|
|
158
|
+
"properties": {
|
|
159
|
+
"surfaces": {
|
|
160
|
+
"type": "array",
|
|
161
|
+
"description": "Enumerable surfaces to check. Each surface compares code-truth (from a glob) against documented list entries in target markdown files.",
|
|
162
|
+
"items": {
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"name": { "type": "string", "description": "Human-readable surface name used in warnings (e.g. \"commands\")." },
|
|
166
|
+
"glob": { "type": "string", "description": "Glob discovering the code-truth files (e.g. `cli/commands/*.mjs`). Only leaf `*` supported." },
|
|
167
|
+
"extract": { "type": "string", "enum": ["basename", "basename-no-ext"], "description": "How to derive the surface name from each file path." },
|
|
168
|
+
"ignore": { "type": "array", "items": { "type": "string" }, "description": "Surface names to skip (e.g. known deprecation aliases)." },
|
|
169
|
+
"docs": { "type": "array", "items": { "type": "string" }, "description": "Target markdown files to scan for documented list entries." },
|
|
170
|
+
"section": { "type": "string", "description": "Optional heading text to scope the scan (substring match, case-insensitive). Without it, the entire doc is scanned and tables for OTHER surfaces produce cross-table false positives. Use when one doc lists multiple surfaces." }
|
|
171
|
+
},
|
|
172
|
+
"required": ["name", "glob"],
|
|
173
|
+
"additionalProperties": false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"additionalProperties": true
|
|
152
178
|
}
|
|
153
179
|
},
|
|
154
180
|
"additionalProperties": true
|