@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 +0 -1
- package/package.json +3 -7
- package/src/composed-image-prisma-check.mjs +23 -12
- package/src/composed-image-prisma-check.test.mjs +18 -0
- package/src/composed-image-stage.mjs +7 -5
- package/src/composed-image-stage.test.mjs +41 -0
- package/src/composed-migrate.mjs +71 -2
- package/src/scrub-swagger-plugin-paths.mjs +66 -8
- package/src/install-git-hooks.mjs +0 -323
- package/src/install-git-hooks.test.mjs +0 -85
- package/src/prepush-sanity.mjs +0 -473
- package/src/prepush-sanity.test.mjs +0 -205
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.
|
|
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/
|
|
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:
|
|
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
|
|
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
|
-
|
|
41
|
+
schema = memberPrismaSchema(root, directory);
|
|
40
42
|
} catch {
|
|
41
43
|
continue; // reported by the main pass
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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/${
|
|
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
|
-
|
|
118
|
-
|
|
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
|
+
});
|
package/src/composed-migrate.mjs
CHANGED
|
@@ -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:
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 });
|