@intentius/chant 0.45.0 → 0.46.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 (74) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  5. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  6. package/dist/cli/commands/lint.d.ts.map +1 -1
  7. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +7 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/codegen/lexicon-regen.d.ts +11 -0
  14. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  15. package/dist/codegen/validate.d.ts +10 -0
  16. package/dist/codegen/validate.d.ts.map +1 -1
  17. package/dist/config.d.ts +28 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/env.d.ts +12 -1
  20. package/dist/env.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +182 -2
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/index.d.ts +1 -0
  24. package/dist/lifecycle/index.d.ts.map +1 -1
  25. package/dist/lifecycle/teardown.d.ts +130 -0
  26. package/dist/lifecycle/teardown.d.ts.map +1 -0
  27. package/dist/lint/engine.d.ts +6 -2
  28. package/dist/lint/engine.d.ts.map +1 -1
  29. package/dist/lint/rule.d.ts +31 -0
  30. package/dist/lint/rule.d.ts.map +1 -1
  31. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  32. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  33. package/dist/lint/rules/index.d.ts +2 -1
  34. package/dist/lint/rules/index.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +36 -7
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +1 -1
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/testing.d.ts +136 -0
  40. package/dist/testing.d.ts.map +1 -0
  41. package/package.json +6 -1
  42. package/src/cli/commands/build.test.ts +131 -0
  43. package/src/cli/commands/build.ts +20 -0
  44. package/src/cli/commands/check-lexicon.test.ts +45 -1
  45. package/src/cli/commands/check-lexicon.ts +45 -0
  46. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  47. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  48. package/src/cli/commands/lint.ts +19 -6
  49. package/src/cli/handlers/graph.ts +2 -2
  50. package/src/cli/handlers/lifecycle.test.ts +231 -1
  51. package/src/cli/handlers/lifecycle.ts +219 -3
  52. package/src/cli/handlers/search.ts +5 -2
  53. package/src/cli/main.ts +12 -1
  54. package/src/cli/registry.ts +7 -0
  55. package/src/codegen/lexicon-regen.ts +19 -1
  56. package/src/codegen/validate.test.ts +33 -0
  57. package/src/codegen/validate.ts +21 -2
  58. package/src/config.test.ts +40 -0
  59. package/src/config.ts +58 -1
  60. package/src/env.test.ts +35 -1
  61. package/src/env.ts +17 -3
  62. package/src/lexicon.ts +182 -2
  63. package/src/lifecycle/index.ts +1 -0
  64. package/src/lifecycle/teardown.test.ts +537 -0
  65. package/src/lifecycle/teardown.ts +357 -0
  66. package/src/lint/engine.ts +7 -1
  67. package/src/lint/rule.ts +23 -0
  68. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  69. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  70. package/src/lint/rules/index.ts +4 -1
  71. package/src/op/builders.ts +40 -7
  72. package/src/op/index.ts +1 -1
  73. package/src/testing.test.ts +261 -0
  74. package/src/testing.ts +338 -0
@@ -309,6 +309,137 @@ export const testEntity = {
309
309
  });
310
310
  });
311
311
 
