@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
@@ -0,0 +1,71 @@
1
+ /**
2
+ * chant#3612 — `build()` and `foldProject` report the same entities, under the
3
+ * same names, with the same provenance, for projects that use composites.
4
+ *
5
+ * The two share `collectEntities` and `foldProvenanceOfEntities`; what could
6
+ * still differ is what each hands them. These fixtures are real examples, one
7
+ * per lexicon whose composites a project calls, built the way `chant build`
8
+ * builds them (fold on, the plugins' intrinsics, the lexicon names).
9
+ */
10
+ import { describe, test, expect, beforeAll } from "vitest";
11
+ import { join, resolve, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { build } from "../build";
14
+ import { foldProject, type FoldProvenance } from "../index";
15
+ import { findInfraFiles } from "./files";
16
+ import { loadPlugins, resolveProjectLexicons } from "../cli/plugins";
17
+ import type { LexiconPlugin } from "../lexicon";
18
+
19
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
20
+
21
+ const FIXTURES = [
22
+ "lexicons/aws/examples/fargate-alb",
23
+ "lexicons/aws/examples/lambda-function",
24
+ "lexicons/aws/examples/multi-service-alb",
25
+ "lexicons/k8s/examples/batch-workers",
26
+ "lexicons/k8s/examples/web-platform",
27
+ "lexicons/sql/examples/postgres-composites",
28
+ ];
29
+
30
+ describe("build() and foldProject agree on composite-using projects (chant#3612)", () => {
31
+ // Loading a lexicon (aws's generated barrel above all) is the slow part, and
32
+ // it is not what the tests measure, so it happens once, up front.
33
+ const pluginsOf = new Map<string, LexiconPlugin[]>();
34
+ beforeAll(async () => {
35
+ for (const fixture of FIXTURES) {
36
+ const project = join(repoRoot, fixture);
37
+ pluginsOf.set(fixture, await loadPlugins(await resolveProjectLexicons(project)));
38
+ }
39
+ }, 60_000);
40
+
41
+ test.each(FIXTURES)("%s", async (fixture) => {
42
+ const src = join(repoRoot, fixture, "src");
43
+ const plugins = pluginsOf.get(fixture)!;
44
+ const intrinsics = plugins.flatMap((p) => p.intrinsics?.() ?? []);
45
+ const lexicons = plugins.map((p) => p.name);
46
+
47
+ const built = await build(src, plugins.map((p) => p.serializer), undefined, { fold: true, intrinsics, lexicons });
48
+ expect(built.errors).toEqual([]);
49
+
50
+ const files = await findInfraFiles(src);
51
+ const verdicts = await foldProject(files, intrinsics, { lexicons });
52
+ // Same verdict per file. A file that runs (a `chant.config.ts` in `src`)
53
+ // has no fold provenance in foldProject, so it is left out of both sides.
54
+ const mode = new Map(built.foldDecisions.map((d) => [d.file, d.mode]));
55
+ const folded: FoldProvenance = {};
56
+ for (const [file, verdict] of verdicts) {
57
+ expect({ file, verdict: verdict.verdict }).toEqual({ file, verdict: mode.get(file) });
58
+ Object.assign(folded, verdict.foldProvenance);
59
+ }
60
+ const foldedFiles = new Set(files.filter((file) => verdicts.get(file)!.verdict === "fold"));
61
+ expect(foldedFiles.size).toBeGreaterThan(0);
62
+ const fromBuild = Object.fromEntries(
63
+ Object.entries(built.foldProvenance).filter(([, record]) => foldedFiles.has(record.sourceFile ?? "")),
64
+ );
65
+
66
+ // The fixture has to exercise a composite, or the comparison proves nothing.
67
+ expect(Object.values(fromBuild).some((record) => record.composite !== undefined)).toBe(true);
68
+ expect(Object.keys(folded).sort()).toEqual(Object.keys(fromBuild).sort());
69
+ expect(folded).toEqual(fromBuild);
70
+ });
71
+ });
@@ -60,6 +60,7 @@ import {
60
60
  import { getInterpolationFields, getProvenance, setPathProvenance, setProvenance } from "../provenance";
61
61
  import { foldProvenanceOfEntities, type FoldProvenance } from "../fold-provenance";
62
62
  import { collectEntities } from "./collect";
63
+ import { hostCompositeName, markHostCompositeInstance } from "./host-composite";
63
64
  import { intrinsicCallFoldsEagerly, intrinsicTagFolds, type IntrinsicDef } from "../lexicon";
64
65
  import type { BuildParamValue } from "../build-params";
65
66
 
@@ -3056,53 +3057,6 @@ async function interpretCompositeFactory(
3056
3057
  }
3057
3058
  }
3058
3059
 
3059
- /**
3060
- * chant#3598 — a host composite instance's own marker. Non-enumerable and
3061
- * symbol-keyed, like provenance, so nothing that serializes or compares the
3062
- * exports sees it. It carries the composite's name, which the members object
3063
- * otherwise has no record of once it is returned.
3064
- */
3065
- const HOST_COMPOSITE_INSTANCE = Symbol.for("chant.fold.hostCompositeInstance");
3066
-
3067
- /**
3068
- * Mark `members` as the instance a host's composite produced, and return it
3069
- * unchanged.
3070
- *
3071
- * chant#3608 — every declarable member, and every member of a nested host
3072
- * instance, is also stamped with the composite, the way `expandComposite`
3073
- * stamps a chant instance's members. A destructured member is never expanded,
3074
- * so this is the only record it has that a composite wrote it, and without
3075
- * it a path the fold did not attribute reads as authored and so `direct`.
3076
- * `setProvenance` keeps the first writer, and a nested instance was marked
3077
- * when its own call returned, so a member keeps its innermost composite.
3078
- */
3079
- function markHostCompositeInstance(members: Record<string, unknown>, compositeName: string): Record<string, unknown> {
3080
- if (Object.isExtensible(members) && !Object.prototype.hasOwnProperty.call(members, HOST_COMPOSITE_INSTANCE)) {
3081
- Object.defineProperty(members, HOST_COMPOSITE_INSTANCE, {
3082
- value: compositeName,
3083
- enumerable: false,
3084
- configurable: true,
3085
- });
3086
- }
3087
- stampHostCompositeMembers(members, compositeName);
3088
- return members;
3089
- }
3090
-
3091
- /** Stamp `composite` on each declarable member of a host instance, descending into nested host instances. */
3092
- function stampHostCompositeMembers(members: Record<string, unknown>, compositeName: string): void {
3093
- for (const member of Object.values(members)) {
3094
- if (isDeclarable(member)) setProvenance(member, { composite: compositeName });
3095
- else if (hostCompositeName(member) !== undefined) stampHostCompositeMembers(member as Record<string, unknown>, compositeName);
3096
- }
3097
- }
3098
-
3099
- /** The composite name a host composite instance was marked with, or `undefined` for any other value. */
3100
- function hostCompositeName(value: unknown): string | undefined {
3101
- if (!isIndexableObject(value)) return undefined;
3102
- const name = (value as Record<symbol, unknown>)[HOST_COMPOSITE_INSTANCE];
3103
- return typeof name === "string" ? name : undefined;
3104
- }
3105
-
3106
3060
  /** Evaluate an admissible factory body's statements in order, binding each `const`, and return the `return` expression's value. */
3107
3061
  async function interpretFactoryBody(
3108
3062
  fn: ts.ArrowFunction | ts.FunctionExpression,
@@ -5081,7 +5035,7 @@ export async function foldProject(
5081
5035
  const attempt = attempts.get(file);
5082
5036
  return {
5083
5037
  file,
5084
- exports: attempt?.ok === true ? expandHostCompositeExports(Object.fromEntries(attempt.exportedValues)) : {},
5038
+ exports: attempt?.ok === true ? Object.fromEntries(attempt.exportedValues) : {},
5085
5039
  };
5086
5040
  }),
5087
5041
  );
