docguard-cli 0.40.5 → 0.41.1

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 (56) hide show
  1. package/CHANGELOG.md +3246 -0
  2. package/README.md +25 -16
  3. package/cli/assessment.mjs +94 -0
  4. package/cli/commands/ci.mjs +15 -5
  5. package/cli/commands/diagnose.mjs +20 -13
  6. package/cli/commands/fix.mjs +14 -45
  7. package/cli/commands/guard.mjs +53 -25
  8. package/cli/commands/hooks.mjs +51 -10
  9. package/cli/commands/init.mjs +15 -0
  10. package/cli/commands/reconcile.mjs +10 -3
  11. package/cli/commands/report.mjs +5 -1
  12. package/cli/commands/score.mjs +2 -1
  13. package/cli/commands/specs.mjs +15 -4
  14. package/cli/commands/upgrade.mjs +4 -1
  15. package/cli/commands/verify.mjs +9 -2
  16. package/cli/commands/watch.mjs +3 -2
  17. package/cli/config.mjs +23 -0
  18. package/cli/evidence/adapters.mjs +14 -0
  19. package/cli/evidence/manifest.mjs +15 -0
  20. package/cli/evidence/python-literal.mjs +304 -0
  21. package/cli/findings.mjs +17 -3
  22. package/cli/scanners/instruction-audit.mjs +88 -11
  23. package/cli/scanners/js-ast.mjs +156 -18
  24. package/cli/scanners/reconciliation.mjs +56 -6
  25. package/cli/scanners/routes.mjs +84 -9
  26. package/cli/scanners/spec-registry.mjs +29 -0
  27. package/cli/shared-git.mjs +98 -0
  28. package/cli/shared-ignore.mjs +1 -1
  29. package/cli/shared.mjs +30 -1
  30. package/cli/validators/api-doc-smells.mjs +2 -2
  31. package/cli/validators/api-surface.mjs +4 -9
  32. package/cli/validators/diff-suspicion.mjs +3 -2
  33. package/cli/validators/docs-sync.mjs +45 -29
  34. package/cli/validators/environment.mjs +64 -6
  35. package/cli/validators/metrics-consistency.mjs +52 -11
  36. package/cli/validators/reference-existence.mjs +4 -2
  37. package/cli/validators/security.mjs +37 -12
  38. package/cli/validators/spec-registry.mjs +10 -7
  39. package/cli/validators/todo-tracking.mjs +31 -11
  40. package/cli/validators/traceability.mjs +29 -4
  41. package/cli/writers/junit.mjs +3 -3
  42. package/cli/writers/sarif.mjs +13 -9
  43. package/docs/configuration.md +12 -1
  44. package/extensions/spec-kit-docguard/extension.yml +1 -1
  45. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  46. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  48. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  49. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  50. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
  51. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  52. package/package.json +2 -1
  53. package/schemas/docguard-config.schema.json +15 -1
  54. package/schemas/docguard-evidence.schema.json +12 -0
  55. package/templates/ci/github-actions.yml +1 -1
  56. package/templates/evidence-manifest.json +16 -0
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Hooks Command — Generate pre-commit/pre-push hooks for DocGuard
3
3
  * Creates git hooks that run guard/score before commits.
4
+ * @implements docguard.adoption-workflow-integrity#FR-003
4
5
  */
5
6
 