312
+ describe("#1221 — dynamic-env legality against declared environments", () => {
313
+ const thisDir = dirname(fileURLToPath(import.meta.url));
314
+ const runtimePath = resolvePath(thisDir, "../../runtime");
315
+ const paramsPath = resolvePath(thisDir, "../../params");
316
+
317
+ // Serializer exposing the physical name (from params.env interpolation)
318
+ // next to the ownership marker, so one test can assert both are disjoint
319
+ // across two builds.
320
+ const namingSerializer: Serializer = {
321
+ name: "aws",
322
+ rulePrefix: "TEST",
323
+ serialize: (entities, _outputs, context) =>
324
+ JSON.stringify(
325
+ [...entities.values()].map((e) => ({
326
+ name: (e as unknown as { props: { name: string } }).props.name,
327
+ marker: context?.ownership,
328
+ })),
329
+ ),
330
+ };
331
+
332
+ async function writeProject(environments: string) {
333
+ await writeFile(
334
+ join(testDir, "chant.config.ts"),
335
+ `
336
+ export default {
337
+ environments: ${environments},
338
+ ownership: { stack: "billing", env: { param: "env" } },
339
+ buildParams: {
340
+ env: { type: "string", default: "dev" },
341
+ },
342
+ };
343
+ `,
344
+ );
345
+ await writeFile(
346
+ join(testDir, "resources.ts"),
347
+ `
348
+ import { createResource } from ${JSON.stringify(runtimePath)};
349
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
350
+ `,
351
+ );
352
+ await writeFile(
353
+ join(testDir, "main.ts"),
354
+ `
355
+ import { Bucket } from "./resources";
356
+ import { params } from ${JSON.stringify(paramsPath)};
357
+ export const uploads = new Bucket({ name: \`billing-\${params.env}-uploads\` });
358
+ `,
359
+ );
360
+ }
361
+
362
+ function built(): Array<{ name: string; marker?: { stack: string; env?: string } }> {
363
+ return JSON.parse(readFileSync(outputFile, "utf-8"));
364
+ }
365
+
366
+ async function buildWithEnv(env: string) {
367
+ return buildCommand({
368
+ path: testDir,
369
+ output: outputFile,
370
+ format: "json",
371
+ serializers: [namingSerializer],
372
+ params: { env },
373
+ });
374
+ }
375
+
376
+ test("--param env=pr-42 is legal when a pr-* pattern entry is declared", async () => {
377
+ await writeProject(`["dev", "prod", "pr-*"]`);
378
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
379
+ try {
380
+ const result = await buildWithEnv("pr-42");
381
+ expect(result.errors).toEqual([]);
382
+ expect(result.success).toBe(true);
383
+ expect(built()).toEqual([{ name: "billing-pr-42-uploads", marker: { stack: "billing", env: "pr-42" } }]);
384
+ } finally {
385
+ errorSpy.mockRestore();
386
+ }
387
+ });
388
+
389
+ test("--param env outside the declared entries (no pattern covers it) fails the build", async () => {
390
+ await writeProject(`["dev", "prod"]`);
391
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
392
+ try {
393
+ const result = await buildWithEnv("pr-42");
394
+ expect(result.success).toBe(false);
395
+ expect(result.errors.some((e) => e.includes('Unknown environment "pr-42"'))).toBe(true);
396
+ } finally {
397
+ errorSpy.mockRestore();
398
+ }
399
+ });
400
+
401
+ test("a literal entry still matches by equality", async () => {
402
+ await writeProject(`["dev", "prod", "pr-*"]`);
403
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
404
+ try {
405
+ const result = await buildWithEnv("prod");
406
+ expect(result.errors).toEqual([]);
407
+ expect(built()).toEqual([{ name: "billing-prod-uploads", marker: { stack: "billing", env: "prod" } }]);
408
+ } finally {
409
+ errorSpy.mockRestore();
410
+ }
411
+ });
412
+
413
+ test("two builds with different --param env yield disjoint names and disjoint markers", async () => {
414
+ await writeProject(`["dev", "prod", "pr-*"]`);
415
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
416
+ try {
417
+ expect((await buildWithEnv("pr-1")).success).toBe(true);
418
+ const first = built();
419
+ expect((await buildWithEnv("pr-2")).success).toBe(true);
420
+ const second = built();
421
+ expect(first).toEqual([{ name: "billing-pr-1-uploads", marker: { stack: "billing", env: "pr-1" } }]);
422
+ expect(second).toEqual([{ name: "billing-pr-2-uploads", marker: { stack: "billing", env: "pr-2" } }]);
423
+ expect(first[0].name).not.toBe(second[0].name);
424
+ expect(first[0].marker?.env).not.toBe(second[0].marker?.env);
425
+ } finally {
426
+ errorSpy.mockRestore();
427
+ }
428
+ });
429
+
430
+ test("a project with no declared environments accepts any dynamic value, as before", async () => {
431
+ await writeProject(`undefined`);
432
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
433
+ try {
434
+ const result = await buildWithEnv("anything-goes");
435
+ expect(result.errors).toEqual([]);
436
+ expect(built()).toEqual([{ name: "billing-anything-goes-uploads", marker: { stack: "billing", env: "anything-goes" } }]);
437
+ } finally {
438
+ errorSpy.mockRestore();
439
+ }
440
+ });
441
+ });
442
+
312
443
  test("#1064 — a declared build-time parameter binds to params.<name> and folds to a literal", async () => {
313
444
  const thisDir = dirname(fileURLToPath(import.meta.url));
314
445
  const runtimePath = resolvePath(thisDir, "../../runtime");
@@ -4,9 +4,11 @@ import {
4
4
  resolveOwnershipMarker,
5
5
  resolveOwnershipEnv,
6
6
  ownershipEnvDisagreement,
7
+ isOwnershipParamRef,
7
8
  resolveFoldEnabled,
8
9
  resolveSandboxEnabled,
9
10
  } from "../../config";
11
+ import { unknownEnvError } from "../../env";
10
12
  import type { OwnershipMarker } from "../../ownership";
11
13
  import { resolveCliBuildParams } from "../build-params-cli";
12
14
  import type { Serializer, SerializerResult } from "../../serializer";
@@ -258,6 +260,24 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
258
260
  const disagreement = ownershipEnvDisagreement(config, paramsResolution.provenance);
259
261
  if (disagreement) warnings.push(formatWarning({ message: disagreement }));
260
262
 
263
+ // #1221 — dynamic-env legality. `--env` is validated against the declared
264
+ // `environments` in cli/main.ts; a param-bound `ownership.env` supplied via
265
+ // `--param env=<value>` reached here unchecked, so `--param env=pord`
266
+ // stamped a marker for an environment the project never declared. Same
267
+ // check, same site of truth: literal entries match by equality, entries
268
+ // containing `*` (e.g. `"pr-*"`) match as glob patterns, so an unbounded
269
+ // family like per-PR environments is declarable without listing each name.
270
+ if (isOwnershipParamRef(config.ownership?.env)) {
271
+ const dynamicEnvErr = unknownEnvError(env, config.environments);
272
+ if (dynamicEnvErr) {
273
+ errors.push(formatError({
274
+ message: dynamicEnvErr,
275
+ hint: 'Declare it in chant.config `environments` (a "pr-*" pattern entry covers a dynamic family), or pass a declared value.',
276
+ }));
277
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
278
+ }
279
+ }
280
+
261
281
  // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
262
282
  // `Sub`) through to the fold path, so a file using a registered intrinsic
263
283
  // tagged template folds instead of unconditionally falling back to run.
@@ -1,7 +1,9 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { checkLexicon } from "./check-lexicon";
4
+ import { checkLexicon, coverageReportCheck } from "./check-lexicon";
5
+ import { loadLexiconFromDir } from "./check-lexicon-plugin";
6
+ import type { LexiconPlugin } from "../../lexicon";
5
7
 
6
8
  // chant #1067 — check-lexicon.ts had zero tests before this issue, despite
7
9
  // being the tool meant to gate every lexicon's completeness. This locks in
@@ -32,3 +34,45 @@ describe("checkLexicon", () => {
32
34
  expect(matches?.pass).toBe(true);
33
35
  });
34
36
  });
