docguard-cli 0.11.2 → 0.13.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.
Files changed (31) hide show
  1. package/README.md +35 -16
  2. package/cli/commands/fix.mjs +55 -0
  3. package/cli/commands/guard.mjs +129 -5
  4. package/cli/commands/init.mjs +52 -1
  5. package/cli/commands/sync.mjs +50 -0
  6. package/cli/commands/trace.mjs +105 -0
  7. package/cli/commands/upgrade.mjs +250 -0
  8. package/cli/docguard.mjs +39 -3
  9. package/cli/shared-git.mjs +0 -0
  10. package/cli/shared-ignore.mjs +50 -0
  11. package/cli/shared.mjs +62 -0
  12. package/cli/validators/cross-reference.mjs +289 -0
  13. package/cli/validators/docs-sync.mjs +15 -0
  14. package/cli/validators/freshness.mjs +5 -10
  15. package/cli/validators/generated-staleness.mjs +97 -0
  16. package/cli/writers/fix-memory.mjs +133 -0
  17. package/cli/writers/mechanical.mjs +22 -0
  18. package/commands/docguard.guard.md +2 -2
  19. package/docs/quickstart.md +1 -1
  20. package/extensions/spec-kit-docguard/README.md +1 -1
  21. package/extensions/spec-kit-docguard/commands/guard.md +1 -1
  22. package/extensions/spec-kit-docguard/extension.yml +11 -1
  23. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  24. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -3
  25. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  26. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +1 -1
  28. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +51 -0
  29. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +48 -0
  30. package/package.json +1 -1
  31. package/templates/commands/docguard.guard.md +2 -2
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  - [What is DocGuard?](#what-is-docguard)
18
18
  - [Quick Start](#-quick-start)
19
19
  - [Spec Kit Integration](#-spec-kit-integration)
20
- - [Commands](#-commands)
20
+ - [Usage](#usage)
21
21
  - [Validators](#-validators)
22
22
  - [Templates](#-templates)
23
23
  - [AI Agent Support](#-ai-agent-support)
@@ -343,7 +343,7 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
343
343
 
344
344
  | Command | What It Does |
345
345
  |:--------|:-------------|
346
- | `/docguard.guard` | Run quality validation — check all 20 validators |
346
+ | `/docguard.guard` | Run quality validation — check all 22 validators |
347
347
  | `/docguard.review` | Analyze doc quality and suggest improvements |
348
348
  | `/docguard.fix` | Generate targeted fix prompts for specific issues |
349
349
  | `/docguard.score` | Show CDD maturity score with category breakdown |
@@ -433,20 +433,42 @@ DocGuard runs its own `guard`, `score`, `diff`, `diagnose`, and `badge` commands
433
433
 
434
434
  ## ⚙️ CI/CD Integration
435
435
 
436
- ### GitHub Actions
436
+ > **Full recipes:** see [`docs-canonical/CI-RECIPES.md`](./docs-canonical/CI-RECIPES.md) for guard, auto-fix (commits mechanical fixes back to PRs), nightly sync, score-on-PR, and pre-commit configs.
437
+
438
+ ### GitHub Actions — Guard (most common)
437
439
 
438
440
  ```yaml
439
- name: DocGuard CDD Check
440
- on: [pull_request]
441
+ name: DocGuard Guard
442
+ on: [pull_request, push]
441
443
  jobs:
442
444
  docguard:
443
445
  runs-on: ubuntu-latest
444
446
  steps:
445
447
  - uses: actions/checkout@v4
446
- - uses: actions/setup-node@v4
447
- with: { node-version: '20' }
448
- - run: npx docguard-cli guard
449
- - run: npx docguard-cli score --format json
448
+ with: { fetch-depth: 0 }
449
+ - uses: raccioly/docguard@v0.12.0
450
+ with:
451
+ command: guard
452
+ ```
453
+
454
+ ### GitHub Actions — Auto-Fix (commits mechanical fixes back)
455
+
456
+ ```yaml
457
+ name: DocGuard Auto-Fix
458
+ on: { pull_request: { types: [opened, synchronize, reopened] } }
459
+ permissions: { contents: write, pull-requests: write }
460
+ jobs:
461
+ autofix:
462
+ runs-on: ubuntu-latest
463
+ if: github.event.pull_request.head.repo.full_name == github.repository
464
+ steps:
465
+ - uses: actions/checkout@v4
466
+ with:
467
+ ref: ${{ github.event.pull_request.head.ref }}
468
+ token: ${{ secrets.GITHUB_TOKEN }}
469
+ fetch-depth: 0
470
+ - uses: raccioly/docguard@v0.12.0
471
+ with: { command: fix, auto-commit: 'true', comment-on-pr: 'true' }
450
472
  ```
451
473
 
452
474
  ### Pre-commit Hook
@@ -455,14 +477,11 @@ jobs:
455
477
  npx docguard-cli hooks --type pre-commit
456
478
  ```
457
479
 
458
- ### GitHub Marketplace
480
+ ### Workflow starters (copy directly)
459
481
 
460
- ```yaml
461
- - uses: raccioly/docguard@v0.9.7
462
- with:
463
- command: guard
464
- fail-on-warning: true
465
- ```
482
+ Two ready-to-use templates ship with the Spec Kit extension and as standalone files:
483
+ - `extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml` — mandatory CI gate
484
+ - `extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml` — PR auto-fix
466
485
 
467
486
  ---
468
487
 
@@ -21,6 +21,7 @@ import { c } from '../shared.mjs';
21
21
  import { computeApiSurfaceDrift } from '../validators/api-surface.mjs';
22
22
  import { removeEndpoints, hasGeneratedMarker } from '../writers/api-reference.mjs';
23
23
  import { applyMechanicalFixes } from '../writers/mechanical.mjs';
24
+ import { loadFixMemory } from '../writers/fix-memory.mjs';
24
25
  import { runGuardInternal } from './guard.mjs';
25
26
 
26
27
  const API_DOC = 'docs-canonical/API-REFERENCE.md';
@@ -281,6 +282,55 @@ export function applyAllMechanicalFixes(projectDir, config, { force = false } =
281
282
  return { applied, skipped, total: fixes.length };
282
283
  }
283
284
 
285
+ /**
286
+ * M-2 — `docguard fix --history` shows the audit log of mechanical fixes
287
+ * that have been applied to this project. Reads `.docguard/fixed.json`
288
+ * and pretty-prints (or emits JSON when --format json).
289
+ */
290
+ function runHistoryMode(projectDir, flags) {
291
+ const mem = loadFixMemory(projectDir);
292
+ const isJson = flags.format === 'json';
293
+
294
+ if (isJson) {
295
+ console.log(JSON.stringify(mem, null, 2));
296
+ return;
297
+ }
298
+
299
+ if (mem.entries.length === 0) {
300
+ console.log(`${c.bold}🗂 DocGuard Fix History${c.reset}`);
301
+ console.log(`${c.dim} No fixes recorded yet. Run \`docguard fix --write\` to start the audit log.${c.reset}`);
302
+ return;
303
+ }
304
+
305
+ console.log(`${c.bold}🗂 DocGuard Fix History${c.reset} ${c.dim}(${mem.entries.length} entries, newest first)${c.reset}\n`);
306
+
307
+ // Group by date for readability
308
+ const byDate = new Map();
309
+ for (const e of mem.entries) {
310
+ const day = (e.appliedAt || '').slice(0, 10);
311
+ if (!byDate.has(day)) byDate.set(day, []);
312
+ byDate.get(day).push(e);
313
+ }
314
+
315
+ // Show the most recent N days (cap output at 20 entries)
316
+ let printed = 0;
317
+ for (const [day, dayEntries] of byDate) {
318
+ if (printed >= 20) break;
319
+ console.log(` ${c.cyan}${day}${c.reset} ${c.dim}(${dayEntries.length} fix${dayEntries.length > 1 ? 'es' : ''})${c.reset}`);
320
+ for (const e of dayEntries.slice(0, 5)) {
321
+ if (printed >= 20) break;
322
+ const time = (e.appliedAt || '').slice(11, 16);
323
+ console.log(` ${c.dim}${time}${c.reset} ${e.type} → ${c.cyan}${e.file}${c.reset} ${c.dim}${e.summary || ''}${c.reset}`);
324
+ printed++;
325
+ }
326
+ if (dayEntries.length > 5) console.log(` ${c.dim}... ${dayEntries.length - 5} more on this day${c.reset}`);
327
+ }
328
+
329
+ if (mem.entries.length > 20) {
330
+ console.log(`\n ${c.dim}... ${mem.entries.length - 20} older entries. Use ${c.cyan}--format json${c.dim} for the full log.${c.reset}`);
331
+ }
332
+ }
333
+
284
334
  function runWriteMode(projectDir, config, flags) {
285
335
  const isJson = flags.format === 'json';
286
336
  const { applied, skipped, total } = applyAllMechanicalFixes(projectDir, config, { force: flags.force });
@@ -321,6 +371,11 @@ export function runFix(projectDir, config, flags) {
321
371
  const autoFix = flags.auto || false;
322
372
  const specificDoc = flags.doc || null;
323
373
 
374
+ // M-2: --history shows the audit trail of past mechanical fixes.
375
+ if (flags.history) {
376
+ return runHistoryMode(projectDir, flags);
377
+ }
378
+
324
379
  // --write: deterministically APPLY mechanical fixes (no LLM). Currently:
325
380
  // remove API-REFERENCE.md endpoints the OpenAPI spec confirms no longer exist.
326
381
  if (flags.write) {
@@ -7,8 +7,10 @@
7
7
  * runGuardInternal() → returns data, no side effects (for diagnose, ci)
8
8
  */
9
9
 
10
- import { c } from '../shared.mjs';
10
+ import { c, resolveSeverity } from '../shared.mjs';
11
11
  import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
12
+ import { checkUpgradeStatus } from './upgrade.mjs';
13
+ import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
12
14
  import { validateStructure, validateDocSections } from '../validators/structure.mjs';
13
15
  import { validateDrift } from '../validators/drift.mjs';
14
16
  import { validateChangelog } from '../validators/changelog.mjs';
@@ -25,6 +27,8 @@ import { validateMetadataSync } from '../validators/metadata-sync.mjs';
25
27
  import { validateMetricsConsistency } from '../validators/metrics-consistency.mjs';
26
28
  import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
27
29
  import { validateDocQuality } from '../validators/doc-quality.mjs';
30
+ import { validateCrossReferences } from '../validators/cross-reference.mjs';
31
+ import { validateGeneratedStaleness } from '../validators/generated-staleness.mjs';
28
32
  import { validateTodoTracking } from '../validators/todo-tracking.mjs';
29
33
  import { validateSchemaSync } from '../validators/schema-sync.mjs';
30
34
  import { validateSpecKitIntegration } from '../scanners/speckit.mjs';
@@ -100,6 +104,8 @@ export function runGuardInternal(projectDir, config) {
100
104
  { key: 'todoTracking', name: 'TODO-Tracking', fn: () => validateTodoTracking(projectDir, config) },
101
105
  { key: 'schemaSync', name: 'Schema-Sync', fn: () => validateSchemaSync(projectDir, config) },
102
106
  { key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
107
+ { key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
108
+ { key: 'generatedStaleness', name: 'Generated-Staleness', fn: () => validateGeneratedStaleness(projectDir, config) },
103
109
  // Metrics-Consistency runs post-loop (needs guard results)
104
110
  ];
105
111
 
@@ -133,6 +139,25 @@ export function runGuardInternal(projectDir, config) {
133
139
  const totalPassed = activeResults.reduce((sum, r) => sum + r.passed, 0);
134
140
  const totalChecks = activeResults.reduce((sum, r) => sum + r.total, 0);
135
141
 
142
+ // Per-validator severity overrides (v0.5 schema). Affects EXIT-CODE only,
143
+ // not display. Annotate each validator with its resolved severity and roll
144
+ // up effective error/warning counts:
145
+ // - high → validator's warnings get promoted to "effective errors"
146
+ // - low → validator's warnings are demoted (ignored for exit code)
147
+ // - medium (default) → warnings stay as-is
148
+ for (const v of activeResults) {
149
+ v.severity = resolveSeverity(config, v.key);
150
+ }
151
+ let effectiveErrors = totalErrors;
152
+ let effectiveWarnings = 0;
153
+ for (const v of activeResults) {
154
+ const wCount = v.warnings.length;
155
+ if (wCount === 0) continue;
156
+ if (v.severity === 'high') effectiveErrors += wCount;
157
+ else if (v.severity === 'low') { /* ignored for exit */ }
158
+ else effectiveWarnings += wCount;
159
+ }
160
+
136
161
  const overallStatus = totalErrors > 0 ? 'FAIL' : totalWarnings > 0 ? 'WARN' : 'PASS';
137
162
 
138
163
  return {
@@ -143,22 +168,80 @@ export function runGuardInternal(projectDir, config) {
143
168
  total: totalChecks,
144
169
  errors: totalErrors,
145
170
  warnings: totalWarnings,
171
+ // v0.5: severity-aware counts for exit-code logic. The display still uses
172
+ // the raw counts above so users see every warning, but CI only fails on
173
+ // things they've marked as high-severity.
174
+ effectiveErrors,
175
+ effectiveWarnings,
146
176
  validators: results,
147
177
  timestamp: new Date().toISOString(),
148
178
  };
149
179
  }
150
180
 
181
+ /**
182
+ * The "pre-commit lite" validator set — fast checks suitable for running
183
+ * on every commit/save. Tuned for <2s wall-clock on average repos.
184
+ *
185
+ * The list is intentionally short: validators that catch >80% of the
186
+ * common doc drift that developers introduce mid-feature (route added but
187
+ * not documented, env var renamed but not updated in ENVIRONMENT.md,
188
+ * endpoint deleted but still in API-REFERENCE.md). Heavy validators —
189
+ * Freshness (git log), Traceability (REQ scan), Doc-Quality (prose lint) —
190
+ * stay off for speed.
191
+ */
192
+ export const CHANGED_ONLY_VALIDATORS = ['docsSync', 'environment', 'apiSurface'];
193
+
194
+ /**
195
+ * Build a validators map that enables only the pre-commit-lite set.
196
+ * Used by `docguard guard --changed-only`.
197
+ */
198
+ function liteValidatorsConfig() {
199
+ const all = [
200
+ 'structure', 'docsSync', 'drift', 'changelog', 'testSpec', 'environment',
201
+ 'security', 'architecture', 'freshness', 'traceability', 'docsDiff',
202
+ 'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
203
+ 'schemaSync', 'specKit', 'crossReference', 'generatedStaleness', 'metricsConsistency',
204
+ ];
205
+ const out = {};
206
+ for (const k of all) out[k] = CHANGED_ONLY_VALIDATORS.includes(k);
207
+ return out;
208
+ }
209
+
151
210
  /**
152
211
  * Public guard — prints results and exits.
153
212
  */
154
213
  export function runGuard(projectDir, config, flags) {
214
+ // --changed-only: pre-commit lite mode. Overrides the validator set to a
215
+ // fast subset (Docs-Sync, Environment, API-Surface). Designed for husky/
216
+ // lefthook hooks; expects to finish in under 2 seconds.
217
+ if (flags.changedOnly) {
218
+ // Compute the set of changed files since the given ref (default HEAD~1 —
219
+ // the pre-commit common case: "files changed in this commit vs the last
220
+ // committed state"). Validators that opt into `config.changedFiles` can
221
+ // scope to this list; others run normally over the whole tree.
222
+ const ref = flags.since || 'HEAD~1';
223
+ const changed = isGitRepo(projectDir) ? changedFilesSince(projectDir, ref) : [];
224
+ config = {
225
+ ...config,
226
+ validators: liteValidatorsConfig(),
227
+ changedFiles: changed,
228
+ changedSinceRef: ref,
229
+ };
230
+ const label = changed.length > 0
231
+ ? `${changed.length} file(s) changed since ${ref}`
232
+ : `no changes since ${ref} — running all ${CHANGED_ONLY_VALIDATORS.length} lite validators on full tree`;
233
+ console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(${label})${c.reset}\n`);
234
+ }
235
+
155
236
  const data = runGuardInternal(projectDir, config);
156
237
 
157
238
  // ── JSON output ──
158
239
  if (flags.format === 'json') {
159
240
  console.log(JSON.stringify(data, null, 2));
160
- if (data.errors > 0) process.exit(1);
161
- if (data.warnings > 0) process.exit(2);
241
+ // Use severity-aware effective counts for exit code; raw counts stay in the JSON
242
+ // for display tools that want to show the full picture.
243
+ if (data.effectiveErrors > 0) process.exit(1);
244
+ if (data.effectiveWarnings > 0) process.exit(2);
162
245
  process.exit(0);
163
246
  }
164
247
 
@@ -243,14 +326,55 @@ export function runGuard(projectDir, config, flags) {
243
326
  const badgeUrl = `https://img.shields.io/badge/CDD_Guard-${data.passed}%2F${data.total}_passed-${bColor}`;
244
327
  console.log(`\n ${c.dim}📎 Badge: ![CDD Guard](${badgeUrl})${c.reset}`);
245
328
 
329
+ // Schema upgrade nudge — fires when the project's .docguard.json schema is
330
+ // behind the CLI's CURRENT_SCHEMA_VERSION. Cheap, file-local check; no
331
+ // network access. Suppressed in JSON output to keep machine consumers clean.
332
+ if (!flags || flags.format !== 'json') {
333
+ const upgradeHint = checkUpgradeStatus(projectDir);
334
+ if (upgradeHint) {
335
+ console.log(`\n ${c.yellow}↑ ${upgradeHint}${c.reset}`);
336
+ }
337
+
338
+ // K-6 / S-2: sweep-needed nudge. Aggregates freshness warnings — if 2+
339
+ // canonical docs are stale (matching the "X code commits since last doc
340
+ // update" pattern), suggest a single `docguard sync --write` pass that
341
+ // refreshes every code-truth section in one shot. Individual freshness
342
+ // warnings already named the docs; this nudge just turns "5 warnings"
343
+ // into one actionable recommendation.
344
+ const freshness = data.validators.find(v => v.key === 'freshness');
345
+ if (freshness && freshness.warnings) {
346
+ const staleDocs = freshness.warnings.filter(w => /\d+ code commits since/.test(w));
347
+ if (staleDocs.length >= 2) {
348
+ console.log(`\n ${c.yellow}↻ ${staleDocs.length} docs are stale (10+ commits since last update). Run ${c.cyan}docguard sync --write${c.yellow} to refresh code-truth sections in one pass.${c.reset}`);
349
+ }
350
+ }
351
+ }
352
+
246
353
  // Spec-kit reminder — persistent nudge if not initialized
247
354
  if (!isSpecKitInitialized(projectDir)) {
248
355
  console.log(`\n ${c.yellow}💡${c.reset} ${c.dim}Enhance DocGuard with Spec Kit: ${c.cyan}uv tool install specify-cli --from git+https://github.com/github/spec-kit.git${c.reset}`);
249
356
  }
250
357
 
358
+ // When severity overrides demoted warnings to "low" (or promoted them to
359
+ // "high"), show a one-line note so the user knows the exit code may not
360
+ // match what they expected from reading the warning count.
361
+ const severityShifted =
362
+ data.effectiveErrors !== data.errors || data.effectiveWarnings !== data.warnings;
363
+ if (severityShifted) {
364
+ const upgraded = data.effectiveErrors - data.errors;
365
+ const ignored = data.warnings - data.effectiveWarnings - upgraded;
366
+ const parts = [];
367
+ if (upgraded > 0) parts.push(`${upgraded} warning(s) escalated to fail (severity=high)`);
368
+ if (ignored > 0) parts.push(`${ignored} warning(s) ignored for exit code (severity=low)`);
369
+ if (parts.length > 0) {
370
+ console.log(`\n ${c.dim}Severity override: ${parts.join('; ')}.${c.reset}`);
371
+ }
372
+ }
373
+
251
374
  console.log('');
252
375
 
253
- if (data.errors > 0) process.exit(1);
254
- if (data.warnings > 0) process.exit(2);
376
+ // v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
377
+ if (data.effectiveErrors > 0) process.exit(1);
378
+ if (data.effectiveWarnings > 0) process.exit(2);
255
379
  process.exit(0);
256
380
  }
@@ -168,7 +168,7 @@ export async function runInit(projectDir, config, flags) {
168
168
 
169
169
  const defaultConfig = {
170
170
  projectName: config.projectName,
171
- version: '0.4',
171
+ version: '0.5',
172
172
  profile: profileName,
173
173
  projectType: detectedType,
174
174
  projectTypeConfig: ptc,
@@ -186,6 +186,13 @@ export async function runInit(projectDir, config, flags) {
186
186
  environment: true,
187
187
  freshness: true,
188
188
  },
189
+ // Per-validator severity overrides (v0.5+).
190
+ // 'high': warnings from this validator fail CI (exit 1)
191
+ // 'medium': default — warnings exit 2 (informational)
192
+ // 'low': warnings ignored for exit code (exit 0)
193
+ // Empty by default — every validator uses 'medium'. Add entries to dial
194
+ // strictness up (CI-critical checks) or down (experimental validators).
195
+ severity: {},
189
196
  };
190
197
 
191
198
  writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf-8');
@@ -196,6 +203,50 @@ export async function runInit(projectDir, config, flags) {
196
203
  console.log(` ${c.yellow}⏭️${c.reset} .docguard.json ${c.dim}(already exists)${c.reset}`);
197
204
  }
198
205
 
206
+ // ── Create .docguardignore ────────────────────────────────────────────
207
+ // Starter ignore file with gitignore-style syntax. Patterns here are merged
208
+ // into config.ignore at load time so every validator honors them.
209
+ const ignorePath = resolve(projectDir, '.docguardignore');
210
+ if (!existsSync(ignorePath)) {
211
+ const ignoreContent = `# .docguardignore — paths to exclude from DocGuard validation.
212
+ # Gitignore-style syntax: one pattern per line, # for comments.
213
+ # Merged into config.ignore (in .docguard.json) at runtime.
214
+ #
215
+ # Common examples:
216
+ # build/ # exclude a directory
217
+ # **/__generated__/** # exclude anything in any __generated__ dir
218
+ # vendor/legacy.ts # exclude a single file
219
+ # **/*.snap # exclude all files matching a glob
220
+ #
221
+ # Build outputs, vendored libs, generated code are good candidates here.
222
+
223
+ # Vendored / generated code that's not yours to document
224
+ **/__generated__/**
225
+ **/generated/**
226
+ **/*.generated.*
227
+
228
+ # Migrations and lock files
229
+ **/migrations/**
230
+ **/*.lock
231
+ package-lock.json
232
+ yarn.lock
233
+ pnpm-lock.yaml
234
+ Cargo.lock
235
+ poetry.lock
236
+
237
+ # Common build artifacts (defaults also cover these, but listing here is clearer)
238
+ # dist/
239
+ # build/
240
+ # coverage/
241
+ `;
242
+ writeFileSync(ignorePath, ignoreContent, 'utf-8');
243
+ created.push('.docguardignore');
244
+ console.log(` ${c.green}✅${c.reset} Created: ${c.cyan}.docguardignore${c.reset} ${c.dim}(gitignore-style exclusions)${c.reset}`);
245
+ } else {
246
+ skipped.push('.docguardignore');
247
+ console.log(` ${c.yellow}⏭️${c.reset} .docguardignore ${c.dim}(already exists)${c.reset}`);
248
+ }
249
+
199
250
  // ── Spec-Kit Integration (Extension-First) ────────────────────────────
200
251
  // Delegate LLM/IDE detection and spec-kit skill install to `specify init`
201
252
  const specKitAvailable = isSpecKitAvailable();
@@ -34,6 +34,48 @@ function gitChangedFiles(projectDir, since) {
34
34
  return [...new Set([...committed, ...working])];
35
35
  }
36
36
 
37
+ /**
38
+ * L-1: Map each `source: 'code'` section ID to a predicate that returns true
39
+ * when one of the changed file paths could plausibly affect it. Conservative
40
+ * by design — when in doubt we run the section's sync, never skip it.
41
+ *
42
+ * The predicates are matched against project-relative POSIX paths (the form
43
+ * `git diff --name-only` returns).
44
+ */
45
+ const SECTION_FILE_MATCHERS = {
46
+ 'tech-stack': (p) => /package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|pom\.xml$|Gemfile$/.test(p),
47
+ 'frontend-modules': (p) => /(^|\/)(src\/)?(stores|hooks|contexts|features)\//.test(p),
48
+ 'endpoints-table': (p) => /(^|\/)(routes|controllers|handlers|app\/api)\//.test(p)
49
+ || /\.(yaml|yml|json)$/i.test(p) && /openapi|swagger/i.test(p),
50
+ 'entities-table': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
51
+ || /\.prisma$/.test(p),
52
+ 'relationships': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
53
+ || /\.prisma$/.test(p),
54
+ 'screens-table': (p) => /(^|\/)(screens|pages|app)\//.test(p)
55
+ || /\.(tsx|jsx)$/.test(p),
56
+ 'flows': (p) => /(^|\/)(screens|pages|app|routes)\//.test(p),
57
+ 'integrations-table':(p) => /package\.json$|pyproject\.toml$|requirements.*\.txt$|Cargo\.toml$/.test(p),
58
+ 'features-table': (p) => /(^|\/)(features|domains)\//.test(p),
59
+ 'features': (p) => /(^|\/)(features|domains)\//.test(p),
60
+ 'env-vars-table': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p)
61
+ || /\.(ts|tsx|js|jsx|mjs|py|go|rs|java|kt|rb)$/.test(p), // any code may use env
62
+ 'setup': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p),
63
+ };
64
+
65
+ /**
66
+ * Decide whether a given code-truth section should be re-synced based on the
67
+ * set of changed files. Returns true when:
68
+ * - changedFiles is null/empty (no scope info → sync everything), OR
69
+ * - any changed file matches the section's known source patterns, OR
70
+ * - the section has no matcher registered (unknown → conservative: sync)
71
+ */
72
+ function sectionTouchedByChanges(sectionId, changedFiles) {
73
+ if (!changedFiles || changedFiles.length === 0) return true;
74
+ const matcher = SECTION_FILE_MATCHERS[sectionId];
75
+ if (!matcher) return true; // unknown section → don't accidentally skip it
76
+ return changedFiles.some(matcher);
77
+ }
78
+
37
79
  export function runSync(projectDir, config, flags) {
38
80
  const plan = buildMemoryPlan(projectDir, config);
39
81
  const apply = !!flags.write;
@@ -63,6 +105,14 @@ export function runSync(projectDir, config, flags) {
63
105
  const existing = getSection(content, sec.id);
64
106
  if (!existing) continue; // sync refreshes sections that already exist
65
107
  if (existing.body.trim() === String(sec.body).trim()) continue; // already current
108
+ // L-1: when --since is provided, only update sections whose underlying
109
+ // source files appear in the changed set. Avoids spurious updates when
110
+ // the section's CONTENT would naturally drift (e.g. timestamp-driven
111
+ // counters) but no real source file changed.
112
+ if (changed !== null && !sectionTouchedByChanges(sec.id, changed)) {
113
+ skipped.push({ doc: doc.path, reason: `section ${sec.id} unchanged since ${flags.since} (no underlying source files in diff)` });
114
+ continue;
115
+ }
66
116
  codeSectionChanged = true;
67
117
  updates.push({ doc: doc.path, section: sec.id, status: apply ? 'updated' : 'stale' });
68
118
  if (apply) { content = replaceSection(content, sec.id, sec.body).content; docChanged = true; }
@@ -84,7 +84,112 @@ const TRACE_MAP = {
84
84
  },
85
85
  };
86
86
 
87
+ /**
88
+ * L-2 / S-3 — Reverse trace: given a code file, find which canonical doc
89
+ * sections mention it. Mirror of the forward trace (doc → code).
90
+ *
91
+ * Match strategies (each yields a hit):
92
+ * 1. Direct path match: full project-relative path appears in doc text.
93
+ * 2. Basename match: e.g. `users.ts` appears (covers cases where the doc
94
+ * refers to the file by name without the full path).
95
+ * 3. Module name match: file stem (e.g. `users`) appears as a fenced
96
+ * `code` reference. Tighter than 2 — avoids matching common nouns.
97
+ *
98
+ * Output: one line per (doc, match-line) pair, with the surrounding context.
99
+ */
100
+ export function runTraceReverse(projectDir, config, flags) {
101
+ const target = flags.args && flags.args[0];
102
+ if (!target) {
103
+ console.error(`${c.red}Error: trace --reverse requires a target path${c.reset}`);
104
+ console.log(`Usage: ${c.cyan}docguard trace --reverse <code-path>${c.reset}`);
105
+ console.log(`Example: ${c.cyan}docguard trace --reverse src/routes/users.ts${c.reset}`);
106
+ process.exit(1);
107
+ }
108
+
109
+ // Suppress chrome in JSON mode so stdout stays parseable.
110
+ const isJson = flags.format === 'json';
111
+ if (!isJson) {
112
+ console.log(`${c.bold}🔄 DocGuard Trace (reverse) — ${target}${c.reset}`);
113
+ console.log(`${c.dim} Finding canonical doc sections that reference this file...${c.reset}\n`);
114
+ }
115
+
116
+ const docsDir = resolve(projectDir, 'docs-canonical');
117
+ if (!existsSync(docsDir)) {
118
+ if (isJson) {
119
+ console.log(JSON.stringify({ target, matches: [], error: 'no docs-canonical/ directory' }, null, 2));
120
+ } else {
121
+ console.log(` ${c.yellow}No docs-canonical/ directory found.${c.reset}`);
122
+ }
123
+ return;
124
+ }
125
+
126
+ // Normalize the target path: strip leading ./
127
+ const normalized = target.replace(/^\.\//, '');
128
+ const base = basename(normalized);
129
+ const stem = base.replace(/\.[^.]+$/, '');
130
+
131
+ const matches = []; // { doc, line, content, kind }
132
+ for (const f of readdirSync(docsDir)) {
133
+ if (!f.endsWith('.md')) continue;
134
+ const docPath = resolve(docsDir, f);
135
+ let content;
136
+ try { content = readFileSync(docPath, 'utf-8'); } catch { continue; }
137
+ const lines = content.split('\n');
138
+ for (let i = 0; i < lines.length; i++) {
139
+ const line = lines[i];
140
+ let kind = null;
141
+ if (line.includes(normalized)) kind = 'path';
142
+ else if (line.includes(base)) kind = 'basename';
143
+ else if (new RegExp(`\`${escapeRegex(stem)}\``).test(line)) kind = 'module';
144
+ if (kind) {
145
+ matches.push({ doc: f, line: i + 1, content: line.trim(), kind });
146
+ }
147
+ }
148
+ }
149
+
150
+ if (flags.format === 'json') {
151
+ console.log(JSON.stringify({
152
+ target: normalized,
153
+ matches,
154
+ timestamp: new Date().toISOString(),
155
+ }, null, 2));
156
+ return;
157
+ }
158
+
159
+ if (matches.length === 0) {
160
+ console.log(` ${c.yellow}⚠️ No canonical doc references "${normalized}"${c.reset}`);
161
+ console.log(` ${c.dim}Consider documenting this file in docs-canonical/ARCHITECTURE.md or DATA-MODEL.md${c.reset}`);
162
+ return;
163
+ }
164
+
165
+ // Group by doc for readable output
166
+ const byDoc = new Map();
167
+ for (const m of matches) {
168
+ if (!byDoc.has(m.doc)) byDoc.set(m.doc, []);
169
+ byDoc.get(m.doc).push(m);
170
+ }
171
+
172
+ console.log(` ${c.green}✅ ${matches.length} reference(s) across ${byDoc.size} doc(s):${c.reset}\n`);
173
+ for (const [doc, hits] of byDoc) {
174
+ console.log(` ${c.cyan}${doc}${c.reset} ${c.dim}(${hits.length} hit${hits.length > 1 ? 's' : ''})${c.reset}`);
175
+ for (const h of hits.slice(0, 5)) {
176
+ const trimmed = h.content.length > 80 ? h.content.slice(0, 77) + '…' : h.content;
177
+ console.log(` ${c.dim}L${h.line} [${h.kind}]${c.reset} ${trimmed}`);
178
+ }
179
+ if (hits.length > 5) console.log(` ${c.dim}... ${hits.length - 5} more${c.reset}`);
180
+ }
181
+ }
182
+
183
+ function escapeRegex(s) {
184
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
185
+ }
186
+
87
187
  export function runTrace(projectDir, config, flags) {
188
+ // L-2: dispatch to reverse mode when --reverse is set.
189
+ if (flags.reverse) {
190
+ return runTraceReverse(projectDir, config, flags);
191
+ }
192
+
88
193
  console.log(`${c.bold}🔗 DocGuard Trace — ${config.projectName}${c.reset}`);
89
194
  console.log(`${c.dim} Directory: ${projectDir}${c.reset}`);
90
195
  console.log(`${c.dim} Generating requirements traceability matrix...${c.reset}\n`);