@xemahq/repo-build-tooling 0.26.5 → 0.27.1

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 CHANGED
@@ -34,7 +34,6 @@ pnpm exec xema-scrub-swagger-paths dist
34
34
  | `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
35
35
  | `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
36
36
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
37
- | `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents, and `test` and `openapi:current` (where defined) only on the packages the push changed; a fleet-wide change runs only `lint` and `typecheck` on the full graph; root `lint` only without turbo; a park-only push (`refs/xema-session/*`) skips |
38
37
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
39
38
 
40
39
  `xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.26.5",
3
+ "version": "0.27.1",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
@@ -25,8 +25,6 @@
25
25
  "xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
26
26
  "xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
27
27
  "xema-composed-image": "src/composed-image.mjs",
28
- "xema-install-git-hooks": "src/install-git-hooks.mjs",
29
- "xema-prepush-sanity": "src/prepush-sanity.mjs",
30
28
  "xema-promote-image": "src/promote-image.mjs",
31
29
  "xema-readiness": "src/readiness.mjs",
32
30
  "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
@@ -38,9 +36,7 @@
38
36
  "./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
39
37
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
40
38
  "./composed-image": "./src/composed-image.mjs",
41
- "./install-git-hooks": "./src/install-git-hooks.mjs",
42
39
  "./package.json": "./package.json",
43
- "./prepush-sanity": "./src/prepush-sanity.mjs",
44
40
  "./promote-image": "./src/promote-image.mjs",
45
41
  "./readiness": "./src/readiness.mjs",
46
42
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
@@ -51,9 +47,9 @@
51
47
  "@xemahq/release-policy": "^16.0.0"
52
48
  },
53
49
  "scripts": {
54
- "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs && node --test src/service-image.test.mjs && node --test src/install-git-hooks.test.mjs && node --test src/image-cpu-arch.test.mjs"
50
+ "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs"
55
51
  },
56
52
  "xemaRelease": {
57
- "sourceDigest": "sha256:f0df75e0b53e7d061fca29cca85baadc3e8d20ac245220164fe16d5e3f051adc"
53
+ "sourceDigest": "sha256:65b9132685b9423a89b13ec6d03b21f3c7c66c529c98fbf09ee6a7bd4cde39d9"
58
54
  }
59
55
  }
@@ -1,18 +1,19 @@
1
1
  // `xema-composed-image prisma-check`, INSIDE the image build, on the TARGET arch.
2
2
  // Every member that owns a Prisma schema carries its generated client(s) in
3
3
  // `<member>/node_modules/.prisma/<client>/`, generated on the CI runner and staged
4
- // without running anything. Each client's schema must be the member's own schema as
4
+ // without running anything; non-client generator outputs there (the org-erasure graph)
5
+ // must load and export their names. Each client's schema must be the member's own schema as
5
6
  // `prisma format` writes it, or the client is stale. The image carries no native engine: the only supported
6
7
  // client is Rust-free (a wasm query compiler plus a driver adapter). This refuses any
7
8
  // native engine file, compiles the wasm, checks the client matches the member's
8
9
  // `prisma` CLI, and constructs it. It never connects.
9
10
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
11
  import { createRequire } from 'node:module';
11
- import { join } from 'node:path';
12
+ import { basename, join } from 'node:path';
12
13
 
13
14
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
14
15
  import { readMembers } from './composed-image-members.mjs';
15
- import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
16
+ import { clientSchemaProblems, memberPrismaOutputs, memberPrismaSchema } from './composed-migrate.mjs';
16
17
 
17
18
  const NATIVE_ENGINE = [/^libquery_engine-.*\.node$/, /^query-engine-/, /\.so\.node$/, /\.dll\.node$/, /\.dylib\.node$/];
18
19
  const WASM_COMPILER = /^query_compiler.*_bg\.wasm$/;
@@ -35,16 +36,14 @@ function cliVersion(memberRequire, memberDirectory) {
35
36
  async function schemaVerdicts({ root, imageDir, formatSpawn, concurrency }) {
36
37
  const jobs = [];
37
38
  for (const [, directory] of readMembers({ root, imageDir })) {
39
+ let schema;
38
40
  try {
39
- if (memberPrismaSchema(root, directory) === undefined) continue;
41
+ schema = memberPrismaSchema(root, directory);
40
42
  } catch {
41
43
  continue; // reported by the main pass
42
44
  }
43
- const container = join(root, directory, 'node_modules', '.prisma');
44
- if (!existsSync(container)) continue;
45
- for (const entry of readdirSync(container, { withFileTypes: true }).filter((e) => e.isDirectory())) {
46
- jobs.push({ root, memberDir: directory, clientDir: join(container, entry.name) });
47
- }
45
+ if (schema === undefined) continue;
46
+ for (const clientDir of memberPrismaOutputs(root, directory, schema).clients) jobs.push({ root, memberDir: directory, clientDir });
48
47
  }
49
48
  const verdicts = await clientSchemaProblems(jobs, { spawn: formatSpawn, concurrency });
50
49
  return new Map(jobs.map((job, i) => [job.clientDir, verdicts[i]]));
@@ -75,10 +74,22 @@ export async function prismaCheck({ root = process.cwd(), imageDir = COMPOSED_IM
75
74
  continue;
76
75
  }
77
76
  const memberRequire = createRequire(join(memberDirectory, 'package.json'));
78
- for (const name of dirs) {
77
+ const outputs = memberPrismaOutputs(root, directory, schema);
78
+ for (const problem of outputs.problems) problems.push(`${key}: ${problem}`);
79
+ // A non-client artifact (e.g. the org-erasure graph) is imported at boot: it must load and export its names.
80
+ for (const { dir, kind, exports } of outputs.artifacts) {
81
+ const label = `${key} (.prisma/${basename(dir)})`;
82
+ try {
83
+ const module = memberRequire(dir);
84
+ const absent = exports.filter((name) => module?.[name] === undefined);
85
+ if (absent.length > 0) problems.push(`${label}: the ${kind} does not export ${absent.join(', ')}`);
86
+ } catch (error) {
87
+ problems.push(`${label}: the ${kind} does not load: ${error?.message ?? String(error)}`);
88
+ }
89
+ }
90
+ for (const clientDirectory of outputs.clients) {
79
91
  clients += 1;
80
- const label = `${key} (.prisma/${name})`;
81
- const clientDirectory = join(container, name);
92
+ const label = `${key} (.prisma/${basename(clientDirectory)})`;
82
93
  const staleProblem = stale.get(clientDirectory);
83
94
  if (staleProblem !== undefined) {
84
95
  problems.push(`${label}: ${staleProblem}`);
@@ -38,3 +38,21 @@ test('a stale client (the source gained a field after generation) is refused; th
38
38
  await assert.rejects(prismaCheck({ root: stale, log() {}, formatSpawn: formatNoop }), /differs from the member's prisma\/schema\.prisma .*stale/);
39
39
  await assert.rejects(prismaCheck({ root: workspace(), log() {}, formatSpawn: formatNoop }), (e) => !/stale/.test(e.message));
40
40
  });
41
+
42
+ test('an org-erasure graph is not treated as a client: it must load and export orgErasureGraph', async () => {
43
+ const schema =
44
+ 'generator client {\n provider = "prisma-client-js"\n output = "../node_modules/.prisma/alpha-db"\n}\n\n' +
45
+ 'generator orgErasureGraph {\n provider = "xema-org-erasure-graph"\n output = "../node_modules/.prisma/alpha-db-org-erasure-graph"\n}\n\ndatasource db {}\n';
46
+ const graph = (root, js) => {
47
+ write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', schema);
48
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', schema);
49
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.js', js);
50
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.d.ts', '');
51
+ return root;
52
+ };
53
+ // The fixture client has no wasm compiler, so the check fails, but only on the client, never on the graph.
54
+ await assert.rejects(prismaCheck({ root: graph(workspace(), 'exports.orgErasureGraph = {};\n'), log() {}, formatSpawn: formatNoop }), (e) =>
55
+ /\.prisma\/alpha-db\): no query_compiler/.test(e.message) && !/org-erasure-graph/.test(e.message));
56
+ await assert.rejects(prismaCheck({ root: graph(workspace(), 'exports.other = 1;\n'), log() {}, formatSpawn: formatNoop }),
57
+ /\.prisma\/alpha-db-org-erasure-graph\): the org-erasure graph does not export orgErasureGraph/);
58
+ });
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
7
7
  // `--also <pkg>` adds that package's closure (no member checks)
8
- // <dir>/out/<pkg dir>/node_modules/.prisma/ the generated Prisma clients of every closure root that has
8
+ // <dir>/out/<pkg dir>/node_modules/.prisma/ the generated Prisma clients (and artifacts such as the org-erasure graph) of every closure root that has
9
9
  // `prisma/schema.prisma` (members and `--also` roots alike)
10
10
  // <dir>/tool/ this package (package.json + src, no tests):
11
11
  // bind-mounted into the build, never copied into a layer
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
18
18
 
19
19
  import { COMPOSED_IMAGE_DIR, NODE_MAJOR_LABEL } from './composed-image-paths.mjs';
20
20
  import { memberClosure, readMembers } from './composed-image-members.mjs';
21
- import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
21
+ import { clientSchemaProblems, memberPrismaOutputs, memberPrismaSchema } from './composed-migrate.mjs';
22
22
 
23
23
  export const DEFAULT_OUTPUTS = Object.freeze(['dist']);
24
24
  const TOOL_PACKAGE = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -114,9 +114,11 @@ export async function planBuildOutputs({
114
114
  const hasClient = existsSync(clients) && readdirSync(clients, { withFileTypes: true }).some((e) => e.isDirectory());
115
115
  if (schema !== undefined && !hasClient) problems.push(`${key}: ${directory} has prisma/schema.prisma but no node_modules/.prisma/<client>/ — run prisma generate`);
116
116
  if (schema !== undefined && hasClient) {
117
- for (const entry of readdirSync(clients, { withFileTypes: true }).filter((e) => e.isDirectory())) {
118
- schemaJobs.push({ key, root, memberDir: directory, clientDir: join(clients, entry.name) });
119
- }
117
+ // Each folder by what generated it: clients are checked against the schema, other artifacts
118
+ // (e.g. the org-erasure graph the process imports at boot) must be present and complete.
119
+ const prismaOutputs = memberPrismaOutputs(root, directory, schema);
120
+ for (const problem of prismaOutputs.problems) problems.push(`${key}: ${problem}`);
121
+ for (const clientDir of prismaOutputs.clients) schemaJobs.push({ key, root, memberDir: directory, clientDir });
120
122
  }
121
123
  if (schema === undefined && hasClient) problems.push(`${key}: ${directory} ships node_modules/.prisma/<client>/ but has no prisma/schema.prisma`);
122
124
  }
@@ -173,3 +173,44 @@ test('--also refuses a non-member closure root with prisma/schema.prisma but no
173
173
  await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /packages\/sdks\/extra has prisma\/schema\.prisma but no generated client: run prisma generate/);
174
174
  assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
175
175
  });
176
+
177
+ const GRAPH_SCHEMA =
178
+ 'generator client {\n provider = "prisma-client-js"\n output = "../node_modules/.prisma/alpha-db"\n}\n\n' +
179
+ 'generator orgErasureGraph {\n provider = "xema-org-erasure-graph"\n output = "../node_modules/.prisma/alpha-db-org-erasure-graph"\n}\n\ndatasource db {}\n';
180
+
181
+ function withErasureGraph() {
182
+ const root = ready();
183
+ write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', GRAPH_SCHEMA);
184
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', GRAPH_SCHEMA);
185
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.js', 'exports.orgErasureGraph = {};\n');
186
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.d.ts', 'export declare const orgErasureGraph: unknown;\n');
187
+ return root;
188
+ }
189
+
190
+ test('stages a member with a Prisma client and an org-erasure graph, carrying the graph', async () => {
191
+ const root = withErasureGraph();
192
+ await stage(root);
193
+ const staged = files(path.join(root, '.xema-stage/out'));
194
+ assert.ok(staged.includes('biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.js'));
195
+ assert.ok(staged.includes('biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/index.js'));
196
+ });
197
+
198
+ test('a client without its schema still fails beside an org-erasure graph; a missing or incomplete graph fails clearly', async () => {
199
+ const noClientSchema = withErasureGraph();
200
+ fs.rmSync(path.join(noClientSchema, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma'));
201
+ await assert.rejects(stage(noClientSchema), (e) => /\.prisma\/alpha-db: carries no schema\.prisma/.test(e.message) && !/org-erasure-graph/.test(e.message));
202
+ const missing = withErasureGraph();
203
+ fs.rmSync(path.join(missing, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph'), { recursive: true });
204
+ await assert.rejects(stage(missing), /generator orgErasureGraph \(provider "xema-org-erasure-graph"\): node_modules\/\.prisma\/alpha-db-org-erasure-graph is missing — run prisma generate/);
205
+ const incomplete = withErasureGraph();
206
+ fs.rmSync(path.join(incomplete, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db-org-erasure-graph/index.d.ts'));
207
+ await assert.rejects(stage(incomplete), /the org-erasure graph node_modules\/\.prisma\/alpha-db-org-erasure-graph lacks index\.d\.ts/);
208
+ });
209
+
210
+ test('refuses a generator of an unknown provider writing into node_modules/.prisma', async () => {
211
+ const root = withErasureGraph();
212
+ const schema = `${GRAPH_SCHEMA}generator other {\n provider = "some-generator"\n output = "../node_modules/.prisma/alpha-db-other"\n}\n`;
213
+ write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', schema);
214
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', schema);
215
+ await assert.rejects(stage(root), /generator other \(provider "some-generator"\) writes node_modules\/\.prisma\/alpha-db-other, a kind the composed image does not know/);
216
+ });
@@ -11,9 +11,9 @@
11
11
  // platform migration runner drops it the same way). Exits with the child's code.
12
12
  // This file is also the single home of `memberPrismaSchema`, which the build tool imports.
13
13
  import { spawn as spawnAsync, spawnSync } from 'node:child_process';
14
- import { copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
14
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
15
15
  import { availableParallelism, tmpdir } from 'node:os';
16
- import { dirname, join, resolve } from 'node:path';
16
+ import { basename, dirname, join, resolve } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
 
19
19
  /**
@@ -42,6 +42,75 @@ export function memberPrismaSchema(root, memberDir) {
42
42
  return existsSync(schema) ? schema : undefined;
43
43
  }
44
44
 
45
+ /**
46
+ * Generators that write into `node_modules/.prisma/` something that is NOT a Prisma client,
47
+ * keyed by provider: what the output is, the files it must carry and the names its module
48
+ * must export. The process imports these at boot, so a missing one is a stage failure.
49
+ */
50
+ export const PRISMA_ARTIFACT_GENERATORS = Object.freeze({
51
+ 'xema-org-erasure-graph': Object.freeze({ kind: 'org-erasure graph', files: ['index.js', 'index.d.ts'], exports: ['orgErasureGraph'] }),
52
+ });
53
+ const PRISMA_CLIENT_PROVIDERS = new Set(['prisma-client-js', 'prisma-client']);
54
+
55
+ /**
56
+ * The schema's generator blocks: `[{ name, provider, output }]`. `output` is the absolute
57
+ * directory (resolved against the schema's directory, as Prisma does), or undefined when the
58
+ * block sets none or not as a string literal (e.g. `env(...)`).
59
+ */
60
+ export function schemaGenerators(schemaPath) {
61
+ const text = readFileSync(schemaPath, 'utf8').replace(/^\s*\/\/.*$/gm, '');
62
+ const generators = [];
63
+ for (const block of text.matchAll(/^\s*generator\s+(\w+)\s*\{([^}]*)\}/gm)) {
64
+ const provider = /^\s*provider\s*=\s*"([^"]*)"/m.exec(block[2])?.[1];
65
+ const output = /^\s*output\s*=\s*"([^"]*)"/m.exec(block[2])?.[1];
66
+ generators.push({ name: block[1], provider, output: output === undefined ? undefined : resolve(dirname(schemaPath), output) });
67
+ }
68
+ return generators;
69
+ }
70
+
71
+ /**
72
+ * Sort a member's `node_modules/.prisma/<dir>/` folders by the generator that wrote them, read
73
+ * from the member's schema: `{ clients, artifacts, problems }`. A folder a client generator
74
+ * declares, or one no generator declares, is a Prisma client (`clients`: absolute dirs, to be
75
+ * checked against the schema). A folder a `PRISMA_ARTIFACT_GENERATORS` provider declares is that
76
+ * artifact (`artifacts`: `{ dir, provider, kind, exports }`) and must carry its files. A declared
77
+ * output that is absent, or a generator of an unknown provider writing into `.prisma/`, is a problem.
78
+ */
79
+ export function memberPrismaOutputs(root, memberDir, schemaPath) {
80
+ const container = join(resolve(root, memberDir), 'node_modules', '.prisma');
81
+ const present = new Set(
82
+ existsSync(container) ? readdirSync(container, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(container, e.name)) : [],
83
+ );
84
+ const declared = new Map(); // dir -> provider
85
+ const problems = [];
86
+ for (const { name, provider, output } of schemaGenerators(schemaPath)) {
87
+ if (output === undefined || dirname(output) !== container) continue;
88
+ const label = `${memberDir}: generator ${name} (provider "${provider}")`;
89
+ if (!PRISMA_CLIENT_PROVIDERS.has(provider) && PRISMA_ARTIFACT_GENERATORS[provider] === undefined) {
90
+ problems.push(`${label} writes node_modules/.prisma/${basename(output)}, a kind the composed image does not know`);
91
+ continue;
92
+ }
93
+ declared.set(output, provider);
94
+ if (!present.has(output)) problems.push(`${label}: node_modules/.prisma/${basename(output)} is missing — run prisma generate`);
95
+ }
96
+ const clients = [];
97
+ const artifacts = [];
98
+ for (const dir of [...present].sort()) {
99
+ const spec = PRISMA_ARTIFACT_GENERATORS[declared.get(dir)];
100
+ if (spec === undefined) {
101
+ clients.push(dir);
102
+ continue;
103
+ }
104
+ const missing = spec.files.filter((file) => !existsSync(join(dir, file)));
105
+ if (missing.length > 0) {
106
+ problems.push(`${memberDir}: the ${spec.kind} node_modules/.prisma/${basename(dir)} lacks ${missing.join(', ')} — run prisma generate`);
107
+ continue;
108
+ }
109
+ artifacts.push({ dir, provider: declared.get(dir), kind: spec.kind, exports: spec.exports });
110
+ }
111
+ return { clients, artifacts, problems };
112
+ }
113
+
45
114
  /** Async process run with collected output: `{ status, stdout, stderr }` or `{ error }`. */