37
+
38
+ // chant #1330 — fountain's spec-coverage gate lived only in its own
39
+ // coverage.test.ts, a convention rather than a check-lexicon contract. These
40
+ // lock in the tier-1 check over the plugin's coverageReport() member: red on
41
+ // an unaccounted kind, vacuous pass without the member, red on a throw, and
42
+ // green against the real fountain lexicon in this repo.
43
+ describe("coverageReportCheck", () => {
44
+ test("fails when the report leaves a kind unaccounted", async () => {
45
+ const plugin = {
46
+ coverageReport: async () => ({ unaccountedKinds: ["SandboxRequest"] }),
47
+ } as unknown as LexiconPlugin;
48
+ const item = await coverageReportCheck(plugin);
49
+ expect(item.tier).toBe(1);
50
+ expect(item.pass).toBe(false);
51
+ expect(item.detail).toContain("SandboxRequest");
52
+ });
53
+
54
+ test("passes vacuously when the plugin has no coverageReport", async () => {
55
+ const item = await coverageReportCheck({} as LexiconPlugin);
56
+ expect(item.pass).toBe(true);
57
+ expect(item.detail).toBeUndefined();
58
+ });
59
+
60
+ test("fails when the report throws", async () => {
61
+ const plugin = {
62
+ coverageReport: async () => {
63
+ throw new Error("snapshot missing");
64
+ },
65
+ } as unknown as LexiconPlugin;
66
+ const item = await coverageReportCheck(plugin);
67
+ expect(item.pass).toBe(false);
68
+ expect(item.detail).toContain("snapshot missing");
69
+ });
70
+
71
+ test("is green on the current fountain lexicon", async () => {
72
+ const loaded = await loadLexiconFromDir(join(repoRoot, "lexicons", "fountain"));
73
+ expect(loaded.plugin).toBeDefined();
74
+ const item = await coverageReportCheck(loaded.plugin);
75
+ expect(item.pass).toBe(true);
76
+ expect(item.detail).toBe("all spec kinds accounted for");
77
+ });
78
+ });
@@ -6,6 +6,7 @@ import { auditDocsClassification, auditDocsReachability } from "./check-lexicon-
6
6
  import { auditMcpNames, lexiconNameFor } from "./check-lexicon-mcp";