@@ -5153,71 +5107,6 @@ function foldProvenanceByFile(
5153
5107
  return out;
5154
5108
  }
5155
5109
 
5156
- /**
5157
- * chant#3598 — a file's exports with every host composite instance replaced by
5158
- * its members, for {@link foldProvenanceByFile} only.
5159
- *
5160
- * A host's registration form (chant#2442) returns the members object the
5161
- * factory built, not a chant `CompositeInstance`, because wrapping it would run
5162
- * chant's member validation against the host's own entities. `collectEntities`
5163
- * expands only chant's instances, so an instance exported whole
5164
- * (`export const store = Store({...})`) contributed no entity and therefore no
5165
- * record, while a destructured member, a plain declarable export, did.
5166
- *
5167
- * The members are named and stamped the way `expandComposite` names and stamps
5168
- * a chant instance: `${exportName}${Member}`, with a whole-entity `composite`
5169
- * origin under the paths the fold already attributed. That origin is written
5170
- * before `collectEntities` adds its `authored` one, and the first writer wins,
5171
- * so a path the fold did not attribute is `unknown` rather than `direct`.
5172
- *
5173
- * Only a top-level export is expanded. A host instance nested in another
5174
- * composite's body or placed in an array does not fold today, so its file is
5175
- * `run` and has no record to give.
5176
- *
5177
- * The exports a caller receives in the verdict are the originals.
5178
- */
5179
- function expandHostCompositeExports(exports: Record<string, unknown>): Record<string, unknown> {
5180
- const out: Record<string, unknown> = {};
5181
- for (const [name, value] of Object.entries(exports)) {
5182
- if (hostCompositeName(value) === undefined) {
5183
- out[name] = value;
5184
- continue;
5185
- }
5186
- expandHostCompositeInstance(name, value as Record<string, unknown>, name, out);
5187
- }
5188
- return out;
5189
- }
5190
-
5191
- /**
5192
- * chant#3608 — one host instance's members into `out`, a nested host instance
5193
- * expanded in place under its member's name (`siteMainBucket`), as
5194
- * `expandComposite` expands a nested chant instance. Every member belongs to
5195
- * the top-level `instance`; its composite is the innermost one, which
5196
- * {@link markHostCompositeInstance} already stamped, so the whole-entity
5197
- * origin names that composite too.
5198
- */
5199
- function expandHostCompositeInstance(
5200
- prefix: string,
5201
- members: Record<string, unknown>,
5202
- instance: string,
5203
- out: Record<string, unknown>,
5204
- ): void {
5205
- const compositeName = hostCompositeName(members) as string;
5206
- for (const [memberName, member] of Object.entries(members)) {
5207
- if (memberName.length === 0) continue;
5208
- const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`;
5209
- if (hostCompositeName(member) !== undefined) {
5210
- expandHostCompositeInstance(fullName, member as Record<string, unknown>, instance, out);
5211
- continue;
5212
- }
5213
- if (!isDeclarable(member)) continue;
5214
- setProvenance(member, { composite: compositeName, compositeInstance: instance });
5215
- const composite = getProvenance(member)?.composite ?? compositeName;
5216
- setPathProvenance(member, "", { kind: "composite", composite, instance });
5217
- out[fullName] = member;
5218
- }
5219
- }
5220
-
5221
5110
  /** The deepest directory every one of `files` sits under. */
5222
5111
  function commonDirectory(files: readonly string[]): string {
5223
5112
  const split = files.map((file) => dirname(resolvePath(file)).split(sep));
@@ -394,11 +394,15 @@ describe("foldProject provenance for host composites (chant#3608)", () => {
394
394
  expect(provenance.bucket.composite).toBe("Archive");
395
395
  expect(provenance.bucket.fields).toEqual({ name: unknown, versioned: unknown });
396
396
 
397
- // build() does not expand a host instance exported whole, so the
398
- // destructured member is the one it can be held to.
399
- const built = await build(join(root, "archive"), [namesSerializer], undefined, { fold: true });
397
+ // chant#3612 — build() expands the whole instance too, and agrees on every record.
398
+ const built = await build(join(root, "archive"), [namesSerializer], undefined, {
399
+ fold: true,
400
+ lexiconPackages: ["@tsad/shapes-3608"],
401
+ sandbox: true,
402
+ });
400
403
  expect(built.errors).toEqual([]);
401
- expect(built.foldProvenance.bucket).toEqual(provenance.bucket);
404
+ expect([...built.entities.keys()].sort()).toEqual(["archiveBucket", "bucket"]);
405
+ expect(built.foldProvenance).toEqual(provenance);
402
406
  });
403
407
 
404
408
  test("a whole instance's nested composite members have records, and the innermost composite wins", async () => {
@@ -458,6 +462,19 @@ describe("foldProject provenance for host composites (chant#3608)", () => {
458
462
  expect(provenance.mainBucket.fields).toMatchObject({ name: innerName("main"), tier: innerTier("main") });
459
463
  expect(provenance.fixedBucket.fields).toMatchObject({ name: innerName("fixed"), tier: innerTier("fixed") });
460
464
  expect(provenance.side.fields.name).toMatchObject({ kind: "composite-parameter", composite: "Outer", parameters: ["name"] });
465
+
466
+ // chant#3612 — build() given the same host package reports the same
467
+ // entities and the same records. Before, it emitted nothing for `site`
468
+ // and, with the host off the allowlist, ran the file and read `side` as direct.
469
+ const built = await build(join(root, "site"), [namesSerializer], undefined, {
470
+ fold: true,
471
+ lexiconPackages: ["@tsad/shapes-3608"],
472
+ sandbox: true,
473
+ });
474
+ expect(built.errors).toEqual([]);
475
+ expect(built.foldDecisions.map((d) => d.mode)).toEqual(["fold"]);
476
+ expect([...built.entities.keys()].sort()).toEqual(Object.keys(provenance).sort());
477
+ expect(built.foldProvenance).toEqual(provenance);
461
478
  });
462
479
  });
463
480
 
@@ -0,0 +1,101 @@
1
+ import { isDeclarable, type Declarable } from "../declarable";
2
+ import { getProvenance, setPathProvenance, setProvenance } from "../provenance";
3
+
4
+ /**
5
+ * chant#3598 — a host composite instance's own marker. Non-enumerable and
6
+ * symbol-keyed, like provenance, so nothing that serializes or compares the
7
+ * exports sees it. It carries the composite's name, which the members object
8
+ * otherwise has no record of once it is returned.
9
+ *
10
+ * Only the fold path sets it: a host's registration form (chant#2442) returns
11
+ * the members object the factory built, not a chant `CompositeInstance`, and
12
+ * chant knows the object came from a composite only because the fold resolved
13
+ * the call. A file that runs instead has no such knowledge.
14
+ */
15
+ const HOST_COMPOSITE_INSTANCE = Symbol.for("chant.fold.hostCompositeInstance");
16
+
17
+ function isPlainIndexable(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ /**
22
+ * Mark `members` as the instance a host's composite produced, and return it
23
+ * unchanged.
24
+ *
25
+ * chant#3608 — every declarable member, and every member of a nested host
26
+ * instance, is also stamped with the composite, the way `expandComposite`
27
+ * stamps a chant instance's members. A destructured member is never expanded,
28
+ * so this is the only record it has that a composite wrote it, and without
29
+ * it a path the fold did not attribute reads as authored and so `direct`.
30
+ * `setProvenance` keeps the first writer, and a nested instance was marked
31
+ * when its own call returned, so a member keeps its innermost composite.
32
+ */
33
+ export function markHostCompositeInstance(
34
+ members: Record<string, unknown>,
35
+ compositeName: string,
36
+ ): Record<string, unknown> {
37
+ if (Object.isExtensible(members) && !Object.prototype.hasOwnProperty.call(members, HOST_COMPOSITE_INSTANCE)) {
38
+ Object.defineProperty(members, HOST_COMPOSITE_INSTANCE, {
39
+ value: compositeName,
40
+ enumerable: false,
41
+ configurable: true,
42
+ });
43
+ }
44
+ stampHostCompositeMembers(members, compositeName);
45
+ return members;
46
+ }
47
+
48
+ /** Stamp `composite` on each declarable member of a host instance, descending into nested host instances. */
49
+ function stampHostCompositeMembers(members: Record<string, unknown>, compositeName: string): void {
50
+ for (const member of Object.values(members)) {
51
+ if (isDeclarable(member)) setProvenance(member, { composite: compositeName });
52
+ else if (hostCompositeName(member) !== undefined) stampHostCompositeMembers(member as Record<string, unknown>, compositeName);
53
+ }
54
+ }
55
+
56
+ /** The composite name a host composite instance was marked with, or `undefined` for any other value. */
57
+ export function hostCompositeName(value: unknown): string | undefined {
58
+ if (!isPlainIndexable(value)) return undefined;
59
+ const name = (value as Record<symbol, unknown>)[HOST_COMPOSITE_INSTANCE];
60
+ return typeof name === "string" ? name : undefined;
61
+ }
62
+
63
+ /**
64
+ * chant#3598, chant#3612 — a host composite instance's members as entities,
65
+ * for `collectEntities`.
66
+ *
67
+ * `collectEntities` is the one place `build()` and `foldProject` turn exports
68
+ * into entities, so expanding here is what makes the two report the same set:
69
+ * `export const archive = Archive({...})` gives `archiveBucket` to both.
70
+ *
71
+ * The members are named and stamped the way `expandComposite` names and stamps
72
+ * a chant instance: `${exportName}${Member}`, a nested host instance expanded
73
+ * in place under its member's name (`siteMainBucket`), with a whole-entity
74
+ * `composite` origin under the paths the fold already attributed. That origin
75
+ * is written before `collectEntities` adds its own record, and the first
76
+ * writer wins, so a path the fold did not attribute is `unknown` rather than
77
+ * `direct`. Every member belongs to the top-level `instance`; its composite is
78
+ * the innermost one, which {@link markHostCompositeInstance} already stamped.
79
+ */
80
+ export function expandHostComposite(
81
+ prefix: string,
82
+ members: Record<string, unknown>,
83
+ instance: string = prefix,
84
+ ): Array<[string, Declarable]> {
85
+ const out: Array<[string, Declarable]> = [];
86
+ const compositeName = hostCompositeName(members) as string;
87
+ for (const [memberName, member] of Object.entries(members)) {
88
+ if (memberName.length === 0) continue;
89
+ const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`;
90
+ if (hostCompositeName(member) !== undefined) {
91
+ out.push(...expandHostComposite(fullName, member as Record<string, unknown>, instance));
92
+ continue;
93
+ }
94
+ if (!isDeclarable(member)) continue;
95
+ setProvenance(member, { composite: compositeName, compositeInstance: instance });
96
+ const composite = getProvenance(member)?.composite ?? compositeName;
97
+ setPathProvenance(member, "", { kind: "composite", composite, instance });
98
+ out.push([fullName, member]);
99
+ }
100
+ return out;
101
+ }
@@ -143,6 +143,16 @@ export interface DiscoveryOptions {
143
143
  */
144
144
  lexicons?: readonly string[];
145
145
 
146
+ /**
147
+ * chant#3612 — package specifiers to follow a bare import into, verbatim,
148
+ * alongside whatever {@link lexicons} names. The same allowlist
149
+ * `FoldProjectOptions.lexiconPackages` gives `foldProject` (chant#2438), so
150
+ * a build over a host of its own folds the files `foldProject` folds. A
151
+ * chant project never needs it: its packages are `lexicons`. Only
152
+ * meaningful when {@link fold} is set. Default: none.
153
+ */
154
+ lexiconPackages?: readonly string[];
155
+
146
156
  /**
147
157
  * chant #1045 Phase 2 — opt-in: whatever would otherwise reach the
148
158
  * in-process `importModule` step (every file, when {@link fold} isn't set;
@@ -289,7 +299,7 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
289
299
  buildParamValuesMap,
290
300
  options.lexicons,
291
301
  options.sandbox === true,
292
- [],
302
+ options.lexiconPackages ?? [],
293
303
  options.executing === true,
294
304
  files,
295
305
  )
@@ -6,7 +6,7 @@
6
6
  * rule because `@intentius/chant/op/gate` pulls in zod and the git ledger
7
7
  * code. `@intentius/chant/gate-decision` holds the decision for a plan digest
8
8
  * under `ledger`, `pr-review` and `sealed`, the seal check over
9
- * node:crypto, and the `identity.gates` rule with its class check (#3862). This bundles it the way terragucci would and reads esbuild's
9
+ * node:crypto, and the `identity.gates` rule with its class check (#3862), reading the classes pinned packages supply as data, without ajv (#3888). This bundles it the way terragucci would and reads esbuild's
10
10
  * own list of what went in.
11
11
  */
12
12
 
@@ -15,7 +15,7 @@ import { build } from "esbuild";
15
15
  import { describe, expect, test } from "vitest";
16
16
  import { REPO } from "./workspace/__fixtures__/contract-repo";
17
17
 
18
- const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/lifecycle\/git\.ts$/, /\/src\/lifecycle\/gate-ledger\.ts$/];
18
+ const FORBIDDEN = [/node_modules\//, /\/src\/workspace\/(kinds|principal-classes|pin-integrity)\.ts$/, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/lifecycle\/git\.ts$/, /\/src\/lifecycle\/gate-ledger\.ts$/];
19
19
 
20
20
  describe("the gate-decision bundle", () => {
21
21
  test("holds the decision, the seal check and the plan digest, and no package or git code", async () => {
@@ -46,13 +46,14 @@ describe("the gate-decision bundle", () => {
46
46
  "packages/core/src/notify.ts",
47
47
  "packages/core/src/op/gate-point.ts",
48
48
  "packages/core/src/op/op-waves.ts",
49
+ "packages/core/src/workspace/class-data.ts",
49
50
  "packages/core/src/workspace/identity-gate-rule.ts",
50
51
  "packages/core/src/workspace/jsonc.ts",
51
52
  "packages/core/src/workspace/trust/gate-seal.ts",
52
53
  "packages/core/src/workspace/trust/trust-files.ts",
53
54
  ]);
54
- // About 27 KB today.
55
+ // About 43 KB unminified today, 7 KB of it the pinned classes read as data (#3888).
55
56
  const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
56
- expect(bytes).toBeLessThan(40 * 1024);
57
+ expect(bytes).toBeLessThan(48 * 1024);
57
58
  });
58
59
  });
@@ -139,18 +139,71 @@ describe.skipIf(!hasSshKeygen)("identity.gates read at base, as chant reads it",
139
139
  expect(judgeBoth(invalid.r, invalid.base, invalid.approvals).ship![1]).toMatch(/no signers file/);
140
140
  });
