@ecoma-io/archkeep 0.22.2 → 0.23.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.
Files changed (50) hide show
  1. package/package.json +40 -13
  2. package/src/analysis/jvm/packages.mjs +0 -17
  3. package/src/analysis/manifest-util.mjs +14 -5
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +1 -11
  6. package/src/commands/README.md +16 -7
  7. package/src/commands/change-intent.mjs +2 -11
  8. package/src/commands/check.mjs +69 -5
  9. package/src/commands/completeness.mjs +0 -32
  10. package/src/commands/context-command.mjs +1 -1
  11. package/src/commands/context.mjs +46 -47
  12. package/src/commands/diff.mjs +1 -1
  13. package/src/commands/discover.mjs +7 -3
  14. package/src/commands/evaluation-primitives.mjs +6 -2
  15. package/src/commands/impact-reachability.mjs +104 -0
  16. package/src/commands/impact.mjs +9 -71
  17. package/src/commands/scenario-evaluation.mjs +1 -1
  18. package/src/config.mjs +1 -15
  19. package/src/custom-rules/evidence.mjs +1 -1
  20. package/src/custom-rules/host.mjs +2 -2
  21. package/src/custom-rules/values.mjs +8 -3
  22. package/src/errors.mjs +1 -1
  23. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  24. package/src/governance/adr-registry.mjs +31 -16
  25. package/src/governance/decision-graph.mjs +1 -1
  26. package/src/governance/evolution-store.mjs +33 -16
  27. package/src/governance/fitness-registry.mjs +1 -11
  28. package/src/governance/profile-registry.mjs +20 -23
  29. package/src/governance/provenance-record.mjs +1 -11
  30. package/src/governance/reconcile-score.mjs +0 -3
  31. package/src/governance/row-schema.mjs +1 -11
  32. package/src/governance/verdict.mjs +168 -4
  33. package/src/intent/intent-manifest.json +12 -12
  34. package/src/lsp/diagnose.mjs +2 -2
  35. package/src/lsp/server.mjs +1 -1
  36. package/src/lsp/workspace-index.mjs +3 -3
  37. package/src/options.mjs +1 -1
  38. package/src/providers/model-gate.mjs +59 -0
  39. package/src/providers/moon.mjs +1 -1
  40. package/src/providers/native/model.mjs +2 -16
  41. package/src/report/README.md +13 -7
  42. package/src/report/evidence.mjs +11 -168
  43. package/src/report/json.mjs +10 -7
  44. package/src/report/sarif.mjs +29 -4
  45. package/src/report/text.mjs +39 -0
  46. package/src/rules/README.md +18 -9
  47. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  48. package/src/values.mjs +49 -0
  49. package/src/verdict.mjs +25 -5
  50. package/src/workspace.mjs +29 -0
