@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The shared governance row schema — the ONE row extension every governance
3
+ * capability in this wave reads from, and the single set of validators that
4
+ * row owners invoke. Contract 2 of the master plan.
5
+ *
6
+ * A "row" is one rule/fitness statement in a governance declaration: a
7
+ * `depConstraints` entry in the boundary law, a row in `architecture-intent.json`
8
+ * (`allowed`/`forbidden`/`projects`/`dependencies`/`forbiddenTags`), a fitness
9
+ * rule, an ADR decision. Every row may optionally carry the governance block:
10
+ *
11
+ * ```json
12
+ * {
13
+ * "origin": { "by": "jane@example.com", "tool": "archkeep:v1" },
14
+ * "rationale": "why this row exists",
15
+ * "decisionRef": "adr:0012",
16
+ * "fitnessBindings": ["fitness:hotspot"]
17
+ * }
18
+ * ```
19
+ *
20
+ * ## The additivity rule — old rows stay valid, byte-identical
21
+ *
22
+ * Every key below is optional. A row written before governance existed does
23
+ * not carry the block, and it must parse to exactly the same object it parsed
24
+ * to before this module existed — the block is a delta a row owner accepts,
25
+ * never a reshape. That is the byte-identity half of the invariant; the
26
+ * owners (`../config.mjs`, `../architecture-intent/model.mjs`) call the
27
+ * validators and pass the row through untouched.
28
+ *
29
+ * ## One validator set, no duplicates across the wave
30
+ *
31
+ * Every governance capability's row gains the same four keys from this one
32
+ * module, so there is one shape check per key for the whole wave. A row owner
33
+ * that hand-rolled its own `origin` check would be a second copy that drifts
34
+ * the first time provenance-record.mjs changes. `rowSchemaViolations` is the
35
+ * entry; the four key-specific validators live here and nowhere else.
36
+ *
37
+ * ## The `decisionRef` and `fitnessBindings` resolution contract
38
+ *
39
+ * `decisionRef` names the ADR (or rule/fitness id) that decides the row;
40
+ * `fitnessBindings` names the fitness rules that measure it. Shape is
41
+ * validated here; RESOLUTION is the capability that owns the registry
42
+ * (the ADR capability's registry, the fitness catalogue), injected rather
43
+ * than imported so this module stays the small contract and nothing here
44
+ * hard-codes which governance objects exist. A reference that cannot resolve
45
+ * is a load error, loud — a rule that reads as bound while nothing binds it
46
+ * is the silent direction (`AGENTS.md`).
47
+ *
48
+ * ## Determinism
49
+ *
50
+ * `origin.on` — the only timestamp a row may carry — is produced by
51
+ * `../governance/provenance-record.mjs`'s `recordOrigin`, which refuses to run
52
+ * without `../governance/clock.mjs`. Read-side validation here checks an
53
+ * `on` already committed as a static fact, never invents one: a row that
54
+ * omits `on` stays byte-identical across every run, and a row that records it
55
+ * records a fact about committed bytes, not about the wall clock of whatever
56
+ * machine happened to read the file.
57
+ */
58
+
59
+ import { originViolations } from "./provenance-record.mjs";
60
+
61
+ /** The shape of any `origin.on` producer. Re-exported for a row owner's own docs. */
62
+ export { clockViolations as clockValidation } from "./clock.mjs";
63
+
64
+ /** The four governance keys a row may carry, in the order reports list them. */
65
+ export const GOVERNANCE_ROW_KEYS = Object.freeze([
66
+ "origin",
67
+ "rationale",
68
+ "decisionRef",
69
+ "fitnessBindings",
70
+ ]);
71
+
72
+ /**
73
+ * @typedef {import("./provenance-record.mjs").OriginRecord} OriginRecord
74
+ */
75
+
76
+ /**
77
+ * @typedef {object} GovernanceRowBlock
78
+ * @property {OriginRecord} [origin] Who decided the row, with which tool and —
79
+ * when the clock was passed — when.
80
+ * @property {string} [rationale] Free-text "why", for a human or an agent
81
+ * reading the row. No format enforced.
82
+ * @property {string} [decisionRef] Id of the decision that makes the row
83
+ * enforceable — an ADR id (`adr:0012` / `0012-slug`), a rule id
84
+ * (`rule:no-direct-dep`), or a fitness id (`fitness:hotspot`).
85
+ * @property {string[]} [fitnessBindings] Fitness ids this row is bound to.
86
+ */
87
+
88
+ /** @type {(value: unknown) => value is Record<string, unknown>} */
89
+ const isPlainObject = (value) =>
90
+ value !== null && typeof value === "object" && !Array.isArray(value);
91
+
92
+ /** A value's type, for an error message that shows what was actually there. */
93
+ function describe(value) {
94
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
95
+ if (value === null) return "null";
96
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
97
+ }
98
+
99
+ /**
100
+ * Everything wrong with a row's `rationale`, `decisionRef`, or
101
+ * `fitnessBindings` — the three string-shaped governance keys. `origin` has
102
+ * its own validator (`./provenance-record.mjs`); this one covers the rest so
103
+ * a row owner asks exactly one function.
104
+ *
105
+ * @param {unknown} raw The raw row object.
106
+ * @param {string} at Dotted path of the row, e.g. `forbidden[3]`, for messages.
107
+ * @returns {string[]}
108
+ */
109
+ export function governanceBlockViolations(raw, at) {
110
+ if (!isPlainObject(raw)) return [];
111
+ const violations = [];
112
+
113
+ if ("rationale" in raw) {
114
+ if (typeof raw.rationale !== "string" || raw.rationale.trim() === "") {
115
+ violations.push(
116
+ `${at}.rationale: must be a non-empty string when present, got ${describe(raw.rationale)} — ` +
117
+ `a reason that cannot be read is a reason nobody can rely on`,
118
+ );
119
+ }
120
+ }
121
+
122
+ if ("decisionRef" in raw) {
123
+ if (typeof raw.decisionRef !== "string" || raw.decisionRef.trim() === "") {
124
+ violations.push(
125
+ `${at}.decisionRef: must be a non-empty string naming a decision (an ADR id like "adr:0012", ` +
126
+ `or a rule/fitness id like "rule:keep-a" / "fitness:hotspot"), got ${describe(raw.decisionRef)}`,
127
+ );
128
+ }
129
+ }
130
+
131
+ if ("fitnessBindings" in raw) {
132
+ const bindings = raw.fitnessBindings;
133
+ if (!Array.isArray(bindings)) {
134
+ violations.push(
135
+ `${at}.fitnessBindings: must be an array of non-empty strings, got ${describe(bindings)}`,
136
+ );
137
+ } else if (bindings.length === 0) {
138
+ violations.push(
139
+ `${at}.fitnessBindings: must not be empty — a list present but empty reads as binding while ` +
140
+ `binding nothing`,
141
+ );
142
+ } else {
143
+ bindings.forEach((binding, index) => {
144
+ if (typeof binding !== "string" || binding.trim() === "") {
145
+ violations.push(
146
+ `${at}.fitnessBindings[${index}]: must be a non-empty string, got ${describe(binding)}`,
147
+ );
148
+ }
149
+ });
150
+ }
151
+ }
152
+
153
+ return violations;
154
+ }
155
+
156
+ /**
157
+ * The full governance-block validation for one row: `origin` shape (via the
158
+ * provenance record), then the three string-shaped keys. Resolution of a
159
+ * `decisionRef` / `fitnessBindings` id is the registry capability's, injected
160
+ * as `io.resolve` when a caller has one.
161
+ *
162
+ * @param {unknown} raw The raw row object.
163
+ * @param {string} at Dotted path of the row, for messages, e.g. `forbidden[3]`.
164
+ * @param {{resolve?: (key: "decisionRef"|"fitnessBindings", id: string) => boolean,
165
+ * kindLabel?: string}} [io] `resolve` answers "does this id name something
166
+ * known?"; `kindLabel` names what `resolve` knows about, for the error
167
+ * message.
168
+ * @returns {string[]} Empty when the row's governance block is valid.
169
+ */
170
+ export function rowSchemaViolations(raw, at, io = {}) {
171
+ if (!isPlainObject(raw)) return [];
172
+
173
+ const violations = [];
174
+
175
+ if ("origin" in raw) {
176
+ violations.push(...originViolations(raw.origin).map((m) => `${at}.${m}`));
177
+ }
178
+ violations.push(...governanceBlockViolations(raw, at));
179
+
180
+ // Resolution half — a reference that cannot resolve is a load error, loud.
181
+ if (io.resolve) {
182
+ if (typeof raw.decisionRef === "string" && raw.decisionRef.trim() !== "") {
183
+ if (!io.resolve("decisionRef", raw.decisionRef)) {
184
+ violations.push(
185
+ `${at}.decisionRef: "${raw.decisionRef}" does not resolve — it names no ${io.kindLabel ?? "declared decision"} ` +
186
+ `(no ADR with that id, and no rule or fitness id with that name). A rule that cannot be ` +
187
+ `bound is the silent direction; fix the reference or record the decision it names.`,
188
+ );
189
+ }
190
+ }
191
+ if (Array.isArray(raw.fitnessBindings)) {
192
+ raw.fitnessBindings.forEach((binding, index) => {
193
+ if (
194
+ typeof binding === "string" &&
195
+ binding.trim() !== "" &&
196
+ !io.resolve("fitnessBindings", binding)
197
+ ) {
198
+ violations.push(
199
+ `${at}.fitnessBindings[${index}]: "${binding}" does not resolve — it names no ${io.kindLabel ?? "declared fitness"} ` +
200
+ `with that id. A rule that cannot be measured is the silent direction; fix the binding or record the fitness rule it names.`,
201
+ );
202
+ }
203
+ });
204
+ }
205
+ }
206
+
207
+ return violations;
208
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The canonical 4-state verdict vocabulary every governance capability emits
3
+ * and consumes — the single answer shape an architecture-governance envelope
4
+ * carries.
5
+ *
6
+ * The vocabulary exists because the three statuses the envelope has always
7
+ * used (`"ok"` / `"findings"` / `"no-verdict"`) are about THIS run's position
8
+ * on THIS tree, while the governance wave needs a verdict an evidencing
9
+ * process can compare across runs, feeds, and capabilities. The mapping is
10
+ * the interesting part and it is one-way on purpose:
11
+ *
12
+ * `"ok"` → `"pass"`
13
+ * `"findings"` → `"fail"`
14
+ * `"no-verdict"` → `"unknown"`
15
+ *
16
+ * and `"not_applicable"` has no source status in this release — it is the
17
+ * vocabulary for Fitness functions and Waivers (a follow-up capability),
18
+ * never for engine behavior today.
19
+ *
20
+ * ## The invariants, I1–I5
21
+ *
22
+ * I1. `pass` implies complete coverage — a run that could not fully read the
23
+ * tree can never pass. A `pass` without coverage is a claim the evidence
24
+ * cannot back.
25
+ * I2. `fail` implies at least one finding — a failing verdict that names no
26
+ * finding leaves the reader to guess what failed.
27
+ * I3. `unknown` implies "could not look" — coverage incomplete, an unresolved
28
+ * question, or a thrown analysis. `unknown` is never a degraded `pass`.
29
+ * I4. `not_applicable` requires `notApplicableReason` — the reader has to be
30
+ * able to tell WHY a rule did not apply, since "did not apply" and "did
31
+ * not run" are indistinguishable otherwise.
32
+ * I5. The cardinal rule: an analysis that failed, or a rule that could not
33
+ * determine, must emit `unknown`, NEVER `pass`. `pass` is the loudest
34
+ * claim the vocabulary makes and the hardest to disprove, so every other
35
+ * state exists to refuse it.
36
+ *
37
+ * The enforcer that makes the invariants executable lives in
38
+ * `../report/evidence.mjs` (`buildDecision`) — this module owns the closed
39
+ * vocabulary and its relation to the envelope's existing statuses, and the
40
+ * report module owns the runtime check a command's counts go through.
41
+ */
42
+
43
+ /** The four canonical verdict values. */
44
+ export const VERDICTS = Object.freeze(["pass", "fail", "unknown", "not_applicable"]);
45
+
46
+ /** The single mapping from an envelope status to a verdict. */
47
+ export const VERDICT_FOR_STATUS = Object.freeze({
48
+ ok: "pass",
49
+ findings: "fail",
50
+ "no-verdict": "unknown",
51
+ });
52
+
53
+ /**
54
+ * Whether a string names one of the four verdicts.
55
+ *
56
+ * @param {unknown} value
57
+ * @returns {boolean}
58
+ */
59
+ export function isVerdict(value) {
60
+ return typeof value === "string" && VERDICTS.includes(value);
61
+ }
62
+
63
+ /**
64
+ * The verdict an envelope status implies. Unknown statuses refuse loudly
65
+ * rather than map to a guess — a status this vocabulary has never heard of is
66
+ * a caller reading from the future, and guessing which verdict it implied
67
+ * would make the evidence lie about what actually ran.
68
+ *
69
+ * @param {string} status One of `"ok"` | `"findings"` | `"no-verdict"`.
70
+ * @returns {"pass"|"fail"|"unknown"}
71
+ * @throws {Error} when `status` is not one of the three the envelope can hold.
72
+ */
73
+ export function verdictForStatus(status) {
74
+ const verdict = VERDICT_FOR_STATUS[status];
75
+ if (verdict === undefined) {
76
+ throw new Error(
77
+ `archkeep: status "${status}" has no verdict — expected one of ` +
78
+ `${Object.keys(VERDICT_FOR_STATUS).join(", ")}`,
79
+ );
80
+ }
81
+ return verdict;
82
+ }
83
+
84
+ /**
85
+ * One function's verdict record — the evidence envelope every governance
86
+ * consumer (a fitness function, a waiver judge) reads.
87
+ *
88
+ * `evidence` is an object of deterministic facts the verdict is a claim over
89
+ * (the same facts a report row renders), and `message` is human text naming
90
+ * what was decided and why. `rows` is optional per-function observed detail
91
+ * (matched projects, judged edges); it rides along so a report can show the
92
+ * function's coverage without re-deriving it a second way.
93
+ *
94
+ * A `not_applicable` verdict carries its `notApplicableReason` (invariant I4):
95
+ * "did not apply" and "did not run" are indistinguishable otherwise, and a
96
+ * reader has to be told which one this was.
97
+ *
98
+ * @param {{verdict: (typeof VERDICTS)[number], name: string, evidence: object,
99
+ * message: string, rows?: object[], notApplicableReason?: string}} decision
100
+ * @returns {object}
101
+ * @throws {Error} on a verdict outside the four — a programming error in the
102
+ * function that built the decision, not a fact about the workspace — and on
103
+ * a `not_applicable` verdict without its `notApplicableReason`.
104
+ */
105
+ export function fitnessVerdict({ verdict, name, evidence, message, rows, notApplicableReason }) {
106
+ if (!isVerdict(verdict)) {
107
+ throw new Error(
108
+ `archkeep: fitness function "${name}" returned verdict ${JSON.stringify(verdict)} — ` +
109
+ `expected one of ${VERDICTS.join(", ")}.`,
110
+ );
111
+ }
112
+ if (verdict === "not_applicable" && notApplicableReason === undefined) {
113
+ throw new Error(
114
+ `archkeep: fitness function "${name}" returned "not_applicable" without ` +
115
+ `notApplicableReason — invariant I4: the reader must be told why the ` +
116
+ `function did not apply.`,
117
+ );
118
+ }
119
+ return {
120
+ verdict,
121
+ name,
122
+ evidence,
123
+ message,
124
+ ...(rows === undefined ? {} : { rows }),
125
+ ...(notApplicableReason === undefined ? {} : { notApplicableReason }),
126
+ };
127
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The waiver model: a `boundarySuppressions` row with an `expiresAt` is a
3
+ * WAIVER, and this module is the single place that decides what one does at a
4
+ * given reference time.
5
+ *
6
+ * The distinction from a legacy suppression (a row with no `expiresAt`) is the
7
+ * whole point of the feature:
8
+ *
9
+ * - a **suppression** removes a violation the workspace decided to accept,
10
+ * permanently, and a run whose violations are all suppressed reads clean
11
+ * (exit 0 — that is the existing boundary feature, unchanged);
12
+ * - a **waiver** accepts a violation TEMPORARILY. It never removes the
13
+ * violation from the run's findings — a run whose only findings are waived
14
+ * is NOT clean (the exit code stays 1), because accepting a boundary breach
15
+ * for a fixed term is a tracked decision, not a fix. An ACTIVE waiver marks
16
+ * the violation as waived (the report renders it under "accepted
17
+ * violations"); an EXPIRED waiver stops covering it, and the violation
18
+ * re-asserts itself in full with the evidence `"expired waiver"`.
19
+ *
20
+ * That preserves the load-bearing invariant in `../../../../AGENTS.md`: an
21
+ * empty diagnostic list must mean "no violation" and nothing else. Waiving
22
+ * never makes a finding disappear; it annotates one. And because suppressions
23
+ * filter VERDICTS after every site has been judged (never skip sites
24
+ * up-front), a waiver can never promote `unknown` → `pass` — the "could not
25
+ * tell" failures travel beside the records and no waiver ever touches them.
26
+ *
27
+ * The clock is the shared governance contract (`./clock.mjs`): injectable,
28
+ * so the bytes a run emits are reproducible over an unchanged tree and an
29
+ * unchanged injected reference time (the determinism rule every governance
30
+ * capability shares).
31
+ */
32
+
33
+ import { referenceTime } from "./clock.mjs";
34
+
35
+ /** The evidence a re-asserted violation carries after its waiver expired. */
36
+ export const EXPIRED_WAIVER_EVIDENCE = "expired waiver";
37
+
38
+ /**
39
+ * Whether a suppression row is a waiver. Presence of `expiresAt` is the
40
+ * distinguishing field — validated by `../config.mjs` to be a parseable ISO
41
+ * instant when present, so a malformed one never reaches this module.
42
+ *
43
+ * @param {object|null|undefined} row A `boundarySuppressions` entry.
44
+ * @returns {boolean}
45
+ */
46
+ export function isWaiver(row) {
47
+ return row != null && typeof row === "object" && "expiresAt" in row;
48
+ }
49
+
50
+ /**
51
+ * The waiver's expiry as epoch milliseconds. Only called on rows `isWaiver`
52
+ * accepts, which config validation has guaranteed parseable.
53
+ *
54
+ * @param {object} row A `boundarySuppressions` entry with `expiresAt`.
55
+ * @returns {number}
56
+ */
57
+ export function expiresAtMs(row) {
58
+ return Date.parse(String(row.expiresAt));
59
+ }
60
+
61
+ /**
62
+ * Whether the waiver is in force at `now`. `now` is included: a waiver whose
63
+ * expiry exactly equals the reference time is expired — a waiver "valid
64
+ * through" a term means it covers strictly before `expiresAt`.
65
+ *
66
+ * @param {object} row A waiver (has `expiresAt`).
67
+ * @param {string} [now] Reference instant, ISO-8601; defaults to the shared clock.
68
+ * @returns {"active"|"expired"}
69
+ */
70
+ export function waiverStatus(row, now = referenceTime()) {
71
+ return Date.parse(now) >= expiresAtMs(row) ? "expired" : "active";
72
+ }
73
+
74
+ /**
75
+ * Milliseconds until expiry — negative when expired. Fractional-time aware
76
+ * (epoch-ms precision), which is the boundary every clock test drives.
77
+ *
78
+ * @param {object} row A waiver (has `expiresAt`).
79
+ * @param {string} [now] Reference instant, ISO-8601; defaults to the shared clock.
80
+ * @returns {number}
81
+ */
82
+ export function remainingMs(row, now = referenceTime()) {
83
+ return expiresAtMs(row) - Date.parse(now);
84
+ }
85
+
86
+ /**
87
+ * What a suppression entry does to the violation it covers at `now`.
88
+ *
89
+ * - `"suppress"` — a legacy row (no expiry): the violation is removed, the
90
+ * existing boundary feature's semantics, unchanged.
91
+ * - `"waive"` — an active waiver: the violation stays in this run's findings,
92
+ * marked `waivedBy` so the report can render it as an accepted violation.
93
+ * The run is still findings (exit 1) — waiving does not flip exit 1 → 0.
94
+ * - `"reassert"` — an expired waiver: the violation stays, carrying
95
+ * `evidence: "expired waiver"`. A waiver that stopped being in force is
96
+ * a waiver that covers nothing, and the boundary it accepted is live again.
97
+ *
98
+ * @param {object} row A `boundarySuppressions` entry.
99
+ * @param {string} [now] Reference instant, ISO-8601; defaults to the shared clock.
100
+ * @returns {"suppress"|"waive"|"reassert"}
101
+ */
102
+ export function suppressionFate(row, now = referenceTime()) {
103
+ if (!isWaiver(row)) return "suppress";
104
+ return waiverStatus(row, now) === "expired" ? "reassert" : "waive";
105
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The graph layer: cross-project EDGES for Go, Rust, and Python, in the shape
3
+ * Nx's `createDependencies` hook returns. Nothing else — nodes still come from
4
+ * each project's hand-written `project.json`, and targets are never inferred
5
+ * (`packages/archkeep/AGENTS.md`).
6
+ *
7
+ * This is the plugin half of the tool, and it is deliberately separate from
8
+ * `../analysis/`. The two answer different questions over the same tree: the
9
+ * graph asks "does project A depend on project B", which is all `nx affected`
10
+ * can act on, while analysis asks "which import, written where, in what form" —
11
+ * a superset a boundary rule needs and an Nx edge cannot carry
12
+ * (`../analysis/contract.md`). Keeping them apart is what stops the graph from
13
+ * growing fields Nx will drop and analysis from being trimmed to what Nx keeps.
14
+ *
15
+ * Each resolver reads tracked manifests and sources statically (regex for Go
16
+ * imports, smol-toml for Cargo/pyproject manifests) so the graph computes
17
+ * without any language toolchain installed. A workspace with no Go/Rust/Python
18
+ * projects pays nothing: every resolver keys off `<projectRoot>/<manifest>`
19
+ * existing in the project's tracked files. A resolver may THROW instead of
20
+ * returning — the Python one does, for a declared path dependency it cannot
21
+ * attribute to any project (`../analysis/python.mjs` header) — and the throw
22
+ * is deliberate: edges and an error are the only two outputs this hook has,
23
+ * and an edge quietly missing from the graph is the failure mode this plugin
24
+ * exists to close.
25
+ *
26
+ * Resolver contract (kept identical across languages, see `../analysis/*.mjs`):
27
+ * resolve(projects, filesOf, readFile) -> [{ source, target, sourceFile, type }]
28
+ */
29
+ import { readFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+
32
+ import { containmentViolation } from "../containment.mjs";
33
+ import { resolveGoDependencies } from "../analysis/go.mjs";
34
+ import { resolvePythonDependencies } from "../analysis/python.mjs";
35
+ import { resolveRustDependencies } from "../analysis/rust.mjs";
36
+ import { resolveOptions } from "../options.mjs";
37
+
38
+ /** Pure core over an abstract workspace; injectable for tests. */
39
+ export function resolvePolyglotDependencies(projects, filesOf, readFile) {
40
+ const deps = [
41
+ ...resolveGoDependencies(projects, filesOf, readFile),
42
+ ...resolveRustDependencies(projects, filesOf, readFile),
43
+ ...resolvePythonDependencies(projects, filesOf, readFile),
44
+ ];
45
+ // One edge per (source, target, sourceFile) — a Go project importing a
46
+ // sibling from ten files yields ten sourceFile-attributed edges upstream
47
+ // of us; Nx dedupes too, this just keeps the plugin's output canonical.
48
+ const seen = new Set();
49
+ return deps.filter((d) => {
50
+ const key = `${d.source} ${d.target} ${d.sourceFile}`;
51
+ if (seen.has(key)) return false;
52
+ seen.add(key);
53
+ return true;
54
+ });
55
+ }
56
+
57
+ /**
58
+ * The Nx hook.
59
+ *
60
+ * `options` is validated and then not used, and both halves of that are
61
+ * deliberate. Edge resolution reads language manifests, whose names are fixed
62
+ * external contracts rather than options (`../options.mjs` says why) — so
63
+ * nothing here needs a value from the table. Validating it anyway is what makes
64
+ * a typo'd key fail at the FIRST graph computation, which every `nx` invocation
65
+ * performs, instead of waiting for whichever later CLI or editor run happens to
66
+ * read the same table. The alternative is a workspace that lints green all week
67
+ * and discovers on Friday that its `tsConfig` was spelled `tsconfigBase` and no
68
+ * path alias ever resolved.
69
+ */
70
+ export const createDependencies = (options, context) => {
71
+ resolveOptions(options);
72
+ const projects = Object.entries(context.projects).map(([projectName, config]) => ({
73
+ name: projectName,
74
+ root: config.root,
75
+ }));
76
+ const filesOf = (projectName) =>
77
+ (context.fileMap?.projectFileMap?.[projectName] ?? []).map((f) => f.file);
78
+ const readFile = (workspaceRelativePath) => {
79
+ const abs = join(context.workspaceRoot, workspaceRelativePath);
80
+ // Every value this reader is handed comes from the tree's own `fileMap` —
81
+ // attacker-supplied the moment a PR adds a tracked path. A tracked symlink
82
+ // whose realpath leaves the workspace would draw a dependency edge from
83
+ // outside bytes into `nx affected`'s graph; refusing (null) drops the
84
+ // read so the file produces no edge (the `resolvePolyglotDependencies`
85
+ // contract is a null read = no edge, not a throw — this hook cannot exit
86
+ // non-zero by design). A plugin that never resolves outside bytes stays
87
+ // silent-green only when the bytes are really inside (`../containment.mjs`).
88
+ if (containmentViolation(context.workspaceRoot, abs) !== null) return null;
89
+ try {
90
+ return readFileSync(abs, "utf8");
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+ return resolvePolyglotDependencies(projects, filesOf, readFile);
96
+ };