docguard-cli 0.40.5 → 0.41.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/CHANGELOG.md +3218 -0
- package/README.md +25 -16
- package/cli/assessment.mjs +94 -0
- package/cli/commands/ci.mjs +15 -5
- package/cli/commands/diagnose.mjs +20 -13
- package/cli/commands/fix.mjs +14 -45
- package/cli/commands/guard.mjs +53 -25
- package/cli/commands/hooks.mjs +51 -10
- package/cli/commands/init.mjs +15 -0
- package/cli/commands/reconcile.mjs +10 -3
- package/cli/commands/report.mjs +5 -1
- package/cli/commands/score.mjs +2 -1
- package/cli/commands/upgrade.mjs +4 -1
- package/cli/commands/verify.mjs +9 -2
- package/cli/commands/watch.mjs +3 -2
- package/cli/config.mjs +23 -0
- package/cli/evidence/adapters.mjs +14 -0
- package/cli/evidence/manifest.mjs +15 -0
- package/cli/evidence/python-literal.mjs +304 -0
- package/cli/findings.mjs +17 -3
- package/cli/scanners/instruction-audit.mjs +88 -11
- package/cli/scanners/js-ast.mjs +156 -18
- package/cli/scanners/reconciliation.mjs +56 -6
- package/cli/scanners/routes.mjs +84 -9
- package/cli/scanners/spec-registry.mjs +29 -0
- package/cli/shared-git.mjs +98 -0
- package/cli/shared-ignore.mjs +1 -1
- package/cli/shared.mjs +30 -1
- package/cli/validators/api-doc-smells.mjs +2 -2
- package/cli/validators/api-surface.mjs +4 -9
- package/cli/validators/diff-suspicion.mjs +3 -2
- package/cli/validators/docs-sync.mjs +45 -29
- package/cli/validators/environment.mjs +64 -6
- package/cli/validators/metrics-consistency.mjs +52 -11
- package/cli/validators/reference-existence.mjs +4 -2
- package/cli/validators/security.mjs +37 -12
- package/cli/validators/spec-registry.mjs +10 -7
- package/cli/validators/todo-tracking.mjs +31 -11
- package/cli/validators/traceability.mjs +29 -4
- package/cli/writers/junit.mjs +3 -3
- package/cli/writers/sarif.mjs +13 -9
- package/docs/configuration.md +12 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +2 -1
- package/schemas/docguard-config.schema.json +15 -1
- package/schemas/docguard-evidence.schema.json +12 -0
- package/templates/ci/github-actions.yml +1 -1
- package/templates/evidence-manifest.json +16 -0
package/cli/commands/hooks.mjs
CHANGED
|
@@ -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
|
|
282
|
-
const status =
|
|
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
|
|
295
|
-
if (
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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`);
|
package/cli/commands/init.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/cli/commands/report.mjs
CHANGED
|
@@ -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(`|
|
|
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) {
|
package/cli/commands/score.mjs
CHANGED
|
@@ -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
|
|
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}`);
|
package/cli/commands/upgrade.mjs
CHANGED
|
@@ -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 (
|
|
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', [
|
package/cli/commands/verify.mjs
CHANGED
|
@@ -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
|
}
|
package/cli/commands/watch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|