@ecoma-io/archkeep 0.24.0 → 0.25.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/package.json +1 -1
- package/src/analysis/csharp.mjs +3 -1
- package/src/analysis/dotnet/csproj.mjs +5 -1
- package/src/analysis/dotnet/namespaces.mjs +1 -0
- package/src/analysis/go.mjs +6 -0
- package/src/analysis/java.mjs +2 -0
- package/src/analysis/jvm/gradle.mjs +3 -1
- package/src/analysis/jvm/maven.mjs +6 -1
- package/src/analysis/jvm/packages.mjs +1 -0
- package/src/analysis/jvm/resolve.mjs +4 -2
- package/src/analysis/kotlin.mjs +2 -0
- package/src/analysis/markdown.mjs +1 -0
- package/src/analysis/python.mjs +8 -0
- package/src/analysis/rust.mjs +5 -1
- package/src/analysis/source-util.mjs +1 -1
- package/src/analysis/typescript.mjs +2 -0
- package/src/architecture-intent/model.mjs +11 -7
- package/src/architecture-intent/selectors.mjs +2 -1
- package/src/commands/change-intent.mjs +10 -9
- package/src/commands/change.mjs +86 -23
- package/src/commands/check.mjs +11 -9
- package/src/commands/completeness.mjs +7 -6
- package/src/commands/coverage-acceptance.mjs +46 -0
- package/src/commands/custom-rules.mjs +1 -0
- package/src/commands/delta-classify.mjs +3 -0
- package/src/commands/delta-snapshot.mjs +27 -5
- package/src/commands/delta.mjs +73 -39
- package/src/commands/diff.mjs +3 -9
- package/src/commands/drift.mjs +1 -1
- package/src/commands/evaluation-primitives.mjs +4 -4
- package/src/commands/evolution.mjs +2 -0
- package/src/commands/explain.mjs +2 -0
- package/src/commands/graph.mjs +39 -17
- package/src/commands/history.mjs +36 -0
- package/src/commands/plan-context-command.mjs +4 -1
- package/src/commands/policy.mjs +5 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/commands/snapshot-meta.mjs +33 -0
- package/src/commands/trajectory.mjs +2 -1
- package/src/config.mjs +1 -1
- package/src/custom-rules/host.mjs +3 -3
- package/src/custom-rules/values.mjs +1 -4
- package/src/eslint-config.mjs +1 -0
- package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
- package/src/go-work.mjs +1 -1
- package/src/governance/adr-registry.mjs +4 -1
- package/src/governance/debt-ledger.mjs +1 -1
- package/src/governance/decision-fitness.mjs +2 -0
- package/src/governance/decision-graph.mjs +1 -0
- package/src/governance/discovery-proposal.mjs +8 -2
- package/src/governance/evolution-event.mjs +42 -0
- package/src/governance/fitness-registry.mjs +16 -1
- package/src/governance/preset-fingerprints.json +14 -14
- package/src/governance/profile-registry.mjs +22 -3
- package/src/governance/provenance-record.mjs +4 -1
- package/src/governance/reconcile-score.mjs +4 -0
- package/src/governance/row-schema.mjs +1 -0
- package/src/governance/verdict.mjs +37 -4
- package/src/governance/waiver.mjs +1 -0
- package/src/intent/intent-manifest.json +18 -12
- package/src/intent/mask-non-code.mjs +1 -0
- package/src/lsp/diagnostics.mjs +3 -2
- package/src/lsp/protocol.mjs +2 -1
- package/src/lsp/server.mjs +3 -0
- package/src/lsp/workspace-index.mjs +3 -1
- package/src/providers/native/differential.fixtures.mjs +29 -11
- package/src/providers/native/index.mjs +2 -1
- package/src/providers/native/model.mjs +4 -0
- package/src/report/envelope-shape.mjs +2 -0
- package/src/report/json.mjs +4 -2
- package/src/report/sarif.mjs +21 -8
- package/src/report/snapshot-text.mjs +3 -3
- package/src/report/text.mjs +10 -2
- package/src/rules/match.mjs +7 -5
- package/src/rules/specifiers.mjs +2 -0
- package/src/rules/tags.mjs +3 -2
- package/src/rules/topology.mjs +6 -1
- package/src/values.mjs +11 -0
- package/src/workspace.mjs +1 -0
package/src/commands/change.mjs
CHANGED
|
@@ -103,15 +103,18 @@ import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
|
|
|
103
103
|
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
104
104
|
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
105
105
|
import { cyclicProjects } from "../governance/fitness-rules.mjs";
|
|
106
|
-
import {
|
|
106
|
+
import { eventSnapshotSide } from "./history.mjs";
|
|
107
|
+
import { VERDICTS, fitnessVerdict, isVerdict } from "../governance/verdict.mjs";
|
|
108
|
+
import { describe } from "../values.mjs";
|
|
107
109
|
import { buildDecision } from "../report/evidence.mjs";
|
|
108
110
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
109
111
|
import { formatChangeReport } from "../report/change-text.mjs";
|
|
110
112
|
import { evaluateRun } from "../rules/index.mjs";
|
|
111
|
-
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
113
|
+
import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
|
|
112
114
|
import { resolveProvenance } from "./provenance.mjs";
|
|
113
115
|
import { referenceTime } from "../governance/clock.mjs";
|
|
114
116
|
import {
|
|
117
|
+
assertReproducibleEventIdentity,
|
|
115
118
|
classifyEvolution,
|
|
116
119
|
declarationDigest,
|
|
117
120
|
escapeIdentityField,
|
|
@@ -170,6 +173,7 @@ function cmpFacts(a, b) {
|
|
|
170
173
|
* @returns {string}
|
|
171
174
|
*/
|
|
172
175
|
export function violationFindingId(entry) {
|
|
176
|
+
// used by its own test
|
|
173
177
|
const sourceProject =
|
|
174
178
|
entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
|
|
175
179
|
return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
|
|
@@ -250,6 +254,7 @@ function observedFrom(structural, meta) {
|
|
|
250
254
|
* @returns {{matched: object[], unexpected: object[], missingExpected: object[]}}
|
|
251
255
|
*/
|
|
252
256
|
export function reconcileMaterialDelta(intent, delta) {
|
|
257
|
+
// used by its own test
|
|
253
258
|
const addedProjectNames = new Set(intent.projects.add);
|
|
254
259
|
const removedProjectNames = new Set(intent.projects.remove);
|
|
255
260
|
// The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
|
|
@@ -358,13 +363,16 @@ export function reconcileMaterialDelta(intent, delta) {
|
|
|
358
363
|
* @param {string[]} unprovenReasons Why the base identity could not be proven.
|
|
359
364
|
* @returns {"matched"|"undeclared"|"unfulfilled"|"unproven"}
|
|
360
365
|
*/
|
|
361
|
-
|
|
366
|
+
function reconciliationVerdict(lists, unprovenReasons) {
|
|
362
367
|
if (unprovenReasons.length > 0) return "unproven";
|
|
363
368
|
if (lists.unexpected.length > 0) return "undeclared";
|
|
364
369
|
if (lists.missingExpected.length > 0) return "unfulfilled";
|
|
365
370
|
return "matched";
|
|
366
371
|
}
|
|
367
372
|
|
|
373
|
+
/** The verdict axis `reconcileDisposition` maps — the four `reconciliationVerdict` answers. */
|
|
374
|
+
const RECONCILE_VERDICTS = Object.freeze(["matched", "undeclared", "unfulfilled", "unproven"]);
|
|
375
|
+
|
|
368
376
|
/**
|
|
369
377
|
* The reconcile DISPOSITION mapping (wave 3, design §5) — the verdict axis
|
|
370
378
|
* first, then the declared constraints' verdicts. Pure and exported so the
|
|
@@ -387,8 +395,34 @@ export function reconciliationVerdict(lists, unprovenReasons) {
|
|
|
387
395
|
* this run — empty when none were declared or when the base identity was
|
|
388
396
|
* unproven (constraints are left unevaluated then).
|
|
389
397
|
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
398
|
+
* @throws {Error} on an axis value outside the four, or a constraint row
|
|
399
|
+
* whose verdict is outside the canonical vocabulary — a stranger would fold
|
|
400
|
+
* to `accepted`, and a fabricated acceptance is the one answer this mapping
|
|
401
|
+
* never hands out.
|
|
390
402
|
*/
|
|
391
403
|
export function reconcileDisposition(verdict, constraints = []) {
|
|
404
|
+
// The mapping owns its input latch — it is exported and pure, the one place
|
|
405
|
+
// the precedence is stated, and a stranger axis value would match no named
|
|
406
|
+
// arm and land on `accepted`: a fabricated acceptance, the one answer this
|
|
407
|
+
// function must never hand out over input it did not understand.
|
|
408
|
+
if (!RECONCILE_VERDICTS.includes(verdict)) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`archkeep: refusing to map a reconcile verdict ${describe(verdict)} — ` +
|
|
411
|
+
`expected one of ${RECONCILE_VERDICTS.join(", ")}. This is a bug in the ` +
|
|
412
|
+
`reconciliation that produced it.`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
// Same latch one axis down: a constraint row verdict outside the four states
|
|
416
|
+
// matches neither `unknown` nor `fail`, and the mapping reads it as consent.
|
|
417
|
+
for (const row of constraints) {
|
|
418
|
+
if (!isVerdict(row.verdict)) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`archkeep: refusing to map a constraint row whose verdict is ${describe(row.verdict)} — ` +
|
|
421
|
+
`expected one of ${VERDICTS.join(", ")}. This is a bug in the judge that built the row.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// used by its own test
|
|
392
426
|
if (verdict === "unproven") return "no-verdict";
|
|
393
427
|
if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
|
|
394
428
|
if (constraints.some((row) => row.verdict === "unknown")) return "no-verdict";
|
|
@@ -644,6 +678,28 @@ export async function changeCommand(
|
|
|
644
678
|
dependencies: buildDependencies(graph.dependencies),
|
|
645
679
|
};
|
|
646
680
|
const structural = computeDiff(baseGraphForDiff, headGraphForDiff);
|
|
681
|
+
// The event's identity sides are frozen HERE — at the same instant the diff
|
|
682
|
+
// above was computed from these same graph objects — so the identity always
|
|
683
|
+
// describes the graph the diff actually compared. Everything between this
|
|
684
|
+
// line and the event assembly (constraint re-judgement, custom rules,
|
|
685
|
+
// classification, debt) reads the graphs too; the engine's purity is what
|
|
686
|
+
// keeps them honest today, but the WS-I adversarial audit named the seam:
|
|
687
|
+
// identity and diff agreeing must not rest on purity conventions alone when
|
|
688
|
+
// one structural line closes it.
|
|
689
|
+
const baseCommit = baseline.provenance?.commit;
|
|
690
|
+
const headCommit = headProvenance?.commit;
|
|
691
|
+
const eventBase = eventSnapshotSide({
|
|
692
|
+
revision: baseCommit,
|
|
693
|
+
projects: baseGraphForDiff.projects,
|
|
694
|
+
dependencies: baseGraphForDiff.dependencies,
|
|
695
|
+
policyFingerprint: baseline.policyFingerprint,
|
|
696
|
+
});
|
|
697
|
+
const eventHead = eventSnapshotSide({
|
|
698
|
+
revision: headCommit,
|
|
699
|
+
projects: headGraphForDiff.projects,
|
|
700
|
+
dependencies: headGraphForDiff.dependencies,
|
|
701
|
+
policyFingerprint: headFingerprint,
|
|
702
|
+
});
|
|
647
703
|
const reconciliation = reconcileMaterialDelta(intent, structural);
|
|
648
704
|
|
|
649
705
|
// Constraints and the law axis are computed over both sides re-judged under
|
|
@@ -732,16 +788,10 @@ export async function changeCommand(
|
|
|
732
788
|
);
|
|
733
789
|
}
|
|
734
790
|
if (meta.dirtyBaseline) {
|
|
735
|
-
notes.push(
|
|
736
|
-
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
737
|
-
"claim about the commit the contract pins",
|
|
738
|
-
);
|
|
791
|
+
notes.push(dirtyBaselineNote(true));
|
|
739
792
|
}
|
|
740
793
|
if (meta.dirtyHead) {
|
|
741
|
-
notes.push(
|
|
742
|
-
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
743
|
-
"commit HEAD names",
|
|
744
|
-
);
|
|
794
|
+
notes.push(dirtyHeadNote());
|
|
745
795
|
}
|
|
746
796
|
if (meta.provenanceOneSided) {
|
|
747
797
|
notes.push(
|
|
@@ -851,23 +901,24 @@ export async function changeCommand(
|
|
|
851
901
|
}
|
|
852
902
|
}
|
|
853
903
|
|
|
854
|
-
const baseCommit = baseline.provenance?.commit;
|
|
855
|
-
const headCommit = headProvenance?.commit;
|
|
856
904
|
/** @type {object} */
|
|
857
905
|
const event = {
|
|
858
906
|
schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
859
907
|
kind: "reconcile",
|
|
860
908
|
source: "change",
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
909
|
+
// Both sides were frozen through the ONE identity spelling
|
|
910
|
+
// (`eventSnapshotSide`, ./history.mjs) at the diff site above: a revision
|
|
911
|
+
// when one is known, plus the snapshot identity of the graph the diff
|
|
912
|
+
// compared — never the baseline's storage path, which is machine-local
|
|
913
|
+
// and must not make the identity a per-machine property.
|
|
914
|
+
base: eventBase,
|
|
915
|
+
head: eventHead,
|
|
916
|
+
// The caller's own evidence ref — the baseline file this run consumed,
|
|
917
|
+
// spelled as the run received it (the same convention `declaration.file`
|
|
918
|
+
// uses for the intent path). Disclosed OUTSIDE the identity: the tuple
|
|
919
|
+
// above names the state itself, so a relocated baseline is still the
|
|
920
|
+
// same event.
|
|
921
|
+
evidence: baselinePath,
|
|
871
922
|
declaration: { file: intentPath, digest: declarationDigest(intent) },
|
|
872
923
|
observed,
|
|
873
924
|
affected: evolution.affected,
|
|
@@ -898,6 +949,18 @@ export async function changeCommand(
|
|
|
898
949
|
/** @type {{dir: string, id: string, duplicate: boolean}|null} */
|
|
899
950
|
let eventWritten = null;
|
|
900
951
|
if (eventOut !== undefined && eventOut !== null && eventOut !== "") {
|
|
952
|
+
// F-delta-event-id on the reconcile event: the event's identity names
|
|
953
|
+
// base and head revisions, so a commitless head or a dirty tree collapses
|
|
954
|
+
// distinct evidence states onto one event id — a later transition is
|
|
955
|
+
// silently lost or aliased, the silent direction. `delta` holds the same
|
|
956
|
+
// gate; the shared law and its frozen wording live in
|
|
957
|
+
// `../governance/evolution-event.mjs`.
|
|
958
|
+
assertReproducibleEventIdentity({
|
|
959
|
+
label: "change",
|
|
960
|
+
headCommit,
|
|
961
|
+
baseDirty: baseline.provenance?.dirty === true,
|
|
962
|
+
headDirty: headProvenance?.dirty === true,
|
|
963
|
+
});
|
|
901
964
|
// The store's io.root is the workspace root: the write must be provable
|
|
902
965
|
// to stay inside the workspace (the same containment `--output` obeys).
|
|
903
966
|
// A write failure throws — exit 3 upstream, the could-not-look lane —
|
package/src/commands/check.mjs
CHANGED
|
@@ -29,7 +29,10 @@ import {
|
|
|
29
29
|
unownedGapWithoutRunConfiguration,
|
|
30
30
|
untrackedOwnedFiles,
|
|
31
31
|
} from "./context.mjs";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
partitionUnownedCoverage,
|
|
34
|
+
withdrawAcceptedUnclaimedFailures,
|
|
35
|
+
} from "./coverage-acceptance.mjs";
|
|
33
36
|
import { readAdrContext } from "./adr.mjs";
|
|
34
37
|
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
35
38
|
import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
|
|
@@ -223,7 +226,7 @@ export async function check(
|
|
|
223
226
|
);
|
|
224
227
|
const { root, graph, workspace, tracked } = commandContext;
|
|
225
228
|
const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
|
|
226
|
-
|
|
229
|
+
let failures = [...commandContext.analysis.failures];
|
|
227
230
|
const analyzed = commandContext.analysis.analyzed;
|
|
228
231
|
|
|
229
232
|
// The config's location is a separate fact from the workspace root, which is
|
|
@@ -308,17 +311,16 @@ export async function check(
|
|
|
308
311
|
// acceptance now — stated below as the `"accepted-unowned-files"` coverage
|
|
309
312
|
// gap, never silently — rather than the exit-3 refusal an unanswered
|
|
310
313
|
// orphan earns. Uncovered unclaimed files keep their failures, and with
|
|
311
|
-
// them the exit code, byte-identical to before the channel existed.
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
314
|
+
// them the exit code, byte-identical to before the channel existed. The
|
|
315
|
+
// one-failure-per-file assumption the withdrawal rests on is enforced where
|
|
316
|
+
// it is spent (`./coverage-acceptance.mjs`), not stated here: an accepted
|
|
317
|
+
// file carrying a second failure refuses the run instead of losing both
|
|
318
|
+
// rows to a by-file splice.
|
|
315
319
|
const acceptedUnclaimed = new Set(
|
|
316
320
|
commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
|
|
317
321
|
);
|
|
318
322
|
if (acceptedUnclaimed.size > 0) {
|
|
319
|
-
|
|
320
|
-
if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
|
|
321
|
-
}
|
|
323
|
+
failures = withdrawAcceptedUnclaimedFailures(failures, acceptedUnclaimed);
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
// The go.work drift check, keyed off the manifest's presence the way every
|
|
@@ -40,10 +40,10 @@ export const EVALUATION_STATUS = Object.freeze({
|
|
|
40
40
|
REFUSED: "refused",
|
|
41
41
|
});
|
|
42
42
|
export const EVALUATED = EVALUATION_STATUS.EVALUATED;
|
|
43
|
-
export const PARTIAL = EVALUATION_STATUS.PARTIAL;
|
|
43
|
+
export const PARTIAL = EVALUATION_STATUS.PARTIAL; // used by its own test
|
|
44
44
|
export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
|
|
45
|
-
export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
|
|
46
|
-
export const REFUSED = EVALUATION_STATUS.REFUSED;
|
|
45
|
+
export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED; // used by its own test
|
|
46
|
+
export const REFUSED = EVALUATION_STATUS.REFUSED; // used by its own test
|
|
47
47
|
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
// Evaluation contract types — which gates are required per evaluation type
|
|
@@ -71,7 +71,7 @@ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
|
|
|
71
71
|
*
|
|
72
72
|
* @type {Readonly<Object<string, ReadonlySet<string>>>}
|
|
73
73
|
*/
|
|
74
|
-
|
|
74
|
+
const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
|
|
75
75
|
[EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
|
|
76
76
|
new Set([
|
|
77
77
|
"domainCoverage",
|
|
@@ -107,7 +107,7 @@ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
|
|
|
107
107
|
* Defaults to SCENARIO (most restrictive).
|
|
108
108
|
* @returns {boolean}
|
|
109
109
|
*/
|
|
110
|
-
|
|
110
|
+
function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
|
|
111
111
|
const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
|
|
112
112
|
return required ? required.has(gateKey) : true;
|
|
113
113
|
}
|
|
@@ -121,6 +121,7 @@ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES
|
|
|
121
121
|
* @type {Readonly<{key: string, label: string, type: string}[]>}
|
|
122
122
|
*/
|
|
123
123
|
export const EVIDENCE_COMPLETE_GATES = Object.freeze([
|
|
124
|
+
// used by its own test
|
|
124
125
|
{ key: "domainCoverage", label: "Domain coverage", type: "ratio" },
|
|
125
126
|
{ key: "claimEvidenceCoverage", label: "Claim evidence coverage", type: "ratio" },
|
|
126
127
|
{ key: "causalCoverage", label: "Causal coverage", type: "ratio" },
|
|
@@ -265,7 +266,7 @@ export const REQUIRED_DOMAINS = Object.freeze([
|
|
|
265
266
|
* @param {string} status EVALUATION_STATUS value
|
|
266
267
|
* @returns {boolean}
|
|
267
268
|
*/
|
|
268
|
-
|
|
269
|
+
function isDomainEvaluated(status) {
|
|
269
270
|
return status === EVALUATION_STATUS.EVALUATED;
|
|
270
271
|
}
|
|
271
272
|
|
|
@@ -111,3 +111,49 @@ export function partitionUnownedCoverage({ rows, unownedGap, unclaimedFiles, tra
|
|
|
111
111
|
},
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Withdraws each accepted unclaimed file's failure from the run's failure
|
|
117
|
+
* list — the acceptance channel's effect on it, kept beside the matching so
|
|
118
|
+
* the channel's contract (accept the file ⇒ withdraw its refusal) is one
|
|
119
|
+
* module's. `./check.mjs` holds the list and narrows `acceptedFiles` to the
|
|
120
|
+
* unclaimed half; `./waivers.mjs` has no failure list and only reports rows.
|
|
121
|
+
*
|
|
122
|
+
* An accepted unclaimed file carries exactly one failure — its unowned-file
|
|
123
|
+
* refusal, the only row any producer appends for a file no analyzer ever
|
|
124
|
+
* read. The withdrawal does not TRUST that assumption, it ENFORCES it: an
|
|
125
|
+
* accepted file carrying more than one failure means some producer appended a
|
|
126
|
+
* second row for it, and a splice by `sourceFile` would take the second one
|
|
127
|
+
* down with the first — the failure count falls, `coverage.notAnalyzed` loses
|
|
128
|
+
* a row, and a could-not-look run tips toward a clean one, the silent
|
|
129
|
+
* direction (`../../../../AGENTS.md`). The run refuses instead, naming the
|
|
130
|
+
* file and the count it found.
|
|
131
|
+
*
|
|
132
|
+
* @param {{sourceFile: string}[]} failures The run's failure list, in order.
|
|
133
|
+
* @param {Set<string>} acceptedFiles The accepted subset of the unclaimed
|
|
134
|
+
* list — `partitionUnownedCoverage`'s `acceptedFiles` narrowed to that list
|
|
135
|
+
* by the caller.
|
|
136
|
+
* @returns {{sourceFile: string}[]} A new array without the accepted files'
|
|
137
|
+
* single failures; the input is not mutated.
|
|
138
|
+
* @throws {Error} when an accepted file carries more than one failure.
|
|
139
|
+
*/
|
|
140
|
+
export function withdrawAcceptedUnclaimedFailures(failures, acceptedFiles) {
|
|
141
|
+
const carried = new Map();
|
|
142
|
+
for (const failure of failures) {
|
|
143
|
+
if (!acceptedFiles.has(failure.sourceFile)) continue;
|
|
144
|
+
carried.set(failure.sourceFile, (carried.get(failure.sourceFile) ?? 0) + 1);
|
|
145
|
+
}
|
|
146
|
+
const overloaded = [...carried.entries()].filter(([, count]) => count > 1);
|
|
147
|
+
if (overloaded.length > 0) {
|
|
148
|
+
const named = overloaded
|
|
149
|
+
.map(([file, count]) => `'${file}' (carries ${count} failures)`)
|
|
150
|
+
.join(", ");
|
|
151
|
+
throw new Error(
|
|
152
|
+
`archkeep: the coverage.unowned acceptance channel cannot withdraw ${named} — ` +
|
|
153
|
+
`acceptance withdraws exactly one failure per file (the unowned-file refusal), and ` +
|
|
154
|
+
`withdrawing by file would drop the second one with it, understating what this run ` +
|
|
155
|
+
`could not read`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return failures.filter((failure) => !acceptedFiles.has(failure.sourceFile));
|
|
159
|
+
}
|
|
@@ -140,6 +140,7 @@ export function declaresCustomRules(config) {
|
|
|
140
140
|
* @returns {(artifact: string) => Uint8Array|null}
|
|
141
141
|
*/
|
|
142
142
|
export function readArtifactBytes(root) {
|
|
143
|
+
// used by its own test
|
|
143
144
|
return (artifact) => {
|
|
144
145
|
const abs = join(root, artifact);
|
|
145
146
|
if (containmentViolation(root, abs) !== null) return null;
|
|
@@ -79,6 +79,7 @@ import { namespacedId } from "./custom-rules.mjs";
|
|
|
79
79
|
* constraint: object|null}}|{ok: false, reason: string}}
|
|
80
80
|
*/
|
|
81
81
|
export function violationIdentity(violation) {
|
|
82
|
+
// used by its own test
|
|
82
83
|
if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
|
|
83
84
|
return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
|
|
84
85
|
}
|
|
@@ -148,6 +149,7 @@ export function violationIdentity(violation) {
|
|
|
148
149
|
* original violation plus the `reason` its identity could not be stated.
|
|
149
150
|
*/
|
|
150
151
|
export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
|
|
152
|
+
// used by its own test
|
|
151
153
|
const baseIdentified = base.map(identityOf);
|
|
152
154
|
const headIdentified = head.map(identityOf);
|
|
153
155
|
|
|
@@ -237,6 +239,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
|
|
|
237
239
|
* unknown: object[]}}
|
|
238
240
|
*/
|
|
239
241
|
export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
242
|
+
// used by its own test
|
|
240
243
|
const attribute = sourceProjectOf ?? (() => null);
|
|
241
244
|
const baseIdentified = base
|
|
242
245
|
.filter(isUnresolvable)
|
|
@@ -52,7 +52,7 @@ import { isPlainObject } from "../values.mjs";
|
|
|
52
52
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
53
53
|
|
|
54
54
|
/** The only snapshot schemaVersion this module writes and reads. */
|
|
55
|
-
export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
55
|
+
export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1; // used by its own test
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Builds the snapshot object from already-captured evidence.
|
|
@@ -274,7 +274,9 @@ export function buildEvidenceSnapshot({
|
|
|
274
274
|
* order is the only order the format keeps, and `buildEvidenceSnapshot` sorts
|
|
275
275
|
* every array whose source does not guarantee it. Two captures over one
|
|
276
276
|
* unchanged tree produce byte-identical files, which is what makes a plain
|
|
277
|
-
* `diff` of two baselines meaningful.
|
|
277
|
+
* `diff` of two baselines meaningful. That determinism is also why this
|
|
278
|
+
* family carries no identity field, by decision: the bytes are the identity
|
|
279
|
+
* (`../../../../docs/adr/0008-snapshot-identity-per-family.md`).
|
|
278
280
|
*
|
|
279
281
|
* @param {object} snapshot From `buildEvidenceSnapshot`.
|
|
280
282
|
* @returns {string} The JSON text, newline-terminated.
|
|
@@ -330,7 +332,11 @@ export function readEvidenceSnapshot(path, io = {}) {
|
|
|
330
332
|
* What is deliberately NOT a refusal: dirty base provenance. A baseline from
|
|
331
333
|
* an uncommitted tree is weaker evidence, not unreadable evidence — the parsed
|
|
332
334
|
* snapshot exposes `provenance.dirty` so the renderer can say so loudly, and
|
|
333
|
-
* classification itself proceeds.
|
|
335
|
+
* classification itself proceeds. A dirty BIT that is not a boolean is a
|
|
336
|
+
* different thing — malformed, not weaker: the gates read `dirty === true`
|
|
337
|
+
* while the renderers interpolate truthiness, so any other type would read
|
|
338
|
+
* clean to some consumers and dirty to others. The provenance block below
|
|
339
|
+
* refuses it by name.
|
|
334
340
|
*
|
|
335
341
|
* @param {string} text The file contents.
|
|
336
342
|
* @param {string} path The path the text came from, for error messages.
|
|
@@ -338,6 +344,7 @@ export function readEvidenceSnapshot(path, io = {}) {
|
|
|
338
344
|
* @throws {Error} on every condition above.
|
|
339
345
|
*/
|
|
340
346
|
export function parseEvidenceSnapshot(text, path) {
|
|
347
|
+
// used by its own test
|
|
341
348
|
let parsed;
|
|
342
349
|
try {
|
|
343
350
|
parsed = JSON.parse(text);
|
|
@@ -391,8 +398,23 @@ export function parseEvidenceSnapshot(text, path) {
|
|
|
391
398
|
|
|
392
399
|
if (parsed.provenance !== null && !isPlainObject(parsed.provenance)) {
|
|
393
400
|
problems.push("provenance: must be an object ({commit, remote, dirty}) or null");
|
|
394
|
-
} else if (isPlainObject(parsed.provenance)
|
|
395
|
-
|
|
401
|
+
} else if (isPlainObject(parsed.provenance)) {
|
|
402
|
+
if (typeof parsed.provenance.commit !== "string") {
|
|
403
|
+
problems.push("provenance.commit: must be a string when provenance is present");
|
|
404
|
+
}
|
|
405
|
+
if (parsed.provenance.remote !== null && typeof parsed.provenance.remote !== "string") {
|
|
406
|
+
problems.push("provenance.remote: must be a string or null when provenance is present");
|
|
407
|
+
}
|
|
408
|
+
if (typeof parsed.provenance.dirty !== "boolean") {
|
|
409
|
+
// Malformed, not weaker: the gates read `dirty === true` while the
|
|
410
|
+
// renderers interpolate truthiness, so a value of any other type reads
|
|
411
|
+
// clean to some consumers and dirty to others — the one disagreement
|
|
412
|
+
// this record must never carry.
|
|
413
|
+
problems.push(
|
|
414
|
+
"provenance.dirty: must be a boolean when provenance is present — the gates decide " +
|
|
415
|
+
"between committed and uncommitted evidence on this bit",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
396
418
|
}
|
|
397
419
|
|
|
398
420
|
if (typeof parsed.policyFingerprint !== "string" || parsed.policyFingerprint === "") {
|
package/src/commands/delta.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
75
75
|
import { referenceTime } from "../governance/clock.mjs";
|
|
76
76
|
import {
|
|
77
|
+
assertReproducibleEventIdentity,
|
|
77
78
|
eventDedupeKey,
|
|
78
79
|
eventId,
|
|
79
80
|
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
@@ -105,9 +106,10 @@ import {
|
|
|
105
106
|
} from "./delta-snapshot.mjs";
|
|
106
107
|
import { computeDiff } from "./diff.mjs";
|
|
107
108
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
109
|
+
import { eventSnapshotSide } from "./history.mjs";
|
|
108
110
|
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
109
111
|
import { resolveProvenance } from "./provenance.mjs";
|
|
110
|
-
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
112
|
+
import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
|
|
111
113
|
|
|
112
114
|
const require = createRequire(import.meta.url);
|
|
113
115
|
/** @type {{name: string, version: string}} */
|
|
@@ -161,6 +163,7 @@ export function refusePluginGapHead(commandContext, activity) {
|
|
|
161
163
|
* @throws {Error} on either condition.
|
|
162
164
|
*/
|
|
163
165
|
export function refuseUnjudgeableHead(commandContext, activity) {
|
|
166
|
+
// used by its own test
|
|
164
167
|
refusePluginGapHead(commandContext, activity);
|
|
165
168
|
const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
|
|
166
169
|
|
|
@@ -271,6 +274,8 @@ export function captureDelta(commandContext, { config }) {
|
|
|
271
274
|
* type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
|
|
272
275
|
* A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
|
|
273
276
|
* @returns {object} A graph `evaluate()` consumes.
|
|
277
|
+
* @throws {Error} when an `exemptedFiles` entry is not a string — the engine's
|
|
278
|
+
* own filter would drop it in silence (the refusal beside the copy below).
|
|
274
279
|
*/
|
|
275
280
|
export function evidenceGraphToProjectGraph(storedGraph) {
|
|
276
281
|
/** @type {Record<string, object>} */
|
|
@@ -297,7 +302,26 @@ export function evidenceGraphToProjectGraph(storedGraph) {
|
|
|
297
302
|
const graph = { nodes, dependencies };
|
|
298
303
|
if (storedGraph.workspaceLayout !== undefined)
|
|
299
304
|
graph.workspaceLayout = storedGraph.workspaceLayout;
|
|
300
|
-
if (Array.isArray(storedGraph.exemptedFiles))
|
|
305
|
+
if (Array.isArray(storedGraph.exemptedFiles)) {
|
|
306
|
+
// An entry that is not a string is refused here rather than carried:
|
|
307
|
+
// `../rules/index.mjs`'s `createContext` filters `graph.exemptedFiles`
|
|
308
|
+
// with `typeof file === "string"`, so a corrupted snapshot's entry would
|
|
309
|
+
// ride through this conversion and vanish there — the exemption set the
|
|
310
|
+
// snapshot recorded silently shrinking by one file, the under-count
|
|
311
|
+
// disclosed nowhere.
|
|
312
|
+
const malformed = storedGraph.exemptedFiles
|
|
313
|
+
.map((entry, at) => ({ entry, at }))
|
|
314
|
+
.filter(({ entry }) => typeof entry !== "string");
|
|
315
|
+
if (malformed.length > 0) {
|
|
316
|
+
const { entry, at } = malformed[0];
|
|
317
|
+
throw new Error(
|
|
318
|
+
`archkeep: the snapshot's graph.exemptedFiles[${at}] is ${JSON.stringify(entry)}, ` +
|
|
319
|
+
`not a string — the rule engine drops such entries in silence, which would shrink ` +
|
|
320
|
+
`the exemption set the snapshot recorded; re-capture the baseline or correct the file`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
graph.exemptedFiles = storedGraph.exemptedFiles;
|
|
324
|
+
}
|
|
301
325
|
return graph;
|
|
302
326
|
}
|
|
303
327
|
|
|
@@ -373,6 +397,7 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
|
373
397
|
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
374
398
|
*/
|
|
375
399
|
export function deltaDisposition({ status }) {
|
|
400
|
+
// used by its own test
|
|
376
401
|
if (status === "no-verdict") return "no-verdict";
|
|
377
402
|
if (status === "findings") return "rejected";
|
|
378
403
|
return "accepted";
|
|
@@ -545,16 +570,10 @@ export async function deltaCommand(
|
|
|
545
570
|
);
|
|
546
571
|
}
|
|
547
572
|
if (meta.dirtyBaseline) {
|
|
548
|
-
notes.push(
|
|
549
|
-
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
550
|
-
"claim about the commit it names",
|
|
551
|
-
);
|
|
573
|
+
notes.push(dirtyBaselineNote(false));
|
|
552
574
|
}
|
|
553
575
|
if (meta.dirtyHead) {
|
|
554
|
-
notes.push(
|
|
555
|
-
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
556
|
-
"commit HEAD names",
|
|
557
|
-
);
|
|
576
|
+
notes.push(dirtyHeadNote());
|
|
558
577
|
}
|
|
559
578
|
|
|
560
579
|
// The event's structural signal: what moved between the two graphs the
|
|
@@ -564,10 +583,11 @@ export async function deltaCommand(
|
|
|
564
583
|
// a second spelling of "changed". The head side is rebuilt by the same
|
|
565
584
|
// `buildProjects`/`buildDependencies` the snapshot stores
|
|
566
585
|
// (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
|
|
567
|
-
const
|
|
586
|
+
const headGraph = {
|
|
568
587
|
projects: buildProjects(graph.nodes),
|
|
569
588
|
dependencies: buildDependencies(graph.dependencies),
|
|
570
|
-
}
|
|
589
|
+
};
|
|
590
|
+
const structuralDiff = computeDiff(baseline.graph, headGraph);
|
|
571
591
|
const structural = {
|
|
572
592
|
projects: {
|
|
573
593
|
added: structuralDiff.addedProjects.map((project) => project.name),
|
|
@@ -594,6 +614,25 @@ export async function deltaCommand(
|
|
|
594
614
|
// same", and a policy change is disclosed, not folded into drift.
|
|
595
615
|
const baseCommit = baseline.provenance?.commit;
|
|
596
616
|
const headCommit = headProvenance?.commit;
|
|
617
|
+
// The event's identity sides are frozen HERE — at the same instant the diff
|
|
618
|
+
// above was computed from these same graph objects — so the identity always
|
|
619
|
+
// describes the graph the diff actually compared. Everything below this line
|
|
620
|
+
// (custom rules, classification, intent, debt) reads the graphs too; the
|
|
621
|
+
// engine's purity is what keeps them honest today, but the WS-I adversarial
|
|
622
|
+
// audit named the seam: identity and diff agreeing must not rest on purity
|
|
623
|
+
// conventions alone when one structural line closes it.
|
|
624
|
+
const eventBase = eventSnapshotSide({
|
|
625
|
+
revision: baseCommit,
|
|
626
|
+
projects: baseline.graph.projects,
|
|
627
|
+
dependencies: baseline.graph.dependencies,
|
|
628
|
+
policyFingerprint: baseline.policyFingerprint,
|
|
629
|
+
});
|
|
630
|
+
const eventHead = eventSnapshotSide({
|
|
631
|
+
revision: headCommit,
|
|
632
|
+
projects: headGraph.projects,
|
|
633
|
+
dependencies: headGraph.dependencies,
|
|
634
|
+
policyFingerprint: headFingerprint,
|
|
635
|
+
});
|
|
597
636
|
const provenanceAdvanced =
|
|
598
637
|
typeof baseCommit === "string" &&
|
|
599
638
|
typeof headCommit === "string" &&
|
|
@@ -810,26 +849,16 @@ export async function deltaCommand(
|
|
|
810
849
|
let eventWrite = null;
|
|
811
850
|
if (eventOut !== null && eventOut !== undefined) {
|
|
812
851
|
// F-delta-event-id: an evolution event is only written from a reproducible
|
|
813
|
-
// identity — a committed, clean head and a clean base
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
"collide on one event id. Commit the head, or capture without --event-out.",
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
|
|
827
|
-
throw new Error(
|
|
828
|
-
"archkeep: refusing to write a delta event from a dirty working tree — the event " +
|
|
829
|
-
"would name a commit whose evidence is uncommitted, and distinct uncommitted " +
|
|
830
|
-
"states would collide on one event id. Commit both sides first.",
|
|
831
|
-
);
|
|
832
|
-
}
|
|
852
|
+
// identity — a committed, clean head and a clean base, by the shared law
|
|
853
|
+
// `assertReproducibleEventIdentity` owns. The wording is frozen there; the
|
|
854
|
+
// messages consumers match on are byte-identical to the inline refusals
|
|
855
|
+
// this call replaces.
|
|
856
|
+
assertReproducibleEventIdentity({
|
|
857
|
+
label: "delta",
|
|
858
|
+
headCommit,
|
|
859
|
+
baseDirty: baseline.provenance?.dirty === true,
|
|
860
|
+
headDirty: headProvenance?.dirty === true,
|
|
861
|
+
});
|
|
833
862
|
// The architecture-debt sub-ledger (design §8): judged by re-running the
|
|
834
863
|
// current intent over this run's base and head graphs — a drift finding
|
|
835
864
|
// present at head but not base is introduced; one gone is resolved. Both
|
|
@@ -864,13 +893,18 @@ export async function deltaCommand(
|
|
|
864
893
|
schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
865
894
|
kind: "transition",
|
|
866
895
|
source: "delta",
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
head:
|
|
896
|
+
// Both sides were frozen through the ONE identity spelling
|
|
897
|
+
// (`eventSnapshotSide`, ./history.mjs) at the diff site above: a
|
|
898
|
+
// revision when one is known, plus the snapshot identity of the graph
|
|
899
|
+
// the diff compared — never the baseline's storage path, which is
|
|
900
|
+
// machine-local and must not make the identity a per-machine property.
|
|
901
|
+
base: eventBase,
|
|
902
|
+
head: eventHead,
|
|
903
|
+
// The evidence ref is the baseline file this run actually compared
|
|
904
|
+
// against — a pointer into the evidence, never a graph, and disclosed
|
|
905
|
+
// OUTSIDE the identity: the tuple above names the state itself, so a
|
|
906
|
+
// relocated baseline is still the same event.
|
|
907
|
+
evidence: baselinePath,
|
|
874
908
|
recordedAt: recordOrigin({
|
|
875
909
|
by: "cli",
|
|
876
910
|
tool: `archkeep:v${TOOL_VERSION}`,
|
package/src/commands/diff.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
|
46
46
|
import { SCHEMA_VERSION } from "../report/json.mjs";
|
|
47
47
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
48
48
|
import { formatDiffReport } from "../report/diff-text.mjs";
|
|
49
|
-
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
49
|
+
import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
|
|
50
50
|
import { resolveProvenance } from "./provenance.mjs";
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -475,16 +475,10 @@ export function diffCommand(
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
if (meta.dirtyBaseline) {
|
|
478
|
-
coverage.notes.push(
|
|
479
|
-
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
480
|
-
"claim about the commit it names",
|
|
481
|
-
);
|
|
478
|
+
coverage.notes.push(dirtyBaselineNote(false));
|
|
482
479
|
}
|
|
483
480
|
if (meta.dirtyHead) {
|
|
484
|
-
coverage.notes.push(
|
|
485
|
-
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
486
|
-
"commit HEAD names",
|
|
487
|
-
);
|
|
481
|
+
coverage.notes.push(dirtyHeadNote());
|
|
488
482
|
}
|
|
489
483
|
|
|
490
484
|
if (meta.policyChanged === true) {
|