@intentius/chant 0.79.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.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 +14 -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/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/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.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.ts +2 -1
- 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 +69 -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 +14 -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/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/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.ts +3 -1
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- 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
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import * as coreContracts from "./activities/activity-contracts";
|
|
38
38
|
import { collectActivityContracts, type ActivityContract } from "./activity-contract";
|
|
39
|
+
import { importLexiconModule } from "../lexicon-module";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* The shape {@link loadActivityContracts} reads off a plugin: an optional
|
|
@@ -74,16 +75,29 @@ export async function loadActivityContracts(
|
|
|
74
75
|
|
|
75
76
|
for (const entry of lexicons) {
|
|
76
77
|
const name = typeof entry === "string" ? entry : entry.name;
|
|
78
|
+
let contributor: LexiconActivityContractContributor | undefined = typeof entry === "string" ? undefined : entry;
|
|
77
79
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
// chant #2520 — a lexicon declared by module path has no subpath to
|
|
81
|
+
// import. Its plugin's `activityContracts()` member stands in for it;
|
|
82
|
+
// given only the name, the plugin is read from the declared module.
|
|
83
|
+
const local = await importLexiconModule(name);
|
|
84
|
+
if (local !== undefined) {
|
|
85
|
+
contributor ??= Object.values(local).find(
|
|
86
|
+
(v): v is LexiconActivityContractContributor =>
|
|
87
|
+
typeof v === "object" && v !== null && (v as { name?: unknown }).name === name &&
|
|
88
|
+
typeof (v as { activityContracts?: unknown }).activityContracts === "function",
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
const spec = `@intentius/chant-lexicon-${name}/op/activity-contracts`;
|
|
92
|
+
collectActivityContracts((await import(spec)) as Record<string, unknown>, contracts);
|
|
93
|
+
}
|
|
80
94
|
} catch {
|
|
81
95
|
// Lexicon absent or declares no contracts at the conventional subpath.
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
if (
|
|
98
|
+
if (contributor !== undefined && typeof contributor.activityContracts === "function") {
|
|
85
99
|
try {
|
|
86
|
-
for (const contract of
|
|
100
|
+
for (const contract of contributor.activityContracts()) contracts.set(contract.name, contract);
|
|
87
101
|
} catch {
|
|
88
102
|
// A plugin member that throws contributes nothing, the same as one that is absent.
|
|
89
103
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import * as baseActivities from "./activities";
|
|
14
14
|
import { ACTIVITY_PROFILES, type ActivityProfile } from "./activity-profiles";
|
|
15
|
+
import { importLexiconModule } from "../lexicon-module";
|
|
15
16
|
|
|
16
17
|
export type { ActivityProfile } from "./activity-profiles";
|
|
17
18
|
|
|
@@ -52,6 +53,16 @@ export async function loadActivities(lexicons: string[] = []): Promise<Map<strin
|
|
|
52
53
|
|
|
53
54
|
for (const name of lexicons) {
|
|
54
55
|
try {
|
|
56
|
+
// chant #2520 — a lexicon declared by module path has no subpath to
|
|
57
|
+
// import; its plugin's `activities()` member stands in for it.
|
|
58
|
+
const local = await importLexiconModule(name);
|
|
59
|
+
if (local !== undefined) {
|
|
60
|
+
const plugin = Object.values(local).find((v) => isActivityContributor(v) && v.name === name) as
|
|
61
|
+
| ActivityContributor
|
|
62
|
+
| undefined;
|
|
63
|
+
if (plugin) collectActivities(await plugin.activities(), activities);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
55
66
|
const spec = `@intentius/chant-lexicon-${name}/op/activities`;
|
|
56
67
|
collectActivities((await import(spec)) as Record<string, unknown>, activities);
|
|
57
68
|
} catch {
|
|
@@ -62,6 +73,21 @@ export async function loadActivities(lexicons: string[] = []): Promise<Map<strin
|
|
|
62
73
|
return activities;
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
/** A plugin-shaped export with an `activities()` member (`LexiconPlugin.activities`). Structural, so this layer does not import `../lexicon.ts`. */
|
|
77
|
+
interface ActivityContributor {
|
|
78
|
+
name: string;
|
|
79
|
+
activities(): Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isActivityContributor(value: unknown): value is ActivityContributor {
|
|
83
|
+
return (
|
|
84
|
+
typeof value === "object" &&
|
|
85
|
+
value !== null &&
|
|
86
|
+
typeof (value as { name?: unknown }).name === "string" &&
|
|
87
|
+
typeof (value as { activities?: unknown }).activities === "function"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
/**
|
|
66
92
|
* The built-in profile table. Kept async because it is awaited on the CLI's
|
|
67
93
|
* run path alongside {@link loadActivities}, and because a hosted runtime may
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discoverOps } from "./discover";
|
|
6
|
+
import { resetDiscoveryWarnings } from "../discovery/convergence";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* chant#2527's warning release for Op discovery. The upward search for the
|
|
10
|
+
* Op root stays as it is; what changes next release is the downward walk,
|
|
11
|
+
* which stops at child projects. Today an Op in a child project is
|
|
12
|
+
* discovered, and it still is here, with a warning naming it.
|
|
13
|
+
*/
|
|
14
|
+
let fakeGitRoot = "";
|
|
15
|
+
vi.mock("../runtime-adapter", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("../runtime-adapter")>()),
|
|
17
|
+
getRuntime: () => ({
|
|
18
|
+
spawn: async (cmd: string[]) =>
|
|
19
|
+
cmd[0] === "git" && cmd[1] === "rev-parse"
|
|
20
|
+
? { stdout: fakeGitRoot, stderr: "", exitCode: 0 }
|
|
21
|
+
: { stdout: "", stderr: "", exitCode: 0 },
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const OP_FILE = (name: string): string =>
|
|
26
|
+
`export default { props: { name: ${JSON.stringify(name)}, overview: "t", phases: [{ name: "Run", steps: [] }] } };\n`;
|
|
27
|
+
|
|
28
|
+
describe("discoverOps — an Op in a child project (#2527 warning release)", () => {
|
|
29
|
+
let root: string;
|
|
30
|
+
let stderr: string[];
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-2527-ops-")));
|
|
34
|
+
fakeGitRoot = root;
|
|
35
|
+
resetDiscoveryWarnings();
|
|
36
|
+
stderr = [];
|
|
37
|
+
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => void stderr.push(args.join(" ")));
|
|
38
|
+
vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
39
|
+
writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
40
|
+
mkdirSync(join(root, "ops"), { recursive: true });
|
|
41
|
+
writeFileSync(join(root, "ops", "mine.op.ts"), OP_FILE("mine"));
|
|
42
|
+
mkdirSync(join(root, "child", "ops"), { recursive: true });
|
|
43
|
+
writeFileSync(join(root, "child", "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
44
|
+
writeFileSync(join(root, "child", "ops", "theirs.op.ts"), OP_FILE("theirs"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
rmSync(root, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("is still discovered, and the warning names it with the include glob", async () => {
|
|
53
|
+
const { ops, errors } = await discoverOps({ cwd: root });
|
|
54
|
+
expect(errors).toEqual([]);
|
|
55
|
+
expect([...ops.keys()].sort()).toEqual(["mine", "theirs"]);
|
|
56
|
+
expect(stderr).toEqual([
|
|
57
|
+
"warning: Op discovery under the current directory changes in the next release (chant#2527):\n" +
|
|
58
|
+
" child/ops/theirs.op.ts: child/ is a child project with its own chant.config. " +
|
|
59
|
+
"Discovery stops at child projects from the next release. " +
|
|
60
|
+
'To keep reading it after the change, add "child" to include in chant.config.json, which the next release honours.',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("with the glob in the config, nothing is printed", async () => {
|
|
65
|
+
writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], include: ["child"] }));
|
|
66
|
+
const { ops } = await discoverOps({ cwd: root });
|
|
67
|
+
expect([...ops.keys()].sort()).toEqual(["mine", "theirs"]);
|
|
68
|
+
expect(stderr).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/op/discover.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import type { OpConfig } from "./types";
|
|
6
|
+
import { warnDiscoveryChanges } from "../discovery/convergence";
|
|
6
7
|
|
|
7
8
|
export interface DiscoveredOp {
|
|
8
9
|
config: OpConfig;
|
|
@@ -148,6 +149,9 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
|
|
|
148
149
|
|
|
149
150
|
const root = await findDiscoveryRoot(opts?.cwd);
|
|
150
151
|
const files = await collectOpFiles(root);
|
|
152
|
+
// #2527's warning release: Op discovery stops at child projects below the
|
|
153
|
+
// root next release, and skips git-ignored files. `files` is unchanged.
|
|
154
|
+
await warnDiscoveryChanges({ walker: "ops", root, files });
|
|
151
155
|
|
|
152
156
|
const nameToFile = new Map<string, string>();
|
|
153
157
|
|
package/src/op/gate-approval.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { createHash } from "node:crypto";
|
|
35
|
+
import { lexiconModulePath } from "../lexicon-module";
|
|
35
36
|
import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
|
|
36
37
|
|
|
37
38
|
/** What a policy's decision does to the gate. */
|
|
@@ -221,7 +222,8 @@ function isContextValue(value: unknown): boolean {
|
|
|
221
222
|
|
|
222
223
|
/** Import `lexicon`'s evaluator. Throws a message that names the package when it is not installed or exports none. */
|
|
223
224
|
export async function loadGatePolicyEvaluator(lexicon: string): Promise<GatePolicyEvaluator> {
|
|
224
|
-
|
|
225
|
+
// chant #2520 — a lexicon declared by module path exports its evaluator from that module.
|
|
226
|
+
const spec = lexiconModulePath(lexicon) ?? `@intentius/chant-lexicon-${lexicon}/gate-policy`;
|
|
225
227
|
let mod: Partial<GatePolicyEvaluator>;
|
|
226
228
|
try {
|
|
227
229
|
mod = (await import(spec)) as Partial<GatePolicyEvaluator>;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { discoverOps, type DiscoveredOp } from "./discover";
|
|
20
|
+
import { lexiconModulePath } from "../lexicon-module";
|
|
20
21
|
import {
|
|
21
22
|
isLexiconPlugin,
|
|
22
23
|
type LexiconPlugin,
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
async function loadLexiconPlugin(name: string): Promise<LexiconPlugin | null> {
|
|
37
38
|
let mod: Record<string, unknown>;
|
|
38
39
|
try {
|
|
39
|
-
mod = (await import(`@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
|
|
40
|
+
mod = (await import(lexiconModulePath(name) ?? `@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
|
|
40
41
|
} catch {
|
|
41
42
|
return null;
|
|
42
43
|
}
|
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
|
}
|
|
@@ -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
|
+
});
|