@intentius/chant-lexicon-cedar 0.44.13 → 0.45.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 (49) hide show
  1. package/README.md +13 -4
  2. package/dist/avp/client.d.ts +18 -0
  3. package/dist/avp/client.d.ts.map +1 -1
  4. package/dist/codegen/docs.d.ts +5 -9
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/codegen/generate.d.ts +35 -8
  7. package/dist/codegen/generate.d.ts.map +1 -1
  8. package/dist/commands.d.ts +14 -0
  9. package/dist/commands.d.ts.map +1 -0
  10. package/dist/config.d.ts +38 -2
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/index.d.ts +5 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/init-templates.d.ts +1 -1
  15. package/dist/integrity.json +4 -4
  16. package/dist/lint/post-synth/cede010.d.ts +5 -4
  17. package/dist/lint/post-synth/cede010.d.ts.map +1 -1
  18. package/dist/manifest.json +1 -1
  19. package/dist/plugin.d.ts.map +1 -1
  20. package/dist/rules/cede010.ts +5 -4
  21. package/dist/schema-artifact.d.ts +50 -0
  22. package/dist/schema-artifact.d.ts.map +1 -0
  23. package/dist/serializer.d.ts.map +1 -1
  24. package/dist/skills/chant-cedar-authoring.md +9 -3
  25. package/dist/spec/fetch.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/avp/OWNERSHIP.md +4 -1
  28. package/src/avp/client.test.ts +35 -0
  29. package/src/avp/client.ts +29 -2
  30. package/src/codegen/docs.ts +5 -799
  31. package/src/codegen/generate-cli.ts +8 -6
  32. package/src/codegen/generate.ts +63 -14
  33. package/src/codegen/output-dir.test.ts +137 -0
  34. package/src/commands.test.ts +93 -0
  35. package/src/commands.ts +95 -0
  36. package/src/config.ts +50 -4
  37. package/src/index.ts +10 -2
  38. package/src/init-templates.test.ts +22 -4
  39. package/src/init-templates.ts +16 -16
  40. package/src/lint/post-synth/cede010.ts +5 -4
  41. package/src/plugin.ts +33 -8
  42. package/src/schema-artifact.test.ts +160 -0
  43. package/src/schema-artifact.ts +87 -0
  44. package/src/serializer.ts +17 -1
  45. package/src/skills/chant-cedar-authoring.md +9 -3
  46. package/src/spec/fetch.ts +23 -2
  47. package/dist/codegen/docs-dogwood.d.ts +0 -21
  48. package/dist/codegen/docs-dogwood.d.ts.map +0 -1
  49. package/src/codegen/docs-dogwood.ts +0 -1119
@@ -6,13 +6,15 @@
6
6
  * schema bundled at `src/spec/default-schema.cedarschema` when there is not
