@intentius/chant 0.39.0 → 0.41.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.
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readFileSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { computeCoverage, checkThresholds, type CoverageThresholds } from "./coverage";
11
+ import { extractSurface, diffSurface, parseSnapshot, formatDelta } from "./surface-snapshot";
11
12
 
12
13
  export interface ValidateCheck {
13
14
  name: string;
@@ -20,6 +21,12 @@ export interface ValidateResult {
20
21
  checks: ValidateCheck[];
21
22
  }
22
23
 
24
+ /**
25
+ * Set by the publish workflow to arm the release-time surface gate
26
+ * (chant #1473). Absent in ordinary CI, where upstream drift is expected.
27
+ */
28
+ export const RELEASE_GATE_ENV = "CHANT_RELEASE_GATE";
29
+
23
30
  export interface LexiconValidationConfig {
24
31
  /** Filename of the lexicon JSON (e.g. "lexicon-mydom.json") */
25
32
  lexiconJsonFilename: string;
@@ -35,6 +42,32 @@ export interface LexiconValidationConfig {
35
42
  requiredNamesMatchSubstring?: boolean;
36
43
  /** Base path of the lexicon package */
37
44
  basePath: string;
45
+ /**
46
+ * chant #1473 — this lexicon's release is gated on the generated API
47
+ * matching the committed `surface.snapshot.json`.
48
+ *
49
+ * Two conditions, both required. The lexicon opts in here, AND
50
+ * {@link RELEASE_GATE_ENV} is set — which the publish workflow does and
51
+ * ordinary CI does not.
52
+ *
53
+ * The env half is not caution, it is correctness. `validate` runs on every
54
+ * PR, and the upstream a lexicon generates from can move at any time: the
55
+ * CloudFormation archive republishes schemas several times a day, and some
56
+ * of those edits do change the surface. A hard surface check on every PR
57
+ * would turn any unrelated change red the moment upstream moved, which is
58
+ * the same trap the spec pin fell into one level down. Drift between
59
+ * releases is expected and is what the scheduled lexicon-upgrade job exists
60
+ * to report (#1423).
61
+ *
62
+ * What must never happen is *publishing* a surface nobody reviewed. That is
63
+ * a release-time property, so it is checked at release time.
64
+ *
65
+ * Opt-in per lexicon because k8s and azure are currently adrift from their
66
+ * own baselines (393 and 483 entries, #1475).
67
+ */
68
+ checkSurfaceSnapshot?: boolean;
69
+ /** Environment to read {@link RELEASE_GATE_ENV} from. Defaults to `process.env`; overridden in tests. */
70
+ env?: NodeJS.ProcessEnv;
38
71
  /** Path to the generated directory (defaults to basePath/src/generated) */
39
72
  generatedDir?: string;
40
73
  /** Coverage thresholds (optional) */
@@ -148,6 +181,47 @@ export async function validateLexiconArtifacts(config: LexiconValidationConfig):
148
181
  }
149
182
  }
150
183
 
184
+ // Check: the generated API matches the reviewed one (chant #1473).
185
+ //
186
+ // This is the gate that makes a release trustworthy. `prepack` regenerates
187
+ // from upstream, and for aws that upstream republishes schemas several times
188
+ // a day, so the input can differ from the one whose delta a human accepted.
189
+ // What must not differ is the API that ships. Comparing the just-generated
190
+ // artifacts against the committed `surface.snapshot.json` says exactly that,
191
+ // and says nothing about byte churn that changed no declaration.
192
+ //
193
+ // Runs on the artifacts already on disk — no second generation — and is
194
+ // skipped for a lexicon with no committed snapshot, which is the case for a
195
+ // new lexicon before its first baseline.
196
+ const snapshotPath = join(config.basePath, "surface.snapshot.json");
197
+ const releaseGate = config.checkSurfaceSnapshot && (config.env ?? process.env)[RELEASE_GATE_ENV] === "1";
198
+ if (releaseGate && lexiconData && existsSync(snapshotPath) && existsSync(dtsPath)) {
199
+ try {
200
+ const fresh = extractSurface(readFileSync(lexiconPath, "utf-8"), readFileSync(dtsPath, "utf-8"));
201
+ const delta = diffSurface(parseSnapshot(readFileSync(snapshotPath, "utf-8")), fresh);
202
+ const moved = delta.added.length + delta.removed.length + delta.changed.length;
203
+ checks.push(
204
+ moved === 0
205
+ ? { name: "surface-matches-snapshot", ok: true }
206
+ : {
207
+ name: "surface-matches-snapshot",
208
+ ok: false,
209
+ error:
210
+ `The generated API differs from the reviewed surface.snapshot.json ` +
211
+ `(${delta.added.length} added, ${delta.removed.length} removed, ${delta.changed.length} changed). ` +
212
+ `Accept it deliberately with \`chant dev surface-diff <lexicon> --update-snapshot --bump\`, ` +
213
+ `never as a side effect of a release.\n${formatDelta(delta)}`,
214
+ },
215
+ );
216
+ } catch (err) {
217
+ checks.push({
218
+ name: "surface-matches-snapshot",
219
+ ok: false,
220
+ error: `Failed to compare against surface.snapshot.json: ${err instanceof Error ? err.message : String(err)}`,
221
+ });
222
+ }
223
+ }
224
+
151
225
  return {
152
226
  success: checks.every((c) => c.ok),
153
227
  checks,
@@ -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";