@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
};
|