@intentius/chant 0.39.0 → 0.41.1

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 (39) hide show
  1. package/dist/apply.d.ts +171 -0
  2. package/dist/apply.d.ts.map +1 -0
  3. package/dist/cli/build-params-cli.d.ts +24 -0
  4. package/dist/cli/build-params-cli.d.ts.map +1 -1
  5. package/dist/cli/commands/doctor.d.ts.map +1 -1
  6. package/dist/cli/handlers/components.d.ts.map +1 -1
  7. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  8. package/dist/codegen/naming.d.ts +48 -1
  9. package/dist/codegen/naming.d.ts.map +1 -1
  10. package/dist/codegen/validate.d.ts +31 -0
  11. package/dist/codegen/validate.d.ts.map +1 -1
  12. package/dist/components/deploy-units.d.ts +49 -0
  13. package/dist/components/deploy-units.d.ts.map +1 -0
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/discovery/index.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/package.json +1 -1
  20. package/src/apply.test.ts +169 -0
  21. package/src/apply.ts +249 -0
  22. package/src/cli/build-params-cli.ts +35 -0
  23. package/src/cli/commands/doctor.test.ts +45 -0
  24. package/src/cli/commands/doctor.ts +40 -0
  25. package/src/cli/handlers/components.ts +29 -23
  26. package/src/cli/handlers/graph.test.ts +51 -1
  27. package/src/cli/handlers/graph.ts +16 -1
  28. package/src/cli/handlers/lifecycle.ts +18 -3
  29. package/src/codegen/naming.test.ts +129 -0
  30. package/src/codegen/naming.ts +72 -1
  31. package/src/codegen/validate.test.ts +86 -0
  32. package/src/codegen/validate.ts +74 -0
  33. package/src/components/deploy-units.test.ts +42 -0
  34. package/src/components/deploy-units.ts +82 -0
  35. package/src/config.test.ts +53 -0
  36. package/src/config.ts +41 -3
  37. package/src/discovery/index.ts +59 -0
  38. package/src/discovery/params-cjs-warning.test.ts +75 -0
  39. package/src/index.ts +1 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Deploy units — which live unit(s) a component's composition targets (#1495).
