@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.
Files changed (87) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/emulator.d.ts +24 -0
  3. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +15 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/plugins.d.ts +9 -1
  10. package/dist/cli/plugins.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +6 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/components/driver-output.d.ts.map +1 -1
  14. package/dist/deep-observation.d.ts +22 -1
  15. package/dist/deep-observation.d.ts.map +1 -1
  16. package/dist/detectLexicon.d.ts +2 -1
  17. package/dist/detectLexicon.d.ts.map +1 -1
  18. package/dist/lifecycle/deep-diff.d.ts +9 -1
  19. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  20. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  21. package/dist/lifecycle/git.d.ts +12 -0
  22. package/dist/lifecycle/git.d.ts.map +1 -1
  23. package/dist/lifecycle/local-note.d.ts +14 -0
  24. package/dist/lifecycle/local-note.d.ts.map +1 -0
  25. package/dist/op/activities/converge.d.ts.map +1 -1
  26. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  27. package/dist/op/composites/watch-op.d.ts +8 -0
  28. package/dist/op/composites/watch-op.d.ts.map +1 -1
  29. package/dist/op/emulator-lifecycle.d.ts +31 -0
  30. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  31. package/dist/op/gate-summary.d.ts.map +1 -1
  32. package/dist/op/gate.d.ts +4 -1
  33. package/dist/op/gate.d.ts.map +1 -1
  34. package/dist/op/index.d.ts +3 -3
  35. package/dist/op/index.d.ts.map +1 -1
  36. package/dist/op/local-output.d.ts.map +1 -1
  37. package/dist/op/op-ir.d.ts +2 -0
  38. package/dist/op/op-ir.d.ts.map +1 -1
  39. package/dist/op/types.d.ts +10 -0
  40. package/dist/op/types.d.ts.map +1 -1
  41. package/dist/serializer.d.ts +11 -0
  42. package/dist/serializer.d.ts.map +1 -1
  43. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/commands/build.test.ts +29 -0
  46. package/src/cli/commands/build.ts +11 -2
  47. package/src/cli/handlers/emulator.test.ts +136 -4
  48. package/src/cli/handlers/emulator.ts +171 -14
  49. package/src/cli/handlers/lifecycle.ts +13 -2
  50. package/src/cli/handlers/operator.test.ts +9 -7
  51. package/src/cli/handlers/operator.ts +18 -23
  52. package/src/cli/handlers/run.test.ts +52 -0
  53. package/src/cli/handlers/run.ts +43 -3
  54. package/src/cli/lexicon-subpath-build.test.ts +129 -0
  55. package/src/cli/main.ts +24 -6
  56. package/src/cli/plugins.ts +26 -5
  57. package/src/cli/registry.ts +6 -0
  58. package/src/components/driver-output.ts +4 -1
  59. package/src/deep-observation.ts +29 -1
  60. package/src/detectLexicon.test.ts +38 -0
  61. package/src/detectLexicon.ts +7 -2
  62. package/src/lifecycle/deep-diff.test.ts +24 -1
  63. package/src/lifecycle/deep-diff.ts +15 -0
  64. package/src/lifecycle/deep-observe.ts +5 -2
  65. package/src/lifecycle/git.test.ts +18 -0
  66. package/src/lifecycle/git.ts +25 -0
  67. package/src/lifecycle/local-note.ts +17 -0
  68. package/src/op/activities/converge-push.test.ts +8 -6
  69. package/src/op/activities/converge.ts +2 -7
  70. package/src/op/activities/lifecycle.test.ts +5 -0
  71. package/src/op/activities/lifecycle.ts +3 -0
  72. package/src/op/composites/composites.test.ts +5 -0
  73. package/src/op/composites/watch-op.ts +9 -0
  74. package/src/op/emulator-lifecycle.test.ts +13 -1
  75. package/src/op/emulator-lifecycle.ts +48 -0
  76. package/src/op/gate-summary.test.ts +6 -0
  77. package/src/op/gate-summary.ts +4 -1
  78. package/src/op/gate.ts +7 -25
  79. package/src/op/index.ts +3 -3
  80. package/src/op/local-output.test.ts +12 -0
  81. package/src/op/local-output.ts +5 -1
  82. package/src/op/op-ir.test.ts +9 -0
  83. package/src/op/op-ir.ts +4 -0
  84. package/src/op/types.ts +11 -0
  85. package/src/serializer.ts +11 -0
  86. package/src/workspace/ci-green-workflow.ts +1 -1
  87. 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
- const lexiconNames = await resolveProjectLexicons(resolve(path));
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 };
@@ -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(projectPath: string): Promise<string[]> {
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
- return lexiconNames(config.lexicons);
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
- // Fallback: detect from source imports — a pure text scan, no execution.
328
- const files = await findInfraFiles(projectPath);
329
- return detectLexicons(files);
350
+ return requested && !names.includes(requested) ? [...names, requested] : names;
330
351
  }
@@ -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") +
@@ -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 { resources: value.resources ?? {}, unobserved: value.unobserved ?? {} };
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
  });
@@ -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
- const regex = /(?:import|export)\s+[\s\S]*?\s+from\s+['"]@intentius\/chant-lexicon-([a-z][\w-]*)['"]/g;
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 {
@@ -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
+ }