@@ -1,170 +1,13 @@
1
1
  /**
2
- * The decision builder: turns a command's verdict counts into the `decision`
3
- * the envelope optionally carries, enforcing the five evidence invariants
4
- * (`../governance/verdict.mjs` states them) in code rather than leaving them
5
- * to a docs page a later command author might not read.
6
- *
7
- * This module decides nothing about whether a finding IS one the command
8
- * that built the envelope owns that. What it decides is whether the verdict
9
- * and its evidence AGREE, and it throws when they do not, the same posture
10
- * `jsonEnvelope` takes for the three consistency rules it enforces: a
11
- * mismatch here is a bug in the command, not a fact about the workspace.
12
- *
13
- * The shape it produces:
14
- *
15
- * {
16
- * verdict: "pass" | "fail" | "unknown" | "not_applicable",
17
- * reason?: string, // always present for unknown
18
- * notApplicableReason?: string, // always present for not_applicable
19
- * sampleTime?: string // opt-in, never on a deterministic envelope
20
- * }
21
- *
22
- * A caller may pass `reason` for `unknown` — it names WHICH could-not-look
23
- * condition fired (coverage incomplete, an unresolved intent boundary, a
24
- * thrown analysis). Without it, `buildDecision` states the generic one. The
25
- * reason field itself is always present on an `unknown` decision (I3).
26
- *
27
- * ## Determinism is the default
28
- *
29
- * The envelope this decision rides on is byte-deterministic
30
- * (`docs/reference/json-output.md`: no timestamp, no random identifier). So
31
- * `sampleTime` is OPT-IN by construction: a command passes it explicitly when
32
- * it is an age/count capability (waivers, debt, health — the features
33
- * `../governance/clock.mjs` serves), and a command whose verdict must stay
34
- * reproducible over an unchanged tree emits a decision with no time at all.
35
- * That is how the determinism↔time tension is resolved — the clock is
36
- * injectable (a test drives the same code with a fixed time), never asserted
37
- * from the wall clock.
38
- *
39
- * ## The invariants, executable
40
- *
41
- * I1. `pass` requires complete coverage. A run that could not fully read the
42
- * tree can never pass — the same refusal `jsonEnvelope` makes for
43
- * `status: "ok"` over incomplete coverage, at the verdict layer.
44
- * I2. `fail` requires at least one finding. A failing verdict that names no
45
- * finding leaves the reader guessing what failed.
46
- * I3. `unknown` requires a reason. `unknown` is a claim that something could
47
- * not be determined, and the reader has to be able to tell what.
48
- * I4. `not_applicable` requires `notApplicableReason`. "Did not apply" and
49
- * "did not run" are indistinguishable without it.
50
- * I5. The cardinal rule: a failed analysis or an unresolved question must
51
- * emit `unknown`, NEVER `pass` — enforced here by I1's first check
52
- * (pass + not-complete throws) and by every caller choosing `unknown`
53
- * wherever the run did not reach a verdict.
54
- *
55
- * `not_applicable` has no envelope status, so `buildDecision` reaches it only
56
- * through an explicit `verdict` — the route a Fitness or Waiver capability
57
- * (a later governance wave) takes. Engine behavior today never passes it:
58
- * `jsonEnvelope` refuses a `decision.verdict` that contradicts the envelope's
59
- * `status`, and no status maps to `not_applicable`, so the state is locked
60
- * out of every envelope this release builds.
2
+ * Re-export only. `buildDecision` lives in `../governance/verdict.mjs`,
3
+ * beside the four-state vocabulary it enforces (#650) this file used to
4
+ * hold it, which made the core verdict module (`../verdict.mjs`) import the
5
+ * presentation layer, the one import direction the report layer must never
6
+ * own. The path stays for the render-side callers — `src/commands/*` build a
7
+ * decision while composing the payload they render and importing the
8
+ * governance module directly is equivalent and welcome. What must never
9
+ * reappear here is a second implementation: `evidence.test.mjs` asserts the
10
+ * re-export by identity, so a copy that drifts from the vocabulary's
11
+ * enforcer fails loudly instead of silently disagreeing with it.
61
12
  */