3
+ *
4
+ * `chant components status --live` (and `chant graph --live`) need to know
5
+ * what a component deployed in order to observe it. That answer used to be a
6
+ * string literal in the walk — `kind === "cfn-deploy"` — which made the whole
7
+ * chain CloudFormation-shaped: a component whose deploy phase is a server-side
8
+ * apply or a Helm upgrade contributed nothing and was skipped, not
9
+ * "unobserved", simply absent from the result.
10
+ *
11
+ * This module is the seam that fixes the walk (piece 1 of #1495's
12
+ * decomposition): a registry mapping a deploy-family step `kind` to the field
13
+ * naming its unit and the lexicon whose `describeStackStatus` can observe it.
14
+ * The registry is data, not a rule about what steps look like — a kind not
15
+ * listed here contributes no unit, exactly as before.
16
+ */
17
+ import type { Phase } from "./component";
18
+
19
+ /** A deploy-family step kind that names a live unit, and who observes it. */
20
+ export interface DeployUnitRule {
21
+ /** The step `kind` (the capability's registered name). */
22
+ kind: string;
23
+ /** The step field carrying the unit's name (`stack`, `release`). */
24
+ field: string;
25
+ /** The lexicon whose `describeStackStatus` reads this kind of unit. */
26
+ lexicon: string;
27
+ }
28
+
29
+ /**
30
+ * The deploy-family kinds that name a unit. cfn-deploy's unit is its stack;
31
+ * kubectl-apply's is the ownership stack its labels carry (#1495 piece 2);
32
+ * helm-upgrade's is its release (#1495 piece 4). Listing a kind here is safe
33
+ * before its lexicon implements `describeStackStatus` — units whose lexicon
34
+ * has no observer are skipped by the caller, the same absent-observer path as
35
+ * before.
36
+ */
37
+ export const DEPLOY_UNIT_RULES: readonly DeployUnitRule[] = [
38
+ { kind: "cfn-deploy", field: "stack", lexicon: "aws" },
39
+ { kind: "kubectl-apply", field: "stack", lexicon: "k8s" },
40
+ { kind: "helm-upgrade", field: "release", lexicon: "helm" },
41
+ ];
42
+
43
+ /** One resolved unit a component's deploy composition targets. */
44
+ export interface DeployUnit {
45
+ /** The unit's name — a stack, a release. */
46
+ unit: string;
47
+ /** The lexicon that observes this kind of unit. */
48
+ lexicon: string;
49
+ }
50
+
51
+ /**
52
+ * Every distinct deploy unit a component's phases target, in declaration
53
+ * order. A step may itself be a nested `Phase`, so the walk recurses; a
54
+ * resolved component carries the unit as a concrete string. Pure.
55
+ */
56
+ export function deployUnits(deploy: Phase[]): DeployUnit[] {
57
+ const byKind = new Map(DEPLOY_UNIT_RULES.map((r) => [r.kind, r]));
58
+ const seen = new Set<string>();
59
+ const units: DeployUnit[] = [];
60
+ const walkSteps = (steps: Phase["steps"]): void => {
61
+ for (const step of steps) {
62
+ // A step may itself be a nested Phase (it carries its own `steps`). Step
63
+ // is open-typed (capability inputs), so discriminate structurally.
64
+ const nested = (step as { steps?: unknown }).steps;
65
+ if (Array.isArray(nested)) {
66
+ walkSteps(nested as Phase["steps"]);
67
+ continue;
68
+ }
69
+ const s = step as { kind?: string } & Record<string, unknown>;
70
+ const rule = s.kind ? byKind.get(s.kind) : undefined;
71
+ if (!rule) continue;
72
+ const unit = s[rule.field];
73
+ if (typeof unit !== "string" || unit.length === 0) continue;
74
+ const key = `${rule.lexicon}\0${unit}`;
75
+ if (seen.has(key)) continue;
76
+ seen.add(key);
77
+ units.push({ unit, lexicon: rule.lexicon });
78
+ }
79
+ };
80
+ for (const phase of deploy) walkSteps(phase.steps);
81
+ return units;
82
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
2
  import {
3
3
  loadChantConfig,
4
+ loadChantConfigUpward,
4
5
  DEFAULT_CHANT_CONFIG,
5
6
  resolveAutoReleaseDisabled,
6
7
  resolveFoldEnabled,
@@ -137,6 +138,58 @@ describe("loadChantConfig", () => {
137
138
  });
138
139
  });
139
140
 
