docguard-cli 0.38.0 → 0.40.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 (63) hide show
  1. package/README.md +45 -22
  2. package/cli/commands/agent.mjs +47 -1
  3. package/cli/commands/explain.mjs +16 -0
  4. package/cli/commands/feedback.mjs +147 -6
  5. package/cli/commands/fix.mjs +13 -11
  6. package/cli/commands/generate.mjs +52 -18
  7. package/cli/commands/guard.mjs +13 -2
  8. package/cli/commands/mcp.mjs +22 -2
  9. package/cli/commands/score.mjs +13 -1
  10. package/cli/commands/specs.mjs +21 -2
  11. package/cli/commands/sync.mjs +20 -7
  12. package/cli/commands/verify.mjs +65 -2
  13. package/cli/config.mjs +3 -0
  14. package/cli/docguard.mjs +48 -16
  15. package/cli/evidence/adapters.mjs +200 -0
  16. package/cli/evidence/evaluate.mjs +185 -0
  17. package/cli/evidence/manifest.mjs +194 -0
  18. package/cli/evidence/markdown.mjs +107 -0
  19. package/cli/feedback-fixture.mjs +188 -0
  20. package/cli/findings.mjs +31 -0
  21. package/cli/repository-root.mjs +159 -0
  22. package/cli/scanners/py-ast.mjs +39 -2
  23. package/cli/scanners/task-context.mjs +312 -0
  24. package/cli/shared-doc-roles.mjs +44 -1
  25. package/cli/shared-source.mjs +101 -28
  26. package/cli/validators/architecture.mjs +186 -13
  27. package/cli/validators/environment.mjs +14 -1
  28. package/cli/validators/evidence.mjs +52 -0
  29. package/cli/validators/security.mjs +5 -4
  30. package/cli/validators/todo-tracking.mjs +45 -2
  31. package/cli/writers/doc-generators.mjs +31 -17
  32. package/cli/writers/mechanical.mjs +44 -14
  33. package/cli/writers/sections.mjs +31 -3
  34. package/docs/ai-integration.md +31 -6
  35. package/docs/commands.md +43 -5
  36. package/docs/configuration.md +11 -3
  37. package/docs/quickstart.md +1 -1
  38. package/extensions/spec-kit-docguard/commands/fix.md +4 -2
  39. package/extensions/spec-kit-docguard/commands/generate.md +6 -1
  40. package/extensions/spec-kit-docguard/commands/guard.md +3 -2
  41. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  42. package/extensions/spec-kit-docguard/extension.yml +1 -1
  43. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
  44. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
  45. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
  46. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
  49. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
  50. package/package.json +3 -1
  51. package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
  52. package/schemas/docguard-agent-context-result.schema.json +95 -0
  53. package/schemas/docguard-benchmark.schema.json +84 -0
  54. package/schemas/docguard-config.schema.json +1 -0
  55. package/schemas/docguard-evidence.schema.json +169 -0
  56. package/schemas/docguard-feedback-fixture.schema.json +54 -0
  57. package/schemas/docguard-task-context.schema.json +144 -0
  58. package/templates/AGENTS.md.template +9 -4
  59. package/templates/ci/github-actions.yml +4 -4
  60. package/templates/commands/docguard.guard.md +5 -1
  61. package/templates/commands/docguard.review.md +6 -1
  62. package/templates/evidence-manifest.json +21 -0
  63. package/templates/feedback-fixture.json +18 -0