141
141
 
142
- test("a domain class the caller read from a pin, and a pin this reader does not load", () => {
143
- const { r, base, approvals } = setup(
144
- (k) => ({ ".chant/allowed_signers": `alice ${k.alice.pub}\n`, ".chant/trust.json": JSON.stringify({ schema: 1, roles: { operator: ["alice"] } }) }),
145
- { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] },
142
+ /** A plugin publishing `classes` at ./workspace-principals, as files under `dir`. */
143
+ const plugin = (dir: string, classes: unknown[], version = "1.0.0"): Record<string, string> => ({
144
+ [`${dir}/package.json`]: JSON.stringify({ name: "@acme/ops-classes", version, exports: { "./workspace-principals": "./principals.json" } }),
145
+ [`${dir}/principals.json`]: JSON.stringify({ schema: 1, classes }),
146
+ });
147
+ const OPERATOR = { name: "operator", description: "people who apply infrastructure", role: "operator" };
148
+ const operatorPolicy = (k: Record<string, Key>) => ({
149
+ ".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\n`,
150
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { operator: ["alice"], runner: ["bob"] } }),
151
+ });
152
+ const install = (r: TestRepo, files: Record<string, string>) => {
153
+ for (const [path, text] of Object.entries(files)) r.write(path, text);
154
+ };
155
+
156
+ test("a class only a pinned package supplies, read from the installed package as chant reads it (#3888)", () => {
157
+ const { r, base, approvals } = setup(operatorPolicy, { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] });
158
+ install(r, plugin("node_modules/@acme/ops-classes", [OPERATOR]));
159
+ const ours = judgeBoth(r, base, approvals);
160
+ // alice holds the operator role at base, so she is an operator and no longer human.
161
+ expect(ours.ops![1]).toBeNull();
162
+ expect(ours.ops![2]).toBe("BOB is not in the class operator that gate ops needs: no role grant at base puts them there");
163
+ expect(ours.deploy![1]).toMatch(/^alice is not in the class human that gate deploy needs/);
164
+
165
+ // A bare clone holds no node_modules; the caller names where the packages are installed.
166
+ const bare = `${r.dir}.git`;
167
+ r.git(["clone", "-q", "--bare", r.dir, bare]);
168
+ const fromBare = identityGateAdmissionAt(bare, base, "ops", { packages: r.dir })!;
169
+ expect(approvals("ops").map((a) => fromBare.refuses(a))).toEqual(ours.ops);
170
+ });
171
+
172
+ test("a path pin's class is read at base, so the change being applied can't remap it", () => {
173
+ const { r, base, approvals } = setup((k) => ({ ...operatorPolicy(k), ...plugin("plugins/ops", [OPERATOR]) }), { pins: [{ path: "plugins/ops" }] });
174
+ // After base the plugin moves the operator class to bob's role; base still says operator.
175
+ install(r, plugin("plugins/ops", [{ ...OPERATOR, role: "runner-ops" }]));
176
+ r.commit("remaps operator");
177
+ const ours = judgeBoth(r, base, approvals);
178
+ expect(ours.ops![1]).toBeNull();
179
+ });
180
+
181
+ test("a pin it cannot read refuses a gate whose class the pin could change, and says why", () => {
182
+ const { r, base, approvals } = setup(operatorPolicy, { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] });
183
+ const missing = identityGateAdmissionAt(r.dir, base, "ops")!;
184
+ expect(missing.refuses(approvals("ops")[1])).toBe(
185
+ `identity.gates.ops names the class operator, and the pins in chant.workspace.json at base could not all be read, so no approval can show it is in that class: @acme/ops-classes is not installed under ${r.dir}`,
146
186
  );
147
- const unread = identityGateAdmissionAt(r.dir, base, "ops")!;
148
- expect(unread.refuses(approvals("ops")[1])).toMatch(/is not a core class, and this reader does not load the classes the pinned packages in chant\.workspace\.json at base supply/);
187
+ // human is everyone in no other class, so an unread pin leaves it unknown too; runner and no class are not affected.
188
+ expect(identityGateAdmissionAt(r.dir, base, "deploy")!.refuses(approvals("deploy")[1])).toMatch(/names the class human, and the pins .* could not all be read/);
189
+ expect(identityGateAdmissionAt(r.dir, base, "release")!.refuses(approvals("release")[2])).toBeNull();
190
+ expect(identityGateAdmissionAt(r.dir, base, "ship")!.refuses(approvals("ship")[1])).toBeNull();
191
+
192
+ install(r, plugin("node_modules/@acme/ops-classes", [OPERATOR], "2.0.0"));
193
+ expect(identityGateAdmissionAt(r.dir, base, "ops")!.refuses(approvals("ops")[1])).toMatch(/: @acme\/ops-classes is pinned at 1\.0\.0, and 2\.0\.0 is installed under /);
194
+
195
+ // A class the caller read stands for the pins, as before.
149
196
  const read = identityGateAdmissionAt(r.dir, base, "ops", { classes: [{ name: "operator", role: "operator" }] })!;
150
197
  expect(read.refuses(approvals("ops")[1])).toBeNull();
151
198
  expect(read.refuses(approvals("ops")[0])).toMatch(/not signed/);
152
199
  });
