@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envelope's field roster, as something a test can compare.
|
|
3
|
+
*
|
|
4
|
+
* `../../../../docs/reference/json-output.md` promises that every field name
|
|
5
|
+
* in it, and `schemaVersion` itself, are a public contract: no field is
|
|
6
|
+
* renamed, no field changes type, and a capability that does not fit the
|
|
7
|
+
* current shape ships as a new field rather than as a change to an existing
|
|
8
|
+
* one. Until this module existed, nothing in the tree went red when a field
|
|
9
|
+
* was renamed or dropped — the promise was prose, and `./json.mjs` enforces
|
|
10
|
+
* only the three consistency rules between `status`, `exitCode`, `coverage`
|
|
11
|
+
* and `decision`, never the roster itself.
|
|
12
|
+
*
|
|
13
|
+
* This module is the half that can be measured. It turns an envelope into a
|
|
14
|
+
* flat, sorted list of `path: type` entries, and `../../e2e/envelope-shape.e2e.mjs`
|
|
15
|
+
* compares that list — for every command the CLI declares — against the
|
|
16
|
+
* snapshot committed beside it.
|
|
17
|
+
*
|
|
18
|
+
* ## What the roster is, and what it is NOT
|
|
19
|
+
*
|
|
20
|
+
* It is a MEASURED shape, in the sense `scripts/differential-real-trees.mjs`
|
|
21
|
+
* uses for `expectViolations`: the answer a real run gives today, written
|
|
22
|
+
* down so that a change to it has to be made on purpose. It is not a second
|
|
23
|
+
* copy of the documentation's field tables, and it must not be maintained as
|
|
24
|
+
* one — a hand-written roster would be exactly the second statement of a rule
|
|
25
|
+
* `../../../../AGENTS.md` refuses, drifting from both the docs and the code.
|
|
26
|
+
* The snapshot is regenerated from a run and reviewed as a diff; the diff IS
|
|
27
|
+
* the review question ("is this rename allowed by the promise?").
|
|
28
|
+
*
|
|
29
|
+
* ## Why every node carries an entry, containers included
|
|
30
|
+
*
|
|
31
|
+
* A roster of leaves alone cannot see an emptied object: drop every key of
|
|
32
|
+
* `workspace` and the leaf list simply loses four rows, which reads the same
|
|
33
|
+
* as four fields having been renamed elsewhere. So each node emits its own
|
|
34
|
+
* `path: type` — `workspace: object` survives its children — and the type
|
|
35
|
+
* vocabulary keeps `null` apart from `object`, because `result.goWork` being
|
|
36
|
+
* `null` on a tree with no `go.work` and being an object on a tree with one
|
|
37
|
+
* is precisely the difference a consumer branches on.
|
|
38
|
+
*
|
|
39
|
+
* ## Arrays collapse, and that is the point
|
|
40
|
+
*
|
|
41
|
+
* Every element of an array folds onto one `path[]` prefix, so a roster does
|
|
42
|
+
* not grow with the number of violations a fixture happens to produce — the
|
|
43
|
+
* shape is what is under contract, not the count. A path that appears with
|
|
44
|
+
* two types across elements (an optional field present on one row and absent
|
|
45
|
+
* on another) yields both entries, which is how an optional field is
|
|
46
|
+
* recorded rather than hidden.
|
|
47
|
+
*
|
|
48
|
+
* The root itself gets no entry: `./json.mjs` cannot return anything but an
|
|
49
|
+
* object, so a row asserting it is one would be a row that can never move.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The type name a roster entry carries. Deliberately coarser than JavaScript's
|
|
54
|
+
* own vocabulary in one place and finer in two: `null` is its own name rather
|
|
55
|
+
* than `object`, and arrays are `array` rather than `object`, because those
|
|
56
|
+
* are the two distinctions a consumer's parser actually branches on.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} value
|
|
59
|
+
* @returns {"null"|"array"|"object"|"string"|"number"|"boolean"}
|
|
60
|
+
*/
|
|
61
|
+
function typeName(value) {
|
|
62
|
+
if (value === null) return "null";
|
|
63
|
+
if (Array.isArray(value)) return "array";
|
|
64
|
+
const type = typeof value;
|
|
65
|
+
if (type === "object" || type === "string" || type === "number" || type === "boolean") {
|
|
66
|
+
return type;
|
|
67
|
+
}
|
|
68
|
+
// `undefined`, a function, a symbol, a bigint: none can survive
|
|
69
|
+
// `JSON.stringify`, so a value of that type in an envelope is a bug in the
|
|
70
|
+
// command that built it rather than a shape to record. Naming it is the
|
|
71
|
+
// loud direction — recording it as some neighbouring type would put a row
|
|
72
|
+
// in the roster describing a field no consumer will ever receive.
|
|
73
|
+
throw new Error(
|
|
74
|
+
`archkeep: refusing to build an envelope roster over a ${type} — an envelope holds only ` +
|
|
75
|
+
`JSON values, so a ${type} in it is a bug in the command that built the envelope.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every node of `envelope`, as sorted `"<path>: <type>"` entries.
|
|
81
|
+
*
|
|
82
|
+
* Paths are dotted, with `[]` marking a step through an array: a violation's
|
|
83
|
+
* message id is `result.violations[].messageId`. Duplicate entries — the same
|
|
84
|
+
* path with the same type on many array elements — collapse to one.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} envelope The envelope `./json.mjs` built.
|
|
87
|
+
* @returns {string[]} Sorted, unique `path: type` entries.
|
|
88
|
+
* @throws {Error} when the envelope is not an object, or holds a value no
|
|
89
|
+
* JSON document can carry.
|
|
90
|
+
*/
|
|
91
|
+
export function envelopeFieldPaths(envelope) {
|
|
92
|
+
if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`archkeep: refusing to build an envelope roster over ${envelope === null ? "null" : `a ${Array.isArray(envelope) ? "array" : typeof envelope}`} ` +
|
|
95
|
+
`— an envelope is the object ./json.mjs returns.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
/** @type {Map<string, Set<string>>} */
|
|
99
|
+
const byPath = new Map();
|
|
100
|
+
walk(envelope, "", byPath);
|
|
101
|
+
// Sorted by path first and type second, rather than by the rendered string:
|
|
102
|
+
// `"tool.name: string"` sorts BEFORE `"tool: object"` under a plain string
|
|
103
|
+
// sort (`.` is below `:`), which would print every child above its own
|
|
104
|
+
// parent and make the snapshot's diffs harder to read than they need to be.
|
|
105
|
+
return [...byPath.keys()]
|
|
106
|
+
.sort()
|
|
107
|
+
.flatMap((path) => [...(byPath.get(path) ?? [])].sort().map((type) => `${path}: ${type}`));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Records `value` at `path` and descends. `path` is `""` only for the root,
|
|
112
|
+
* whose own entry is deliberately not recorded (see this module's header).
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} value
|
|
115
|
+
* @param {string} path
|
|
116
|
+
* @param {Map<string, Set<string>>} byPath
|
|
117
|
+
*/
|
|
118
|
+
function walk(value, path, byPath) {
|
|
119
|
+
if (path !== "") {
|
|
120
|
+
const types = byPath.get(path) ?? new Set();
|
|
121
|
+
types.add(typeName(value));
|
|
122
|
+
byPath.set(path, types);
|
|
123
|
+
}
|
|
124
|
+
if (value === null) return;
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
for (const element of value) walk(element, `${path}[]`, byPath);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof value !== "object") return;
|
|
130
|
+
for (const key of Object.keys(value)) {
|
|
131
|
+
walk(
|
|
132
|
+
/** @type {Record<string, unknown>} */ (value)[key],
|
|
133
|
+
path === "" ? key : `${path}.${key}`,
|
|
134
|
+
byPath,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The two directions a roster can move, named separately because they are not
|
|
141
|
+
* the same review question.
|
|
142
|
+
*
|
|
143
|
+
* `added` is the additive direction the promise allows — a new field, which
|
|
144
|
+
* still has to be recorded so that "additive" is a claim someone made rather
|
|
145
|
+
* than a diff nobody read. `removed` is the direction the promise forbids: a
|
|
146
|
+
* field that was in the roster and is not in the run either was renamed,
|
|
147
|
+
* dropped, or changed type, and every one of those breaks a consumer that
|
|
148
|
+
* parses this output today.
|
|
149
|
+
*
|
|
150
|
+
* @param {string[]} recorded The snapshot's entries for this command.
|
|
151
|
+
* @param {string[]} observed `envelopeFieldPaths` over a real run.
|
|
152
|
+
* @returns {{added: string[], removed: string[]}}
|
|
153
|
+
*/
|
|
154
|
+
export function compareFieldPaths(recorded, observed) {
|
|
155
|
+
const recordedSet = new Set(recorded);
|
|
156
|
+
const observedSet = new Set(observed);
|
|
157
|
+
return {
|
|
158
|
+
added: observed.filter((entry) => !recordedSet.has(entry)),
|
|
159
|
+
removed: recorded.filter((entry) => !observedSet.has(entry)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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.
|
|
61
|
+
*/
|
|
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
|
+
if (verdict === "pass") {
|
|
101
|
+
if (run.coverageComplete !== true) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`archkeep: refusing to emit a "pass" decision over incomplete coverage ` +
|
|
104
|
+
`(coverage.complete: ${run.coverageComplete}) — a run that could not fully read the ` +
|
|
105
|
+
`tree can never pass. This is a bug in the command that built the decision.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (run.findings > 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`archkeep: refusing to emit a "pass" decision with ${run.findings} finding(s) — ` +
|
|
111
|
+
`"pass" and "fail" cannot both be true of the same run. This is a bug in the command.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return withSampleTime({ verdict }, run.sampleTime);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (verdict === "fail") {
|
|
118
|
+
if (run.findings < 1) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`archkeep: refusing to emit a "fail" decision with no findings — a failing verdict ` +
|
|
121
|
+
`must name what failed. This is a bug in the command that built the decision.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return withSampleTime({ verdict }, run.sampleTime);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (verdict === "unknown") {
|
|
128
|
+
const reason =
|
|
129
|
+
run.reason ??
|
|
130
|
+
(run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
|
|
131
|
+
return withSampleTime({ verdict, reason }, run.sampleTime);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// verdict === "not_applicable" (I4).
|
|
135
|
+
if (!run.notApplicableReason) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
|
|
138
|
+
`"did not apply" and "did not run" must never be indistinguishable. ` +
|
|
139
|
+
`This is a bug in the command that built the decision.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return withSampleTime({ verdict, notApplicableReason: run.notApplicableReason }, run.sampleTime);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Adds `sampleTime` to the decision only when the caller opted into time —
|
|
147
|
+
* the determinism rule in this module's header. Absent `sampleTime`, the
|
|
148
|
+
* decision object carries exactly the invariant-bearing fields and nothing
|
|
149
|
+
* more.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} decision
|
|
152
|
+
* @param {string|undefined} sampleTime
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
function withSampleTime(decision, sampleTime) {
|
|
156
|
+
return sampleTime === undefined ? decision : { ...decision, sampleTime };
|
|
157
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `explain` command: one import site's judgment,
|
|
3
|
+
* explained.
|
|
4
|
+
*
|
|
5
|
+
* The first line is `file:line:column`, unindented and with no prefix — the
|
|
6
|
+
* same shape the terminal and the editor both make clickable, and the same
|
|
7
|
+
* shape `./text.mjs` uses for violations. Everything after it is indented,
|
|
8
|
+
* so the position line stands alone.
|
|
9
|
+
*
|
|
10
|
+
* Seven things are printed, each with a reader in mind:
|
|
11
|
+
*
|
|
12
|
+
* - the import specifier and its kind (what was written)
|
|
13
|
+
* - the source project and its tags (who wrote it)
|
|
14
|
+
* - the target project and its tags (where it reaches)
|
|
15
|
+
* - the constraint row(s) that matched (which rule applied)
|
|
16
|
+
* - the verdict (violation or allowed)
|
|
17
|
+
* - the message, when there is one (what the verdict means in prose)
|
|
18
|
+
* - coverage information (whether this explanation is complete)
|
|
19
|
+
*
|
|
20
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
21
|
+
* wearing a formatter's name (`../README.md`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Two spaces of indent for detail lines. */
|
|
25
|
+
const DETAIL = " ";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tags rendered as a bracketed list: `[layer:domain, scope:billing]`.
|
|
29
|
+
* An empty list renders as `[]`.
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]} tags
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function formatTags(tags) {
|
|
35
|
+
return `[${tags.join(", ")}]`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A project name with its tags: `billing-core [layer:domain, scope:billing]`.
|
|
40
|
+
* A null project renders as `(unresolved)`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string|null} name
|
|
43
|
+
* @param {string[]} tags
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function formatProject(name, tags) {
|
|
47
|
+
if (name === null) return "(unresolved)";
|
|
48
|
+
return `${name} ${formatTags(tags)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One constraint row rendered from the row's own keys — the same function
|
|
53
|
+
* `./text.mjs`'s `formatConstraint` uses, copied here because that function
|
|
54
|
+
* is not exported (it does not need to be; the two are parallel renderers).
|
|
55
|
+
*
|
|
56
|
+
* @param {object|null} constraint A `depConstraints` row, or `null`.
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function formatConstraint(constraint) {
|
|
60
|
+
if (!constraint) {
|
|
61
|
+
return "not driven by a depConstraints row — this check fires before the table is read";
|
|
62
|
+
}
|
|
63
|
+
const source =
|
|
64
|
+
"allSourceTags" in constraint
|
|
65
|
+
? `allSourceTags [${constraint.allSourceTags.join(", ")}]`
|
|
66
|
+
: `sourceTag ${constraint.sourceTag}`;
|
|
67
|
+
const rest = Object.entries(constraint)
|
|
68
|
+
.filter(([key]) => key !== "sourceTag" && key !== "allSourceTags")
|
|
69
|
+
.map(([key, value]) => `${key} [${(Array.isArray(value) ? value : [value]).join(", ")}]`);
|
|
70
|
+
return [source, ...rest].join(" → ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One matched constraint row rendered as a readable line.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} constraint A `depConstraints` row.
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function formatMatchedConstraint(constraint) {
|
|
80
|
+
return formatConstraint(constraint);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The whole explain report.
|
|
85
|
+
*
|
|
86
|
+
* @param {{explanation: object, coverage: object}} input
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function formatExplainReport({ explanation, coverage }) {
|
|
90
|
+
const { site } = explanation;
|
|
91
|
+
const sections = [];
|
|
92
|
+
|
|
93
|
+
// The position line — same shape as a violation entry.
|
|
94
|
+
sections.push(`${site.file}:${site.line}:${site.column}`);
|
|
95
|
+
|
|
96
|
+
if (explanation.unresolvable) {
|
|
97
|
+
sections.push(`${DETAIL}unresolvable ${explanation.reason}`);
|
|
98
|
+
sections.push(
|
|
99
|
+
`${DETAIL}verdict UNRESOLVABLE — this import site could not be resolved statically, ` +
|
|
100
|
+
`so no judgment was reached`,
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
const imp = explanation.import;
|
|
104
|
+
sections.push(`${DETAIL}import ${JSON.stringify(imp.specifier)} (${imp.kind})`);
|
|
105
|
+
sections.push(
|
|
106
|
+
`${DETAIL}source ${formatProject(explanation.sourceProject, explanation.sourceTags)}`,
|
|
107
|
+
);
|
|
108
|
+
sections.push(
|
|
109
|
+
`${DETAIL}target ${formatProject(explanation.targetProject, explanation.targetTags)}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (explanation.matchedConstraints.length > 0) {
|
|
113
|
+
for (const constraint of explanation.matchedConstraints) {
|
|
114
|
+
sections.push(`${DETAIL}constraint ${formatMatchedConstraint(constraint)}`);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
sections.push(
|
|
118
|
+
`${DETAIL}constraint (none — the source project matches no depConstraints row)`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (explanation.violations && explanation.violations.length > 0) {
|
|
123
|
+
for (const v of explanation.violations) {
|
|
124
|
+
sections.push(`${DETAIL}verdict VIOLATION — ${v.messageId}`);
|
|
125
|
+
// The rendered message — same indent as the violation report.
|
|
126
|
+
const message = v.message
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => (line === "" ? "" : `${DETAIL} ${line}`))
|
|
129
|
+
.join("\n");
|
|
130
|
+
sections.push(message);
|
|
131
|
+
if (v.constraint?.description) {
|
|
132
|
+
sections.push(`${DETAIL}rule ${v.constraint.description}`);
|
|
133
|
+
}
|
|
134
|
+
if (v.constraint?.remediation) {
|
|
135
|
+
sections.push(`${DETAIL}remediation ${v.constraint.remediation}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Coverage — same shape as every other command's footer.
|
|
144
|
+
const inspected =
|
|
145
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
146
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
147
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
148
|
+
|
|
149
|
+
if (coverage.complete) {
|
|
150
|
+
sections.push(`✔ coverage complete (${inspected})`);
|
|
151
|
+
} else {
|
|
152
|
+
sections.push(
|
|
153
|
+
`✖ coverage incomplete — ${coverage.notAnalyzed.length} file${coverage.notAnalyzed.length === 1 ? "" : "s"} ` +
|
|
154
|
+
`could not be analyzed (${inspected})`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return sections.join("\n");
|
|
159
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `graph` command: the project graph as readable
|
|
3
|
+
* text.
|
|
4
|
+
*
|
|
5
|
+
* Each project lists its outgoing edges beneath it — the reading order an
|
|
6
|
+
* architect uses ("what does this project reach?"). A project with no
|
|
7
|
+
* dependencies renders `(no dependencies)` rather than an omitted section,
|
|
8
|
+
* because a project with no edges and a project the renderer forgot look
|
|
9
|
+
* identical otherwise — the same reasoning as `./text.mjs`'s
|
|
10
|
+
* `formatGoWork` clean-result line.
|
|
11
|
+
*
|
|
12
|
+
* The coverage claim sits ABOVE the listing, not below it, so the reader knows
|
|
13
|
+
* whether the snapshot is complete before reading any entry — an incomplete
|
|
14
|
+
* snapshot printed in full would have the "this may under-represent" warning
|
|
15
|
+
* buried at the bottom.
|
|
16
|
+
*
|
|
17
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
18
|
+
* wearing a formatter's name (`../README.md`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One project as a line: name, root, type, and tags.
|
|
23
|
+
*
|
|
24
|
+
* @param {{name: string, root: string, type?: string, tags: string[]}} project
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function formatProject(project) {
|
|
28
|
+
const type = project.type ? ` ${project.type}` : "";
|
|
29
|
+
const tags = project.tags.length > 0 ? ` [${project.tags.join(", ")}]` : "";
|
|
30
|
+
return ` ${project.name} ${project.root}${type}${tags}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One edge as a line: source -> target (type).
|
|
35
|
+
*
|
|
36
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function formatEdge(edge) {
|
|
40
|
+
return ` → ${edge.target} (${edge.type})`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The whole graph report.
|
|
45
|
+
*
|
|
46
|
+
* @param {{projects: object[], dependencies: object[], workspaceLayout: object,
|
|
47
|
+
* workspaceLayoutSource: string, coverage: object}} input
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatGraphReport({
|
|
51
|
+
projects,
|
|
52
|
+
dependencies,
|
|
53
|
+
workspaceLayout,
|
|
54
|
+
workspaceLayoutSource,
|
|
55
|
+
coverage,
|
|
56
|
+
}) {
|
|
57
|
+
const sections = [];
|
|
58
|
+
|
|
59
|
+
// Coverage claim goes FIRST — above the listing — so the reader knows
|
|
60
|
+
// whether the snapshot is complete before reading any entry.
|
|
61
|
+
const inspected =
|
|
62
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
63
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
64
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
65
|
+
|
|
66
|
+
if (coverage.complete) {
|
|
67
|
+
sections.push(`✔ graph snapshot complete (${inspected})`);
|
|
68
|
+
} else {
|
|
69
|
+
const notAnalyzedCount = coverage.notAnalyzed.length;
|
|
70
|
+
sections.push(
|
|
71
|
+
`✖ graph snapshot incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
|
|
72
|
+
`could not be analyzed, so this snapshot may under-represent the architecture (${inspected})`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Layout line
|
|
77
|
+
const layoutSource = workspaceLayoutSource === "declared" ? "declared in workspace" : "default";
|
|
78
|
+
sections.push(`layout ${workspaceLayout.appsDir}/${workspaceLayout.libsDir} (${layoutSource})`);
|
|
79
|
+
|
|
80
|
+
// Project count header
|
|
81
|
+
const projectWord = projects.length === 1 ? "project" : "projects";
|
|
82
|
+
sections.push(`${projects.length} ${projectWord}`);
|
|
83
|
+
|
|
84
|
+
// Build per-project edge map for the dependency sub-lists
|
|
85
|
+
const edgesBySource = new Map();
|
|
86
|
+
for (const edge of dependencies) {
|
|
87
|
+
let list = edgesBySource.get(edge.source);
|
|
88
|
+
if (!list) {
|
|
89
|
+
list = [];
|
|
90
|
+
edgesBySource.set(edge.source, list);
|
|
91
|
+
}
|
|
92
|
+
list.push(edge);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Each project lists its outgoing edges beneath it
|
|
96
|
+
for (const project of projects) {
|
|
97
|
+
sections.push(formatProject(project));
|
|
98
|
+
const edges = edgesBySource.get(project.name);
|
|
99
|
+
if (edges && edges.length > 0) {
|
|
100
|
+
for (const edge of edges) {
|
|
101
|
+
sections.push(formatEdge(edge));
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// A project with no dependencies still gets a line, so the reader can
|
|
105
|
+
// tell the project exists from the report alone — the same reasoning
|
|
106
|
+
// as `./text.mjs`'s `formatGoWork` clean-result line.
|
|
107
|
+
sections.push(" (no dependencies)");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Flat edge count for summary
|
|
112
|
+
const edgeWord = dependencies.length === 1 ? "edge" : "edges";
|
|
113
|
+
sections.push(`${dependencies.length} ${edgeWord}`);
|
|
114
|
+
|
|
115
|
+
return sections.join("\n");
|
|
116
|
+
}
|