141
+ // #1502 — the upward walk skips lint-scoping fragments. A `src/chant.config.json`
142
+ // holding only `extends`/`rules` (the examples/ convention) must not shadow the
143
+ // project config above it, or `chant build src` silently loses `ownership`/
144
+ // `buildParams` — the exact fallback #1117's walk exists to prevent.
145
+ describe("loadChantConfigUpward (#1502 — lint fragments do not end the walk)", () => {
146
+ const SRC = join(TEST_DIR, "src");
147
+
148
+ test("walks past a lint-only src/chant.config.json to the project config", async () => {
149
+ mkdirSync(SRC, { recursive: true });
150
+ writeFileSync(
151
+ join(SRC, "chant.config.json"),
152
+ JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"], rules: { COR001: "off" } }),
153
+ );
154
+ writeFileSync(
155
+ join(TEST_DIR, "chant.config.json"),
156
+ JSON.stringify({ ownership: { stack: "billing", env: "prod" } }),
157
+ );
158
+
159
+ const result = await loadChantConfigUpward(SRC);
160
+ expect(result.config.ownership).toEqual({ stack: "billing", env: "prod" });
161
+ expect(result.configPath).toBe(join(TEST_DIR, "chant.config.json"));
162
+ });
163
+
164
+ test("a src/chant.config.json declaring any project-level key still wins in place", async () => {
165
+ mkdirSync(SRC, { recursive: true });
166
+ writeFileSync(
167
+ join(SRC, "chant.config.json"),
168
+ JSON.stringify({ ownership: { stack: "nested" }, rules: { COR001: "off" } }),
169
+ );
170
+ writeFileSync(
171
+ join(TEST_DIR, "chant.config.json"),
172
+ JSON.stringify({ ownership: { stack: "root" } }),
173
+ );
174
+
175
+ const result = await loadChantConfigUpward(SRC);
176
+ expect(result.config.ownership?.stack).toBe("nested");
177
+ });
178
+
179
+ test("a fragment-only project resolves to the default config at the boundary", async () => {
180
+ mkdirSync(SRC, { recursive: true });
181
+ writeFileSync(join(TEST_DIR, "package.json"), JSON.stringify({ name: "boundary" }));
182
+ writeFileSync(
183
+ join(SRC, "chant.config.json"),
184
+ JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"] }),
185
+ );
186
+
187
+ const result = await loadChantConfigUpward(SRC);
188
+ expect(result.config).toEqual(DEFAULT_CHANT_CONFIG);
189
+ expect(result.configPath).toBeUndefined();
190
+ });
191
+ });
192
+
140
193
  // #1166 — `environments` accepts either a bare name (unchanged) or
141
194
  // `{ name, endpoint }`, so a declared environment can be self-sufficient for
142
195
  // `--live` reads without an ambient AWS_ENDPOINT_URL export.
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync } from "fs";
2
- import { join } from "path";
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
3
  import { z } from "zod";
4
4
  import type { LintConfig } from "./lint/config";
5
5
  import type { OwnershipMarker } from "./ownership";
@@ -384,12 +384,50 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
384
384
  * same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
385
385
  * shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
386
386
  * contract for the whole CLI.
387
+ *
388
+ * chant #1502 — a lint-scoping fragment does not end the walk. The convention
389
+ * of a `src/chant.config.json` holding only `extends`/`rules` (cc-aws-canonical
390
+ * and most of examples/) sits BETWEEN the build directory and the real
391
+ * `chant.config.ts`, and stopping there re-introduced the exact silent
392
+ * fallback this walk exists to prevent: `chant build src` resolved the
393
+ * fragment, found no `ownership`, and built unstamped manifests that every
394
+ * owned-scoped live read then withheld. A fragment is skipped, not merged —
395
+ * lint resolution keeps its own nearest-wins walk untouched, and a JSON
396
+ * config declaring any project-level key still wins where it stands.
387
397
  */
388
398
  export async function loadChantConfigUpward(startDir: string): Promise<ResolvedConfig> {
389
- const { dir } = findProjectConfig(startDir);
399
+ let { dir, configPath } = findProjectConfig(startDir);
400
+ while (configPath && isLintOnlyFragment(configPath)) {
401
+ const parent = dirname(dir);
402
+ if (parent === dir) break;
403
+ ({ dir, configPath } = findProjectConfig(parent));
404
+ }
390
405
  return loadChantConfig(dir);
391
406
  }
392
407
 