153
200
 
201
+ test("an installed package that supplies no such class refuses as chant does", () => {
202
+ const { r, base, approvals } = setup(operatorPolicy, { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] });
203
+ install(r, plugin("node_modules/@acme/ops-classes", [{ ...OPERATOR, name: "deployer" }]));
204
+ expect(judgeBoth(r, base, approvals).ops![1]).toMatch(/names the class operator, which no pinned package supplies, .*known classes: human, agent, runner, service, deployer/);
205
+ });
206
+
154
207
  test("passed to decideGateApproval as refuses, it counts what chant's wave gate counts", async () => {
155
208
  const { r, base, approvals } = setup((k) => ({
156
209
  ".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\n`,
@@ -18,8 +18,9 @@
18
18
  *
19
19
  * A gate the declaration at base names under `identity.gates` counts only a
20
20
  * sealed approval from its class: {@link identityGateAdmissionAt} reads that
21
- * rule from a bare clone at base and judges by `admitApprover`, the function
22
- * chant's `gateAdmission` judges by (#3862); pass its `refuses` to
21
+ * rule from a bare clone at base, with the principal classes the pinned
22
+ * packages supply read as data (#3888), and judges by `admitApprover`, the
23
+ * function chant's `gateAdmission` judges by (#3862); pass its `refuses` to
23
24
  * {@link decideGateApproval}.
24
25
  *
25
26
  * With `spend` (#3784), an approval a wave already applied under is spent and
@@ -27,14 +28,17 @@
27
28
  * the lines of `_gates/<op>/applied.jsonl` ({@link parseAppliedGateLines}).
28
29
  *
29
30
  * The module reads the ledger lines and the review it is handed; reading
30
- * them is the caller's. It imports no TypeScript, esbuild, zod, lint, fold or
31
- * codegen module, and none of chant's git ledger code, which
31
+ * them is the caller's. It imports no TypeScript, esbuild, zod, ajv, lint,
32
+ * fold or codegen module, no package's code, and none of chant's git ledger
33
+ * code, which
32
34
  * `gate-decision-bundle.test.ts` holds it to. Its rules are chant's own:
33
35
  * `./lifecycle/gate-ledger-select.ts` is what `evaluateGate` decides by, and
34
36
  * `gate-decision.test.ts` decides the same ledgers both ways.
35
37
  */
36
38
 
37
39
  import { spawnSync } from "node:child_process";
40
+ import { readFileSync } from "node:fs";
41
+ import { join, posix } from "node:path";
38
42
  import {
39
43
  isPendingGateExpired,
40
44
  latestPendingGate,
@@ -50,7 +54,8 @@ import {
50
54
  trustConfigProblems,
51
55
  } from "./workspace/trust/trust-files";
52
56
  import { parseJsonText } from "./workspace/jsonc";
53
- import { CORE_CLASSES, admitApprover, type ClassGrant, type GateAdmissionRule } from "./workspace/identity-gate-rule";
57
+ import { CORE_CLASSES, admitApprover, type ClassGrant, type GateAdmissionRule, type PrincipalClass } from "./workspace/identity-gate-rule";
58
+ import { diskFiles, findInstalledPackage, mergePinClasses, parseClassDataByHand, readClassFiles, type PinClassRead } from "./workspace/class-data";
54
59
  import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
55
60
  import type { MergedReview } from "./op/gate-review";
56
61
  import type { GateApprovalSource } from "./op/op-waves";
@@ -207,6 +212,56 @@ export interface IdentityGateAdmission {
207
212
  refuses(approval: SealedGateApproval): string | null;
208
213
  }
209
214
 
215
+ /** The domain classes the pins of the declaration at base supply, as chant's `loadClassRegistry` reads them, and the pins that could not be read. */
216
+ interface PinnedClasses {
217
+ classes: PrincipalClass[];
218
+ /** Pins whose classes are not known, one line each: a package pin that is not installed, or not at its pinned version. */
219
+ unread: string[];
220
+ }
221
+
222
+ /**
223
+ * Read the classes each pin supplies, as chant's `gateAdmission(cwd, gate,
224
+ * base)` reads them: a path pin's `./workspace-principals` file from `base`
225
+ * itself, so a change can't remap a class by editing its own copy of the
226
+ * plugin; a package pin's from the package installed under `packages` (its
227
+ * `node_modules` or a parent's), which must be at the pinned version. Reads
228
+ * data only and imports no package.
229
+ */
230
+ function pinnedClassesAt(repo: string, base: string, pins: unknown, packages: string): PinnedClasses {
231
+ const reads: PinClassRead[] = [];
232
+ const unread: string[] = [];
233
+ (Array.isArray(pins) ? pins : []).forEach((raw: unknown, i) => {
234
+ const pin = (raw !== null && typeof raw === "object" ? raw : {}) as { package?: unknown; version?: unknown; path?: unknown };
235
+ if (typeof pin.path === "string" && pin.path) {
236
+ const dir = posix.normalize(pin.path);
237
+ const files = (path: string) => showAt(repo, base, dir === "." ? path : posix.join(dir, path));
238
+ reads.push({ pin: i, source: pin.path, read: readClassFiles(files, pin.path, parseClassDataByHand) });
239
+ return;
240
+ }
241
+ if (typeof pin.package !== "string" || !pin.package) return;
242
+ const dir = findInstalledPackage(pin.package, packages);
243
+ if (!dir) {
244
+ unread.push(`${pin.package} is not installed under ${packages}`);
245
+ return;
246
+ }
247
+ let installed: unknown;
248
+ try {
249
+ installed = (JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as { version?: unknown }).version;
250
+ } catch {
251
+ installed = undefined;
252
+ }
253
+ if (typeof pin.version === "string" && pin.version && installed !== pin.version) {
254
+ unread.push(`${pin.package} is pinned at ${pin.version}, and ${String(installed ?? "an unknown version")} is installed under ${packages}`);
255
+ return;
256
+ }
257
+ reads.push({ pin: i, source: pin.package, read: readClassFiles(diskFiles(dir), pin.package, parseClassDataByHand) });
258
+ });
259
+ return { classes: mergePinClasses(reads).classes, unread };
260
+ }
261
+
262
+ /** The core classes whose members no domain class can change: every one but `human`, which is the principals in no other class. */
263
+ const PIN_FREE = new Set(CORE_CLASSES.filter((c) => c.role !== null).map((c) => c.name));
264
+
210
265
  /**
211
266
  * The `identity.gates` rule for `gate` at `base`, or null when the
212
267
  * declaration there names none: what chant's `gateAdmission(cwd, gate, base)`
@@ -215,34 +270,39 @@ export interface IdentityGateAdmission {
215
270
  * class, the role grants in `.chant/trust.json` at base put the approver in
216
271
  * it. Both judge with the same function ({@link admitApprover}).
217
272
  *
218
- * The classes are the four core ones, then `classes`, the domain classes the
219
- * caller read from the pinned packages (`./workspace-principals`), in pin
220
- * order. This reader loads no package. A gate whose class is in neither
221
- * refuses every approval, and when the declaration at base pins anything the
222
- * reason says the pins were not read, since one of them may supply it.
273
+ * The classes are the four core ones, then the domain classes the
274
+ * declaration's pins supply, in pin order (#3888), read as chant reads them:
275
+ * a path pin's `./workspace-principals` file at `base`, and a package pin's
276
+ * from the package installed under `packages` (default `repo`) at the
277
+ * pinned version. A bare clone has no `node_modules`, so a caller that reads
278
+ * one passes the directory its packages are installed under. Only data is
279
+ * read; no package is imported. `classes`, when given, stands for every
280
+ * domain class instead and no pin is read.
281
+ *
282
+ * When a pin can't be read (its package is not installed, or not at the
283
+ * pinned version), a gate whose class it could change (a domain class, or
284
+ * `human`) refuses every approval and says which pins were not read; a gate
285
+ * naming `agent`, `runner` or `service`, or no class, is judged as usual.
223
286
  * Throws when the declaration at base cannot be parsed.
224
287
  */
225
288
  export function identityGateAdmissionAt(
226
289
  repo: string,
227
290
  base: string,
228
291
  gate: string,
229
- opts: { classes?: readonly ClassGrant[] } = {},
292
+ opts: { classes?: readonly ClassGrant[]; packages?: string } = {},
230
293
  ): IdentityGateAdmission | null {
231
294
  const declaration = declarationAt(repo, base);
232
295
  const requirement = declaration === null ? undefined : identityGatesOf(declaration.value).find((g) => g.gate === gate);
233
296
  if (!requirement) return null;
234
297
  const { signers, roles } = trustAt(repo, base);
235
298
  const pins = (declaration!.value as { pins?: unknown }).pins;
236
- const rule: GateAdmissionRule = {
237
- gate,
238
- class: requirement.class,
239
- signers,
240
- roles,
241
- classes: [...CORE_CLASSES, ...(opts.classes ?? [])],
242
- ...(Array.isArray(pins) && pins.length > 0
243
- ? { unknownClass: `is not a core class, and this reader does not load the classes the pinned packages in ${declaration!.file} at base supply` }
244
- : {}),
245
- };
299
+ const pinned = opts.classes ? { classes: opts.classes, unread: [] } : pinnedClassesAt(repo, base, pins, opts.packages ?? repo);
300
+ const want = requirement.class;
301
+ if (want !== null && !PIN_FREE.has(want) && pinned.unread.length > 0) {
302
+ const reason = `identity.gates.${gate} names the class ${want}, and the pins in ${declaration!.file} at base could not all be read, so no approval can show it is in that class: ${pinned.unread.join("; ")}`;
303
+ return { requirement, refuses: () => reason };
304
+ }
305
+ const rule: GateAdmissionRule = { gate, class: want, signers, roles, classes: [...CORE_CLASSES, ...pinned.classes] };
246
306
  return { requirement, refuses: (a) => admitApprover(rule, a) };
247
307
  }
248
308