@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
|
@@ -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
|
|
@@ -140,6 +140,35 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
|
|
|
140
140
|
* @property {number|null} persistent
|
|
141
141
|
*/
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The trend-facts block: per-class counts and boundary-movement totals over
|
|
145
|
+
* the SAME comparable transitions the axes count — a pair whose fingerprint
|
|
146
|
+
* or provenance could not be compared is excluded exactly as it is excluded
|
|
147
|
+
* from `transitions.unchanged`, so the basis is one consistent subset. Each
|
|
148
|
+
* class counts once per transition carrying it (a transition may carry
|
|
149
|
+
* several). `violationsIntroduced`/`violationsResolved` count classes, never
|
|
150
|
+
* violation rows — and for snapshot-sourced transitions no finding evidence
|
|
151
|
+
* exists at all, so those totals are `0` with the `note` saying why: the
|
|
152
|
+
* absence of evidence, never a claim that none occurred (the silent
|
|
153
|
+
* direction).
|
|
154
|
+
*
|
|
155
|
+
* `null` when no trend can be derived: fewer than two observations, or a
|
|
156
|
+
* history where every transition was incomparable. Never a zero-filled block.
|
|
157
|
+
*
|
|
158
|
+
* @typedef {object} TrajectoryTrends
|
|
159
|
+
* @property {{CHANGE: number, DRIFT: number, VIOLATION: number, REPAIR: number,
|
|
160
|
+
* DECISION_CHANGE: number}} byClass One count per evolution class, over
|
|
161
|
+
* comparable transitions whose `classifications` carry it.
|
|
162
|
+
* @property {number} violationsIntroduced
|
|
163
|
+
* @property {number} violationsResolved
|
|
164
|
+
* @property {number} comparableTransitions The number of transitions the
|
|
165
|
+
* counts were derived over — `transitions.count − transitions.incomparable`.
|
|
166
|
+
* @property {"comparable transition classifications"} basis What the counts
|
|
167
|
+
* are a claim about, stated as a value.
|
|
168
|
+
* @property {string} [note] A disclosure when the counts cannot speak about
|
|
169
|
+
* evidence the input never carried (violation/repair rows).
|
|
170
|
+
*/
|
|
171
|
+
|
|
143
172
|
/**
|
|
144
173
|
* Aggregates the deterministic trajectory over an ordered snapshot set.
|
|
145
174
|
* Pure: same bytes in, same object out. All keys are always present — shape
|
|
@@ -154,7 +183,8 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
|
|
|
154
183
|
* transitions: {count: number, architecture: number, policy: number,
|
|
155
184
|
* provider: number, codeDrift: number, incomparable: number, unchanged: number},
|
|
156
185
|
* disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
|
|
157
|
-
* projects: TrajectoryAxis, edges: TrajectoryAxis
|
|
186
|
+
* projects: TrajectoryAxis, edges: TrajectoryAxis,
|
|
187
|
+
* trends: TrajectoryTrends|null}}
|
|
158
188
|
*/
|
|
159
189
|
export function computeTrajectory(files) {
|
|
160
190
|
const n = files.length;
|
|
@@ -182,6 +212,10 @@ export function computeTrajectory(files) {
|
|
|
182
212
|
};
|
|
183
213
|
const disclosures = { policyOneSided: 0, provenanceOneSided: 0, crossRepo: 0 };
|
|
184
214
|
|
|
215
|
+
/** @type {{CHANGE: number, DRIFT: number, VIOLATION: number, REPAIR: number,
|
|
216
|
+
DECISION_CHANGE: number}} */
|
|
217
|
+
const classCounts = { CHANGE: 0, DRIFT: 0, VIOLATION: 0, REPAIR: 0, DECISION_CHANGE: 0 };
|
|
218
|
+
|
|
185
219
|
// Cumulative transition events, accumulated while classifying. Kept as
|
|
186
220
|
// scalars rather than deferred to a second pass — one walk over the pairs.
|
|
187
221
|
let addedProjectEvents = 0;
|
|
@@ -231,15 +265,34 @@ export function computeTrajectory(files) {
|
|
|
231
265
|
if (record.policyChanged === true) transitions.policy += 1;
|
|
232
266
|
if (record.providerChanged) transitions.provider += 1;
|
|
233
267
|
if (record.codeDrift) transitions.codeDrift += 1;
|
|
234
|
-
|
|
235
268
|
// The asymmetric-evidence cases, counted from `meta` itself — never
|
|
236
269
|
// parsed back out of the record's prose notes.
|
|
237
270
|
if (meta.policyOneSided) disclosures.policyOneSided += 1;
|
|
238
271
|
if (meta.provenanceOneSided) disclosures.provenanceOneSided += 1;
|
|
239
272
|
if (meta.crossRepo) disclosures.crossRepo += 1;
|
|
240
|
-
|
|
273
|
+
// The advanced-both-absent pair is the same incomparable case as
|
|
274
|
+
// one-sided: neither side records the boundary law while the commit
|
|
275
|
+
// advanced, so the transition carried real code motion the tool cannot
|
|
276
|
+
// classify (F-HIST-1). `provenanceChanged === true` requires both sides
|
|
277
|
+
// to record provenance AND the commits to differ, so neither-side-
|
|
278
|
+
// absent histories (both commits `null`) stay comparable-unchanged.
|
|
279
|
+
const incomparable =
|
|
280
|
+
meta.policyOneSided ||
|
|
281
|
+
meta.provenanceOneSided ||
|
|
282
|
+
(meta.policyChanged === null && meta.provenanceChanged === true);
|
|
241
283
|
if (incomparable) transitions.incomparable += 1;
|
|
242
284
|
|
|
285
|
+
// The trend classes are counted over the SAME comparable subset the
|
|
286
|
+
// axes already disclose: a pair excluded from `unchanged` because its
|
|
287
|
+
// fingerprint or provenance could not be compared is excluded from the
|
|
288
|
+
// trend facts the same way — never silently folded in. Each class is a
|
|
289
|
+
// fact per transition; a transition carrying several counts in each.
|
|
290
|
+
if (!incomparable) {
|
|
291
|
+
for (const cls of record.classifications) {
|
|
292
|
+
classCounts[cls] += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
243
296
|
// `unchanged` is deliberately STRICTER than the label `history`'s text
|
|
244
297
|
// renderer prints for the same transition: an aggregate has no
|
|
245
298
|
// per-transition note to disclose "one side carried no fingerprint",
|
|
@@ -328,6 +381,38 @@ export function computeTrajectory(files) {
|
|
|
328
381
|
resolved: available ? edgeMovement.resolved : null,
|
|
329
382
|
persistent: available ? persistentCount(edgePresence) : null,
|
|
330
383
|
};
|
|
384
|
+
// The trend-facts block. It exists only when comparable evidence exists:
|
|
385
|
+
// fewer than two observations, or a history whose every transition was
|
|
386
|
+
// incomparable, yields `null` — never a zero-filled block (a zero would
|
|
387
|
+
// claim "no change" over evidence this run could not compare). The counts
|
|
388
|
+
// ride the same classification the axes classify with; both come from
|
|
389
|
+
// `classifyTransition`'s one walk, so the trends are idempotent: same
|
|
390
|
+
// snapshots, same object, every run.
|
|
391
|
+
//
|
|
392
|
+
// The violation/repair totals are computed from the classification inputs,
|
|
393
|
+
// and snapshot-sourced transitions carry none: stored snapshots hold the
|
|
394
|
+
// graph and the policy fingerprint, not findings. Zero there is the absence
|
|
395
|
+
// of evidence, never a claim that none occurred — the note says so, on
|
|
396
|
+
// every non-null trends block.
|
|
397
|
+
/** @type {TrajectoryTrends|null} */
|
|
398
|
+
let trends = null;
|
|
399
|
+
if (available) {
|
|
400
|
+
const comparableTransitions = transitions.count - transitions.incomparable;
|
|
401
|
+
if (comparableTransitions > 0) {
|
|
402
|
+
trends = {
|
|
403
|
+
byClass: classCounts,
|
|
404
|
+
violationsIntroduced: 0,
|
|
405
|
+
violationsResolved: 0,
|
|
406
|
+
comparableTransitions,
|
|
407
|
+
basis: "comparable transition classifications",
|
|
408
|
+
note:
|
|
409
|
+
"transition classifications carry no violation or repair evidence — stored " +
|
|
410
|
+
"snapshots hold the graph and the policy fingerprint, not findings, so " +
|
|
411
|
+
"VIOLATION/REPAIR and the violations-* totals are 0 because no finding could " +
|
|
412
|
+
"be classified, never because none occurred",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
331
416
|
|
|
332
417
|
return {
|
|
333
418
|
observations: {
|
|
@@ -344,6 +429,7 @@ export function computeTrajectory(files) {
|
|
|
344
429
|
disclosures,
|
|
345
430
|
projects,
|
|
346
431
|
edges,
|
|
432
|
+
trends,
|
|
347
433
|
};
|
|
348
434
|
}
|
|
349
435
|
|