@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.
- package/dist/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +48 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apply.test.ts +169 -0
- package/src/apply.ts +249 -0
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/index.ts +1 -0
package/src/codegen/validate.ts
CHANGED
|
@@ -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,
|
package/src/discovery/index.ts
CHANGED
|
@@ -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";
|