@intentius/chant 0.124.14 → 0.124.15

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 (110) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/build.js +1 -0
  4. package/dist/change-set.d.ts +24 -2
  5. package/dist/change-set.d.ts.map +1 -1
  6. package/dist/change-set.js +50 -1
  7. package/dist/ci-jobs.d.ts +227 -0
  8. package/dist/ci-jobs.d.ts.map +1 -0
  9. package/dist/ci-jobs.js +448 -0
  10. package/dist/components/pr-pipeline.d.ts.map +1 -1
  11. package/dist/components/pr-pipeline.js +3 -2
  12. package/dist/composite.d.ts +10 -2
  13. package/dist/composite.d.ts.map +1 -1
  14. package/dist/composite.js +8 -0
  15. package/dist/discovery/collect.d.ts +2 -1
  16. package/dist/discovery/collect.d.ts.map +1 -1
  17. package/dist/discovery/collect.js +16 -1
  18. package/dist/discovery/fold-import.d.ts.map +1 -1
  19. package/dist/discovery/fold-import.js +2 -107
  20. package/dist/discovery/host-composite.d.ts +35 -0
  21. package/dist/discovery/host-composite.d.ts.map +1 -0
  22. package/dist/discovery/host-composite.js +93 -0
  23. package/dist/discovery/index.d.ts +9 -0
  24. package/dist/discovery/index.d.ts.map +1 -1
  25. package/dist/discovery/index.js +1 -1
  26. package/dist/gate-decision.d.ts +20 -9
  27. package/dist/gate-decision.d.ts.map +1 -1
  28. package/dist/gate-decision.js +72 -19
  29. package/dist/lifecycle/gate-audit.d.ts +69 -2
  30. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  31. package/dist/lifecycle/gate-audit.js +76 -2
  32. package/dist/lifecycle/gate-ledger.d.ts +24 -0
  33. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/gate-ledger.js +9 -1
  35. package/dist/op/activities/activity-contracts.d.ts +1 -1
  36. package/dist/op/activities/activity-contracts.js +1 -1
  37. package/dist/op/activities/change-set.d.ts +8 -3
  38. package/dist/op/activities/change-set.d.ts.map +1 -1
  39. package/dist/op/activities/change-set.js +5 -2
  40. package/dist/op/activities/reconcile.d.ts +28 -16
  41. package/dist/op/activities/reconcile.d.ts.map +1 -1
  42. package/dist/op/activities/reconcile.js +34 -20
  43. package/dist/op/builders.d.ts +1 -1
  44. package/dist/op/builders.d.ts.map +1 -1
  45. package/dist/op/builders.js +1 -1
  46. package/dist/op/op-waves-run.d.ts +1 -1
  47. package/dist/op/op-waves-run.d.ts.map +1 -1
  48. package/dist/op/op-waves-run.js +3 -3
  49. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  50. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  51. package/dist/workspace/__fixtures__/contract-repo.js +17 -1
  52. package/dist/workspace/__fixtures__/sessions.d.ts +1 -1
  53. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  54. package/dist/workspace/__fixtures__/sessions.js +3 -3
  55. package/dist/workspace/change-set.schema.json +5 -3
  56. package/dist/workspace/ci-green-workflow.d.ts +3 -1
  57. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  58. package/dist/workspace/ci-green-workflow.js +5 -2
  59. package/dist/workspace/class-data.d.ts +84 -0
  60. package/dist/workspace/class-data.d.ts.map +1 -0
  61. package/dist/workspace/class-data.js +238 -0
  62. package/dist/workspace/kinds.d.ts +3 -7
  63. package/dist/workspace/kinds.d.ts.map +1 -1
  64. package/dist/workspace/kinds.js +6 -40
  65. package/dist/workspace/principal-classes.d.ts +4 -11
  66. package/dist/workspace/principal-classes.d.ts.map +1 -1
  67. package/dist/workspace/principal-classes.js +11 -94
  68. package/package.json +1 -1
  69. package/src/build.ts +8 -0
  70. package/src/change-set.test.ts +48 -0
  71. package/src/change-set.ts +52 -2
  72. package/src/ci-jobs.test.ts +49 -0
  73. package/src/ci-jobs.ts +515 -0
  74. package/src/components/pr-pipeline.ts +3 -2
  75. package/src/composite.ts +15 -3
  76. package/src/discovery/collect.ts +15 -1
  77. package/src/discovery/fold-build-parity.test.ts +71 -0
  78. package/src/discovery/fold-import.ts +2 -113
  79. package/src/discovery/fold-project-provenance.test.ts +21 -4
  80. package/src/discovery/host-composite.ts +101 -0
  81. package/src/discovery/index.ts +11 -1
  82. package/src/gate-decision-bundle.test.ts +5 -4
  83. package/src/gate-decision-identity.test.ts +59 -6
  84. package/src/gate-decision.ts +81 -21
  85. package/src/lifecycle/gate-audit.test.ts +55 -1
  86. package/src/lifecycle/gate-audit.ts +116 -3
  87. package/src/lifecycle/gate-ledger.test.ts +20 -1
  88. package/src/lifecycle/gate-ledger.ts +33 -1
  89. package/src/op/activities/activity-contracts.ts +1 -1
  90. package/src/op/activities/change-set.ts +11 -3
  91. package/src/op/activities/lexicon-upgrade.test.ts +2 -0
  92. package/src/op/activities/reconcile.test.ts +62 -2
  93. package/src/op/activities/reconcile.ts +46 -22
  94. package/src/op/builders.ts +1 -1
  95. package/src/op/op-waves-run.ts +4 -3
  96. package/src/op/op-waves.test.ts +2 -0
  97. package/src/op/work-lease-run.test.ts +12 -4
  98. package/src/workspace/__fixtures__/contract-repo.ts +17 -1
  99. package/src/workspace/__fixtures__/sessions.ts +3 -3
  100. package/src/workspace/change-set.schema.json +5 -3
  101. package/src/workspace/ci-green-workflow.test.ts +4 -4
  102. package/src/workspace/ci-green-workflow.ts +5 -2
  103. package/src/workspace/class-data.test.ts +64 -0
  104. package/src/workspace/class-data.ts +236 -0
  105. package/src/workspace/kinds.ts +6 -35
  106. package/src/workspace/principal-classes.ts +11 -94
  107. package/src/workspace/records-formats.test.ts +7 -2
  108. package/src/workspace/records-sessions-write.test.ts +3 -3
  109. package/src/workspace/status-contract.e2e.test.ts +37 -0
  110. package/src/workspace/status-contract.test.ts +23 -21
