bmad-plus 0.20.0 → 0.22.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 +41 -0
- package/README.md +14 -14
- package/SECURITY.md +62 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +1 -1
- package/readme-international/README.de.md +14 -14
- package/readme-international/README.es.md +14 -14
- package/readme-international/README.fr.md +14 -14
- package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +26 -5
- package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
- package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
- package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
- package/src/bmad-plus/packs/pack-shield/README.md +12 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
- package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
- package/tools/build/generate-adapters.js +7 -0
- package/tools/build/generate.js +14 -0
- package/tools/cli/bmad-plus-cli.js +2 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/review.js +141 -7
- package/tools/cli/lib/ai-register.js +393 -0
- package/tools/cli/lib/assurance.js +822 -0
- package/tools/cli/lib/control-refs.js +132 -0
- package/tools/cli/lib/installation-health.js +17 -0
- package/tools/cli/lib/packs.js +60 -2
- package/tools/cli/lib/page-origins.js +582 -0
- package/tools/cli/lib/review-rules.js +124 -26
- package/tools/cli/lib/review.js +493 -10
- package/tools/cli/lib/uat.js +22 -5
- package/tools/cli/review-rules/index.yaml +9 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security assurance cases: start one from the Shield template, run the checks that produce
|
|
3
|
+
* evidence, verify a case against them.
|
|
4
|
+
*/
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const assurance = require('../lib/assurance');
|
|
10
|
+
|
|
11
|
+
const ACTIONS = ['init', 'run', 'verify'];
|
|
12
|
+
|
|
13
|
+
function print(json, payload, lines) {
|
|
14
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
|
|
15
|
+
else for (const line of lines) console.log(line);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `--emit-check` writes a JSON file atomically, never over the case or its ledger. */
|
|
19
|
+
function writeCheck(projectDir, value, kase, ledger, result) {
|
|
20
|
+
const file = path.resolve(projectDir, value);
|
|
21
|
+
if (!/\.json$/i.test(file)) throw new Error('--emit-check writes a .json file');
|
|
22
|
+
const protectedFiles = [path.resolve(projectDir, kase.file), ledger];
|
|
23
|
+
if (protectedFiles.some((taken) => path.relative(taken, file) === ''))
|
|
24
|
+
throw new Error('--emit-check must not overwrite the case or its ledger');
|
|
25
|
+
if (fs.existsSync(file) && !fs.statSync(file).isFile())
|
|
26
|
+
throw new Error(`--emit-check: ${file} is not a file`);
|
|
27
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
29
|
+
fs.writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
30
|
+
try {
|
|
31
|
+
fs.renameSync(temporary, file);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
fs.rmSync(temporary, { force: true });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function initAction(projectDir, caseFile, json) {
|
|
40
|
+
const { file, id } = assurance.initCase(projectDir, caseFile);
|
|
41
|
+
print(json, { action: 'init', case: id, file }, [
|
|
42
|
+
`${file}: case ${id} started from the Shield template.`,
|
|
43
|
+
'Replace every example claim and check with those of the project, commit it, then run it.',
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function runAction(projectDir, kase, options, json) {
|
|
48
|
+
const { file, results, head, authenticated } = await assurance.runChecks(projectDir, kase, {
|
|
49
|
+
dir: options.dir,
|
|
50
|
+
only: options.check,
|
|
51
|
+
});
|
|
52
|
+
const failed = results.filter((result) => result.failures.length);
|
|
53
|
+
print(
|
|
54
|
+
json,
|
|
55
|
+
{
|
|
56
|
+
action: 'run',
|
|
57
|
+
case: kase.id,
|
|
58
|
+
ledger: file,
|
|
59
|
+
head,
|
|
60
|
+
authenticated,
|
|
61
|
+
results: results.map(({ check, failures, record }) => ({
|
|
62
|
+
check,
|
|
63
|
+
status: failures.length ? 'failed' : 'passed',
|
|
64
|
+
failures,
|
|
65
|
+
sequence: record.sequence,
|
|
66
|
+
exitCode: record.exitCode,
|
|
67
|
+
revision: record.revision,
|
|
68
|
+
dirty: record.dirty,
|
|
69
|
+
sha256: record.sha256,
|
|
70
|
+
})),
|
|
71
|
+
},
|
|
72
|
+
[
|
|
73
|
+
`${file}`,
|
|
74
|
+
...results.map(
|
|
75
|
+
({ check, failures, record }) =>
|
|
76
|
+
` ${failures.length ? 'failed' : 'passed'} ${check.padEnd(24)} exit ${record.exitCode ?? '-'} in ${Math.round(record.durationMs / 100) / 10} s${failures.length ? ` — ${failures.join('; ')}` : ''}`
|
|
77
|
+
),
|
|
78
|
+
...(results.some(({ record }) => record.dirty !== false)
|
|
79
|
+
? [
|
|
80
|
+
' Ran on uncommitted changes, untracked files or outside git: a case bound to a commit will not accept these runs.',
|
|
81
|
+
]
|
|
82
|
+
: []),
|
|
83
|
+
`Recorded ${results.length} run(s)${authenticated ? ', authenticated by key' : ''}; ledger head ${head}.`,
|
|
84
|
+
`Keep the head outside the ledger and verify with bmad-plus assurance verify ${kase.file} --ledger-head ${head}.`,
|
|
85
|
+
]
|
|
86
|
+
);
|
|
87
|
+
process.exitCode = failed.length ? 1 : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function verifyAction(projectDir, kase, options, json) {
|
|
91
|
+
const verdict = assurance.verifyCase(projectDir, kase, {
|
|
92
|
+
dir: options.dir,
|
|
93
|
+
head: options.ledgerHead ?? null,
|
|
94
|
+
});
|
|
95
|
+
let check = null;
|
|
96
|
+
if (options.emitCheck)
|
|
97
|
+
check = writeCheck(
|
|
98
|
+
projectDir,
|
|
99
|
+
options.emitCheck,
|
|
100
|
+
kase,
|
|
101
|
+
assurance.ledgerFile(projectDir, options.dir, kase.id),
|
|
102
|
+
assurance.checkResult(verdict)
|
|
103
|
+
);
|
|
104
|
+
print(
|
|
105
|
+
json,
|
|
106
|
+
{ action: 'verify', ...verdict, check },
|
|
107
|
+
[
|
|
108
|
+
`${kase.id}: ${verdict.status} — ${verdict.claims.filter((c) => c.status === 'supported').length}/${verdict.claims.length} claim(s) supported, ${verdict.ledger.runs} recorded run(s)`,
|
|
109
|
+
...verdict.reasons.map((reason) => ` - ${reason}`),
|
|
110
|
+
...Object.entries(verdict.checks)
|
|
111
|
+
.filter(([, judged]) => judged.status !== 'passed')
|
|
112
|
+
.map(([id, judged]) => ` ${judged.status.padEnd(7)} ${id}: ${judged.reasons.join('; ')}`),
|
|
113
|
+
...verdict.claims
|
|
114
|
+
.filter((claim) => claim.status !== 'supported')
|
|
115
|
+
.map((claim) => ` unsupported claim ${claim.id}: ${claim.reasons.join('; ')}`),
|
|
116
|
+
...(verdict.controls.supported.length
|
|
117
|
+
? [` controls with executed evidence: ${verdict.controls.supported.join(', ')}`]
|
|
118
|
+
: []),
|
|
119
|
+
check ? ` check: ${check}` : '',
|
|
120
|
+
].filter(Boolean)
|
|
121
|
+
);
|
|
122
|
+
process.exitCode = verdict.exitCode;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
command: 'assurance <action> <case>',
|
|
127
|
+
description: 'Security assurance case bound to executed checks: init, run, verify',
|
|
128
|
+
options: [
|
|
129
|
+
['-d, --directory <path>', 'Project directory'],
|
|
130
|
+
['--dir <path>', 'Evidence folder inside the project', assurance.DEFAULT_DIR],
|
|
131
|
+
['--check <id>', 'run: only this check (repeatable)', (v, all) => [...all, v], []],
|
|
132
|
+
['--ledger-head <sha256>', 'verify: fail unless the ledger still holds this record'],
|
|
133
|
+
['--emit-check <file>', 'verify: also write the verdict as a JSON check result for CI'],
|
|
134
|
+
['--json', 'Machine-readable output'],
|
|
135
|
+
],
|
|
136
|
+
action: async (action, caseFile, options = {}) => {
|
|
137
|
+
const projectDir = path.resolve(options.directory || process.cwd());
|
|
138
|
+
const json = Boolean(options.json);
|
|
139
|
+
const settings = { dir: options.dir || assurance.DEFAULT_DIR, check: options.check || [] };
|
|
140
|
+
try {
|
|
141
|
+
if (!ACTIONS.includes(action))
|
|
142
|
+
throw new Error(`unknown action "${action}" (${ACTIONS.join(', ')})`);
|
|
143
|
+
if (action === 'init') return initAction(projectDir, caseFile, json);
|
|
144
|
+
const kase = assurance.loadCase(projectDir, caseFile);
|
|
145
|
+
if (action === 'run') await runAction(projectDir, kase, settings, json);
|
|
146
|
+
else
|
|
147
|
+
verifyAction(
|
|
148
|
+
projectDir,
|
|
149
|
+
kase,
|
|
150
|
+
{ ...settings, emitCheck: options.emitCheck, ledgerHead: options.ledgerHead },
|
|
151
|
+
json
|
|
152
|
+
);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (json)
|
|
155
|
+
console.log(
|
|
156
|
+
JSON.stringify({ schemaVersion: 1, status: 'error', message: error.message }, null, 2)
|
|
157
|
+
);
|
|
158
|
+
else console.error(`assurance: ${error.message}`);
|
|
159
|
+
process.exitCode = 3;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -7,7 +7,7 @@ const review = require('../lib/review');
|
|
|
7
7
|
const reviewRules = require('../lib/review-rules');
|
|
8
8
|
|
|
9
9
|
const ID = /^[a-z0-9][a-z0-9.-]{0,80}$/;
|
|
10
|
-
const ACTIONS = ['scope', 'anchor', 'gate', 'compare', 'rules'];
|
|
10
|
+
const ACTIONS = ['scope', 'anchor', 'gate', 'continue', 'compare', 'rules'];
|
|
11
11
|
|
|
12
12
|
function fail(message) {
|
|
13
13
|
throw new Error(message);
|
|
@@ -28,6 +28,48 @@ function writeJson(file, value) {
|
|
|
28
28
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A reader of the file sees the previous version or the new one, never half of it. */
|
|
32
|
+
function writeAtomically(file, text) {
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
35
|
+
fs.writeFileSync(temporary, text, { flag: 'wx' });
|
|
36
|
+
try {
|
|
37
|
+
fs.renameSync(temporary, file);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
fs.rmSync(temporary, { force: true });
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const inside = (dir, file) => {
|
|
45
|
+
const relative = path.relative(dir, file);
|
|
46
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where `--emit-check` writes: a JSON file, resolved against the project, never the evidence
|
|
51
|
+
* itself. A working-tree scope selects untracked files, so its check stays in the review
|
|
52
|
+
* folder or out of the project: anywhere else it would enter the change it reports on.
|
|
53
|
+
*/
|
|
54
|
+
function checkTarget(projectDir, value, paths, scope) {
|
|
55
|
+
const file = path.resolve(projectDir, value);
|
|
56
|
+
if (!/\.json$/i.test(file)) fail('--emit-check writes a .json file');
|
|
57
|
+
const evidence = Object.entries(paths).filter(([key]) => key !== 'root');
|
|
58
|
+
if (evidence.some(([, taken]) => path.relative(taken, file) === ''))
|
|
59
|
+
fail('--emit-check must not overwrite the review evidence');
|
|
60
|
+
if (
|
|
61
|
+
scope.identity.workspace &&
|
|
62
|
+
inside(projectDir, file) &&
|
|
63
|
+
!inside(path.dirname(paths.root), file)
|
|
64
|
+
)
|
|
65
|
+
fail(
|
|
66
|
+
`--emit-check: a working-tree review writes its check in ${path.relative(projectDir, path.dirname(paths.root)) || '.'} or outside the project, where it cannot enter the reviewed change`
|
|
67
|
+
);
|
|
68
|
+
if (fs.existsSync(file) && !fs.statSync(file).isFile())
|
|
69
|
+
fail(`--emit-check: ${file} is not a file`);
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
31
73
|
function print(json, payload, lines) {
|
|
32
74
|
if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
|
|
33
75
|
else for (const line of lines) console.log(line);
|
|
@@ -46,6 +88,75 @@ function loadReview(projectDir, dir, id) {
|
|
|
46
88
|
return { paths, scope };
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
/** One line of observed usage: what the host reported, nothing estimated. */
|
|
92
|
+
function describeUsage(stop, usage) {
|
|
93
|
+
const parts = [
|
|
94
|
+
`stopped: ${stop || 'unstated'}`,
|
|
95
|
+
`${usage.passes ?? '?'}/${usage.plannedPasses ?? '?'} pass(es)`,
|
|
96
|
+
`${usage.unitsAttempted}/${usage.units} unit(s) attempted in ${usage.attempts} attempt(s), ${usage.failedAttempts} failed`,
|
|
97
|
+
];
|
|
98
|
+
if (usage.tokens !== undefined) parts.push(`${usage.tokens} tokens`);
|
|
99
|
+
if (usage.durationMs !== undefined) parts.push(`${Math.round(usage.durationMs / 1000)} s`);
|
|
100
|
+
return parts.join(', ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `review continue <id>`: what an interrupted review still owes, against the same sealed
|
|
105
|
+
* scope. Refused when the code or the rules moved — a new scope and a compare answer that.
|
|
106
|
+
*/
|
|
107
|
+
function continueAction(projectDir, dir, id, paths, scope, json) {
|
|
108
|
+
const drift = review.scopeDrift(projectDir, scope, {
|
|
109
|
+
ruleset: reviewRules.loadRuleset(projectDir),
|
|
110
|
+
outputDir: dir,
|
|
111
|
+
});
|
|
112
|
+
if (drift.length) {
|
|
113
|
+
const next = [
|
|
114
|
+
`bmad-plus review scope <new-id> (same options as ${id})`,
|
|
115
|
+
`bmad-plus review compare <new-id> --since ${id}`,
|
|
116
|
+
];
|
|
117
|
+
print(json, { action: 'continue', id, status: 'moved', drift, next }, [
|
|
118
|
+
`${id} cannot continue: the sealed scope no longer describes the code.`,
|
|
119
|
+
...drift.map((reason) => ` - ${reason}`),
|
|
120
|
+
` Seal a new scope and compare: ${next.join(', then ')}.`,
|
|
121
|
+
]);
|
|
122
|
+
process.exitCode = 3;
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const coverage = readJson(paths.coverage, 'coverage.json');
|
|
126
|
+
const coverageErrors = coverage ? review.validateCoverage(coverage, scope) : [];
|
|
127
|
+
if (coverageErrors.length) {
|
|
128
|
+
print(json, { action: 'continue', id, status: 'error', errors: coverageErrors }, [
|
|
129
|
+
`${id}: coverage.json must be valid before the review continues`,
|
|
130
|
+
...coverageErrors.map((e) => ` error ${e}`),
|
|
131
|
+
]);
|
|
132
|
+
process.exitCode = 3;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const findings = readJson(paths.findings, 'findings.json');
|
|
136
|
+
const findingErrors = findings ? review.validateFindings(findings, scope) : [];
|
|
137
|
+
const anchored =
|
|
138
|
+
findings && !findingErrors.length ? review.anchorFindings(projectDir, scope, findings) : null;
|
|
139
|
+
const packet = review.remainingWork({ scope, coverage, anchored, findingErrors });
|
|
140
|
+
writeJson(paths.continue, packet);
|
|
141
|
+
const c = packet.counts;
|
|
142
|
+
const nothing = !c.files && !c.requote && !c.findingErrors && !c.passes;
|
|
143
|
+
print(json, { action: 'continue', id, status: 'ready', file: paths.continue, counts: c }, [
|
|
144
|
+
`${paths.continue} — same scope ${scope.sha256.slice(0, 12)}`,
|
|
145
|
+
`${c.files} file(s) in ${c.units} unit(s) to review, ${c.requote} finding(s) to requote, ${c.passes} pass(es) left`,
|
|
146
|
+
...packet.units.map((unit) => ` ${unit.id.padEnd(5)} ${unit.paths.join(', ')}`),
|
|
147
|
+
...packet.abandoned.map(
|
|
148
|
+
(file) => ` abandoned ${file.path} (${file.unit}): three failed attempts; it stays failed`
|
|
149
|
+
),
|
|
150
|
+
...packet.requote.map((f) => ` requote ${f.id} ${f.path} (${f.status})`),
|
|
151
|
+
...packet.findingErrors.map((e) => ` fix ${e}`),
|
|
152
|
+
nothing
|
|
153
|
+
? `Nothing remains: once coverage.json records the run as completed, run bmad-plus review gate ${id}.`
|
|
154
|
+
: 'Add to the same coverage.json and findings.json; set run.stop when this session ends.',
|
|
155
|
+
]);
|
|
156
|
+
process.exitCode = 0;
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
49
160
|
/** `review rules [path]`: the effective rule set, or the rules one path would get. */
|
|
50
161
|
function rulesAction(projectDir, target, json) {
|
|
51
162
|
const ruleset = reviewRules.loadRuleset(projectDir);
|
|
@@ -58,21 +169,30 @@ function rulesAction(projectDir, target, json) {
|
|
|
58
169
|
action: 'rules',
|
|
59
170
|
path: file,
|
|
60
171
|
sha256: ruleset.sha256,
|
|
172
|
+
packFiles: ruleset.packFiles,
|
|
61
173
|
projectFile: ruleset.projectFile,
|
|
62
174
|
disabled: ruleset.disabled,
|
|
63
|
-
rules: listed.map(({ id, title, layer, globs, source }) => ({
|
|
175
|
+
rules: listed.map(({ id, title, group, layer, pack, globs, controls, source }) => ({
|
|
64
176
|
id,
|
|
65
177
|
title,
|
|
178
|
+
group,
|
|
66
179
|
layer,
|
|
180
|
+
...(pack ? { pack } : {}),
|
|
67
181
|
globs,
|
|
182
|
+
controls,
|
|
68
183
|
source,
|
|
69
184
|
})),
|
|
185
|
+
controls: reviewRules.controlsOf(
|
|
186
|
+
ruleset,
|
|
187
|
+
listed.map((rule) => rule.id)
|
|
188
|
+
),
|
|
70
189
|
},
|
|
71
190
|
[
|
|
72
|
-
`rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ?
|
|
191
|
+
`rule set ${ruleset.sha256.slice(0, 12)} (${['built-in', ...ruleset.packFiles, ...(ruleset.projectFile ? [ruleset.projectFile] : [])].join(' + ')})`,
|
|
73
192
|
...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
|
|
74
193
|
...listed.map(
|
|
75
|
-
(rule) =>
|
|
194
|
+
(rule) =>
|
|
195
|
+
` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}${rule.controls.length ? `\n ${''.padEnd(24)} controls: ${rule.controls.join(' ')}` : ''}`
|
|
76
196
|
),
|
|
77
197
|
...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
|
|
78
198
|
]
|
|
@@ -81,7 +201,7 @@ function rulesAction(projectDir, target, json) {
|
|
|
81
201
|
|
|
82
202
|
module.exports = {
|
|
83
203
|
command: 'review <action> [id]',
|
|
84
|
-
description: 'Code review evidence: scope, anchor, gate, compare, rules',
|
|
204
|
+
description: 'Code review evidence: scope, anchor, gate, continue, compare, rules',
|
|
85
205
|
options: [
|
|
86
206
|
['-d, --directory <path>', 'Project directory'],
|
|
87
207
|
['--dir <path>', 'Review folder inside the project', review.DEFAULT_DIR],
|
|
@@ -95,6 +215,7 @@ module.exports = {
|
|
|
95
215
|
`Review depth: ${Object.keys(review.EFFORTS).join(', ')} (default medium)`,
|
|
96
216
|
],
|
|
97
217
|
['--since <id>', 'compare: the earlier review to compare against'],
|
|
218
|
+
['--emit-check <file>', 'gate: also write the verdict as a JSON check result for CI'],
|
|
98
219
|
['--json', 'Machine-readable output'],
|
|
99
220
|
],
|
|
100
221
|
action: (action, id, options = {}) => {
|
|
@@ -116,6 +237,7 @@ module.exports = {
|
|
|
116
237
|
include: options.include,
|
|
117
238
|
exclude: options.exclude,
|
|
118
239
|
effort: options.effort,
|
|
240
|
+
outputDir: dir,
|
|
119
241
|
ruleset,
|
|
120
242
|
});
|
|
121
243
|
const paths = review.layout(projectDir, dir, id);
|
|
@@ -201,21 +323,33 @@ module.exports = {
|
|
|
201
323
|
if (findings && !review.validateFindings(findings, scope).length)
|
|
202
324
|
anchored = review.anchorFindings(projectDir, scope, findings);
|
|
203
325
|
const verdict = review.reviewGate({ scope, findings, coverage, anchored });
|
|
326
|
+
let check = null;
|
|
327
|
+
if (options.emitCheck) {
|
|
328
|
+
check = checkTarget(projectDir, options.emitCheck, paths, scope);
|
|
329
|
+
writeAtomically(
|
|
330
|
+
check,
|
|
331
|
+
`${JSON.stringify(review.checkResult({ id, scope, verdict, anchored }), null, 2)}\n`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
204
334
|
print(
|
|
205
335
|
json,
|
|
206
|
-
{ action, id, ...verdict },
|
|
336
|
+
{ action, id, ...verdict, check },
|
|
207
337
|
[
|
|
208
338
|
`${id}: ${verdict.status} — coverage ${verdict.coverage.completed}/${verdict.coverage.selected} completed (${verdict.coverage.waived} waived), ${verdict.findings.open} open finding(s), ${verdict.findings.refuted} refuted`,
|
|
209
339
|
...verdict.reasons.map((reason) => ` - ${reason}`),
|
|
340
|
+
verdict.usage ? ` run: ${describeUsage(verdict.stop, verdict.usage)}` : '',
|
|
341
|
+
check ? ` check: ${check}` : '',
|
|
210
342
|
verdict.status === 'clean'
|
|
211
343
|
? ' clean: no open finding within a fully covered scope. It does not prove the code correct.'
|
|
212
344
|
: '',
|
|
213
345
|
].filter(Boolean)
|
|
214
346
|
);
|
|
215
|
-
process.exitCode =
|
|
347
|
+
process.exitCode = review.GATE_EXIT[verdict.status];
|
|
216
348
|
return undefined;
|
|
217
349
|
}
|
|
218
350
|
|
|
351
|
+
if (action === 'continue') return continueAction(projectDir, dir, id, paths, scope, json);
|
|
352
|
+
|
|
219
353
|
// compare: this review against an earlier one.
|
|
220
354
|
const sinceId = requireId(options.since, '--since <earlier review id>');
|
|
221
355
|
if (sinceId === id) fail('--since must name a different review');
|