7
7
  * (#1650), so this always has something to generate from and no longer needs
8
8
  * the scaffold's swallow-and-continue guard.
9
+ *
10
+ * This script is the package's own build step, so the project root is the
11
+ * package root and the output is `src/generated/` (the monorepo case of
12
+ * `resolveGeneratedDir`). A consumer runs `chant generate --lexicon cedar`
13
+ * instead, which writes into the consumer's tree (#1696).
9
14
  */
10
- import { generate, writeGeneratedFiles } from "./generate";
11
- import { dirname } from "path";
12
- import { fileURLToPath } from "url";
15
+ import { generate, packageDir, resolveGeneratedDir, writeGeneratedFiles } from "./generate";
13
16
 
14
- // <pkg>/src/codegen/generate-cli.ts → three levels up is the package root.
15
- const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
17
+ const pkgDir = packageDir();
16
18
 
17
19
  const result = await generate({ verbose: true, projectRoot: pkgDir });
18
- writeGeneratedFiles(result, pkgDir);
20
+ writeGeneratedFiles(result, resolveGeneratedDir({ projectRoot: pkgDir }));
@@ -16,8 +16,10 @@
16
16
  import { generatePipeline, writeGeneratedArtifacts } from "@intentius/chant/codegen/generate";
17
17
  import type { GenerateOptions, GenerateResult } from "@intentius/chant/codegen/generate";
18
18
  import type { NamingStrategy } from "@intentius/chant/codegen/naming";
19
- import { dirname } from "path";
19
+ import { existsSync, realpathSync } from "fs";
20
+ import { dirname, isAbsolute, resolve } from "path";
20
21
  import { fileURLToPath } from "url";
22
+ import { CEDAR_DEFAULT_OUT_DIR } from "../config";
21
23
  import { createNaming } from "./naming";
22
24
  import {
23
25
  buildEmitModel,
@@ -34,11 +36,54 @@ import { assertPinnedLangVersion, assertPinnedSchema } from "../spec/pin";
34
36
  /** Filename of the generated registry. */
35
37
  export const LEXICON_JSON_FILENAME = "lexicon-cedar.json";
36
38
 
39
+ /** The slice of the `cedar` config namespace generation reads. */
40
+ export interface CedarGenerateConfig {
41
+ schema?: string;
42
+ outDir?: string;
43
+ validation?: { requireProjectSchema?: boolean };
44
+ }
45
+
37
46
  export interface CedarGenerateOptions extends GenerateOptions {
38
47
  /** Project root the `cedar.schema` path is resolved against. Defaults to `process.cwd()`. */
39
48
  projectRoot?: string;
40
49
  /** The `cedar` config namespace, when the caller has already loaded it. */
41
- config?: { schema?: string; validation?: { requireProjectSchema?: boolean } };
50
+ config?: CedarGenerateConfig;
51
+ }
52
+
53
+ /** This package's root. `<pkg>/src/codegen/generate.ts` is three levels down. */
54
+ export function packageDir(): string {
55
+ return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
56
+ }
57
+
58
+ function samePath(a: string, b: string): boolean {
59
+ const real = (p: string): string => (existsSync(p) ? realpathSync(p) : resolve(p));
60
+ return real(a) === real(b);
61
+ }
62
+
63
+ /**
64
+ * Where generated artifacts go (#1696).
65
+ *
66
+ * Three cases, in order:
67
+ *
68
+ * 1. `cedar.outDir` is set: that directory, resolved against the project root.
69
+ * 2. The project root *is* this package (the monorepo checkout, `npm run
70
+ * generate`, `chant dev check-lexicon`): `<pkg>/src/generated`, which
71
+ * `src/index.ts` re-exports so the package's own surface stays whole.
72
+ * 3. Anything else is a consumer: `<project>/src/generated/cedar`.
73
+ *
74
+ * Case 2 is what the old code assumed everywhere. A consumer's project root
75
+ * and the installed package are different directories, and writing into the
76
+ * second means the output is gone after `npm ci`.
77
+ */
78
+ export function resolveGeneratedDir(options: { projectRoot?: string; config?: CedarGenerateConfig } = {}): string {
79
+ const root = options.projectRoot ?? process.cwd();
80
+ const configured = options.config?.outDir;
81
+ if (configured) return isAbsolute(configured) ? configured : resolve(root, configured);
82
+
83
+ const pkg = packageDir();
84
+ if (samePath(root, pkg)) return resolve(pkg, "src", "generated");
85
+
86
+ return resolve(root, CEDAR_DEFAULT_OUT_DIR);
42
87
  }
43
88
 
44
89
  /**
@@ -104,20 +149,24 @@ export async function generate(options: CedarGenerateOptions = {}): Promise<Gene
104
149
  return result;
105
150
  }
106
151
 
152
+ /** The files one generate run produces, keyed by filename. */
153
+ export function generatedFiles(result: GenerateResult): Record<string, string> {
154
+ return {
155
+ [LEXICON_JSON_FILENAME]: result.lexiconJSON,
156
+ "index.d.ts": result.typesDTS,
157
+ "index.ts": result.indexTS,
158
+ ...(result.extraArtifacts ?? {}),
159
+ };
160
+ }
161
+
107
162
  /**
108
- * Write generated files to the package directory.
163
+ * Write generated files into `outDir`. With no directory given, writes where
164
+ * {@link resolveGeneratedDir} says for the current working directory.
109
165
  */
110
- export function writeGeneratedFiles(result: GenerateResult, pkgDir?: string): void {
111
- // This module lives at <pkg>/src/codegen/generate.ts, so the package root is
112
- // three levels up — the scaffold's two-level default wrote into src/src/.
113
- const dir = pkgDir ?? dirname(dirname(dirname(fileURLToPath(import.meta.url))));
166
+ export function writeGeneratedFiles(result: GenerateResult, outDir?: string): void {
114
167
  writeGeneratedArtifacts({
115
- baseDir: dir,
116
- files: {
117
- [LEXICON_JSON_FILENAME]: result.lexiconJSON,
118
- "index.d.ts": result.typesDTS,
119
- "index.ts": result.indexTS,
120
- ...(result.extraArtifacts ?? {}),
121
- },
168
+ baseDir: outDir ?? resolveGeneratedDir(),
169
+ generatedSubdir: ".",
170
+ files: generatedFiles(result),
122
171
  });
123
172
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Where `generate()` writes (#1696).
3
+ *
4
+ * The bug: the output went into this package's `src/generated/` no matter who
5
+ * ran it, so a consumer's classes lived in `node_modules` and vanished on
6
+ * `npm ci`. These tests generate into a throwaway project and check that
7
+ * nothing lands under the package or under any `node_modules`.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { generate, generatedFiles, packageDir, resolveGeneratedDir, writeGeneratedFiles } from "./generate";
15
+ import { CEDAR_DEFAULT_OUT_DIR } from "../config";
16
+
17
+ const pkgDir = packageDir();
18
+
19
+ const PROJECT_SCHEMA = `namespace Shop {
20
+ entity Customer = { "email": String };
21
+ entity Order = { "owner": Customer, "total": Long };
22
+ action view, cancel appliesTo {
23
+ principal: [Customer],
24
+ resource: [Order],
25
+ context: { "mfa": Bool }
26
+ };
27
+ }
28
+ `;
29
+
30
+ /** Every file under `dir`, relative, sorted. */
31
+ function tree(dir: string): string[] {
32
+ if (!existsSync(dir)) return [];
33
+ const out: string[] = [];
34
+ const walk = (d: string, prefix: string): void => {
35
+ for (const entry of readdirSync(d).sort()) {
36
+ const full = join(d, entry);
37
+ const rel = prefix ? `${prefix}/${entry}` : entry;
38
+ if (statSync(full).isDirectory()) walk(full, rel);
39
+ else out.push(rel);
40
+ }
41
+ };
42
+ walk(dir, "");
43
+ return out;
44
+ }
45
+
46
+ /** Modification times of the package's own generated files, to prove they were not touched. */
47
+ function packageGeneratedMtimes(): Map<string, number> {
48
+ const dir = join(pkgDir, "src", "generated");
49
+ return new Map(tree(dir).map((f) => [f, statSync(join(dir, f)).mtimeMs]));
50
+ }
51
+
52
+ describe("resolveGeneratedDir", () => {
53
+ it("keeps the package's own output at src/generated when the project is the package", () => {
54
+ expect(resolveGeneratedDir({ projectRoot: pkgDir })).toBe(resolve(pkgDir, "src", "generated"));
55
+ });
56
+
57
+ it("sends a consumer project to src/generated/cedar under its own root", () => {
58
+ const root = join(tmpdir(), "some-consumer");
59
+ expect(resolveGeneratedDir({ projectRoot: root })).toBe(resolve(root, CEDAR_DEFAULT_OUT_DIR));
60
+ });
61
+
62
+ it("honours cedar.outDir, relative to the project root", () => {
63
+ const root = join(tmpdir(), "some-consumer");
64
+ expect(resolveGeneratedDir({ projectRoot: root, config: { outDir: "authz/generated" } })).toBe(
65
+ resolve(root, "authz", "generated"),
66
+ );
67
+ });
68
+
69
+ it("honours an absolute cedar.outDir as given", () => {
70
+ const abs = join(tmpdir(), "elsewhere");
71
+ expect(resolveGeneratedDir({ projectRoot: pkgDir, config: { outDir: abs } })).toBe(abs);
72
+ });
73
+
74
+ it("never resolves a consumer into the installed package", () => {
75
+ // The shape of the bug: a consumer whose node_modules holds this package.
76
+ const root = join(tmpdir(), "consumer");
77
+ const dir = resolveGeneratedDir({ projectRoot: root });
78
+ expect(dir.startsWith(root)).toBe(true);
79
+ expect(dir.includes("node_modules")).toBe(false);
80
+ expect(dir.startsWith(pkgDir)).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("writeGeneratedFiles into a consumer project", () => {
85
+ let root: string;
86
+
87
+ beforeEach(() => {
88
+ root = mkdtempSync(join(tmpdir(), "cedar-consumer-"));
89
+ mkdirSync(join(root, "node_modules", "@intentius", "chant-lexicon-cedar", "src"), { recursive: true });
90
+ writeFileSync(join(root, "schema.cedarschema"), PROJECT_SCHEMA);
91
+ });
92
+
93
+ afterEach(() => {
94
+ rmSync(root, { recursive: true, force: true });
95
+ });
96
+
97
+ it("writes the project's classes under the project, and nothing under node_modules or the package", async () => {
98
+ const before = packageGeneratedMtimes();
99
+
100
+ const result = await generate({ projectRoot: root });
101
+ const outDir = resolveGeneratedDir({ projectRoot: root });
102
+ writeGeneratedFiles(result, outDir);
103
+
104
+ expect(tree(outDir)).toEqual(["index.d.ts", "index.ts", "lexicon-cedar.json", "runtime.ts"]);
105
+ expect(tree(join(root, "node_modules"))).toEqual([]);
106
+ expect(packageGeneratedMtimes()).toEqual(before);
107
+
108
+ // And what was written is the project's schema, not the bundled default.
109
+ const index = readFileSync(join(outDir, "index.ts"), "utf-8");
110
+ expect(index).toContain('"Shop::Order"');
111
+ expect(index).toContain("CancelAction");
112
+ expect(index).not.toContain('"App::Document"');
113
+ });
114
+
115
+ it("is self-contained: the generated tree imports only from @intentius/chant", async () => {
116
+ // A consumer's copy cannot reach back into this package by relative path.
117
+ const result = await generate({ projectRoot: root });
118
+ for (const [name, content] of Object.entries(generatedFiles(result))) {
119
+ if (!name.endsWith(".ts")) continue;
120
+ const specifiers = [...content.matchAll(/from\s+"([^"]+)"/g)].map((m) => m[1]);
121
+ for (const spec of specifiers) {
122
+ expect(spec === "./runtime" || spec.startsWith("@intentius/chant/"), `${name} imports ${spec}`).toBe(true);
123
+ }
124
+ }
125
+ });
126
+
127
+ it("follows cedar.outDir when the config sets one", async () => {
128
+ const config = { schema: "schema.cedarschema", outDir: "src/authz/generated" };
129
+ const result = await generate({ projectRoot: root, config });
130
+ const outDir = resolveGeneratedDir({ projectRoot: root, config });
131
+ writeGeneratedFiles(result, outDir);
132
+
133
+ expect(outDir).toBe(join(root, "src", "authz", "generated"));
134
+ expect(existsSync(join(outDir, "index.ts"))).toBe(true);
135
+ expect(existsSync(join(root, CEDAR_DEFAULT_OUT_DIR))).toBe(false);
136
+ });
137
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `chant cedar generate` / `chant cedar coverage` (#1696).
3
+ *
4
+ * Runs the handlers against a throwaway project, with `process.cwd()` pointed
5
+ * at it the way core's dispatcher would, and checks the output lands in the
6
+ * project tree.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { RESERVED_COMMAND_NAMES } from "@intentius/chant/cli/command-group";
14
+ import { cedarCommandGroup } from "./commands";
15
+ import { cedarPlugin } from "./plugin";
16
+ import { packageDir } from "./codegen/generate";
17
+
18
+ const SCHEMA = `namespace Shop {
19
+ entity Customer = { "email": String };
20
+ entity Order = { "owner": Customer };
21
+ action view appliesTo {
22
+ principal: [Customer],
23
+ resource: [Order],
24
+ context: { "mfa": Bool }
25
+ };
26
+ }
27
+ `;
28
+
29
+ function verb(name: string) {
30
+ const command = cedarCommandGroup().commands.find((c) => c.name === name);
31
+ if (!command) throw new Error(`no verb ${name}`);
32
+ return command;
33
+ }
34
+
35
+ describe("the cedar command group", () => {
36
+ it("mounts under a name core does not reserve, with generate and coverage", () => {
37
+ const group = cedarCommandGroup();
38
+ expect(group.name).toBe("cedar");
39
+ expect(RESERVED_COMMAND_NAMES.has(group.name)).toBe(false);
40
+ expect(group.commands.map((c) => c.name)).toEqual(["generate", "coverage"]);
41
+ expect(cedarPlugin.commands?.().name).toBe("cedar");
42
+ });
43
+
44
+ it("rejects flags it does not know, naming the ones it does", async () => {
45
+ await expect(verb("generate").handler({ verb: "generate", rawArgs: ["--bogus"] })).rejects.toThrow(
46
+ /Unknown flag: --bogus[\s\S]*--out-dir/,
47
+ );
48
+ });
49
+ });
50
+
51
+ describe("chant cedar generate in a consumer project", () => {
52
+ let root: string;
53
+ const cwd = process.cwd();
54
+ let stderr: ReturnType<typeof vi.spyOn>;
55
+
56
+ beforeEach(() => {
57
+ root = realpathSync(mkdtempSync(join(tmpdir(), "cedar-cli-")));
58
+ writeFileSync(join(root, "schema.cedarschema"), SCHEMA);
59
+ writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["cedar"] }));
60
+ process.chdir(root);
61
+ stderr = vi.spyOn(console, "error").mockImplementation(() => {});
62
+ });
63
+
64
+ afterEach(() => {
65
+ process.chdir(cwd);
66
+ stderr.mockRestore();
67
+ rmSync(root, { recursive: true, force: true });
68
+ });
69
+
70
+ it("writes src/generated/cedar under the project and leaves the package alone", async () => {
71
+ const pkgIndex = join(packageDir(), "src", "generated", "index.ts");
72
+ const before = readFileSync(pkgIndex, "utf-8");
73
+
74
+ expect(await verb("generate").handler({ verb: "generate", rawArgs: [] })).toBe(0);
75
+
76
+ const index = join(root, "src", "generated", "cedar", "index.ts");
77
+ expect(existsSync(index)).toBe(true);
78
+ expect(readFileSync(index, "utf-8")).toContain('"Shop::Order"');
79
+ expect(readFileSync(pkgIndex, "utf-8")).toBe(before);
80
+ expect(existsSync(join(root, "node_modules"))).toBe(false);
81
+ });
82
+
83
+ it("takes --out-dir over the default", async () => {
84
+ expect(await verb("generate").handler({ verb: "generate", rawArgs: ["--out-dir=authz/gen"] })).toBe(0);
85
+ expect(existsSync(join(root, "authz", "gen", "index.ts"))).toBe(true);
86
+ expect(existsSync(join(root, "src", "generated"))).toBe(false);
87
+ });
88
+
89
+ it("reports coverage against what generate wrote", async () => {
90
+ await verb("generate").handler({ verb: "generate", rawArgs: [] });
91
+ expect(await verb("coverage").handler({ verb: "coverage", rawArgs: ["--min-overall", "100"] })).toBe(0);
92
+ });
93
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `chant cedar <verb>` — the consumer-facing surface over this lexicon's
3
+ * codegen (#1696), mounted through the command-group seam (#1078).
4
+ *
5
+ * `chant dev generate` exists, but it is lexicon development: it runs every
6
+ * configured lexicon's generate, validate and coverage in turn, and for a
7
+ * project that also lists `aws` that means fetching the CloudFormation spec.
8
+ * A consumer regenerating its own Cedar classes after a schema edit wants one
9
+ * lexicon, one step, and an output path it can see.
10
+ */
11
+
12
+ import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
13
+ import { splitJoinedFlags, unknownFlagError } from "@intentius/chant/cli/command-group";
14
+
15
+ interface ParsedFlags {
16
+ outDir?: string;
17
+ verbose: boolean;
18
+ minOverall?: number;
19
+ }
20
+
21
+ function parseFlags(verb: string, raw: string[], accept: ReadonlySet<string>): ParsedFlags {
22
+ const args = splitJoinedFlags(raw, new Set(["--verbose", "-v"]));
23
+ const flags: ParsedFlags = { verbose: false };
24
+ for (let i = 0; i < args.length; i++) {
25
+ const a = args[i];
26
+ if ((a === "--verbose" || a === "-v") && accept.has("--verbose")) {
27
+ flags.verbose = true;
28
+ } else if (a === "--out-dir" && accept.has("--out-dir")) {
29
+ const value = args[++i];
30
+ if (!value) throw new Error(`--out-dir needs a directory.`);
31
+ flags.outDir = value;
32
+ } else if (a === "--min-overall" && accept.has("--min-overall")) {
33
+ const value = Number(args[++i]);
34
+ if (!Number.isFinite(value)) throw new Error(`--min-overall needs a number.`);
35
+ flags.minOverall = value;
36
+ } else {
37
+ const hint = `"chant cedar ${verb}" accepts ${[...accept].join(", ")}.`;
38
+ throw unknownFlagError(a, hint);
39
+ }
40
+ }
41
+ return flags;
42
+ }
43
+
44
+ async function generateHandler(ctx: CommandGroupContext): Promise<number> {
45
+ const flags = parseFlags("generate", ctx.rawArgs, new Set(["--out-dir", "--verbose"]));
46
+ const { generate, resolveGeneratedDir, writeGeneratedFiles } = await import("./codegen/generate");
47
+ const { loadCedarProject } = await import("./config");
48
+
49
+ const project = await loadCedarProject(process.cwd());
50
+ // A flag beats the config, and both beat the default — the same order
51
+ // `cedar.schema` follows one level down.
52
+ const config = flags.outDir ? { ...project.config, outDir: flags.outDir } : project.config;
53
+ const result = await generate({ verbose: flags.verbose, projectRoot: project.projectRoot, config });
54
+ const outDir = resolveGeneratedDir({ projectRoot: project.projectRoot, config });
55
+ writeGeneratedFiles(result, outDir);
56
+ console.error(`cedar: generated ${result.resources} declaration(s) into ${outDir}`);
57
+ return 0;
58
+ }
59
+
60
+ async function coverageHandler(ctx: CommandGroupContext): Promise<number> {
61
+ const flags = parseFlags("coverage", ctx.rawArgs, new Set(["--min-overall", "--verbose"]));
62
+ const { analyzeCedarCoverage } = await import("./coverage");
63
+ const { resolveGeneratedDir } = await import("./codegen/generate");
64
+ const { loadCedarProject } = await import("./config");
65
+
66
+ const { projectRoot, config } = await loadCedarProject(process.cwd());
67
+ analyzeCedarCoverage({
68
+ projectRoot,
69
+ config,
70
+ generatedDir: resolveGeneratedDir({ projectRoot, config }),
71
+ verbose: flags.verbose,
72
+ minOverall: flags.minOverall,
73
+ });
74
+ return 0;
75
+ }
76
+
77
+ /** The `chant cedar` verb group. */
78
+ export function cedarCommandGroup(): CommandGroup {
79
+ return {
80
+ name: "cedar",
81
+ description: "Schema-driven codegen for a project's Cedar policies: generate typed classes into the project, check schema coverage",
82
+ commands: [
83
+ {
84
+ name: "generate",
85
+ description: "Read the project's .cedarschema and write typed classes into cedar.outDir (default src/generated/cedar)",
86
+ handler: generateHandler,
87
+ },
88
+ {
89
+ name: "coverage",
90
+ description: "Report which schema declarations the generated classes cover",
91
+ handler: coverageHandler,
92
+ },
93
+ ],
94
+ };
95
+ }
package/src/config.ts CHANGED
@@ -32,6 +32,17 @@ import type { ChantConfig } from "@intentius/chant/config";
32
32
  */
33
33
  export const CEDAR_DEFAULT_SCHEMA_PATH = "schema.cedarschema";
34
34
 
35
+ /**
36
+ * Where `generate()` writes when `cedar.outDir` is unset and the project is a
37
+ * consumer of the published package (#1696).
38
+ *
39
+ * Relative to the project root. Inside this lexicon's own checkout the
40
+ * package directory *is* the project root, and the output stays at
41
+ * `src/generated/` so `src/index.ts` can re-export it; see
42
+ * `resolveGeneratedDir` in `codegen/generate.ts`.
43
+ */
44
+ export const CEDAR_DEFAULT_OUT_DIR = "src/generated/cedar";
45
+
35
46
  /**
36
47
  * `strictObject`, not `object`. Core applies `.strict()` to the top level of a
37
48
  * declared namespace itself; `validation` is nested, and is the lexicon's own
@@ -47,6 +58,19 @@ export const cedarConfigSchema = z.strictObject({
47
58
  */
48
59
  schema: z.string().optional(),
49
60
 
61
+ /**
62
+ * Directory `generate()` writes the typed classes into, relative to the
63
+ * project root. Defaults to {@link CEDAR_DEFAULT_OUT_DIR}.
64
+ *
65
+ * The output used to land in the installed package's own `src/generated/`,
66
+ * which `npm ci` wipes (#1696). It is the project's artifact — the classes
67
+ * describe the project's schema, not the package's — so it lives in the
68
+ * project tree, and `src/policies.ts` imports from it rather than from
69
+ * `@intentius/chant-lexicon-cedar`. Commit it or regenerate it in CI; either
70
+ * way it is never under `node_modules`.
71
+ */
72
+ outDir: z.string().optional(),
73
+
50
74
  /**
51
75
  * Knobs for the `cedar-wasm` validator. `mode` is a single-variant enum on
52
76
  * purpose: `ValidationMode` in cedar-wasm 4.12.0 accepts `"strict"` and
@@ -122,7 +146,7 @@ export type CedarConfigNamespace = NonNullable<ChantConfig["cedar"]>;
122
146
  /**
123
147
  * Read the `cedar` namespace out of the project's config.
124
148
  *
125
- * Searches upward from `startDir`, so `chant generate` finds the config from a
149
+ * Searches upward from `startDir`, so `chant cedar generate` finds the config from a
126
150
  * subdirectory the same way `chant build` does. A project with no config, or
127
151
  * one with no `cedar` key, gets `{}` — the schema resolution order in
128
152
  * `spec/fetch.ts` handles that case without needing to know why.
@@ -133,15 +157,37 @@ export type CedarConfigNamespace = NonNullable<ChantConfig["cedar"]>;
133
157
  * never loaded.
134
158
  */
135
159
  export async function loadCedarConfig(startDir: string): Promise<CedarConfig> {
160
+ return (await loadCedarProject(startDir)).config;
161
+ }
162
+
163
+ /** A project as cedar's commands see it: the root paths resolve against, and the namespace. */
164
+ export interface CedarProject {
165
+ /** The directory holding `chant.config.*`, or `startDir` when there is none. */
166
+ projectRoot: string;
167
+ config: CedarConfig;
168
+ }
169
+
170
+ /**
171
+ * {@link loadCedarConfig}, plus the directory the config was found in.
172
+ *
173
+ * `cedar.schema` and `cedar.outDir` are relative to the config file, not to
174
+ * wherever the command was run (#1696) — `chant cedar generate` from a subdirectory
175
+ * has to write to the same place as the same command from the root.
176
+ */
177
+ export async function loadCedarProject(startDir: string): Promise<CedarProject> {
136
178
  const { loadChantConfigUpward } = await import("@intentius/chant/config");
179
+ const { dirname } = await import("path");
137
180
  try {
138
- const { config } = await loadChantConfigUpward(startDir);
181
+ const { config, configPath } = await loadChantConfigUpward(startDir);
139
182
  const parsed = cedarConfigSchema.safeParse(config.cedar ?? {});
140
- return parsed.success ? parsed.data : {};
183
+ return {
184
+ projectRoot: configPath ? dirname(configPath) : startDir,
185
+ config: parsed.success ? parsed.data : {},
186
+ };
141
187
  } catch {
142
188
  // A project whose config does not load is not this function's problem to
143
189
  // report — every other command reports it first, and generation falling
144
190
  // back to the bundled schema is the same behaviour as having no config.
145
- return {};
191
+ return { projectRoot: startDir, config: {} };
146
192
  }
147
193
  }
package/src/index.ts CHANGED
@@ -139,8 +139,16 @@ export type { PackageOptions, PackageResult } from "./codegen/package";
139
139
 
140
140
  // The `cedar` config namespace (#1344). Importing this package is what brings
141
141
  // the key into ChantConfig, so a `chant.config.ts` that sets it compiles.
142
- export { cedarConfigSchema, loadCedarConfig, CEDAR_DEFAULT_SCHEMA_PATH } from "./config";
143
- export type { CedarConfig } from "./config";
142
+ export { cedarConfigSchema, loadCedarConfig, loadCedarProject, CEDAR_DEFAULT_SCHEMA_PATH, CEDAR_DEFAULT_OUT_DIR } from "./config";
143
+ export type { CedarConfig, CedarProject } from "./config";
144
+
145
+ // Where `chant cedar generate` writes for a project (#1696).
146
+ export { resolveGeneratedDir } from "./codegen/generate";
147
+
148
+ // The project schema as a build artifact (#1697): authored by hand as
149
+ // `new Schema({ text })`, or contributed automatically from `cedar.schema`.
150
+ export { Schema, CEDAR_SCHEMA_TYPE, CEDAR_SCHEMA_FILENAME } from "./schema-artifact";
151
+ export type { SchemaProps } from "./schema-artifact";
144
152
 
145
153
  // Upstream pin — the cedar-wasm package version and the Cedar language
146
154
  // version it implements (#1650).
@@ -48,10 +48,13 @@ function generatedNames(schemaText: string): Set<string> {
48
48
  return names;
49
49
  }
50
50
 
51
- /** Identifiers a policy file imports from the lexicon package (any subpath). */
51
+ /**
52
+ * Identifiers a policy file imports from the lexicon package (any subpath) or
53
+ * from the project's generated tree, `./generated/cedar` (#1696).
54
+ */
52
55
  function importedNames(source: string): string[] {
53
56
  const names: string[] = [];
54
- const importRe = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+"@intentius\/chant-lexicon-cedar[^"]*"/g;
57
+ const importRe = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+"(?:@intentius\/chant-lexicon-cedar[^"]*|\.\/generated\/cedar)"/g;
55
58
  for (const match of source.matchAll(importRe)) {
56
59
  for (const raw of match[1].split(",")) {
57
60
  const name = raw.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
@@ -101,14 +104,29 @@ describe("cedarInitTemplates", () => {
101
104
 
102
105
  it("ships a policy file and a README", () => {
103
106
  expect(policies).toContain("export const");
104
- expect(set.root?.["README.md"]).toContain("chant generate --lexicon cedar");
107
+ expect(set.root?.["README.md"]).toContain("chant cedar generate");
105
108
  });
106
109
 
107
110
  it("wires the generate and build scripts into package.json", () => {
108
- expect(set.scripts?.generate).toBe("chant generate --lexicon cedar");
111
+ expect(set.scripts?.generate).toBe("chant cedar generate");
109
112
  expect(set.scripts?.build).toBe("chant build");
110
113
  });
111
114
 
115
+ it("imports its schema-derived names from the project tree, not the package (#1696)", () => {
116
+ // The package's own `src/generated` describes the bundled default
117
+ // schema. A scaffold typed against that would compile with no
118
+ // generate step and be wrong about its own entity model.
119
+ const available = generatedNames(schemaText);
120
+ const fromPackage = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+"@intentius\/chant-lexicon-cedar[^"]*"/g;
121
+ for (const match of policies.matchAll(fromPackage)) {
122
+ for (const raw of match[1].split(",")) {
123
+ const name = raw.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
124
+ expect(available.has(name), `${template}: ${name} should come from ./generated/cedar`).toBe(false);
125
+ }
126
+ }
127
+ expect(policies).toMatch(/from "\.\/generated\/cedar"/);
128
+ });
129
+
112
130
  it("imports only names its own schema generates", () => {
113
131
  const available = generatedNames(schemaText);
114
132
  const missing = importedNames(policies).filter(