7
7
  import { loadLexiconFromDir, registers, safeList } from "./check-lexicon-plugin";
8
8
  import { RULE_CATALOG } from "../../audit/catalog";
9
+ import type { LexiconPlugin } from "../../lexicon";
9
10
 
10
11
  // ── Types ────────────────────────────────────────────────────────────
11
12
 
@@ -66,6 +67,45 @@ function countSubdirs(dir: string): number {
66
67
  .length;
67
68
  }
68
69
 
70
+ /**
71
+ * #1330 — gate on the plugin's own spec-coverage accounting.
72
+ *
73
+ * fountain's `coverage.test.ts` asserts `unaccountedKinds == []` in CI, but a
74
+ * lexicon-local test is a convention, not a check-lexicon contract — the same
75
+ * class of gap #1342 closed for LSP providers. `coverageReport()` gives core
76
+ * the one fact to gate on: which upstream spec kinds are neither modeled nor
77
+ * on the lexicon's exclusion list. A lexicon without the member passes
78
+ * vacuously, the same conditional shape as the docs-reachability and
79
+ * Diátaxis checks; a report that throws fails, for the same reason `safeList`
80
+ * treats a throw as worse than absence.
81
+ */
82
+ export async function coverageReportCheck(plugin: LexiconPlugin | undefined): Promise<CheckItem> {
83
+ const report = plugin?.coverageReport;
84
+ const hasReport = typeof report === "function";
85
+ let unaccounted: string[] = [];
86
+ let error: string | undefined;
87
+ if (hasReport) {
88
+ try {
89
+ unaccounted = (await report.call(plugin))?.unaccountedKinds ?? [];
90
+ } catch (e) {
91
+ error = e instanceof Error ? e.message : String(e);
92
+ }
93
+ }
94
+ return {
95
+ name: "coverageReport() leaves no spec kind unaccounted",
96
+ tier: 1,
97
+ pass: !hasReport || (error === undefined && unaccounted.length === 0),
98
+ detail:
99
+ error !== undefined
100
+ ? `threw: ${error}`
101
+ : unaccounted.length > 0
102
+ ? `${unaccounted.length} unaccounted: ${unaccounted.join(", ")}`
103
+ : hasReport
104
+ ? "all spec kinds accounted for"
105
+ : undefined,
106
+ };
107
+ }
108
+
69
109
  // ── Check runner ─────────────────────────────────────────────────────
