@intentius/chant-lexicon-cedar 0.44.14 → 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.
- package/README.md +13 -4
- package/dist/avp/client.d.ts +18 -0
- package/dist/avp/client.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +5 -9
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts +35 -8
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/commands.d.ts +14 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts +38 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +1 -1
- package/dist/integrity.json +4 -4
- package/dist/lint/post-synth/cede010.d.ts +5 -4
- package/dist/lint/post-synth/cede010.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/cede010.ts +5 -4
- package/dist/schema-artifact.d.ts +50 -0
- package/dist/schema-artifact.d.ts.map +1 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-cedar-authoring.md +9 -3
- package/dist/spec/fetch.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/avp/OWNERSHIP.md +4 -1
- package/src/avp/client.test.ts +35 -0
- package/src/avp/client.ts +29 -2
- package/src/codegen/docs.ts +5 -799
- package/src/codegen/generate-cli.ts +8 -6
- package/src/codegen/generate.ts +63 -14
- package/src/codegen/output-dir.test.ts +137 -0
- package/src/commands.test.ts +93 -0
- package/src/commands.ts +95 -0
- package/src/config.ts +50 -4
- package/src/index.ts +10 -2
- package/src/init-templates.test.ts +22 -4
- package/src/init-templates.ts +16 -16
- package/src/lint/post-synth/cede010.ts +5 -4
- package/src/plugin.ts +33 -8
- package/src/schema-artifact.test.ts +160 -0
- package/src/schema-artifact.ts +87 -0
- package/src/serializer.ts +17 -1
- package/src/skills/chant-cedar-authoring.md +9 -3
- package/src/spec/fetch.ts +23 -2
- package/dist/codegen/docs-dogwood.d.ts +0 -21
- package/dist/codegen/docs-dogwood.d.ts.map +0 -1
- 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
|
-
|
|
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 }));
|
package/src/codegen/generate.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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
|
|
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,
|
|
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:
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -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
|
|
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
|
-
/**
|
|
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+"
|
|
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
|
|
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
|
|
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(
|