@ecoma-io/archkeep 0.16.1 → 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/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/commands/explain.mjs
CHANGED
|
@@ -44,6 +44,27 @@
|
|
|
44
44
|
* plugin but whose tracked files include polyglot manifests under project
|
|
45
45
|
* roots, `explain` refuses loudly rather than explaining a judgment from a
|
|
46
46
|
* graph whose edges silently under-represent the real architecture.
|
|
47
|
+
*
|
|
48
|
+
* ## The "why does this constraint exist" chain
|
|
49
|
+
*
|
|
50
|
+
* A constraint row that carries a `decisionRef` claims a governing decision —
|
|
51
|
+
* an ADR record, or a rule/fitness id the law declares. `explain` resolves
|
|
52
|
+
* that claim against the workspace's ADR registry and walks it through the
|
|
53
|
+
* governance graph (`../governance/decision-graph.mjs`), surfacing the
|
|
54
|
+
* decision's status and authority, its rationale/context prose, and its
|
|
55
|
+
* supersession lineage — not just the pointer. It is read-only, exactly like
|
|
56
|
+
* the rest of `explain`: an agent is shown WHY the row exists, never handed
|
|
57
|
+
* the authority to change the decision or the verdict.
|
|
58
|
+
*
|
|
59
|
+
* Resolution never changes the exit code and never invents a fact. A
|
|
60
|
+
* `decisionRef` that resolves to nothing — or a registry that cannot be read
|
|
61
|
+
* at all — renders as UNRESOLVED, the same loud wording `check`/`report`
|
|
62
|
+
* use; a `rule:`/`fitness:`-shaped ref that the law declares renders as
|
|
63
|
+
* exactly that, a fitness rule this law declares. Only an unresolved site or
|
|
64
|
+
* incomplete analysis moves `status`, so an unchanged tree pays no changed
|
|
65
|
+
* exit code. The registry is read lazily, only when a matched row actually
|
|
66
|
+
* carries a `decisionRef` — the common case (no `docs/adr/` adopted yet)
|
|
67
|
+
* pays no extra read.
|
|
47
68
|
*/
|
|
48
69
|
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
49
70
|
import { UsageError } from "../errors.mjs";
|
|
@@ -53,6 +74,15 @@ import { findProjectForPath, createProjectRootMappings } from "../rules/specifie
|
|
|
53
74
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
54
75
|
import { formatExplainReport } from "../report/explain-text.mjs";
|
|
55
76
|
import { resolveProvenance } from "./provenance.mjs";
|
|
77
|
+
import { readAdrContext } from "./adr.mjs";
|
|
78
|
+
import { lineage } from "../governance/decision-graph.mjs";
|
|
79
|
+
import { unresolvedDecisionRefNote } from "./provenance-command.mjs";
|
|
80
|
+
import {
|
|
81
|
+
declaredFitnessNames,
|
|
82
|
+
hasAuthority,
|
|
83
|
+
resolveDecisionRef,
|
|
84
|
+
stripAdrPrefix,
|
|
85
|
+
} from "../governance/adr-registry.mjs";
|
|
56
86
|
|
|
57
87
|
/**
|
|
58
88
|
* Parses a `file:line:column` site string into its components.
|
|
@@ -138,6 +168,102 @@ export function findSite(parsed, imports) {
|
|
|
138
168
|
function findMatchingConstraints(depConstraints, sourceProjectNode) {
|
|
139
169
|
return findConstraintsFor(depConstraints, sourceProjectNode);
|
|
140
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Resolves the "why does this constraint exist" chain for every distinct
|
|
173
|
+
* `decisionRef` the matched constraint rows carry, in first-sight order.
|
|
174
|
+
*
|
|
175
|
+
* A `decisionRef` names the ADR (or rule/fitness id) that authorizes the row.
|
|
176
|
+
* Resolution is the registry's own (`resolveDecisionRef`); the walk that
|
|
177
|
+
* surfaces status/authority/rationale/context and lineage is the governance
|
|
178
|
+
* graph's (`lineage`). Failures are named, never silent:
|
|
179
|
+
*
|
|
180
|
+
* - a registry that cannot be read resolves nothing — every ref is `unknown`
|
|
181
|
+
* with the read failure as its reason, the same posture `report` takes for
|
|
182
|
+
* the same condition;
|
|
183
|
+
* - a ref that resolves to no ADR, rule, or fitness record the registry knows
|
|
184
|
+
* is `unknown`, with `unresolvedDecisionRefNote`'s shared wording.
|
|
185
|
+
*
|
|
186
|
+
* Deterministic: matched-row order, distinct refs deduplicated on first
|
|
187
|
+
* sight, and the walk's own registry (byte-sorted filename) order. Empty when
|
|
188
|
+
* no matched row carries a `decisionRef` — the caller then changes no byte of
|
|
189
|
+
* its explanation.
|
|
190
|
+
*
|
|
191
|
+
* @param {object[]} matchedConstraints The constraint rows that matched the
|
|
192
|
+
* explained site.
|
|
193
|
+
* @param {string} root The workspace root, for the registry read.
|
|
194
|
+
* @param {string[]} tracked Tracked files, for the registry read.
|
|
195
|
+
* @param {object} config The loaded boundary config, for the declared-name
|
|
196
|
+
* half of `resolveDecisionRef`.
|
|
197
|
+
* @returns {object[]} One entry per distinct ref: `resolution` is the
|
|
198
|
+
* registry's own `"adr" | "fitness" | "unknown"`; an `"adr"` entry carries
|
|
199
|
+
* the record's authority and prose facts plus the lineage walk.
|
|
200
|
+
*/
|
|
201
|
+
function resolveDecisionChains(matchedConstraints, root, tracked, config) {
|
|
202
|
+
const refs = [];
|
|
203
|
+
for (const row of matchedConstraints) {
|
|
204
|
+
if (typeof row?.decisionRef !== "string" || row.decisionRef.trim() === "") continue;
|
|
205
|
+
if (!refs.includes(row.decisionRef)) refs.push(row.decisionRef);
|
|
206
|
+
}
|
|
207
|
+
if (refs.length === 0) return [];
|
|
208
|
+
|
|
209
|
+
let registry = null;
|
|
210
|
+
let registryReason = null;
|
|
211
|
+
try {
|
|
212
|
+
registry = readAdrContext(root, { tracked });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
registryReason = String(error?.message ?? error);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const knownFitness = declaredFitnessNames(config);
|
|
218
|
+
return refs.map((ref) => {
|
|
219
|
+
if (registry === null) {
|
|
220
|
+
return {
|
|
221
|
+
ref,
|
|
222
|
+
resolution: "unknown",
|
|
223
|
+
reason: `the decision registry could not be read: ${registryReason}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const resolution = resolveDecisionRef(registry.byId, knownFitness, ref);
|
|
227
|
+
if (resolution === "adr") {
|
|
228
|
+
const record = registry.byId.get(stripAdrPrefix(ref));
|
|
229
|
+
const recordFacts = { id: record.id, status: record.status };
|
|
230
|
+
for (const key of [
|
|
231
|
+
"created",
|
|
232
|
+
"updated",
|
|
233
|
+
"context",
|
|
234
|
+
"decision",
|
|
235
|
+
"rationale",
|
|
236
|
+
"alternatives",
|
|
237
|
+
"consequences",
|
|
238
|
+
"assumptions",
|
|
239
|
+
]) {
|
|
240
|
+
if (record[key] !== undefined) recordFacts[key] = record[key];
|
|
241
|
+
}
|
|
242
|
+
recordFacts.supersedes = record.supersedes;
|
|
243
|
+
recordFacts.supersededBy = record.supersededBy;
|
|
244
|
+
recordFacts.bindings = record.bindings;
|
|
245
|
+
return {
|
|
246
|
+
ref,
|
|
247
|
+
resolution: "adr",
|
|
248
|
+
authority: hasAuthority(record.status),
|
|
249
|
+
record: recordFacts,
|
|
250
|
+
lineage: lineage(record.id, {
|
|
251
|
+
records: registry.records,
|
|
252
|
+
byId: registry.byId,
|
|
253
|
+
// The lineage walk reads only `records`/`byId`; the graph walk's
|
|
254
|
+
// full shape is supplied so the call satisfies the type rather than
|
|
255
|
+
// leaning on an unchecked subset.
|
|
256
|
+
knownFitness,
|
|
257
|
+
rows: [],
|
|
258
|
+
}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (resolution === "fitness") {
|
|
262
|
+
return { ref, resolution: "fitness" };
|
|
263
|
+
}
|
|
264
|
+
return { ref, resolution: "unknown", reason: unresolvedDecisionRefNote(ref) };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
141
267
|
|
|
142
268
|
/**
|
|
143
269
|
* Runs the `explain` command: resolves the command context, finds the import
|
|
@@ -353,6 +479,15 @@ export function explainCommand(site, commandContext, config) {
|
|
|
353
479
|
unresolvable: false,
|
|
354
480
|
reason: null,
|
|
355
481
|
};
|
|
482
|
+
// The "why does this constraint exist" chain — the governing decision(s)
|
|
483
|
+
// behind the rows that matched this site, resolved through the ADR registry
|
|
484
|
+
// and walked through the governance graph (`resolveDecisionChains`'s own
|
|
485
|
+
// header argues the fail-closed wording and the determinism). Additive: an
|
|
486
|
+
// explanation whose rows carry no `decisionRef` keeps every byte it had.
|
|
487
|
+
const decisions = resolveDecisionChains(matchedConstraints, root, commandContext.tracked, config);
|
|
488
|
+
if (decisions.length > 0) {
|
|
489
|
+
explanation.decisions = decisions;
|
|
490
|
+
}
|
|
356
491
|
|
|
357
492
|
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
358
493
|
const coverage = {
|
|
@@ -375,6 +510,7 @@ export function explainCommand(site, commandContext, config) {
|
|
|
375
510
|
matchedConstraints,
|
|
376
511
|
violations,
|
|
377
512
|
verdict,
|
|
513
|
+
...(decisions.length > 0 ? { decisions } : {}),
|
|
378
514
|
};
|
|
379
515
|
|
|
380
516
|
const envelope = jsonEnvelope({
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `decisionRef` any of them cite actually resolves to a recorded decision.
|
|
5
5
|
*
|
|
6
6
|
* Provenance is descriptive, exactly like `graph`/`diff`/`drift`: it never
|
|
7
|
-
* changes a verdict, so it never exits 1. It answers
|
|
7
|
+
* changes a verdict, so it never exits 1. It answers four questions:
|
|
8
8
|
*
|
|
9
9
|
* 1. **Repository provenance** — the git commit, remote, and dirty state of the
|
|
10
10
|
* tree this run judged, through the shared `resolveProvenance`
|
|
@@ -28,7 +28,20 @@
|
|
|
28
28
|
* caller, and a row bound to a nonexistent ADR id read as legitimately
|
|
29
29
|
* documented everywhere it was rendered. A row with no `decisionRef` is
|
|
30
30
|
* not a finding here; a row whose `decisionRef` names nothing the registry
|
|
31
|
-
*
|
|
31
|
+
* 4. **Decision lifecycle provenance** — the provenance of the ADR records
|
|
32
|
+
* themselves: for every decision in the registry, who created it and who
|
|
33
|
+
* last changed it (read from the record file's own git history as
|
|
34
|
+
* committed static facts — the author and author-date of the first and
|
|
35
|
+
* last commits that touched `docs/adr/<id>.md`), which decision replaced
|
|
36
|
+
* which, what constraints it binds, and the committed evidence of its
|
|
37
|
+
* current state. The report is read-only: it surfaces the record, it never
|
|
38
|
+
* computes or judges a verdict. A decision whose record file has no
|
|
39
|
+
* attributable history is flagged `no origin recorded — cannot attest`,
|
|
40
|
+
* never silently passed. (PR E —
|
|
41
|
+
* https://github.com/ecoma-io/archkeep/issues/491.)
|
|
42
|
+
* `recordOrigin`'s `on` never enters here: attribution reads a committed
|
|
43
|
+
* author date, it does not produce one, so the injected clock stays out of
|
|
44
|
+
* the read path by construction.
|
|
32
45
|
*
|
|
33
46
|
* ## Determinism
|
|
34
47
|
*
|
|
@@ -53,15 +66,22 @@
|
|
|
53
66
|
*
|
|
54
67
|
* An empty `unattested` list must mean exactly "every governance row carries
|
|
55
68
|
* an origin", an empty `unresolvedDecisionRefs` list must mean exactly "every
|
|
56
|
-
* decisionRef citation resolves", and
|
|
69
|
+
* decisionRef citation resolves", and an empty `decisionLifecycle` list must
|
|
70
|
+
* mean exactly "the registry holds no decisions" — and neither means the
|
|
71
|
+
* other.
|
|
57
72
|
*/
|
|
58
73
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
59
74
|
import { formatProvenanceReport } from "../report/provenance-text.mjs";
|
|
60
75
|
import { loadIntent } from "../architecture-intent/model.mjs";
|
|
61
76
|
import { loadBoundaryConfig } from "../config.mjs";
|
|
62
|
-
import { resolveProvenance } from "./provenance.mjs";
|
|
77
|
+
import { resolveFileAttribution, resolveProvenance } from "./provenance.mjs";
|
|
63
78
|
import { readAdrContext } from "./adr.mjs";
|
|
64
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
ADR_DIR,
|
|
81
|
+
declaredFitnessNames,
|
|
82
|
+
hasAuthority,
|
|
83
|
+
unresolvedDecisionRefRows,
|
|
84
|
+
} from "../governance/adr-registry.mjs";
|
|
65
85
|
|
|
66
86
|
/**
|
|
67
87
|
* Whether a row declares a governance origin (`origin.by`/`origin.tool`).
|
|
@@ -169,28 +189,41 @@ export function unresolvedDecisionRefNote(decisionRef) {
|
|
|
169
189
|
}
|
|
170
190
|
|
|
171
191
|
/**
|
|
172
|
-
* The provenance verdict:
|
|
192
|
+
* The provenance verdict: four answer surfaces, each fail-closed.
|
|
173
193
|
*
|
|
174
194
|
* `repo` is the git provenance, `established` whether git could answer,
|
|
175
|
-
* `rows`/`unattested` the per-row decision provenance,
|
|
195
|
+
* `rows`/`unattested` the per-row decision provenance,
|
|
176
196
|
* `unresolvedDecisionRefs` every row whose `decisionRef` cites no ADR, rule,
|
|
177
|
-
* or fitness record this workspace's registry knows
|
|
178
|
-
*
|
|
179
|
-
*
|
|
197
|
+
* or fitness record this workspace's registry knows, and `decisionLifecycle`
|
|
198
|
+
* the attribution of every recorded decision (its ADR record file's git
|
|
199
|
+
* history) plus its committed status, authority, timeline, lineage, and
|
|
200
|
+
* bindings. All four are findings about *documentation*, not about the
|
|
201
|
+
* architecture — this command never changes what `check` or `drift` decide,
|
|
202
|
+
* and it exits 0 when it completes.
|
|
180
203
|
*
|
|
181
204
|
* @param {{root: string, tracked: string[], provider: string, marker: string,
|
|
182
205
|
* options: {boundaryConfig: string|object, inline?: boolean}}} commandContext
|
|
183
206
|
* From `resolveCommandContext`.
|
|
184
207
|
* @param {{loadIntentOverride?: (root: string, io: object) => Promise<object>,
|
|
185
208
|
* loadConfigOverride?: (root: string, boundaryConfig: string) => Promise<object>,
|
|
186
|
-
* loadAdrRegistryOverride?: typeof import("../governance/adr-registry.mjs").loadAdrRegistry
|
|
187
|
-
*
|
|
188
|
-
*
|
|
209
|
+
* loadAdrRegistryOverride?: typeof import("../governance/adr-registry.mjs").loadAdrRegistry,
|
|
210
|
+
* fileAttribution?: (root: string, file: string) =>
|
|
211
|
+
* {createdBy: import("../governance/provenance-record.mjs").OriginRecord,
|
|
212
|
+
* lastChangedBy: import("../governance/provenance-record.mjs").OriginRecord} | null}}
|
|
213
|
+
* [io] `loadAdrRegistryOverride` is forwarded to `readAdrContext`
|
|
214
|
+
* (`./adr.mjs`) unchanged; `fileAttribution` defaults to
|
|
215
|
+
* `resolveFileAttribution` (`./provenance.mjs`) and reads a record file's
|
|
216
|
+
* commit history as committed static facts.
|
|
189
217
|
* @returns {Promise<{status: "ok", repo: {commit: string|null, remote: string|null,
|
|
190
218
|
* dirty: boolean|null, established: boolean},
|
|
191
219
|
* rows: {kind: string, attested: boolean, origin: object|null}[],
|
|
192
220
|
* unattested: {kind: string, label: string, note: string}[],
|
|
193
221
|
* unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[],
|
|
222
|
+
* decisionLifecycle: {id: string, status: string, authority: boolean,
|
|
223
|
+
* created: string|null, updated: string|null, supersedes: string[],
|
|
224
|
+
* supersededBy: string[], bindings: string[],
|
|
225
|
+
* attribution: {createdBy: object|null, lastChangedBy: object|null},
|
|
226
|
+
* attested: boolean, note: string|null}[],
|
|
194
227
|
* report: {text: string, json: string}}>}
|
|
195
228
|
* @throws {Error} on a malformed intent, boundary config, or ADR registry —
|
|
196
229
|
* exit 3, the loud refusal every command that reads them makes.
|
|
@@ -274,6 +307,38 @@ export async function provenanceCommand(commandContext, io = {}) {
|
|
|
274
307
|
note: unresolvedDecisionRefNote(decisionRef),
|
|
275
308
|
}));
|
|
276
309
|
|
|
310
|
+
// PR E — decision lifecycle provenance: every recorded decision's current
|
|
311
|
+
// state (status, authority, committed timeline, lineage, bindings),
|
|
312
|
+
// attributed with WHO recorded it. Attribution reads the record file's own
|
|
313
|
+
// git history as committed static facts (first commit = createdBy, last =
|
|
314
|
+
// lastChangedBy) — a read, never a produced `on`, so no clock and no
|
|
315
|
+
// wall-clock time enter. When git cannot answer, the fact is named
|
|
316
|
+
// cannot-attest below, never silently passed.
|
|
317
|
+
const attributor = io.fileAttribution ?? resolveFileAttribution;
|
|
318
|
+
const decisionLifecycle = [];
|
|
319
|
+
for (const record of adrContext.records) {
|
|
320
|
+
const attribution = attributor(root, `${ADR_DIR}/${record.id}.md`);
|
|
321
|
+
const supersedes = Array.isArray(record.supersedes) ? record.supersedes : [];
|
|
322
|
+
const supersededBy = Array.isArray(record.supersededBy) ? record.supersededBy : [];
|
|
323
|
+
const bindings = Array.isArray(record.bindings) ? record.bindings : [];
|
|
324
|
+
decisionLifecycle.push({
|
|
325
|
+
id: record.id,
|
|
326
|
+
status: record.status,
|
|
327
|
+
authority: hasAuthority(record.status),
|
|
328
|
+
created: record.created ?? null,
|
|
329
|
+
updated: record.updated ?? null,
|
|
330
|
+
supersedes,
|
|
331
|
+
supersededBy,
|
|
332
|
+
bindings,
|
|
333
|
+
attribution: {
|
|
334
|
+
createdBy: attribution?.createdBy ?? null,
|
|
335
|
+
lastChangedBy: attribution?.lastChangedBy ?? null,
|
|
336
|
+
},
|
|
337
|
+
attested: attribution !== null,
|
|
338
|
+
note: attribution === null ? "no origin recorded — cannot attest" : null,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
277
342
|
const establishment = repo !== null;
|
|
278
343
|
const repoResult = establishment ? repo : { commit: null, remote: null, dirty: null };
|
|
279
344
|
const rowsTotal = rowList.length;
|
|
@@ -292,6 +357,7 @@ export async function provenanceCommand(commandContext, io = {}) {
|
|
|
292
357
|
unattested,
|
|
293
358
|
decisionRefTotal: decisionRefRows.length,
|
|
294
359
|
unresolvedDecisionRefs,
|
|
360
|
+
decisionLifecycle,
|
|
295
361
|
});
|
|
296
362
|
|
|
297
363
|
const context = {
|
|
@@ -314,9 +380,10 @@ export async function provenanceCommand(commandContext, io = {}) {
|
|
|
314
380
|
blindSpots: [],
|
|
315
381
|
notes: [],
|
|
316
382
|
},
|
|
317
|
-
// The
|
|
318
|
-
// order. `unresolvedDecisionRefs`
|
|
319
|
-
// an empty array is itself the claim
|
|
383
|
+
// The four answer surfaces; `result.rows` preserves the canonical row
|
|
384
|
+
// order. `unresolvedDecisionRefs` and `decisionLifecycle` are both
|
|
385
|
+
// unconditional, like `unattested` — an empty array is itself the claim
|
|
386
|
+
// "every citation resolves"/"the registry holds no decisions", never an
|
|
320
387
|
// omitted key that would leave a reader unable to tell "checked, clean"
|
|
321
388
|
// from "never checked" (`../../../../AGENTS.md`).
|
|
322
389
|
result: {
|
|
@@ -329,13 +396,14 @@ export async function provenanceCommand(commandContext, io = {}) {
|
|
|
329
396
|
})),
|
|
330
397
|
unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
|
|
331
398
|
unresolvedDecisionRefs,
|
|
399
|
+
decisionLifecycle,
|
|
332
400
|
},
|
|
333
401
|
});
|
|
334
402
|
|
|
335
403
|
return {
|
|
336
404
|
status: "ok",
|
|
337
405
|
repo: { ...repoResult, established: establishment },
|
|
338
|
-
// The
|
|
406
|
+
// The four answer surfaces, also available readably (not only inside the
|
|
339
407
|
// envelope) so `cli.mjs` can drive the text report from the same facts.
|
|
340
408
|
rows: rowList.map(({ kind, attested, origin }) => ({
|
|
341
409
|
kind,
|
|
@@ -344,6 +412,7 @@ export async function provenanceCommand(commandContext, io = {}) {
|
|
|
344
412
|
})),
|
|
345
413
|
unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
|
|
346
414
|
unresolvedDecisionRefs,
|
|
415
|
+
decisionLifecycle,
|
|
347
416
|
report: {
|
|
348
417
|
text: reportText,
|
|
349
418
|
json: renderJson(envelope),
|
|
@@ -120,3 +120,63 @@ export function resolveProvenance(root) {
|
|
|
120
120
|
|
|
121
121
|
return { commit, remote, dirty };
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolves the git attribution of ONE file under `root`: the origin that
|
|
125
|
+
* CREATED it and the origin that LAST CHANGED it, read from commit metadata.
|
|
126
|
+
*
|
|
127
|
+
* Both are committed static facts — an author name, email, and author date
|
|
128
|
+
* frozen in the repository's history — so the answer is byte-identical across
|
|
129
|
+
* every run over the same tree, and no wall-clock time and no injected clock
|
|
130
|
+
* ever enter (the determinism rule `resolveProvenance` states above). The
|
|
131
|
+
* origin shape is the same one a governance row carries: `by` names the
|
|
132
|
+
* author, `tool` is `"git"` (the commit records the change; the tool behind
|
|
133
|
+
* the commit is unknowable from the bytes), and `on` is the commit's author
|
|
134
|
+
* date — READ, not produced, which is exactly the read surface
|
|
135
|
+
* `../governance/provenance-record.mjs` already documents: an `on` is only
|
|
136
|
+
* ever written by `recordOrigin`, and a committed `on` is its own read fact.
|
|
137
|
+
*
|
|
138
|
+
* Returns `null` when git cannot answer (not a repository) or the file has
|
|
139
|
+
* never been committed — the reader then renders
|
|
140
|
+
* `no origin recorded — cannot attest` rather than pretending an author.
|
|
141
|
+
* A file whose history is missing is a legitimate "no claim" state, not the
|
|
142
|
+
* loud could-not-look a commitless repository is: `resolveProvenance` owns
|
|
143
|
+
* that refusal, and this reads only after a repository is established.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} root The workspace root directory.
|
|
146
|
+
* @param {string} file The tracked file whose history is attributed, relative
|
|
147
|
+
* to `root` (e.g. `docs/adr/0001-boundary-levels.md`).
|
|
148
|
+
* @returns {{createdBy: import("../governance/provenance-record.mjs").OriginRecord,
|
|
149
|
+
* lastChangedBy: import("../governance/provenance-record.mjs").OriginRecord} | null}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveFileAttribution(root, file) {
|
|
152
|
+
// First, the "is this even a git repository at all" question — the same
|
|
153
|
+
// probe `resolveProvenance` runs, so a non-repository is a clean `null`
|
|
154
|
+
// (no claim) rather than a thrown error here.
|
|
155
|
+
try {
|
|
156
|
+
runProcess("git", ["rev-parse", "--is-inside-work-tree"], root);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
let log;
|
|
161
|
+
try {
|
|
162
|
+
// Oldest-first (`--reverse`), so the first line is the creator. `%aI` is
|
|
163
|
+
// the strict ISO-8601 author date (no locale-dependent formatting), and
|
|
164
|
+
// NUL separators keep a name containing spaces or a newline parseable.
|
|
165
|
+
// `--` ends option parsing so a file name beginning with `-` is safe.
|
|
166
|
+
log = runProcess("git", ["log", "--reverse", "--format=%an%x00%ae%x00%aI", "--", file], root);
|
|
167
|
+
} catch {
|
|
168
|
+
// Not a repository, or the file path is unreadable — either way, no
|
|
169
|
+
// attributable history to claim. Null, not a thrown error.
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const lines = log.split("\n").filter((line) => line.length > 0);
|
|
173
|
+
if (lines.length === 0) return null; // the file was never committed
|
|
174
|
+
const parse = (line) => {
|
|
175
|
+
const [name, email, on] = line.split("\u0000");
|
|
176
|
+
return { by: `${name} <${email}>`, tool: "git", on };
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
createdBy: parse(lines[0]),
|
|
180
|
+
lastChangedBy: parse(lines[lines.length - 1]),
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/commands/report.mjs
CHANGED
|
@@ -126,6 +126,8 @@ import {
|
|
|
126
126
|
resolveDecisionRef,
|
|
127
127
|
stripAdrPrefix,
|
|
128
128
|
} from "../governance/adr-registry.mjs";
|
|
129
|
+
import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
|
|
130
|
+
import { hasAuthority, stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
|
|
129
131
|
|
|
130
132
|
/**
|
|
131
133
|
* The message a thrown refusal carries, as the report's reason for a surface
|
|
@@ -442,6 +444,24 @@ export async function reportCommand(commandContext, io = {}) {
|
|
|
442
444
|
// Those two must never read alike (`../report/report-text.mjs` renders both
|
|
443
445
|
// and says the same).
|
|
444
446
|
const unresolvedCitation = citations.some((citation) => citation.resolution === "unknown");
|
|
447
|
+
// The per-record fitness derivation. It is the same function `adr` runs
|
|
448
|
+
// over the same registry, folded here with THIS run's declared gates: the
|
|
449
|
+
// verdicts of the `fitness` surface above (same `{name, verdict}` shape
|
|
450
|
+
// `fitnessCommand` emits) are the ONLY door — a citation resolves against
|
|
451
|
+
// declared ids (F04), and a record's own bound id must match one to be
|
|
452
|
+
// verified. `computeDecisionFitness`'s second argument exists to carry
|
|
453
|
+
// verdicts but is unused by design: the lookup is the single door, so it is
|
|
454
|
+
// `null`, exactly as `adr` passes it. An empty verdict set is legitimate:
|
|
455
|
+
// every authority record then derives `unverifiable` — the registry alone
|
|
456
|
+
// asserts nothing, never a clean pass (the invariant).
|
|
457
|
+
const fitnessById = new Map(
|
|
458
|
+
computeDecisionFitness(registry === null ? [] : registry.records, null, (bindingId) => {
|
|
459
|
+
const stripped = stripRuleFitnessPrefix(bindingId);
|
|
460
|
+
const gate = fitness.functions.find((fn) => fn.name === stripped);
|
|
461
|
+
return gate === undefined ? undefined : { name: gate.name, verdict: gate.verdict };
|
|
462
|
+
}).map((entry) => [entry.id, entry]),
|
|
463
|
+
);
|
|
464
|
+
|
|
445
465
|
const decisions = {
|
|
446
466
|
verdict:
|
|
447
467
|
registry === null
|
|
@@ -463,11 +483,38 @@ export async function reportCommand(commandContext, io = {}) {
|
|
|
463
483
|
: registry.records.map((record) => ({
|
|
464
484
|
id: record.id,
|
|
465
485
|
status: record.status,
|
|
486
|
+
authority: hasAuthority(record.status),
|
|
466
487
|
bindings: [...record.bindings],
|
|
488
|
+
// The per-decision fitness level from the derivation above. A
|
|
489
|
+
// record binding nothing this run's gates declared derives
|
|
490
|
+
// `unverifiable` — the registry alone asserts nothing.
|
|
491
|
+
fitness: fitnessById.get(record.id),
|
|
492
|
+
// The governed rows (intent + constraint) that CITATION this
|
|
493
|
+
// record as their authority — the "who stands on this decision"
|
|
494
|
+
// answer, filtered from the same citation walk above.
|
|
495
|
+
constraints: citations
|
|
496
|
+
.filter(
|
|
497
|
+
(citation) =>
|
|
498
|
+
citation.resolution === "adr" &&
|
|
499
|
+
citation.adr !== null &&
|
|
500
|
+
citation.adr.id === record.id,
|
|
501
|
+
)
|
|
502
|
+
.map((citation) => ({ kind: citation.kind, label: citation.label })),
|
|
467
503
|
})),
|
|
468
504
|
citations,
|
|
505
|
+
// The citations that could not be resolved — every one is already a
|
|
506
|
+
// governed row that holds the document back (they feed `unresolvedCitation`
|
|
507
|
+
// and `uninspectable` above); this materializes them for the text face so
|
|
508
|
+
// a reader sees which rows, not only that one was missing.
|
|
509
|
+
unresolvedDecisionRefs: citations
|
|
510
|
+
.filter((citation) => citation.resolution === "unknown")
|
|
511
|
+
.map((citation) => ({
|
|
512
|
+
kind: citation.kind,
|
|
513
|
+
label: citation.label,
|
|
514
|
+
decisionRef: citation.decisionRef,
|
|
515
|
+
reason: unresolvedDecisionRefNote(citation.decisionRef),
|
|
516
|
+
})),
|
|
469
517
|
};
|
|
470
|
-
|
|
471
518
|
// ── Provenance ────────────────────────────────────────────────────────
|
|
472
519
|
// Where this run's facts came from. `null` is git's honest "no origin
|
|
473
520
|
// claim", printed as such and never folded into a commit this run cannot
|