@ecoma-io/archkeep 0.24.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -103,12 +103,14 @@ 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 { fitnessVerdict } from "../governance/verdict.mjs";
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 {
@@ -368,6 +370,9 @@ function reconciliationVerdict(lists, unprovenReasons) {
368
370
  return "matched";
369
371
  }
370
372
 
373
+ /** The verdict axis `reconcileDisposition` maps — the four `reconciliationVerdict` answers. */
374
+ const RECONCILE_VERDICTS = Object.freeze(["matched", "undeclared", "unfulfilled", "unproven"]);
375
+
371
376
  /**
372
377
  * The reconcile DISPOSITION mapping (wave 3, design §5) — the verdict axis
373
378
  * first, then the declared constraints' verdicts. Pure and exported so the
@@ -390,8 +395,33 @@ function reconciliationVerdict(lists, unprovenReasons) {
390
395
  * this run — empty when none were declared or when the base identity was
391
396
  * unproven (constraints are left unevaluated then).
392
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.
393
402
  */
394
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
+ }
395
425
  // used by its own test
396
426
  if (verdict === "unproven") return "no-verdict";
397
427
  if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
@@ -648,6 +678,28 @@ export async function changeCommand(
648
678
  dependencies: buildDependencies(graph.dependencies),
649
679
  };
650
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
+ });
651
703
  const reconciliation = reconcileMaterialDelta(intent, structural);
652
704
 
653
705
  // Constraints and the law axis are computed over both sides re-judged under
@@ -736,16 +788,10 @@ export async function changeCommand(
736
788
  );
737
789
  }
738
790
  if (meta.dirtyBaseline) {
739
- notes.push(
740
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
741
- "claim about the commit the contract pins",
742
- );
791
+ notes.push(dirtyBaselineNote(true));
743
792
  }
744
793
  if (meta.dirtyHead) {
745
- notes.push(
746
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
747
- "commit HEAD names",
748
- );
794
+ notes.push(dirtyHeadNote());
749
795
  }
