@xemahq/repo-build-tooling 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@ pnpm exec xema-scrub-swagger-paths dist
27
27
  | Bin | What it does |
28
28
  | --- | --- |
29
29
  | `xema-readiness` | Runs the ordered checks between a source change and a push |
30
- | `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
30
+ | `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift. Also derives each component's boot-required environment and edition delivery from its entry source (`oci-default` gives `src/main.ts`, `node-module` `dist/<x>` gives `src/<x>.ts`) with `@xemahq/peer-analysis`, writes `src/generated/<component>.component-facts.generated.json`, and inlines the same `bootEnv` (and `editionDelivery`, when reached) in the descriptor |
31
31
  | `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
32
32
  | `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `stage-tree`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
33
33
  | `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The stage reaches the build as the named context `xema-service` (`--build-context xema-service=.xema-stage`); the authoritative name is `STAGED_SERVICE_CONTEXT` in `@xemahq/distribution-source-hash`, whose image hash frames such a service from its tracked tree, closure, turbo globalDependencies and tsconfig chain, so the stage must be built from no other repository input. The final stage only COPYs |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
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)",
@@ -46,13 +46,14 @@
46
46
  "./service-image": "./src/service-image.mjs"
47
47
  },
48
48
  "dependencies": {
49
- "@xemahq/contracts": "^0.118.1",
50
- "@xemahq/release-policy": "^16.0.0"
49
+ "@xemahq/contracts": "^0.119.0",
50
+ "@xemahq/release-policy": "^16.0.0",
51
+ "@xemahq/peer-analysis": "^0.3.0"
51
52
  },
52
53
  "scripts": {
53
- "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-class.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 && node --test src/staged-image-proof.test.mjs && node --test src/effective-input.test.mjs"
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-class.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-component-facts.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/canonical-hidden-hoist.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 && node --test src/staged-image-proof.test.mjs && node --test src/turbo-config-projection.test.mjs && node --test src/effective-input.test.mjs && node --test src/effective-input-deployed-files.test.mjs"
54
55
  },
55
56
  "xemaRelease": {
56
- "sourceDigest": "sha256:a1e1068eca88fad502417b887f16c732ecf0c9ed0ca339849880ea780aa894d4"
57
+ "sourceDigest": "sha256:561f8561b0ff23023de261c59ca6f4067fc05f585edda41117a75f6d3d3f60e1"
57
58
  }
58
59
  }
@@ -0,0 +1,138 @@
1
+ // pnpm's hidden hoist (`node_modules/.pnpm/node_modules/<alias>`) is where a package in the virtual store resolves a
2
+ // dependency it does not declare. When several versions of one alias are installed, the version pnpm 10.33 links there
3
+ // is not a function of the lockfile: its hoister gives each package the depth at which a depth-first walk FIRST reaches
4
+ // it, and the walk starts from the importers in their selection order (the `--filter` order among them), so the same
5
+ // lockfile hoists `signal-exit@4.1.0` in one install and `signal-exit@3.0.7` in another. It also records an alias twice
6
+ // when its first candidate is skipped for the platform, and links both concurrently: the later write wins.
7
+ //
8
+ // The hoist stays on. Every development install has it, so a registry package that requires an undeclared dependency
9
+ // works there; an image without it would fail only in production, at the first such require. Instead, after the
10
+ // install, each hidden-hoist link into the virtual store is re-pointed at ONE candidate chosen from the installed tree
11
+ // alone: of the packages that depend on the alias, the nearest to the importers (an importer is depth -1, a store
12
+ // package its SHORTEST distance from the importers' direct dependencies), ties broken by the dependent's path. That is
13
+ // pnpm's rule without the walk-order dependence. A link outside the virtual store (a workspace project) is pnpm's
14
+ // hoisting of a unique name and is left alone.
15
+ //
16
+ // `.pnpm/node_modules/.bin` is removed: its shims embed the version pnpm picked, and nothing in an image runs them (the
17
+ // staged install runs no scripts, the image starts `node` directly, and Prisma runs from its dependent's own `.bin`).
18
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
19
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
20
+
21
+ import { lockSections } from './effective-input.mjs';
22
+
23
+ const isDirectory = (path) => lstatSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
24
+
25
+ /** `[alias, linkPath]` of every symlink directly in a modules directory, scoped ones included, sorted by alias. */
26
+ function modulesLinks(modulesDir) {
27
+ const found = [];
28
+ if (!isDirectory(modulesDir)) return found;
29
+ for (const entry of readdirSync(modulesDir, { withFileTypes: true })) {
30
+ if (entry.isSymbolicLink()) found.push([entry.name, join(modulesDir, entry.name)]);
31
+ else if (entry.isDirectory() && entry.name.startsWith('@')) {
32
+ for (const scoped of readdirSync(join(modulesDir, entry.name), { withFileTypes: true })) {
33
+ if (scoped.isSymbolicLink()) found.push([`${entry.name}/${scoped.name}`, join(modulesDir, entry.name, scoped.name)]);
34
+ }
35
+ }
36
+ }
37
+ return found.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
38
+ }
39
+
40
+ const linkTarget = (linkPath) => resolve(dirname(linkPath), readlinkSync(linkPath));
41
+
42
+ /** The virtual-store entry (`<name>@<version>…` directory) a path lies in, or undefined outside the store. */
43
+ function storeEntryOf(store, path) {
44
+ const rel = relative(store, path);
45
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return undefined;
46
+ const entry = rel.split(sep)[0];
47
+ return entry === 'node_modules' ? undefined : entry;
48
+ }
49
+
50
+ const compare = (a, b) => a.depth - b.depth || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0) || (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0);
51
+
52
+ /**
53
+ * The hidden-hoist links of the install at `treeRoot` (which holds its `pnpm-lock.yaml`) that do not point at their
54
+ * canonical candidate: `[{ alias, linkPath, current, canonical }]` (absolute directories). Reads only.
55
+ */
56
+ export function hiddenHoistPlan(treeRoot) {
57
+ const store = join(treeRoot, 'node_modules', '.pnpm');
58
+ const hoistDir = join(store, 'node_modules');
59
+ if (!isDirectory(hoistDir)) return [];
60
+
61
+ // dependents: alias -> [{ depth, key, dir }]; edges: store entry -> store entries it links.
62
+ const dependents = new Map();
63
+ const depend = (alias, candidate) => {
64
+ if (!dependents.has(alias)) dependents.set(alias, []);
65
+ dependents.get(alias).push(candidate);
66
+ };
67
+ const edges = new Map();
68
+ const entries = readdirSync(store, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules').map((e) => e.name).sort();
69
+ for (const entry of entries) {
70
+ const children = [];
71
+ for (const [alias, linkPath] of modulesLinks(join(store, entry, 'node_modules'))) {
72
+ const dir = linkTarget(linkPath);
73
+ const child = storeEntryOf(store, dir);
74
+ if (child === undefined) continue;
75
+ children.push({ alias, dir, child });
76
+ }
77
+ edges.set(entry, children);
78
+ }
79
+
80
+ const importers = Object.keys(lockSections(readFileSync(join(treeRoot, 'pnpm-lock.yaml'), 'utf8')).importers ?? {})
81
+ .filter((id) => isDirectory(join(treeRoot, id, 'node_modules')))
82
+ .sort();
83
+ const depth = new Map();
84
+ let frontier = [];
85
+ for (const id of importers) {
86
+ for (const [alias, linkPath] of modulesLinks(join(treeRoot, id, 'node_modules'))) {
87
+ const dir = linkTarget(linkPath);
88
+ const child = storeEntryOf(store, dir);
89
+ if (child === undefined) continue;
90
+ depend(alias, { depth: -1, key: id, dir });
91
+ if (!depth.has(child)) {
92
+ depth.set(child, 0);
93
+ frontier.push(child);
94
+ }
95
+ }
96
+ }
97
+ for (let level = 1; frontier.length > 0; level += 1) {
98
+ const next = [];
99
+ for (const entry of frontier) {
100
+ for (const { child } of edges.get(entry) ?? []) {
101
+ if (!depth.has(child)) {
102
+ depth.set(child, level);
103
+ next.push(child);
104
+ }
105
+ }
106
+ }
107
+ frontier = next;
108
+ }
109
+ for (const [entry, children] of edges) {
110
+ for (const { alias, dir } of children) depend(alias, { depth: depth.get(entry) ?? Number.POSITIVE_INFINITY, key: entry, dir });
111
+ }
112
+
113
+ const plan = [];
114
+ for (const [alias, linkPath] of modulesLinks(hoistDir)) {
115
+ const current = linkTarget(linkPath);
116
+ if (storeEntryOf(store, current) === undefined) continue;
117
+ const candidates = dependents.get(alias);
118
+ if (candidates === undefined) continue;
119
+ const { dir } = [...candidates].sort(compare)[0];
120
+ if (dir !== current) plan.push({ alias, linkPath, current, canonical: dir });
121
+ }
122
+ return plan;
123
+ }
124
+
125
+ /**
126
+ * Re-point every hidden-hoist link of the install at `treeRoot` at its canonical candidate (`hiddenHoistPlan`) and
127
+ * remove the hidden `.bin`. Returns the number of links re-pointed. A tree without a virtual store is left as it is.
128
+ */
129
+ export function canonicalizeHiddenHoist(treeRoot) {
130
+ const plan = hiddenHoistPlan(treeRoot);
131
+ for (const { linkPath, canonical } of plan) {
132
+ unlinkSync(linkPath);
133
+ symlinkSync(relative(dirname(linkPath), canonical), linkPath);
134
+ }
135
+ const bin = join(treeRoot, 'node_modules', '.pnpm', 'node_modules', '.bin');
136
+ if (existsSync(bin)) rmSync(bin, { recursive: true, force: true });
137
+ return plan.length;
138
+ }
@@ -0,0 +1,133 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import test from 'node:test';
9
+
10
+ import { canonicalizeHiddenHoist } from './canonical-hidden-hoist.mjs';
11
+ import { installClosure, injectFlags } from './composed-image-install.mjs';
12
+
13
+ // Real pnpm, no registry: `file:` packages only. `sig` is installed at 1.0.0 (under zzz-r) and 2.0.0 (under aaa-s).
14
+ // @t/a -> pa -> a1 -> a2 -> { zzz-r, aaa-s } @t/b -> pb -> b1 -> zzz-r
15
+ // zzz-r is two hops from @t/b's direct dependency and three from @t/a's, so the canonical choice is sig@1. Which one
16
+ // pnpm 10.33 itself hoists is not a function of the lockfile: on macOS it usually follows the --filter order (sig@2
17
+ // with @t/a first, sig@1 with @t/b first) but not always, and on Linux it picks sig@2 in either order. The test does
18
+ // not depend on that: it links each tree's hidden hoist at a different candidate itself, right after pnpm's install.
19
+ function fixture() {
20
+ const treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-hidden-hoist-'));
21
+ const write = (file, content) => {
22
+ fs.mkdirSync(path.dirname(path.join(treeRoot, file)), { recursive: true });
23
+ fs.writeFileSync(path.join(treeRoot, file), typeof content === 'string' ? content : JSON.stringify(content));
24
+ };
25
+ const vendor = (dir, name, version, deps = {}) => {
26
+ const dependencies = Object.fromEntries(Object.entries(deps).map(([dep, target]) => [dep, `file:../${target}`]));
27
+ write(`vendor/${dir}/package.json`, { name, version, main: 'index.js', dependencies });
28
+ write(`vendor/${dir}/index.js`, `module.exports = ${JSON.stringify(`${name}@${version}`)};\n`);
29
+ };
30
+ write('package.json', { name: 'root', private: true });
31
+ write('pnpm-workspace.yaml', "packages:\n - 'apps/*'\n");
32
+ vendor('sig-v1', 'sig', '1.0.0');
33
+ vendor('sig-v2', 'sig', '2.0.0');
34
+ vendor('zzz-r', 'zzz-r', '1.0.0', { sig: 'sig-v1' });
35
+ vendor('aaa-s', 'aaa-s', '1.0.0', { sig: 'sig-v2' });
36
+ vendor('a2', 'a2', '1.0.0', { 'zzz-r': 'zzz-r', 'aaa-s': 'aaa-s' });
37
+ vendor('a1', 'a1', '1.0.0', { a2: 'a2' });
38
+ vendor('pa', 'pa', '1.0.0', { a1: 'a1' });
39
+ vendor('b1', 'b1', '1.0.0', { 'zzz-r': 'zzz-r' });
40
+ vendor('pb', 'pb', '1.0.0', { b1: 'b1' });
41
+ write('apps/a/package.json', { name: '@t/a', version: '1.0.0', dependencies: { pa: 'file:../../vendor/pa' } });
42
+ write('apps/b/package.json', { name: '@t/b', version: '1.0.0', dependencies: { pb: 'file:../../vendor/pb' } });
43
+ execFileSync('pnpm', ['install', '--lockfile-only', '--offline', ...injectFlags()], { cwd: treeRoot, stdio: 'ignore' });
44
+ return treeRoot;
45
+ }
46
+
47
+ // CI=true as on the runner, where pnpm keeps a modules directory it would otherwise refuse to reuse without a TTY.
48
+ const exec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'pipe', env: { ...process.env, CI: 'true' } });
49
+
50
+ /** Every path under `root` (pnpm's per-run state files aside, as the stage removes them): link target, file hash or dir. */
51
+ function snapshot(root) {
52
+ const entries = {};
53
+ const walk = (rel) => {
54
+ for (const name of fs.readdirSync(path.join(root, rel)).sort()) {
55
+ const child = path.join(rel, name);
56
+ if (['.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(name)) continue;
57
+ const stat = fs.lstatSync(path.join(root, child));
58
+ if (stat.isSymbolicLink()) entries[child] = `link ${fs.readlinkSync(path.join(root, child))}`;
59
+ else if (stat.isDirectory()) {
60
+ entries[child] = 'dir';
61
+ walk(child);
62
+ } else entries[child] = `file ${createHash('sha256').update(fs.readFileSync(path.join(root, child))).digest('hex')}`;
63
+ }
64
+ };
65
+ walk('');
66
+ return entries;
67
+ }
68
+
69
+ const storePackage = (treeRoot, name) => {
70
+ const store = path.join(treeRoot, 'node_modules', '.pnpm');
71
+ const entry = fs.readdirSync(store).find((e) => e.startsWith(`${name}@`));
72
+ return path.join(store, entry, 'node_modules', name);
73
+ };
74
+ const resolvedFrom = (dir, specifier) => createRequire(path.join(dir, 'package.json'))(specifier);
75
+
76
+ test('two installs of the same lockfile with the importers in either order stage byte-identical trees, and every require still resolves', () => {
77
+ const orders = [['--filter', '@t/a...', '--filter', '@t/b...'], ['--filter', '@t/b...', '--filter', '@t/a...']];
78
+ const trees = orders.map(() => fixture());
79
+ try {
80
+ // Each tree's hidden hoist is linked at a different installed sig after pnpm's install and before the canonical step,
81
+ // as two walk orders leave it: sig@2 in the first tree, sig@1 in the second.
82
+ const divergent = ['sig@file+vendor+sig-v2', 'sig@file+vendor+sig-v1'];
83
+ const raw = trees.map((treeRoot, i) => {
84
+ let hoisted;
85
+ const diverging = (file, args, options) => {
86
+ exec(file, args, options);
87
+ if (!args.includes('--frozen-lockfile')) return;
88
+ const link = path.join(treeRoot, 'node_modules', '.pnpm', 'node_modules', 'sig');
89
+ fs.unlinkSync(link);
90
+ fs.symlinkSync(path.join('..', divergent[i], 'node_modules', 'sig'), link);
91
+ hoisted = fs.readlinkSync(link);
92
+ };
93
+ installClosure({ treeRoot, filters: orders[i], arch: 'arm64', exec: diverging });
94
+ return hoisted;
95
+ });
96
+ assert.notEqual(raw[0], raw[1], 'the two trees enter the canonical step with different hidden hoists');
97
+ assert.deepEqual(snapshot(trees[0]), snapshot(trees[1]));
98
+ for (const treeRoot of trees) {
99
+ const hoisted = fs.readlinkSync(path.join(treeRoot, 'node_modules', '.pnpm', 'node_modules', 'sig'));
100
+ assert.match(hoisted, /sig@file\+vendor\+sig-v1/, 'the hoisted sig is the one nearest the importers');
101
+ assert.equal(resolvedFrom(storePackage(treeRoot, 'b1'), 'sig'), 'sig@1.0.0', 'an undeclared require resolves through the hidden hoist');
102
+ assert.equal(resolvedFrom(storePackage(treeRoot, 'zzz-r'), 'sig'), 'sig@1.0.0');
103
+ assert.equal(resolvedFrom(storePackage(treeRoot, 'aaa-s'), 'sig'), 'sig@2.0.0', 'a declared dependency still resolves to its own version');
104
+ assert.equal(resolvedFrom(path.join(treeRoot, 'apps', 'a'), 'pa'), 'pa@1.0.0');
105
+ assert.equal(resolvedFrom(path.join(treeRoot, 'apps', 'b'), 'pb'), 'pb@1.0.0');
106
+ }
107
+ } finally {
108
+ for (const treeRoot of trees) fs.rmSync(treeRoot, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test('the canonical choice is a no-op on a tree already canonical, and the hidden .bin is removed', () => {
113
+ const treeRoot = fixture();
114
+ try {
115
+ installClosure({ treeRoot, filters: ['--filter', '@t/b...', '--filter', '@t/a...'], arch: 'arm64', exec });
116
+ const before = snapshot(treeRoot);
117
+ fs.mkdirSync(path.join(treeRoot, 'node_modules', '.pnpm', 'node_modules', '.bin'));
118
+ assert.equal(canonicalizeHiddenHoist(treeRoot), 0);
119
+ assert.deepEqual(snapshot(treeRoot), before);
120
+ } finally {
121
+ fs.rmSync(treeRoot, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test('a tree without a virtual store is left as it is', () => {
126
+ const treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-hidden-hoist-empty-'));
127
+ try {
128
+ assert.equal(canonicalizeHiddenHoist(treeRoot), 0);
129
+ assert.deepEqual(fs.readdirSync(treeRoot), []);
130
+ } finally {
131
+ fs.rmSync(treeRoot, { recursive: true, force: true });
132
+ }
133
+ });
@@ -0,0 +1,9 @@
1
+ /** Canonical JSON: keys sorted recursively, no whitespace. */
2
+ export function canonicalJson(value) {
3
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
4
+ if (value !== null && typeof value === 'object') {
5
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
6
+ }
7
+ return JSON.stringify(value);
8
+ }
9
+
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Facts per generated component (its boot-required environment, and how it
3
+ * receives its resolved deployment): which source file is the component's entry,
4
+ * what `deriveComponentFacts` reads from it, and how the result is written (a
5
+ * per-component JSON fact, and `bootEnv` / `editionDelivery` properties inlined
6
+ * in the generated descriptor). Pure functions around one derivation call, so
7
+ * the generator's tests drive them without a checkout.
8
+ */
9
+ import { existsSync, readdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { BiomeComponentEntrypointKind } from '@xemahq/contracts/biome';
13
+ import {
14
+ COMPONENT_FACTS_GENERATED_FILE_SUFFIX,
15
+ ComponentFactsFileSchema,
16
+ } from '@xemahq/contracts/boot-env';
17
+ import { deriveComponentFacts } from '@xemahq/peer-analysis';
18
+
19
+ /**
20
+ * The fact file's retired spelling. No generator writes it any more, so any file
21
+ * with it is a leftover: the lock builder must never read it.
22
+ */
23
+ const RETIRED_BOOT_ENV_SUFFIX = '.boot-env.generated.json';
24
+
25
+ /** The source module a `oci-default` entrypoint runs: the shared Dockerfile's `node dist/main`. */
26
+ const OCI_DEFAULT_ENTRY = 'src/main.ts';
27
+ const NODE_MODULE_PATTERN = /^dist\/([^/].*?)(?:\.js)?$/u;
28
+
29
+ const refuse = (manifestRel, component, why) =>
30
+ new Error(
31
+ `[xema-service-bootstrap] ${manifestRel} component "${component?.key}": cannot determine its source entry to derive its boot environment — ${why}`,
32
+ );
33
+
34
+ /**
35
+ * The package-relative source file a component's runtime entry is compiled from.
36
+ *
37
+ * - `oci-default` runs the image's default `node dist/main` (the kernel schema
38
+ * documents that default), so the entry is `src/main.ts`.
39
+ * - `node-module` names `dist/<x>` literally, so the entry is `src/<x>.ts`.
40
+ *
41
+ * Anything else is refused: the entry is never guessed.
42
+ */
43
+ export function componentEntrySource(component, manifestRel) {
44
+ const entrypoint = component?.entrypoint;
45
+ if (entrypoint?.kind === BiomeComponentEntrypointKind.OciDefault) {
46
+ return OCI_DEFAULT_ENTRY;
47
+ }
48
+ if (entrypoint?.kind === BiomeComponentEntrypointKind.NodeModule) {
49
+ const match = typeof entrypoint.module === 'string' ? NODE_MODULE_PATTERN.exec(entrypoint.module) : null;
50
+ if (match === null) {
51
+ throw refuse(
52
+ manifestRel,
53
+ component,
54
+ `entrypoint.module ${JSON.stringify(entrypoint.module)} must be \`dist/<name>\`, which maps to \`src/<name>.ts\``,
55
+ );
56
+ }
57
+ return `src/${match[1]}.ts`;
58
+ }
59
+ throw refuse(
60
+ manifestRel,
61
+ component,
62
+ `entrypoint.kind ${JSON.stringify(entrypoint?.kind)} is neither \`${BiomeComponentEntrypointKind.OciDefault}\` nor \`${BiomeComponentEntrypointKind.NodeModule}\``,
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Derive one component's facts from its entry source. Any finding fails, naming
68
+ * the component and every `path:line` with its reason.
69
+ *
70
+ * @returns {{ bootEnv: { required: string[], optional: string[] }, editionDelivery?: string }}
71
+ */
72
+ export function deriveFacts({ component, packageDir, manifestRel }) {
73
+ const entryFile = componentEntrySource(component, manifestRel);
74
+ let result;
75
+ try {
76
+ result = deriveComponentFacts({ packageDir, entryFile });
77
+ } catch (error) {
78
+ throw new Error(
79
+ `[xema-service-bootstrap] ${manifestRel} component "${component.key}": cannot derive its facts from ${entryFile}: ${error.message}`,
80
+ { cause: error },
81
+ );
82
+ }
83
+ if (result.findings.length > 0) {
84
+ throw new Error(
85
+ `[xema-service-bootstrap] ${manifestRel} component "${component.key}": its facts cannot be derived from ${entryFile} (${result.findings.length} finding(s)):\n` +
86
+ result.findings.map((f) => ` ${f.path}:${f.line} [${f.seam}] ${f.reason}`).join('\n'),
87
+ );
88
+ }
89
+ return {
90
+ bootEnv: result.bootEnv,
91
+ ...(result.editionDelivery === undefined ? {} : { editionDelivery: result.editionDelivery }),
92
+ };
93
+ }
94
+
95
+ export const componentFactsFileName = (name) => `${name}${COMPONENT_FACTS_GENERATED_FILE_SUFFIX}`;
96
+
97
+ /** The per-component fact file, validated against the contract before it is written. */
98
+ export function renderComponentFactsFile(name, facts) {
99
+ const file = ComponentFactsFileSchema.parse({ factsVersion: 1, component: name, ...facts });
100
+ return `${JSON.stringify(file, null, 2)}\n`;
101
+ }
102
+
103
+ const names = (xs) => (xs.length === 0 ? '[]' : `[${xs.map((x) => `'${x}'`).join(', ')}]`);
104
+
105
+ /** The `bootEnv` (and, when reached, `editionDelivery`) properties inlined in a generated descriptor. */
106
+ export function renderFactsProperties({ bootEnv, editionDelivery }) {
107
+ const lines = [` bootEnv: { required: ${names(bootEnv.required)}, optional: ${names(bootEnv.optional)} },`];
108
+ if (editionDelivery !== undefined) {
109
+ lines.push(` editionDelivery: '${editionDelivery}',`);
110
+ }
111
+ return lines.join('\n');
112
+ }
113
+
114
+ /**
115
+ * The fact files in a generated directory that no emitted component owns, and
116
+ * every file in the retired boot-env spelling: a renamed or removed component
117
+ * leaves its file behind, and the lock builder reads every one it finds.
118
+ */
119
+ export function extraFactsFiles(generatedDir, expectedFileNames) {
120
+ if (!existsSync(generatedDir)) {
121
+ return [];
122
+ }
123
+ return readdirSync(generatedDir)
124
+ .filter(
125
+ (file) =>
126
+ file.endsWith(RETIRED_BOOT_ENV_SUFFIX) ||
127
+ (file.endsWith(COMPONENT_FACTS_GENERATED_FILE_SUFFIX) && !expectedFileNames.has(file)),
128
+ )
129
+ .sort()
130
+ .map((file) => join(generatedDir, file));
131
+ }
@@ -16,7 +16,7 @@
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { dirname, join, posix } from 'node:path';
18
18
 
19
- import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
19
+ import { COMPOSED_DOCKERFILE_HEADER, COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
20
20
  import { toolVersion } from './composed-image-stage.mjs';
21
21
 
22
22
  const TOKEN_REFERENCE = /_authToken\b|\$\{(?:NPM_TOKEN|GH_PACKAGES_TOKEN)\}/;
@@ -59,7 +59,7 @@ export function renderComposedDockerfile({ copy, cacheId, buildSource, usesToken
59
59
  ? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
60
60
  : '';
61
61
  const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
62
- return `# @generated by \`xema-composed-image dockerfile --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
62
+ return `${COMPOSED_DOCKERFILE_HEADER} --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
63
63
  # Re-render with the same options; \`--check\` refuses a stale file.
64
64
  #
65
65
  # The COMPOSED-PROCESS image of this repository: ONE image per build (repository at
@@ -8,11 +8,15 @@
8
8
  // The re-resolve runs in a scratch copy of the tree's manifests, never in the tree: pnpm 10.33 `--lockfile-only`
9
9
  // with a filter that leaves workspace projects out still links a full `node_modules` (dev dependencies included),
10
10
  // and the `--prod` install after it does not prune it, so dev-only packages would ship and be scanned.
11
+ //
12
+ // pnpm's hidden-hoist choice depends on its walk order, not on the lockfile alone, so the install ends by making it
13
+ // canonical (`canonical-hidden-hoist.mjs`): the same lockfile then stages byte-identical trees.
11
14
  import { execFileSync } from 'node:child_process';
12
15
  import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
13
16
  import { tmpdir } from 'node:os';
14
17
  import { join } from 'node:path';
15
18
 
19
+ import { canonicalizeHiddenHoist } from './canonical-hidden-hoist.mjs';
16
20
  import { assertPinsUnchanged } from './composed-image-in-image.mjs';
17
21
  import { pruneForeignLibcPackages } from './prune-foreign-libc.mjs';
18
22
  import { libcOf, nodeArch } from './service-image-platform.mjs';
@@ -74,6 +78,7 @@ export function installClosure({ treeRoot, filters, arch, exec = defaultExec })
74
78
  exec(installFile, installArgs, { cwd: treeRoot });
75
79
  // pnpm 10.33 frozen offline install does not filter by libc; cpu/os do.
76
80
  pruneForeignLibcPackages(treeRoot, libcOf(arch));
81
+ canonicalizeHiddenHoist(treeRoot);
77
82
  } finally {
78
83
  rmSync(scratch, { recursive: true, force: true });
79
84
  }
@@ -12,12 +12,12 @@ const GLOB = /[*?[\]{}!]/;
12
12
  const ignoredFile = (name) => name.endsWith('.tsbuildinfo') || name.endsWith('.log');
13
13
 
14
14
  /**
15
- * Repository-relative COPY sources for `directory`: `package.json` and each plain `files` entry. An entry the
16
- * `.dockerignore` excludes (e.g. `dist`) is built output, staged separately, and is not a source here.
17
- * A package with no `files` array, or a glob entry, is refused: `files` is what says what ships.
15
+ * The package-relative `files` entries of `manifest` (the package at `directory`), normalized: what `pnpm pack` ships
16
+ * beside `package.json`. A package with no `files` array, a glob or negated entry, or an entry outside the package is
17
+ * refused: `files` is what says what ships. The effective-input key reads the same entries (one definition).
18
18
  */
19
- export function packageSources({ root, directory }) {
20
- const files = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8')).files;
19
+ export function shippedFilesEntries({ manifest, directory }) {
20
+ const files = manifest.files;
21
21
  if (!Array.isArray(files)) throw new Error(`${directory}/package.json has no "files" field — nothing says which of its files ship in the image`);
22
22
  const globs = files.filter((entry) => typeof entry !== 'string' || GLOB.test(entry));
23
23
  if (globs.length > 0) throw new Error(`${directory}/package.json "files" has glob or non-string entries (${globs.join(', ')}); list plain paths`);
@@ -25,7 +25,16 @@ export function packageSources({ root, directory }) {
25
25
  for (const entry of entries) {
26
26
  if (entry === '.' || entry.startsWith('/') || entry.split('/').includes('..')) throw new Error(`${directory}/package.json "files" entry "${entry}" is not inside the package`);
27
27
  }
28
- const shipped = entries.filter((entry) => {
28
+ return [...new Set(entries)];
29
+ }
30
+
31
+ /**
32
+ * Repository-relative COPY sources for `directory`: `package.json` and each plain `files` entry. An entry the
33
+ * `.dockerignore` excludes (e.g. `dist`) is built output, staged separately, and is not a source here.
34
+ */
35
+ export function packageSources({ root, directory }) {
36
+ const manifest = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8'));
37
+ const shipped = shippedFilesEntries({ manifest, directory }).filter((entry) => {
29
38
  const parts = entry.split('/');
30
39
  return !parts.some((part) => IGNORED_DIRECTORIES.includes(part)) && !ignoredFile(parts.at(-1));
31
40
  });
@@ -1,5 +1,7 @@
1
1
  // Where a repository keeps its composed-process image: the one place this is spelled.
2
2
  export const COMPOSED_IMAGE_DIR = 'infra/composed-process';
3
+ /** How every composed-image Dockerfile (in-Docker and prebuilt) begins: both COPY whole `--copy` directories. */
4
+ export const COMPOSED_DOCKERFILE_HEADER = '# @generated by `xema-composed-image dockerfile';
3
5
 
4
6
  // The one entry the prebuilt image runs from the bind-mounted tool directory: loads only `node:` and relative modules.
5
7
  export const IN_IMAGE_ENTRY = 'src/composed-image-in-image.mjs';
@@ -6,7 +6,7 @@
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, join, posix } from 'node:path';
8
8
 
9
- import { COMPOSED_IMAGE_DIR, COMPOSED_MANIFEST_FILE, IN_IMAGE_ENTRY, NODE_MAJOR_LABEL, PROOF_DIRECTORY, STAGE_DIST_LAYER, STAGE_PROOF_DIRECTORY, STAGE_STORE_LAYER, STAGE_WORKSPACE_LAYER, VERIFIED_IMAGE_FILE } from './composed-image-paths.mjs';
9
+ import { COMPOSED_DOCKERFILE_HEADER, COMPOSED_IMAGE_DIR, COMPOSED_MANIFEST_FILE, IN_IMAGE_ENTRY, NODE_MAJOR_LABEL, PROOF_DIRECTORY, STAGE_DIST_LAYER, STAGE_PROOF_DIRECTORY, STAGE_STORE_LAYER, STAGE_WORKSPACE_LAYER, VERIFIED_IMAGE_FILE } from './composed-image-paths.mjs';
10
10
  import { toolVersion } from './composed-image-stage.mjs';
11
11
  import { IGNORED_DIRECTORIES, IGNORED_FILE_PATTERNS } from './composed-image-package-files.mjs';
12
12
  import { BaseKind, CLASS_RECIPE, TOOLSET_FILES, assertImageClass } from './composed-image-class.mjs';
@@ -96,10 +96,10 @@ export function renderPrebuiltDockerfile({ root = process.cwd(), imageDir = COMP
96
96
  const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
97
97
  const copies = normalizedCopy(copy).map(copyLine).join('\n');
98
98
  const stageLayer = (layer) => `COPY ${stageDir}/${layer}/ /`;
99
- const allLayers = [STAGE_STORE_LAYER, STAGE_WORKSPACE_LAYER, STAGE_DIST_LAYER].map(stageLayer).join('\n');
99
+ const stableLayers = [STAGE_STORE_LAYER, STAGE_WORKSPACE_LAYER].map(stageLayer).join('\n');
100
100
  const toolsetStages = recipe.toolsets.map((name) => `FROM ${toolsets[name]} AS ${name}\n`).join('');
101
101
  const toolsetCopies = recipe.toolsets.map((name) => `COPY --from=${name} ${TOOLSET_FILES[name].from} ${TOOLSET_FILES[name].to}\n`).join('');
102
- const dockerfile = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
102
+ const dockerfile = `${COMPOSED_DOCKERFILE_HEADER} --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
103
103
  # Re-render with the same options; \`--check\` refuses a stale file (and a stale Dockerfile.dockerignore).
104
104
  #
105
105
  # The ${imageClass.toUpperCase()} COMPOSED-PROCESS image of this repository, COPIED from a tree the CI runner staged: one
@@ -118,7 +118,8 @@ export function renderPrebuiltDockerfile({ root = process.cwd(), imageDir = COMP
118
118
  # \`${stageDir}/tool/\` is this tool, bind-mounted into the steps below and never copied into a layer.
119
119
  #
120
120
  # Stages:
121
- # proof The staged tree and the repository's inputs meet. The root build inputs that decide the
121
+ # proof (COPYs run least to most often changed: store, workspace, root inputs, \`--copy\` directories, dist, manifest, so a source change keeps the store layer cached.)
122
+ # The staged tree and the repository's inputs meet. The root build inputs that decide the
122
123
  # staged tree are COPIED into \`/${PROOF_DIRECTORY}/${CONTEXT_DIRECTORY}/\` and the \`--copy\` directories into
123
124
  # \`/${COPIED_DIRECTORY}/\`, which puts them in the image's content tag. \`prove\` refuses the build unless the
124
125
  # stage's manifest names BUILD_SHA and those bytes, this tool and this platform, and
@@ -134,9 +135,10 @@ ${toolsetStages}FROM ${from} AS base
134
135
  FROM base AS proof
135
136
  WORKDIR /${PROOF_DIRECTORY}
136
137
  ARG BUILD_SHA
137
- COPY ${stageDir}/${STAGE_PROOF_DIRECTORY}/${COMPOSED_MANIFEST_FILE} /${PROOF_DIRECTORY}/
138
+ ${stableLayers}
138
139
  COPY ${rootInputs.join(' ')} /${PROOF_DIRECTORY}/${CONTEXT_DIRECTORY}/
139
- ${copies === '' ? '' : `${copies}\n`}${allLayers}
140
+ ${copies === '' ? '' : `${copies}\n`}${stageLayer(STAGE_DIST_LAYER)}
141
+ COPY ${stageDir}/${STAGE_PROOF_DIRECTORY}/${COMPOSED_MANIFEST_FILE} /${PROOF_DIRECTORY}/
140
142
  RUN --network=none ${toolMount} \\
141
143
  : "\${BUILD_SHA:?BUILD_SHA build-arg is required}" && \\
142
144
  T="node ${node}" && \\
@@ -28,12 +28,24 @@ test('the proof stage COPYs the root build inputs flat, the --copy directories,
28
28
  assert.match(proof, /^COPY \.xema-stage\/proof\/xema-composed-image\.json \/proof\/$/m);
29
29
  assert.match(proof, /^COPY \.npmrc package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \/proof\/context\/$/m);
30
30
  assert.match(proof, /^COPY tooling\/dev\/ \/proof\/copied\/tooling\/dev\/$/m);
31
- assert.match(proof, /^COPY \.xema-stage\/layers\/store\/ \/\nCOPY \.xema-stage\/layers\/workspace\/ \/\nCOPY \.xema-stage\/layers\/dist\/ \/$/m);
31
+ assert.match(proof, /^COPY \.xema-stage\/layers\/store\/ \/\nCOPY \.xema-stage\/layers\/workspace\/ \/$/m);
32
+ assert.match(proof, /^COPY \.xema-stage\/layers\/dist\/ \/$/m);
32
33
  assert.match(proof, /prove \/proof --root \/monorepo --commit "\$\{BUILD_SHA\}" --filter @t\/alpha-api\.\.\. --filter @t\/alpha-db-api\.\.\./);
33
34
  assert.match(proof, /prisma-check --root \/monorepo/);
34
35
  assert.match(proof, /USER node\nRUN --network=none \S+ \\\n {2}NODE_ENV=production node \S+ load --each --root \/monorepo/);
35
36
  });
36
37
 
38
+ test('the proof stage COPYs in stability order: store, workspace, root inputs, --copy trees, dist, manifest', () => {
39
+ const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
40
+ const proof = dockerfile.split('FROM base AS proof\n')[1].split('\nFROM base\n')[0];
41
+ const copies = proof.split('\n').filter((line) => line.startsWith('COPY '));
42
+ const at = (re) => copies.findIndex((line) => re.test(line));
43
+ const order = [/layers\/store\//, /layers\/workspace\//, /\/proof\/context\/$/, /\/proof\/copied\//, /layers\/dist\//, /xema-composed-image\.json/].map(at);
44
+ assert.ok(order.every((i) => i >= 0), `every COPY is present: ${order}`);
45
+ assert.deepEqual(order, [...order].sort((a, b) => a - b));
46
+ assert.equal(order[0], 0, 'the store COPY precedes every source-tree COPY');
47
+ });
48
+
37
49
  test('the root inputs the Dockerfile COPYs are exactly the ROOT_BUILD_INPUT files present', () => {
38
50
  const root = workspace();
39
51
  const opts = options(root);