@@ -130,7 +130,7 @@ export interface OpWavePlansFile {
130
130
  /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
131
131
  export const OP_WAVE_PENDING_RESERVED = [
132
132
  "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
133
- "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer",
133
+ "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
134
134
  ] as const;
135
135
 
136
136
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
@@ -835,6 +835,7 @@ interface HooksOutcome {
835
835
  interface OtherGateHold {
836
836
  label: string;
837
837
  name: string;
838
+ moment: OpWaveHookMoment;
838
839
  target?: string;
839
840
  argv: string[];
840
841
  env: Record<string, string>;
@@ -914,7 +915,7 @@ function runHookList(
914
915
  out.outcomes.push({ ...base, outcome: "held" });
915
916
  const request = readHoldRequest(join(cwd, holdPath), log, label);
916
917
  if (request) {
917
- out.otherGates.push({ label, name, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
918
+ out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
918
919
  log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
919
920
  continue;
920
921
  }
@@ -1068,7 +1069,7 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
1068
1069
  description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
1069
1070
  now,
1070
1071
  // #3839: the wave a hold belongs to, as a field, whatever the hook's own description says.
1071
- fields: { ...hold.request.pending, approvalMode: rules.approval, wave: { number: k, name: wave.name, op: spec.name } },
1072
+ fields: { ...hold.request.pending, approvalMode: rules.approval, wave: { number: k, name: wave.name, op: spec.name }, heldBy: { hook: hold.name, moment: hold.moment, ...(hold.target ? { target: hold.target } : {}) } },
1072
1073
  ...(neverOverMcp ? { neverOverMcp: true } : {}),
1073
1074
  });
1074
1075
  const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
@@ -910,6 +910,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
910
910
  expect(gates.appended[0]).toMatchObject({ op: "tf-migrate", gate: "split-queues", planDigest: digest("m"), members: [] });
911
911
  // #3839: the hold names its wave as a field, even when the hook wrote the description.
912
912
  expect(gates.appended[0]).toMatchObject({ description: "move the queues", wave: { number: 1, name: expect.any(String), op: tg.name } });
913
+ // #3886: and the hook that holds it, as a field.
914
+ expect(gates.appended[0]).toMatchObject({ heldBy: { hook: "migrate", moment: expect.any(String) } });
913
915
  expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
914
916
  expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-migrate", CHANT_GATE: "split-queues" });
915
917
  h.done();
@@ -4,7 +4,7 @@
4
4
  * when it ends, and a lost lease stopping the run. An Op that changes the
5
5
  * checkout runs its leased steps in a worktree of its own.
6
6
  */
7
- import { describe, test, expect } from "vitest";
7
+ import { describe, test, expect, vi } from "vitest";
8
8
  import { withTestDir } from "@intentius/chant-test-utils";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -230,6 +230,11 @@ describe("a run under a work lease", () => {
230
230
  }, 30_000);
231
231
 
232
232
  test("a lease lost mid-step stops the step and the run, which fails lease-lost and releases nothing", async () => {
233
+ // The heartbeat is the only timer faked: the test fires its beat itself
234
+ // once the thief holds the lease, instead of waiting a wall-clock second
235
+ // for a renewal that a loaded machine can delay past any fixed bound.
236
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
237
+ try {
233
238
  await withTestDir(async (dir) => {
234
239
  await initRepo(dir);
235
240
  const log: string[] = [];
@@ -241,9 +246,9 @@ describe("a run under a work lease", () => {
241
246
  // The lease runs out from under this run and another worker takes it.
242
247
  await releaseWorkLease(lease.item, lease.holder, { cwd: dir, token: lease.token });
243
248
  expect((await claimWorkLease(lease.item, "thief", { cwd: dir, ttlMs: 60_000 })).ok).toBe(true);
244
- await new Promise<void>((resolve, reject) => {
245
- const t = setTimeout(() => reject(new Error("never stopped")), 20_000);
246
- signal?.addEventListener("abort", () => { clearTimeout(t); log.push("stopped"); reject(new Error("aborted")); }, { once: true });
249
+ await new Promise<void>((_resolve, reject) => {
250
+ signal?.addEventListener("abort", () => { log.push("stopped"); reject(new Error("aborted")); }, { once: true });
251
+ void vi.advanceTimersByTimeAsync(1_000);
247
252
  });
248
253
  return {};
249
254
  },
@@ -270,6 +275,9 @@ describe("a run under a work lease", () => {
270
275
  expect((await listWorkLeases({ cwd: dir, memberPrefix: "" }))[0]).toMatchObject({ holder: "thief", state: "active" });
271
276
  expect((await readRunLedger("local", "dispatch", { cwd: dir })).records.at(-1)?.status).toBe("fail");
272
277
  });
278
+ } finally {
279
+ vi.useRealTimers();
280
+ }
273
281
  }, 30_000);
274
282
 
275
283
  test("a run whose lease was lost before it is recorded is never recorded as done", async () => {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { execFileSync } from "node:child_process";
7
- import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
  import Ajv2020 from "ajv/dist/2020";
@@ -56,6 +56,22 @@ export function repo(files: Record<string, string | { text: string; mode: number
56
56
  return root;
57
57
  }
58
58
 
59
+ /**
60
+ * Build a fixture repository once per test file and hand each caller a copy of
61
+ * it. A repo costs a dozen git processes (init, add, commit, config), and a
62
+ * file copy none, so a file whose tests each start from the same repo builds
63
+ * it once (#3880). The template is rebuilt if {@link cleanScratch} removed it.
64
+ */
65
+ export function repoFixture(build: () => string, prefix = "chant-contract-copy-"): () => string {
66
+ let template: string | undefined;
67
+ return () => {
68
+ if (template === undefined || !existsSync(template)) template = build();
69
+ const root = scratchDir(prefix);
70
+ cpSync(template, root, { recursive: true });
71
+ return root;
72
+ };
73
+ }
74
+
59
75
  export function commitAll(root: string, message = "next"): string {
60
76
  git(root, "add", "-A");
61
77
  git(root, "commit", "-q", "-m", message);
@@ -7,19 +7,19 @@
7
7
  import { cpSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { sessionSeal } from "../record-sessions";
10
- import { commitAll, git, REPO, scratchDir } from "./contract-repo";
10
+ import { commitAll, git, REPO, repoFixture, scratchDir } from "./contract-repo";
11
11
 
12
12
  export const SESSIONS_KIND = "design/sessions/session.kind.mjs";
13
13
  export const DECISIONS_KIND = "decisions/decision.kind.mjs";
14
14
 
15
15
  /** A git repository holding a copy of the reference workspace's decisions and design member. */
16
- export function sessionsRepo(): string {
16
+ export const sessionsRepo = repoFixture(() => {
17
17
  const root = scratchDir("chant-sessions-");
18
18
  for (const d of ["decisions", "design"]) cpSync(join(REPO, "reference-workspace", d), join(root, d), { recursive: true });
19
19
  git(root, "init", "-q");
20
20
  commitAll(root, "reference");
21
21
  return root;
22
- }
22
+ }, "chant-sessions-");
23
23
 
24
24
  /** A session file's text, sealed when it is closed. */
25
25
  export function sessionText(s: { id: string; state: "open" | "closed"; verdicts?: { record: string; principal: string; verdict: string }[]; agenda?: string[] }): string {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/change-set/v1/change-set.schema.json",
4
4
  "title": "change-set document",
5
- "description": "One change set across planners (#3181): what a combined run's chant, terraform, tofu, choudoufu and warden members will change, a summary, and the digest a gate binds. Written by the composeChangeSet Op activity and by composeChangeSet in @intentius/chant/change-set. Version 1 of the workspace read contract. Readers ignore fields they do not know; a field is only ever added within a version.",
5
+ "description": "One change set across planners (#3181): what a combined run's chant, terraform, tofu, choudoufu, warden and sql members will change, a summary, and the digest a gate binds. Written by the composeChangeSet Op activity and by composeChangeSet in @intentius/chant/change-set. Version 1 of the workspace read contract. Readers ignore fields they do not know; a field is only ever added within a version.",
6
6
  "type": "object",
7
7
  "required": [
8
8
  "$schema",
@@ -87,7 +87,8 @@
87
87
  "tofu",
88
88
  "choudoufu",
89
89
  "chant",
90
- "warden"
90
+ "warden",
91
+ "sql"
91
92
  ]
92
93
  },
93
94
  "scope": {
@@ -171,7 +172,8 @@
171
172
  "tofu",
172
173
  "choudoufu",
173
174
  "chant",
174
- "warden"
175
+ "warden",
176
+ "sql"
175
177
  ]
176
178
  },
177
179
  "address": {
@@ -124,15 +124,15 @@ describe("the token the tick pushes its tags with (#3573)", () => {
124
124
  expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
125
125
  });
126
126
 
127
- test("--token-secret checks out with the secret, so the push uses it, and nothing else changes", () => {
127
+ test("--token-secret checks out with the secret, or the Actions token where it is unset (#3624), and nothing else changes", () => {
128
128
  const plain = renderCiGreenWorkflow(base);
129
129
  const text = renderCiGreenWorkflow({ ...base, push: { secret: "CI_GREEN_TOKEN" } });
130
130
  const steps = (parseYAMLDocument(text) as Yaml).jobs.tick.steps;
131
- expect(steps[0]).toEqual({ uses: "actions/checkout@v6", with: { ref: "main", "fetch-depth": 0, token: "${{ secrets.CI_GREEN_TOKEN }}" } });
131
+ expect(steps[0]).toEqual({ uses: "actions/checkout@v6", with: { ref: "main", "fetch-depth": 0, token: "${{ secrets.CI_GREEN_TOKEN || github.token }}" } });
132
132
  // Check runs are still read with the Actions token: a fine-grained token can't call the Checks API.
133
133
  expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
134
134
  expect((parseYAMLDocument(text) as Yaml).permissions).toEqual({ contents: "write", checks: "read" });
135
- expect(text.replace(" token: ${{ secrets.CI_GREEN_TOKEN }}\n", "")).toBe(plain);
135
+ expect(text.replace(" token: ${{ secrets.CI_GREEN_TOKEN || github.token }}\n", "")).toBe(plain);
136
136
  });
137
137
 
138
138
  test("--app-id-var and --app-key-secret mint an App token before the checkout and check out with it", () => {
@@ -228,7 +228,7 @@ describe("chant ci workflow (#3573)", () => {
228
228
  const withToken = readFileSync(file, "utf-8");
229
229
  expect(withToken.split("\n")[0]).toBe(`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --token-secret CI_GREEN_TOKEN (in the workspace root)`);
230
230
  const steps = (parseYAMLDocument(withToken) as Yaml).jobs.tick.steps;
231
- expect(steps[0].with.token).toBe("${{ secrets.CI_GREEN_TOKEN }}");
231
+ expect(steps[0].with.token).toBe("${{ secrets.CI_GREEN_TOKEN || github.token }}");
232
232
  expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
233
233
 
234
234
  const app = { args: { ciAppIdVar: "CI_GREEN_APP_ID", ciAppKeySecret: "CI_GREEN_APP_KEY" } } as unknown as CommandContext;
@@ -27,7 +27,9 @@
27
27
  * commit whose workflow files differ from the default branch's as an update
28
28
  * to those workflows, and the Actions token can never hold the `workflows`
29
29
  * permission, so it is refused there. `--token-secret <NAME>` checks out
30
- * with `secrets.<NAME>` instead, and `--app-id-var <VAR> --app-key-secret
30
+ * with `secrets.<NAME>` instead, falling back to the Actions token where the
31
+ * secret is unset (#3624), so a fork or a fresh clone still tags the commits
32
+ * that change no workflow file; and `--app-id-var <VAR> --app-key-secret
31
33
  * <NAME>` mints a GitHub App token first and checks out with that. The
32
34
  * tick's own `GITHUB_TOKEN`, which reads check runs, stays the Actions token:
33
35
  * a fine-grained personal access token can't call the Checks API.
@@ -44,6 +46,7 @@ import { GENERATED_MARKER } from "../discovery/files";
44
46
  import { parseYAMLDocument } from "../yaml";
45
47
  import type { CiGreen } from "./declaration";
46
48
  import { matchesCheckRun } from "./ci-green";
49
+ import { githubSecretOrJobToken } from "../ci-jobs";
47
50
 
48
51
  /** Where the workflow goes, relative to the repository root. */
49
52
  export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
@@ -185,7 +188,7 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
185
188
  ` private-key: \${{ secrets.${push.appKeySecret} }}`,
186
189
  ]
187
190
  : [];
188
- const token = !push ? [] : ["secret" in push ? ` token: \${{ secrets.${push.secret} }}` : " token: ${{ steps.app-token.outputs.token }}"];
191
+ const token = !push ? [] : ["secret" in push ? ` token: ${githubSecretOrJobToken(push.secret)}` : " token: ${{ steps.app-token.outputs.token }}"];
189
192
  const lines = [
190
193
  `name: ${CI_GREEN_WORKFLOW_NAME}`,
191
194
  "",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * #3888: the principals file read by hand, for the gate-decision bundle,
3
+ * accepts and refuses what chant's ajv-checked `parseClassData` does.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { classDataShapeProblems, parseClassDataByHand } from "./class-data";
8
+ import { parseClassData } from "./principal-classes";
9
+
10
+ const ok = { name: "operator", description: "people who apply infrastructure", role: "ops" };
11
+
12
+ const FILES: unknown[] = [
13
+ { schema: 1, classes: [ok] },
14
+ { $schema: "x", $comment: "y", "x-note": 3, schema: 1, classes: [{ ...ok, "x-extra": true }] },
15
+ { schema: 1, classes: [ok, { name: "reviewer", description: "r", role: "reviewer" }] },
16
+ { schema: 1, classes: [ok, { ...ok, role: "other" }] },
17
+ { schema: 1, classes: [{ ...ok, name: "agent" }] },
18
+ { schema: 1, classes: [{ ...ok, role: "runner" }] },
19
+ { schema: 1, classes: [ok, { ...ok, name: "other" }] },
20
+ { schema: 2, classes: [ok] },
21
+ { schema: "1", classes: [ok] },
22
+ { classes: [ok] },
23
+ { schema: 1 },
24
+ { schema: 1, classes: [] },
25
+ { schema: 1, classes: {} },
26
+ { schema: 1, classes: [ok], extra: 1 },
27
+ { $schema: 3, schema: 1, classes: [ok] },
28
+ { $comment: null, schema: 1, classes: [ok] },
29
+ { schema: 1, classes: [{ ...ok, other: 1 }] },
30
+ { schema: 1, classes: [{ name: ok.name, role: ok.role }] },
31
+ { schema: 1, classes: [{ name: ok.name, description: ok.description }] },
32
+ { schema: 1, classes: [{ description: ok.description, role: ok.role }] },
33
+ { schema: 1, classes: [{ ...ok, name: "Operator" }] },
34
+ { schema: 1, classes: [{ ...ok, name: "x-operator" }] },
35
+ { schema: 1, classes: [{ ...ok, name: "a".repeat(40) }] },
36
+ { schema: 1, classes: [{ ...ok, name: "a".repeat(41) }] },
37
+ { schema: 1, classes: [{ ...ok, name: "1op" }] },
38
+ { schema: 1, classes: [{ ...ok, name: 7 }] },
39
+ { schema: 1, classes: [{ ...ok, description: "" }] },
40
+ { schema: 1, classes: [{ ...ok, description: 1 }] },
41
+ { schema: 1, classes: [{ ...ok, role: "Ops" }] },
42
+ { schema: 1, classes: [{ ...ok, role: "ops role" }] },
43
+ { schema: 1, classes: [{ ...ok, role: "op-s2" }] },
44
+ { schema: 1, classes: [null] },
45
+ { schema: 1, classes: ["operator"] },
46
+ [],
47
+ null,
48
+ "text",
49
+ ];
50
+
51
+ describe("the principals file read without ajv", () => {
52
+ test.each(FILES.map((f) => [JSON.stringify(f), f]))("%s reads to the classes ajv's check gives", (_, file) => {
53
+ const text = JSON.stringify(file);
54
+ const byAjv = parseClassData(text, "@acme/p");
55
+ const byHand = parseClassDataByHand(text, "@acme/p");
56
+ expect(byHand.classes).toEqual(byAjv.classes);
57
+ expect(byHand.problems.length > 0).toBe(byAjv.problems.length > 0);
58
+ expect(classDataShapeProblems(file, "@acme/p").length === 0).toBe(byAjv.classes.length > 0 || byAjv.problems.every((p) => /core|twice|another class/.test(p)));
59
+ });
60
+
61
+ test("text that is not JSON", () => {
62
+ expect(parseClassDataByHand("{", "@acme/p")).toEqual(parseClassData("{", "@acme/p"));
63
+ });
64
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Reading the principal classes a pinned package supplies, without ajv or
3
+ * any package code (#3888).
4
+ *
5
+ * `./principal-classes.ts` reads a package's `./workspace-principals` file
6
+ * for chant, validating it with ajv against
7
+ * `workspace-principals.schema.json`. `@intentius/chant/gate-decision` reads
8
+ * the same files from a bare clone and must bundle without ajv, so the rules
9
+ * both share live here: where a package's export points, where an installed
10
+ * package is, what a file must hold beyond its schema, and how classes from
11
+ * several pins merge. {@link classDataShapeProblems} is the schema written
12
+ * out by hand for the bundle; `class-data.test.ts` holds it to ajv's answer.
13
+ *
14
+ * The module imports node:fs and node:path and nothing else.
15
+ */
16
+
17
+ import { existsSync, readFileSync, statSync } from "node:fs";
18
+ import { dirname, join, posix, resolve } from "node:path";
19
+ import { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, type PrincipalClass } from "./identity-gate-rule";
20
+
21
+ /** The subpath a package publishes its principal classes at. */
22
+ export const PRINCIPALS_SUBPATH = "./workspace-principals";
23
+
24
+ export interface ClassData {
25
+ classes: PrincipalClass[];
26
+ /** Why some of the data can't be used, one line each. Empty when all of it can. */
27
+ problems: string[];
28
+ }
29
+
30
+ /** A package's files, by a `/`-separated path inside it, or undefined when one is not there. */
31
+ export type PackageFiles = (path: string) => string | undefined;
32
+
33
+ /** A package directory on disk. */
34
+ export function diskFiles(dir: string): PackageFiles {
35
+ return (path) => {
36
+ try {
37
+ return readFileSync(join(dir, ...path.split("/")), "utf-8");
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The directory of an installed package `name`, found the way Node finds a
46
+ * bare import: `node_modules/<name>` in `fromDir` and each directory above it.
47
+ * It looks for the directory, not for an entry point, so it works for a
48
+ * package whose `exports` don't expose `package.json`.
49
+ */
50
+ export function findInstalledPackage(name: string, fromDir: string): string | undefined {
51
+ for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
52
+ const candidate = join(dir, "node_modules", ...name.split("/"));
53
+ try {
54
+ if (statSync(candidate).isDirectory() && existsSync(join(candidate, "package.json"))) return candidate;
55
+ } catch {
56
+ // Not here; keep walking.
57
+ }
58
+ if (dirname(dir) === dir) return undefined;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The file a package's `exports[subpath]` names: undefined when it names
64
+ * none, a problem when it is not one `.json` file inside the package.
65
+ */
66
+ export function dataExportTarget(pkg: Record<string, unknown>, subpath: string): string | undefined | { problem: string } {
67
+ const exports = pkg.exports;
68
+ if (!exports || typeof exports !== "object" || Array.isArray(exports)) return undefined;
69
+ const entry = (exports as Record<string, unknown>)[subpath];
70
+ if (entry === undefined) return undefined;
71
+ let target: unknown = entry;
72
+ if (target && typeof target === "object" && !Array.isArray(target)) {
73
+ const conditions = target as Record<string, unknown>;
74
+ target = conditions.default ?? conditions.import ?? conditions.require;
75
+ }
76
+ if (typeof target !== "string") {
77
+ return { problem: `exports["${subpath}"] must name one file, as a string or a "default" condition` };
78
+ }
79
+ if (!target.startsWith("./") || !target.endsWith(".json")) {
80
+ return { problem: `exports["${subpath}"] is ${target}; it must name a .json file inside the package, since chant reads plugin data as data and never runs a package's code` };
81
+ }
82
+ return target;
83
+ }
84
+
85
+ // ── The file ─────────────────────────────────────────────────────────────────
86
+
87
+ const NAME = /^(?!x-)[a-z][a-z0-9-]{0,39}$/u;
88
+ const ROLE = /^[a-z][a-z0-9-]*$/u;
89
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
90
+ const extra = (key: string, known: readonly string[]) => !known.includes(key) && !key.startsWith("x-");
91
+
92
+ /**
93
+ * What `workspace-principals.schema.json` refuses in `raw`, one line each,
94
+ * written out by hand so the gate-decision bundle needs no ajv. Empty when
95
+ * the schema accepts it.
96
+ */
97
+ export function classDataShapeProblems(raw: unknown, source: string): string[] {
98
+ const p: string[] = [];
99
+ const say = (where: string, what: string) => p.push(`${source}: ${where || "the principals file"} ${what}`);
100
+ if (!isObject(raw)) {
101
+ say("", "must be object");
102
+ return p;
103
+ }
104
+ for (const key of Object.keys(raw)) if (extra(key, ["$schema", "$comment", "schema", "classes"])) say("", `must NOT have additional property ${key}`);
105
+ for (const key of ["$schema", "$comment"]) if (key in raw && typeof raw[key] !== "string") say(`/${key}`, "must be string");
106
+ if (!("schema" in raw)) say("", "must have required property 'schema'");
107
+ else if (raw.schema !== 1) say("/schema", "must be equal to constant 1");
108
+ if (!("classes" in raw)) say("", "must have required property 'classes'");
109
+ else if (!Array.isArray(raw.classes)) say("/classes", "must be array");
110
+ else {
111
+ if (raw.classes.length < 1) say("/classes", "must NOT have fewer than 1 items");
112
+ raw.classes.forEach((c: unknown, i: number) => {
113
+ const at = `/classes/${i}`;
114
+ if (!isObject(c)) return say(at, "must be object");
115
+ for (const key of Object.keys(c)) if (extra(key, ["name", "description", "role"])) say(at, `must NOT have additional property ${key}`);
116
+ for (const key of ["name", "description", "role"]) if (!(key in c)) say(at, `must have required property '${key}'`);
117
+ if ("name" in c && (typeof c.name !== "string" || !NAME.test(c.name))) say(`${at}/name`, "must be a class name");
118
+ if ("description" in c && (typeof c.description !== "string" || [...c.description].length < 1)) say(`${at}/description`, "must be a non-empty string");
119
+ if ("role" in c && (typeof c.role !== "string" || !ROLE.test(c.role))) say(`${at}/role`, "must be a role name");
120
+ });
121
+ }
122
+ return [...new Set(p)];
123
+ }
124
+
125
+ const CORE_ROLES = new Set(CORE_CLASSES.map((c) => c.role).filter((r): r is string => r !== null));
126
+
127
+ /**
128
+ * The classes in a principals file the schema accepts, under the rules the
129
+ * schema can't say: no core name or core role, and no name or role twice.
130
+ * `source` names the pin in the classes and in the messages.
131
+ */
132
+ export function checkClassData(valid: { classes: { name: string; description: string; role: string }[] }, source: string): ClassData {
133
+ const problems: string[] = [];
134
+ const classes: PrincipalClass[] = [];
135
+ const names = new Set<string>();
136
+ const roles = new Set<string>();
137
+ for (const c of valid.classes) {
138
+ if ((CORE_PRINCIPAL_CLASSES as readonly string[]).includes(c.name)) {
139
+ problems.push(`${source}: class ${c.name} is a core class and can't be supplied by a package`);
140
+ continue;
141
+ }
142
+ if (CORE_ROLES.has(c.role)) {
143
+ problems.push(`${source}: class ${c.name} names the role ${c.role}, which puts a principal in the core ${c.role} class`);
144
+ continue;
145
+ }
146
+ if (names.has(c.name)) {
147
+ problems.push(`${source}: class ${c.name} is listed twice`);
148
+ continue;
149
+ }
150
+ if (roles.has(c.role)) {
151
+ problems.push(`${source}: class ${c.name} names the role ${c.role}, which another class in the file names; a role puts a principal in one class`);
152
+ continue;
153
+ }
154
+ names.add(c.name);
155
+ roles.add(c.role);
156
+ classes.push({ name: c.name, description: c.description, role: c.role, source });
157
+ }
158
+ return { classes, problems };
159
+ }
160
+
161
+ /** A principals file's text read with {@link classDataShapeProblems} for the schema: what chant's ajv-checked `parseClassData` reads, by the same rules. */
162
+ export function parseClassDataByHand(text: string, source: string): ClassData {
163
+ let raw: unknown;
164
+ try {
165
+ raw = JSON.parse(text);
166
+ } catch (err) {
167
+ return { classes: [], problems: [`${source}: the principals file is not JSON (${(err as Error).message})`] };
168
+ }
169
+ const problems = classDataShapeProblems(raw, source);
170
+ return problems.length > 0 ? { classes: [], problems } : checkClassData(raw as Parameters<typeof checkClassData>[0], source);
171
+ }
172
+
173
+ /** Read the classes a package publishes at `./workspace-principals` through `files`, checking the file with `parse`. Imports nothing. A package that publishes none supplies none. */
174
+ export function readClassFiles(files: PackageFiles, source: string, parse: (text: string, source: string) => ClassData): ClassData {
175
+ const manifest = files("package.json");
176
+ if (manifest === undefined) return { classes: [], problems: [`${source}: no readable package.json`] };
177
+ let pkg: Record<string, unknown>;
178
+ try {
179
+ pkg = JSON.parse(manifest) as Record<string, unknown>;
180
+ } catch {
181
+ return { classes: [], problems: [`${source}: package.json is not JSON`] };
182
+ }
183
+ const target = dataExportTarget(pkg, PRINCIPALS_SUBPATH);
184
+ if (target === undefined) return { classes: [], problems: [] };
185
+ if (typeof target !== "string") return { classes: [], problems: [`${source}: ${target.problem}`] };
186
+ const inside = posix.normalize(target.slice(2));
187
+ if (inside.startsWith("../") || inside === "..") return { classes: [], problems: [`${source}: exports["${PRINCIPALS_SUBPATH}"] points outside the package`] };
188
+ const text = files(inside);
189
+ if (text === undefined) return { classes: [], problems: [`${source}: exports["${PRINCIPALS_SUBPATH}"] names ${target}, which does not exist`] };
190
+ return parse(text, source);
191
+ }
192
+
193
+ /** One pin's classes, as read. */
194
+ export interface PinClassRead {
195
+ /** The pin's index in the declaration's `pins`. */
196
+ pin: number;
197
+ source: string;
198
+ read: ClassData;
199
+ }
200
+
201
+ /**
202
+ * The domain classes of every pin, in pin order and file order. A class
203
+ * name, or a role, two pins both supply is a problem, and every class
204
+ * involved is left out, so a rule naming it reads it as unknown and fails
205
+ * closed.
206
+ */
207
+ export function mergePinClasses(reads: readonly PinClassRead[]): { classes: PrincipalClass[]; problems: { pin: number; message: string }[] } {
208
+ const problems: { pin: number; message: string }[] = [];
209
+ const extra: PrincipalClass[] = [];
210
+ const byName = new Map<string, string>();
211
+ const byRole = new Map<string, string>();
212
+ const clashing = new Set<string>();
213
+ for (const { pin: i, source, read } of reads) {
214
+ for (const p of read.problems) problems.push({ pin: i, message: p });
215
+ for (const c of read.classes) {
216
+ const named = byName.get(c.name);
217
+ const roled = byRole.get(c.role!);
218
+ if (named !== undefined || roled !== undefined) {
219
+ clashing.add(c.name);
220
+ if (named !== undefined) {
221
+ problems.push({ pin: i, message: `class ${c.name} is supplied by both ${named} and ${source}; a class name has one source` });
222
+ } else {
223
+ const other = extra.find((e) => e.role === c.role)!;
224
+ clashing.add(other.name);
225
+ problems.push({ pin: i, message: `class ${c.name} (${source}) names the role ${c.role}, which class ${other.name} (${roled}) names; a role puts a principal in one class` });
226
+ }
227
+ continue;
228
+ }
229
+ byName.set(c.name, source);
230
+ byRole.set(c.role!, source);
231
+ extra.push(c);
232
+ }
233
+ }
234
+ // A name or role two pins both supply is left out altogether: neither reading is safe.
235
+ return { classes: extra.filter((c) => !clashing.has(c.name)), problems };
236
+ }
@@ -17,13 +17,14 @@
17
17
  * fails ({@link resolveKind}).
18
18
  */
19
19
 
20
- import { existsSync, readFileSync, statSync } from "node:fs";
20
+ import { readFileSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
22
+ import { isAbsolute, join, relative, resolve } from "node:path";
23
23
  import schema from "./workspace-kinds.schema.json" with { type: "json" };
24
24
  import type { WorkspaceTree } from "./tree";
25
25
  import { joinPath, skippedDir } from "./tree";
26
26
  import { checkPinIntegrity } from "./pin-integrity";
27
+ import { dataExportTarget, findInstalledPackage } from "./class-data";
27
28
 
28
29
  export const KINDS_SCHEMA_ID = schema.$id;
29
30
 
@@ -428,22 +429,7 @@ export interface PackageKinds extends KindData {
428
429
  * subpath to code, which chant never runs for plugin data.
429
430
  */
430
431
  export function kindsExportTarget(pkg: Record<string, unknown>, subpath: string = KINDS_SUBPATH): string | undefined | { problem: string } {
431
- const exports = pkg.exports;
432
- if (!exports || typeof exports !== "object" || Array.isArray(exports)) return undefined;
433
- const entry = (exports as Record<string, unknown>)[subpath];
434
- if (entry === undefined) return undefined;
435
- let target: unknown = entry;
436
- if (target && typeof target === "object" && !Array.isArray(target)) {
437
- const conditions = target as Record<string, unknown>;
438
- target = conditions.default ?? conditions.import ?? conditions.require;
439
- }
440
- if (typeof target !== "string") {
441
- return { problem: `exports["${subpath}"] must name one file, as a string or a "default" condition` };
442
- }
443
- if (!target.startsWith("./") || !target.endsWith(".json")) {
444
- return { problem: `exports["${subpath}"] is ${target}; it must name a .json file inside the package, since chant reads plugin data as data and never runs a package's code` };
445
- }
446
- return target;
432
+ return dataExportTarget(pkg, subpath);
447
433
  }
448
434
 
449
435
  /** Read the kinds a package directory publishes. Reads files only; imports nothing. */
@@ -515,23 +501,8 @@ export function substituteGraphConfig(config: Record<string, unknown>, at: Graph
515
501
  return walk(config) as Record<string, unknown>;
516
502
  }
517
503
 
518
- /**
519
- * The directory of an installed package `name`, found the way Node finds a
520
- * bare import: `node_modules/<name>` in `fromDir` and each directory above it.
521
- * It looks for the directory, not for an entry point, so it works for a
522
- * package whose `exports` don't expose `package.json`.
523
- */
524
- export function findInstalledPackage(name: string, fromDir: string): string | undefined {
525
- for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
526
- const candidate = join(dir, "node_modules", ...name.split("/"));
527
- try {
528
- if (statSync(candidate).isDirectory() && existsSync(join(candidate, "package.json"))) return candidate;
529
- } catch {
530
- // Not here; keep walking.
531
- }
532
- if (dirname(dir) === dir) return undefined;
533
- }
534
- }
504
+ /** Where an installed package is, as Node finds a bare import (`./class-data.ts`, so the gate-decision bundle shares it). */
505
+ export { findInstalledPackage };
535
506
 
536
507
  export interface PinLike {
537
508
  package: string | null;