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.
- package/README.md +45 -22
- package/cli/commands/agent.mjs +47 -1
- package/cli/commands/explain.mjs +16 -0
- package/cli/commands/feedback.mjs +147 -6
- package/cli/commands/fix.mjs +13 -11
- package/cli/commands/generate.mjs +52 -18
- package/cli/commands/guard.mjs +13 -2
- package/cli/commands/mcp.mjs +22 -2
- package/cli/commands/score.mjs +13 -1
- package/cli/commands/specs.mjs +21 -2
- package/cli/commands/sync.mjs +20 -7
- package/cli/commands/verify.mjs +65 -2
- package/cli/config.mjs +3 -0
- package/cli/docguard.mjs +48 -16
- package/cli/evidence/adapters.mjs +200 -0
- package/cli/evidence/evaluate.mjs +185 -0
- package/cli/evidence/manifest.mjs +194 -0
- package/cli/evidence/markdown.mjs +107 -0
- package/cli/feedback-fixture.mjs +188 -0
- package/cli/findings.mjs +31 -0
- package/cli/repository-root.mjs +159 -0
- package/cli/scanners/py-ast.mjs +39 -2
- package/cli/scanners/task-context.mjs +312 -0
- package/cli/shared-doc-roles.mjs +44 -1
- package/cli/shared-source.mjs +101 -28
- package/cli/validators/architecture.mjs +186 -13
- package/cli/validators/environment.mjs +14 -1
- package/cli/validators/evidence.mjs +52 -0
- package/cli/validators/security.mjs +5 -4
- package/cli/validators/todo-tracking.mjs +45 -2
- package/cli/writers/doc-generators.mjs +31 -17
- package/cli/writers/mechanical.mjs +44 -14
- package/cli/writers/sections.mjs +31 -3
- package/docs/ai-integration.md +31 -6
- package/docs/commands.md +43 -5
- package/docs/configuration.md +11 -3
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/commands/fix.md +4 -2
- package/extensions/spec-kit-docguard/commands/generate.md +6 -1
- package/extensions/spec-kit-docguard/commands/guard.md +3 -2
- package/extensions/spec-kit-docguard/commands/sync.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
- package/package.json +3 -1
- package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
- package/schemas/docguard-agent-context-result.schema.json +95 -0
- package/schemas/docguard-benchmark.schema.json +84 -0
- package/schemas/docguard-config.schema.json +1 -0
- package/schemas/docguard-evidence.schema.json +169 -0
- package/schemas/docguard-feedback-fixture.schema.json +54 -0
- package/schemas/docguard-task-context.schema.json +144 -0
- package/templates/AGENTS.md.template +9 -4
- package/templates/ci/github-actions.yml +4 -4
- package/templates/commands/docguard.guard.md +5 -1
- package/templates/commands/docguard.review.md +6 -1
- package/templates/evidence-manifest.json +21 -0
- package/templates/feedback-fixture.json +18 -0
package/cli/shared-source.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
360
|
+
const root = { parent: null, functionScope: true, bindings: new Map(), thisEnv: false };
|
|
353
361
|
const reads = [];
|
|
354
|
-
const
|
|
362
|
+
const exportedHandlers = new Set();
|
|
363
|
+
const bind = (pattern, scope, value = false, objectSource = null) => {
|
|
355
364
|
if (!pattern) return;
|
|
356
|
-
if (pattern.type === 'Identifier'
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
bind(
|
|
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
|
-
|
|
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)
|
|
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 === '
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
55
|
+
if (importGraph.limitations.length > 0) {
|
|
54
56
|
applicability = {
|
|
55
57
|
status: importGraph.files.length > 0 ? 'partial' : 'unsupported',
|
|
56
|
-
reason:
|
|
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 (!
|
|
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.
|
|
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
|
-
* @
|
|
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
|
-
|
|
179
|
+
const pythonFiles = allFiles
|
|
169
180
|
.filter(f => extname(f) === '.py' && !isNonProductPath(relative(projectDir, f).replace(/\\/g, '/'), config))
|
|
170
|
-
.
|
|
171
|
-
const codeFiles = allFiles.filter(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)
|
|
39
|
-
{ pattern: /(?:api[_-]?key|apikey)
|
|
40
|
-
{ pattern: /(?:secret[_-]?key|secretkey)
|
|
41
|
-
{ pattern: /(?:access[_-]?token|accesstoken)
|
|
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 <
|
|
124
|
-
if (!SKIP_PATTERNS.some(p => p.test(
|
|
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
|
*
|