70
110
 
71
111
  /**
@@ -389,6 +429,11 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
389
429
  : undefined,
390
430
  });
391
431
 
432
+ // #1330 — fountain's spec-coverage gate lived in a lexicon-local vitest
433
+ // assertion, a convention rather than a check-lexicon contract. The plugin
434
+ // now states the fact directly via `coverageReport()`.
435
+ items.push(await coverageReportCheck(plugin));
436
+
392
437
  // ── Tier 2: Recommended ────────────────────────────────────────
393
438
 
394
439
  const pluginContent = readOr(join(dir, "src/plugin.ts"));
@@ -9,6 +9,12 @@
9
9
  *
10
10
  * The snapshot file is written to `<lexicon-dir>/surface.snapshot.json`.
11
11
  * Pass `--update-snapshot` to commit the fresh snapshot after a successful run.
12
+ *
13
+ * An update run is exempt from the `surface-matches-snapshot` validate check
14
+ * and from nothing else (#1825): that check fails on the stale baseline the
15
+ * run exists to replace, and with an `"always"` gate (#1475) the documented
16
+ * re-baseline flow would deadlock. Any other failing step still refuses the
17
+ * write, so a broken generate cannot be baselined.
12
18
  */
13
19
 
14
20
  import { existsSync, readFileSync } from "fs";
@@ -78,6 +84,9 @@ export async function runSurfaceDiff(opts: SurfaceDiffOptions): Promise<RegenRes
78
84
  skipLint: opts.skipLint,
79
85
  skipExamples: !opts.runExamples,
80
86
  pinnedDigestPath: opts.pinnedDigestPath,
87
+ // The update run skips only the surface-matches-snapshot validate check —
88
+ // the staleness it is about to fix (#1825).
89
+ updatingSnapshot: opts.updateSnapshot,
81
90
  });
82
91
 
