@intentius/chant 0.118.1 → 0.121.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/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.d.ts +24 -0
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +15 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +22 -1
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/detectLexicon.d.ts +2 -1
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +9 -1
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +12 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/local-note.d.ts +14 -0
- package/dist/lifecycle/local-note.d.ts.map +1 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/composites/watch-op.d.ts +8 -0
- package/dist/op/composites/watch-op.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.d.ts +31 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +4 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-output.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/types.d.ts +10 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +29 -0
- package/src/cli/commands/build.ts +11 -2
- package/src/cli/handlers/emulator.test.ts +136 -4
- package/src/cli/handlers/emulator.ts +171 -14
- package/src/cli/handlers/lifecycle.ts +13 -2
- package/src/cli/handlers/operator.test.ts +9 -7
- package/src/cli/handlers/operator.ts +18 -23
- package/src/cli/handlers/run.test.ts +52 -0
- package/src/cli/handlers/run.ts +43 -3
- package/src/cli/lexicon-subpath-build.test.ts +129 -0
- package/src/cli/main.ts +24 -6
- package/src/cli/plugins.ts +26 -5
- package/src/cli/registry.ts +6 -0
- package/src/components/driver-output.ts +4 -1
- package/src/deep-observation.ts +29 -1
- package/src/detectLexicon.test.ts +38 -0
- package/src/detectLexicon.ts +7 -2
- package/src/lifecycle/deep-diff.test.ts +24 -1
- package/src/lifecycle/deep-diff.ts +15 -0
- package/src/lifecycle/deep-observe.ts +5 -2
- package/src/lifecycle/git.test.ts +18 -0
- package/src/lifecycle/git.ts +25 -0
- package/src/lifecycle/local-note.ts +17 -0
- package/src/op/activities/converge-push.test.ts +8 -6
- package/src/op/activities/converge.ts +2 -7
- package/src/op/activities/lifecycle.test.ts +5 -0
- package/src/op/activities/lifecycle.ts +3 -0
- package/src/op/composites/composites.test.ts +5 -0
- package/src/op/composites/watch-op.ts +9 -0
- package/src/op/emulator-lifecycle.test.ts +13 -1
- package/src/op/emulator-lifecycle.ts +48 -0
- package/src/op/gate-summary.test.ts +6 -0
- package/src/op/gate-summary.ts +4 -1
- package/src/op/gate.ts +7 -25
- package/src/op/index.ts +3 -3
- package/src/op/local-output.test.ts +12 -0
- package/src/op/local-output.ts +5 -1
- package/src/op/op-ir.test.ts +9 -0
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +11 -0
- package/src/serializer.ts +11 -0
- package/src/workspace/ci-green-workflow.ts +1 -1
- package/src/workspace/lineage-upgrade-cli.ts +3 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #3648 — a project with no `chant.config.ts` that imports a lexicon only
|
|
3
|
+
* through a subpath (`@intentius/chant-lexicon-sql/clickhouse`, as every sql
|
|
4
|
+
* project does) failed with "No lexicon detected in infrastructure files", and
|
|
5
|
+
* `--lexicon sql` did not help. These tests build the issue's repro for both
|
|
6
|
+
* sql dialects, and check that `--lexicon` is honoured when resolving the
|
|
7
|
+
* project's lexicons.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { resolveProjectLexicons } from "./plugins";
|
|
15
|
+
import { runCommandInProcess } from "./main";
|
|
16
|
+
import { captureRun } from "../workspace/member-run";
|
|
17
|
+
|
|
18
|
+
const repoNodeModules = resolve(fileURLToPath(new URL(".", import.meta.url)), "../../../../node_modules");
|
|
19
|
+
|
|
20
|
+
const REPRO = {
|
|
21
|
+
clickhouse: {
|
|
22
|
+
source:
|
|
23
|
+
'import { table } from "@intentius/chant-lexicon-sql/clickhouse";\n' +
|
|
24
|
+
"export const events = table`CREATE TABLE events (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY (id, ts)`;\n",
|
|
25
|
+
ddl: "clickhouse.sql",
|
|
26
|
+
},
|
|
27
|
+
postgres: {
|
|
28
|
+
source:
|
|
29
|
+
'import { table } from "@intentius/chant-lexicon-sql/postgres";\n' +
|
|
30
|
+
"export const events = table`CREATE TABLE events (id bigint PRIMARY KEY, ts timestamptz NOT NULL)`;\n",
|
|
31
|
+
ddl: "postgres.sql",
|
|
32
|
+
},
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
let root: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-3648-")));
|
|
39
|
+
// A package.json stops the config walk at the project root.
|
|
40
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "repro", private: true, type: "module" }));
|
|
41
|
+
mkdirSync(join(root, "src"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
rmSync(root, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function writeSource(source: string): void {
|
|
49
|
+
writeFileSync(join(root, "src", "infra.ts"), source);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("resolveProjectLexicons with --lexicon (#3648)", () => {
|
|
53
|
+
test("a config-less project with no lexicon import resolves to the named lexicon", async () => {
|
|
54
|
+
writeSource("export const nothing = 1;\n");
|
|
55
|
+
|
|
56
|
+
await expect(resolveProjectLexicons(join(root, "src"))).rejects.toThrow("No lexicon detected");
|
|
57
|
+
expect(await resolveProjectLexicons(join(root, "src"), { lexicon: "sql" })).toEqual(["sql"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the named lexicon joins the detected ones", async () => {
|
|
61
|
+
writeSource('import { Deployment } from "@intentius/chant-lexicon-k8s";\nexport const x = 1;\n');
|
|
62
|
+
|
|
63
|
+
expect(await resolveProjectLexicons(join(root, "src"), { lexicon: "sql" })).toEqual(["k8s", "sql"]);
|
|
64
|
+
expect(await resolveProjectLexicons(join(root, "src"), { lexicon: "k8s" })).toEqual(["k8s"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the named lexicon joins the config's lexicons", async () => {
|
|
68
|
+
writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
69
|
+
writeSource("export const nothing = 1;\n");
|
|
70
|
+
|
|
71
|
+
expect(await resolveProjectLexicons(join(root, "src"), { lexicon: "sql" })).toEqual(["aws", "sql"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a subpath import resolves with no config and no flag", async () => {
|
|
75
|
+
writeSource(REPRO.postgres.source);
|
|
76
|
+
|
|
77
|
+
expect(await resolveProjectLexicons(join(root, "src"))).toEqual(["sql"]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("chant build on the #3648 repro", () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
// The repro imports the workspace's sql lexicon, as an installed project would.
|
|
84
|
+
symlinkSync(repoNodeModules, join(root, "node_modules"), "dir");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for (const [dialect, { source, ddl }] of Object.entries(REPRO)) {
|
|
88
|
+
for (const flags of [[], ["--lexicon", "sql"]]) {
|
|
89
|
+
test(`${dialect}, ${flags.length ? "--lexicon sql" : "no flag"}: builds with no chant.config`, async () => {
|
|
90
|
+
writeSource(source);
|
|
91
|
+
const out = join(root, "dist", "schema.json");
|
|
92
|
+
|
|
93
|
+
const run = await captureRun(() => runCommandInProcess(["build", join(root, "src"), ...flags, "-o", out]));
|
|
94
|
+
|
|
95
|
+
expect(run.stderr).not.toContain("No lexicon detected");
|
|
96
|
+
expect(run.exitCode).toBe(0);
|
|
97
|
+
expect(existsSync(out)).toBe(true);
|
|
98
|
+
expect(readFileSync(join(root, "dist", ddl), "utf-8")).toContain("CREATE TABLE");
|
|
99
|
+
}, 60_000);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// chant #3738 — the DDL is verbatim but not a secret, so a build with no
|
|
103
|
+
// --output prints it rather than refusing as if it were ciphertext.
|
|
104
|
+
test(`${dialect}: builds to stdout with no --output (#3738)`, async () => {
|
|
105
|
+
writeSource(source);
|
|
106
|
+
// The build prints through console, which vitest intercepts before it
|
|
107
|
+
// reaches the stream captureRun replaces.
|
|
108
|
+
const out: string[] = [];
|
|
109
|
+
const err: string[] = [];
|
|
110
|
+
const log = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { out.push(a.join(" ")); });
|
|
111
|
+
const error = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { err.push(a.join(" ")); });
|
|
112
|
+
let run: Awaited<ReturnType<typeof captureRun>>;
|
|
113
|
+
try {
|
|
114
|
+
run = await captureRun(() => runCommandInProcess(["build", join(root, "src")]));
|
|
115
|
+
} finally {
|
|
116
|
+
log.mockRestore();
|
|
117
|
+
error.mockRestore();
|
|
118
|
+
}
|
|
119
|
+
const stderr = run.stderr + err.join("\n");
|
|
120
|
+
|
|
121
|
+
expect(stderr).not.toContain("committed-encrypted");
|
|
122
|
+
expect(run.exitCode).toBe(0);
|
|
123
|
+
expect(JSON.parse(out.join("\n"))).toMatchObject({ dialect });
|
|
124
|
+
expect(stderr).toContain(`--- ${ddl} ---`);
|
|
125
|
+
expect(stderr).toContain("CREATE TABLE events");
|
|
126
|
+
expect(existsSync(join(root, ddl))).toBe(false);
|
|
127
|
+
}, 60_000);
|
|
128
|
+
}
|
|
129
|
+
});
|
package/src/cli/main.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { runSearch } from "./handlers/search";
|
|
|
38
38
|
import { runOp, runOpList, runOpStatus, runOpApprove, runOpSignalRenamed, runOpCancel, runOpLog } from "./handlers/run";
|
|
39
39
|
import { runOpWaveCommand } from "./handlers/run-wave";
|
|
40
40
|
import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
|
|
41
|
-
import { runEmulator } from "./handlers/emulator";
|
|
41
|
+
import { runEmulator, formatEmulatorHelp } from "./handlers/emulator";
|
|
42
42
|
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
43
43
|
import type { LexiconPlugin } from "../lexicon";
|
|
44
44
|
|
|
@@ -290,6 +290,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
290
290
|
result.src = args[++i];
|
|
291
291
|
} else if (arg === "--env") {
|
|
292
292
|
result.env = args[++i];
|
|
293
|
+
} else if (arg === "--port") {
|
|
294
|
+
const value = args[++i];
|
|
295
|
+
if (!value || value.startsWith("-")) throw new Error("--port needs a port: --port <container-port>=<host-port>");
|
|
296
|
+
(result.port ??= []).push(value);
|
|
293
297
|
} else if (arg === "--promote-to") {
|
|
294
298
|
result.promoteTo = args[++i];
|
|
295
299
|
if (!result.promoteTo || result.promoteTo.startsWith("-")) throw new Error("--promote-to needs an environment: --promote-to <env>");
|
|
@@ -920,6 +924,8 @@ Ops:
|
|
|
920
924
|
run <name> Run an Op on the resolved runtime (--on; local by default)
|
|
921
925
|
[--work <id>] [--holder <name>]: the work item an Op
|
|
922
926
|
with a work lease runs under, and who holds it
|
|
927
|
+
--fail-on-drift: exit 2 when the run completes and
|
|
928
|
+
its Drift outcome is true (WatchOp failOn: "drift")
|
|
923
929
|
run list List all Ops with the runtime's state for each
|
|
924
930
|
run status <name> Show the runtime's state for one Op's latest run
|
|
925
931
|
run approve <op> <gate> Record a gate's resolution and wake the runtime
|
|
@@ -1458,7 +1464,8 @@ Lexicon development:
|
|
|
1458
1464
|
|
|
1459
1465
|
Local:
|
|
1460
1466
|
emulator <up|down|status> Boot/stop/inspect configured lexicons' local
|
|
1461
|
-
emulators (Floci etc.); --lexicon <name>, --json
|
|
1467
|
+
emulators (Floci etc.); --lexicon <name>, --json,
|
|
1468
|
+
--port; \`chant emulator --help\` lists ports and logins
|
|
1462
1469
|
|
|
1463
1470
|
Servers:
|
|
1464
1471
|
serve lsp Start the LSP server (stdio)
|
|
@@ -1669,10 +1676,12 @@ async function tryPluginCommand(rawArgv: string[]): Promise<number | undefined>
|
|
|
1669
1676
|
/**
|
|
1670
1677
|
* Load lexicon plugins for the given project path, or exit with an error.
|
|
1671
1678
|
*/
|
|
1672
|
-
async function loadPluginsOrExit(path: string): Promise<import("../lexicon").LexiconPlugin[]> {
|
|
1679
|
+
async function loadPluginsOrExit(path: string, lexicon?: string): Promise<import("../lexicon").LexiconPlugin[]> {
|
|
1673
1680
|
let plugins;
|
|
1674
1681
|
try {
|
|
1675
|
-
|
|
1682
|
+
// chant #3648 — `--lexicon <name>` loads that lexicon even when the
|
|
1683
|
+
// project's config and imports name none.
|
|
1684
|
+
const lexiconNames = await resolveProjectLexicons(resolve(path), { lexicon });
|
|
1676
1685
|
plugins = await loadPlugins(lexiconNames);
|
|
1677
1686
|
} catch (error) {
|
|
1678
1687
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -1771,7 +1780,7 @@ export async function runCommandInProcess(argv: string[]): Promise<number> {
|
|
|
1771
1780
|
console.error(formatError({ message: envErr, hint: "Declare it in chant.config `environments`, or omit --env." }));
|
|
1772
1781
|
return 1;
|
|
1773
1782
|
}
|
|
1774
|
-
const plugins = match.def.requiresPlugins ? await loadPluginsOrExit(match.compound ? "." : args.path) : [];
|
|
1783
|
+
const plugins = match.def.requiresPlugins ? await loadPluginsOrExit(match.compound ? "." : args.path, args.lexicon) : [];
|
|
1775
1784
|
return match.def.handler({ args, plugins, serializers: plugins.map((p) => p.serializer) });
|
|
1776
1785
|
}
|
|
1777
1786
|
|
|
@@ -1993,6 +2002,15 @@ async function main(): Promise<void> {
|
|
|
1993
2002
|
return;
|
|
1994
2003
|
}
|
|
1995
2004
|
|
|
2005
|
+
// #3673 — `chant emulator --help` is the emulator's own usage, with the
|
|
2006
|
+
// ports and sign-in each configured lexicon's emulator uses, not the
|
|
2007
|
+
// global command list it used to print.
|
|
2008
|
+
if (args.help && args.command === "emulator") {
|
|
2009
|
+
console.log(formatEmulatorHelp(await loadPluginsBestEffort().catch(() => [])));
|
|
2010
|
+
await flushAndExit(0);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1996
2014
|
if (args.help || !args.command) {
|
|
1997
2015
|
const groups = await loadPluginsBestEffort().then(collectCommandGroups).catch(() => []);
|
|
1998
2016
|
printHelp(groups);
|
|
@@ -2122,7 +2140,7 @@ async function main(): Promise<void> {
|
|
|
2122
2140
|
? await loadPlugins(await resolveProjectLexicons(resolve(projectPath)).catch(() => [])).catch(() => [])
|
|
2123
2141
|
: match.def.name === "serve mcp" && (await isLexiconlessWorkspaceRoot(projectPath))
|
|
2124
2142
|
? [] // #2700 — runServeMcp loads the chant members' lexicons itself.
|
|
2125
|
-
: await loadPluginsOrExit(projectPath)
|
|
2143
|
+
: await loadPluginsOrExit(projectPath, args.lexicon)
|
|
2126
2144
|
: [];
|
|
2127
2145
|
const serializers = plugins.map((p) => p.serializer);
|
|
2128
2146
|
const ctx = { args, plugins, serializers };
|
package/src/cli/plugins.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync, type Dirent } from "node:fs";
|
|
|
8
8
|
import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
|
|
9
9
|
import { loadChantConfigUpward } from "../config";
|
|
10
10
|
import { findInfraFiles, detectLexicons } from "../index";
|
|
11
|
+
import { isNoLexiconDetected } from "../detectLexicon";
|
|
11
12
|
import { checkConflicts, describeConflict } from "./conflict-check";
|
|
12
13
|
import { prepareProjectCodegen } from "../project-codegen";
|
|
13
14
|
|
|
@@ -317,14 +318,34 @@ export async function loadPlugins(lexiconNames: string[]): Promise<LexiconPlugin
|
|
|
317
318
|
* loaded but never imported by name in that particular stack's files) would
|
|
318
319
|
* otherwise silently miss it.
|
|
319
320
|
*/
|
|
320
|
-
export async function resolveProjectLexicons(
|
|
321
|
+
export async function resolveProjectLexicons(
|
|
322
|
+
projectPath: string,
|
|
323
|
+
options: {
|
|
324
|
+
/**
|
|
325
|
+
* chant #3648: the lexicon the command line named (`--lexicon <name>`).
|
|
326
|
+
* It is always in the result: a project whose config and source name no
|
|
327
|
+
* lexicon resolves to it alone, instead of failing with "No lexicon
|
|
328
|
+
* detected", and one that names other lexicons gets it added to them.
|
|
329
|
+
*/
|
|
330
|
+
lexicon?: string;
|
|
331
|
+
} = {},
|
|
332
|
+
): Promise<string[]> {
|
|
333
|
+
const requested = options.lexicon;
|
|
321
334
|
const { config } = await loadChantConfigUpward(projectPath);
|
|
322
335
|
|
|
336
|
+
let names: string[];
|
|
323
337
|
if (config.lexicons && config.lexicons.length > 0) {
|
|
324
|
-
|
|
338
|
+
names = lexiconNames(config.lexicons);
|
|
339
|
+
} else {
|
|
340
|
+
// Fallback: detect from source imports — a pure text scan, no execution.
|
|
341
|
+
const files = await findInfraFiles(projectPath);
|
|
342
|
+
try {
|
|
343
|
+
names = await detectLexicons(files);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (requested && isNoLexiconDetected(error)) return [requested];
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
325
348
|
}
|
|
326
349
|
|
|
327
|
-
|
|
328
|
-
const files = await findInfraFiles(projectPath);
|
|
329
|
-
return detectLexicons(files);
|
|
350
|
+
return requested && !names.includes(requested) ? [...names, requested] : names;
|
|
330
351
|
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -163,6 +163,8 @@ export interface ParsedArgs {
|
|
|
163
163
|
/** `--env <name>` — active environment: sets CHANT_ENV so env-aware source
|
|
164
164
|
* re-evaluates for that environment (`build` + `graph`), and drives policy. */
|
|
165
165
|
env?: string;
|
|
166
|
+
/** `chant emulator up --port <container-port>=<host-port>`, repeatable (#3673); a bare `<host-port>` when one emulator is selected. */
|
|
167
|
+
port?: string[];
|
|
166
168
|
/** `chant build --components --generate <lexicon> --promote-to <env>` (#2575) — add a promote job to the generated pipeline. */
|
|
167
169
|
promoteTo?: string;
|
|
168
170
|
/** `chant graph --stacks` — render the cross-stack apply-ordering graph */
|
|
@@ -504,6 +506,10 @@ export interface ParsedArgs {
|
|
|
504
506
|
* `chant search "<q>" --check-live|--check-snapshot --fail-on-drift`
|
|
505
507
|
* (#1268) — exit non-zero when the scoped check finds drift, so it is usable
|
|
506
508
|
* as a CI gate. Meaningless without one of the two flags above.
|
|
509
|
+
*
|
|
510
|
+
* `chant run <op> --fail-on-drift` (#3675) — exit 2 instead of 0 when the
|
|
511
|
+
* run completes and its `Drift` outcome is true, the same as the Op
|
|
512
|
+
* declaring `failOn: "drift"`. A failed run still returns 1, a gated one 3.
|
|
507
513
|
*/
|
|
508
514
|
failOnDrift?: boolean;
|
|
509
515
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { DriverRunResult, DriverStepRecord } from "./driver";
|
|
10
|
+
import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
|
|
10
11
|
|
|
11
12
|
type Writer = (line: string) => void;
|
|
12
13
|
|
|
@@ -75,7 +76,9 @@ export function renderDriverHuman(result: DriverRunResult, write: Writer = stder
|
|
|
75
76
|
// #2310: this run's own append reached only the local chant/lifecycle
|
|
76
77
|
// branch. The gate still stands correctly — but an operator elsewhere
|
|
77
78
|
// cannot see the pending fact to approve it.
|
|
78
|
-
if (result.gatePushed === false) {
|
|
79
|
+
if (result.gatePushed === false && isLifecycleLocalNote(result.gatePushWarning)) {
|
|
80
|
+
write(` note : ${LIFECYCLE_LOCAL_NOTE}`);
|
|
81
|
+
} else if (result.gatePushed === false) {
|
|
79
82
|
write(
|
|
80
83
|
` warning : the pending fact was not pushed to the remote — ` +
|
|
81
84
|
(result.gatePushWarning ?? "it exists only in this checkout") +
|
package/src/deep-observation.ts
CHANGED
|
@@ -95,6 +95,24 @@ export interface DeepResourceObservation {
|
|
|
95
95
|
claimedFields?: readonly string[];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A change an apply would make that no one declared entity's properties show
|
|
100
|
+
* (#3706): Postgres privileges, which the declarations decide as the access
|
|
101
|
+
* they add up to rather than one grant at a time, so a privilege granted by
|
|
102
|
+
* hand is a change to make with no declared property it differs on.
|
|
103
|
+
*
|
|
104
|
+
* `lifecycle diff --live` prints these as drift, so the plan digest an
|
|
105
|
+
* approval binds to moves with them.
|
|
106
|
+
*/
|
|
107
|
+
export interface DeepPendingChange {
|
|
108
|
+
/** What the change is on, in the lexicon's words: `relation app.orders TO reader`. */
|
|
109
|
+
subject: string;
|
|
110
|
+
/** What an apply would do about it: the statements, or a sentence. */
|
|
111
|
+
change: string;
|
|
112
|
+
/** The declared entities behind it, where the reader can name them. */
|
|
113
|
+
entities?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
/**
|
|
99
117
|
* The deep observation envelope. Explicitly versioned and discriminated by
|
|
100
118
|
* `deepObservation: "v1"`, for the same reason {@link
|
|
@@ -119,12 +137,16 @@ export interface DeepObservationResult {
|
|
|
119
137
|
* surface, not a claim about existence.
|
|
120
138
|
*/
|
|
121
139
|
unobserved?: Record<string, UnobservedEntity>;
|
|
140
|
+
/** Changes an apply would make beyond the declared properties (#3706). Omitted when there are none. */
|
|
141
|
+
pending?: DeepPendingChange[];
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
/** Normalized form every consumer works with. Both maps always present. */
|
|
125
145
|
export interface NormalizedDeepObservation {
|
|
126
146
|
resources: Record<string, DeepResourceObservation>;
|
|
127
147
|
unobserved: Record<string, UnobservedEntity>;
|
|
148
|
+
/** As {@link DeepObservationResult.pending}; absent when there are none. */
|
|
149
|
+
pending?: DeepPendingChange[];
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/** True when `value` is the versioned {@link DeepObservationResult} envelope. */
|
|
@@ -140,11 +162,13 @@ export function isDeepObservationResult(value: unknown): value is DeepObservatio
|
|
|
140
162
|
export function deepObservation(
|
|
141
163
|
resources: Record<string, DeepResourceObservation>,
|
|
142
164
|
unobserved?: Record<string, UnobservedEntity>,
|
|
165
|
+
pending?: DeepPendingChange[],
|
|
143
166
|
): DeepObservationResult {
|
|
144
167
|
return {
|
|
145
168
|
deepObservation: "v1",
|
|
146
169
|
resources,
|
|
147
170
|
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
171
|
+
...(pending && pending.length > 0 ? { pending } : {}),
|
|
148
172
|
};
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -158,7 +182,11 @@ export function normalizeDeepObservation(
|
|
|
158
182
|
value: DeepObservationResult | undefined,
|
|
159
183
|
): NormalizedDeepObservation {
|
|
160
184
|
if (!value) return { resources: {}, unobserved: {} };
|
|
161
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
resources: value.resources ?? {},
|
|
187
|
+
unobserved: value.unobserved ?? {},
|
|
188
|
+
...(value.pending && value.pending.length > 0 ? { pending: value.pending } : {}),
|
|
189
|
+
};
|
|
162
190
|
}
|
|
163
191
|
|
|
164
192
|
// ── Normalization pass ──────────────────────────────────────────────────────
|
|
@@ -257,4 +257,42 @@ describe("detectLexicons", () => {
|
|
|
257
257
|
const result = await detectLexicons([file]);
|
|
258
258
|
expect(result).toEqual(["testdom"]);
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
// chant #3648: a project that imports a lexicon only through a subpath,
|
|
262
|
+
// as every sql project does, was detected as having no lexicon at all.
|
|
263
|
+
test.each([
|
|
264
|
+
{
|
|
265
|
+
dialect: "clickhouse",
|
|
266
|
+
code: 'import { table } from "@intentius/chant-lexicon-sql/clickhouse";\nexport const events = table`CREATE TABLE events (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY (id, ts)`;',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
dialect: "postgres",
|
|
270
|
+
code: 'import { table } from "@intentius/chant-lexicon-sql/postgres";\nexport const events = table`CREATE TABLE events (id bigint PRIMARY KEY, ts timestamptz NOT NULL)`;',
|
|
271
|
+
},
|
|
272
|
+
])("detects sql from a $dialect subpath import (#3648)", async ({ code }) => {
|
|
273
|
+
const file = join(testDir, "infra.ts");
|
|
274
|
+
await writeFile(file, code);
|
|
275
|
+
|
|
276
|
+
expect(await detectLexicons([file])).toEqual(["sql"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("detects a lexicon from a nested subpath and a subpath re-export (#3648)", async () => {
|
|
280
|
+
const file = join(testDir, "infra.ts");
|
|
281
|
+
await writeFile(
|
|
282
|
+
file,
|
|
283
|
+
'import { rule } from "@intentius/chant-lexicon-testdom/lint/post-synth";\nexport * from \'@intentius/chant-lexicon-k8s/composites\';',
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const result = await detectLexicons([file]);
|
|
287
|
+
expect(result.sort()).toEqual(["k8s", "testdom"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("subpath imports of both sql dialects detect sql once (#3648)", async () => {
|
|
291
|
+
const ch = join(testDir, "ch.ts");
|
|
292
|
+
const pg = join(testDir, "pg.ts");
|
|
293
|
+
await writeFile(ch, 'import { table } from "@intentius/chant-lexicon-sql/clickhouse";');
|
|
294
|
+
await writeFile(pg, 'import { table } from "@intentius/chant-lexicon-sql/postgres";');
|
|
295
|
+
|
|
296
|
+
expect(await detectLexicons([ch, pg])).toEqual(["sql"]);
|
|
297
|
+
});
|
|
260
298
|
});
|
package/src/detectLexicon.ts
CHANGED
|
@@ -19,7 +19,8 @@ export function isNoLexiconDetected(error: unknown): boolean {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Detects which lexicons are being used by analyzing import statements
|
|
22
|
-
* in the provided infrastructure files. Matches any `@intentius/chant-lexicon-*` package
|
|
22
|
+
* in the provided infrastructure files. Matches any `@intentius/chant-lexicon-*` package,
|
|
23
|
+
* imported by its root or by a subpath (`@intentius/chant-lexicon-sql/postgres`).
|
|
23
24
|
*
|
|
24
25
|
* @param files - Array of file paths to analyze
|
|
25
26
|
* @returns Array of detected lexicon names
|
|
@@ -40,7 +41,11 @@ export async function detectLexicons(files: string[]): Promise<string[]> {
|
|
|
40
41
|
// Match @intentius/chant-lexicon-<name> in import/export statements.
|
|
41
42
|
// Uses [\s\S] instead of . to handle multiline imports like:
|
|
42
43
|
// import { Foo, Bar } from "@intentius/chant-lexicon-k8s";
|
|
43
|
-
|
|
44
|
+
// chant #3648: a subpath import names the same lexicon, so
|
|
45
|
+
// "@intentius/chant-lexicon-sql/clickhouse" detects "sql". A project that
|
|
46
|
+
// imports a lexicon only through its subpaths (every sql project) has no
|
|
47
|
+
// other import for this scan to find.
|
|
48
|
+
const regex = /(?:import|export)\s+[\s\S]*?\s+from\s+['"]@intentius\/chant-lexicon-([a-z][\w-]*)(?:\/[^'"\s]*)?['"]/g;
|
|
44
49
|
|
|
45
50
|
for (const match of content.matchAll(regex)) {
|
|
46
51
|
detectedLexicons.add(match[1]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { countHeld, countUnclaimed, countPropertyDrift, diffDeep, suspiciousHeld, type DeclaredDeepEntity } from "./deep-diff";
|
|
2
|
+
import { countHeld, countPending, countUnclaimed, countPropertyDrift, diffDeep, suspiciousHeld, type DeclaredDeepEntity } from "./deep-diff";
|
|
3
3
|
import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation";
|
|
4
4
|
import { HELD_ELSEWHERE_TAG } from "../held-elsewhere";
|
|
5
5
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
@@ -551,4 +551,27 @@ describe("diffDeep — heldElsewhere() (#2162)", () => {
|
|
|
551
551
|
});
|
|
552
552
|
expect(suspiciousHeld(result)).toEqual([]);
|
|
553
553
|
});
|
|
554
|
+
|
|
555
|
+
test("pending changes the reader reports ride through, sorted, and count apart from property drift (#3706)", () => {
|
|
556
|
+
const result = diffDeep({
|
|
557
|
+
declared: { t: { type: "T", properties: { A: 1 } } },
|
|
558
|
+
live: {
|
|
559
|
+
...live({ t: { type: "T", properties: { A: 1 } } }),
|
|
560
|
+
pending: [
|
|
561
|
+
{ subject: "relation b TO r", change: "GRANT SELECT ON TABLE b TO r", entities: ["readB"] },
|
|
562
|
+
{ subject: "relation a TO w", change: "REVOKE INSERT ON TABLE a FROM w" },
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
expect(result.pending?.map((p) => p.subject)).toEqual(["relation a TO w", "relation b TO r"]);
|
|
567
|
+
expect(countPending(result)).toBe(2);
|
|
568
|
+
expect(countPropertyDrift(result)).toBe(0);
|
|
569
|
+
expect(result.unchanged).toEqual(["t"]);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("no pending changes leave the field out", () => {
|
|
573
|
+
const result = diffDeep({ declared: {}, live: live({}) });
|
|
574
|
+
expect(result.pending).toBeUndefined();
|
|
575
|
+
expect(countPending(result)).toBe(0);
|
|
576
|
+
});
|
|
554
577
|
});
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
deepValueEqual,
|
|
39
39
|
flattenDeepProperties,
|
|
40
40
|
type DeepNormalizationHooks,
|
|
41
|
+
type DeepPendingChange,
|
|
41
42
|
type NormalizedDeepObservation,
|
|
42
43
|
} from "../deep-observation";
|
|
43
44
|
import { claimedFieldsFromPaths, heldBy, isClaimed, type FieldClaimSource } from "../claimed-fields";
|
|
@@ -229,6 +230,12 @@ export interface DeepDiffResult {
|
|
|
229
230
|
unobserved: UnobservedResource[];
|
|
230
231
|
/** Entities the deep reader returned that were never declared. Sorted. */
|
|
231
232
|
undeclaredEntities: string[];
|
|
233
|
+
/**
|
|
234
|
+
* Changes an apply would make that no declared property shows (#3706), as
|
|
235
|
+
* the reader reported them: privileges granted or revoked by hand, say.
|
|
236
|
+
* Drift, counted by {@link countPending}. Absent when there are none.
|
|
237
|
+
*/
|
|
238
|
+
pending?: DeepPendingChange[];
|
|
232
239
|
}
|
|
233
240
|
|
|
234
241
|
/** A declared entity's property tree, already normalized with the lexicon's hooks. */
|
|
@@ -421,6 +428,9 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
|
421
428
|
unchanged: unchanged.sort(),
|
|
422
429
|
unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
|
|
423
430
|
undeclaredEntities: undeclaredEntities.sort(),
|
|
431
|
+
...(input.live.pending && input.live.pending.length > 0
|
|
432
|
+
? { pending: [...input.live.pending].sort((a, b) => a.subject.localeCompare(b.subject) || a.change.localeCompare(b.change)) }
|
|
433
|
+
: {}),
|
|
424
434
|
};
|
|
425
435
|
}
|
|
426
436
|
|
|
@@ -429,6 +439,11 @@ export function countPropertyDrift(result: DeepDiffResult): number {
|
|
|
429
439
|
return result.drifted.reduce((n, e) => n + e.changes.length, 0);
|
|
430
440
|
}
|
|
431
441
|
|
|
442
|
+
/** The pending changes (#3706): drift, though on no one entity's properties. */
|
|
443
|
+
export function countPending(result: DeepDiffResult): number {
|
|
444
|
+
return result.pending?.length ?? 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
432
447
|
/** Total live values on paths no declaration claimed, across every entity (#2160). Never added to the drift count. */
|
|
433
448
|
export function countUnclaimed(result: DeepDiffResult): number {
|
|
434
449
|
return result.unclaimed.reduce((n, e) => n + e.fields.length, 0);
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
normalizeDeepObservation,
|
|
24
24
|
normalizeDeepProperties,
|
|
25
25
|
type DeepNormalizationHooks,
|
|
26
|
+
type DeepPendingChange,
|
|
26
27
|
type DeepResourceObservation,
|
|
27
28
|
type NormalizedDeepObservation,
|
|
28
29
|
} from "../deep-observation";
|
|
@@ -68,12 +69,14 @@ export function mergeDeepObservations(
|
|
|
68
69
|
): NormalizedDeepObservation {
|
|
69
70
|
const resources: Record<string, DeepResourceObservation> = {};
|
|
70
71
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
72
|
+
const pending: DeepPendingChange[] = [];
|
|
71
73
|
for (const part of parts) {
|
|
72
74
|
Object.assign(resources, part.resources);
|
|
73
75
|
Object.assign(unobserved, part.unobserved);
|
|
76
|
+
pending.push(...(part.pending ?? []));
|
|
74
77
|
}
|
|
75
78
|
for (const name of Object.keys(resources)) delete unobserved[name];
|
|
76
|
-
return { resources, unobserved };
|
|
79
|
+
return { resources, unobserved, ...(pending.length > 0 ? { pending } : {}) };
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
/**
|
|
@@ -189,7 +192,7 @@ export function diffDeepObservation(
|
|
|
189
192
|
|
|
190
193
|
return diffDeep({
|
|
191
194
|
declared,
|
|
192
|
-
live: { resources: normalizedLive, unobserved: live.unobserved },
|
|
195
|
+
live: { resources: normalizedLive, unobserved: live.unobserved, ...(live.pending ? { pending: live.pending } : {}) },
|
|
193
196
|
baseline,
|
|
194
197
|
hooks,
|
|
195
198
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
listSnapshots,
|
|
13
13
|
getHeadCommit,
|
|
14
14
|
pushLifecycle,
|
|
15
|
+
pushLifecycleReport,
|
|
15
16
|
StaleLifecycleBranchError,
|
|
16
17
|
appendReleaseRecordLine,
|
|
17
18
|
readReleaseLedgerLines,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
RefCASConflictError,
|
|
31
32
|
StaleLockError,
|
|
32
33
|
} from "./git";
|
|
34
|
+
import { LIFECYCLE_LOCAL_NOTE } from "./local-note";
|
|
33
35
|
|
|
34
36
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
35
37
|
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
@@ -417,6 +419,22 @@ describe("lifecycle/git", () => {
|
|
|
417
419
|
}
|
|
418
420
|
});
|
|
419
421
|
|
|
422
|
+
// #3677: no remote is a note, not a failure; a push that lands has no warning.
|
|
423
|
+
test("pushLifecycleReport: no remote reads as the local note, a landed push as pushed", async () => {
|
|
424
|
+
await withTestDir(async (dir) => {
|
|
425
|
+
await initRepo(dir);
|
|
426
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
|
|
427
|
+
expect(await pushLifecycleReport({ cwd: dir })).toEqual({ pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE });
|
|
428
|
+
});
|
|
429
|
+
const { clonePath, cleanup } = await setupClonePair();
|
|
430
|
+
try {
|
|
431
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
432
|
+
expect(await pushLifecycleReport({ cwd: clonePath })).toEqual({ pushed: true });
|
|
433
|
+
} finally {
|
|
434
|
+
await cleanup();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
420
438
|
test("subsequent push from same clone (after fetch) succeeds via lease", async () => {
|
|
421
439
|
const { clonePath, cleanup } = await setupClonePair();
|
|
422
440
|
try {
|
package/src/lifecycle/git.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { getRuntime } from "../runtime-adapter";
|
|
8
8
|
import { resolveMemberLedger } from "./member-ledger";
|
|
9
|
+
import { LIFECYCLE_LOCAL_NOTE } from "./local-note";
|
|
9
10
|
|
|
10
11
|
const STATE_BRANCH = "chant/lifecycle";
|
|
11
12
|
|
|
@@ -776,6 +777,30 @@ export function describeLifecyclePush(result: LifecyclePushStatus): string | nul
|
|
|
776
777
|
}
|
|
777
778
|
}
|
|
778
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Push the ledger branch and say, in one line, why it did not land (#2310,
|
|
782
|
+
* #3677). No remote reads as {@link LIFECYCLE_LOCAL_NOTE}; a push that git
|
|
783
|
+
* refused or that failed keeps its own reason, so a renderer can warn about
|
|
784
|
+
* that case alone. Never throws.
|
|
785
|
+
*/
|
|
786
|
+
export async function pushLifecycleReport(opts?: { cwd?: string }): Promise<{ pushed: boolean; pushWarning?: string }> {
|
|
787
|
+
try {
|
|
788
|
+
const result = await pushLifecycleStatus(opts);
|
|
789
|
+
switch (result.status) {
|
|
790
|
+
case "pushed":
|
|
791
|
+
return { pushed: true };
|
|
792
|
+
case "no-remote":
|
|
793
|
+
return { pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE };
|
|
794
|
+
case "stale":
|
|
795
|
+
return { pushed: false, pushWarning: new StaleLifecycleBranchError(result.expected, result.stderr).message };
|
|
796
|
+
case "failed":
|
|
797
|
+
return { pushed: false, pushWarning: describeLifecyclePush(result) ?? "the push did not land" };
|
|
798
|
+
}
|
|
799
|
+
} catch (err) {
|
|
800
|
+
return { pushed: false, pushWarning: err instanceof Error ? err.message : String(err) };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
779
804
|
/**
|
|
780
805
|
* Fetch the state branch from remote.
|
|
781
806
|
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a ledger write shows when the checkout has no remote (#3677), kept
|
|
3
|
+
* apart from `./git.ts` so the run renderers can name it without loading git.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The line a write shows when this checkout has no remote for the ledger.
|
|
8
|
+
* Nothing went wrong: the record is in the local branch, and with no remote
|
|
9
|
+
* the person who will approve or read it is the one at this machine. A
|
|
10
|
+
* renderer prints it as a note, never as a warning ({@link isLifecycleLocalNote}).
|
|
11
|
+
*/
|
|
12
|
+
export const LIFECYCLE_LOCAL_NOTE = "recorded locally (no remote for chant/lifecycle)";
|
|
13
|
+
|
|
14
|
+
/** Whether a push warning is {@link LIFECYCLE_LOCAL_NOTE}: no remote, rather than a push that failed. */
|
|
15
|
+
export function isLifecycleLocalNote(warning: string | undefined): boolean {
|
|
16
|
+
return warning === LIFECYCLE_LOCAL_NOTE;
|
|
17
|
+
}
|