@ecoma-io/archkeep 0.17.0 → 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/cli.mjs +143 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- 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/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +71 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/debt-ledger.mjs +261 -19
- 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/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +45 -0
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -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);
|
package/src/commands/delta.mjs
CHANGED
|
@@ -43,6 +43,20 @@
|
|
|
43
43
|
* instead. Dirty base provenance and a dirty head are notes too: weaker
|
|
44
44
|
* evidence, not unreadable evidence.
|
|
45
45
|
*
|
|
46
|
+
* (wave 3, additive) Each compare run maps its own capture output into the
|
|
47
|
+
* canonical evolution event (`../governance/evolution-event.mjs`, design §1):
|
|
48
|
+
* a `kind: "transition"`, `source: "delta"` record whose classifications come
|
|
49
|
+
* from the ONE predicate home `classifyEvolution` (through
|
|
50
|
+
* `./delta-classify.mjs`'s `classifyDeltaEvolution`), whose observed/findings/
|
|
51
|
+
* fitness facts are mapped — never re-derived — from the structural signal,
|
|
52
|
+
* the policy comparison and the violation classification this run already
|
|
53
|
+
* computed, and whose disposition is a function of the delta's own verb
|
|
54
|
+
* (`deltaDisposition`). The event rides the result envelope as the additive
|
|
55
|
+
* `classifications`/`affected` fields, and `--event-out <dir>` (owned by
|
|
56
|
+
* `../../cli.mjs`) additionally appends the record to the event store
|
|
57
|
+
* (`../governance/evolution-store.mjs`). Absent the flag, no file is written
|
|
58
|
+
* and nothing else about the run changes byte-for-byte.
|
|
59
|
+
*
|
|
46
60
|
* This module computes and returns; `../../cli.mjs`'s `runDelta` owns argv,
|
|
47
61
|
* output destination and the process exit code (`./README.md`).
|
|
48
62
|
*/
|
|
@@ -51,20 +65,38 @@ import { createRequire } from "node:module";
|
|
|
51
65
|
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
52
66
|
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
53
67
|
import { referenceTime } from "../governance/clock.mjs";
|
|
68
|
+
import {
|
|
69
|
+
eventDedupeKey,
|
|
70
|
+
eventId,
|
|
71
|
+
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
72
|
+
} from "../governance/evolution-event.mjs";
|
|
73
|
+
import { writeEvent } from "../governance/evolution-store.mjs";
|
|
74
|
+
import { recordOrigin } from "../governance/provenance-record.mjs";
|
|
54
75
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
55
76
|
import { buildDecision } from "../report/evidence.mjs";
|
|
56
77
|
import { formatDeltaReport } from "../report/delta-text.mjs";
|
|
57
78
|
import { formatDeltaSarif } from "../report/sarif.mjs";
|
|
58
79
|
import { evaluateRun } from "../rules/index.mjs";
|
|
80
|
+
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
81
|
+
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
82
|
+
import { debtChangeDiff } from "../governance/debt-ledger.mjs";
|
|
59
83
|
import { customRulesForDelta, declaresCustomRules } from "./custom-rules.mjs";
|
|
60
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
classifyCustomFindings,
|
|
86
|
+
classifyDelta,
|
|
87
|
+
classifyDeltaEvolution,
|
|
88
|
+
deltaFindings,
|
|
89
|
+
deltaVerdictDeltas,
|
|
90
|
+
edgeEvolutionIdentity,
|
|
91
|
+
} from "./delta-classify.mjs";
|
|
61
92
|
import {
|
|
62
93
|
buildEvidenceSnapshot,
|
|
63
94
|
providerMismatch,
|
|
64
95
|
readEvidenceSnapshot,
|
|
65
96
|
serializeEvidenceSnapshot,
|
|
66
97
|
} from "./delta-snapshot.mjs";
|
|
67
|
-
import {
|
|
98
|
+
import { computeDiff } from "./diff.mjs";
|
|
99
|
+
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
68
100
|
import { resolveProvenance } from "./provenance.mjs";
|
|
69
101
|
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
70
102
|
|
|
@@ -275,6 +307,40 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
|
275
307
|
};
|
|
276
308
|
}
|
|
277
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The delta event's disposition, mapped from the delta's OWN verb contract —
|
|
312
|
+
* the status `deltaCommand` already folds — so the event's evaluative stance
|
|
313
|
+
* is a function of the run consumers already know, never a second opinion:
|
|
314
|
+
*
|
|
315
|
+
* - `no-verdict` status (any unclassifiable item — violation, unresolvable
|
|
316
|
+
* record, or custom finding) ⇒ `no-verdict`, never a fabricated
|
|
317
|
+
* accepted/rejected;
|
|
318
|
+
* - `findings` status ⇒ `rejected`, unconditionally — `findings` means some
|
|
319
|
+
* introduced gating finding survived the current waiver table, WHATEVER
|
|
320
|
+
* class it carries. The classifications are deliberately NOT consulted:
|
|
321
|
+
* a custom-rule-only introduced finding never reaches the VIOLATION
|
|
322
|
+
* predicate, so a classifications scan would read "accepted" on an exit-1
|
|
323
|
+
* run — the silent direction;
|
|
324
|
+
* - everything else — a clean comparable capture (`ok`, `[]` classifications),
|
|
325
|
+
* an `ok` capture with a fact class (REPAIR, CHANGE, DRIFT,
|
|
326
|
+
* DECISION_CHANGE — each accepted by the vocabulary `classification` earns),
|
|
327
|
+
* or an `ok` capture holding a WAIVED violation (a waiver is a tracked
|
|
328
|
+
* acceptance — which is exactly what kept the gate `ok`) ⇒ `accepted`.
|
|
329
|
+
*
|
|
330
|
+
* The two refusals that can never reach this mapping — an unjudgeable head
|
|
331
|
+
* and a provider mismatch — THROW before any event exists, so a delta that
|
|
332
|
+
* could not be computed has no record at all, never a record with a guessed
|
|
333
|
+
* disposition.
|
|
334
|
+
*
|
|
335
|
+
* @param {{status: "ok"|"findings"|"no-verdict"}} input
|
|
336
|
+
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
337
|
+
*/
|
|
338
|
+
export function deltaDisposition({ status }) {
|
|
339
|
+
if (status === "no-verdict") return "no-verdict";
|
|
340
|
+
if (status === "findings") return "rejected";
|
|
341
|
+
return "accepted";
|
|
342
|
+
}
|
|
343
|
+
|
|
278
344
|
/** First eight hex characters of a fingerprint, for prose that names one. */
|
|
279
345
|
const short = (fingerprint) =>
|
|
280
346
|
typeof fingerprint === "string" ? fingerprint.slice(0, 8) : String(fingerprint);
|
|
@@ -305,27 +371,45 @@ const short = (fingerprint) =>
|
|
|
305
371
|
* (suppressions key on a `messageId` custom findings do not have); an
|
|
306
372
|
* unclassifiable one is a no-verdict. This is also why the function is async:
|
|
307
373
|
* the wasm host is.
|
|
308
|
-
*
|
|
309
374
|
* @param {string} baselinePath Absolute path to the evidence snapshot.
|
|
310
375
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
311
376
|
* @param {{config: object|null, readBaseline?: (path: string) => object,
|
|
312
|
-
* now?: string,
|
|
377
|
+
* now?: string, eventOut?: string|null,
|
|
378
|
+
* loadIntentOverride?: (root: string, opts?: object) => Promise<object|undefined>,
|
|
379
|
+
* readArtifact?: (artifact: string) => Uint8Array|null,
|
|
313
380
|
* timeoutMs?: number}} io The resolved boundary config (required
|
|
314
381
|
* — both sides are re-judged under it), an injectable baseline reader, the
|
|
315
382
|
* one shared reference instant (defaults to the shared governance clock),
|
|
316
|
-
*
|
|
383
|
+
* `eventOut` — the directory an evolution event is appended to when given
|
|
384
|
+
* (absent or `null` ⇒ no event file, byte-identical behavior),
|
|
385
|
+
* `loadIntentOverride` — the architecture-intent reader the event's `debt`
|
|
386
|
+
* sub-ledger judges over this run's base and head graphs (defaults to
|
|
387
|
+
* `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
|
|
388
|
+
* custom-rule host's two injectable seams, passed through to
|
|
317
389
|
* `customRulesForDelta`.
|
|
318
390
|
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta: object,
|
|
319
|
-
* coverage: object,
|
|
391
|
+
* coverage: object,
|
|
392
|
+
* eventWrite: {id: string, duplicate: boolean}|null,
|
|
393
|
+
* report: {text: string, json: string, sarif: string}}>} `delta` carries
|
|
394
|
+
* the additive `classifications`/`affected` fields (design §1); `eventWrite`
|
|
395
|
+
* is `null` unless `eventOut` was given, then the store's answer for the
|
|
396
|
+
* event that was (or already was) recorded.
|
|
320
397
|
* @throws {Error} on every refusal the module header lists, and on a
|
|
321
398
|
* custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
|
|
322
399
|
*/
|
|
323
400
|
export async function deltaCommand(
|
|
324
401
|
baselinePath,
|
|
325
402
|
commandContext,
|
|
326
|
-
{
|
|
403
|
+
{
|
|
404
|
+
config,
|
|
405
|
+
readBaseline = readEvidenceSnapshot,
|
|
406
|
+
now = referenceTime(),
|
|
407
|
+
eventOut = null,
|
|
408
|
+
loadIntentOverride,
|
|
409
|
+
...customRuleIo
|
|
410
|
+
},
|
|
327
411
|
) {
|
|
328
|
-
const { root, provider, marker, graph, analysis } = commandContext;
|
|
412
|
+
const { root, provider, marker, graph, analysis, tracked } = commandContext;
|
|
329
413
|
|
|
330
414
|
refuseUnjudgeableHead(commandContext, "compute a delta");
|
|
331
415
|
if (!config) {
|
|
@@ -415,6 +499,53 @@ export async function deltaCommand(
|
|
|
415
499
|
);
|
|
416
500
|
}
|
|
417
501
|
|
|
502
|
+
// The event's structural signal: what moved between the two graphs the
|
|
503
|
+
// delta already holds, computed through `computeDiff` — the ONE shared
|
|
504
|
+
// structural vocabulary (`./diff.mjs`, shared with `trajectory`) — so a
|
|
505
|
+
// project or edge added/removed/changed is a fact about the evidence, never
|
|
506
|
+
// a second spelling of "changed". The head side is rebuilt by the same
|
|
507
|
+
// `buildProjects`/`buildDependencies` the snapshot stores
|
|
508
|
+
// (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
|
|
509
|
+
const structuralDiff = computeDiff(baseline.graph, {
|
|
510
|
+
projects: buildProjects(graph.nodes),
|
|
511
|
+
dependencies: buildDependencies(graph.dependencies),
|
|
512
|
+
});
|
|
513
|
+
const structural = {
|
|
514
|
+
projects: {
|
|
515
|
+
added: structuralDiff.addedProjects.map((project) => project.name),
|
|
516
|
+
removed: structuralDiff.removedProjects.map((project) => project.name),
|
|
517
|
+
changed: structuralDiff.changedProjects.map((project) => project.name),
|
|
518
|
+
},
|
|
519
|
+
edges: {
|
|
520
|
+
added: structuralDiff.addedEdges.map(edgeEvolutionIdentity),
|
|
521
|
+
removed: structuralDiff.removedEdges.map(edgeEvolutionIdentity),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
const structureChanged =
|
|
525
|
+
structural.projects.added.length +
|
|
526
|
+
structural.projects.removed.length +
|
|
527
|
+
structural.projects.changed.length +
|
|
528
|
+
structural.edges.added.length +
|
|
529
|
+
structural.edges.removed.length >
|
|
530
|
+
0;
|
|
531
|
+
// The delta's `codeDrift` signal (design §2): provenance advanced — both
|
|
532
|
+
// sides carry commits, they differ, and neither side was captured dirty (a
|
|
533
|
+
// dirty tree is weaker evidence, not a claim about the commit it names) —
|
|
534
|
+
// the architecture did not move, and the policy was comparable AND
|
|
535
|
+
// unchanged. `policyChanged === null` (one-sided) never reads as "the
|
|
536
|
+
// same", and a policy change is disclosed, not folded into drift.
|
|
537
|
+
const baseCommit = baseline.provenance?.commit;
|
|
538
|
+
const headCommit = headProvenance?.commit;
|
|
539
|
+
const provenanceAdvanced =
|
|
540
|
+
typeof baseCommit === "string" &&
|
|
541
|
+
typeof headCommit === "string" &&
|
|
542
|
+
baseCommit !== headCommit &&
|
|
543
|
+
baseline.provenance?.dirty !== true &&
|
|
544
|
+
headProvenance?.dirty !== true &&
|
|
545
|
+
meta.crossRepo !== true &&
|
|
546
|
+
meta.provenanceOneSided !== true;
|
|
547
|
+
const codeDrift = provenanceAdvanced && !structureChanged && meta.policyChanged === false;
|
|
548
|
+
|
|
418
549
|
// The custom-rule half, present exactly when a side declares rules: judged
|
|
419
550
|
// two-sided where the law is identical, `unknown` with a mandatory reason
|
|
420
551
|
// everywhere else (`./custom-rules.mjs`'s `customRulesForDelta` owns the
|
|
@@ -480,6 +611,29 @@ export async function deltaCommand(
|
|
|
480
611
|
}
|
|
481
612
|
}
|
|
482
613
|
|
|
614
|
+
// The §1 mapping: the delta's own signals through `classifyEvolution` — one
|
|
615
|
+
// definition (`./delta-classify.mjs`'s `classifyDeltaEvolution` maps, never
|
|
616
|
+
// re-decides a class). The result's additive `classifications`/`affected`
|
|
617
|
+
// ride the envelope, and the same classification feeds the event when
|
|
618
|
+
// `eventOut` is given.
|
|
619
|
+
const deltaPayload = {
|
|
620
|
+
violations: classification.violations,
|
|
621
|
+
unresolvable: classification.unresolvable,
|
|
622
|
+
policyChanged: meta.policyChanged,
|
|
623
|
+
// The one-sided/advanced facts ride the payload (never the event's
|
|
624
|
+
// `observed`, which is the stored record): classifyEvolution needs them
|
|
625
|
+
// as input facts — `policyOneSided` is never derived from `policyChanged
|
|
626
|
+
// === null`, because both-sides-absent is also `null` (F-HIST-1).
|
|
627
|
+
policyOneSided: meta.policyOneSided,
|
|
628
|
+
provenanceChanged: meta.provenanceChanged,
|
|
629
|
+
...(custom === null ? {} : { customRules: custom }),
|
|
630
|
+
};
|
|
631
|
+
const evolution = classifyDeltaEvolution(deltaPayload, {
|
|
632
|
+
projects: structural.projects,
|
|
633
|
+
edges: structural.edges,
|
|
634
|
+
codeDrift,
|
|
635
|
+
});
|
|
636
|
+
|
|
483
637
|
const { violations, unresolvable } = classification;
|
|
484
638
|
const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
|
|
485
639
|
const introducedNotWaived = violations.introduced.length - introducedWaived;
|
|
@@ -577,9 +731,115 @@ export async function deltaCommand(
|
|
|
577
731
|
},
|
|
578
732
|
violations,
|
|
579
733
|
unresolvable,
|
|
734
|
+
classifications: evolution.classifications,
|
|
735
|
+
affected: evolution.affected,
|
|
580
736
|
...(custom === null ? {} : { customRules: custom }),
|
|
581
737
|
};
|
|
582
738
|
|
|
739
|
+
// The evolution event (design §1), built by mapping — never re-deriving —
|
|
740
|
+
// the capture output the run already holds: `observed`/`affected`/
|
|
741
|
+
// `findings`/`fitness` come from the structural signal, the §1 mapping, and
|
|
742
|
+
// the classification above; `classifications` come from the same mapping;
|
|
743
|
+
// the disposition is a function of the delta's own verb through
|
|
744
|
+
// `deltaDisposition`. `recordedAt` carries the SAME injected reference
|
|
745
|
+
// instant the run judged waivers at — one clock, one transition. Written
|
|
746
|
+
// only when `eventOut` is given: absent ⇒ no file, byte-identical behavior.
|
|
747
|
+
/** @type {{id: string, duplicate: boolean}|null} */
|
|
748
|
+
let eventWrite = null;
|
|
749
|
+
if (eventOut !== null && eventOut !== undefined) {
|
|
750
|
+
// F-delta-event-id: an evolution event is only written from a reproducible
|
|
751
|
+
// identity — a committed, clean head and a clean base. A commitless head
|
|
752
|
+
// has no revision to name, and a dirty tree names a commit its evidence
|
|
753
|
+
// does not back; either way TWO distinct evidence states collapse onto ONE
|
|
754
|
+
// event id, so a later transition is silently lost or aliased (the silent
|
|
755
|
+
// direction). Refuse loudly instead. The same run without `--event-out`
|
|
756
|
+
// stays a byte-identical in-memory delta.
|
|
757
|
+
if (typeof headCommit !== "string") {
|
|
758
|
+
throw new Error(
|
|
759
|
+
"archkeep: refusing to write a delta event without a committed head — a commitless " +
|
|
760
|
+
"head has no reproducible event identity, and every distinct head state would " +
|
|
761
|
+
"collide on one event id. Commit the head, or capture without --event-out.",
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
"archkeep: refusing to write a delta event from a dirty working tree — the event " +
|
|
767
|
+
"would name a commit whose evidence is uncommitted, and distinct uncommitted " +
|
|
768
|
+
"states would collide on one event id. Commit both sides first.",
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
// The architecture-debt sub-ledger (design §8): judged by re-running the
|
|
772
|
+
// current intent over this run's base and head graphs — a drift finding
|
|
773
|
+
// present at head but not base is introduced; one gone is resolved. Both
|
|
774
|
+
// ENGINE graphs exist here (the base from the captured snapshot, the head
|
|
775
|
+
// from the live capture), so unlike `change` there is no unproven-base
|
|
776
|
+
// gate; the fail-closed branches are an absent intent and an unjudgeable
|
|
777
|
+
// one, each emitting no ids and an in-band note rather than a fabricated
|
|
778
|
+
// clean ledger.
|
|
779
|
+
/** @type {{introduced: string[], resolved: string[], note?: string}} */
|
|
780
|
+
let debt;
|
|
781
|
+
try {
|
|
782
|
+
const archIntent = await (loadIntentOverride ?? loadIntent)(root, { tracked });
|
|
783
|
+
if (archIntent === undefined || archIntent === null) {
|
|
784
|
+
debt = {
|
|
785
|
+
introduced: [],
|
|
786
|
+
resolved: [],
|
|
787
|
+
note: `no '${INTENT_FILE}' tracked — the delta event carries no architecture debt ids`,
|
|
788
|
+
};
|
|
789
|
+
} else {
|
|
790
|
+
const baseVerdict = judgeIntent(archIntent, baseGraph);
|
|
791
|
+
const headVerdict = judgeIntent(archIntent, graph);
|
|
792
|
+
debt = debtChangeDiff(baseVerdict, headVerdict);
|
|
793
|
+
}
|
|
794
|
+
} catch (error) {
|
|
795
|
+
debt = {
|
|
796
|
+
introduced: [],
|
|
797
|
+
resolved: [],
|
|
798
|
+
note: `architecture intent could not be judged — no debt ids emitted (${error.message})`,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const event = {
|
|
802
|
+
schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
803
|
+
kind: "transition",
|
|
804
|
+
source: "delta",
|
|
805
|
+
base: {
|
|
806
|
+
...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
|
|
807
|
+
// The evidence ref is the baseline file this run actually compared
|
|
808
|
+
// against — a pointer into the evidence, never a graph.
|
|
809
|
+
evidence: baselinePath,
|
|
810
|
+
},
|
|
811
|
+
head: typeof headCommit === "string" ? { revision: headCommit } : {},
|
|
812
|
+
recordedAt: recordOrigin({
|
|
813
|
+
by: "cli",
|
|
814
|
+
tool: `archkeep:v${TOOL_VERSION}`,
|
|
815
|
+
clock: { now: () => now },
|
|
816
|
+
}),
|
|
817
|
+
observed: {
|
|
818
|
+
architectureChanged: structureChanged,
|
|
819
|
+
projects: structural.projects,
|
|
820
|
+
edges: structural.edges,
|
|
821
|
+
policyChanged: meta.policyChanged,
|
|
822
|
+
// A delta that completed is a delta whose provider matched — the
|
|
823
|
+
// mismatch refusal above throws before any event could exist.
|
|
824
|
+
providerChanged: false,
|
|
825
|
+
},
|
|
826
|
+
affected: evolution.affected,
|
|
827
|
+
findings: deltaFindings(deltaPayload),
|
|
828
|
+
fitness: { verdictDeltas: deltaVerdictDeltas(deltaPayload) },
|
|
829
|
+
debt,
|
|
830
|
+
classifications: evolution.classifications,
|
|
831
|
+
disposition: deltaDisposition({ status }),
|
|
832
|
+
notes: [...notes, ...evolution.notes],
|
|
833
|
+
provenance: [
|
|
834
|
+
...(typeof baseCommit === "string" ? [{ kind: "git-commit", ref: baseCommit }] : []),
|
|
835
|
+
...(typeof headCommit === "string" ? [{ kind: "git-commit", ref: headCommit }] : []),
|
|
836
|
+
],
|
|
837
|
+
};
|
|
838
|
+
event.dedupeKey = eventDedupeKey(event);
|
|
839
|
+
event.id = eventId(event);
|
|
840
|
+
eventWrite = writeEvent(eventOut, event, { root });
|
|
841
|
+
}
|
|
842
|
+
|
|
583
843
|
const envelope = jsonEnvelope({
|
|
584
844
|
command: "delta",
|
|
585
845
|
context: { root, provider, marker, provenance: headProvenance },
|
|
@@ -592,6 +852,7 @@ export async function deltaCommand(
|
|
|
592
852
|
|
|
593
853
|
return {
|
|
594
854
|
status,
|
|
855
|
+
eventWrite,
|
|
595
856
|
delta: result,
|
|
596
857
|
coverage,
|
|
597
858
|
report: {
|