@@ -174,7 +174,7 @@ export function resolveSourceRoots(projectDir, config = {}) {
174
174
  for (const d of getWorkspaceDirs(projectDir)) add(d);
175
175
 
176
176
  // 3. conventional roots (only those that exist)
177
- const conventional = ['src', 'app', 'lib', 'server', 'api', 'backend/src', 'backend', 'cli'];
177
+ const conventional = ['src', 'app', 'lib', 'server', 'api', 'functions', 'backend/src', 'backend', 'cli'];
178
178
  for (const cr of conventional) add(resolve(projectDir, cr));
179
179
 
180
180
  // 4. Fall back to the project root ONLY when nothing else resolved. Adding it
@@ -347,50 +347,120 @@ function workerType(param) {
347
347
  return type?.type === 'TSTypeReference' && type.typeName?.type === 'Identifier' ? type.typeName.name : '';
348
348
  }
349
349
 
350
- /** Static lexical binding analysis; collect declarations before resolving reads. */
350
+ const WORKER_ENTRYPOINTS = new Set(['WorkerEntrypoint', 'DurableObject', 'WorkflowEntrypoint']);
351
+ const WORKER_HANDLER_KEYS = new Set(['fetch', 'scheduled', 'queue', 'email', 'tail', 'trace', 'alarm', 'test']);
352
+ const PAGES_HANDLER_RE = /^onRequest(?:Get|Post|Put|Patch|Delete|Head|Options)?$/;
353
+
354
+ /**
355
+ * Static lexical binding analysis; collect declarations before resolving reads.
356
+ * @implements docguard.language-repository-coverage#FR-006
357
+ * @implements docguard.language-repository-coverage#FR-007
358
+ */
351
359
  function workerAstBindings(ast, configured) {
352
- const root = { parent: null, functionScope: true, env: undefined };
360
+ const root = { parent: null, functionScope: true, bindings: new Map(), thisEnv: false };
353
361
  const reads = [];
354
- const bind = (pattern, scope, value = false) => {
362
+ const exportedHandlers = new Set();
363
+ const bind = (pattern, scope, value = false, objectSource = null) => {
355
364
  if (!pattern) return;
356
- if (pattern.type === 'Identifier' && pattern.name === 'env') scope.env = value;
357
- else if (pattern.type === 'AssignmentPattern') bind(pattern.left, scope, value);
358
- else if (pattern.type === 'RestElement') bind(pattern.argument, scope, value);
359
- else if (pattern.type === 'ArrayPattern') for (const element of pattern.elements) bind(element, scope, value);
365
+ if (pattern.type === 'Identifier') scope.bindings.set(pattern.name, value);
366
+ else if (pattern.type === 'AssignmentPattern') bind(pattern.left, scope, value, objectSource);
367
+ else if (pattern.type === 'RestElement') bind(pattern.argument, scope, value, objectSource);
368
+ else if (pattern.type === 'ArrayPattern') for (const element of pattern.elements) bind(element, scope, false);
360
369
  else if (pattern.type === 'ObjectPattern') {
361
- for (const property of pattern.properties) bind(property.type === 'RestElement' ? property.argument : property.value, scope, value);
370
+ for (const property of pattern.properties) {
371
+ if (property.type === 'RestElement') bind(property.argument, scope, false);
372
+ else {
373
+ const key = property.computed ? property.key?.value : (property.key?.name || property.key?.value);
374
+ bind(property.value, scope, objectSource === 'pages-context' && key === 'env' ? 'worker-env' : false);
375
+ }
376
+ }
377
+ }
378
+ };
379
+ const lookup = (name, scope) => {
380
+ while (scope) {
381
+ if (scope.bindings.has(name)) return scope.bindings.get(name);
382
+ scope = scope.parent;
362
383
  }
384
+ return undefined;
385
+ };
386
+ const propertyName = node => node?.computed
387
+ ? (node.property?.type === 'StringLiteral' ? node.property.value : null)
388
+ : (node?.property?.name || null);
389
+ const valueKind = (node, scope) => {
390
+ if (!node) return false;
391
+ if (node.type === 'Identifier') return lookup(node.name, scope) || false;
392
+ if (node.type !== 'MemberExpression' && node.type !== 'OptionalMemberExpression') return false;
393
+ const property = propertyName(node);
394
+ if (property !== 'env') return false;
395
+ if (node.object?.type === 'Identifier' && lookup(node.object.name, scope) === 'pages-context') return 'worker-env';
396
+ if (node.object?.type === 'ThisExpression' && scope.thisEnv) return 'worker-env';
397
+ return false;
363
398
  };
399
+
400
+ // Module imports and direct named exports are instantiated before execution,
401
+ // so collect their identity before walking source-order declarations.
402
+ for (const statement of ast.program.body || []) {
403
+ if (statement.type === 'ImportDeclaration') {
404
+ const trusted = statement.source?.value === 'cloudflare:workers';
405
+ for (const specifier of statement.specifiers || []) {
406
+ const imported = specifier.imported?.name || specifier.imported?.value;
407
+ const kind = trusted && imported === 'env' ? 'worker-env'
408
+ : trusted && WORKER_ENTRYPOINTS.has(imported) ? 'worker-entrypoint-class' : false;
409
+ if (specifier.local?.name) root.bindings.set(specifier.local.name, kind);
410
+ }
411
+ }
412
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : null;
413
+ if (declaration?.type === 'FunctionDeclaration') exportedHandlers.add(declaration);
414
+ if (declaration?.type === 'VariableDeclaration') {
415
+ for (const item of declaration.declarations || []) if (item.init) exportedHandlers.add(item.init);
416
+ }
417
+ }
418
+
364
419
  const functionTypes = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod']);
365
420
  function visit(node, scope, parent) {
366
421
  if (!node || typeof node.type !== 'string') return;
367
422
  if (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') bind(node.id, scope);
368
423
  if (functionTypes.has(node.type)) {
369
- scope = { parent: scope, functionScope: true, env: undefined };
424
+ const inheritedThis = node.type === 'ArrowFunctionExpression' ? scope.thisEnv
425
+ : ['ClassMethod', 'ClassPrivateMethod'].includes(node.type) ? scope.thisEnv : false;
426
+ scope = { parent: scope, functionScope: true, bindings: new Map(), thisEnv: inheritedThis };
370
427
  if (node.type === 'FunctionExpression') bind(node.id, scope);
371
428
  const key = node.key?.name || node.key?.value || node.id?.name ||
372
429
  (parent?.type === 'ObjectProperty' ? parent.key?.name || parent.key?.value : parent?.type === 'VariableDeclarator' ? parent.id?.name : '');
373
430
  const request = workerType(node.params[0]) === 'Request';
374
- for (const param of node.params) {
431
+ for (const param of node.params) bind(param, scope, false);
432
+ if (exportedHandlers.has(node) && PAGES_HANDLER_RE.test(key) && node.params[0]) {
433
+ const target = node.params[0].type === 'AssignmentPattern' ? node.params[0].left : node.params[0];
434
+ if (target.type === 'Identifier') bind(target, scope, 'pages-context');
435
+ else bind(target, scope, false, 'pages-context');
436
+ } else for (const param of node.params) {
375
437
  const target = param.type === 'AssignmentPattern' ? param.left : param;
438
+ const typed = /^(?:Env|[\w$]*Env|[\w$]*Bindings)$/.test(workerType(target));
376
439
  const worker = target.type === 'Identifier' && target.name === 'env' &&
377
- /^(?:Env|[\w$]*Env|[\w$]*Bindings)$/.test(workerType(target)) && (configured || (key === 'fetch' && request));
378
- bind(param, scope, worker);
440
+ ((configured && WORKER_HANDLER_KEYS.has(key)) || (typed && (configured || (key === 'fetch' && request))));
441
+ if (worker) bind(target, scope, 'worker-env');
379
442
  }
380
443
  } else if (['BlockStatement', 'ForStatement', 'ForOfStatement', 'ForInStatement', 'CatchClause', 'SwitchStatement', 'ClassExpression', 'ClassDeclaration', 'StaticBlock'].includes(node.type)) {
381
- scope = { parent: scope, functionScope: node.type === 'StaticBlock', env: undefined };
444
+ const workerClass = ['ClassExpression', 'ClassDeclaration'].includes(node.type)
445
+ && node.superClass?.type === 'Identifier' && lookup(node.superClass.name, scope) === 'worker-entrypoint-class';
446
+ scope = { parent: scope, functionScope: node.type === 'StaticBlock', bindings: new Map(), thisEnv: workerClass || scope.thisEnv };
382
447
  if (node.type === 'CatchClause') bind(node.param, scope);
383
448
  if (node.type === 'ClassExpression' || node.type === 'ClassDeclaration') bind(node.id, scope);
384
449
  }
385
450
  if (node.type === 'VariableDeclaration') {
386
451
  let declarationScope = scope;
387
452
  if (node.kind === 'var') while (!declarationScope.functionScope && declarationScope.parent) declarationScope = declarationScope.parent;
388
- for (const declaration of node.declarations) bind(declaration.id, declarationScope);
453
+ for (const declaration of node.declarations) {
454
+ const kind = valueKind(declaration.init, scope);
455
+ bind(declaration.id, declarationScope, kind, kind);
456
+ }
457
+ }
458
+ if (node.type === 'ImportDeclaration') {
459
+ for (const spec of node.specifiers) if (!scope.bindings.has(spec.local?.name)) bind(spec.local, scope);
389
460
  }
390
- if (node.type === 'ImportDeclaration') for (const spec of node.specifiers) bind(spec.local, scope);
391
- if ((node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') && node.object?.type === 'Identifier' && node.object.name === 'env') {
392
- const name = node.computed ? (node.property.type === 'StringLiteral' ? node.property.value : null) : node.property.name;
393
- if (name) reads.push({ scope, name });
461
+ if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
462
+ const name = propertyName(node);
463
+ if (name) reads.push({ scope, name, object: node.object });
394
464
  }
395
465
  for (const [key, child] of Object.entries(node)) {
396
466
  if (['loc', 'start', 'end', 'extra', 'comments', 'tokens'].includes(key)) continue;
@@ -399,13 +469,7 @@ function workerAstBindings(ast, configured) {
399
469
  }
400
470
  }
401
471
  visit(ast.program, root, null);
402
- const names = new Set();
403
- for (const read of reads) {
404
- let scope = read.scope;
405
- while (scope && scope.env === undefined) scope = scope.parent;
406
- if (scope?.env === true) names.add(read.name);
407
- }
408
- return names;
472
+ return new Set(reads.filter(read => valueKind(read.object, read.scope) === 'worker-env').map(read => read.name));
409
473
  }
410
474
 
411
475
  /** Optional parser argument makes the absent/failed-parser contract testable. */
@@ -419,7 +483,13 @@ export function extractWorkerEnvBindings(content, filename = 'file.ts', configur
419
483
  return workerAstBindings(ast, configured);
420
484
  } catch { /* Failed parsing retains conservative lexical evidence. */ }
421
485
  }
422
- return workerEnvUsageFallback(content, classifyChars(content, extname(filename)), configured);
486
+ const result = workerEnvUsageFallback(content, classifyChars(content, extname(filename)), configured);
487
+ const fallbackOnly = [];
488
+ if (/cloudflare:workers/.test(content) && /\bimport\s*\{[^}]*\benv\b/.test(content)) fallbackOnly.push('worker-imported-env-needs-ast');
489
+ if (/\bonRequest(?:Get|Post|Put|Patch|Delete|Head|Options)?\b/.test(content) && /\bcontext\s*\.\s*env\b/.test(content)) fallbackOnly.push('pages-context-needs-ast');
490
+ if (/\bextends\s+(?:WorkerEntrypoint|DurableObject|WorkflowEntrypoint)\b/.test(content) && /\bthis\s*\.\s*env\b/.test(content)) fallbackOnly.push('worker-class-env-needs-ast');
491
+ result.limitations = fallbackOnly;
492
+ return result;
423
493
  }
424
494
 
425
495
  // Resolve binding positions in simple lexical patterns, not property names.
@@ -537,6 +607,7 @@ function workerEnvUsageFallback(content, kind, configured) {
537
607
 
538
608
  export function grepEnvUsage(projectDir, config = {}) {
539
609
  const names = new Set();
610
+ names.limitations = [];
540
611
  const roots = resolveSourceRoots(projectDir, config);
541
612
  const seen = new Set();
542
613
 
@@ -579,7 +650,9 @@ export function grepEnvUsage(projectDir, config = {}) {
579
650
  // code while only the argument 'X' is a string, so the name is still caught.
580
651
  const kind = classifyChars(content, extname(filePath));
581
652
  if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(filePath))) {
582
- for (const name of extractWorkerEnvBindings(content, filePath, workerConfigForFile(projectDir, filePath))) names.add(name);
653
+ const workerBindings = extractWorkerEnvBindings(content, filePath, workerConfigForFile(projectDir, filePath));
654
+ for (const name of workerBindings) names.add(name);
655
+ for (const limitation of workerBindings.limitations || []) names.limitations.push({ code: limitation, file: rel.replace(/\\/g, '/') });
583
656
  }
584
657
  // patterns[2] is the import.meta.env one — its matches are Vite-injected
585
658
  // when the name is an intrinsic, and must not be reported as user env vars.
@@ -20,6 +20,8 @@ import { resolve, join, extname, relative, dirname, basename } from 'node:path';
20
20
  import { shouldIgnore, isNonProductPath, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
21
21
  import { mkFinding, resultFromFindings } from '../findings.mjs';
22
22
  import { resolveDocRole } from '../shared-doc-roles.mjs';
23
+ import { getWorkspaceDirs } from '../shared-source.mjs';
24
+ import { extractPythonFiles } from '../scanners/py-ast.mjs';
23
25
 
24
26
  const IGNORE_DIRS = new Set([
25
27
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -27,7 +29,7 @@ const IGNORE_DIRS = new Set([
27
29
  'templates', 'configs', 'Research', 'docs-canonical', 'docs-implementation',
28
30
  ]);
29
31
 
30
- const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx']);
32
+ const JS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx']);
31
33
 
32
34
  // v0.29: migrated to structured findings (ARC001–ARC003). Messages are
33
35
  // byte-identical to the legacy strings — resultFromFindings derives the
@@ -35,7 +37,7 @@ const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx']);
35
37
  // helpers below mutate in place.
36
38
  export function validateArchitecture(projectDir, config = {}) {
37
39
  const acc = { findings: [], passed: 0, total: 0 };
38
- let applicability = { status: 'checked', reason: 'JS/TS static import graph inspected; arbitrary runtime dependencies are not resolved' };
40
+ let applicability = { status: 'checked', reason: 'Repository-local JS/TS and Python static import graphs inspected; runtime dependency resolution is outside scope' };
39
41
  const compose = () => ({
40
42
  name: 'architecture',
41
43
  applicability,
@@ -50,16 +52,20 @@ export function validateArchitecture(projectDir, config = {}) {
50
52
 
51
53
  // ── 2. Auto-detect import graph ──
52
54
  const importGraph = buildImportGraph(projectDir, config);
53
- if (importGraph.unsupportedFiles.length > 0) {
55
+ if (importGraph.limitations.length > 0) {
54
56
  applicability = {
55
57
  status: importGraph.files.length > 0 ? 'partial' : 'unsupported',
56
- reason: 'Python import graph analysis is unsupported: relative imports, package paths, src-layout resolution and dynamic imports are not verified; JS/TS findings, when present, are retained',
58
+ reason: `Static import findings are retained; incomplete evidence: ${summarizeLimitations(importGraph.limitations)}`,
57
59
  };
58
60
  } else if (importGraph.files.length === 0) {
59
- applicability = { status: 'not-applicable', reason: 'No supported JS/TS source files found for import graph analysis' };
61
+ applicability = { status: 'not-applicable', reason: 'No supported JS/TS or Python source files found for import graph analysis' };
60
62
  }
61
63
  if (importGraph.files.length === 0) return compose();
62
64
 
65
+ if (layers && Object.keys(layers).length > 0) {
66
+ validatePythonConfigLayers(importGraph, layers, acc);
67
+ }
68
+
63
69
  // ── 3. Detect circular dependencies ──
64
70
  const circles = detectCircularDeps(importGraph);
65
71
  for (const circle of circles) {
@@ -122,7 +128,7 @@ function validateConfigLayers(projectDir, config, layers, acc) {
122
128
 
123
129
  const files = getFilesRecursive(layerDir, config, projectDir);
124
130
  for (const file of files) {
125
- if (!CODE_EXTENSIONS.has(extname(file))) continue;
131
+ if (!JS_EXTENSIONS.has(extname(file))) continue;
126
132
 
127
133
  const content = readFileSync(file, 'utf-8');
128
134
  const relPath = relative(projectDir, file);
@@ -155,20 +161,25 @@ function validateConfigLayers(projectDir, config, layers, acc) {
155
161
  // ── Import Graph Builder ────────────────────────────────────────────────────
156
162
 
157
163
  /**
158
- * Build the project's JS/TS import graph. Exported for reuse by `impact`
164
+ * Build the project's repository-local JS/TS and Python static import graph.
165
+ * Exported for reuse by `impact`
159
166
  * (indirect code→doc analysis walks this graph's reverse edges) — one graph
160
167
  * builder, not two.
161
168
  *
162
- * @returns {{files: string[], edges: {from,to,dynamic}[], fileMap: Map<string,string[]>}}
169
+ * @implements docguard.language-repository-coverage#FR-002
170
+ * @implements docguard.language-repository-coverage#FR-003
171
+ * @implements docguard.language-repository-coverage#FR-004
172
+ * @implements docguard.language-repository-coverage#FR-005
173
+ * @returns {{files: string[], edges: {from,to,dynamic,language}[], fileMap: Map<string,string[]>, unsupportedFiles: string[], limitations: object[]}}
163
174
  */
164
175
  export function buildImportGraph(projectDir, config) {
165
- const graph = { files: [], edges: [], fileMap: new Map(), unsupportedFiles: [] };
176
+ const graph = { files: [], edges: [], fileMap: new Map(), unsupportedFiles: [], limitations: [] };
166
177
 
167
178
  const allFiles = getFilesRecursive(projectDir, config, projectDir);
168
- graph.unsupportedFiles = allFiles
179
+ const pythonFiles = allFiles
169
180
  .filter(f => extname(f) === '.py' && !isNonProductPath(relative(projectDir, f).replace(/\\/g, '/'), config))
170
- .map(f => relative(projectDir, f));
171
- const codeFiles = allFiles.filter(f => CODE_EXTENSIONS.has(extname(f)));
181
+ .filter(f => !(config && shouldIgnore(relative(projectDir, f), config)));
182
+ const codeFiles = allFiles.filter(f => JS_EXTENSIONS.has(extname(f)));
172
183
 
173
184
  for (const file of codeFiles) {
174
185
  const relPath = relative(projectDir, file);
@@ -190,7 +201,7 @@ export function buildImportGraph(projectDir, config) {
190
201
  const fromDir = dirname(file);
191
202
  const resolved = resolveImport(fromDir, imp.spec, projectDir);
192
203
  if (resolved) {
193
- graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic });
204
+ graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic, language: 'javascript' });
194
205
  // v0.28 (field report #2): a dynamic `await import()` does NOT create a
195
206
  // load-time edge — it's the canonical way to BREAK an import cycle. So
196
207
  // it's excluded from the cycle-detection adjacency (fileMap) while still
@@ -204,9 +215,171 @@ export function buildImportGraph(projectDir, config) {
204
215
  } catch { /* skip binary or unreadable files */ }
205
216
  }
206
217
 
218
+ addPythonImportGraph(projectDir, config || {}, pythonFiles, graph);
219
+
207
220
  return graph;
208
221
  }
209
222
 
223
+ function posixPath(path) {
224
+ return path.replace(/\\/g, '/');
225
+ }
226
+
227
+ function summarizeLimitations(limitations) {
228
+ const labels = {
229
+ 'python-interpreter-unavailable': 'Python interpreter unavailable',
230
+ 'python-parse-failed': 'Python parse failure',
231
+ 'python-dynamic-import': 'dynamic Python import',
232
+ 'python-path-mutation': 'runtime sys.path mutation',
233
+ 'python-relative-outside-package': 'relative import outside a resolvable package',
234
+ 'python-ambiguous-module': 'ambiguous Python module across import roots',
235
+ };
236
+ const counts = new Map();
237
+ for (const item of limitations) counts.set(item.code, (counts.get(item.code) || 0) + 1);
238
+ return [...counts].map(([code, count]) => `${labels[code] || code}${count > 1 ? ` (${count})` : ''}`).join('; ');
239
+ }
240
+
241
+ function pythonImportRoots(projectDir, config, pythonFiles) {
242
+ const candidates = [];
243
+ const add = (path, priority) => {
244
+ const absolute = resolve(path);
245
+ if (!existsSync(absolute) || candidates.some(item => item.path === absolute)) return;
246
+ if (!pythonFiles.some(file => file === absolute || !relative(absolute, file).startsWith('..'))) return;
247
+ candidates.push({ path: absolute, priority });
248
+ };
249
+ const configured = config.sourceRoot ? (Array.isArray(config.sourceRoot) ? config.sourceRoot : [config.sourceRoot]) : [];
250
+ for (const root of configured) {
251
+ const absolute = resolve(projectDir, root);
252
+ if (existsSync(join(absolute, 'src'))) add(join(absolute, 'src'), 0);
253
+ add(absolute, 1);
254
+ if (existsSync(join(absolute, '__init__.py'))) add(dirname(absolute), 2);
255
+ }
256
+ for (const workspace of getWorkspaceDirs(projectDir)) {
257
+ if (existsSync(join(workspace, 'src'))) add(join(workspace, 'src'), 3);
258
+ add(workspace, 4);
259
+ }
260
+ if (existsSync(join(projectDir, 'src'))) add(join(projectDir, 'src'), 5);
261
+ add(projectDir, 6);
262
+ return candidates.sort((a, b) => a.priority - b.priority || b.path.length - a.path.length);
263
+ }
264
+
265
+ function pythonModuleForFile(file, roots) {
266
+ const containing = roots.filter(root => {
267
+ const rel = relative(root.path, file);
268
+ return rel !== '' && !rel.startsWith('..') && !rel.startsWith('/');
269
+ });
270
+ if (containing.length === 0) return null;
271
+ const selected = containing[0];
272
+ const rel = posixPath(relative(selected.path, file));
273
+ const parts = rel.replace(/\.py$/, '').split('/');
274
+ const isPackage = parts.at(-1) === '__init__';
275
+ if (isPackage) parts.pop();
276
+ if (parts.length === 0) return null;
277
+ let cursor = selected.path;
278
+ let namespace = false;
279
+ const packageParts = isPackage ? parts : parts.slice(0, -1);
280
+ for (const part of packageParts) {
281
+ cursor = join(cursor, part);
282
+ if (!existsSync(join(cursor, '__init__.py'))) namespace = true;
283
+ }
284
+ return { name: parts.join('.'), packageName: (isPackage ? parts : parts.slice(0, -1)).join('.'), namespace, root: selected.path };
285
+ }
286
+
287
+ function pythonImportCandidates(imp, owner) {
288
+ let base = imp.module || '';
289
+ if (imp.level > 0) {
290
+ const pkg = owner.packageName ? owner.packageName.split('.') : [];
291
+ const remove = imp.level - 1;
292
+ if (remove >= pkg.length && !(remove === 0 && pkg.length > 0)) return { candidates: [], outside: true };
293
+ const prefix = pkg.slice(0, pkg.length - remove);
294
+ base = [...prefix, ...(base ? base.split('.') : [])].join('.');
295
+ }
296
+ if (imp.kind === 'import') return { candidates: base ? [base] : [], outside: false };
297
+ const names = Array.isArray(imp.names) ? imp.names.filter(name => name && name !== '*') : [];
298
+ if (names.length === 0) return { candidates: base ? [base] : [], outside: false };
299
+ return { candidates: names.map(name => [base, name].filter(Boolean).join('.')), fallback: base || null, outside: false };
300
+ }
301
+
302
+ function addPythonImportGraph(projectDir, config, pythonFiles, graph) {
303
+ if (pythonFiles.length === 0) return;
304
+ const extracted = extractPythonFiles(pythonFiles);
305
+ if (extracted === null) {
306
+ graph.unsupportedFiles.push(...pythonFiles.map(file => posixPath(relative(projectDir, file))));
307
+ graph.limitations.push({ code: 'python-interpreter-unavailable', files: graph.unsupportedFiles.length });
308
+ return;
309
+ }
310
+ const roots = pythonImportRoots(projectDir, config, pythonFiles);
311
+ const moduleByFile = new Map();
312
+ const modules = new Map();
313
+ for (const file of pythonFiles) {
314
+ const owner = pythonModuleForFile(file, roots);
315
+ if (!owner) continue;
316
+ moduleByFile.set(file, owner);
317
+ if (!modules.has(owner.name)) modules.set(owner.name, []);
318
+ modules.get(owner.name).push({ file, ...owner });
319
+ }
320
+
321
+ for (const file of pythonFiles) {
322
+ const relPath = posixPath(relative(projectDir, file));
323
+ const parsed = extracted[file];
324
+ const owner = moduleByFile.get(file);
325
+ if (!parsed?.ok || !owner) {
326
+ graph.unsupportedFiles.push(relPath);
327
+ graph.limitations.push({ code: 'python-parse-failed', file: relPath });
328
+ continue;
329
+ }
330
+ graph.files.push(relPath);
331
+ const resolvedImports = [];
332
+ if (parsed.dynamicImports) graph.limitations.push({ code: 'python-dynamic-import', file: relPath });
333
+ if (parsed.pathMutation) graph.limitations.push({ code: 'python-path-mutation', file: relPath });
334
+ for (const imp of parsed.imports || []) {
335
+ const request = pythonImportCandidates(imp, owner);
336
+ if (request.outside) {
337
+ graph.limitations.push({ code: 'python-relative-outside-package', file: relPath });
338
+ continue;
339
+ }
340
+ const selected = [];
341
+ for (const candidate of request.candidates) {
342
+ const matches = modules.get(candidate) || [];
343
+ if (matches.length > 1) {
344
+ graph.limitations.push({ code: 'python-ambiguous-module', file: relPath, module: candidate });
345
+ } else if (matches.length === 1) {
346
+ selected.push(matches[0]);
347
+ }
348
+ }
349
+ if (selected.length === 0 && request.fallback) {
350
+ const matches = modules.get(request.fallback) || [];
351
+ if (matches.length > 1) graph.limitations.push({ code: 'python-ambiguous-module', file: relPath, module: request.fallback });
352
+ else if (matches.length === 1) selected.push(matches[0]);
353
+ }
354
+ for (const target of selected) {
355
+ const to = posixPath(relative(projectDir, target.file));
356
+ if (to === relPath || resolvedImports.includes(to)) continue;
357
+ graph.edges.push({ from: relPath, to, dynamic: false, language: 'python' });
358
+ resolvedImports.push(to);
359
+ }
360
+ }
361
+ graph.fileMap.set(relPath, resolvedImports);
362
+ }
363
+ }
364
+
365
+ function validatePythonConfigLayers(graph, layers, acc) {
366
+ const layerEntries = Object.entries(layers)
367
+ .filter(([, value]) => value?.dir && Array.isArray(value.canImport))
368
+ .map(([name, value]) => ({ name, dir: posixPath(value.dir).replace(/\/$/, ''), canImport: value.canImport }));
369
+ for (const edge of graph.edges.filter(item => item.language === 'python')) {
370
+ const from = layerEntries.find(layer => edge.from === layer.dir || edge.from.startsWith(`${layer.dir}/`));
371
+ const to = layerEntries.find(layer => edge.to === layer.dir || edge.to.startsWith(`${layer.dir}/`));
372
+ if (!from || !to || from.name === to.name || from.canImport.includes(to.name)) continue;
373
+ acc.total++;
374
+ acc.findings.push(mkFinding({
375
+ code: 'ARC001', validator: 'architecture', severity: 'error',
376
+ message: `${edge.from}: ${from.name} layer imports from forbidden layer (${to.dir})`,
377
+ location: edge.from,
378
+ suggestion: { kind: 'fix', text: 'Remove the import or route it through an allowed layer (see the layers config in .docguard.json)' },
379
+ }));
380
+ }
381
+ }
382
+
210
383
  /**
211
384
  * Extract a file's imports as `{ spec, dynamic }`. `dynamic:true` marks a
212
385
  * runtime `import('…')` — which does NOT create a load-time dependency edge and
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @implements docguard.language-repository-coverage#FR-006
3
+ * @implements docguard.language-repository-coverage#FR-007
4
+ * @implements docguard.language-repository-coverage#FR-008
5
+ */
1
6
  import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
2
7
  /**
3
8
  * Environment Validator — Checks ENVIRONMENT.md docs and .env.example
@@ -18,6 +23,7 @@ export function validateEnvironment(projectDir, config) {
18
23
  const findings = [];
19
24
  let passed = 0;
20
25
  let total = 0;
26
+ let applicability = null;
21
27
  const ptc = config.projectTypeConfig || {};
22
28
 
23
29
  const envDoc = docRolePath(config, 'environment');
@@ -113,6 +119,13 @@ export function validateEnvironment(projectDir, config) {
113
119
  }
114
120
 
115
121
  const codeUsed = grepEnvUsage(projectDir, config);
122
+ if (codeUsed.limitations?.length) {
123
+ const forms = [...new Set(codeUsed.limitations.map(item => item.code))].join(', ');
124
+ applicability = {
125
+ status: 'partial',
126
+ reason: `Environment findings are retained; the parser fallback cannot verify these Worker forms: ${forms}`,
127
+ };
128
+ }
116
129
 
117
130
  // Only assess when code actually reads env vars — otherwise the check is
118
131
  // vacuous (always passes) and would just inflate the count.
@@ -180,5 +193,5 @@ export function validateEnvironment(projectDir, config) {
180
193
  passed++;
181
194
  }
182
195
 
183
- return { name: 'environment', ...resultFromFindings(findings, { passed, total }) };
196
+ return { name: 'environment', ...(applicability ? { applicability } : {}), ...resultFromFindings(findings, { passed, total }) };
184
197
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Guard adapter for evidence-scoped verification.
3
+ * @implements docguard.evidence-scoped-verification#FR-010
4
+ */
5
+
6
+ import { evaluateEvidence } from '../evidence/evaluate.mjs';
7
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
8
+
9
+ const stateFinding = {
10
+ contradicted: {
11
+ code: 'EVD002', severity: 'error',
12
+ suggestion: { kind: 'review', text: 'Review approved intent and the current source, then correct the regressed side.' },
13
+ },
14
+ stale: {
15
+ code: 'EVD003', severity: 'warn',
16
+ suggestion: { kind: 'fix', text: 'Regenerate the saved upstream report and refresh its declared input hashes.', command: 'docguard verify --evidence' },
17
+ },
18
+ inconclusive: {
19
+ code: 'EVD004', severity: 'warn',
20
+ suggestion: { kind: 'fix', text: 'Restore safe, unique evidence or narrow the declaration.', command: 'docguard verify --evidence' },
21
+ },
22
+ unsupported: {
23
+ code: 'EVD005', severity: 'warn',
24
+ suggestion: { kind: 'report', text: 'Contribute a synthetic failing fixture and neighboring valid control before expanding the adapter.' },
25
+ },
26
+ };
27
+
28
+ export function validateEvidence(projectDir, config = {}) {
29
+ const evaluation = evaluateEvidence(projectDir, config);
30
+ if (!evaluation.exists) return { ...resultFromFindings([], { applicable: false }), evidence: evaluation };
31
+ const findings = [];
32
+ for (const error of evaluation.errors) {
33
+ findings.push(mkFinding({
34
+ code: 'EVD001', validator: 'evidence', severity: 'error', confidence: 'high',
35
+ message: error.declarationId ? `${error.declarationId}: ${error.message}` : error.message,
36
+ location: '.docguard-evidence.json',
37
+ suggestion: { kind: 'fix', text: 'Repair the strict manifest contract, then rerun evidence verification.', command: 'docguard verify --evidence' },
38
+ reportable: false,
39
+ }));
40
+ }
41
+ for (const result of evaluation.results) {
42
+ const contract = stateFinding[result.state];
43
+ if (!contract) continue;
44
+ findings.push(mkFinding({
45
+ ...contract, validator: 'evidence', confidence: 'high',
46
+ message: `${result.declarationId}: ${result.message} (${result.reasonCode})`,
47
+ location: result.location, reportable: false,
48
+ }));
49
+ }
50
+ const passed = evaluation.summary['verified-within-scope'] || 0;
51
+ return { ...resultFromFindings(findings, { passed, total: passed + findings.length, applicable: true }), evidence: evaluation };
52
+ }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Respects config.securityIgnore (glob patterns) and config.ignore (global).
5
5
  * Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
6
+ * @implements docguard.precision-evidence-loop#FR-003
6
7
  */
7
8
 
8
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
@@ -35,10 +36,10 @@ const IGNORE_DIRS = new Set([
35
36
 
36
37
  // Patterns that might indicate hardcoded secrets
37
38
  const SECRET_PATTERNS = [
38
- { pattern: /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]/gi, label: 'hardcoded password' },
39
- { pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"][^'"]{16,}['"]/gi, label: 'hardcoded API key' },
40
- { pattern: /(?:secret[_-]?key|secretkey)\s*[:=]\s*['"][^'"]{16,}['"]/gi, label: 'hardcoded secret key' },
41
- { pattern: /(?:access[_-]?token|accesstoken)\s*[:=]\s*['"][^'"]{16,}['"]/gi, label: 'hardcoded access token' },
39
+ { pattern: /(?:password|passwd|pwd)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{8,}['"]/gi, label: 'hardcoded password' },
40
+ { pattern: /(?:api[_-]?key|apikey)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{16,}['"]/gi, label: 'hardcoded API key' },
41
+ { pattern: /(?:secret[_-]?key|secretkey)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{16,}['"]/gi, label: 'hardcoded secret key' },
42
+ { pattern: /(?:access[_-]?token|accesstoken)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{16,}['"]/gi, label: 'hardcoded access token' },
42
43
  { pattern: /AKIA[0-9A-Z]{16}/g, label: 'AWS Access Key ID' },
43
44
  { pattern: /(?:sk-|sk_live_|sk_test_)[a-zA-Z0-9]{20,}/g, label: 'API secret key (Stripe/OpenAI pattern)' },
44
45
  ];
@@ -119,9 +119,10 @@ function skippedCalls(content, filename) {
119
119
  // Parser failure/unavailability cannot turn an unexplained skip into a pass.
120
120
  function fallbackSkippedCalls(content) {
121
121
  const lines = content.split('\n');
122
+ const codeLines = maskJsNonCode(content).split('\n');
122
123
  const calls = [];
123
- for (let i = 0; i < lines.length; i++) {
124
- if (!SKIP_PATTERNS.some(p => p.test(lines[i]))) continue;
124
+ for (let i = 0; i < codeLines.length; i++) {
125
+ if (!SKIP_PATTERNS.some(p => p.test(codeLines[i]))) continue;
125
126
  // Only a directly preceding comment is unambiguous without a parser.
126
127
  const previous = lines[i - 1] || '';
127
128
  calls.push({ line: i + 1, hasReason: /^\s*\/\//.test(previous) &&
@@ -130,6 +131,48 @@ function fallbackSkippedCalls(content) {
130
131
  return calls;
131
132
  }
132
133
 
134
+ /**
135
+ * Preserve line structure while hiding comments and string/template literals.
136
+ * This keeps the parser-failure fallback from treating fixture text such as
137
+ * `"test.skip()"` as executable code. Template interpolation is intentionally
138
+ * masked with the surrounding template: on malformed input it is safer to miss
139
+ * that uncommon form than to report prose as a real skipped test.
140
+ */
141
+ function maskJsNonCode(content) {
142
+ let state = 'code';
143
+ let escaped = false;
144
+ let out = '';
145
+ for (let i = 0; i < content.length; i++) {
146
+ const ch = content[i];
147
+ const next = content[i + 1];
148
+ if (state === 'code') {
149
+ if (ch === '/' && next === '/') { out += ' '; i++; state = 'line-comment'; continue; }
150
+ if (ch === '/' && next === '*') { out += ' '; i++; state = 'block-comment'; continue; }
151
+ if (ch === "'") { out += ' '; state = 'single'; escaped = false; continue; }
152
+ if (ch === '"') { out += ' '; state = 'double'; escaped = false; continue; }
153
+ if (ch === '`') { out += ' '; state = 'template'; escaped = false; continue; }
154
+ out += ch;
155
+ continue;
156
+ }
157
+ if (ch === '\n') {
158
+ out += '\n';
159
+ if (state === 'line-comment') state = 'code';
160
+ if (state === 'single' || state === 'double') escaped = false;
161
+ continue;
162
+ }
163
+ if (state === 'block-comment' && ch === '*' && next === '/') {
164
+ out += ' '; i++; state = 'code'; continue;
165
+ }
166
+ if (state === 'line-comment' || state === 'block-comment') { out += ' '; continue; }
167
+ const quote = state === 'single' ? "'" : state === 'double' ? '"' : '`';
168
+ if (!escaped && ch === quote) { out += ' '; state = 'code'; continue; }
169
+ if (!escaped && ch === '\\') { escaped = true; out += ' '; continue; }
170
+ escaped = false;
171
+ out += ' ';
172
+ }
173
+ return out;
174
+ }
175
+
133
176
  /**
134
177
  * Main validator — checks for untracked TODOs and unexplained test skips.
135
178
  *