@ecoma-io/archkeep 0.16.1 → 0.18.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 +1 -1
- package/cli.mjs +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -0
- package/src/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `decisions` command: the deterministic chain behind one recorded
|
|
3
|
+
* architecture decision — decision → the governed rows that stand on it
|
|
4
|
+
* (intent + constraint + fitness) → the projects they govern → the current
|
|
5
|
+
* evidence/findings, and the decision's verification level.
|
|
6
|
+
*
|
|
7
|
+
* It composes the wave-2 modules without owning any of them:
|
|
8
|
+
*
|
|
9
|
+
* - `../governance/adr-registry.mjs` — the read of `docs/adr/` and the
|
|
10
|
+
* record index (`readAdrContext` below is this module's thin wrapper);
|
|
11
|
+
* - `../governance/decision-graph.mjs` — `forwardDecision`, the walk that
|
|
12
|
+
* attaches governed rows, projects and evidence, and reports every hop it
|
|
13
|
+
* cannot resolve in `walk.unresolved`;
|
|
14
|
+
* - `../governance/decision-fitness.mjs` — `computeDecisionFitness`, the
|
|
15
|
+
* per-decision verification level;
|
|
16
|
+
* - `./provenance-command.mjs` — `intentRows`/`configRows`, the same row
|
|
17
|
+
* walk `provenance` uses, so this command never holds a second copy of
|
|
18
|
+
* which rows exist.
|
|
19
|
+
*
|
|
20
|
+
* It is descriptive, like `adr`/`report`: it never exits 1. The chain is a
|
|
21
|
+
* description of what is recorded and whether it is currently satisfied, not
|
|
22
|
+
* a finding. What it DOES refuse, loudly (exit 3, never clean):
|
|
23
|
+
*
|
|
24
|
+
* - an unreadable registry — a read that throws propagates to the caller;
|
|
25
|
+
* - a reference that does not resolve — either the positional `<id>` naming
|
|
26
|
+
* no ADR record, or any hop of the walk (`walk.unresolved`), including a
|
|
27
|
+
* binding that names no governed row. A chain that could not walk every hop
|
|
28
|
+
* is rendered as an unresolved block, never as a clean chain (the
|
|
29
|
+
* invariant, `../../../../AGENTS.md`).
|
|
30
|
+
*
|
|
31
|
+
* ## What the chain's Fitness leg verifies
|
|
32
|
+
*
|
|
33
|
+
* A decision's `bindings` name rule/fitness ids. This command walks the
|
|
34
|
+
* workspace's declared `fitness` list (like `report` does) and evaluates
|
|
35
|
+
* those functions against the same snapshot the `fitness` command builds, so
|
|
36
|
+
* a binding that names a declared gate derives its real verdict. A binding
|
|
37
|
+
* naming no declared gate derives `unverifiable` — the registry alone asserts
|
|
38
|
+
* nothing, never a clean pass. The whole derivation is deterministic: the
|
|
39
|
+
* same tree, the same law, the same chain.
|
|
40
|
+
*/
|
|
41
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
42
|
+
import { formatDecisionChain } from "../report/decisions-text.mjs";
|
|
43
|
+
import { readAdrContext } from "./adr.mjs";
|
|
44
|
+
import { forwardDecision } from "../governance/decision-graph.mjs";
|
|
45
|
+
import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
|
|
46
|
+
import { stripAdrPrefix, stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
|
|
47
|
+
import { intentRows, configRows, rowLabel } from "./provenance-command.mjs";
|
|
48
|
+
import { evaluateFitness, fitnessSnapshot } from "../governance/fitness-registry.mjs";
|
|
49
|
+
import { hasTag, isComboDepConstraint } from "../rules/tags.mjs";
|
|
50
|
+
import { resolveMembers } from "../architecture-intent/selectors.mjs";
|
|
51
|
+
import { evaluate } from "../rules/index.mjs";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The projects whose tags satisfy a constraint row's source selector — the
|
|
55
|
+
* same match `findConstraintsFor` (`../rules/tags.mjs`) makes, so the row's
|
|
56
|
+
* `governs` never disagrees with the row a rule actually applies to.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} row A `depConstraints` row.
|
|
59
|
+
* @param {object} graph The project graph.
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function constraintGoverns(row, graph) {
|
|
63
|
+
return Object.entries(graph.nodes)
|
|
64
|
+
.filter(([, project]) =>
|
|
65
|
+
isComboDepConstraint(row)
|
|
66
|
+
? row.allSourceTags.every((tag) => hasTag(project, tag))
|
|
67
|
+
: hasTag(project, row.sourceTag),
|
|
68
|
+
)
|
|
69
|
+
.map(([name]) => name)
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves one intent side (`from`/`to`/`name`/`source`/`target`) to the
|
|
75
|
+
* projects it names: a declared boundary wins, else the side is an inline
|
|
76
|
+
* selector. Mirrors `sidePatterns` (`../architecture-intent/judge.mjs`).
|
|
77
|
+
*
|
|
78
|
+
* @param {object} intent The normalized intent model.
|
|
79
|
+
* @param {object} graph The project graph.
|
|
80
|
+
* @param {string} side A boundary name or inline selector.
|
|
81
|
+
* @returns {string[]}
|
|
82
|
+
*/
|
|
83
|
+
function sideProjects(intent, graph, side) {
|
|
84
|
+
const declared = (intent.boundaries ?? []).find((b) => b.name === side);
|
|
85
|
+
const patterns = declared ? declared.match : [side];
|
|
86
|
+
return resolveMembers(patterns, graph.nodes);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The projects one intent row governs, by row family — the "who does this
|
|
91
|
+
* row bind" answer. A row with no resolvable side governs nothing, which is
|
|
92
|
+
* itself a fact the walk reports (a governed project list is never assumed).
|
|
93
|
+
*
|
|
94
|
+
* @param {object} intent The normalized intent model.
|
|
95
|
+
* @param {object} graph The project graph.
|
|
96
|
+
* @param {object} row An intent row.
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
function intentRowGoverns(intent, graph, row) {
|
|
100
|
+
if (typeof row.name === "string") return [row.name];
|
|
101
|
+
if (typeof row.from === "string") {
|
|
102
|
+
// `forbiddenTags` and the dependency rows claim the FROM side's edges;
|
|
103
|
+
// `allowed`/`forbidden` claim the boundary between both sides.
|
|
104
|
+
const from = sideProjects(intent, graph, row.from);
|
|
105
|
+
if (typeof row.to === "string") {
|
|
106
|
+
const to = sideProjects(intent, graph, row.to);
|
|
107
|
+
return Array.from(new Set([...from, ...to])).sort();
|
|
108
|
+
}
|
|
109
|
+
return from;
|
|
110
|
+
}
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The verdicts of the workspace's declared fitness functions, folded into a
|
|
116
|
+
* `{name, verdict}` list — the same `{name, verdict}` shape `fitness` emits,
|
|
117
|
+
* so `computeDecisionFitness` can verify a decision's bound gates against
|
|
118
|
+
* this run's real judgements. Deterministic: same tree, same law, same list.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
121
|
+
* @param {object} config The resolved boundary law.
|
|
122
|
+
* @param {object|null} intent The normalized intent model, or null when the
|
|
123
|
+
* workspace declares none.
|
|
124
|
+
* @returns {object[]} `[{name, verdict}, ...]`.
|
|
125
|
+
*/
|
|
126
|
+
function fitnessVerdictsFor(commandContext, config, intent) {
|
|
127
|
+
if (!Array.isArray(config?.fitness) || config.fitness.length === 0) return [];
|
|
128
|
+
const snapshot = fitnessSnapshot(commandContext, {
|
|
129
|
+
intent,
|
|
130
|
+
suppressions: config.suppressions ?? [],
|
|
131
|
+
});
|
|
132
|
+
return evaluateFitness(config.fitness, snapshot).map((decision) => ({
|
|
133
|
+
name: decision.name,
|
|
134
|
+
verdict: decision.verdict,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The verdict for one `decisions` run: the payload for both renderers, the
|
|
140
|
+
* status, and the coverage that decides exit 0 against 3.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} decisionId The ADR id to walk (`NNN-slug` or `adr:NNN-slug`).
|
|
143
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
144
|
+
* @param {object} config The resolved boundary law.
|
|
145
|
+
* @param {{intent?: object|null, fitnessVerdicts?: object[]}} [io]
|
|
146
|
+
* `intent` is the normalized intent model (or null); `fitnessVerdicts`
|
|
147
|
+
* overrides the run's own evaluation — a test supplies a fixed list.
|
|
148
|
+
* @returns {{status: "ok"|"no-verdict", result: object, coverage: object,
|
|
149
|
+
* report: {text: string, json: string}}}
|
|
150
|
+
* @throws {Error} on an unreadable registry, a malformed law, or a config
|
|
151
|
+
* declaring fitness that fails to evaluate — exit-3 class.
|
|
152
|
+
*/
|
|
153
|
+
export function decisionsCommand(decisionId, commandContext, config, io = {}) {
|
|
154
|
+
const intent = io.intent ?? null;
|
|
155
|
+
|
|
156
|
+
// The registry read — throws on an unreadable `docs/adr/`, which the caller
|
|
157
|
+
// maps to exit 3. Never a clean result.
|
|
158
|
+
const { records, byId, knownFitness } = readAdrContext(commandContext.root, {
|
|
159
|
+
tracked: commandContext.tracked,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The governed rows the walk attaches: intent rows and config constraint
|
|
163
|
+
// rows, each carrying the governance block and the projects it governs.
|
|
164
|
+
// `governs` is derived deterministically from the graph, so the walk never
|
|
165
|
+
// has to re-resolve a selector.
|
|
166
|
+
const rowsFromIntent = intent === null ? [] : intentRows(intent);
|
|
167
|
+
/** @type {import("../governance/decision-graph.mjs").GovernedRow[]} */
|
|
168
|
+
const rows = [
|
|
169
|
+
...rowsFromIntent.map(
|
|
170
|
+
({ kind, row }) =>
|
|
171
|
+
/** @type {import("../governance/decision-graph.mjs").GovernedRow} */ ({
|
|
172
|
+
id: rowLabel(kind, row),
|
|
173
|
+
kind: "intent",
|
|
174
|
+
decisionRef: row.decisionRef,
|
|
175
|
+
governs: intentRowGoverns(intent, commandContext.graph, row),
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
...configRows(config).map(
|
|
179
|
+
({ kind, row }) =>
|
|
180
|
+
/** @type {import("../governance/decision-graph.mjs").GovernedRow} */ ({
|
|
181
|
+
id: rowLabel(kind, row),
|
|
182
|
+
kind: "constraint",
|
|
183
|
+
decisionRef: row.decisionRef,
|
|
184
|
+
governs: constraintGoverns(row, commandContext.graph),
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
// The declared fitness gates themselves, keyed by their name — the same
|
|
188
|
+
// id space `declaredFitnessNames`/`stripRuleFitnessPrefix` use, so a
|
|
189
|
+
// decision binding `fitness:cycle-free` resolves to the gate that exists.
|
|
190
|
+
...(Array.isArray(config?.fitness) ? config.fitness : []).map(
|
|
191
|
+
(gate) =>
|
|
192
|
+
/** @type {import("../governance/decision-graph.mjs").GovernedRow} */ ({
|
|
193
|
+
id: gate.name,
|
|
194
|
+
kind: "fitness",
|
|
195
|
+
governs: resolveMembers(gate.match ?? ["*"], commandContext.graph.nodes),
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
// The evidence leg: findings for the projects the rows govern, built from
|
|
201
|
+
// the same evaluation `check` runs. A path-scoped or graph-less context
|
|
202
|
+
// would leave this lookup undefined and `attachRowLeg` reports it as an
|
|
203
|
+
// unresolved walk — never a quiet "no findings" claim.
|
|
204
|
+
const violations = evaluate(commandContext.analysis.imports, commandContext.graph, config);
|
|
205
|
+
const findingsByProject = new Map();
|
|
206
|
+
for (const violation of violations) {
|
|
207
|
+
const list = findingsByProject.get(violation.sourceProject) ?? [];
|
|
208
|
+
list.push({
|
|
209
|
+
id: `${violation.sourceFile}:${violation.line}:${violation.column}`,
|
|
210
|
+
project: violation.sourceProject,
|
|
211
|
+
ruleId: violation.messageId,
|
|
212
|
+
});
|
|
213
|
+
findingsByProject.set(violation.sourceProject, list);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const walk = forwardDecision(decisionId, {
|
|
217
|
+
records,
|
|
218
|
+
byId,
|
|
219
|
+
knownFitness,
|
|
220
|
+
rows,
|
|
221
|
+
findingsByProject: (project) => findingsByProject.get(project) ?? [],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// The per-decision fitness derivation, folded with THIS run's verdicts —
|
|
225
|
+
// the `{name, verdict}` list from the declared fitness gates above (or the
|
|
226
|
+
// caller's override). `computeDecisionFitness`'s second argument carries
|
|
227
|
+
// verdicts but is unused; the lookup is the single door, so it is null.
|
|
228
|
+
const verdicts = io.fitnessVerdicts ?? fitnessVerdictsFor(commandContext, config, intent);
|
|
229
|
+
const verdictByName = new Map(verdicts.map((v) => [v.name, v]));
|
|
230
|
+
const fitnessLookup = (bindingId) => verdictByName.get(stripRuleFitnessPrefix(bindingId));
|
|
231
|
+
const fitnessById = new Map(
|
|
232
|
+
computeDecisionFitness(records, null, fitnessLookup).map((entry) => [entry.id, entry]),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const record = byId.get(stripAdrPrefix(decisionId)) ?? null;
|
|
236
|
+
const result = {
|
|
237
|
+
decisionId,
|
|
238
|
+
record,
|
|
239
|
+
walk,
|
|
240
|
+
fitness: record === null ? undefined : fitnessById.get(record.id),
|
|
241
|
+
knownFitness: [...knownFitness].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const unresolved = walk.unresolved;
|
|
245
|
+
const status = walk.ok ? "ok" : "no-verdict";
|
|
246
|
+
const exitCode = status === "ok" ? 0 : 3;
|
|
247
|
+
|
|
248
|
+
const coverage = {
|
|
249
|
+
complete: walk.ok,
|
|
250
|
+
projects: Object.keys(commandContext.graph?.nodes ?? {}).length,
|
|
251
|
+
analyzedFiles: commandContext.analysis?.analyzed ?? 0,
|
|
252
|
+
imports: (commandContext.analysis?.imports ?? []).length,
|
|
253
|
+
notAnalyzed: unresolved.map(({ ref, kind: ukind, reason }) => ({
|
|
254
|
+
file: ukind === "decision" ? `${ref}.md` : ref,
|
|
255
|
+
reason,
|
|
256
|
+
})),
|
|
257
|
+
blindSpots: [],
|
|
258
|
+
notes: [],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const envelope = jsonEnvelope({
|
|
262
|
+
command: "decisions",
|
|
263
|
+
context: {
|
|
264
|
+
root: commandContext.root,
|
|
265
|
+
provider: commandContext.provider ?? "native",
|
|
266
|
+
marker: "docs/adr",
|
|
267
|
+
provenance: null,
|
|
268
|
+
},
|
|
269
|
+
status,
|
|
270
|
+
exitCode,
|
|
271
|
+
coverage,
|
|
272
|
+
result,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const text = formatDecisionChain({
|
|
276
|
+
decisionId,
|
|
277
|
+
record,
|
|
278
|
+
walk,
|
|
279
|
+
fitness: result.fitness,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
status,
|
|
284
|
+
result,
|
|
285
|
+
coverage,
|
|
286
|
+
report: {
|
|
287
|
+
text: `${text}\n`,
|
|
288
|
+
json: renderJson(envelope),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
import { canonicalizeJson } from "../canonical.mjs";
|
|
66
66
|
import { suppressionCovers } from "../config.mjs";
|
|
67
67
|
import { referenceTime } from "../governance/clock.mjs";
|
|
68
|
+
import { classifyEvolution } from "../governance/evolution-event.mjs";
|
|
68
69
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
69
70
|
import { namespacedId } from "./custom-rules.mjs";
|
|
70
71
|
|
|
@@ -491,6 +492,262 @@ export function classifyDelta(input) {
|
|
|
491
492
|
};
|
|
492
493
|
}
|
|
493
494
|
|
|
495
|
+
/**
|
|
496
|
+
* The stable identity string of one classified delta violation entry, for an
|
|
497
|
+
* evolution event's `findings` (`../governance/evolution-event.mjs`) — the
|
|
498
|
+
* delta's own identity facts, never a second spelling: `messageId`,
|
|
499
|
+
* `sourceProject`, the target (project or specifier, with the marker saying
|
|
500
|
+
* which), and the canonical constraint row that fired. `baseCount`/`headCount`
|
|
501
|
+
* and the sites are attached evidence, exactly as they are outside identity in
|
|
502
|
+
* `violationIdentity` — a growth or shrink changes counts, not what the
|
|
503
|
+
* violation IS.
|
|
504
|
+
*
|
|
505
|
+
* @param {object} entry One classified entry from `classifyViolations` or
|
|
506
|
+
* `classifyDelta`'s `violations` buckets.
|
|
507
|
+
* @returns {string} The canonical identity string.
|
|
508
|
+
*/
|
|
509
|
+
function deltaEntryIdentity(entry) {
|
|
510
|
+
return canonicalizeJson({
|
|
511
|
+
messageId: entry.messageId,
|
|
512
|
+
sourceProject: entry.sourceProject ?? null,
|
|
513
|
+
target: entry.target ?? null,
|
|
514
|
+
targetIsSpecifier: entry.targetIsSpecifier === true,
|
|
515
|
+
constraint: entry.constraint ?? null,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The best name an UNCLASSIFIABLE delta entry can honestly carry into an
|
|
521
|
+
* event's `unknown` list — the identity it has, or an honest absence. The
|
|
522
|
+
* reason is the load-bearing half (`classifyEvolution` discloses each with
|
|
523
|
+
* it); the id exists so the note can name the entry.
|
|
524
|
+
*
|
|
525
|
+
* @param {{violation?: object}} entry A violation-classification unknown.
|
|
526
|
+
* @returns {string}
|
|
527
|
+
*/
|
|
528
|
+
function unknownViolationIdentity(entry) {
|
|
529
|
+
const violation = entry?.violation;
|
|
530
|
+
if (violation !== null && typeof violation === "object") {
|
|
531
|
+
const messageId = typeof violation.messageId === "string" ? violation.messageId : null;
|
|
532
|
+
const target =
|
|
533
|
+
typeof violation.targetProject === "string" && violation.targetProject !== ""
|
|
534
|
+
? violation.targetProject
|
|
535
|
+
: typeof violation.specifier === "string" && violation.specifier !== ""
|
|
536
|
+
? violation.specifier
|
|
537
|
+
: null;
|
|
538
|
+
if (messageId !== null || target !== null) {
|
|
539
|
+
return `violation ${messageId ?? "?"}${target === null ? "" : ` → ${target}`}`;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return "unidentifiable violation";
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The same best-name discipline for an unresolvable-record unknown. */
|
|
546
|
+
function unknownRecordIdentity(entry) {
|
|
547
|
+
const record = entry?.record;
|
|
548
|
+
if (
|
|
549
|
+
record !== null &&
|
|
550
|
+
typeof record === "object" &&
|
|
551
|
+
typeof record.specifier === "string" &&
|
|
552
|
+
record.specifier !== ""
|
|
553
|
+
) {
|
|
554
|
+
return `unresolvable import '${record.specifier}'`;
|
|
555
|
+
}
|
|
556
|
+
return "unidentifiable unresolvable import";
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The delta event's `findings` (design §1) mapped from a delta capture: the
|
|
561
|
+
* identity strings of the classified violations, plus every verdict-relevant
|
|
562
|
+
* unknown the run disclosed — violation unknowns, unresolvable-record
|
|
563
|
+
* unknowns, and custom-rule unknowns, each with its reason. Unresolvable
|
|
564
|
+
* introduced/resolved records are NOT findings: the delta carries them but
|
|
565
|
+
* never counts them as violations (no rule reached a verdict about them), and
|
|
566
|
+
* the event's findings mirror the delta's gating vocabulary.
|
|
567
|
+
*
|
|
568
|
+
* @param {object} delta The `deltaCommand` result payload (`violations`,
|
|
569
|
+
* `unresolvable`, optional `customRules`).
|
|
570
|
+
* @returns {{introduced: string[], resolved: string[], unknown: {id: string,
|
|
571
|
+
* reason: string}[]}}
|
|
572
|
+
*/
|
|
573
|
+
export function deltaFindings(delta) {
|
|
574
|
+
return {
|
|
575
|
+
introduced: delta.violations.introduced.map(deltaEntryIdentity),
|
|
576
|
+
resolved: delta.violations.resolved.map(deltaEntryIdentity),
|
|
577
|
+
unknown: [
|
|
578
|
+
...delta.violations.unknown.map((entry) => ({
|
|
579
|
+
id: unknownViolationIdentity(entry),
|
|
580
|
+
reason: entry.reason,
|
|
581
|
+
})),
|
|
582
|
+
...delta.unresolvable.unknown.map((entry) => ({
|
|
583
|
+
id: unknownRecordIdentity(entry),
|
|
584
|
+
reason: entry.reason,
|
|
585
|
+
})),
|
|
586
|
+
...(delta.customRules === undefined
|
|
587
|
+
? []
|
|
588
|
+
: delta.customRules.findings.unknown.map((entry) => ({
|
|
589
|
+
id: `custom rule '${entry.rule}'`,
|
|
590
|
+
reason: entry.reason,
|
|
591
|
+
}))),
|
|
592
|
+
],
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The identity string of one graph edge, in the design's canonical spelling
|
|
598
|
+
* `source>target:type` (the `(source, target, type)` identity the design
|
|
599
|
+
* §1 names, printed the way `docs/concepts/evolution.md`'s example shows).
|
|
600
|
+
* The ONE spelling the delta's event `observed.edges`/`affected.boundaries`
|
|
601
|
+
* use — a second spelling somewhere would be a second definition of "same
|
|
602
|
+
* edge", and two definitions drift.
|
|
603
|
+
*
|
|
604
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
605
|
+
* @returns {string}
|
|
606
|
+
*/
|
|
607
|
+
export function edgeEvolutionIdentity({ source, target, type }) {
|
|
608
|
+
return `${source}>${target}:${type}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The delta event's per-constraint verdict deltas (design §1 `fitness`),
|
|
613
|
+
* derived from the classified entries the capture already carries — never a
|
|
614
|
+
* re-judgment. A constraint's base/head verdict is judged from the entries
|
|
615
|
+
* that name it: `fail` when an entry attributes any live site to that side
|
|
616
|
+
* (`baseCount`/`headCount`), `pass` otherwise. Only rows whose verdict MOVED
|
|
617
|
+
* are deltas — a constraint failing on both sides moved nothing, and a
|
|
618
|
+
* half-fixed one is the delta's report of the half it moved. Rows are sorted
|
|
619
|
+
* by constraint identity, so two runs over the same capture are
|
|
620
|
+
* byte-identical.
|
|
621
|
+
*
|
|
622
|
+
* @param {object} delta The `deltaCommand` result payload.
|
|
623
|
+
* @returns {{constraint: string, base: "pass"|"fail", head: "pass"|"fail"}[]}
|
|
624
|
+
*/
|
|
625
|
+
export function deltaVerdictDeltas(delta) {
|
|
626
|
+
const entries = [
|
|
627
|
+
...delta.violations.introduced,
|
|
628
|
+
...delta.violations.resolved,
|
|
629
|
+
...delta.violations.unchanged,
|
|
630
|
+
];
|
|
631
|
+
/** @type {Map<string, {base: number, head: number}>} */
|
|
632
|
+
const byConstraint = new Map();
|
|
633
|
+
for (const entry of entries) {
|
|
634
|
+
if (entry.constraint === undefined || entry.constraint === null) continue;
|
|
635
|
+
const id = canonicalizeJson(entry.constraint);
|
|
636
|
+
const row = byConstraint.get(id) ?? { base: 0, head: 0 };
|
|
637
|
+
if (entry.baseCount > 0) row.base += 1;
|
|
638
|
+
if (entry.headCount > 0) row.head += 1;
|
|
639
|
+
byConstraint.set(id, row);
|
|
640
|
+
}
|
|
641
|
+
/** @type {{constraint: string, base: "pass"|"fail", head: "pass"|"fail"}[]} */
|
|
642
|
+
const deltas = [];
|
|
643
|
+
for (const [constraint, counts] of byConstraint) {
|
|
644
|
+
/** @type {"pass"|"fail"} */
|
|
645
|
+
const base = counts.base > 0 ? "fail" : "pass";
|
|
646
|
+
/** @type {"pass"|"fail"} */
|
|
647
|
+
const head = counts.head > 0 ? "fail" : "pass";
|
|
648
|
+
if (base === head) continue;
|
|
649
|
+
deltas.push({ constraint, base, head });
|
|
650
|
+
}
|
|
651
|
+
return deltas;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The §1 mapping for a delta capture: feeds the delta's OWN signals — the
|
|
656
|
+
* classified violations with their waiver state, the policy-change fact, and
|
|
657
|
+
* the structural-change/code-drift signals the caller supplies when it
|
|
658
|
+
* computed them — to `classifyEvolution` (`../governance/evolution-event.mjs`),
|
|
659
|
+
* the one home of the classification predicates, and returns its verdict.
|
|
660
|
+
* This module adds no second opinion about what a class means; it maps.
|
|
661
|
+
*
|
|
662
|
+
* The delta's verdict-relevant unknowns — violation unknowns, unresolvable-
|
|
663
|
+
* record unknowns, custom-rule unknowns — are passed through as
|
|
664
|
+
* `violations.unknown`, so `classifyEvolution`'s fail-closed discipline holds
|
|
665
|
+
* for every item the delta itself could not place: each raises a `notes[]`
|
|
666
|
+
* disclosure and forces disposition `no-verdict`; none is ever folded into a
|
|
667
|
+
* clean class.
|
|
668
|
+
*
|
|
669
|
+
* `affected.constraints` is the one delta-specific derivation: the delta's
|
|
670
|
+
* governed constraints are the `depConstraints` rows its classified
|
|
671
|
+
* introduced/resolved entries name (the capture output carries each row), and
|
|
672
|
+
* no reconcile-vocabulary verdict exists for them — so they are mapped from
|
|
673
|
+
* the entries, never invented. `affected.projects`/`boundaries`/`decisions`
|
|
674
|
+
* come from `classifyEvolution`'s own mapping of the supplied signals.
|
|
675
|
+
*
|
|
676
|
+
* @param {object} delta The `deltaCommand` result payload.
|
|
677
|
+
* @param {{projects?: {added: string[], removed: string[], changed: string[]},
|
|
678
|
+
* edges?: {added: string[], removed: string[]}, codeDrift?: boolean}} [signals]
|
|
679
|
+
* The structural-change and drift signals a delta run derives from the two
|
|
680
|
+
* graphs it holds (the graph diff is `diff`'s vocabulary, shared here, never
|
|
681
|
+
* re-derived) — `projects`/`edges` carry identity strings (`edgeEvolutionIdentity`
|
|
682
|
+
* for edges), and `codeDrift` is the delta's computed "provenance advanced,
|
|
683
|
+
* no arch/policy change" fact. Absent signals are empty, so a delta that
|
|
684
|
+
* computed none reads as a violation-only mapping.
|
|
685
|
+
* @returns {{classifications: string[], disposition: "accepted"|"rejected"|"no-verdict",
|
|
686
|
+
* notes: string[], affected: {projects: string[], boundaries: string[],
|
|
687
|
+
* constraints: string[], decisions: string[]}}} The full
|
|
688
|
+
* `EvolutionClassification` — `classifications`/`notes` per the wave
|
|
689
|
+
* contract, with `disposition`/`affected` riding from the one definition so
|
|
690
|
+
* no caller re-derives either.
|
|
691
|
+
*/
|
|
692
|
+
export function classifyDeltaEvolution(delta, signals = {}) {
|
|
693
|
+
const projects = signals.projects ?? { added: [], removed: [], changed: [] };
|
|
694
|
+
const edges = signals.edges ?? { added: [], removed: [] };
|
|
695
|
+
const evolution = classifyEvolution({
|
|
696
|
+
observed: {
|
|
697
|
+
projects,
|
|
698
|
+
edges,
|
|
699
|
+
// `null` survives: it is the one-sided policy case, and `classifyEvolution`
|
|
700
|
+
// reads it as "could not be compared" — never as "the same". The
|
|
701
|
+
// one-sided/advanced facts are input facts the payload carries (F-HIST-1):
|
|
702
|
+
// both-sides-absent is also `null` but stays comparable.
|
|
703
|
+
policyChanged: delta.policyChanged,
|
|
704
|
+
policyOneSided: delta.policyOneSided,
|
|
705
|
+
provenanceChanged: delta.provenanceChanged,
|
|
706
|
+
},
|
|
707
|
+
codeDrift: signals.codeDrift === true,
|
|
708
|
+
violations: {
|
|
709
|
+
introduced: delta.violations.introduced.map((entry) => ({
|
|
710
|
+
id: deltaEntryIdentity(entry),
|
|
711
|
+
waived: entry.waived === true,
|
|
712
|
+
})),
|
|
713
|
+
resolved: delta.violations.resolved.map(deltaEntryIdentity),
|
|
714
|
+
unknown: [
|
|
715
|
+
...delta.violations.unknown.map((entry) => ({
|
|
716
|
+
id: unknownViolationIdentity(entry),
|
|
717
|
+
reason: entry.reason,
|
|
718
|
+
})),
|
|
719
|
+
...delta.unresolvable.unknown.map((entry) => ({
|
|
720
|
+
id: unknownRecordIdentity(entry),
|
|
721
|
+
reason: entry.reason,
|
|
722
|
+
})),
|
|
723
|
+
...(delta.customRules === undefined
|
|
724
|
+
? []
|
|
725
|
+
: delta.customRules.findings.unknown.map((entry) => ({
|
|
726
|
+
id: `custom rule '${entry.rule}'`,
|
|
727
|
+
reason: entry.reason,
|
|
728
|
+
}))),
|
|
729
|
+
],
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
/** @type {Set<string>} */
|
|
734
|
+
const constraintIds = new Set();
|
|
735
|
+
for (const entry of [...delta.violations.introduced, ...delta.violations.resolved]) {
|
|
736
|
+
if (entry.constraint === undefined || entry.constraint === null) continue;
|
|
737
|
+
constraintIds.add(canonicalizeJson(entry.constraint));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
classifications: evolution.classifications,
|
|
742
|
+
disposition: evolution.disposition,
|
|
743
|
+
notes: evolution.notes,
|
|
744
|
+
affected: {
|
|
745
|
+
...evolution.affected,
|
|
746
|
+
constraints: [...constraintIds].sort(cmpString),
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
494
751
|
/** Identity-or-reason wrapper applied to every raw violation. */
|
|
495
752
|
function identityOf(violation) {
|
|
496
753
|
const result = violationIdentity(violation);
|