408
+ /**
409
+ * The top-level keys of `./lint/config.ts`'s `LintConfigSchema` (plus the
410
+ * `$schema` editor convention). A `chant.config.json` whose keys all come from
411
+ * this set is a lint-scoping fragment, not a project config — see
412
+ * {@link loadChantConfigUpward}. `chant.config.ts` is never a fragment: it is
413
+ * project-authored code, and inspecting it would mean evaluating it.
414
+ */
415
+ const LINT_FRAGMENT_KEYS = new Set(["$schema", "extends", "rules", "overrides", "plugins", "policies"]);
416
+
417
+ function isLintOnlyFragment(configPath: string): boolean {
418
+ if (!configPath.endsWith("chant.config.json")) return false;
419
+ try {
420
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
421
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
422
+ const keys = Object.keys(parsed);
423
+ return keys.length > 0 && keys.every((k) => LINT_FRAGMENT_KEYS.has(k));
424
+ } catch {
425
+ // Unreadable/unparseable JSON: let loadChantConfig surface the real error
426
+ // in place rather than silently walking past it.
427
+ return false;
428
+ }
429
+ }
430
+
393
431
  /**
394
432
  * Resolve the ownership marker to stamp from project config, or undefined when
395
433
  * ownership marking is off (no `stack`, or `enabled: false`).
@@ -11,6 +11,64 @@ import { getProvenance } from "../provenance";
11
11
  import type { BuildParamProvenance } from "../provenance";
12
12
  import { buildParamValues } from "../build-params";
13
13
  import { setBuildParams } from "../params";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join, parse } from "node:path";
16
+
17
+ /**
18
+ * Warn when resolved build parameters cannot reach project source (#1421).
19
+ *
20
+ * chant's core is ESM. When the project is CommonJS — `"type": "commonjs"`, or
21
+ * no `type` field — tsx loads project source through the CommonJS transform, so
22
+ * the project's `require` of `params.ts` and core's `import` of it produce two
23
+ * separate module records. {@link setBuildParams} mutates one object in place;
24
+ * project source reads the other, and sees `{}`.
25
+ *
26
+ * The failure is silence. chant prints `[param] tier = "prod" (cli)` and then
27
+ * emits the graph for the default branch. `chant graph` is always affected
28
+ * because it always takes the run path; `chant build --no-fold` likewise. Plain
29
+ * `chant build` usually escapes because folding substitutes parameters
30
+ * statically — but a file that falls back to run inside a folded build is wrong
31
+ * the same way, which is why this warns regardless of `fold`.
32
+ *
33
+ * Only fires when parameters were actually resolved, so a CJS project that uses
34
+ * none is never nagged. `chant doctor`'s `package-type-module` check is the
35
+ * ambient version of the same advice.
36
+ *
37
+ * Best-effort and never throws: a project whose `package.json` cannot be found
38
+ * or parsed gets no warning rather than a failed build.
39
+ */
40
+ function warnIfParamsCannotReachProject(path: string, values: Record<string, unknown>): void {
41
+ if (Object.keys(values).length === 0) return;
42
+ try {
43
+ const pkgPath = findPackageJsonUpward(path);
44
+ if (!pkgPath) return;
45
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { type?: string };
46
+ if (pkg.type === "module") return;
47
+ const found = pkg.type ? `"type": "${pkg.type}"` : "no `type` field";
48
+ const names = Object.keys(values).sort().join(", ");
49
+ console.error(
50
+ `warning: ${pkgPath} has ${found}, but chant is ESM — build parameters (${names}) ` +
51
+ `will read as empty in project source on the run path, so declarations conditioned ` +
52
+ `on them take their default branch. Set "type": "module". (chant #1421)`,
53
+ );
54
+ } catch {
55
+ // Unreadable or unparseable package.json — say nothing rather than fail.
56
+ }
57
+ }
58
+
59
+ /** Nearest `package.json` at or above `startDir`, or undefined. */
60
+ function findPackageJsonUpward(startDir: string): string | undefined {
61
+ let dir = startDir;
62
+ const { root } = parse(dir);
63
+ for (;;) {
64
+ const candidate = join(dir, "package.json");
65
+ if (existsSync(candidate)) return candidate;
66
+ if (dir === root) return undefined;
67
+ const parent = dirname(dir);
68
+ if (parent === dir) return undefined;
69
+ dir = parent;
70
+ }
71
+ }
14
72
 
