@adrkit/cli 0.1.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 +23 -0
- package/dist/LICENSE +201 -0
- package/dist/NOTICE +11 -0
- package/dist/evaluate-snapshot.d.ts +31 -0
- package/dist/evaluate.d.ts +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1037 -0
- package/dist/main-module.d.ts +1 -0
- package/package.json +58 -0
- package/src/evaluate-snapshot.ts +607 -0
- package/src/evaluate.ts +157 -0
- package/src/index.ts +409 -0
- package/src/main-module.ts +7 -0
package/src/evaluate.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/cli — `adr evaluate` composition boundary.
|
|
3
|
+
*
|
|
4
|
+
* This is the IMPURE boundary: it reads the proposal + corpus from disk, validates
|
|
5
|
+
* the snapshot bundle into immutable data, constructs the trusted target/assertion
|
|
6
|
+
* registries from composition code (never from JSON), resolves `--date`, calls the
|
|
7
|
+
* pure `evaluatePass0`, and renders + selects the exit code. It never writes the
|
|
8
|
+
* report/patch back to any record or store — there is NO `--write` (FR-014).
|
|
9
|
+
*
|
|
10
|
+
* US1 wires empty registries; US3 (T042) constructs the real deterministic ports.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
import { lintCorpus, normalizeDisplayPath } from '@adrkit/core';
|
|
16
|
+
import {
|
|
17
|
+
canonicalBytes,
|
|
18
|
+
createAssertionEngineRegistry,
|
|
19
|
+
createJsonPathEngine,
|
|
20
|
+
createPackageTargetResolver,
|
|
21
|
+
createPathTargetResolver,
|
|
22
|
+
createTargetResolutionRegistry,
|
|
23
|
+
evaluatePass0,
|
|
24
|
+
type Pass0Input,
|
|
25
|
+
} from '@adrkit/evaluator';
|
|
26
|
+
import { loadSnapshotBundle, SnapshotContractError, type NormalizedSnapshot } from './evaluate-snapshot.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The trusted deterministic registries, constructed ONLY from composition code —
|
|
30
|
+
* never from snapshot JSON. Built-in `path`/`package` target resolvers and the approved
|
|
31
|
+
* JSONPath source engine are registered; `entity`/`resource`/`api`/`data` resolvers and
|
|
32
|
+
* a Rego/grep/custom engine are absent by default (the affected rules report inert).
|
|
33
|
+
*/
|
|
34
|
+
const TARGET_REGISTRY = createTargetResolutionRegistry([createPathTargetResolver(), createPackageTargetResolver()]);
|
|
35
|
+
const ASSERTION_ENGINES = createAssertionEngineRegistry({ jsonpath: createJsonPathEngine() });
|
|
36
|
+
|
|
37
|
+
export interface EvaluateOptions {
|
|
38
|
+
readonly proposalPath: string;
|
|
39
|
+
readonly snapshotPath: string;
|
|
40
|
+
readonly date: string;
|
|
41
|
+
readonly json: boolean;
|
|
42
|
+
readonly dir?: string;
|
|
43
|
+
readonly cwd?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EvaluateOutput {
|
|
47
|
+
readonly exitCode: 0 | 1 | 2;
|
|
48
|
+
readonly stdout: string;
|
|
49
|
+
readonly stderr: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
53
|
+
|
|
54
|
+
/** Strict `YYYY-MM-DD` including calendar correctness (no clock is read). */
|
|
55
|
+
export function isValidIsoDate(value: string): boolean {
|
|
56
|
+
if (!ISO_DATE.test(value)) return false;
|
|
57
|
+
const [y, m, d] = value.split('-').map(Number) as [number, number, number];
|
|
58
|
+
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
|
|
59
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
60
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Assemble the immutable Pass0Input from the loaded corpus + normalized snapshot. */
|
|
64
|
+
function buildInput(
|
|
65
|
+
proposalPath: string,
|
|
66
|
+
corpus: Awaited<ReturnType<typeof lintCorpus>>,
|
|
67
|
+
snapshot: NormalizedSnapshot,
|
|
68
|
+
date: string,
|
|
69
|
+
): Pass0Input {
|
|
70
|
+
return {
|
|
71
|
+
corpus,
|
|
72
|
+
proposalPath,
|
|
73
|
+
...(snapshot.federatedLogs ? { federatedLogs: snapshot.federatedLogs } : {}),
|
|
74
|
+
...(snapshot.resolutionLog !== undefined ? { resolutionLog: snapshot.resolutionLog } : {}),
|
|
75
|
+
targets: snapshot.targets,
|
|
76
|
+
// Trusted registries are injected here — never selected by the snapshot JSON.
|
|
77
|
+
targetRegistry: TARGET_REGISTRY,
|
|
78
|
+
assertionInputs: snapshot.assertionInputs,
|
|
79
|
+
assertionEngines: ASSERTION_ENGINES,
|
|
80
|
+
...(snapshot.identity ? { identity: snapshot.identity } : {}),
|
|
81
|
+
...(snapshot.scopeEvidence ? { scopeEvidence: snapshot.scopeEvidence } : {}),
|
|
82
|
+
...(snapshot.routingEvidence ? { routingEvidence: snapshot.routingEvidence } : {}),
|
|
83
|
+
evaluationDate: date,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderHuman(report: import('@adrkit/evaluator').Pass0Report): string {
|
|
88
|
+
const lines: string[] = [`Pass 0 evaluation of ${report.proposalPath} — outcome: ${report.outcome}`];
|
|
89
|
+
for (const result of report.results) {
|
|
90
|
+
const severity = result.status === 'fail' && result.severity ? ` (${result.severity})` : '';
|
|
91
|
+
lines.push(` ${result.rule}: ${result.status}${severity} — ${result.reason}`);
|
|
92
|
+
}
|
|
93
|
+
const { routing } = report;
|
|
94
|
+
const target =
|
|
95
|
+
routing.target.kind === 'resolved'
|
|
96
|
+
? `${routing.target.human} (via ${routing.target.via})`
|
|
97
|
+
: routing.target.kind;
|
|
98
|
+
lines.push(
|
|
99
|
+
` routing: ${routing.escalate ? `escalate [${routing.reasons.join(', ')}]` : 'no escalation'}, target: ${target}`,
|
|
100
|
+
);
|
|
101
|
+
return `${lines.join('\n')}\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pure-ish evaluation core: given already-parsed options, produce the exit + streams.
|
|
106
|
+
* Filesystem access (proposal/corpus/snapshot reads) happens here at the boundary.
|
|
107
|
+
*/
|
|
108
|
+
export async function evaluate(options: EvaluateOptions): Promise<EvaluateOutput> {
|
|
109
|
+
const cwd = options.cwd ?? process.cwd();
|
|
110
|
+
|
|
111
|
+
if (!isValidIsoDate(options.date)) {
|
|
112
|
+
return { exitCode: 2, stdout: '', stderr: `adr evaluate: --date must be a valid YYYY-MM-DD (got "${options.date}")\n` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let snapshot: NormalizedSnapshot;
|
|
116
|
+
try {
|
|
117
|
+
const text = await readFile(options.snapshotPath, 'utf8');
|
|
118
|
+
snapshot = loadSnapshotBundle(text);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof SnapshotContractError) {
|
|
121
|
+
return { exitCode: 2, stdout: '', stderr: `adr evaluate: ${error.message}\n` };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
exitCode: 2,
|
|
125
|
+
stdout: '',
|
|
126
|
+
stderr: `adr evaluate: could not read snapshot "${options.snapshotPath}": ${error instanceof Error ? error.message : String(error)}\n`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dir = options.dir ?? dirname(options.proposalPath);
|
|
131
|
+
const corpus = await lintCorpus({ paths: [dir, options.proposalPath], cwd });
|
|
132
|
+
const proposalPath = normalizeDisplayPath(options.proposalPath, cwd);
|
|
133
|
+
|
|
134
|
+
const outcome = evaluatePass0(buildInput(proposalPath, corpus, snapshot, options.date));
|
|
135
|
+
|
|
136
|
+
if (outcome.kind === 'input-error') {
|
|
137
|
+
return {
|
|
138
|
+
exitCode: 2,
|
|
139
|
+
stdout: '',
|
|
140
|
+
stderr: `adr evaluate: ${outcome.error.code} — "${proposalPath}" has status "${outcome.error.actualStatus}", not draft/proposed\n`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { report, patch } = outcome.result;
|
|
145
|
+
const exitCode: 0 | 1 = report.outcome === 'returned' ? 1 : 0;
|
|
146
|
+
|
|
147
|
+
if (options.json) {
|
|
148
|
+
// Canonicalize the complete envelope. Caller metadata remains outside `result`,
|
|
149
|
+
// so report/patch content is still independent of metadata (FR-005).
|
|
150
|
+
const envelope = {
|
|
151
|
+
result: { report, patch },
|
|
152
|
+
metadata: { evaluatorVersion: '0.1.0' },
|
|
153
|
+
};
|
|
154
|
+
return { exitCode, stdout: canonicalBytes(envelope), stderr: '' };
|
|
155
|
+
}
|
|
156
|
+
return { exitCode, stdout: renderHuman(report), stderr: '' };
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs, type ParseArgsConfig } from 'node:util';
|
|
4
|
+
import {
|
|
5
|
+
buildAdrGraph,
|
|
6
|
+
checkChanges,
|
|
7
|
+
countFindings,
|
|
8
|
+
createAdr,
|
|
9
|
+
exitCodeForFindings,
|
|
10
|
+
lintCorpus,
|
|
11
|
+
migrateMadr,
|
|
12
|
+
resolveAffects,
|
|
13
|
+
renderDotGraph,
|
|
14
|
+
renderJsonGraph,
|
|
15
|
+
ScaffoldError,
|
|
16
|
+
sortFindings,
|
|
17
|
+
type Finding,
|
|
18
|
+
} from '@adrkit/core';
|
|
19
|
+
import { evaluate } from './evaluate.ts';
|
|
20
|
+
import { isMainModule } from './main-module.ts';
|
|
21
|
+
|
|
22
|
+
function writeStdout(text: string): void {
|
|
23
|
+
process.stdout.write(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeStderr(text: string): void {
|
|
27
|
+
process.stderr.write(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usage(message?: string): number {
|
|
31
|
+
if (message) writeStderr(`${message}\n`);
|
|
32
|
+
writeStderr(`Usage:
|
|
33
|
+
adr lint [paths...] [--json] [--dir docs/adr]
|
|
34
|
+
adr migrate --from madr [--dir docs/adr] [--dry-run] [--json]
|
|
35
|
+
adr new <title> [--status draft] [--dir docs/adr] [--json]
|
|
36
|
+
adr graph [--dir docs/adr] [--format dot|json]
|
|
37
|
+
adr explain <path> [--dir docs/adr] [--json]
|
|
38
|
+
adr check <files...> [--dir docs/adr] [--json]
|
|
39
|
+
adr evaluate <proposal-path> --snapshot <bundle.json> --date YYYY-MM-DD [--json] [--dir docs/adr]
|
|
40
|
+
|
|
41
|
+
Round-trip sync is explicitly unsupported (ADR-0008); migrate is one-way and non-destructive.
|
|
42
|
+
`);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseCommandArgs(
|
|
47
|
+
args: string[],
|
|
48
|
+
options: ParseArgsConfig['options'],
|
|
49
|
+
): ReturnType<typeof parseArgs> {
|
|
50
|
+
return parseArgs({ args, options, allowPositionals: true, strict: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderFinding(finding: Finding): string {
|
|
54
|
+
const field = finding.field ? ` ${finding.field}` : '';
|
|
55
|
+
const id = finding.id ? ` ${finding.id}` : '';
|
|
56
|
+
const pattern = finding.pattern ? ` ${finding.pattern}` : '';
|
|
57
|
+
return ` ${finding.severity} ${finding.rule}${id}${field}${pattern}: ${finding.message}\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderHumanLint(findings: readonly Finding[]): string {
|
|
61
|
+
const grouped = new Map<string, Finding[]>();
|
|
62
|
+
for (const finding of findings) {
|
|
63
|
+
const group = finding.path ?? '(corpus)';
|
|
64
|
+
let list = grouped.get(group);
|
|
65
|
+
if (!list) {
|
|
66
|
+
list = [];
|
|
67
|
+
grouped.set(group, list);
|
|
68
|
+
}
|
|
69
|
+
list.push(finding);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let output = '';
|
|
73
|
+
for (const [path, groupFindings] of [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
74
|
+
output += `${path}\n`;
|
|
75
|
+
for (const finding of groupFindings) {
|
|
76
|
+
output += renderFinding(finding);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return output;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runLint(args: string[]): Promise<number> {
|
|
83
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
84
|
+
try {
|
|
85
|
+
parsed = parseCommandArgs(args, {
|
|
86
|
+
json: { type: 'boolean', default: false },
|
|
87
|
+
dir: { type: 'string', default: 'docs/adr' },
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = await lintCorpus({
|
|
94
|
+
dir: String(parsed.values.dir),
|
|
95
|
+
paths: parsed.positionals,
|
|
96
|
+
});
|
|
97
|
+
const findings = sortFindings(result.findings);
|
|
98
|
+
const counts = countFindings(findings);
|
|
99
|
+
|
|
100
|
+
if (parsed.values.json) {
|
|
101
|
+
writeStdout(`${JSON.stringify({ checked: result.checked, findings }, null, 2)}\n`);
|
|
102
|
+
} else {
|
|
103
|
+
const humanFindings = renderHumanLint(findings);
|
|
104
|
+
if (humanFindings) writeStderr(humanFindings);
|
|
105
|
+
writeStdout(`checked ${result.checked} records, ${counts.errors} errors, ${counts.warnings} warnings\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return exitCodeForFindings(findings);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderHumanMigrate(result: Awaited<ReturnType<typeof migrateMadr>>): string {
|
|
112
|
+
const counts = {
|
|
113
|
+
migrated: 0,
|
|
114
|
+
updated: 0,
|
|
115
|
+
unchanged: 0,
|
|
116
|
+
diverged: 0,
|
|
117
|
+
skipped: 0,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
let output = '';
|
|
121
|
+
for (const item of result.results) {
|
|
122
|
+
counts[item.outcome] += 1;
|
|
123
|
+
output += `${item.outcome} ${item.path}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
output += `summary: migrated ${counts.migrated}, updated ${counts.updated}, unchanged ${counts.unchanged}, diverged ${counts.diverged}, skipped ${counts.skipped}\n`;
|
|
127
|
+
output += 'Divergence (report only):\n';
|
|
128
|
+
if (result.divergence.length === 0) {
|
|
129
|
+
output += ' none\n';
|
|
130
|
+
} else {
|
|
131
|
+
for (const item of result.divergence) {
|
|
132
|
+
output += ` ${item.path} sourceRef=${item.sourceRef}\n`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.findings.length > 0) {
|
|
137
|
+
output += 'Findings:\n';
|
|
138
|
+
for (const finding of result.findings) {
|
|
139
|
+
output += renderFinding(finding);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return output;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function runMigrate(args: string[]): Promise<number> {
|
|
147
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
148
|
+
try {
|
|
149
|
+
parsed = parseCommandArgs(args, {
|
|
150
|
+
from: { type: 'string' },
|
|
151
|
+
dir: { type: 'string', default: 'docs/adr' },
|
|
152
|
+
'dry-run': { type: 'boolean', default: false },
|
|
153
|
+
json: { type: 'boolean', default: false },
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (parsed.positionals.length > 0) return usage('adr migrate does not accept positional arguments');
|
|
160
|
+
const from = parsed.values.from;
|
|
161
|
+
if (from !== 'madr') {
|
|
162
|
+
return usage(
|
|
163
|
+
from
|
|
164
|
+
? `adr migrate --from ${String(from)} is not supported yet; only --from madr is available, and round-trip sync is unsupported (ADR-0008)`
|
|
165
|
+
: 'adr migrate requires --from madr; non-MADR sources and round-trip sync are unsupported in this phase (ADR-0008)',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const result = await migrateMadr({
|
|
170
|
+
dir: String(parsed.values.dir),
|
|
171
|
+
write: parsed.values['dry-run'] !== true,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (parsed.values.json) {
|
|
175
|
+
writeStdout(`${JSON.stringify(result, null, 2)}\n`);
|
|
176
|
+
} else {
|
|
177
|
+
writeStdout(renderHumanMigrate(result));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runNew(args: string[]): Promise<number> {
|
|
184
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
185
|
+
try {
|
|
186
|
+
parsed = parseCommandArgs(args, {
|
|
187
|
+
json: { type: 'boolean', default: false },
|
|
188
|
+
dir: { type: 'string', default: 'docs/adr' },
|
|
189
|
+
status: { type: 'string', default: 'draft' },
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const title = parsed.positionals.join(' ').trim();
|
|
196
|
+
if (!title) return usage('adr new requires a title');
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const result = await createAdr({
|
|
200
|
+
title,
|
|
201
|
+
status: String(parsed.values.status),
|
|
202
|
+
dir: String(parsed.values.dir),
|
|
203
|
+
});
|
|
204
|
+
if (parsed.values.json) {
|
|
205
|
+
writeStdout(`${JSON.stringify({ id: result.id, path: result.path }, null, 2)}\n`);
|
|
206
|
+
} else {
|
|
207
|
+
writeStdout(`${result.path}\n`);
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof ScaffoldError) {
|
|
212
|
+
writeStderr(`${error.message}\n`);
|
|
213
|
+
return error.code === 'exists' ? 1 : 2;
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runGraph(args: string[]): Promise<number> {
|
|
220
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
221
|
+
try {
|
|
222
|
+
parsed = parseCommandArgs(args, {
|
|
223
|
+
dir: { type: 'string', default: 'docs/adr' },
|
|
224
|
+
format: { type: 'string', default: 'dot' },
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (parsed.positionals.length > 0) return usage('adr graph does not accept positional arguments');
|
|
231
|
+
const format = String(parsed.values.format);
|
|
232
|
+
if (format !== 'dot' && format !== 'json') return usage('adr graph --format must be dot or json');
|
|
233
|
+
|
|
234
|
+
const result = await lintCorpus({ dir: String(parsed.values.dir) });
|
|
235
|
+
const graph = buildAdrGraph(result.records);
|
|
236
|
+
writeStdout(format === 'json' ? renderJsonGraph(graph) : renderDotGraph(graph));
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function runExplain(args: string[]): Promise<number> {
|
|
241
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
242
|
+
try {
|
|
243
|
+
parsed = parseCommandArgs(args, {
|
|
244
|
+
json: { type: 'boolean', default: false },
|
|
245
|
+
dir: { type: 'string', default: 'docs/adr' },
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (parsed.positionals.length !== 1) return usage('adr explain requires exactly one path');
|
|
252
|
+
const path = parsed.positionals[0];
|
|
253
|
+
if (!path) return usage('adr explain requires exactly one path');
|
|
254
|
+
|
|
255
|
+
const corpus = await lintCorpus({ dir: String(parsed.values.dir) });
|
|
256
|
+
const corpusFindings = sortFindings(corpus.findings);
|
|
257
|
+
if (exitCodeForFindings(corpusFindings) !== 0) {
|
|
258
|
+
if (parsed.values.json) {
|
|
259
|
+
writeStdout(`${JSON.stringify({ path, governedBy: [], findings: corpusFindings }, null, 2)}\n`);
|
|
260
|
+
} else {
|
|
261
|
+
const humanFindings = renderHumanLint(corpusFindings);
|
|
262
|
+
if (humanFindings) writeStderr(humanFindings);
|
|
263
|
+
}
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const recordsById = new Map(corpus.records.map((record) => [record.frontmatter.id, record]));
|
|
268
|
+
const resolution = resolveAffects({ records: corpus.records, changedFiles: [path] });
|
|
269
|
+
const governedBy = resolution.matches.map((match) => ({
|
|
270
|
+
recordId: match.recordId,
|
|
271
|
+
title: recordsById.get(match.recordId)?.frontmatter.title ?? '',
|
|
272
|
+
firedMatchers: match.firedMatchers,
|
|
273
|
+
}));
|
|
274
|
+
const findings = sortFindings(resolution.findings);
|
|
275
|
+
|
|
276
|
+
if (parsed.values.json) {
|
|
277
|
+
writeStdout(`${JSON.stringify({ path, governedBy, findings }, null, 2)}\n`);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (governedBy.length === 0) {
|
|
282
|
+
writeStdout(`No decision governs ${path}.\n`);
|
|
283
|
+
} else {
|
|
284
|
+
for (const match of governedBy) {
|
|
285
|
+
writeStdout(`${match.recordId} ${match.title}\n`);
|
|
286
|
+
for (const matcher of match.firedMatchers) {
|
|
287
|
+
writeStdout(` via ${matcher.type}: ${matcher.pattern}\n`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (findings.length > 0) {
|
|
293
|
+
writeStdout('Findings:\n');
|
|
294
|
+
for (const finding of findings) {
|
|
295
|
+
writeStdout(renderFinding(finding));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderHumanCheck(outcome: ReturnType<typeof checkChanges>): string {
|
|
303
|
+
let output = '';
|
|
304
|
+
if (outcome.governedBy.length === 0) {
|
|
305
|
+
output += 'No decisions govern the changed files.\n';
|
|
306
|
+
} else {
|
|
307
|
+
output += 'Decisions governing this change:\n';
|
|
308
|
+
for (const decision of outcome.governedBy) {
|
|
309
|
+
output += ` ${decision.recordId} ${decision.title}\n`;
|
|
310
|
+
for (const matcher of decision.firedMatchers) {
|
|
311
|
+
output += ` via ${matcher.type}: ${matcher.pattern}\n`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (outcome.findings.length > 0) {
|
|
317
|
+
output += 'Findings:\n';
|
|
318
|
+
output += renderHumanLint(outcome.findings);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const changedRecordErrors = outcome.findings.filter(
|
|
322
|
+
(finding) => finding.severity === 'error' && finding.path && outcome.changedRecords.includes(finding.path),
|
|
323
|
+
).length;
|
|
324
|
+
output += `checked: ${outcome.governedBy.length} governing, ${outcome.changedRecords.length} changed records, ${changedRecordErrors} changed-record errors\n`;
|
|
325
|
+
return output;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runCheck(args: string[]): Promise<number> {
|
|
329
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
330
|
+
try {
|
|
331
|
+
parsed = parseCommandArgs(args, {
|
|
332
|
+
json: { type: 'boolean', default: false },
|
|
333
|
+
dir: { type: 'string', default: 'docs/adr' },
|
|
334
|
+
});
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const dir = String(parsed.values.dir);
|
|
340
|
+
const lint = await lintCorpus({ dir });
|
|
341
|
+
const outcome = checkChanges({ lint, changedFiles: parsed.positionals, dir });
|
|
342
|
+
|
|
343
|
+
if (parsed.values.json) {
|
|
344
|
+
writeStdout(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
345
|
+
} else {
|
|
346
|
+
writeStdout(renderHumanCheck(outcome));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return outcome.ok ? 0 : 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runEvaluate(args: string[]): Promise<number> {
|
|
353
|
+
let parsed: ReturnType<typeof parseArgs>;
|
|
354
|
+
try {
|
|
355
|
+
parsed = parseCommandArgs(args, {
|
|
356
|
+
snapshot: { type: 'string' },
|
|
357
|
+
date: { type: 'string' },
|
|
358
|
+
json: { type: 'boolean', default: false },
|
|
359
|
+
dir: { type: 'string' },
|
|
360
|
+
});
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (parsed.positionals.length !== 1) return usage('adr evaluate requires exactly one proposal path');
|
|
366
|
+
const proposalPath = parsed.positionals[0];
|
|
367
|
+
if (!proposalPath) return usage('adr evaluate requires a proposal path');
|
|
368
|
+
const snapshot = parsed.values.snapshot;
|
|
369
|
+
if (typeof snapshot !== 'string' || snapshot.length === 0) {
|
|
370
|
+
return usage('adr evaluate requires --snapshot <bundle.json>');
|
|
371
|
+
}
|
|
372
|
+
const date = parsed.values.date;
|
|
373
|
+
if (typeof date !== 'string' || date.length === 0) {
|
|
374
|
+
return usage('adr evaluate requires --date YYYY-MM-DD');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const result = await evaluate({
|
|
378
|
+
proposalPath,
|
|
379
|
+
snapshotPath: snapshot,
|
|
380
|
+
date,
|
|
381
|
+
json: parsed.values.json === true,
|
|
382
|
+
...(typeof parsed.values.dir === 'string' ? { dir: parsed.values.dir } : {}),
|
|
383
|
+
});
|
|
384
|
+
if (result.stderr) writeStderr(result.stderr);
|
|
385
|
+
if (result.stdout) writeStdout(result.stdout);
|
|
386
|
+
return result.exitCode;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
390
|
+
const [command, ...args] = argv;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
if (command === 'lint') return await runLint(args);
|
|
394
|
+
if (command === 'migrate') return await runMigrate(args);
|
|
395
|
+
if (command === 'new') return await runNew(args);
|
|
396
|
+
if (command === 'graph') return await runGraph(args);
|
|
397
|
+
if (command === 'explain') return await runExplain(args);
|
|
398
|
+
if (command === 'check') return await runCheck(args);
|
|
399
|
+
if (command === 'evaluate') return await runEvaluate(args);
|
|
400
|
+
return usage(command ? `Unknown command "${command}"` : undefined);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
writeStderr(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
403
|
+
return 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
408
|
+
process.exitCode = await main();
|
|
409
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean {
|
|
5
|
+
if (!argvPath || !existsSync(argvPath)) return false;
|
|
6
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
|
|
7
|
+
}
|