46
115
  function runAsync(command, args, options) {
47
116
  return new Promise((done) => {
@@ -129,7 +129,7 @@
129
129
 
130
130
  import { createRequire } from 'node:module';
131
131
  import { readFileSync, realpathSync } from 'node:fs';
132
- import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
132
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
133
133
  import { tmpdir } from 'node:os';
134
134
  import { dirname, join, resolve } from 'node:path';
135
135
  import process from 'node:process';
@@ -168,14 +168,14 @@ const FLAT_LEAK_REGEX = new RegExp(
168
168
  // and down into the contracts package. There is no `node_modules/` segment
169
169
  // so shapes 1 and 2 above never fire.
170
170
  // Capture group 1: relative path from the scrubbed file to the package directory.
171
- // Capture group 2: first path segment after dist/ = the subpath export name.
171
+ // Capture group 2: everything after dist/ (the physical subpath inside the package).
172
172
  // The public package name is NOT derived from the directory name or a fixed
173
173
  // scope: it is read from the target package's own package.json `name`, so
174
174
  // unscoped and differently-named workspace packages resolve correctly.
175
175
  // Scoped to paths that pass through `packages/`, `biomes/`, or `kernel/` to
176
176
  // avoid false-positive matches on intra-service relative requires.
177
177
  const RELATIVE_WORKSPACE_REGEX = new RegExp(
178
- `require\\("((?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*[a-z0-9][a-z0-9.-]*)/dist/([a-z0-9][a-z0-9-]*)(?:/[^"]*)?"\\)`,
178
+ `require\\("((?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*[a-z0-9][a-z0-9.-]*)/dist/([a-z0-9][a-z0-9-]*(?:/[^"]*)?)"\\)`,
179
179
  'g',
180
180
  );
181
181
 
@@ -323,6 +323,7 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
323
323
  * @param {string} pkg e.g. `@xemahq/kernel-contracts`
324
324
  * @param {string} subpath physical path inside the package, e.g. `dist/agent-composition/lib/x`
325
325
  * @param {string} original the full leaked specifier, for the proof and the error
326
+ * @param {string} [importer] the file holding the require, named in the refusal
326
327
  */
327
328
  // NUL is the separator in the cache key because it cannot occur in a package
328
329
  // name or a subpath, so no two distinct specifiers can collide on a
@@ -332,7 +333,7 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
332
333
  // silently, and a file no search can read is a file no audit, no review and no
333
334
  // boundary check covers. Enforced by the aggregator boundary gate
334
335
  // `grep-readable-sources`.
335
- function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
336
+ function publicSpecifierFor(resolveFrom, pkg, subpath, original, importer) {
336
337
  const cacheKey = `${pkg}\u0000${subpath}`;
337
338
  if (specifierCache.has(cacheKey)) return specifierCache.get(cacheKey);
338
339
 
@@ -412,6 +413,7 @@ function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
412
413
  throw new ScrubFailure(
413
414
  `no public specifier of ${pkg} resolves to the same file as\n` +
414
415
  ` ${original}\n` +
416
+ (importer ? ` required by ${importer}\n` : '') +
415
417
  ` tried: ${candidates.length > 0 ? candidates.join(', ') : '(none — the exports map admits no matching form)'}\n` +
416
418
  ` Add the needed entry to that package's "exports", or export the symbol from a\n` +
417
419
  ` path that is public. Rewriting to a specifier that resolves elsewhere would\n` +
@@ -586,10 +588,16 @@ async function scrubFile(path) {
586
588
  ` rather than reaching through dist/.`,
587
589
  );
588
590
  }
589
- out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, subpath) => {
591
+ out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, rest) => {
590
592
  count += 1;
591
593
  const name = packageNameAt(resolve(dirname(path), relPackageDir), match);
592
- return `require("${name}/${subpath}")`;
594
+ // Through the target's own `exports` map, never a hardcoded `<name>/<seg>`:
595
+ // a package that exports only "." has no such subpath, and the old form
596
+ // crashed at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED. When the root
597
+ // barrel re-exports the file, the root is the proved public form; when no
598
+ // exported subpath reaches it, the build refuses.
599
+ const original = match.slice('require("'.length, -2);
600
+ return `require("${publicSpecifierFor(resolveFrom, name, `dist/${rest}`, original, path)}")`;
593
601
  });
594
602
  if (count > 0) {
595
603
  await writeFile(path, out, 'utf8');
@@ -712,6 +720,13 @@ async function expectFailure(label, fn) {
712
720
  return threw;
713
721
  }
714
722
 
723
+ /** Make `name` resolvable from `consumerDir` the way a workspace install does. */
724
+ async function linkWorkspacePackage(consumerDir, name, packageDir) {
725
+ const link = join(consumerDir, 'node_modules', name);
726
+ await mkdir(dirname(link), { recursive: true });
727
+ await symlink(packageDir, link, 'dir');
728
+ }
729
+
715
730
  async function selfTest() {
716
731
  const root = await mkdtemp(join(tmpdir(), 'scrub-swagger-selftest-'));
717
732
  try {
@@ -830,11 +845,18 @@ async function selfTest() {
830
845
  ['packages/contracts/plain-dir', 'plain-contract'],
831
846
  ['packages/contracts/other-dir', '@xemahq/renamed'],
832
847
  ];
848
+ await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
833
849
  for (const [dir, name] of pkgs) {
834
850
  await mkdir(join(ws, dir, 'dist/lib'), { recursive: true });
835
- await writeFile(join(ws, dir, 'package.json'), JSON.stringify({ name }), 'utf8');
851
+ await writeFile(
852
+ join(ws, dir, 'package.json'),
853
+ JSON.stringify({ name, exports: { './*': './dist/*/index.js' } }),
854
+ 'utf8',
855
+ );
856
+ await writeFile(join(ws, dir, 'dist/lib/index.js'), 'module.exports = require("./x");\n', 'utf8');
857
+ await writeFile(join(ws, dir, 'dist/lib/x.js'), 'module.exports = {};\n', 'utf8');
858
+ await linkWorkspacePackage(join(ws, 'services/svc'), name, join(ws, dir));
836
859
  }
837
- await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
838
860
  const wsFile = join(ws, 'services/svc/dist/dto.js');
839
861
  const relOf = (dir) => `../../../${dir}/dist/lib/x`;
840
862
  await writeFile(
@@ -851,6 +873,42 @@ async function selfTest() {
851
873
  throw new Error(`self-test: relative-workspace rewrite missing ${expected}: ${wsAfter}`);
852
874
  }
853
875
  }
876
+
877
+ // 4c. A package that exports ONLY "." (xema-base authorization-nest). The
878
+ // swagger plugin requires the enum's declaring file; `<name>/<file>` is not
879
+ // exported and crashed at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED.
880
+ // - root barrel re-exports the file -> the root is the proved form;
881
+ // - no exported subpath reaches the file -> the build refuses, naming the
882
+ // package, the file and the importing module.
883
+ const dot = join(root, 'dot');
884
+ const dotPkg = join(dot, 'packages/sdks/dot-only');
885
+ await mkdir(join(dotPkg, 'dist'), { recursive: true });
886
+ await mkdir(join(dot, 'svc/dist'), { recursive: true });
887
+ await writeFile(
888
+ join(dotPkg, 'package.json'),
889
+ JSON.stringify({ name: '@xemahq/dot-only', exports: { '.': './dist/index.js' } }),
890
+ 'utf8',
891
+ );
892
+ await writeFile(join(dotPkg, 'dist/index.js'), 'module.exports = require("./grant-wire");\n', 'utf8');
893
+ await writeFile(join(dotPkg, 'dist/grant-wire.js'), 'module.exports = { Kind: {} };\n', 'utf8');
894
+ await writeFile(join(dotPkg, 'dist/internal-only.js'), 'module.exports = { Hidden: {} };\n', 'utf8');
895
+ await linkWorkspacePackage(join(dot, 'svc'), '@xemahq/dot-only', dotPkg);
896
+ const dotDto = (file) =>
897
+ `class Dto {\n static _OPENAPI_METADATA_FACTORY() {\n return { kind: { type: () => require("../../packages/sdks/dot-only/dist/${file}").Kind } };\n }\n}\n`;
898
+ const dotFile = join(dot, 'svc/dist/dto.js');
899
+ await writeFile(dotFile, dotDto('grant-wire'), 'utf8');
900
+ await run([join(dot, 'svc/dist')]);
901
+ const dotAfter = await readFile(dotFile, 'utf8');
902
+ if (!dotAfter.includes('require("@xemahq/dot-only").Kind') || dotAfter.includes('dot-only/')) {
903
+ throw new Error(`self-test: a "."-only package must map to its root, got: ${dotAfter}`);
904
+ }
905
+ await writeFile(dotFile, dotDto('internal-only'), 'utf8');
906
+ const dotRefusal = await expectFailure('unexported file no exported subpath reaches', () => run([join(dot, 'svc/dist')]));
907
+ for (const named of ['@xemahq/dot-only', 'internal-only', dotFile]) {
908
+ if (!dotRefusal.message.includes(named)) {
909
+ throw new Error(`self-test: the refusal must name ${named}; got: ${dotRefusal.message}`);
910
+ }
911
+ }
854
912
  // A target directory without its own package.json is a hard failure.
855
913
  const orphan = join(root, 'orphan');
856
914
  await mkdir(join(orphan, 'svc/dist'), { recursive: true });