62
- import { verdictForStatus } from "../governance/verdict.mjs";
63
-
64
- /**
65
- * Builds the `decision` a verdict's counts produce.
66
- *
67
- * @param {{
68
- * verdict?: "pass"|"fail"|"unknown"|"not_applicable",
69
- * status?: "ok"|"findings"|"no-verdict",
70
- * coverageComplete: boolean,
71
- * findings: number,
72
- * reason?: string|null,
73
- * notApplicableReason?: string|null,
74
- * sampleTime?: string
75
- * }} run
76
- * @returns {{verdict: string, reason?: string, notApplicableReason?: string,
77
- * sampleTime?: string}}
78
- * @throws {Error} on any invariant violation (I1–I4).
79
- */
80
- export function buildDecision(run) {
81
- if (run.verdict === undefined && run.status === undefined) {
82
- // No status, no explicit verdict — a builder called with neither is a
83
- // programming error, not a fact about the workspace.
84
- throw new Error("archkeep: buildDecision needs either a status or an explicit verdict");
85
- }
86
- const verdict = run.verdict ?? verdictForStatus(run.status);
87
- if (
88
- run.verdict !== undefined &&
89
- run.status !== undefined &&
90
- run.verdict !== verdictForStatus(run.status)
91
- ) {
92
- throw new Error(
93
- `archkeep: refusing to build a decision where verdict "${run.verdict}" contradicts status ` +
94
- `"${run.status}" — status implies ${verdictForStatus(run.status)}, and a decision that ` +
95
- `disagrees with its own status would make one of the two a lie. ` +
96
- `This is a bug in the command that built the decision.`,
97
- );
98
- }
99
-
100
- // The `findings` count is the cardinal evidence number — a non-negative
101
- // integer that the I1–I5 invariants all rely on. A missing, non-numeric, or
102
- // negative value would silently falsify every comparison (`undefined > 0` is
103
- // `false`), producing a clean verdict over a run whose counts were never set
104
- // or are logically impossible — the exact silent direction this module exists
105
- // to refuse.
106
- if (typeof run.findings !== "number" || !Number.isFinite(run.findings) || run.findings < 0) {
107
- throw new Error(
108
- `archkeep: refusing to build a decision where findings is ${JSON.stringify(run.findings)} ` +
109
- `— findings must be a non-negative number, or the verdict invariants cannot be enforced. ` +
110
- `This is a bug in the command that built the decision.`,
111
- );
112
- }
113
- if (verdict === "pass") {
114
- if (run.coverageComplete !== true) {
115
- throw new Error(
116
- `archkeep: refusing to emit a "pass" decision over incomplete coverage ` +
117
- `(coverage.complete: ${run.coverageComplete}) — a run that could not fully read the ` +
118
- `tree can never pass. This is a bug in the command that built the decision.`,
119
- );
120
- }
121
- if (run.findings > 0) {
122
- throw new Error(
123
- `archkeep: refusing to emit a "pass" decision with ${run.findings} finding(s) — ` +
124
- `"pass" and "fail" cannot both be true of the same run. This is a bug in the command.`,
125
- );
126
- }
127
- return withSampleTime({ verdict }, run.sampleTime);
128
- }
129
-
130
- if (verdict === "fail") {
131
- if (run.findings < 1) {
132
- throw new Error(
133
- `archkeep: refusing to emit a "fail" decision with no findings — a failing verdict ` +
134
- `must name what failed. This is a bug in the command that built the decision.`,
135
- );
136
- }
137
- return withSampleTime({ verdict }, run.sampleTime);
138
- }
139
-
140
- if (verdict === "unknown") {
141
- const reason =
142
- run.reason ??
143
- (run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
144
- return withSampleTime({ verdict, reason }, run.sampleTime);
145
- }
146
-
147
- // verdict === "not_applicable" (I4).
148
- if (!run.notApplicableReason) {
149
- throw new Error(
150
- `archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
151
- `"did not apply" and "did not run" must never be indistinguishable. ` +
152
- `This is a bug in the command that built the decision.`,
153
- );
154
- }
155
- return withSampleTime({ verdict, notApplicableReason: run.notApplicableReason }, run.sampleTime);
156
- }
157
-
158
- /**
159
- * Adds `sampleTime` to the decision only when the caller opted into time —
160
- * the determinism rule in this module's header. Absent `sampleTime`, the
161
- * decision object carries exactly the invariant-bearing fields and nothing
162
- * more.
163
- *
164
- * @param {object} decision
165
- * @param {string|undefined} sampleTime
166
- * @returns {object}
167
- */
168
- function withSampleTime(decision, sampleTime) {
169
- return sampleTime === undefined ? decision : { ...decision, sampleTime };
170
- }
13
+ export { buildDecision } from "../governance/verdict.mjs";
@@ -37,6 +37,7 @@
37
37
  * does not opt in), which is what keeps SCHEMA_VERSION at 2: additive,
38
38
  * byte-compatible with every consumer that reads the envelope today.
39
39
  */
40
+ import { EXIT_FOR_STATUS } from "../verdict.mjs";
40
41
  import { verdictForStatus } from "../governance/verdict.mjs";
41
42
  import { createRequire } from "node:module";
42
43
 
@@ -56,9 +57,6 @@ const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json")
56
57
  */
57
58
  export const SCHEMA_VERSION = 2;
58
59
 
59
- /** The one `status`↔`exitCode` mapping every command's envelope must agree with. */
60
- const EXIT_CODE_FOR_STATUS = Object.freeze({ ok: 0, findings: 1, "no-verdict": 3 });
61
-
62
60
  /**
63
61
  * Builds the envelope, asserting the three invariants that keep it from ever
64
62
  * claiming more than a run actually established.
@@ -86,10 +84,14 @@ export function jsonEnvelope({ command, context, status, exitCode, coverage, res
86
84
  `workspace being judged.`,
87
85
  );
88
86
  }
89
- if (EXIT_CODE_FOR_STATUS[status] !== exitCode) {
87
+ // The `status`↔`exitCode` agreement reads `EXIT_FOR_STATUS` (`../verdict.mjs`)
88
+ // — the one status-keyed view of the process's exit-code table — so this
89
+ // assertion and `verdictFor`'s own encoding cannot become two independent
90
+ // copies of the same contract.
91
+ if (EXIT_FOR_STATUS[status] !== exitCode) {
90
92
  throw new Error(
91
93
  `archkeep: refusing to build a JSON envelope where status "${status}" and exitCode ${exitCode} ` +
92
- `disagree — status "${status}" must carry exitCode ${EXIT_CODE_FOR_STATUS[status]}. A ` +
94
+ `disagree — status "${status}" must carry exitCode ${EXIT_FOR_STATUS[status]}. A ` +
93
95
  `consumer reading a file written by --output has only these two fields to trust; letting ` +
94
96
  `them disagree would make one of them a lie.`,
95
97
  );
@@ -149,8 +151,9 @@ export function jsonEnvelope({ command, context, status, exitCode, coverage, res
149
151
  `This is a bug in the command that built the envelope.`,
150
152
  );
151
153
  }
152
- // I3, enforced again at this boundary — `src/governance/evidence.mjs`
153
- // already guarantees it for the engine path, but a hand-built decision
154
+ // I3, enforced again at this boundary — `buildDecision`
155
+ // (`../governance/verdict.mjs`) already guarantees it for the engine
156
+ // path, but a hand-built decision
154
157
  // would otherwise ship an "unknown" without the reason I3 requires and
155
158
  // only this boundary would ever see the mistake. I2 (findings on "fail")
156
159
  // gets no latch here for the same reason I4 gets none: the other
@@ -421,7 +421,7 @@ export function sarifTsconfigPathsResult(finding) {
421
421
  * `messageId` is one of the same three `depConstraints` ids `sarifResult`
422
422
  * already catalogues (`onlyTagsConstraintViolation`,
423
423
  * `notTagsConstraintViolation`, `projectWithoutTagsCannotHaveDependencies`) —
424
- * `judgeEdge` (`../commands/edge-constraints.mjs`) reuses the identical
424
+ * `judgeEdge` (`../rules/edge-constraints.mjs`) reuses the identical
425
425
  * tag-matching functions `evaluate()`'s import-site path does, so the rule IS
426
426
  * the same rule and needs no second entry in `sarifRules()`; only the
427
427
  * `ruleIndex` lookup is shared, via `MESSAGE_IDS` rather than a second table.
@@ -440,7 +440,7 @@ export function sarifTsconfigPathsResult(finding) {
440
440
  * function learned the provider, which is a file a Moon tree is refused for
441
441
  * carrying at all.
442
442
  *
443
- * @param {object} finding A finding from `../commands/edge-constraints.mjs`'s
443
+ * @param {object} finding A finding from `../rules/edge-constraints.mjs`'s
444
444
  * `declaredEdgeViolationsForCheck`, extended with `file` — workspace-relative.
445
445
  * @returns {object}
446
446
  */
@@ -705,6 +705,30 @@ export function sarifCoverageGapNotification(gap) {
705
705
  },
706
706
  };
707
707
  }
708
+ // Files a project owns that git does not track (#675) — present in the
709
+ // worktree, absent from the `git ls-files` universe this run was built
710
+ // from. Bounded the same way the two unowned arms above bound their own
711
+ // lists, and for the same reason: the count is the fact an uploader acts
712
+ // on, the paths its sample, and the remainder is named rather than dropped.
713
+ if (gap.kind === "untracked-files") {
714
+ const files = gap.files ?? [];
715
+ const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
716
+ const remaining = files.length - shown.length;
717
+ const listed =
718
+ shown.length > 0
719
+ ? `: ${shown.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
720
+ : "";
721
+ return {
722
+ level: "warning",
723
+ message: {
724
+ text:
725
+ `${files.length} project-owned file${files.length === 1 ? "" : "s"} ` +
726
+ `${files.length === 1 ? "is" : "are"} not tracked by git — never read by this run, ` +
727
+ `so no boundary verdict in this run covers ` +
728
+ `${files.length === 1 ? "it" : "them"}${listed}`,
729
+ },
730
+ };
731
+ }
708
732
  return {
709
733
  level: "warning",
710
734
  message: {
@@ -793,8 +817,9 @@ export function sarifDecisionRefNotification(decisionRef) {
793
817
  * `check` exiting 3 on an intent it could not establish uploaded SARIF
794
818
  * byte-identical to a clean run's.
795
819
  * - `coverageGaps` — coverage this run knows it did not provide: the polyglot
796
- * edges nothing in the workspace covers, and the tracked analyzable files no
797
- * project owns (`sarifCoverageGapNotification`).
820
+ * edges nothing in the workspace covers, the tracked analyzable files no
821
+ * project owns, and the project-owned files git does not track that this
822
+ * run therefore never read (`sarifCoverageGapNotification`).
798
823
  * - `unresolvedDecisionRefs` — every citation no ADR, rule, or fitness record
799
824
  * answers (`sarifDecisionRefNotification`), sorted, which is the order
800
825
  * `../../cli.mjs`'s JSON envelope lists the same set in: two faces of one
@@ -630,6 +630,7 @@ function formatCoverageGap(gap) {
630
630
  if (gap.kind === "unregistered-plugin") return formatUnregisteredPluginGap(gap);
631
631
  if (gap.kind === "unowned-files") return formatUnownedFilesGap(gap);
632
632
  if (gap.kind === "accepted-unowned-files") return formatAcceptedUnownedFilesGap(gap);
633
+ if (gap.kind === "untracked-files") return formatUntrackedFilesGap(gap);
633
634
  return `⚠ coverage gap "${gap.kind}" — part of this workspace is outside what this run covered`;
634
635
  }
635
636
 
@@ -732,6 +733,44 @@ function formatAcceptedUnownedFilesGap(gap) {
732
733
  );
733
734
  }
734
735
 
736
+ /**
737
+ * The untracked-files gap (#675): files a project owns that git does not
738
+ * track — present in the worktree, analyzable, and absent from the
739
+ * `git ls-files` universe this run was built from, so no analyzer ever read
740
+ * them. They are not failures and they change no exit code: the verdict over
741
+ * the tracked universe stands (`../../commands/check.mjs` states the whole
742
+ * argument beside the row's construction). What this section exists for is
743
+ * the difference between "checked, and clean" and "checked the tracked tree,
744
+ * and clean" — before this section existed those two printed the same bytes.
745
+ *
746
+ * The count leads and the paths follow, bounded by the same sample limit the
747
+ * two unowned gaps above print — the identical shape of list, so the
748
+ * identical bound (`UNOWNED_SAMPLE_LIMIT`, whose rule this face owns once).
749
+ *
750
+ * @param {{files?: string[]}} gap
751
+ * @returns {string}
752
+ */
753
+ function formatUntrackedFilesGap(gap) {
754
+ // Read as defensively as its siblings: `./check.mjs` contributes this entry
755
+ // only when the list is non-empty, but `formatCoverageGaps` is exported and
756
+ // a heading with nothing under it reads as a truncation, not as "none".
757
+ const files = gap.files ?? [];
758
+ const count = files.length;
759
+ const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
760
+ const remaining = count - shown.length;
761
+ const lines = shown.map((file) => `${CONTINUED}${file}`);
762
+ if (remaining > 0) {
763
+ lines.push(`${CONTINUED}… and ${remaining} more — the full list is in --format json`);
764
+ }
765
+ const them = count === 1 ? "it" : "them";
766
+ return (
767
+ `⚠ ${count} project-owned file${count === 1 ? "" : "s"} ${count === 1 ? "is" : "are"} ` +
768
+ `not tracked by git — never read by this run, so no boundary verdict here covers ${them}\n` +
769
+ `${lines.join("\n")}\n` +
770
+ `${DETAIL}git add ${them} so the next run reads ${them}, or let git ignore ${them}`
771
+ );
772
+ }
773
+
735
774
  /**
736
775
  * The policy-identity line — which law this run enforced — rendered FIRST,
737
776
  * ahead of every verdict below it: a reader has to know WHICH law produced a
@@ -36,15 +36,24 @@ weaker analyzer. That purity is what lets the CLI and the language server share
36
36
  one verdict, and what lets all fifteen rules be driven from fixtures with no
37
37
  workspace at all.
38
38
 
39
- | module | what it owns |
40
- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
41
- | `index.mjs` | the pipeline, in upstream's order; `allow`; the `Violation` record |
42
- | `tags.mjs` | only / not / empty-only, and which constraints a source is held to |
43
- | `topology.mjs` | circular, self-circular, apps, e2e, buildable, lazy, transitive |
44
- | `specifiers.mjs` | relative-across, relative-externals, banned and nested-banned externals |
45
- | `messages.mjs` | the fifteen message templates, copied verbatim from upstream |
46
- | `match.mjs` | Nx's three pattern dialects (none of them minimatch), plus the brace-expansion cap in front of `path.posix.matchesGlob` |
47
- | `reachability.mjs` | who reaches whom: the cycle check and the transitive tag check share it |
39
+ `edge-constraints.mjs` is this layer's second judgment surface, and deliberately
40
+ not a rule: it judges GRAPH EDGES against the `depConstraints` table — the
41
+ `implicit`-typed declarations `evaluate()` structurally cannot reach, because
42
+ they have no import site behind them reusing this directory's tag matching and
43
+ reachability rather than re-deriving them. It reads records and nothing else,
44
+ like everything here; `src/commands/` and `src/lsp/diagnose.mjs` both reach it
45
+ as a downward import.
46
+
47
+ | module | what it owns |
48
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `index.mjs` | the pipeline, in upstream's order; `allow`; the `Violation` record |
50
+ | `tags.mjs` | only / not / empty-only, and which constraints a source is held to |
51
+ | `topology.mjs` | circular, self-circular, apps, e2e, buildable, lazy, transitive |
52
+ | `specifiers.mjs` | relative-across, relative-externals, banned and nested-banned externals |
53
+ | `messages.mjs` | the fifteen message templates, copied verbatim from upstream |
54
+ | `match.mjs` | Nx's three pattern dialects (none of them minimatch), plus the brace-expansion cap in front of `path.posix.matchesGlob` |
55
+ | `reachability.mjs` | who reaches whom: the cycle check and the transitive tag check share it |
56
+ | `edge-constraints.mjs` | graph-edge judgment: `judgeEdge` against the constraint table, the `implicit`-edge pass `check` shares, and the diff/impact fold |
48
57
 
49
58
  ## Read these before changing anything here
50
59
 
@@ -2,12 +2,13 @@
2
2
  * Edge-constraint analysis: which boundary-rule violations an edge introduces
3
3
  * or removes, and which constraint rows govern a given edge.
4
4
  *
5
- * This is the bridge between the structural commands (`diff`, `impact`) and the
6
- * boundary rules. Both commands operate on graph edges — not on import sites —
7
- * so they cannot call `evaluate` directly. Instead, this module provides a
8
- * narrower function that judges a single edge against the `depConstraints`
9
- * table, which is the part of the boundary rules that depends only on project
10
- * tags (not on npm imports, circular dependencies, lazy loading, etc.).
5
+ * The rules layer's counterpart to `./index.mjs`'s import-site judgment:
6
+ * `evaluate` judges import sites, and the structural commands (`diff`,
7
+ * `impact`) operate on graph edges not on import sites — so they cannot call
8
+ * `evaluate` directly. Instead, this module provides a narrower function that
9
+ * judges a single edge against the `depConstraints` table, which is the part
10
+ * of the boundary rules that depends only on project tags (not on npm imports,
11
+ * circular dependencies, lazy loading, etc.).
11
12
  *
12
13
  * `check` is a third caller, through `declaredEdgeViolationsForCheck` below —
13
14
  * not for every edge, only the ones `evaluate()` structurally cannot reach: an
@@ -16,7 +17,9 @@
16
17
  * `evaluate()` to iterate. Without this, `check` could report a clean tree
17
18
  * while `context`/`impact` showed the exact same edge as a tag violation — the
18
19
  * "empty result is a claim, not a shrug" invariant (`../../../AGENTS.md`)
19
- * broken by omission rather than by a wrong answer.
20
+ * broken by omission rather than by a wrong answer. The language server rides
21
+ * the same function (`../lsp/diagnose.mjs`), so an editor paints the same
22
+ * declared-edge verdict `check` exits 1 over.
20
23
  *
21
24
  * ## What it checks and what it does not
22
25
  *
@@ -34,23 +37,26 @@
34
37
  * edge with no import site cannot gain one by being judged from `check` rather
35
38
  * than `impact`. A consumer who needs the full verdict should run `check`.
36
39
  *
37
- * ## Why this lives here and not in `src/rules/`
40
+ * ## Why a separate module, and not part of `./tags.mjs` or `./index.mjs`
38
41
  *
39
42
  * `src/rules/` judges import sites; this module judges graph edges. They share
40
43
  * the tag-matching functions (`findConstraintsFor`, `onlyTagsViolation`,
41
44
  * `notTagsViolation`, `emptyOnlyTagsViolation`) but the input is different
42
- * enough that merging them would blur the layer boundary the AGENTS.md guards.
45
+ * enough that merging them would put two input contracts in one module — the
46
+ * boundary this file exists to keep legible. A sibling module in the same
47
+ * layer, reached by `commands/` and `lsp/` as a downward import, keeps the
48
+ * shared tag matching in one place without blurring the two judgments.
43
49
  */
44
50
 
45
51
  import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
46
- import { renderMessage } from "../rules/messages.mjs";
47
- import { buildReachability } from "../rules/reachability.mjs";
52
+ import { renderMessage } from "./messages.mjs";
53
+ import { buildReachability } from "./reachability.mjs";
48
54
  import {
49
55
  emptyOnlyTagsViolation,
50
56
  findConstraintsFor,
51
57
  notTagsViolation,
52
58
  onlyTagsViolation,
53
- } from "../rules/tags.mjs";
59
+ } from "./tags.mjs";
54
60
 
55
61
  /**
56
62
  * Judges a single edge against the `depConstraints` table.
package/src/values.mjs ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The value side of a refusal, shared by every layer that validates: the two
3
+ * shape guards a validator refuses with, and the describer that names the
4
+ * value a refusal is about.
5
+ *
6
+ * **They live at the floor of the package because every layer above needs
7
+ * them and none of them may reach up to get them.** The one pre-existing home
8
+ * for a shared guard was `./custom-rules/values.mjs`, but `custom-rules/`
9
+ * sits above its own dependencies — `./custom-rules/host.mjs` imports
10
+ * `./config.mjs` and `./governance/verdict.mjs` — so a validator in
11
+ * `src/config.mjs` or `src/governance/` importing the guard from there would
12
+ * point a foundational module up into a directory that depends back on it.
13
+ * The definitions are here instead, `custom-rules/values.mjs` re-exports the
14
+ * guard so its own importers keep their spelling, and the layer direction
15
+ * stays one-way.
16
+ *
17
+ * **Two describers deliberately live elsewhere**, and this module is not the
18
+ * place to finish the job:
19
+ *
20
+ * - `./commands/delta-classify.mjs` and `./commands/delta-snapshot.mjs`
21
+ * render a refused primitive BARELY — `typeof` alone, no JSON dump —
22
+ * because their refusals name shapes, not contents.
23
+ * - `./governance/clock.mjs` omits the array branch; its only call site
24
+ * checks `typeof` before describing, so an array is never rendered.
25
+ *
26
+ * Each is pinned by its own test file, and converging any of them onto the
27
+ * rendering below would change the sentence an unchanged workspace is told —
28
+ * a semantic change, not a cleanup.
29
+ */
30
+
31
+ /** @type {(value: unknown) => value is Record<string, any>} */
32
+ export const isPlainObject = (value) =>
33
+ value !== null && typeof value === "object" && !Array.isArray(value);
34
+
35
+ /** @type {(value: unknown) => value is string[]} */
36
+ export const isStringArray = (value) =>
37
+ Array.isArray(value) && value.every((item) => typeof item === "string");
38
+
39
+ /**
40
+ * A value's type, for an error message that shows what was actually there.
41
+ *
42
+ * @param {unknown} value
43
+ * @returns {string}
44
+ */
45
+ export function describe(value) {
46
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
47
+ if (value === null) return "null";
48
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
49
+ }
package/src/verdict.mjs CHANGED
@@ -8,9 +8,15 @@
8
8
  * `../cli.mjs`'s `runCheck` takes the process's exit code from the same call.
9
9
  * `../cli.mjs` re-exports `EXIT` under its own name, so every importer that
10
10
  * already reads it from there keeps working.
11
+ *
12
+ * `EXIT` is the one place a status→exit-code number is written. The
13
+ * status-keyed view of it, `EXIT_FOR_STATUS`, is derived from `EXIT` and is
14
+ * what the envelope's consistency check (`./report/json.mjs`) asserts against,
15
+ * so a consumer-facing status and the process's own exit code can never drift
16
+ * into two encodings of one contract.
11
17
  */
12
18
 
13
- import { buildDecision } from "./report/evidence.mjs";
19
+ import { buildDecision } from "./governance/verdict.mjs";
14
20
 
15
21
  export const EXIT = Object.freeze({
16
22
  ok: 0,
@@ -18,6 +24,20 @@ export const EXIT = Object.freeze({
18
24
  usage: 2,
19
25
  error: 3,
20
26
  });
27
+
28
+ /**
29
+ * The envelope `status`→`exitCode` view of `EXIT` — the one mapping
30
+ * `jsonEnvelope` asserts every command's envelope against. Derived from
31
+ * `EXIT` rather than restated, so the numbers are written exactly once;
32
+ * `usage` has no status because a usage error never reaches an envelope.
33
+ *
34
+ * @type {Readonly<Record<"ok"|"findings"|"no-verdict", 0|1|3>>}
35
+ */
36
+ export const EXIT_FOR_STATUS = Object.freeze({
37
+ ok: EXIT.ok,
38
+ findings: EXIT.violations,
39
+ "no-verdict": EXIT.error,
40
+ });
21
41
  /**
22
42
  * The coverage clauses of a no-verdict reason, spelled once — the strings
23
43
  * `verdictFor` joins into `decision.reason` and `check`'s text report renders
@@ -57,7 +77,7 @@ export function coverageIncompleteReasons({ unchecked, blindSpots, analyzed }) {
57
77
  * "checked, and fine".
58
78
  *
59
79
  * The `decision` is the canonical 4-state verb of the same verdict
60
- * (`./report/evidence.mjs`), built from the same counts so the envelope's
80
+ * (`./governance/verdict.mjs`), built from the same counts so the envelope's
61
81
  * `status` and its `decision.verdict` cannot disagree: `ok`→`pass`,
62
82
  * `findings`→`fail`, `no-verdict`→`unknown`. `buildDecision` throws on any
63
83
  * invariant the counts violate (a `pass` over incomplete coverage, a `fail`
@@ -103,7 +123,7 @@ export function verdictFor({
103
123
  ) {
104
124
  return {
105
125
  status: "findings",
106
- exitCode: EXIT.violations,
126
+ exitCode: EXIT_FOR_STATUS.findings,
107
127
  reasons: coverageReasons,
108
128
  decision: buildDecision({
109
129
  status: "findings",
@@ -164,7 +184,7 @@ export function verdictFor({
164
184
  ].filter(Boolean);
165
185
  return {
166
186
  status: "no-verdict",
167
- exitCode: EXIT.error,
187
+ exitCode: EXIT_FOR_STATUS["no-verdict"],
168
188
  reasons,
169
189
  decision: buildDecision({
170
190
  status: "no-verdict",
@@ -176,7 +196,7 @@ export function verdictFor({
176
196
  }
177
197
  return {
178
198
  status: "ok",
179
- exitCode: EXIT.ok,
199
+ exitCode: EXIT_FOR_STATUS.ok,
180
200
  reasons: [],
181
201
  decision: buildDecision({
182
202
  status: "ok",
package/src/workspace.mjs CHANGED
@@ -177,6 +177,35 @@ export function listTrackedFiles(workspaceRoot, { run = runProcess } = {}) {
177
177
  return out.split("\0").filter((path) => path !== "");
178
178
  }
179
179
 
180
+ /**
181
+ * Every file present in the worktree that git does NOT track, workspace-
182
+ * relative — the complement of `listTrackedFiles` above, and the answer to
183
+ * the only question that pair can ask: what exists in the tree the tracked
184
+ * universe was cut from, but never entered it (#675).
185
+ *
186
+ * `--exclude-standard` is what keeps this answer a git answer and not a walk
187
+ * of our own: ignored files — build outputs, dependency installs, anything a
188
+ * `.gitignore`, `.git/info/exclude` or `core.excludesFile` names — are not
189
+ * part of the workspace's population, exactly as for the tracked list. The
190
+ * header's argument for `git ls-files` over a tree walk applies here with one
191
+ * more clause: walking the tree for the untracked half would need those ignore
192
+ * rules reimplemented, and the copy would drift from `.gitignore` the first
193
+ * time a build directory was added.
194
+ *
195
+ * The order is git's worktree-traversal order, not a sorted order — every
196
+ * consumer of this list sorts before it renders (a file list in a report must
197
+ * not vary with git's traversal), the same discipline
198
+ * `../commands/check.mjs`'s `sortViolations` states for the tracked list.
199
+ *
200
+ * @param {string} workspaceRoot
201
+ * @param {{ run?: typeof runProcess }} [io]
202
+ * @returns {string[]}
203
+ */
204
+ export function listUntrackedFiles(workspaceRoot, { run = runProcess } = {}) {
205
+ const out = run("git", ["ls-files", "--others", "--exclude-standard", "-z"], workspaceRoot);
206
+ return out.split("\0").filter((path) => path !== "");
207
+ }
208
+
180
209
  /**
181
210
  * The `Workspace` the analysis contract defines — `{ root, projects, filesOf,
182
211
  * readFile, tsConfig }` — plus the per-project file index it is built from,