@intentius/chant 0.78.0 → 0.80.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/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +18 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +22 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
- package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +142 -0
- package/dist/op/gate-approval.d.ts.map +1 -0
- package/dist/op/gate.d.ts +54 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +4 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +118 -2
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +78 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +18 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
- package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/builders.ts +10 -1
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +260 -0
- package/src/op/gate.ts +148 -10
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/index.ts +9 -1
- package/src/op/local-executor.test.ts +66 -0
- package/src/op/local-executor.ts +44 -1
- package/src/op/op-ir.ts +4 -0
- package/src/op/runtimes/local.ts +2 -1
- package/src/op/types.ts +7 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
package/src/op/op-ir.ts
CHANGED
|
@@ -144,6 +144,8 @@ export interface OpIRGateStep {
|
|
|
144
144
|
* Absent on a gate that binds no plan.
|
|
145
145
|
*/
|
|
146
146
|
plan?: GateStep["plan"];
|
|
147
|
+
/** Quorum, roles and policy (#2508), carried as authored. Absent on a gate that passes on one approval. */
|
|
148
|
+
approval?: GateStep["approval"];
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
export interface OpIREffectStep {
|
|
@@ -240,6 +242,7 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
240
242
|
timeout: step.timeout ?? "48h",
|
|
241
243
|
...(step.description ? { description: step.description } : {}),
|
|
242
244
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
245
|
+
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
243
246
|
};
|
|
244
247
|
}
|
|
245
248
|
|
|
@@ -389,6 +392,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
389
392
|
timeout: step.timeout,
|
|
390
393
|
...(step.description ? { description: step.description } : {}),
|
|
391
394
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
395
|
+
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
392
396
|
};
|
|
393
397
|
}
|
|
394
398
|
return {
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* answers.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { lexiconNames } from "../../lexicon-module";
|
|
21
22
|
import { loadChantConfig } from "../../config";
|
|
22
23
|
import { loadActivities, loadProfiles } from "../activity-registry";
|
|
23
24
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
|
|
@@ -129,7 +130,7 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
129
130
|
// unreadable config just yields the base activities.
|
|
130
131
|
let lexicons: string[] = [];
|
|
131
132
|
try {
|
|
132
|
-
lexicons = (await loadChantConfig(projectPath)).config.lexicons ?? [];
|
|
133
|
+
lexicons = lexiconNames((await loadChantConfig(projectPath)).config.lexicons ?? []);
|
|
133
134
|
} catch {
|
|
134
135
|
// No/invalid chant.config — base activities only.
|
|
135
136
|
}
|
package/src/op/types.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { EffectReceiptRef } from "./receipt-store";
|
|
9
9
|
import type { ActivityProfileName } from "./activity-profiles";
|
|
10
10
|
import type { StepOutputRef } from "./step-output-ref";
|
|
11
|
+
import type { GateApproval } from "./gate-approval";
|
|
11
12
|
|
|
12
13
|
export interface OpConfig {
|
|
13
14
|
/** Kebab-case identifier. Names the Op's output directory (`dist/ops/<name>/`), and is the name `chant run <name>` and another Op's `depends` refer to. */
|
|
@@ -196,6 +197,12 @@ export interface GateStepBase {
|
|
|
196
197
|
* phase publishes no digest is not thereby unrunnable.
|
|
197
198
|
*/
|
|
198
199
|
plan?: string | StepOutputRef;
|
|
200
|
+
/**
|
|
201
|
+
* Quorum, roles and a policy for who may pass this gate (#2508). Absent,
|
|
202
|
+
* the gate passes on one approval, as every gate did before. See
|
|
203
|
+
* `./gate-approval.ts`.
|
|
204
|
+
*/
|
|
205
|
+
approval?: GateApproval;
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
/**
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where records are read from: the working tree, or a commit's git objects
|
|
3
|
+
* (`--at <rev>`, #2536). Reading at a revision needs no checkout and no
|
|
4
|
+
* network; it asks the local git object store only.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { RecordReadError } from "./records";
|
|
11
|
+
|
|
12
|
+
export interface RecordSource {
|
|
13
|
+
/** Appended to messages, such as " at 91c7547e". Empty for the working tree. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** File names directly inside `dir` (relative to the root, `/`-separated), or undefined when `dir` is missing. */
|
|
16
|
+
list(dir: string): string[] | undefined;
|
|
17
|
+
/** The text of a file listed by `list`. */
|
|
18
|
+
read(path: string): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Records in the working tree under `root`. */
|
|
22
|
+
export function workingTreeSource(root: string): RecordSource {
|
|
23
|
+
return {
|
|
24
|
+
label: "",
|
|
25
|
+
list(dir) {
|
|
26
|
+
try {
|
|
27
|
+
return readdirSync(join(root, dir), { withFileTypes: true })
|
|
28
|
+
.filter((d) => d.isFile())
|
|
29
|
+
.map((d) => d.name);
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
read(path) {
|
|
35
|
+
return readFileSync(join(root, path), "utf-8");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function git(cwd: string, args: string[], input?: string): string {
|
|
41
|
+
return execFileSync("git", args, {
|
|
42
|
+
cwd,
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
input,
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
46
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The top of the git repository holding `cwd`, or undefined outside one. */
|
|
51
|
+
export function gitRoot(cwd: string): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The full commit id `rev` names in the repository at `root`. */
|
|
60
|
+
export function resolveRevision(root: string, rev: string): string {
|
|
61
|
+
if (rev.startsWith("-")) throw new RecordReadError("revision-unknown", `--at ${rev} is not a revision`);
|
|
62
|
+
try {
|
|
63
|
+
return git(root, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).trim();
|
|
64
|
+
} catch {
|
|
65
|
+
throw new RecordReadError("revision-unknown", `--at ${rev} names no commit in this repository`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Records as they were at `commit`, read from the object store of the repository at `root`. */
|
|
70
|
+
export function gitRevisionSource(root: string, commit: string): RecordSource {
|
|
71
|
+
const blobs = new Map<string, string>();
|
|
72
|
+
return {
|
|
73
|
+
label: ` at ${commit.slice(0, 8)}`,
|
|
74
|
+
list(dir) {
|
|
75
|
+
const treeish = dir === "." ? `${commit}^{tree}` : `${commit}:${dir}`;
|
|
76
|
+
try {
|
|
77
|
+
if (git(root, ["cat-file", "-t", treeish]).trim() !== "tree") return undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const entries = git(root, ["ls-tree", "-z", treeish])
|
|
82
|
+
.split("\0")
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.map((line) => {
|
|
85
|
+
const tab = line.indexOf("\t");
|
|
86
|
+
const [, type, oid] = line.slice(0, tab).split(" ");
|
|
87
|
+
return { type, oid, name: line.slice(tab + 1) };
|
|
88
|
+
})
|
|
89
|
+
.filter((e) => e.type === "blob");
|
|
90
|
+
// One `cat-file --batch` for the whole directory: records are small, and
|
|
91
|
+
// one process beats one per file.
|
|
92
|
+
if (entries.length > 0) {
|
|
93
|
+
const out = Buffer.from(
|
|
94
|
+
execFileSync("git", ["cat-file", "--batch"], {
|
|
95
|
+
cwd: root,
|
|
96
|
+
input: entries.map((e) => e.oid).join("\n") + "\n",
|
|
97
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
let at = 0;
|
|
101
|
+
for (const e of entries) {
|
|
102
|
+
const nl = out.indexOf(0x0a, at);
|
|
103
|
+
const size = Number(out.subarray(at, nl).toString("utf-8").split(" ")[2]);
|
|
104
|
+
const start = nl + 1;
|
|
105
|
+
blobs.set(dir === "." ? e.name : `${dir}/${e.name}`, out.subarray(start, start + size).toString("utf-8"));
|
|
106
|
+
at = start + size + 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return entries.map((e) => e.name);
|
|
110
|
+
},
|
|
111
|
+
read(path) {
|
|
112
|
+
const text = blobs.get(path);
|
|
113
|
+
if (text === undefined) throw new Error(`${path} was not listed at ${commit}`);
|
|
114
|
+
return text;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace records --kind <path> [--current] [--at <rev>] [--json]`,
|
|
3
|
+
* the first-test slice of the records query (#2546, #2536).
|
|
4
|
+
*
|
|
5
|
+
* It reads every record the kind file locates and prints them, with reason
|
|
6
|
+
* codes for any that are invalid. An invalid record never fails the command:
|
|
7
|
+
* the exit code is 0 whenever the read itself worked. Only a kind, schema or
|
|
8
|
+
* revision that cannot be read exits 1.
|
|
9
|
+
*
|
|
10
|
+
* It needs no `chant.workspace.json`. The kind is passed explicitly, so
|
|
11
|
+
* nothing is inferred (#2525 rule 1).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { relative } from "node:path";
|
|
15
|
+
import { formatError } from "../cli/format";
|
|
16
|
+
import type { CommandContext } from "../cli/registry";
|
|
17
|
+
import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
|
|
18
|
+
import { loadRecordKind, readRecords, RecordReadError, type ReadErrorCode, type RecordEntry } from "./records";
|
|
19
|
+
|
|
20
|
+
/** The version of the `records` output this chant writes. */
|
|
21
|
+
export const RECORDS_CONTRACT_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
|
|
24
|
+
export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
|
|
25
|
+
|
|
26
|
+
const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--json]";
|
|
27
|
+
|
|
28
|
+
export interface RecordsQuery {
|
|
29
|
+
kind: string;
|
|
30
|
+
current?: boolean;
|
|
31
|
+
at?: string;
|
|
32
|
+
/** Where `kind` is resolved from and the repository is found. */
|
|
33
|
+
cwd: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `records` output: a result, or a failure with one error code. */
|
|
37
|
+
export type RecordsDocument =
|
|
38
|
+
| {
|
|
39
|
+
$schema: string;
|
|
40
|
+
contract: number;
|
|
41
|
+
kind: { name: string; schema: string; file: string };
|
|
42
|
+
at: string | null;
|
|
43
|
+
current: boolean;
|
|
44
|
+
records: RecordEntry[];
|
|
45
|
+
summary: { total: number; valid: number; invalid: number; superseded: number };
|
|
46
|
+
}
|
|
47
|
+
| { $schema: string; contract: number; error: { code: ReadErrorCode; message: string } };
|
|
48
|
+
|
|
49
|
+
/** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
|
|
50
|
+
export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
|
|
51
|
+
const top = gitRoot(query.cwd);
|
|
52
|
+
const root = top ?? query.cwd;
|
|
53
|
+
try {
|
|
54
|
+
const loaded = await loadRecordKind(query.kind, query.cwd);
|
|
55
|
+
let at: string | null = null;
|
|
56
|
+
let source = workingTreeSource(root);
|
|
57
|
+
if (query.at !== undefined) {
|
|
58
|
+
if (!top) throw new RecordReadError("not-a-git-repository", "--at reads git objects, and this directory is not in a git repository");
|
|
59
|
+
at = resolveRevision(top, query.at);
|
|
60
|
+
source = gitRevisionSource(top, at);
|
|
61
|
+
}
|
|
62
|
+
const result = await readRecords(loaded, { root, source, current: !!query.current });
|
|
63
|
+
return {
|
|
64
|
+
$schema: RECORDS_OUTPUT_SCHEMA_ID,
|
|
65
|
+
contract: RECORDS_CONTRACT_VERSION,
|
|
66
|
+
kind: {
|
|
67
|
+
name: loaded.kind.name,
|
|
68
|
+
schema: loaded.kind.schema.id,
|
|
69
|
+
file: relative(root, loaded.file).split("\\").join("/"),
|
|
70
|
+
},
|
|
71
|
+
at,
|
|
72
|
+
current: !!query.current,
|
|
73
|
+
records: result.records,
|
|
74
|
+
summary: result.summary,
|
|
75
|
+
};
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
78
|
+
return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, error: { code: err.code, message: err.message } };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
|
|
83
|
+
const { args } = ctx;
|
|
84
|
+
if (!args.kind) {
|
|
85
|
+
console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
const doc = await queryRecords({ kind: args.kind, current: args.current, at: args.at, cwd: process.cwd() });
|
|
89
|
+
if (args.json) {
|
|
90
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
91
|
+
} else if ("error" in doc) {
|
|
92
|
+
console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
93
|
+
} else {
|
|
94
|
+
console.log(formatRecords(doc.records, doc.summary, doc.at));
|
|
95
|
+
}
|
|
96
|
+
return "error" in doc ? 1 : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatRecords(records: RecordEntry[], summary: { total: number; valid: number; invalid: number; superseded: number }, at: string | null): string {
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
const idWidth = Math.max(2, ...records.map((r) => (r.id ?? "-").length));
|
|
102
|
+
const stateWidth = Math.max(5, ...records.map((r) => (r.state ?? "-").length));
|
|
103
|
+
for (const r of records) {
|
|
104
|
+
const title = typeof r.data?.title === "string" ? r.data.title : r.path;
|
|
105
|
+
const flag = r.valid ? "" : " INVALID";
|
|
106
|
+
const superseded = r.supersededBy ? ` superseded by ${r.supersededBy}` : "";
|
|
107
|
+
lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${flag}`);
|
|
108
|
+
for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
|
|
109
|
+
}
|
|
110
|
+
lines.push(
|
|
111
|
+
`${summary.total} records${at ? ` at ${at.slice(0, 8)}` : ""}: ${summary.valid} valid, ${summary.invalid} invalid, ${summary.superseded} superseded`,
|
|
112
|
+
);
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `chant workspace <anything else>`: only `records` exists so far. */
|
|
117
|
+
export async function runWorkspaceUnknown(ctx: CommandContext): Promise<number> {
|
|
118
|
+
const sub = ctx.args.path && ctx.args.path !== "." ? ctx.args.path : "";
|
|
119
|
+
console.error(
|
|
120
|
+
formatError({
|
|
121
|
+
message: sub ? `Unknown workspace subcommand: ${sub}` : "chant workspace needs a subcommand",
|
|
122
|
+
hint: `Only records exists so far: ${USAGE}`,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read contract for `chant workspace records --json` (#2536): the output
|
|
3
|
+
* schema is a valid draft 2020-12 document, its closed code lists match the
|
|
4
|
+
* code, and real output validates against it. The reference workspace (#2543)
|
|
5
|
+
* doesn't exist yet, so the chant repo's own decision files stand in for it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
12
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
13
|
+
import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
|
|
14
|
+
import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
|
|
15
|
+
import schema from "./records.schema.json";
|
|
16
|
+
|
|
17
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
18
|
+
const KIND = "docs/design/decisions/decision.kind.mjs";
|
|
19
|
+
|
|
20
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
21
|
+
const validate = ajv.compile(schema);
|
|
22
|
+
|
|
23
|
+
function expectValid(doc: RecordsDocument): void {
|
|
24
|
+
const ok = validate(doc);
|
|
25
|
+
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const scratch: string[] = [];
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** A directory holding a copy of the decisions, kind and schema, outside any git repository. */
|
|
34
|
+
function copyDecisions(): string {
|
|
35
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-records-contract-")));
|
|
36
|
+
scratch.push(root);
|
|
37
|
+
mkdirSync(join(root, "docs", "design"), { recursive: true });
|
|
38
|
+
cpSync(join(REPO, "docs", "design", "decisions"), join(root, "docs", "design", "decisions"), { recursive: true });
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("records output schema", () => {
|
|
43
|
+
test("is a valid draft 2020-12 document with the published $id", () => {
|
|
44
|
+
expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
|
|
45
|
+
expect(ajv.validateSchema(schema)).toBe(true);
|
|
46
|
+
expect(schema.$id).toBe(RECORDS_OUTPUT_SCHEMA_ID);
|
|
47
|
+
expect(RECORDS_CONTRACT_VERSION).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("lists exactly the reason and error codes the code can return", () => {
|
|
51
|
+
expect(schema.$defs.reason.properties.code.enum).toEqual([...RECORD_REASON_CODES]);
|
|
52
|
+
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...READ_ERROR_CODES]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the chant repo's decisions validate, current and not", async () => {
|
|
56
|
+
for (const current of [false, true]) {
|
|
57
|
+
const doc = await queryRecords({ kind: KIND, current, cwd: REPO });
|
|
58
|
+
expectValid(doc);
|
|
59
|
+
expect("error" in doc).toBe(false);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("invalid records validate, each with its reason", async () => {
|
|
64
|
+
const root = copyDecisions();
|
|
65
|
+
const dir = join(root, "docs", "design", "decisions");
|
|
66
|
+
writeFileSync(join(dir, "ws-001-trust-root.md"), "no front matter\n");
|
|
67
|
+
writeFileSync(join(dir, "ws-900-extra.md"), "---\nid: \"ws-900\"\nstate: \"decided\"\n---\n");
|
|
68
|
+
const doc = await queryRecords({ kind: KIND, cwd: root });
|
|
69
|
+
expectValid(doc);
|
|
70
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
71
|
+
expect(doc.at).toBeNull();
|
|
72
|
+
expect(doc.summary.invalid).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("every failure validates with its code", async () => {
|
|
76
|
+
const root = copyDecisions();
|
|
77
|
+
const docs = [
|
|
78
|
+
await queryRecords({ kind: "missing.kind.mjs", cwd: root }),
|
|
79
|
+
await queryRecords({ kind: KIND, at: "HEAD", cwd: root }),
|
|
80
|
+
];
|
|
81
|
+
for (const doc of docs) expectValid(doc);
|
|
82
|
+
expect(docs.map((d) => ("error" in d ? d.error.code : "ok"))).toEqual(["kind-unreadable", "not-a-git-repository"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a document mixing records and an error is refused", () => {
|
|
86
|
+
expect(validate({ $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: 1, error: { code: "revision-unknown", message: "x" }, records: [] })).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json",
|
|
4
|
+
"title": "chant workspace records output",
|
|
5
|
+
"description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536). This is the first-test slice (#2546): records are unsealed and there is no workspace declaration yet. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version.",
|
|
6
|
+
"oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }],
|
|
7
|
+
"$defs": {
|
|
8
|
+
"result": {
|
|
9
|
+
"description": "The read worked. Exit code 0, even when some records are invalid.",
|
|
10
|
+
"type": "object",
|
|
11
|
+
"required": ["$schema", "contract", "kind", "at", "current", "records", "summary"],
|
|
12
|
+
"properties": {
|
|
13
|
+
"$schema": { "const": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json" },
|
|
14
|
+
"contract": { "const": 1 },
|
|
15
|
+
"kind": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["name", "schema", "file"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"name": { "type": "string", "description": "The kind's name, such as \"decision\"." },
|
|
20
|
+
"schema": { "type": "string", "description": "The `$id` of the schema each record was validated against." },
|
|
21
|
+
"file": { "type": "string", "description": "The kind file, relative to the repository root, with / separators." }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"at": {
|
|
25
|
+
"description": "The full commit id the records were read at (--at), or null for the working tree.",
|
|
26
|
+
"type": ["string", "null"],
|
|
27
|
+
"pattern": "^[0-9a-f]{40,64}$"
|
|
28
|
+
},
|
|
29
|
+
"error": false,
|
|
30
|
+
"current": { "type": "boolean", "description": "Whether superseded records were left out (--current)." },
|
|
31
|
+
"records": {
|
|
32
|
+
"description": "One entry per file the kind locates, sorted by path.",
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": { "$ref": "#/$defs/record" }
|
|
35
|
+
},
|
|
36
|
+
"summary": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"required": ["total", "valid", "invalid", "superseded"],
|
|
39
|
+
"properties": {
|
|
40
|
+
"total": { "type": "integer", "minimum": 0, "description": "Entries in records." },
|
|
41
|
+
"valid": { "type": "integer", "minimum": 0 },
|
|
42
|
+
"invalid": { "type": "integer", "minimum": 0 },
|
|
43
|
+
"superseded": { "type": "integer", "minimum": 0, "description": "Records a closed record supersedes, counted before --current leaves them out." }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"record": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"required": ["id", "path", "state", "valid", "reasons", "supersededBy", "data"],
|
|
51
|
+
"properties": {
|
|
52
|
+
"id": { "type": ["string", "null"], "description": "Null when the front matter could not be parsed or has no string id." },
|
|
53
|
+
"path": { "type": "string", "description": "From the repository root, with / separators." },
|
|
54
|
+
"state": { "type": ["string", "null"], "description": "The state as the record writes it. Supersession is reported in supersededBy, not here." },
|
|
55
|
+
"valid": { "type": "boolean", "description": "True exactly when reasons is empty." },
|
|
56
|
+
"reasons": { "type": "array", "items": { "$ref": "#/$defs/reason" } },
|
|
57
|
+
"supersededBy": {
|
|
58
|
+
"type": ["string", "null"],
|
|
59
|
+
"description": "The id of the closed record whose supersedes link replaces this one. Derived from that record's link, never from this record's own fields."
|
|
60
|
+
},
|
|
61
|
+
"data": {
|
|
62
|
+
"type": ["object", "null"],
|
|
63
|
+
"description": "The front matter as JSON, following the kind's schema when valid is true. Null when it could not be parsed."
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"allOf": [
|
|
67
|
+
{
|
|
68
|
+
"if": { "properties": { "valid": { "const": true } } },
|
|
69
|
+
"then": { "properties": { "reasons": { "type": "array", "maxItems": 0 }, "data": { "type": "object" }, "id": { "type": "string" } } },
|
|
70
|
+
"else": { "properties": { "reasons": { "type": "array", "minItems": 1 } } }
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"reason": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": ["code", "message"],
|
|
77
|
+
"properties": {
|
|
78
|
+
"code": {
|
|
79
|
+
"enum": [
|
|
80
|
+
"record-unparseable",
|
|
81
|
+
"record-schema-invalid",
|
|
82
|
+
"record-id-duplicate",
|
|
83
|
+
"record-supersedes-unknown",
|
|
84
|
+
"record-supersedes-conflict"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"message": { "type": "string" }
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"failure": {
|
|
91
|
+
"description": "The read failed and returned no records. Exit code 1.",
|
|
92
|
+
"type": "object",
|
|
93
|
+
"required": ["$schema", "contract", "error"],
|
|
94
|
+
"properties": {
|
|
95
|
+
"$schema": { "const": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json" },
|
|
96
|
+
"contract": { "const": 1 },
|
|
97
|
+
"error": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"required": ["code", "message"],
|
|
100
|
+
"properties": {
|
|
101
|
+
"code": {
|
|
102
|
+
"enum": [
|
|
103
|
+
"kind-unreadable",
|
|
104
|
+
"kind-invalid",
|
|
105
|
+
"schema-unreadable",
|
|
106
|
+
"schema-id-mismatch",
|
|
107
|
+
"schema-invalid",
|
|
108
|
+
"location-missing",
|
|
109
|
+
"not-a-git-repository",
|
|
110
|
+
"revision-unknown"
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
"message": { "type": "string" }
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"records": false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|