15
73
  /**
16
74
  * Per-file fold-vs-run outcome (chant #1022, epic #1019), populated only
@@ -150,6 +208,7 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
150
208
  // `--watch`) never leaks into a build that supplied none.
151
209
  const buildParamValuesMap = buildParamValues(options?.buildParams ?? []);
152
210
  setBuildParams(buildParamValuesMap);
211
+ warnIfParamsCannotReachProject(path, buildParamValuesMap);
153
212
 
154
213
  // Step 1: Scan for TypeScript files
155
214
  const files = await findInfraFiles(path);
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { discover } from "./index";
6
+
7
+ /**
8
+ * #1421 — chant's core is ESM. A CommonJS project's `require` of `params.ts`
9
+ * and core's `import` of it are two module records, so `setBuildParams`'s
10
+ * in-place mutation never reaches project source: it reads `{}` and every
11
+ * declaration conditioned on a parameter takes its default branch, silently.
12
+ *
13
+ * The fix is to stop it being silent. These assert the warning fires exactly
14
+ * when the hazard exists and stays quiet otherwise.
15
+ */
16
+ describe("build params that cannot reach a CommonJS project (#1421)", () => {
17
+ const dirs: string[] = [];
18
+ const project = (type: string | undefined): string => {
19
+ const dir = mkdtempSync(join(tmpdir(), "chant-1421-"));
20
+ dirs.push(dir);
21
+ writeFileSync(
22
+ join(dir, "package.json"),
23
+ JSON.stringify(type === undefined ? { name: "p" } : { name: "p", type }),
24
+ );
25
+ mkdirSync(join(dir, "src"));
26
+ writeFileSync(join(dir, "src", "main.ts"), "export const x = 1;\n");
27
+ return dir;
28
+ };
29
+
30
+ afterEach(() => {
31
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ const warnings = async (dir: string, params: Array<{ name: string; value: unknown }>): Promise<string[]> => {
36
+ const seen: string[] = [];
37
+ vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => void seen.push(a.join(" ")));
38
+ await discover(join(dir, "src"), {
39
+ buildParams: params as never,
40
+ });
41
+ return seen.filter((s) => s.includes("#1421"));
42
+ };
43
+
44
+ test('warns for "type": "commonjs" when parameters were resolved', async () => {
45
+ const out = await warnings(project("commonjs"), [{ name: "tier", value: "prod" }]);
46
+ expect(out).toHaveLength(1);
47
+ expect(out[0]).toMatch(/"type": "commonjs"/);
48
+ expect(out[0]).toMatch(/tier/);
49
+ expect(out[0]).toMatch(/Set "type": "module"/);
50
+ });
51
+
52
+ // The sneakier half: no `type` field at all is also CommonJS.
53
+ test("warns when package.json declares no type at all", async () => {
54
+ const out = await warnings(project(undefined), [{ name: "tier", value: "prod" }]);
55
+ expect(out).toHaveLength(1);
56
+ expect(out[0]).toMatch(/no `type` field/);
57
+ });
58
+
59
+ test('stays quiet for "type": "module"', async () => {
60
+ expect(await warnings(project("module"), [{ name: "tier", value: "prod" }])).toEqual([]);
61
+ });
62
+
63
+ // A CJS project using no parameters is not at risk, and must not be nagged.
64
+ test("stays quiet when no parameters were resolved", async () => {
65
+ expect(await warnings(project("commonjs"), [])).toEqual([]);
66
+ });
67
+
68
+ test("names every resolved parameter, sorted", async () => {
69
+ const out = await warnings(project("commonjs"), [
70
+ { name: "zone", value: "b" },
71
+ { name: "tier", value: "prod" },
72
+ ]);
73
+ expect(out[0]).toMatch(/\(tier, zone\)/);
74
+ });
75
+ });
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export * from "./import/parser";
49
49
  export * from "./import/generator";
50
50
  export * from "./lexicon";
51
51
  export * from "./observation";
52
+ export * from "./apply";
52
53
  export * from "./deep-observation";
53
54
  export * from "./owner-chain";
54
55
  export * from "./lexicon-integrity";