83
92
  // Update snapshot when requested and the run succeeded
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `--update-snapshot` against a failing snapshot gate (#1825).
3
+ *
4
+ * Since #1475 the k8s/azure gates run in "always" mode, so a stale baseline
5
+ * fails validate on every run — including the update run whose whole purpose
6
+ * is to replace that baseline. These tests run the real pipeline (no mocks)
7
+ * against a fixture lexicon whose validate script behaves like an "always"
8
+ * gate: it fails while the committed snapshot is stale, unless the run is
9
+ * exempted via CHANT_SNAPSHOT_UPDATE.
10
+ */
11
+
12
+ import { describe, test, expect } from "vitest";
13
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+ import { runSurfaceDiff } from "./lexicon-surface-diff";
17
+ import { SNAPSHOT_FILENAME } from "../../codegen/lexicon-regen";
18
+ import { parseSnapshot } from "../../codegen/surface-snapshot";
19
+
20
+ const STALE_SNAPSHOT = JSON.stringify({
21
+ schemaVersion: 1,
22
+ generatedAt: "2026-01-01T00:00:00.000Z",
23
+ entries: { Queue: { kind: "resource", resourceType: "X::Y::Queue", attrs: [], props: [] } },
24
+ });
25
+
26
+ const GENERATE_SCRIPT = `node -e "
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const dir = path.join(process.cwd(), 'src', 'generated');
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ fs.writeFileSync(path.join(dir, 'lexicon-test.json'), JSON.stringify({Widget:{kind:'resource',resourceType:'X::Y::Widget',attrs:{Id:'Id'}}}));
32
+ fs.writeFileSync(path.join(dir, 'index.d.ts'), 'export declare class Widget { constructor(props: { Name?: string; }); readonly Id: string; }');
33
+ "`;
34
+
35
+ // Emulates a checkSurfaceSnapshot: "always" gate: fail while the committed
36
+ // snapshot does not describe the generated surface, unless the run carries the
37
+ // update exemption.
38
+ const GATED_VALIDATE_SCRIPT = `node -e "
39
+ if (process.env.CHANT_SNAPSHOT_UPDATE === '1') process.exit(0);
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const snap = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'surface.snapshot.json'), 'utf-8'));
43
+ process.exit(snap.entries && snap.entries.Widget ? 0 : 1);
44
+ "`;
45
+
46
+ function makeLexiconDir(validateScript: string): string {
47
+ const dir = mkdtempSync(join(tmpdir(), "chant-sd-update-"));
48
+ const pkg = {
49
+ name: "@intentius/chant-lexicon-test",
50
+ version: "0.1.0",
51
+ type: "module",
52
+ scripts: {
53
+ generate: GENERATE_SCRIPT,
54
+ validate: validateScript,
55
+ },
56
+ };
57
+ writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
58
+ writeFileSync(join(dir, SNAPSHOT_FILENAME), STALE_SNAPSHOT);
59
+ return dir;
60
+ }
61
+
62
+ const baseOpts = { skipBundle: true, skipBuild: true, skipLint: true };
63
+
64
+ describe("surface-diff --update-snapshot vs. an \"always\" snapshot gate (#1825)", () => {
65
+ test("without --update-snapshot the stale snapshot still fails validate", async () => {
66
+ const dir = makeLexiconDir(GATED_VALIDATE_SCRIPT);
67
+ try {
68
+ const result = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts });
69
+ expect(result.ok).toBe(false);
70
+ expect(result.failures.some((f) => f.step === "validate")).toBe(true);
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test("stale snapshot + healthy codegen: the update succeeds and the snapshot matches after", async () => {
77
+ const dir = makeLexiconDir(GATED_VALIDATE_SCRIPT);
78
+ try {
79
+ const result = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts, updateSnapshot: true });
80
+ expect(result.ok).toBe(true);
81
+ expect(result.failures).toEqual([]);
82
+
83
+ // The baseline now describes the generated surface, not the stale one.
84
+ const written = parseSnapshot(readFileSync(join(dir, SNAPSHOT_FILENAME), "utf-8"));
85
+ expect(written.entries.Widget).toBeDefined();
86
+ expect(written.entries.Queue).toBeUndefined();
87
+
88
+ // A second run against the fresh baseline is green with no exemption.
89
+ const rerun = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts });
90
+ expect(rerun.ok).toBe(true);
91
+ expect(rerun.changed).toBe(false);
92
+ } finally {
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("stale snapshot + another failing validate check: the update refuses", async () => {
98
+ // A validate that fails even with the exemption stands in for any check
99
+ // other than surface-matches-snapshot. The exemption covers only the
100
+ // staleness check, so a broken generate cannot be baselined.
101
+ const dir = makeLexiconDir("node -e \"process.exit(1)\"");
102
+ try {
103
+ const result = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts, updateSnapshot: true });
104
+ expect(result.ok).toBe(false);
105
+ expect(result.failures.some((f) => f.step === "validate")).toBe(true);
106
+ // The stale baseline is untouched.
107
+ expect(readFileSync(join(dir, SNAPSHOT_FILENAME), "utf-8")).toBe(STALE_SNAPSHOT);
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ });
@@ -20,6 +20,8 @@ import { GENERATED_MARKER } from "../../discovery/files";
20
20
 
21
21
  // Import config loader
22
22
  import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
23
+ import { loadChantConfig } from "../../config";
24
+ import type { LintProjectConfig } from "../../lint/rule";
23
25
 
24
26
  /**
25
27
  * Type guard to check if a value conforms to the LintRule interface.
@@ -447,6 +449,17 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
447
449
  const config = loadConfig(projectRoot);
448
450
  const hasOverrides = config.overrides && config.overrides.length > 0;
449
451
 
452
+ // chant #1221 — the project's chant.config slice for config-aware rules
453
+ // (COR021 reads `environments` + `ownership`), threaded into every
454
+ // runLint() call below via LintContext.projectConfig. Best-effort: a
455
+ // directory with no project config lints with those rules silent.
456
+ let projectConfig: LintProjectConfig | undefined;
457
+ try {
458
+ projectConfig = (await loadChantConfig(projectRoot)).config as LintProjectConfig;
459
+ } catch {
460
+ projectConfig = undefined;
461
+ }
462
+
450
463
  // Load all rules from lexicon plugins (core "chant" + lexicon-specific)
451
464
  const loaded = await loadAllPluginRules(projectRoot);
452
465
  let allRules = loaded.rules;
@@ -470,7 +483,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
470
483
  let diagnostics: LintDiagnostic[];
471
484
  let suppressed: Array<LintDiagnostic & { reason?: string }> = [];
472
485
  if (options.rules) {
473
- const result = await runLint(files, options.rules, undefined, intrinsics);
486
+ const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
474
487
  diagnostics = result.diagnostics;
475
488
  suppressed = result.suppressed;
476
489
  } else if (hasOverrides) {
@@ -478,13 +491,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
478
491
  for (const file of files) {
479
492
  const relativePath = relative(projectRoot, file);
480
493
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
481
- const result = await runLint([file], fileRules, ruleOptions, intrinsics);
494
+ const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
482
495
  diagnostics.push(...result.diagnostics);
483
496
  suppressed.push(...result.suppressed);
484
497
  }
485
498
  } else {
486
499
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
487
- const result = await runLint(files, rules, ruleOptions, intrinsics);
500
+ const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
488
501
  diagnostics = result.diagnostics;
489
502
  suppressed = result.suppressed;
490
503
  }
@@ -518,7 +531,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
518
531
 
519
532
  // Re-lint after fixes to get updated diagnostics
520
533
  if (options.rules) {
521
- const postResult = await runLint(files, options.rules, undefined, intrinsics);
534
+ const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
522
535
  diagnostics = postResult.diagnostics;
523
536
  suppressed = postResult.suppressed;
524
537
  } else if (hasOverrides) {
@@ -527,13 +540,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
527
540
  for (const file of files) {
528
541
  const relativePath = relative(projectRoot, file);
529
542
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
530
- const postResult = await runLint([file], fileRules, ruleOptions, intrinsics);
543
+ const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
531
544
  diagnostics.push(...postResult.diagnostics);
532
545
  suppressed.push(...postResult.suppressed);
533
546
  }
534
547
  } else {
535
548
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
536
- const postResult = await runLint(files, rules, ruleOptions, intrinsics);
549
+ const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
537
550
  diagnostics = postResult.diagnostics;
538
551
  suppressed = postResult.suppressed;
539
552
  }
@@ -8,7 +8,7 @@ import { mergeProjectOps } from "../../graph-ops";
8
8
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
9
9
  import { observeResources } from "../../lifecycle/observe";
10
10
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
11
- import { loadChantConfig, environmentNames, loadChantConfigUpward, type ChantConfig } from "../../config";
11
+ import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, loadChantConfigUpward, type ChantConfig } from "../../config";
12
12
  import { applyLiveEndpoint } from "../../live-endpoint";
13
13
  import { applyDetail, detailInertNotice, type DetailLevel } from "../../graph-detail";
14
14
  import { applyLens, parseLens } from "../../graph-lens";
@@ -210,7 +210,7 @@ async function runGraphLive(
210
210
  // observation plugins — load them here, mirroring the lifecycle handlers.
211
211
  const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
212
212
  const declaredEnvNames = environmentNames(config.environments);
213
- if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
213
+ if (declaredEnvNames && !matchesDeclaredEnvironment(config.environments, environment)) {
214
214
  console.error(formatError({
215
215
  message: `Unknown environment "${environment}"`,
216
216
  hint: `Defined environments: ${declaredEnvNames.join(", ")}`,