6
7
  import { existsSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
@@ -54,6 +55,29 @@ function spliceManagedBlock(existing, newBody) {
54
55
  const bodyNoShebang = newBody.replace(/^#!.*\n/, '');
55
56
  return `${before}${BEGIN_MARKER}\n${bodyNoShebang.replace(/\n+$/, '')}\n${END_MARKER}${after}`;
56
57
  }
58
+
59
+ function hookState(name, hooksDir) {
60
+ const path = resolve(hooksDir, name);
61
+ if (!existsSync(path)) return { kind: 'missing', path, content: '' };
62
+ let content;
63
+ try { content = readFileSync(path, 'utf-8'); }
64
+ catch { return { kind: 'unreadable', path, content: '' }; }
65
+ if (content.includes(BEGIN_MARKER) && content.includes(END_MARKER)) {
66
+ return { kind: 'managed', path, content };
67
+ }
68
+ const legacySignature = new RegExp(`^# DocGuard ${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} hook(?: \\(auto-fix mode\\))?$`, 'm');
69
+ if (legacySignature.test(content)) return { kind: 'legacy', path, content };
70
+ return { kind: 'foreign', path, content };
71
+ }
72
+
73
+ function removeManagedBlock(content) {
74
+ const start = content.indexOf(BEGIN_MARKER);
75
+ const end = content.indexOf(END_MARKER);
76
+ if (start === -1 || end === -1 || end < start) return null;
77
+ const remaining = `${content.slice(0, start)}${content.slice(end + END_MARKER.length)}`
78
+ .replace(/\n{3,}/g, '\n\n');
79
+ return remaining;
80
+ }
57
81
  import { resolve, relative, basename } from 'node:path';
58
82
  import { c } from '../shared.mjs';
59
83
  import { safeWrite } from '../writers/generate-io.mjs';
@@ -278,8 +302,16 @@ export function runHooks(projectDir, config, flags) {
278
302
  if (flags.list) {
279
303
  console.log(` ${c.bold}Available hooks:${c.reset}\n`);
280
304
  for (const [name, hook] of Object.entries(HOOKS)) {
281
- const installed = existsSync(resolve(hooksDir, name));
282
- const status = installed ? `${c.green}✅ installed${c.reset}` : `${c.dim}not installed${c.reset}`;
305
+ const state = hookState(name, hooksDir);
306
+ const status = state.kind === 'managed'
307
+ ? `${c.green}✅ DocGuard installed${c.reset}`
308
+ : state.kind === 'legacy'
309
+ ? `${c.yellow}⚠ legacy DocGuard hook — upgrade with --force${c.reset}`
310
+ : state.kind === 'foreign'
311
+ ? `${c.yellow}existing non-DocGuard hook${c.reset}`
312
+ : state.kind === 'unreadable'
313
+ ? `${c.red}unreadable hook${c.reset}`
314
+ : `${c.dim}not installed${c.reset}`;
283
315
  console.log(` ${c.cyan}${name}${c.reset}: ${hook.description} [${status}]`);
284
316
  }
285
317
  console.log(`\n ${c.dim}Install: docguard hooks --type <name>${c.reset}`);
@@ -291,16 +323,25 @@ export function runHooks(projectDir, config, flags) {
291
323
  if (flags.remove) {
292
324
  let removed = 0;
293
325
  for (const name of hookTypes) {
294
- const hookPath = resolve(hooksDir, name);
295
- if (existsSync(hookPath)) {
296
- const content = readFileSync(hookPath, 'utf-8');
297
- if (content.includes('DocGuard')) {
298
- unlinkSync(hookPath);
299
- console.log(` ${c.yellow}🗑️ Removed: ${name}${c.reset}`);
300
- removed++;
326
+ const state = hookState(name, hooksDir);
327
+ if (state.kind === 'managed') {
328
+ const remaining = removeManagedBlock(state.content);
329
+ const meaningful = remaining.replace(/^#!.*(?:\n|$)/, '').trim();
330
+ if (meaningful) {
331
+ safeWrite(state.path, remaining);
332
+ chmodSync(state.path, 0o755);
333
+ console.log(` ${c.yellow}🗑️ Removed DocGuard block from ${name}; preserved other hook commands${c.reset}`);
301
334
  } else {
302
- console.log(` ${c.dim}⏭️ ${name}: not a DocGuard hook (skipped)${c.reset}`);
335
+ unlinkSync(state.path);
336
+ console.log(` ${c.yellow}🗑️ Removed: ${name}${c.reset}`);
303
337
  }
338
+ removed++;
339
+ } else if (state.kind === 'legacy') {
340
+ unlinkSync(state.path);
341
+ console.log(` ${c.yellow}🗑️ Removed legacy DocGuard hook: ${name}${c.reset}`);
342
+ removed++;
343
+ } else if (state.kind !== 'missing') {
344
+ console.log(` ${c.dim}⏭️ ${name}: not a recognized DocGuard hook (skipped)${c.reset}`);
304
345
  }
305
346
  }
306
347
  console.log(`\n Removed: ${removed}\n`);
@@ -199,6 +199,18 @@ function shouldRunGenerate(projectDir, flags) {
199
199
  }
200
200
 
201
201
  export async function runInit(projectDir, config, flags) {
202
+ // `--list` is read-only inventory owned by the hooks scaffolder. Routing it
203
+ // through normal init can start prompts or create documentation before the
204
+ // requested inventory is shown, which violates the command's read-only intent.
205
+ if (flags.list && Array.isArray(flags.with)) {
206
+ if (flags.with.length !== 1 || flags.with[0] !== 'hooks') {
207
+ console.error(`${c.red}--list is supported only with \`docguard init --with hooks\`.${c.reset}`);
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+ const { runHooks } = await import('./hooks.mjs');
212
+ return runHooks(projectDir, config, flags);
213
+ }
202
214
  if (true) assertDefaultDocWrites(config);
203
215
  // v0.20: `--wizard` dispatches to the full interactive onboarding (formerly
204
216
  // `docguard setup`). Done before profile validation so the wizard can ask
@@ -381,6 +393,9 @@ export async function runInit(projectDir, config, flags) {
381
393
  // Empty by default — every validator uses 'medium'. Add entries to dial
382
394
  // strictness up (CI-critical checks) or down (experimental validators).
383
395
  severity: {},
396
+ // Exact stable-code overrides. Use this when one rule needs a different
397
+ // policy without weakening or escalating every finding in its validator.
398
+ findingSeverity: {},
384
399
  };
385
400
 
386
401
  writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf-8');
@@ -7,12 +7,19 @@
7
7
  import { buildReconciliationPlan } from '../scanners/reconciliation.mjs';
8
8
  import { runSync } from './sync.mjs';
9
9
 
10
- function printPlan(result) {
10
+ function printPlan(result, flags = {}) {
11
11
  console.log(`Reconciliation: ${result.status}`);
12
12
  console.log(`Revision: ${result.baseRevision || 'unknown'} → ${result.revision || 'unknown'}`);
13
- for (const item of result.classifications) {
13
+ if (result.range) console.log(`Range: ${result.range.commitCount ?? 'unknown'} commit(s), ${result.range.changedFileCount} changed file(s)`);
14
+ if (result.coverage?.status !== 'complete') {
15
+ console.log(`Coverage: ${result.coverage.status} — ${result.coverage.reason}`);
16
+ }
17
+ const maximum = flags.verbose ? result.classifications.length : 20;
18
+ for (const item of result.classifications.slice(0, maximum)) {
14
19
  console.log(` [${item.confidence}] ${item.path || item.kind}: ${item.disposition}`);
15
20
  }
21
+ const elided = result.classifications.length - maximum;
22
+ if (elided > 0) console.log(` … ${elided} more classification(s); rerun with --verbose to list all.`);
16
23
  if (result.writes.length) console.log(`Mechanical write available: ${result.writes[0].command}`);
17
24
  }
18
25
 
@@ -29,7 +36,7 @@ export function runReconcile(projectDir, config, flags = {}) {
29
36
  }
30
37
  const result = { command: 'reconcile', ...plan, applied: Boolean(flags.write), mechanical };
31
38
  if (flags.format === 'json') console.log(JSON.stringify(result, null, 2));
32
- else printPlan(result);
39
+ else printPlan(result, flags);
33
40
  if (flags.check && !['READY'].includes(result.status)) process.exitCode = 2;
34
41
  return result;
35
42
  } catch (error) {
@@ -29,6 +29,7 @@ import { runGuardInternal } from './guard.mjs';
29
29
  import { runScoreInternal, computeAlcoaCompliance } from './score.mjs';
30
30
  import { getHeadInfo, isGitRepo } from '../shared-git.mjs';
31
31
  import { loadFixMemory } from '../writers/fix-memory.mjs';
32
+ import { buildReadinessAssessment } from '../assessment.mjs';
32
33
 
33
34
  const _PKG = JSON.parse(readFileSync(resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8'));
34
35
  const CLI_VERSION = _PKG.version;
@@ -44,6 +45,7 @@ export function buildReport(projectDir, config) {
44
45
  const alcoa = computeAlcoaCompliance(projectDir, config, scoreData.categories);
45
46
  const git = isGitRepo(projectDir) ? getHeadInfo(projectDir) : null;
46
47
  const fixMemory = loadFixMemory(projectDir);
48
+ const assessment = buildReadinessAssessment(guardData, scoreData);
47
49
 
48
50
  // Findings grouped by stable code — auditors care about "how many of
49
51
  // which class", not the per-file noise. Codeless findings group as OTHER.
@@ -65,6 +67,7 @@ export function buildReport(projectDir, config) {
65
67
  type: config.projectType || 'unknown',
66
68
  },
67
69
  git: git ? { commit: git.commit, branch: git.branch, dirty: git.dirty } : null,
70
+ assessment,
68
71
  guard: {
69
72
  status: guardData.status,
70
73
  passed: guardData.passed,
@@ -126,7 +129,8 @@ export function toMarkdown(r) {
126
129
  lines.push('');
127
130
  lines.push('| Metric | Value |');
128
131
  lines.push('|--------|-------|');
129
- lines.push(`| CDD Score (structural maturity) | ${r.score.score}/100 (${r.score.grade}) |`);
132
+ lines.push(`| Readiness | **${r.assessment.status}** ${r.assessment.summary} |`);
133
+ lines.push(`| Structural Maturity | ${r.score.score}/100 (${r.score.grade}); this is not a guard verdict |`);
130
134
  lines.push(`| Factual accuracy | Unverified — ${r.score.assurance.unverifiedClaims ?? 'unknown number of'} extracted claim(s); discovery is heuristic |`);
131
135
  lines.push(`| Guard | ${r.guard.status.toUpperCase()} — ${r.guard.passed}/${r.guard.total} checks, ${r.guard.errors} error(s), ${r.guard.warnings} warning(s) |`);
132
136
  if (r.guard.baselineSuppressed > 0) {
@@ -160,7 +160,7 @@ export function runScore(projectDir, config, flags) {
160
160
  // mixed ANSI escapes with JSON.
161
161
  const isJson = flags.format === 'json';
162
162
  if (!isJson) {
163
- console.log(`${c.bold}📊 DocGuard Score — ${config.projectName}${c.reset}`);
163
+ console.log(`${c.bold}📊 DocGuard Structural Maturity — ${config.projectName}${c.reset}`);
164
164
  console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
165
165
  }
166
166
 
@@ -222,6 +222,7 @@ export function runScore(projectDir, config, flags) {
222
222
 
223
223
  const gradeColor = totalScore >= 80 ? c.green : totalScore >= 60 ? c.yellow : c.red;
224
224
  console.log(` ${gradeColor}${c.bold}CDD Maturity Score: ${totalScore}/100 (${grade})${c.reset}`);
225
+ console.log(` ${c.dim}Structural Maturity only — this score is not a guard verdict. Run ${c.cyan}docguard guard${c.dim} for PASS/WARN/FAIL.${c.reset}`);
225
226
  // Memory framing: is the documentation memory COMPLETE and ACCURATE?
226
227
  const memColor = (s) => s >= 80 ? c.green : s >= 60 ? c.yellow : c.red;
227
228
  console.log(` ${c.dim}Memory:${c.reset} ${memColor(memory.completeness)}Completeness ${memory.completeness}%${c.reset} ${c.dim}·${c.reset} ${c.cyan}Factual accuracy: unverified${c.reset}`);
@@ -57,7 +57,10 @@ function archiveReadiness(spec, targetVerified = false) {
57
57
  }
58
58
 
59
59
  function completionTransition(spec) {
60
- if (spec?.reviewed.lifecycle.delivery === 'verified') return 'verified→verified';
60
+ if (spec?.reviewed.lifecycle.persistenceModel === 'living'
61
+ && ['verified', 'released'].includes(spec.reviewed.lifecycle.delivery)) {
62
+ return `${spec.reviewed.lifecycle.delivery}→${spec.reviewed.lifecycle.delivery}`;
63
+ }
61
64
  return spec?.reviewed.lifecycle.delivery === 'in_progress'
62
65
  ? 'in_progress→implemented→verified'
63
66
  : 'implemented→verified';
@@ -77,14 +80,22 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
77
80
 
78
81
  let reconcile = null;
79
82
  if (spec) {
80
- const maintenance = spec.reviewed.lifecycle.delivery === 'verified'
83
+ const maintenance = ['verified', 'released'].includes(spec.reviewed.lifecycle.delivery)
81
84
  && spec.reviewed.lifecycle.persistenceModel === 'living';
82
85
  if (spec.reviewed.lifecycle.approval !== 'approved') blockers.push({ code: 'SPC002', message: 'Only an approved spec can become verified.' });
83
86
  if (!['in_progress', 'implemented'].includes(spec.reviewed.lifecycle.delivery) && !maintenance) {
84
87
  blockers.push({ code: 'SPC002', message: `Expected delivery=in_progress, implemented, or verified with persistenceModel=living; found ${spec.reviewed.lifecycle.delivery}/${spec.reviewed.lifecycle.persistenceModel || 'unset'}.` });
85
88
  }
86
89
  const tasks = spec.observed.taskCompletion;
87
- if (!tasks.total || tasks.checked !== tasks.total) blockers.push({ code: 'SPC003', message: `All tasks must be checked (${tasks.checked}/${tasks.total}).` });
90
+ const hasTaskLedger = spec.observed.artifacts.some(artifact => /(?:^|\/)tasks\.md$/i.test(artifact.path));
91
+ if (hasTaskLedger && (!tasks.total || tasks.checked !== tasks.total)) {
92
+ blockers.push({ code: 'SPC003', message: `All declared tasks must be checked (${tasks.checked}/${tasks.total}).` });
93
+ } else if (!hasTaskLedger && spec.reviewed.lifecycle.persistenceModel !== 'living') {
94
+ blockers.push({
95
+ code: 'SPC003',
96
+ message: 'Completion requires a task ledger unless the approved spec is a living verification contract with qualified evidence for every requirement.',
97
+ });
98
+ }
88
99
  if (spec.observed.implementationEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified source implementation annotation is required.' });
89
100
  if (spec.observed.testEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified test annotation is required.' });
90
101
  const covered = new Set([...spec.observed.implementationEvidence, ...spec.observed.testEvidence].map(item => item.requirementId));
@@ -162,7 +173,7 @@ export function completeSpec(projectDir, config, flags, options = {}) {
162
173
  successor: flags.successor || null,
163
174
  };
164
175
  const specContent = appendImplementationOutcome(readFileSync(specPath, 'utf8'), outcome);
165
- spec.reviewed.lifecycle.delivery = 'verified';
176
+ if (spec.reviewed.lifecycle.delivery !== 'released') spec.reviewed.lifecycle.delivery = 'verified';
166
177
  spec.reviewed.reconciliation.lastReviewedRevision = plan.revision;
167
178
  spec.reviewed.reconciliation.outcomes = [...spec.reviewed.reconciliation.outcomes, outcome].slice(-20);
168
179
  const artifact = spec.observed.artifacts.find(item => item.path === spec.path);
@@ -129,6 +129,9 @@ export function migrateSchema(cfg, fromVersion) {
129
129
  // additive: existing projects get an empty severity map and default
130
130
  // (medium) behavior. No behavioral change unless they explicitly opt in.
131
131
  '0.5': (c) => ({ ...c, severity: c.severity || {}, version: '0.5' }),
132
+ // v0.6 — exact finding-code enforcement overrides. Empty by default, so
133
+ // existing validator-level behavior remains unchanged after migration.
134
+ '0.6': (c) => ({ ...c, findingSeverity: c.findingSeverity || {}, version: '0.6' }),
132
135
  };
133
136
  let current = { ...cfg };
134
137
  let changed = false;
@@ -211,7 +214,7 @@ function openUpgradePR(projectDir, migratedConfig, fromVersion, toVersion) {
211
214
  `Automated schema migration from \`${fromVersion}\` → \`${toVersion}\`.\n\n` +
212
215
  `This PR was opened by \`docguard upgrade --apply --pr\`. It updates the\n` +
213
216
  `\`.docguard.json\` schema version and any additive fields the new schema\n` +
214
- `introduces (e.g. \`severity: {}\` for v0.5).\n\n` +
217
+ `introduces (for example \`findingSeverity: {}\` for v0.6).\n\n` +
215
218
  `Review and merge to keep your team's DocGuard config in sync.\n\n` +
216
219
  `> 🤖 Generated by [DocGuard](https://github.com/raccioly/docguard)`;
217
220
  r = spawnSync('gh', [
@@ -208,8 +208,8 @@ function runEvidenceVerification(projectDir, config, flags) {
208
208
  function runInstructionAudit(projectDir, config, flags) {
209
209
  const isJson = flags.format === 'json';
210
210
  const { rules, deterministic, tasks } = auditInstructions(projectDir, config);
211
- const { duplicates, negations, stalePointers, staleCommands } = deterministic;
212
- const findingCount = duplicates.length + negations.length + stalePointers.length + staleCommands.length;
211
+ const { duplicates, negations, stalePointers, ambiguousPointers = [], unsafePointers = [], staleCommands } = deterministic;
212
+ const findingCount = duplicates.length + negations.length + stalePointers.length + ambiguousPointers.length + unsafePointers.length + staleCommands.length;
213
213
  // Structured change context helps the agent judge whether a rule about code
214
214
  // has been invalidated by a recent change (feat 6).
215
215
  const changeContext = buildChangeContext(projectDir, flags.since);
@@ -254,6 +254,13 @@ function runInstructionAudit(projectDir, config, flags) {
254
254
  for (const s of stalePointers) {
255
255
  console.log(` ${c.yellow}⚠${c.reset} stale pointer — ${s.file}:${s.line}: ${c.cyan}${s.path}${c.reset} does not exist`);
256
256
  }
257
+ for (const pointer of ambiguousPointers) {
258
+ console.log(` ${c.yellow}AMBIGUOUS POINTER${c.reset} ${pointer.file}:${pointer.line} — ${pointer.path}`);
259
+ console.log(` ${c.dim}${pointer.matchCount} matches: ${pointer.matches.join(', ')}${c.reset}`);
260
+ }
261
+ for (const pointer of unsafePointers) {
262
+ console.log(` ${c.yellow}UNSAFE POINTER${c.reset} ${pointer.file}:${pointer.line} — ${pointer.path}`);
263
+ }
257
264
  for (const s of staleCommands) {
258
265
  console.log(` ${c.yellow}⚠${c.reset} stale command — ${s.file}:${s.line}: ${c.cyan}docguard ${s.command}${c.reset} is not a docguard command`);
259
266
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { watch as fsWatch, readdirSync, lstatSync } from 'node:fs';
11
- import { resolve, extname } from 'node:path';
11
+ import { resolve, extname, basename } from 'node:path';
12
12
  import { c } from '../shared.mjs';
13
13
  import { runGuardInternal } from './guard.mjs';
14
14
  import { clearMemoryPlanCache } from '../scanners/memory-plan.mjs';
@@ -24,7 +24,8 @@ export function runWatch(projectDir, config, flags = {}, runtime = {}) {
24
24
  const watch = runtime.watch || fsWatch;
25
25
  const guard = runtime.guard || runGuardInternal;
26
26
  const clearCache = runtime.clearCache || clearMemoryPlanCache;
27
- console.log(`${c.bold}👁️ DocGuard Watch ${config.projectName}${c.reset}`);
27
+ const projectName = config.projectName || basename(resolve(projectDir)) || 'project';
28
+ console.log(`${c.bold}👁️ DocGuard Watch — ${projectName}${c.reset}`);
28
29
  console.log(`${c.dim} Directory: ${projectDir}${c.reset}`);
29
30
  if (flags.autoFix) {
30
31
  console.log(`${c.cyan} Mode: auto-fix (will output AI prompts on failures)${c.reset}`);
package/cli/config.mjs CHANGED
@@ -14,6 +14,7 @@ import { applyDocRoles } from './shared-doc-roles.mjs';
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import { resolve, basename } from 'node:path';
16
16
  import { c, PROFILES, SEVERITY_LEVELS } from './shared.mjs';
17
+ import { CODES } from './findings.mjs';
17
18
  import { mergeIgnoreFile } from './shared-ignore.mjs';
18
19
  import { detectProjectName } from './scanners/project-type.mjs';
19
20
 
@@ -91,6 +92,7 @@ export function loadConfig(projectDir) {
91
92
  referenceExistence: true,
92
93
  apiDocSmells: true,
93
94
  },
95
+ findingSeverity: {},
94
96
  };
95
97
 
96
98
  if (existsSync(configPath)) {
@@ -123,6 +125,16 @@ export function loadConfig(projectDir) {
123
125
  }
124
126
  }
125
127
  }
128
+ if (merged.findingSeverity && typeof merged.findingSeverity === 'object') {
129
+ for (const [code, val] of Object.entries(merged.findingSeverity)) {
130
+ if (!Object.hasOwn(CODES, code)) {
131
+ throw new Error(`findingSeverity.${code} is not a known finding code`);
132
+ }
133
+ if (typeof val !== 'string' || !SEVERITY_LEVELS.has(val.toLowerCase())) {
134
+ throw new Error(`findingSeverity.${code} must be high, medium, or low`);
135
+ }
136
+ }
137
+ }
126
138
 
127
139
  // Auto-detect project type if not set
128
140
  if (!merged.projectType) {
@@ -249,6 +261,17 @@ function normalizeConfig(cfg) {
249
261
  const out = { ...cfg };
250
262
  if (out.validators) out.validators = _normalizeValidatorKeys(out.validators);
251
263
  if (out.severity) out.severity = _normalizeValidatorKeys(out.severity);
264
+ if (out.findingSeverity && typeof out.findingSeverity === 'object' && !Array.isArray(out.findingSeverity)) {
265
+ const normalized = {};
266
+ for (const [rawCode, value] of Object.entries(out.findingSeverity)) {
267
+ const code = rawCode.toUpperCase();
268
+ if (Object.hasOwn(normalized, code)) {
269
+ throw new Error(`findingSeverity contains duplicate code ${code}`);
270
+ }
271
+ normalized[code] = typeof value === 'string' ? value.toLowerCase() : value;
272
+ }
273
+ out.findingSeverity = normalized;
274
+ }
252
275
  return out;
253
276
  }
254
277
 
@@ -9,6 +9,7 @@
9
9
  import { lstatSync, readdirSync, realpathSync } from 'node:fs';
10
10
  import { isAbsolute, relative, resolve, sep } from 'node:path';
11
11
  import { buildIgnoreFilter, compileGlob, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
12
+ import { countPythonLiteralEntries } from './python-literal.mjs';
12
13
 
13
14
  const MAX_COLLECTION_FILES = 20_000;
14
15
  const MAX_REPORT_FINDINGS = 10_000;
@@ -194,6 +195,19 @@ export function readEvidenceSource(projectDir, declaration, read, config = {}) {
194
195
  }
195
196
  return result;
196
197
  }
198
+ if (source.adapter === 'python-literal-count') {
199
+ const snapshot = read(source.path);
200
+ if (snapshot.content === null) {
201
+ return answer('inconclusive', `source-${snapshot.evidence.reason}`, `Cannot safely read Python source ${source.path}.`);
202
+ }
203
+ const parsed = countPythonLiteralEntries(snapshot.content, source.symbol);
204
+ const extra = { sourceEvidence: snapshot.evidence, inputHashes: [] };
205
+ if (Object.hasOwn(parsed, 'value')) extra.value = parsed.value;
206
+ if (parsed.status === 'ok' && parsed.value === 0 && !source.allowEmpty) {
207
+ return answer('inconclusive', 'empty-python-literal-not-allowed', 'Python literal has no entries and allowEmpty is false.', extra);
208
+ }
209
+ return answer(parsed.status, parsed.reasonCode, parsed.message, extra);
210
+ }
197
211
  if (source.adapter === 'oasdiff') return oasdiffReport(source, read);
198
212
  if (source.adapter === 'buf') return bufReport(source, read);
199
213
  return answer('unsupported', 'unsupported-adapter', `Adapter ${source.adapter} is unsupported.`);
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from 'node:fs';
8
8
  import { resolve } from 'node:path';
9
9
  import { createEvidenceReader } from '../scanners/semantic-claims.mjs';
10
+ import { PYTHON_SYMBOL_RE } from './python-literal.mjs';
10
11
 
11
12
  export const EVIDENCE_MANIFEST_PATH = '.docguard-evidence.json';
12
13
  export const EVIDENCE_SCHEMA_URL = 'https://raccioly.github.io/docguard/schemas/docguard-evidence.schema.json';
@@ -90,6 +91,17 @@ function validateSource(source, errors, id) {
90
91
  if (typeof source.allowEmpty !== 'boolean') errors.push(issue(`${id}.source.allowEmpty must be boolean.`, id));
91
92
  return;
92
93
  }
94
+ if (source.adapter === 'python-literal-count') {
95
+ exactKeys(source, ['adapter', 'path', 'symbol', 'allowEmpty'], `${id}.source`, errors, id);
96
+ if (!isSafeEvidencePath(source.path) || !source.path.endsWith('.py')) {
97
+ errors.push(issue(`${id}.source.path must be a safe repository-relative Python path.`, id));
98
+ }
99
+ if (!PYTHON_SYMBOL_RE.test(source.symbol || '')) {
100
+ errors.push(issue(`${id}.source.symbol must be an ASCII Python identifier containing at most 128 characters.`, id));
101
+ }
102
+ if (typeof source.allowEmpty !== 'boolean') errors.push(issue(`${id}.source.allowEmpty must be boolean.`, id));
103
+ return;
104
+ }
93
105
  if (REPORT_ADAPTERS.has(source.adapter)) {
94
106
  exactKeys(source, ['adapter', 'adapterVersion', 'path', 'producerVersion', 'command', 'inputs'], `${id}.source`, errors, id);
95
107
  if (!Number.isInteger(source.adapterVersion) || source.adapterVersion < 1 || source.adapterVersion > 100) {
@@ -143,6 +155,9 @@ function validatePredicate(predicate, source, statement, errors, id) {
143
155
  if (source?.adapter === 'collection-count' && predicate.kind !== 'count-equals') {
144
156
  errors.push(issue(`${id} must combine collection-count with count-equals.`, id));
145
157
  }
158
+ if (source?.adapter === 'python-literal-count' && predicate.kind !== 'count-equals') {
159
+ errors.push(issue(`${id} must combine python-literal-count with count-equals.`, id));
160
+ }
146
161
  if (REPORT_ADAPTERS.has(source?.adapter) && predicate.kind !== 'no-findings') {
147
162
  errors.push(issue(`${id} must combine ${source.adapter} with no-findings.`, id));
148
163
  }