@ecoma-io/archkeep 0.16.0 → 0.17.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 +115 -0
- package/package.json +1 -1
- package/src/analysis/csharp.mjs +38 -9
- package/src/analysis/go.mjs +15 -1
- package/src/commands/adr.mjs +45 -4
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/explain.mjs +136 -0
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -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/decisions-text.mjs +164 -0
- package/src/report/explain-text.mjs +77 -1
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
package/src/report/adr-text.mjs
CHANGED
|
@@ -15,10 +15,90 @@ import { ADR_DIR, ADR_STATUSES } from "../governance/adr-registry.mjs";
|
|
|
15
15
|
/** A status label for a record, in the text a human reads. */
|
|
16
16
|
function statusLabel(status) {
|
|
17
17
|
if (status === "accepted") return "accepted";
|
|
18
|
+
if (status === "active") return "active";
|
|
18
19
|
if (status === "superseded") return "superseded";
|
|
20
|
+
if (status === "retired") return "retired";
|
|
19
21
|
return "proposed";
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The `fitness:` line for one record — the per-decision verification level
|
|
26
|
+
* the command derives (`../governance/decision-fitness.mjs` owns the
|
|
27
|
+
* vocabulary). The line is always printed with its reason when the level has
|
|
28
|
+
* one; an `enforced` level carries none and says so. This command alone can
|
|
29
|
+
* never verify anything (its header's "What it cannot assert" owns why), so
|
|
30
|
+
* the level comes in precomputed from the caller — absent it, the line is an
|
|
31
|
+
* explicit "(not measured)" that cannot be mistaken for a verdict.
|
|
32
|
+
*
|
|
33
|
+
* @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function fitnessLine(fitness) {
|
|
37
|
+
if (fitness === undefined) return `fitness: ${"(not measured)"}`;
|
|
38
|
+
if (fitness.verified)
|
|
39
|
+
return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
|
|
40
|
+
if (typeof fitness.reason === "string") {
|
|
41
|
+
return `fitness: ${fitness.level} — ${fitness.reason}`;
|
|
42
|
+
}
|
|
43
|
+
return `fitness: ${fitness.level}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `created:` / `updated:` metadata lines for one record, when the record
|
|
48
|
+
* carries them. Absent frontmatter stays absent — these are the decision's
|
|
49
|
+
* own committed timeline, never a wall-clock guess.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} record
|
|
52
|
+
* @returns {string[]}
|
|
53
|
+
*/
|
|
54
|
+
function timelineLines(record) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
if (typeof record.created === "string") lines.push(`created: ${record.created}`);
|
|
57
|
+
if (typeof record.updated === "string") lines.push(`updated: ${record.updated}`);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The reverse lineage line — the records whose `supersedes` names this one,
|
|
63
|
+
* derived at load by the registry and therefore always consistent with the
|
|
64
|
+
* forward `supersedes:` line on the other record.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} record
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
function supersededByLines(record) {
|
|
70
|
+
if (!Array.isArray(record.supersededBy) || record.supersededBy.length === 0) return [];
|
|
71
|
+
return [`supersededBy: ${record.supersededBy.join(", ")}`];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One prose field rendered for the single-record view: the label on the
|
|
76
|
+
* first line, continued lines aligned under the prose. A field that is
|
|
77
|
+
* absent or blank renders nothing — the record's own body decides.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} label The 12-wide `label:` prefix.
|
|
80
|
+
* @param {string} text The field's markdown prose.
|
|
81
|
+
* @returns {string[]}
|
|
82
|
+
*/
|
|
83
|
+
function proseLines(label, text) {
|
|
84
|
+
const content = text.split("\n").map((line) => line.trimEnd());
|
|
85
|
+
const out = [`${label}${content[0]}`];
|
|
86
|
+
for (const line of content.slice(1)) {
|
|
87
|
+
out.push(`${" ".repeat(label.length)}${line}`);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The prose fields of one record, in the order §3.2 of the wave 2 contract names them. */
|
|
93
|
+
const PROSE_FIELDS = [
|
|
94
|
+
["context: ", "context"],
|
|
95
|
+
["decision: ", "decision"],
|
|
96
|
+
["rationale: ", "rationale"],
|
|
97
|
+
["alternatives:", "alternatives"],
|
|
98
|
+
["consequences:", "consequences"],
|
|
99
|
+
["assumptions: ", "assumptions"],
|
|
100
|
+
];
|
|
101
|
+
|
|
22
102
|
/**
|
|
23
103
|
* The `bindings:` line for one record — the ONE place either face decides how
|
|
24
104
|
* a binding is rendered.
|
|
@@ -54,21 +134,26 @@ function bindingsLine(record, known) {
|
|
|
54
134
|
* Renders the whole registry: one block per record, each naming its status,
|
|
55
135
|
* its supersession chain, and the rules/fitnesses it binds.
|
|
56
136
|
*
|
|
57
|
-
* @param {{records: object[], knownFitness?: Set<string
|
|
137
|
+
* @param {{records: object[], knownFitness?: Set<string>, fitnessById?:
|
|
138
|
+
* Map<string, {level: string, verified: boolean, reason?: string}>}} result
|
|
58
139
|
* @returns {string}
|
|
59
140
|
*/
|
|
60
|
-
export function formatAdrDump({ records, knownFitness }) {
|
|
141
|
+
export function formatAdrDump({ records, knownFitness, fitnessById }) {
|
|
61
142
|
if (records.length === 0) {
|
|
62
143
|
return `no ADRs in ${ADR_DIR}/ — nothing is recorded, and nothing is enforceable through it`;
|
|
63
144
|
}
|
|
64
145
|
const known = knownFitness ?? new Set();
|
|
146
|
+
const fitnessMap = fitnessById ?? new Map();
|
|
65
147
|
const blocks = records.map((record) => {
|
|
66
148
|
const header = `${record.id} (${statusLabel(record.status)})`;
|
|
67
149
|
const lines = [header, "-".repeat(header.length)];
|
|
68
150
|
if (record.supersedes.length > 0) {
|
|
69
151
|
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
70
152
|
}
|
|
153
|
+
lines.push(...supersededByLines(record));
|
|
154
|
+
lines.push(...timelineLines(record));
|
|
71
155
|
lines.push(bindingsLine(record, known));
|
|
156
|
+
lines.push(fitnessLine(fitnessMap.get(record.id)));
|
|
72
157
|
lines.push(`status set: ${ADR_STATUSES.join(", ")}`);
|
|
73
158
|
return lines.join("\n");
|
|
74
159
|
});
|
|
@@ -110,20 +195,40 @@ export function formatAdrMissing({ adrId }) {
|
|
|
110
195
|
* a reader who asked about one record sees strictly less than one who dumped
|
|
111
196
|
* the registry otherwise, and what they stop seeing is the marker.
|
|
112
197
|
*
|
|
113
|
-
*
|
|
198
|
+
* Beyond the dump's lines it also renders the record's own prose (context,
|
|
199
|
+
* decision, rationale, alternatives, consequences, assumptions) when the body
|
|
200
|
+
* records them — this is the view a reader asking "why was this decided" gets.
|
|
201
|
+
*
|
|
202
|
+
* @param {{id: string, status: string, supersedes: string[], supersededBy?:
|
|
203
|
+
* string[], bindings: string[], created?: string, updated?: string, context?:
|
|
204
|
+
* string, decision?: string, rationale?: string, alternatives?: string,
|
|
205
|
+
* consequences?: string, assumptions?: string}} record
|
|
114
206
|
* @param {Set<string>} [knownFitness] The ids the registry's records mention.
|
|
115
207
|
* Optional only so the parameter can be omitted where there is nothing to
|
|
116
208
|
* compare against; an absent set marks every binding rather than none,
|
|
117
209
|
* because "nothing corroborates this" is the honest answer when no set was
|
|
118
210
|
* supplied — never the quiet one.
|
|
211
|
+
* @param {Map<string, {level: string, verified: boolean, reason?: string}>}
|
|
212
|
+
* [fitnessById] The fitness level per record id, computed by the caller
|
|
213
|
+
* (`../commands/adr.mjs`). Absent, a record's fitness line reads as the
|
|
214
|
+
* explicit "(not measured)" — never as a verdict this renderer invented.
|
|
119
215
|
* @returns {string}
|
|
120
216
|
*/
|
|
121
|
-
export function formatAdrRecord(record, knownFitness) {
|
|
217
|
+
export function formatAdrRecord(record, knownFitness, fitnessById) {
|
|
122
218
|
const header = `${record.id} (${statusLabel(record.status)})`;
|
|
123
219
|
const lines = [header, "-".repeat(header.length)];
|
|
124
220
|
if (record.supersedes.length > 0) {
|
|
125
221
|
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
126
222
|
}
|
|
223
|
+
lines.push(...supersededByLines(record));
|
|
224
|
+
lines.push(...timelineLines(record));
|
|
127
225
|
lines.push(bindingsLine(record, knownFitness ?? new Set()));
|
|
226
|
+
lines.push(fitnessLine(fitnessById?.get(record.id)));
|
|
227
|
+
for (const [label, key] of PROSE_FIELDS) {
|
|
228
|
+
const text = record[key];
|
|
229
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
230
|
+
lines.push(...proseLines(label, text));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
128
233
|
return lines.join("\n");
|
|
129
234
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `decisions` command's text renderer — the deterministic chain
|
|
3
|
+
* decision → governed rows → projects → findings → fitness, one function a
|
|
4
|
+
* test drives for both the resolved and the unresolved walk.
|
|
5
|
+
*
|
|
6
|
+
* The renderer owns the invariant: a chain that could not walk every hop
|
|
7
|
+
* (`walk.ok === false`) renders a loud unresolved block, never a clean-looking
|
|
8
|
+
* chain. The walk (`../governance/decision-graph.mjs`) reports each
|
|
9
|
+
* unresolved reference with a reason; this face prints them all, so a reader
|
|
10
|
+
* sees which hop broke and why — the same refrain as every other surface in
|
|
11
|
+
* this wave ("an empty result is a claim, not a shrug").
|
|
12
|
+
*
|
|
13
|
+
* It is a pure function of the walk result + the record + the fitness level;
|
|
14
|
+
* it reads no files. The fitness line reuses the `fitness:` byte convention
|
|
15
|
+
* `./adr-text.mjs` establishes, so the two decision surfaces cannot disagree
|
|
16
|
+
* about what a level means.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A status label for a record, in the same terms `./adr-text.mjs` uses. */
|
|
20
|
+
function statusLabel(status) {
|
|
21
|
+
if (status === "accepted") return "accepted";
|
|
22
|
+
if (status === "active") return "active";
|
|
23
|
+
if (status === "superseded") return "superseded";
|
|
24
|
+
if (status === "retired") return "retired";
|
|
25
|
+
return "proposed";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `fitness:` line — byte-identical to `./adr-text.mjs`'s own, so a fitness
|
|
30
|
+
* level means the same thing on the `adr` face and here.
|
|
31
|
+
*
|
|
32
|
+
* @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function fitnessLine(fitness) {
|
|
36
|
+
if (fitness === undefined) return `fitness: ${"(not measured)"}`;
|
|
37
|
+
if (fitness.verified)
|
|
38
|
+
return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
|
|
39
|
+
if (typeof fitness.reason === "string") {
|
|
40
|
+
return `fitness: ${fitness.level} — ${fitness.reason}`;
|
|
41
|
+
}
|
|
42
|
+
return `fitness: ${fitness.level}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One prose field rendered for a record, first line carrying the label. */
|
|
46
|
+
function proseLines(label, text) {
|
|
47
|
+
const first = text.split("\n")[0];
|
|
48
|
+
const rest = text.split("\n").slice(1);
|
|
49
|
+
const out = [`${label}${first}`];
|
|
50
|
+
for (const line of rest) out.push(`${" ".repeat(label.length)}${line}`);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The prose fields of one record, in the order the wave-2 contract names them. */
|
|
55
|
+
const PROSE_FIELDS = [
|
|
56
|
+
["context: ", "context"],
|
|
57
|
+
["decision: ", "decision"],
|
|
58
|
+
["rationale: ", "rationale"],
|
|
59
|
+
["alternatives:", "alternatives"],
|
|
60
|
+
["consequences:", "consequences"],
|
|
61
|
+
["assumptions: ", "assumptions"],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** The kind label a governed row node renders as. */
|
|
65
|
+
const ROW_KIND_LABEL = {
|
|
66
|
+
intent: "intent row",
|
|
67
|
+
constraint: "constraint",
|
|
68
|
+
fitness: "fitness rule",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Renders the deterministic decision chain for one record.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {string} args.decisionId The id the caller asked about.
|
|
76
|
+
* @param {object} args.record The ADR record the chain is about.
|
|
77
|
+
* @param {object} args.walk The `forwardDecision` walk
|
|
78
|
+
* (`../governance/decision-graph.mjs`): `{ok, nodes, edges, unresolved}`.
|
|
79
|
+
* @param {{level: string, verified: boolean, reason?: string}|undefined}
|
|
80
|
+
* args.fitness The per-decision fitness level, computed by the caller.
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function formatDecisionChain({ decisionId, record, walk, fitness }) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
|
|
86
|
+
// An id the registry does not know is a named unknown, never a clean chain:
|
|
87
|
+
// the record is null, so there is no header to derive — the walk's
|
|
88
|
+
// unresolved block below is the whole answer. The renderer must not throw
|
|
89
|
+
// over a null record; that is precisely the case it exists to report.
|
|
90
|
+
const header =
|
|
91
|
+
record === null || record === undefined
|
|
92
|
+
? `${decisionId} (unknown)`
|
|
93
|
+
: `${record.id} (${statusLabel(record.status)})`;
|
|
94
|
+
lines.push(header, "-".repeat(header.length));
|
|
95
|
+
|
|
96
|
+
if (record !== null && record !== undefined) {
|
|
97
|
+
for (const [label, key] of PROSE_FIELDS) {
|
|
98
|
+
const text = record[key];
|
|
99
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
100
|
+
lines.push(...proseLines(label, text));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ((record.supersedes ?? []).length > 0) {
|
|
105
|
+
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
lines.push(fitnessLine(fitness));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The governed rows the decision attaches (decisionRef citations and
|
|
112
|
+
// bindings): the "who stands on this decision" half of the chain.
|
|
113
|
+
const rowNodes = walk.nodes.filter(
|
|
114
|
+
(node) => node.kind === "constraint" || node.kind === "intent" || node.kind === "fitness",
|
|
115
|
+
);
|
|
116
|
+
if (rowNodes.length > 0) {
|
|
117
|
+
lines.push("governs:");
|
|
118
|
+
for (const node of rowNodes) {
|
|
119
|
+
const governed = walk.edges
|
|
120
|
+
.filter((edge) => edge.kind === "governs" && edge.from === node.id)
|
|
121
|
+
.map((edge) => edge.to);
|
|
122
|
+
const kind = ROW_KIND_LABEL[node.kind] ?? node.kind;
|
|
123
|
+
const target = governed.length > 0 ? ` → ${governed.join(", ")}` : "";
|
|
124
|
+
lines.push(` ${node.id} (${kind})${target}`);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
lines.push("governs: (none — recorded but not enforceable)");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The evidence leg: every project the rows govern, and each project's
|
|
131
|
+
// current findings. A project with no findings is a fact, stated as such.
|
|
132
|
+
const projectNodes = walk.nodes.filter((node) => node.kind === "project");
|
|
133
|
+
if (projectNodes.length > 0) {
|
|
134
|
+
lines.push("evidence:");
|
|
135
|
+
for (const project of projectNodes) {
|
|
136
|
+
const findings = walk.nodes
|
|
137
|
+
.filter((node) => node.kind === "finding")
|
|
138
|
+
.filter((finding) =>
|
|
139
|
+
walk.edges.some(
|
|
140
|
+
(edge) => edge.kind === "finding" && edge.from === project.id && edge.to === finding.id,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
if (findings.length === 0) {
|
|
144
|
+
lines.push(` ${project.id}: no current findings`);
|
|
145
|
+
} else {
|
|
146
|
+
for (const finding of findings) {
|
|
147
|
+
const ruleId = finding.data?.ruleId ?? "";
|
|
148
|
+
lines.push(` ${project.id}: ${finding.label}${ruleId ? ` (${ruleId})` : ""}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The loud unresolved block — a walk that could not resolve every hop never
|
|
155
|
+
// reads as a clean chain.
|
|
156
|
+
if (!walk.ok) {
|
|
157
|
+
lines.push("unresolved:");
|
|
158
|
+
for (const entry of walk.unresolved) {
|
|
159
|
+
lines.push(` ${entry.ref}: ${entry.reason}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* shape `./text.mjs` uses for violations. Everything after it is indented,
|
|
8
8
|
* so the position line stands alone.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Eight things are printed, each with a reader in mind:
|
|
11
11
|
*
|
|
12
12
|
* - the import specifier and its kind (what was written)
|
|
13
13
|
* - the source project and its tags (who wrote it)
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* and a remediation line that is the author's declared guidance verbatim —
|
|
20
20
|
* or, when none is declared, an explicit pointer at the constraint row and
|
|
21
21
|
* its `decisionRef`, never a fix this renderer composed
|
|
22
|
+
* - for a matched row carrying a `decisionRef`: the governing decision's
|
|
23
|
+
* status and authority, its context/rationale prose, and its supersession
|
|
24
|
+
* lineage — or, when the ref or the registry cannot be resolved, a loud
|
|
25
|
+
* UNRESOLVED line naming the reason (an empty result here would read as
|
|
26
|
+
* "no decision behind this row", which is a different claim)
|
|
22
27
|
* - coverage information (whether this explanation is complete)
|
|
23
28
|
*
|
|
24
29
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
@@ -84,6 +89,69 @@ function formatMatchedConstraint(constraint) {
|
|
|
84
89
|
return formatConstraint(constraint);
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The status line for one governing decision: `id (status — authority)`.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} entry A resolved `"adr"` chain entry.
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function decisionStatusLine(entry) {
|
|
99
|
+
const authority = entry.authority ? " — has authority" : " — no authority";
|
|
100
|
+
return `${DETAIL}decision ${entry.record.id} (${entry.record.status}${authority})`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The first non-empty line of a prose field, trimmed to a display width.
|
|
105
|
+
* The excerpt is a pointer, never a paraphrase — a reader who needs the whole
|
|
106
|
+
* context opens the ADR.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function proseExcerpt(text) {
|
|
112
|
+
const first = text.split("\n").find((line) => line.trim() !== "") ?? "";
|
|
113
|
+
const trimmed = first.trim();
|
|
114
|
+
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}…` : trimmed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One decision-chain entry as report lines. The chain never renders blank:
|
|
119
|
+
* a ref the registry cannot resolve, and a ref that cannot be read at all,
|
|
120
|
+
* both render as UNRESOLVED naming the reason (AGENTS.md: "an empty result
|
|
121
|
+
* is a claim, not a shrug").
|
|
122
|
+
*
|
|
123
|
+
* @param {object} entry One `explanation.decisions` entry.
|
|
124
|
+
* @returns {string[]}
|
|
125
|
+
*/
|
|
126
|
+
function formatDecisionEntry(entry) {
|
|
127
|
+
if (entry.resolution === "unknown") {
|
|
128
|
+
return [`${DETAIL}decisionRef ${entry.ref} (UNRESOLVED — ${entry.reason})`];
|
|
129
|
+
}
|
|
130
|
+
if (entry.resolution === "fitness") {
|
|
131
|
+
return [`${DETAIL}decisionRef ${entry.ref} — a fitness rule this law declares`];
|
|
132
|
+
}
|
|
133
|
+
const lines = [decisionStatusLine(entry)];
|
|
134
|
+
if (typeof entry.record.context === "string") {
|
|
135
|
+
lines.push(`${DETAIL}context ${proseExcerpt(entry.record.context)}`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof entry.record.rationale === "string") {
|
|
138
|
+
lines.push(`${DETAIL}rationale ${proseExcerpt(entry.record.rationale)}`);
|
|
139
|
+
}
|
|
140
|
+
const supersedes = Array.isArray(entry.record.supersedes) ? entry.record.supersedes : [];
|
|
141
|
+
const supersededBy = Array.isArray(entry.record.supersededBy) ? entry.record.supersededBy : [];
|
|
142
|
+
if (supersedes.length > 0 || supersededBy.length > 0) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (supersedes.length > 0) parts.push(`supersedes: ${supersedes.join(", ")}`);
|
|
145
|
+
if (supersededBy.length > 0) parts.push(`superseded by: ${supersededBy.join(", ")}`);
|
|
146
|
+
lines.push(`${DETAIL}lineage ${parts.join(" · ")}`);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(`${DETAIL}lineage none — no supersession chain is recorded for this decision`);
|
|
149
|
+
}
|
|
150
|
+
for (const gap of entry.lineage?.unresolved ?? []) {
|
|
151
|
+
lines.push(`${DETAIL}lineage UNRESOLVED — ${gap.reason}`);
|
|
152
|
+
}
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
87
155
|
/**
|
|
88
156
|
* The whole explain report.
|
|
89
157
|
*
|
|
@@ -165,6 +233,14 @@ export function formatExplainReport({ explanation, coverage }) {
|
|
|
165
233
|
} else {
|
|
166
234
|
sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
|
|
167
235
|
}
|
|
236
|
+
|
|
237
|
+
// The "why does this constraint exist" chain — one block per governing
|
|
238
|
+
// decision the matched rows name. Additive: an explanation whose rows
|
|
239
|
+
// carry no `decisionRef` has no `decisions` list, and renders exactly as
|
|
240
|
+
// it did before this section existed.
|
|
241
|
+
for (const entry of explanation.decisions ?? []) {
|
|
242
|
+
sections.push(...formatDecisionEntry(entry));
|
|
243
|
+
}
|
|
168
244
|
}
|
|
169
245
|
|
|
170
246
|
// Coverage — same shape as every other command's footer.
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* `../commands/provenance-command.mjs` already resolved — no wall-clock time
|
|
10
10
|
* and no `localeCompare` enter here, matching every other report renderer.
|
|
11
11
|
*
|
|
12
|
+
* A fourth section carries the decision lifecycle: every recorded decision's
|
|
13
|
+
* status, authority, committed timeline, lineage, and bindings, attributed
|
|
14
|
+
* with WHO recorded it (the record file's own git history). A decision with
|
|
15
|
+
* no attributable history is named `no origin recorded — cannot attest`,
|
|
16
|
+
* never silently passed; the section renders only when at least one decision
|
|
17
|
+
* exists, the same "no fact, no claim" bargain the row arms state.
|
|
18
|
+
*
|
|
12
19
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
13
20
|
* wearing a formatter's name (`./README.md`).
|
|
14
21
|
*/
|
|
@@ -19,10 +26,17 @@
|
|
|
19
26
|
* rowsTotal: number,
|
|
20
27
|
* unattested: {kind: string, label: string, note: string}[],
|
|
21
28
|
* decisionRefTotal: number,
|
|
22
|
-
* unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[]
|
|
29
|
+
* unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[],
|
|
30
|
+
* decisionLifecycle?: {id: string, status: string, authority: boolean,
|
|
31
|
+
* created: string|null, updated: string|null, supersedes: string[],
|
|
32
|
+
* supersededBy: string[], bindings: string[],
|
|
33
|
+
* attribution: {createdBy: object|null, lastChangedBy: object|null}|null,
|
|
34
|
+
* attested: boolean, note: string|null}[]}} input
|
|
23
35
|
* `decisionRefTotal` is how many governance rows cite a `decisionRef` at
|
|
24
36
|
* all — the resolution section renders only when it is non-zero, the same
|
|
25
37
|
* "no fact, no claim" bargain every optional axis in this tool states.
|
|
38
|
+
* `decisionLifecycle` (optional, default `[]`) is the decision-lifecycle
|
|
39
|
+
* section — it renders only when non-empty.
|
|
26
40
|
* @returns {string}
|
|
27
41
|
*/
|
|
28
42
|
export function formatProvenanceReport({
|
|
@@ -32,6 +46,7 @@ export function formatProvenanceReport({
|
|
|
32
46
|
unattested,
|
|
33
47
|
decisionRefTotal,
|
|
34
48
|
unresolvedDecisionRefs,
|
|
49
|
+
decisionLifecycle = [],
|
|
35
50
|
}) {
|
|
36
51
|
const attestedCount = rowsTotal - unattested.length;
|
|
37
52
|
const text = [];
|
|
@@ -74,5 +89,56 @@ export function formatProvenanceReport({
|
|
|
74
89
|
);
|
|
75
90
|
}
|
|
76
91
|
}
|
|
92
|
+
// PR E — the decision lifecycle. "No fact, no claim", like the resolution
|
|
93
|
+
// arm: the section renders only when the registry holds at least one
|
|
94
|
+
// decision, and a decision with no attributable history is listed under a
|
|
95
|
+
// cannot-attest heading, never silently passed.
|
|
96
|
+
const decidedCount = decisionLifecycle.length;
|
|
97
|
+
if (decidedCount > 0) {
|
|
98
|
+
const attributedCount = decisionLifecycle.filter((d) => d.attested).length;
|
|
99
|
+
const unattributedCount = decidedCount - attributedCount;
|
|
100
|
+
text.push(
|
|
101
|
+
`decisions ${decidedCount} recorded — ${attributedCount} attributed, ` +
|
|
102
|
+
`${unattributedCount} without attribution`,
|
|
103
|
+
);
|
|
104
|
+
for (const decision of decisionLifecycle) {
|
|
105
|
+
if (!decision.attested) continue;
|
|
106
|
+
const createdBy = decision.attribution?.createdBy ?? null;
|
|
107
|
+
const lastChangedBy = decision.attribution?.lastChangedBy ?? null;
|
|
108
|
+
const facts = [
|
|
109
|
+
createdBy === null
|
|
110
|
+
? "created — no origin recorded — cannot attest"
|
|
111
|
+
: `created by ${createdBy.by} on ${createdBy.on}`,
|
|
112
|
+
lastChangedBy === null
|
|
113
|
+
? "changed — no origin recorded — cannot attest"
|
|
114
|
+
: `changed by ${lastChangedBy.by} on ${lastChangedBy.on}`,
|
|
115
|
+
];
|
|
116
|
+
if (decision.supersedes.length > 0) {
|
|
117
|
+
facts.push(`supersedes ${decision.supersedes.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
if (decision.supersededBy.length > 0) {
|
|
120
|
+
facts.push(`superseded by ${decision.supersededBy.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
if (decision.bindings.length > 0) {
|
|
123
|
+
facts.push(`binds ${decision.bindings.join(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
if (decision.created !== null || decision.updated !== null) {
|
|
126
|
+
facts.push(`timeline ${decision.created ?? "?"} → ${decision.updated ?? "?"}`);
|
|
127
|
+
}
|
|
128
|
+
text.push(` ${decision.status.padEnd(11)} ${decision.id} ${facts.join("; ")}`);
|
|
129
|
+
}
|
|
130
|
+
if (unattributedCount > 0) {
|
|
131
|
+
text.push("unattributed lifecycle (no origin recorded — cannot attest):");
|
|
132
|
+
for (const decision of decisionLifecycle) {
|
|
133
|
+
if (!decision.attested) text.push(` ${decision.id}`);
|
|
134
|
+
}
|
|
135
|
+
text.push(`${unattributedCount} of them carry no recorded origin behind their lifecycle`);
|
|
136
|
+
} else {
|
|
137
|
+
text.push(
|
|
138
|
+
`✔ every decision's lifecycle is attributed — each change names who ` +
|
|
139
|
+
`recorded it and with what tool`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
77
143
|
return text.join("\n");
|
|
78
144
|
}
|
|
@@ -155,7 +155,13 @@ function fitnessLines(fitness) {
|
|
|
155
155
|
*/
|
|
156
156
|
function decisionLines(decisions) {
|
|
157
157
|
const lines = [surfaceLine("decisions", decisions)];
|
|
158
|
-
|
|
158
|
+
// "does not resolve" is a claim about the REF — that nothing in the
|
|
159
|
+
// workspace answers to it. When the registry itself could not be read, that
|
|
160
|
+
// claim is not one this run established: it could not look. The two get
|
|
161
|
+
// different sentences, for the same reason `unknown` and `not_applicable`
|
|
162
|
+
// do (`count: null` is how the command says the registry was unreadable).
|
|
163
|
+
const registryUnread = decisions.registry.count === null;
|
|
164
|
+
if (!registryUnread) {
|
|
159
165
|
lines.push(
|
|
160
166
|
` ${decisions.registry.count} record${decisions.registry.count === 1 ? "" : "s"} in ` +
|
|
161
167
|
`${decisions.registry.dir}/`,
|
|
@@ -165,29 +171,58 @@ function decisionLines(decisions) {
|
|
|
165
171
|
record.bindings.length > 0
|
|
166
172
|
? `binds ${record.bindings.join(", ")}`
|
|
167
173
|
: "binds nothing — not yet enforceable";
|
|
174
|
+
// The record row is byte-identical to the pre-wave-2 line; the fitness
|
|
175
|
+
// and "stands on" lines below it are additive surface (contract items
|
|
176
|
+
// 3 + F/B), never a rewrite of what a longer-maintained reader expects.
|
|
168
177
|
lines.push(` ${record.id} (${record.status}) ${binds}`);
|
|
178
|
+
if (record.fitness !== undefined) {
|
|
179
|
+
const fit = record.fitness;
|
|
180
|
+
const reasonString =
|
|
181
|
+
typeof fit.reason === "string"
|
|
182
|
+
? ` — ${fit.reason}`
|
|
183
|
+
: fit.verified
|
|
184
|
+
? " — verified true: bound constraints resolve and pass"
|
|
185
|
+
: "";
|
|
186
|
+
lines.push(` fitness: ${fit.level}${reasonString}`);
|
|
187
|
+
}
|
|
188
|
+
if (record.authority) {
|
|
189
|
+
if (record.constraints.length > 0) {
|
|
190
|
+
const refs = record.constraints.map((c) => `${c.kind} ${c.label}`).join(", ");
|
|
191
|
+
lines.push(` stands on: ${refs}`);
|
|
192
|
+
} else {
|
|
193
|
+
lines.push(" stands on: no governed row cites this decision");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
169
196
|
}
|
|
170
197
|
}
|
|
198
|
+
// The citations render even when the registry could not be read: that is
|
|
199
|
+
// exactly the case where "does not resolve" must NOT be claimed and the
|
|
200
|
+
// registry-unreadable sentence must. Skipping them here would print the
|
|
201
|
+
// "unknown" verdict with no citation lines to back it — the silent direction.
|
|
171
202
|
if (decisions.citations.length === 0) {
|
|
172
203
|
lines.push(" no governed row cites a decisionRef");
|
|
173
|
-
|
|
204
|
+
} else {
|
|
205
|
+
for (const citation of decisions.citations) {
|
|
206
|
+
const target =
|
|
207
|
+
citation.resolution === "adr" && citation.adr !== null
|
|
208
|
+
? `${citation.adr.id} (${citation.adr.status})`
|
|
209
|
+
: citation.resolution === "fitness"
|
|
210
|
+
? `${citation.decisionRef} — a fitness rule this law declares`
|
|
211
|
+
: registryUnread
|
|
212
|
+
? `${citation.decisionRef} — unresolved: the decision registry could not be read`
|
|
213
|
+
: `${citation.decisionRef} — does not resolve`;
|
|
214
|
+
lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
|
|
215
|
+
}
|
|
174
216
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
? `${citation.adr.id} (${citation.adr.status})`
|
|
185
|
-
: citation.resolution === "fitness"
|
|
186
|
-
? `${citation.decisionRef} — a fitness rule this law declares`
|
|
187
|
-
: registryUnread
|
|
188
|
-
? `${citation.decisionRef} — unresolved: the decision registry could not be read`
|
|
189
|
-
: `${citation.decisionRef} — does not resolve`;
|
|
190
|
-
lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
|
|
217
|
+
if (
|
|
218
|
+
!registryUnread &&
|
|
219
|
+
Array.isArray(decisions.unresolvedDecisionRefs) &&
|
|
220
|
+
decisions.unresolvedDecisionRefs.length > 0
|
|
221
|
+
) {
|
|
222
|
+
lines.push(" unresolved decisionRefs:");
|
|
223
|
+
for (const ref of decisions.unresolvedDecisionRefs) {
|
|
224
|
+
lines.push(` ${ref.kind} ${ref.label} → ${ref.reason}`);
|
|
225
|
+
}
|
|
191
226
|
}
|
|
192
227
|
return lines;
|
|
193
228
|
}
|