750
796
  if (meta.provenanceOneSided) {
751
797
  notes.push(
@@ -855,23 +901,24 @@ export async function changeCommand(
855
901
  }
856
902
  }
857
903
 
858
- const baseCommit = baseline.provenance?.commit;
859
- const headCommit = headProvenance?.commit;
860
904
  /** @type {object} */
861
905
  const event = {
862
906
  schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
863
907
  kind: "reconcile",
864
908
  source: "change",
865
- base: {
866
- ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
867
- // The caller's own evidence ref the baseline file this run consumed,
868
- // spelled as the run received it (the same convention `declaration.file`
869
- // uses for the intent path).
870
- evidence: baselinePath,
871
- },
872
- head: {
873
- ...(typeof headCommit === "string" ? { revision: headCommit } : {}),
874
- },
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,
875
922
  declaration: { file: intentPath, digest: declarationDigest(intent) },
876
923
  observed,
877
924
  affected: evolution.affected,
@@ -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.
@@ -392,8 +398,23 @@ export function parseEvidenceSnapshot(text, path) {
392
398
 
393
399
  if (parsed.provenance !== null && !isPlainObject(parsed.provenance)) {
394
400
  problems.push("provenance: must be an object ({commit, remote, dirty}) or null");
395
- } else if (isPlainObject(parsed.provenance) && typeof parsed.provenance.commit !== "string") {
396
- problems.push("provenance.commit: must be a string when provenance is present");
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
+ }
397
418
  }
398
419
 
399
420
  if (typeof parsed.policyFingerprint !== "string" || parsed.policyFingerprint === "") {
@@ -106,9 +106,10 @@ import {
106
106
  } from "./delta-snapshot.mjs";
107
107
  import { computeDiff } from "./diff.mjs";
108
108
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
109
+ import { eventSnapshotSide } from "./history.mjs";
109
110
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
110
111
  import { resolveProvenance } from "./provenance.mjs";
111
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
112
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
112
113
 
113
114
  const require = createRequire(import.meta.url);
114
115
  /** @type {{name: string, version: string}} */
@@ -569,16 +570,10 @@ export async function deltaCommand(
569
570
  );
570
571
  }
571
572
  if (meta.dirtyBaseline) {
572
- notes.push(
573
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
574
- "claim about the commit it names",
575
- );
573
+ notes.push(dirtyBaselineNote(false));
576
574
  }
577
575
  if (meta.dirtyHead) {
578
- notes.push(
579
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
580
- "commit HEAD names",
581
- );
576
+ notes.push(dirtyHeadNote());
582
577
  }
583
578
 
584
579
  // The event's structural signal: what moved between the two graphs the
@@ -588,10 +583,11 @@ export async function deltaCommand(
588
583
  // a second spelling of "changed". The head side is rebuilt by the same
589
584
  // `buildProjects`/`buildDependencies` the snapshot stores
590
585
  // (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
591
- const structuralDiff = computeDiff(baseline.graph, {
586
+ const headGraph = {
592
587
  projects: buildProjects(graph.nodes),
593
588
  dependencies: buildDependencies(graph.dependencies),
594
- });
589
+ };
590
+ const structuralDiff = computeDiff(baseline.graph, headGraph);
595
591
  const structural = {
596
592
  projects: {
597
593
  added: structuralDiff.addedProjects.map((project) => project.name),
@@ -618,6 +614,25 @@ export async function deltaCommand(
618
614
  // same", and a policy change is disclosed, not folded into drift.
619
615
  const baseCommit = baseline.provenance?.commit;
620
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
+ });
621
636
  const provenanceAdvanced =
622
637
  typeof baseCommit === "string" &&
623
638
  typeof headCommit === "string" &&
@@ -878,13 +893,18 @@ export async function deltaCommand(
878
893
  schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
879
894
  kind: "transition",
880
895
  source: "delta",
881
- base: {
882
- ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
883
- // The evidence ref is the baseline file this run actually compared
884
- // against a pointer into the evidence, never a graph.
885
- evidence: baselinePath,
886
- },
887
- head: typeof headCommit === "string" ? { revision: headCommit } : {},
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,
888
908
  recordedAt: recordOrigin({
889
909
  by: "cli",
890
910
  tool: `archkeep:v${TOOL_VERSION}`,
@@ -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) {
@@ -112,6 +112,12 @@ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
112
112
  * Provider and provenance changes surface through the transition
113
113
  * classification instead.
114
114
  *
115
+ * This function is the graph-snapshot family's ONE identity — evolution event
116
+ * sides consume its output through `eventSnapshotSide` below, never a second
117
+ * derivation (`../../../../docs/adr/0008-snapshot-identity-per-family.md`
118
+ * records the one-identity-per-family decision, including why the evidence
119
+ * snapshot family deliberately carries no id at all).
120
+ *
115
121
  * @param {{projects: object[], dependencies: object[], policy?: {fingerprint: string}|null}} snapshot
116
122
  * @returns {string} A hex-encoded SHA-256.
117
123
  */
@@ -136,6 +142,34 @@ export function snapshotIdentity({ projects, dependencies, policy }) {
136
142
  return createHash("sha256").update(canonical).digest("hex");
137
143
  }
138
144
 
145
+ /**
146
+ * An evolution event's identity side: the reference to ONE state — a revision
147
+ * when one is known, plus the snapshot identity of the graph that side was
148
+ * judged over. `delta` and `change` build every side through this one
149
+ * function so the identity spelling cannot drift between them, and a storage
150
+ * path can never re-enter the tuple a committed event store dedupes on
151
+ * (`docs/concepts/evolution.md`'s "Nor does a storage path"). `history` and
152
+ * `evolution` spell the same `{revision, snapshot}` shape directly, from a
153
+ * snapshot whose identity is already computed.
154
+ *
155
+ * @param {{revision?: string|null, projects: object[], dependencies: object[],
156
+ * policyFingerprint?: string|null}} side
157
+ * @returns {{revision?: string, snapshot: string}} The identity side.
158
+ */
159
+ export function eventSnapshotSide({ revision, projects, dependencies, policyFingerprint }) {
160
+ return {
161
+ ...(typeof revision === "string" ? { revision } : {}),
162
+ snapshot: snapshotIdentity({
163
+ projects,
164
+ dependencies,
165
+ policy:
166
+ policyFingerprint === undefined || policyFingerprint === null
167
+ ? null
168
+ : { fingerprint: policyFingerprint },
169
+ }),
170
+ };
171
+ }
172
+
139
173
  /**
140
174
  * Reads and validates every snapshot in the history directory.
141
175
  *
@@ -23,6 +23,39 @@
23
23
  * `null` before treating either as a boolean.
24
24
  */
25
25
 
26
+ /**
27
+ * The one wording for the dirty-baseline disclosure, shared by every command
28
+ * that compares a snapshot pair (`diff`, `delta`, `change`). Three copies of
29
+ * one disclosure spell drift three ways, so the wording lives beside the fact
30
+ * it translates. A consumer that pins the baseline by its own contract names
31
+ * that pin (`pinned`) — `change`'s contract carries `base.commit`; a diff or
32
+ * delta pair has no such pin, so its note names the commit the snapshot
33
+ * itself claims.
34
+ *
35
+ * @param {boolean} pinned Whether the consumer's contract pins the base commit.
36
+ * @returns {string} The disclosure note.
37
+ */
38
+ export function dirtyBaselineNote(pinned) {
39
+ return pinned
40
+ ? "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
41
+ "claim about the commit the contract pins"
42
+ : "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
43
+ "claim about the commit it names";
44
+ }
45
+
46
+ /**
47
+ * The one wording for the dirty-head disclosure — byte-identical in every
48
+ * consumer, so it is stated exactly once.
49
+ *
50
+ * @returns {string} The disclosure note.
51
+ */
52
+ export function dirtyHeadNote() {
53
+ return (
54
+ "this run's working tree is dirty — the head side describes uncommitted state, not the " +
55
+ "commit HEAD names"
56
+ );
57
+ }
58
+
26
59
  /**
27
60
  * Compares the provider, provenance, and policy fingerprint of two graph
28
61
  * envelopes.
@@ -25,10 +25,7 @@
25
25
  * to reach it.
26
26
  */
27
27
 
28
- export { isPlainObject } from "../values.mjs";
29
-
30
- /** @type {(value: unknown) => boolean} */
31
- export const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
28
+ export { isPlainObject, isNonEmptyString } from "../values.mjs";
32
29
 
33
30
  /**
34
31
  * A value's type, for a refusal that shows what was actually there.
@@ -52,7 +52,7 @@ import { languageOf } from "../analysis/registry.mjs";
52
52
  import { canonicalizeJson } from "../canonical.mjs";
53
53
  import { describe, isPlainObject } from "../values.mjs";
54
54
  import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./row-schema.mjs";
55
- import { fitnessVerdict, isVerdict } from "./verdict.mjs";
55
+ import { VERDICTS, fitnessVerdict, isVerdict } from "./verdict.mjs";
56
56
  import {
57
57
  coverageMinimum,
58
58
  cycleFree,
@@ -481,6 +481,19 @@ export function evaluateFitness(rows, snapshot) {
481
481
  * @returns {{verdict: "pass"|"fail"|"unknown"|"not_applicable", decisions: object[]}}
482
482
  */
483
483
  export function fitnessVerdictFor(decisions) {
484
+ // The fold is an overall-verdict constructor, so it owns the same vocabulary
485
+ // latch `buildDecision` does rather than trusting every caller to have
486
+ // latched its rows first: a stranger verdict matches none of the checks
487
+ // below and falls through to `pass` — the loudest clean state a governance
488
+ // run can emit, fabricated from a row nobody judged.
489
+ for (const decision of decisions) {
490
+ if (!isVerdict(decision.verdict)) {
491
+ throw new Error(
492
+ `archkeep: refusing to fold a fitness row whose verdict is ${describe(decision.verdict)} — ` +
493
+ `expected one of ${VERDICTS.join(", ")}. This is a bug in the rule that judged the row.`,
494
+ );
495
+ }
496
+ }
484
497
  if (decisions.some((d) => d.verdict === "fail")) return { verdict: "fail", decisions };
485
498
  if (decisions.some((d) => d.verdict === "unknown")) return { verdict: "unknown", decisions };
486
499
  if (decisions.length === 0 || decisions.every((d) => d.verdict === "not_applicable")) {
@@ -43,6 +43,8 @@
43
43
  * path — a path, never a second implementation.
44
44
  */
45
45
 
46
+ import { describe, isNonEmptyString } from "../values.mjs";
47
+
46
48
  /** The four canonical verdict values. */
47
49
  export const VERDICTS = Object.freeze(["pass", "fail", "unknown", "not_applicable"]);
48
50
 
@@ -113,7 +115,7 @@ export function fitnessVerdict({ verdict, name, evidence, message, rows, notAppl
113
115
  `expected one of ${VERDICTS.join(", ")}.`,
114
116
  );
115
117
  }
116
- if (verdict === "not_applicable" && notApplicableReason === undefined) {
118
+ if (verdict === "not_applicable" && !isNonEmptyString(notApplicableReason)) {
117
119
  throw new Error(
118
120
  `archkeep: fitness function "${name}" returned "not_applicable" without ` +
119
121
  `notApplicableReason — invariant I4: the reader must be told why the ` +
@@ -206,6 +208,21 @@ export function buildDecision(run) {
206
208
  throw new Error("archkeep: buildDecision needs either a status or an explicit verdict");
207
209
  }
208
210
  const verdict = run.verdict ?? verdictForStatus(run.status);
211
+ // The vocabulary latch. `verdictForStatus` cannot produce a stranger, so
212
+ // this refuses exactly the explicitly-passed verdicts — and it must run
213
+ // BEFORE the status-conflict check below, so a malformed verdict reports
214
+ // the vocabulary it violated rather than a conflict it never had. Without
215
+ // this latch, "perhaps", "PASS", `42` and `{}` fell through the
216
+ // pass/fail/unknown arms into the not_applicable one and SHIPPED whenever
217
+ // `notApplicableReason` happened to be truthy — a byte-legal decision
218
+ // about a workspace nobody judged.
219
+ if (!isVerdict(verdict)) {
220
+ throw new Error(
221
+ `archkeep: refusing to build a decision with verdict ${describe(run.verdict)} — ` +
222
+ `expected one of ${VERDICTS.join(", ")}. This is a bug in the command that ` +
223
+ `built the decision.`,
224
+ );
225
+ }
209
226
  if (
210
227
  run.verdict !== undefined &&
211
228
  run.status !== undefined &&
@@ -260,17 +277,32 @@ export function buildDecision(run) {
260
277
  }
261
278
 
262
279
  if (verdict === "unknown") {
280
+ // An absent reason (undefined/null) still defaults to the generic one —
281
+ // absence is not emptiness. A SUPPLIED reason must actually say
282
+ // something: "" and " " are byte-present but semantically absent, and
283
+ // a non-string reason would ship a `typeof` artifact where the reader
284
+ // was promised a sentence (I3).
263
285
  const reason =
264
286
  run.reason ??
265
287
  (run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
288
+ if (!isNonEmptyString(reason)) {
289
+ throw new Error(
290
+ `archkeep: refusing to emit an "unknown" decision with a ${describe(run.reason)} reason — ` +
291
+ `I3 requires a non-empty reason naming why no verdict was reached. ` +
292
+ `This is a bug in the command that built the decision.`,
293
+ );
294
+ }
266
295
  return withSampleTime({ verdict, reason }, run.sampleTime);
267
296
  }
268
297
 
269
- // verdict === "not_applicable" (I4).
270
- if (!run.notApplicableReason) {
298
+ // verdict === "not_applicable" (I4). The reason must be a string a reader
299
+ // could act on — the falsy check this replaces refused "" but SHIPPED
300
+ // " " and non-strings, the same byte-present-semantically-absent hole.
301
+ if (!isNonEmptyString(run.notApplicableReason)) {
271
302
  throw new Error(
272
303
  `archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
273
- `"did not apply" and "did not run" must never be indistinguishable. ` +
304
+ `"did not apply" and "did not run" must never be indistinguishable, and a reason of ` +
305
+ `${describe(run.notApplicableReason)} tells the reader nothing. ` +
274
306
  `This is a bug in the command that built the decision.`,
275
307
  );
276
308
  }
@@ -25,7 +25,7 @@
25
25
  {
26
26
  "type": "source-evidence",
27
27
  "path": "src/commands/context.mjs",
28
- "assertion": "Only commands/context.mjs imports providers — the designated orchestration layer",
28
+ "assertion": "commands/context.mjs imports providers — the designated orchestration path",
29
29
  "sha256": "b505e3a7476c64af4b2daeb106e2f9cb05be82e314604229d508dda04b629a98"
30
30
  }
31
31
  ],
@@ -40,7 +40,7 @@
40
40
  "type": "behavioral-test",
41
41
  "path": "src/report/json.test.mjs",
42
42
  "assertion": "JSON envelope throws on status=ok + incomplete coverage, status/exitCode disagreement, coverage.complete/notAnalyzed disagreement",
43
- "sha256": "a371d36b89b10c5a4bda4d9e9a2e3bde114256c1377eb95699c9f0f195aabc4a"
43
+ "sha256": "8f587d3bd3f06519b90f760e7eafc091ca4e4cf26b67929ab180abe35c36c8f0"
44
44
  },
45
45
  {
46
46
  "type": "source-evidence",
@@ -66,13 +66,13 @@
66
66
  "type": "behavioral-test",
67
67
  "path": "src/intent/intent.test.mjs",
68
68
  "assertion": "analysis output conforms to the frozen contract schema (no extra verdict/policy fields); analysis output is invariant under project tag changes (three workspaces differing only in project tag fields compared byte-for-byte)",
69
- "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
69
+ "sha256": "85b98de1f9268c94020b8b728a3ebbf1e8b50f8c70731c5460c788ea00be3198"
70
70
  },
71
71
  {
72
72
  "type": "architecture-test",
73
73
  "path": "src/intent/intent.test.mjs",
74
74
  "assertion": "the gate walks every production analysis module and fails when judging vocabulary (judge/forbid/permit/allow/ban) appears in code",
75
- "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
75
+ "sha256": "85b98de1f9268c94020b8b728a3ebbf1e8b50f8c70731c5460c788ea00be3198"
76
76
  },
77
77
  {
78
78
  "type": "source-evidence",
@@ -103,9 +103,15 @@
103
103
  {
104
104
  "type": "source-evidence",
105
105
  "path": "src/commands/graph.mjs",
106
- "assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped; SCHEMA_VERSION = 2",
106
+ "assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped",
107
107
  "sha256": "436902ab3ca994c233318437a896630f969a2c8a22b0f93e598d309faed39d49"
108
108
  },
109
+ {
110
+ "type": "source-evidence",
111
+ "path": "src/report/json.mjs",
112
+ "assertion": "SCHEMA_VERSION = 2 — the envelope's schemaVersion constant",
113
+ "sha256": "090418f6f60dec51638976dff658c55f8a932012bfc7c8bc59952b0f03543340"
114
+ },
109
115
  {
110
116
  "type": "source-evidence",
111
117
  "path": "src/commands/graph.mjs",
@@ -130,7 +136,7 @@
130
136
  "type": "source-evidence",
131
137
  "path": "src/commands/diff.mjs",
132
138
  "assertion": "parseBaseline validates schemaVersion; refuses unknown versions",
133
- "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
139
+ "sha256": "e28620e288b58e35916e8cd41de446b999bdc2c4e5fcb5f3a617ab0ee449b0fd"
134
140
  }
135
141
  ],
136
142
  "status": "proven"
@@ -150,7 +156,7 @@
150
156
  "type": "source-evidence",
151
157
  "path": "src/commands/diff.mjs",
152
158
  "assertion": "computeDiff returns structural diff; policyMismatch detected via fingerprint; computeRuleImpact for depConstraints-only context",
153
- "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
159
+ "sha256": "e28620e288b58e35916e8cd41de446b999bdc2c4e5fcb5f3a617ab0ee449b0fd"
154
160
  }
155
161
  ],
156
162
  "status": "proven"
@@ -230,7 +236,7 @@
230
236
  "type": "behavioral-test",
231
237
  "path": "src/intent/intent.test.mjs",
232
238
  "assertion": "depConstraints verdicts from judgeEdge agree with evaluate in both directions (violating edge found by both, legal edge reported by neither); explain includes the same violations as evaluate at a given site",
233
- "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
239
+ "sha256": "85b98de1f9268c94020b8b728a3ebbf1e8b50f8c70731c5460c788ea00be3198"
234
240
  },
235
241
  {
236
242
  "type": "behavioral-test",
@@ -260,7 +266,7 @@
260
266
  "type": "source-evidence",
261
267
  "path": "src/commands/diff.mjs",
262
268
  "assertion": "coverage.notes warns when ruleImpact is computed (depConstraints only, 3 of 15)",
263
- "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
269
+ "sha256": "e28620e288b58e35916e8cd41de446b999bdc2c4e5fcb5f3a617ab0ee449b0fd"
264
270
  },
265
271
  {
266
272
  "type": "documentation",
@@ -39,6 +39,7 @@
39
39
  */
40
40
  import { EXIT_FOR_STATUS } from "../verdict.mjs";
41
41
  import { verdictForStatus } from "../governance/verdict.mjs";
42
+ import { describe, isNonEmptyString } from "../values.mjs";
42
43
  import { createRequire } from "node:module";
43
44
 
44
45
  const require = createRequire(import.meta.url);
@@ -163,10 +164,11 @@ export function jsonEnvelope({ command, context, status, exitCode, coverage, res
163
164
  // `result`. A hand-built "fail" with no findings is still a loud lie
164
165
  // (status "findings", exitCode 1, verdict "fail"), never the silent
165
166
  // direction.
166
- if (decision.verdict === "unknown" && decision.reason === undefined) {
167
+ if (decision.verdict === "unknown" && !isNonEmptyString(decision.reason)) {
167
168
  throw new Error(
168
169
  `archkeep: refusing to build a JSON envelope where an "unknown" decision has no reason — ` +
169
- `I3: an unknown verdict must say why no verdict was reached, or it reads as a shrug. ` +
170
+ `I3: an unknown verdict must say why no verdict was reached, or it reads as a shrug ` +
171
+ `(a reason of ${describe(decision.reason)} is not a reason). ` +
170
172
  `This is a bug in the command that built the envelope.`,
171
173
  );
172
174
  }
package/src/values.mjs CHANGED
@@ -36,6 +36,17 @@ export const isPlainObject = (value) =>
36
36
  export const isStringArray = (value) =>
37
37
  Array.isArray(value) && value.every((item) => typeof item === "string");
38
38
 
39
+ /**
40
+ * Whether a value is a string a reader could actually act on — non-empty
41
+ * after trimming. The verdict contract's reason fields (I3/I4) refuse
42
+ * everything else: `""` and `" "` are byte-present but semantically
43
+ * absent, and a non-string reason would ship a `typeof` artifact where the
44
+ * reader was promised a sentence.
45
+ *
46
+ * @type {(value: unknown) => value is string}
47
+ */
48
+ export const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
49
+
39
50
  /**
40
51
  * A value's type, for an error message that shows what was actually there.
41
52
  *