@ecoma-io/archkeep 0.14.0 → 0.15.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/README.md +9 -3
- package/cli.mjs +447 -59
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +40 -2
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/path-util.mjs +40 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/tsconfig-paths.mjs +3 -2
package/commands.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command layer's public face — what an integration imports from this
|
|
3
|
+
* package's `./commands` subpath to run the same commands `cli.mjs` runs,
|
|
4
|
+
* in-process, without spawning the CLI or parsing its text output.
|
|
5
|
+
*
|
|
6
|
+
* It holds no logic on purpose, the same bargain `./nx` (`nx.mjs`) makes: a
|
|
7
|
+
* named entry that is a re-export, so the command layer can grow under
|
|
8
|
+
* `src/commands/` without a second copy of any decision appearing beside it.
|
|
9
|
+
* `cli.mjs` and this file are two faces of one layer — the CLI owns argv,
|
|
10
|
+
* output destinations and exit codes; an importer of this face owns those for
|
|
11
|
+
* itself, and gets back exactly what the command functions return: statuses,
|
|
12
|
+
* payloads and reports, never a printed byte.
|
|
13
|
+
*
|
|
14
|
+
* Who this is for: integrations that compose the CLI's verbs programmatically
|
|
15
|
+
* — `packages/archkeep-mcp`, the agent capability interface, is the first. The
|
|
16
|
+
* engine primitives (discovery and judgment) remain the root entry
|
|
17
|
+
* (`index.mjs`); a caller that wants to JUDGE import records itself composes
|
|
18
|
+
* those, while a caller that wants the CLI's answers — a verdict, an impact
|
|
19
|
+
* set, an explanation — composes these. The two entries never overlap: a
|
|
20
|
+
* function exported here is never also exported there, so no integration can
|
|
21
|
+
* accidentally hold two spellings of one decision.
|
|
22
|
+
*
|
|
23
|
+
* The roster is exactly what the first consumer composes, no more — a
|
|
24
|
+
* function joins it when an integration calls it, never in anticipation of
|
|
25
|
+
* one. What is deliberately absent: the `run*` drivers and argv parsing
|
|
26
|
+
* (`cli.mjs` alone owns what a process's stdout and exit code mean), and the
|
|
27
|
+
* renderers (`src/report/` shapes bytes for humans; an importer reads the
|
|
28
|
+
* envelopes the commands already return).
|
|
29
|
+
*
|
|
30
|
+
* The seams the CLI threads are threaded the same way here: `readGraph` and
|
|
31
|
+
* `listFiles` are injectable on every entry point that reaches outside the
|
|
32
|
+
* process, so a caller drives the real analysis, rules and reports over a
|
|
33
|
+
* fixture tree with neither Nx nor git present.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export { resolveCommandContext, WORKSPACE_MARKERS } from "./src/commands/context.mjs";
|
|
37
|
+
|
|
38
|
+
export { resolveDescribedPolicy, resolvePolicy } from "./src/commands/policy.mjs";
|
|
39
|
+
|
|
40
|
+
export { check } from "./src/commands/check.mjs";
|
|
41
|
+
export { graphCommand } from "./src/commands/graph.mjs";
|
|
42
|
+
export { impactCommand } from "./src/commands/impact.mjs";
|
|
43
|
+
export { explainCommand } from "./src/commands/explain.mjs";
|
|
44
|
+
export { driftCommand } from "./src/commands/drift.mjs";
|
|
45
|
+
export { planContextCommand } from "./src/commands/plan-context-command.mjs";
|
|
46
|
+
export { historyCommand } from "./src/commands/history.mjs";
|
|
47
|
+
export { adrCommand } from "./src/commands/adr.mjs";
|
|
48
|
+
export { discoverCommand } from "./src/commands/discover.mjs";
|
|
49
|
+
export { reconcileCommand } from "./src/commands/reconcile.mjs";
|
|
50
|
+
|
|
51
|
+
export { UsageError } from "./src/errors.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript and Vue",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
|
@@ -28,12 +28,14 @@
|
|
|
28
28
|
"exports": {
|
|
29
29
|
".": "./index.mjs",
|
|
30
30
|
"./nx": "./nx.mjs",
|
|
31
|
+
"./commands": "./commands.mjs",
|
|
31
32
|
"./presets/*.json": "./presets/*.json",
|
|
32
33
|
"./package.json": "./package.json"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"index.mjs",
|
|
36
37
|
"nx.mjs",
|
|
38
|
+
"commands.mjs",
|
|
37
39
|
"cli.mjs",
|
|
38
40
|
"lsp.mjs",
|
|
39
41
|
"src/",
|
|
@@ -83,6 +83,7 @@ import { isBuiltin } from "node:module";
|
|
|
83
83
|
import ts from "typescript";
|
|
84
84
|
|
|
85
85
|
import { DEFAULT_OPTIONS } from "../options.mjs";
|
|
86
|
+
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
86
87
|
import { normalizePath } from "./manifest-util.mjs";
|
|
87
88
|
import { emptyResult, fileFailure, perWorkspace, projectOwning } from "./source-util.mjs";
|
|
88
89
|
|
|
@@ -378,7 +379,7 @@ function scriptKindFor(sourceFile, lang) {
|
|
|
378
379
|
* translation to the workspace-relative reader happens here and once.
|
|
379
380
|
*/
|
|
380
381
|
function resolutionHostFor(workspace) {
|
|
381
|
-
const root = workspace.root
|
|
382
|
+
const root = stripTrailingSlashes(workspace.root);
|
|
382
383
|
const prefix = `${root}/`;
|
|
383
384
|
const contents = new Map();
|
|
384
385
|
const read = (absolute) => {
|
package/src/commands/README.md
CHANGED
|
@@ -56,10 +56,32 @@ the resolution order.
|
|
|
56
56
|
incomplete head coverage, and an Nx workspace with polyglot manifests but no
|
|
57
57
|
plugin registration; a policy-fingerprint change is a loud coverage note, not
|
|
58
58
|
a refusal. A verdict, not a description: a non-waived introduced violation is
|
|
59
|
-
a finding (exit 1 — the third verb beside `check` and `fitness`
|
|
59
|
+
a finding (exit 1 — then the third verb beside `check` and `fitness`; `change`
|
|
60
|
+
and its declared-intent question arrived later as the fourth), an
|
|
60
61
|
unclassifiable item is a no-verdict (exit 3), and a waived-introduced entry
|
|
61
62
|
is reported without gating. Capture stays descriptive: never exits 1.
|
|
62
63
|
|
|
64
|
+
- **`change`** (`./change.mjs`'s `changeCommand`, with the manifest grammar in
|
|
65
|
+
`./change-intent.mjs`) — a declared change-intent contract reconciled
|
|
66
|
+
against the actual architectural delta: did the change produce exactly the
|
|
67
|
+
material consequences its declaration named? The material delta is
|
|
68
|
+
`./diff.mjs`'s own `computeDiff` over the baseline evidence snapshot's
|
|
69
|
+
stored graph and this run's graph — never a second opinion about which edits
|
|
70
|
+
count; declared constraints re-judge BOTH sides under ONE current law and
|
|
71
|
+
instant through the same engine `./delta.mjs` uses (`../rules/index.mjs`,
|
|
72
|
+
`./delta-classify.mjs`, and `../governance/fitness-rules.mjs`'s
|
|
73
|
+
`cyclicProjects`). Verdicts `matched | undeclared | unfulfilled | unproven`
|
|
74
|
+
stay separate: an undeclared consequence is a review signal, not a law
|
|
75
|
+
verdict, and the workspace-law axis it reports is informational — computed,
|
|
76
|
+
labeled as evidence, never folded into the exit code, because `check`
|
|
77
|
+
remains the authority on the law. Undeclared, unfulfilled, or a failed
|
|
78
|
+
declared constraint is a finding (exit 1 — the fourth verb beside `check`,
|
|
79
|
+
`fitness`, `delta`); an unproven base identity or an undeterminable
|
|
80
|
+
constraint is exit 3, and constraints are left unevaluated over a base the
|
|
81
|
+
run cannot vouch for. Refuses a manifest that fails shape or reference
|
|
82
|
+
validation, an unreadable/malformed/incomplete baseline, a provider
|
|
83
|
+
mismatch, incomplete head coverage, and the unregistered-plugin graph.
|
|
84
|
+
|
|
63
85
|
- **`impact`** (`./impact.mjs`'s `impactCommand`) — reverse reachability from
|
|
64
86
|
the project graph: given a project name, lists every project that transitively
|
|
65
87
|
depends on it. Separates direct from transitive dependents. When a boundary
|
|
@@ -114,6 +136,35 @@ the resolution order.
|
|
|
114
136
|
head coverage. Refuses an empty or unreadable directory, and a snapshot that
|
|
115
137
|
parses as an incomplete envelope. Descriptive: never exits 1.
|
|
116
138
|
|
|
139
|
+
- **`trajectory`** (`./trajectory.mjs`'s `trajectoryCommand`) — the aggregate
|
|
140
|
+
over the same directory `history` reads: signal counts, structural churn
|
|
141
|
+
and persistence, derived through `./history.mjs`'s own `readSnapshots` and
|
|
142
|
+
`classifyTransition` so the two commands cannot disagree about a single
|
|
143
|
+
classification. One observation is one stored graph snapshot — never a
|
|
144
|
+
commit or a day. Identities are `./diff.mjs`'s (`edgeIdentityKey`); no
|
|
145
|
+
violation-level trajectory exists because stored snapshots carry no
|
|
146
|
+
findings. A one-snapshot directory answers `insufficient_history` with null
|
|
147
|
+
derived fields, never zeros; an empty or malformed one refuses (exit 3).
|
|
148
|
+
No boundary law is loaded and nothing is captured here — the fingerprints
|
|
149
|
+
compared travel inside the snapshots, and exactly one command writes them.
|
|
150
|
+
Descriptive: never exits 1.
|
|
151
|
+
|
|
152
|
+
- **`evolution`** (`./evolution.mjs`'s `evolutionCommand`) — the same
|
|
153
|
+
evolution record across a selected range of Git revisions instead of a
|
|
154
|
+
snapshot directory. Git answers only which trees to read: both endpoints are
|
|
155
|
+
resolved with `rev-parse`, base must be an ancestor of head, every commit in
|
|
156
|
+
`base..head` must be single-parent (a merge refuses loudly rather than being
|
|
157
|
+
flattened), and each selected commit is materialized into a temporary
|
|
158
|
+
detached worktree that is released before the run reports — the caller's
|
|
159
|
+
working tree is never touched. Each materialized tree runs through the
|
|
160
|
+
ordinary context and policy ladders, and the transitions are classified by
|
|
161
|
+
`./history.mjs`'s own `computeEvolution`, so the two commands cannot
|
|
162
|
+
disagree about what a transition means. A change is attributed to the first
|
|
163
|
+
analyzed revision where it is observable — where history shows it, never why
|
|
164
|
+
it was made. Refuses every unusable selection and every revision that cannot
|
|
165
|
+
be fully analyzed, so no failure can read as a shorter history. Descriptive:
|
|
166
|
+
never exits 1.
|
|
167
|
+
|
|
117
168
|
- **`provenance`** (`./provenance-command.mjs`'s `provenanceCommand`) — where
|
|
118
169
|
this run's facts came from and which governance rows carry an origin.
|
|
119
170
|
Two surfaces: repository provenance (the git commit, remote and dirty state
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change-intent contract — grammar, validation, and loading.
|
|
3
|
+
*
|
|
4
|
+
* A change intent is a PER-CHANGE declaration of the material architectural
|
|
5
|
+
* consequences its author expects one specific edit to produce: which projects
|
|
6
|
+
* appear or disappear, which project-to-project dependencies appear or
|
|
7
|
+
* disappear, and which delta-level constraints the change must hold. It is
|
|
8
|
+
* written beside the work, verified by the `change` command against the actual
|
|
9
|
+
* architectural delta (`./change.mjs`), and then belongs to the pull request
|
|
10
|
+
* as the reviewable answer to "what did this change do to the architecture?".
|
|
11
|
+
*
|
|
12
|
+
* This file mirrors `../architecture-intent/model.mjs`'s split — a pure
|
|
13
|
+
* `(raw) -> string[]` validator, a thin loader that reads, parses, validates
|
|
14
|
+
* and throws one Error naming every violation at once — and deliberately does
|
|
15
|
+
* NOT extend that module: architecture-intent.json is the workspace's
|
|
16
|
+
* long-lived law, judged by `drift`/`check`; a change intent names one
|
|
17
|
+
* transaction's expected consequences and expires with its pull request. One
|
|
18
|
+
* grammar per concept, so neither can grow the other's semantics by accident.
|
|
19
|
+
*
|
|
20
|
+
* Four contracts, each the honest side of the empty-result invariant
|
|
21
|
+
* (`../../../../AGENTS.md` — an empty list must mean "nothing unexpected",
|
|
22
|
+
* and nothing else):
|
|
23
|
+
*
|
|
24
|
+
* - **It is strict JSON, never JSONC**, for the same reason
|
|
25
|
+
* `../architecture-intent/model.mjs` refuses comments: this is a
|
|
26
|
+
* machine-written declaration a verification run consumes, and a comment
|
|
27
|
+
* is a load error, not leniency.
|
|
28
|
+
* - **An unknown key is rejected by name**, never ignored — a typo'd
|
|
29
|
+
* section would silently declare nothing, and a run over a declaration
|
|
30
|
+
* that said nothing must not read as "the change declared nothing
|
|
31
|
+
* material".
|
|
32
|
+
* - **A duplicate declaration is a load error**, not last-one-wins: the
|
|
33
|
+
* contract is a SET of expected facts, and two rows under one fact would
|
|
34
|
+
* make the reconciliation depend on array order.
|
|
35
|
+
* - **Shape is nodes-free.** Whether a declared project reference can exist
|
|
36
|
+
* is a question about the captured baseline, so it belongs to the
|
|
37
|
+
* command (`./change.mjs`'s reference check), not here. A manifest must
|
|
38
|
+
* load and be told its references are wrong — loudly — rather than fail
|
|
39
|
+
* to parse at all.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
43
|
+
|
|
44
|
+
/** The only `version` this module accepts. A different value is a load error. */
|
|
45
|
+
export const CHANGE_INTENT_VERSION = "1";
|
|
46
|
+
|
|
47
|
+
/** The only keys a valid change-intent file may carry at the top level. */
|
|
48
|
+
export const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
|
|
49
|
+
"version",
|
|
50
|
+
"base",
|
|
51
|
+
"summary",
|
|
52
|
+
"projects",
|
|
53
|
+
"edges",
|
|
54
|
+
"constraints",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** The keys the `base` section may carry. */
|
|
58
|
+
export const CHANGE_INTENT_BASE_KEYS = Object.freeze(["commit"]);
|
|
59
|
+
|
|
60
|
+
/** The sub-keys the `projects` section may carry. */
|
|
61
|
+
export const CHANGE_INTENT_PROJECT_SECTION_KEYS = Object.freeze(["add", "remove"]);
|
|
62
|
+
/** The sub-keys the `edges` section may carry. */
|
|
63
|
+
export const CHANGE_INTENT_EDGE_SECTION_KEYS = Object.freeze(["add", "remove"]);
|
|
64
|
+
/** The keys an edge row may carry. */
|
|
65
|
+
export const CHANGE_INTENT_EDGE_ROW_KEYS = Object.freeze(["from", "to"]);
|
|
66
|
+
/** The keys the `constraints` section may carry. */
|
|
67
|
+
export const CHANGE_INTENT_CONSTRAINT_KEYS = Object.freeze(["noNewViolations", "noNewCycles"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The constraints the reconciliation judges, in the fixed order their verdict
|
|
71
|
+
* rows are emitted — the order itself is part of the deterministic output
|
|
72
|
+
* contract (`docs/reference/json-output.md`), so a manifest that declares them
|
|
73
|
+
* in either order gets the same row order.
|
|
74
|
+
*/
|
|
75
|
+
export const CONSTRAINT_ORDER = Object.freeze(["noNewViolations", "noNewCycles"]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The manifest key a constraint row answers to, keyed by the row name the
|
|
79
|
+
* report and JSON use. One copy, so a renamed key cannot desynchronize the
|
|
80
|
+
* loader's accepted set from the judge's row names.
|
|
81
|
+
*/
|
|
82
|
+
export const CONSTRAINT_ROW_NAMES = Object.freeze({
|
|
83
|
+
noNewViolations: "no-new-violations",
|
|
84
|
+
noNewCycles: "no-new-cycles",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** A value's type, for an error message that shows what was actually there. */
|
|
88
|
+
function describe(value) {
|
|
89
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
90
|
+
if (value === null) return "null";
|
|
91
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
95
|
+
const isPlainObject = (value) =>
|
|
96
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
97
|
+
|
|
98
|
+
/** `key` on `obj` that is not one of `allowed` — the reject-by-name rule. */
|
|
99
|
+
function unknownKeys(obj, allowed) {
|
|
100
|
+
return Object.keys(obj).filter((key) => !allowed.includes(key));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Non-empty-string guard with its message fragment. */
|
|
104
|
+
function nonEmptyString(value) {
|
|
105
|
+
return typeof value === "string" && value.length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Everything wrong with the `projects`/`edges` sections' shared list shape —
|
|
110
|
+
* present-or-absent wholesale, an array when present, strings (for projects)
|
|
111
|
+
* or `{from, to}` rows (for edges) inside, no duplicates within a list, no
|
|
112
|
+
* member of `add` also in `remove`. Written once for both sections because a
|
|
113
|
+
* second copy of the duplicate rule is how the two sections drift into
|
|
114
|
+
* answering "is this a set?" differently.
|
|
115
|
+
*
|
|
116
|
+
* @param {unknown} section The raw section value.
|
|
117
|
+
* @param {{name: string, keys: readonly string[], row: (row: unknown, at: string) => string[],
|
|
118
|
+
* identity: (row: unknown) => string|null}} spec
|
|
119
|
+
* `name` is the dotted path prefix, `keys` the allowed sub-keys, `row` the
|
|
120
|
+
* per-element validator, `identity` the element's dedup key (or `null` when
|
|
121
|
+
* the element is malformed and already reported).
|
|
122
|
+
* @returns {string[]}
|
|
123
|
+
*/
|
|
124
|
+
function sectionListViolations(section, spec) {
|
|
125
|
+
const problems = [];
|
|
126
|
+
if (section === undefined) return problems;
|
|
127
|
+
if (!isPlainObject(section)) {
|
|
128
|
+
problems.push(`${spec.name}: must be an object when present, got ${describe(section)}`);
|
|
129
|
+
return problems;
|
|
130
|
+
}
|
|
131
|
+
for (const key of unknownKeys(section, spec.keys)) {
|
|
132
|
+
problems.push(
|
|
133
|
+
`${spec.name}.${key}: unknown key — ${spec.name} may carry only ${spec.keys.join(", ")}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
/** @type {Map<string, number>} */
|
|
137
|
+
const seen = new Map();
|
|
138
|
+
for (const kind of spec.keys) {
|
|
139
|
+
const list = section[kind];
|
|
140
|
+
if (list === undefined) continue;
|
|
141
|
+
if (!Array.isArray(list)) {
|
|
142
|
+
problems.push(`${spec.name}.${kind}: must be an array when present, got ${describe(list)}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
list.forEach((entry, index) => {
|
|
146
|
+
const at = `${spec.name}.${kind}[${index}]`;
|
|
147
|
+
const rowProblems = spec.row(entry, at);
|
|
148
|
+
if (rowProblems.length > 0) {
|
|
149
|
+
problems.push(...rowProblems);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const id = /** @type {string} */ (spec.identity(entry));
|
|
153
|
+
const first = seen.get(id);
|
|
154
|
+
if (first !== undefined) {
|
|
155
|
+
problems.push(
|
|
156
|
+
`${at}: duplicates ${spec.name}.${kind}[${first}] (${id}) — the contract is a set ` +
|
|
157
|
+
`of expected facts, and a repeated declaration would make reconciliation depend ` +
|
|
158
|
+
`on array order`,
|
|
159
|
+
);
|
|
160
|
+
} else {
|
|
161
|
+
seen.set(id, index);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// An `add` and a `remove` naming the same fact cancel to nothing while
|
|
166
|
+
// reading as a declaration — refuse the pair rather than reconcile a
|
|
167
|
+
// contradiction.
|
|
168
|
+
if (Array.isArray(section.add) && Array.isArray(section.remove)) {
|
|
169
|
+
const removeIds = new Set(section.remove.map(spec.identity));
|
|
170
|
+
section.add.forEach((entry, index) => {
|
|
171
|
+
const id = spec.identity(entry);
|
|
172
|
+
if (id !== null && removeIds.has(id)) {
|
|
173
|
+
problems.push(
|
|
174
|
+
`${spec.name}.add[${index}]: "${id}" is also declared in ${spec.name}.remove — ` +
|
|
175
|
+
`a fact cannot be both expected to appear and expected to disappear`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return problems;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Everything wrong with a raw change-intent file, as messages; empty when it
|
|
185
|
+
* is well-formed. Pure — no baseline, no graph, no clock: whether a declared
|
|
186
|
+
* reference CAN exist is `findChangeIntentReferenceViolations`' question,
|
|
187
|
+
* below.
|
|
188
|
+
*
|
|
189
|
+
* @param {unknown} raw The parsed JSON value.
|
|
190
|
+
* @returns {string[]}
|
|
191
|
+
*/
|
|
192
|
+
export function findChangeIntentViolations(raw) {
|
|
193
|
+
const violations = [];
|
|
194
|
+
if (!isPlainObject(raw)) {
|
|
195
|
+
return [`top level: must be an object, got ${describe(raw)}`];
|
|
196
|
+
}
|
|
197
|
+
for (const key of unknownKeys(raw, CHANGE_INTENT_TOP_LEVEL_KEYS)) {
|
|
198
|
+
violations.push(
|
|
199
|
+
`unknown key "${key}" — a change intent may carry only ` +
|
|
200
|
+
`${CHANGE_INTENT_TOP_LEVEL_KEYS.join(", ")}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (raw.version !== CHANGE_INTENT_VERSION) {
|
|
205
|
+
violations.push(
|
|
206
|
+
`version: must be exactly "${CHANGE_INTENT_VERSION}", got ${describe(raw.version)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The base pin is the whole proof that reconciliation compares the same two
|
|
211
|
+
// architectures the author saw: without a commit there is nothing to verify
|
|
212
|
+
// the baseline against, and the run must answer unproven rather than guess
|
|
213
|
+
// (`./change.mjs`). So the section itself is required, not optional.
|
|
214
|
+
if (!isPlainObject(raw.base)) {
|
|
215
|
+
violations.push(`base: is required and must be an object, got ${describe(raw.base)}`);
|
|
216
|
+
} else {
|
|
217
|
+
for (const key of unknownKeys(raw.base, CHANGE_INTENT_BASE_KEYS)) {
|
|
218
|
+
violations.push(
|
|
219
|
+
`base.${key}: unknown key — base may carry only ${CHANGE_INTENT_BASE_KEYS.join(", ")}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!nonEmptyString(raw.base.commit)) {
|
|
223
|
+
violations.push(
|
|
224
|
+
"base.commit: is required and must be a non-empty string — the git commit the " +
|
|
225
|
+
"baseline snapshot was captured at (`archkeep delta --capture` records it in the " +
|
|
226
|
+
"snapshot's provenance)",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (raw.summary !== undefined && !nonEmptyString(raw.summary)) {
|
|
232
|
+
violations.push(
|
|
233
|
+
`summary: must be a non-empty string when present, got ${describe(raw.summary)} — it is ` +
|
|
234
|
+
"informational only and is never read by the reconciliation",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
violations.push(
|
|
239
|
+
...sectionListViolations(raw.projects, {
|
|
240
|
+
name: "projects",
|
|
241
|
+
keys: CHANGE_INTENT_PROJECT_SECTION_KEYS,
|
|
242
|
+
row: (entry, at) =>
|
|
243
|
+
nonEmptyString(entry)
|
|
244
|
+
? []
|
|
245
|
+
: [`${at}: must be a non-empty project name, got ${describe(entry)}`],
|
|
246
|
+
identity: (entry) => (typeof entry === "string" ? entry : ""),
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
violations.push(
|
|
251
|
+
...sectionListViolations(raw.edges, {
|
|
252
|
+
name: "edges",
|
|
253
|
+
keys: CHANGE_INTENT_EDGE_SECTION_KEYS,
|
|
254
|
+
row: (entry, at) => {
|
|
255
|
+
if (!isPlainObject(entry)) {
|
|
256
|
+
return [`${at}: must be an object with "from" and "to", got ${describe(entry)}`];
|
|
257
|
+
}
|
|
258
|
+
const problems = [];
|
|
259
|
+
for (const key of unknownKeys(entry, CHANGE_INTENT_EDGE_ROW_KEYS)) {
|
|
260
|
+
problems.push(
|
|
261
|
+
`${at}.${key}: unknown key — an edge row may carry only ` +
|
|
262
|
+
`${CHANGE_INTENT_EDGE_ROW_KEYS.join(", ")}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
for (const side of ["from", "to"]) {
|
|
266
|
+
if (!nonEmptyString(entry[side])) {
|
|
267
|
+
problems.push(
|
|
268
|
+
`${at}.${side}: must be a non-empty project name, got ${describe(entry[side])}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (nonEmptyString(entry.from) && entry.from === entry.to) {
|
|
273
|
+
problems.push(
|
|
274
|
+
`${at}: "from" and "to" are both "${entry.from}" — the project graph strips ` +
|
|
275
|
+
`self-edges, so a self-dependency can never be observed and the declaration ` +
|
|
276
|
+
`could never match`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return problems;
|
|
280
|
+
},
|
|
281
|
+
identity: (entry) =>
|
|
282
|
+
isPlainObject(entry) && nonEmptyString(entry.from) && nonEmptyString(entry.to)
|
|
283
|
+
? `${entry.from}\u0000${entry.to}`
|
|
284
|
+
: "",
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (raw.constraints !== undefined) {
|
|
289
|
+
if (!isPlainObject(raw.constraints)) {
|
|
290
|
+
violations.push(
|
|
291
|
+
`constraints: must be an object when present, got ${describe(raw.constraints)}`,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
for (const key of unknownKeys(raw.constraints, CHANGE_INTENT_CONSTRAINT_KEYS)) {
|
|
295
|
+
violations.push(
|
|
296
|
+
`constraints.${key}: unknown key — constraints may carry only ` +
|
|
297
|
+
`${CHANGE_INTENT_CONSTRAINT_KEYS.join(", ")}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
for (const key of CHANGE_INTENT_CONSTRAINT_KEYS) {
|
|
301
|
+
const value = raw.constraints[key];
|
|
302
|
+
if (value === undefined) continue;
|
|
303
|
+
if (value !== true) {
|
|
304
|
+
violations.push(
|
|
305
|
+
`constraints.${key}: must be exactly true when present, got ${describe(value)} — ` +
|
|
306
|
+
"a constraint this run should not judge is declared by omitting the key, not by " +
|
|
307
|
+
"writing false",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return violations;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Parses and validates change-intent text into the normalized shape the
|
|
319
|
+
* `change` command reconciles against. Pure: text in, validated contract out.
|
|
320
|
+
*
|
|
321
|
+
* Absent sections normalize to empty expectations — a manifest with no
|
|
322
|
+
* `projects` section expects no project to appear or disappear, which is a
|
|
323
|
+
* REAL expectation the reconciliation enforces, never an absence of one.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} text The file contents.
|
|
326
|
+
* @param {string} path The path the text came from, for error messages.
|
|
327
|
+
* @returns {{
|
|
328
|
+
* version: string,
|
|
329
|
+
* base: {commit: string},
|
|
330
|
+
* summary?: string,
|
|
331
|
+
* projects: {add: string[], remove: string[]},
|
|
332
|
+
* edges: {add: {from: string, to: string}[], remove: {from: string, to: string}[]},
|
|
333
|
+
* constraints: {noNewViolations?: true, noNewCycles?: true},
|
|
334
|
+
* }}
|
|
335
|
+
* @throws {Error} naming every violation at once — these become exit-3-class
|
|
336
|
+
* input errors upstream, and a run that consumed a malformed declaration
|
|
337
|
+
* silently would reconcile against an expectation nobody wrote.
|
|
338
|
+
*/
|
|
339
|
+
export function parseChangeIntent(text, path) {
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(text);
|
|
343
|
+
} catch (cause) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`archkeep: the change intent '${path}' is not valid JSON: ${cause?.message ?? cause}`,
|
|
346
|
+
{ cause },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const violations = findChangeIntentViolations(parsed);
|
|
350
|
+
if (violations.length > 0) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`archkeep: the change intent '${path}' is not a usable contract:\n ` +
|
|
353
|
+
violations.join("\n "),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
version: parsed.version,
|
|
358
|
+
base: { commit: parsed.base.commit },
|
|
359
|
+
...(parsed.summary === undefined ? {} : { summary: parsed.summary }),
|
|
360
|
+
projects: {
|
|
361
|
+
add: parsed.projects?.add ?? [],
|
|
362
|
+
remove: parsed.projects?.remove ?? [],
|
|
363
|
+
},
|
|
364
|
+
edges: {
|
|
365
|
+
add: (parsed.edges?.add ?? []).map(({ from, to }) => ({ from, to })),
|
|
366
|
+
remove: (parsed.edges?.remove ?? []).map(({ from, to }) => ({ from, to })),
|
|
367
|
+
},
|
|
368
|
+
constraints: { ...(parsed.constraints ?? {}) },
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Reads the change-intent file from a path — the module's one filesystem seam,
|
|
374
|
+
* injectable so tests and embedders drive validation without disk.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} path Absolute path to the manifest.
|
|
377
|
+
* @param {{read?: (path: string) => Promise<string>}} [io]
|
|
378
|
+
* @returns {Promise<object>} Whatever `parseChangeIntent` returns.
|
|
379
|
+
* @throws {Error} when the file cannot be read, and whatever
|
|
380
|
+
* `parseChangeIntent` throws.
|
|
381
|
+
*/
|
|
382
|
+
export async function readChangeIntent(path, io = {}) {
|
|
383
|
+
const read = io.read ?? ((p) => readFileFromDisk(p, "utf8"));
|
|
384
|
+
let text;
|
|
385
|
+
try {
|
|
386
|
+
text = await read(path);
|
|
387
|
+
} catch (cause) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`archkeep: cannot read the change intent '${path}': ${cause?.message ?? cause}`,
|
|
390
|
+
{ cause },
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return parseChangeIntent(text, path);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Everything wrong with a loaded manifest's REFERENCES — whether each declared
|
|
398
|
+
* fact could exist in a tree descended from the captured baseline. Pure: the
|
|
399
|
+
* baseline's project-name set arrives as an argument.
|
|
400
|
+
*
|
|
401
|
+
* These are load errors too (the command throws on the first list), because a
|
|
402
|
+
* declaration that references a project that exists nowhere would sit in
|
|
403
|
+
* `missingExpected` forever, reading as "the change forgot something" when
|
|
404
|
+
* the truth is "the declaration cannot be satisfied by ANY tree".
|
|
405
|
+
*
|
|
406
|
+
* @param {object} intent A `parseChangeIntent` result.
|
|
407
|
+
* @param {Set<string>} baselineProjects Every project name in the captured
|
|
408
|
+
* baseline's graph.
|
|
409
|
+
* @returns {string[]} One entry per problem, empty when every reference lands.
|
|
410
|
+
*/
|
|
411
|
+
export function findChangeIntentReferenceViolations(intent, baselineProjects) {
|
|
412
|
+
const violations = [];
|
|
413
|
+
const known = new Set([...baselineProjects, ...intent.projects.add]);
|
|
414
|
+
for (const name of intent.projects.add) {
|
|
415
|
+
if (baselineProjects.has(name)) {
|
|
416
|
+
violations.push(
|
|
417
|
+
`projects.add: "${name}" already exists at the captured base — a project the base ` +
|
|
418
|
+
`graph already has cannot be declared as appearing`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const name of intent.projects.remove) {
|
|
423
|
+
if (!baselineProjects.has(name)) {
|
|
424
|
+
violations.push(
|
|
425
|
+
`projects.remove: "${name}" does not exist at the captured base — there is no such ` +
|
|
426
|
+
`project to expect gone`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const [kind, list] of [
|
|
431
|
+
["add", intent.edges.add],
|
|
432
|
+
["remove", intent.edges.remove],
|
|
433
|
+
]) {
|
|
434
|
+
for (const { from, to } of list) {
|
|
435
|
+
for (const side of ["from", "to"]) {
|
|
436
|
+
const name = side === "from" ? from : to;
|
|
437
|
+
if (kind === "add") {
|
|
438
|
+
if (intent.projects.remove.includes(name)) {
|
|
439
|
+
violations.push(
|
|
440
|
+
`edges.${kind}: ${from} -> ${to} names "${name}" in projects.remove — an edge ` +
|
|
441
|
+
`cannot appear attached to a project declared to disappear`,
|
|
442
|
+
);
|
|
443
|
+
} else if (!known.has(name)) {
|
|
444
|
+
violations.push(
|
|
445
|
+
`edges.${kind}: ${from} -> ${to} names "${name}", which is neither in the ` +
|
|
446
|
+
`baseline graph nor declared in projects.add — the reference can never match ` +
|
|
447
|
+
`anything`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
} else if (!baselineProjects.has(name)) {
|
|
451
|
+
violations.push(
|
|
452
|
+
`edges.${kind}: ${from} -> ${to} names "${name}", which is not in the baseline ` +
|
|
453
|
+
`graph — an edge of a project the base does not have cannot be expected to ` +
|
|
454
|
+
`disappear${intent.projects.add.includes(name) ? " (it is declared in projects.add, so it had no base edges)" : ""}`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return violations;
|
|
461
|
+
}
|