@xemahq/repo-build-tooling 0.10.0 → 0.10.2
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 +46 -138
- package/package.json +2 -2
- package/src/scrub-swagger-plugin-paths.mjs +83 -4
package/README.md
CHANGED
|
@@ -1,138 +1,46 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
immediately after `nest build` in all 54 services that use the plugin.
|
|
48
|
-
|
|
49
|
-
It rewrites specifiers that name the builder's private install layout into the
|
|
50
|
-
target package's public specifier, then re-reads the output from disk and fails
|
|
51
|
-
the build if any leak survives.
|
|
52
|
-
|
|
53
|
-
The rewrite is **derived and proved**, never tabled: the replacement comes from
|
|
54
|
-
the target package's own `exports` map, and is accepted only if the emitted
|
|
55
|
-
re-export chain (read statically — package code is never executed) reaches the
|
|
56
|
-
same module. It refuses rather than guesses. That matters because the two
|
|
57
|
-
packages that leak most are opposites — `@xemahq/kernel-contracts` exports only
|
|
58
|
-
`./*` and has no root export, so rewriting to the bare name would swap one crash
|
|
59
|
-
for another, while `@xemahq/platform-common` exports both `.` and `./dist/*`.
|
|
60
|
-
|
|
61
|
-
`--self-test` proves, on a scratch tree with no build and no fixtures, that a
|
|
62
|
-
missing/empty/no-`.js` directory each FAIL, that the detector matches every leak
|
|
63
|
-
shape and no clean literal, and that a residual leak fails the run. This step's
|
|
64
|
-
failure mode is silence, so "0 leaks" and "0 files read" must never be
|
|
65
|
-
indistinguishable.
|
|
66
|
-
|
|
67
|
-
### `xema-composed-image <command>`
|
|
68
|
-
|
|
69
|
-
The build-time half of a repository's composed-process image (the runtime half,
|
|
70
|
-
the `xema-composed-process` host, is `@xemahq/xema-service-nest`'s). One image per
|
|
71
|
-
build carries every composable platform service of the repository at one commit.
|
|
72
|
-
|
|
73
|
-
- `members --write|--check` derives that set from the tracked biome manifests and
|
|
74
|
-
writes or verifies `infra/composed-process/image-build-inputs.json`. A member is a
|
|
75
|
-
`kind: service` with `scheduler: platform`; workers, jobs and runtime-scheduled
|
|
76
|
-
services never are. A service is left out, with its reason printed, when its
|
|
77
|
-
`src/main.ts` is not one top-level `void bootstrapXemaService(…)`, it has its own
|
|
78
|
-
`Dockerfile`, it bakes cross-repository inputs, or the canonical Dockerfile
|
|
79
|
-
installs a toolchain for it. Options say where the repository keeps those facts:
|
|
80
|
-
`--manifests <glob>` (repeatable, default `**/xema-biome.json`),
|
|
81
|
-
`--canonical-dockerfile <path>` and `--cross-repo-manifest <path>` (both optional).
|
|
82
|
-
- Inside the image build: `filters` and `dirs` name the members' closures,
|
|
83
|
-
`pins <committed-lockfile>` refuses a re-resolution that moves a registry pin,
|
|
84
|
-
`index <commit> --build-source <repo>` writes the member index the host reads
|
|
85
|
-
(graded by `@xemahq/contracts/process-topology`, resolved from the members) and
|
|
86
|
-
links the host bin, and `load` evaluates every member's entry through the host's
|
|
87
|
-
`readServiceEntry`. `--build-source` is the `repo` the repository's subjects carry
|
|
88
|
-
in an edition's image lock.
|
|
89
|
-
|
|
90
|
-
- `coordinate --image-name <name>` is the image's release coordinate: the content tag
|
|
91
|
-
and whether GHCR already serves it (only 200 and 404 are answers). The tag is computed
|
|
92
|
-
ONLY by the repository's own `@xemahq/distribution-source-hash` (`xema-compute-image-tag`,
|
|
93
|
-
resolved from the repository root); the command refuses when it is not installed, and never
|
|
94
|
-
hashes anything itself. Environment: `SHA`, `REGISTRY`, `REGISTRY_ORG`, `REGISTRY_USER`,
|
|
95
|
-
`REGISTRY_TOKEN`, optional `TAG_ARGS_FILE`, `GITHUB_OUTPUT`. Outputs `image`, `content_tag`,
|
|
96
|
-
`exists`, `entry`. The registry check is also exported as `./ghcr-manifest`
|
|
97
|
-
(`ghcrManifestExists`, `acquireGhcrPullToken`).
|
|
98
|
-
- `load-image <image>` runs the member-load check inside the shipped image (`docker run`), as its own user and `NODE_ENV`; `load` runs in the builder and cannot prove that.
|
|
99
|
-
- `smoke-refusal <image> [--member <biomeId>/<componentKey>]x2` boots the pushed image
|
|
100
|
-
with two members on one port and fails unless the host refuses at the declaration, names
|
|
101
|
-
the collision, and constructs no member. Needs only `docker`.
|
|
102
|
-
- `dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>`
|
|
103
|
-
renders `infra/composed-process/Dockerfile` from one template. The builder stage installs
|
|
104
|
-
this tool at the EXACT version (so the hashed image directory moves when the tool does),
|
|
105
|
-
runs every step above, and runs `load` last as `node` with `NODE_ENV=production`. Nothing here
|
|
106
|
-
ships in an image: the final stage copies `/monorepo` only.
|
|
107
|
-
|
|
108
|
-
Every step refuses rather than skips. The tool has no dependencies: the host and
|
|
109
|
-
the kernel schema are resolved from the members being built.
|
|
110
|
-
|
|
111
|
-
### `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]`
|
|
112
|
-
|
|
113
|
-
Generates the descriptors `@xemahq/xema-service-nest` boots from, out of the
|
|
114
|
-
repository's own `xema-biome.json` manifests. For every `kind: service` component
|
|
115
|
-
it writes `src/generated/<name>.bootstrap.generated.ts` (a `BiomeServiceDescriptor`),
|
|
116
|
-
and for every `kind: worker` component `src/generated/<key>.worker-descriptor.generated.ts`
|
|
117
|
-
(a `XemaWorkerDescriptor`; a `shared-oci-image` worker lands in the package of the
|
|
118
|
-
component whose image it shares). Only packages that declare
|
|
119
|
-
`@xemahq/xema-service-nest` are written to.
|
|
120
|
-
|
|
121
|
-
`requirements.scaling.concurrency.maximumPerInstance` becomes
|
|
122
|
-
`maximumConcurrentRequests` on a service and `maximumConcurrentActivities` on a
|
|
123
|
-
worker. A component without a positive integer there is refused, naming the
|
|
124
|
-
manifest and the component.
|
|
125
|
-
|
|
126
|
-
`--check` writes nothing and fails on any drift. A run that finds no manifest, or no
|
|
127
|
-
service or worker to generate, fails rather than reporting success. Run it from the
|
|
128
|
-
repository root, or pass `--root`.
|
|
129
|
-
|
|
130
|
-
## Scope boundary
|
|
131
|
-
|
|
132
|
-
Dev-time tooling that **more than one repository** runs. Two rules keep it from
|
|
133
|
-
becoming a junk drawer:
|
|
134
|
-
|
|
135
|
-
- Nothing with a runtime consumer. If a service imports it at run time it is not
|
|
136
|
-
build tooling and belongs in an SDK package.
|
|
137
|
-
- Nothing with a single consumer. A tool used by one repository stays in that
|
|
138
|
-
repository until a second one needs it.
|
|
1
|
+
# @xemahq/repo-build-tooling
|
|
2
|
+
|
|
3
|
+
> Dev-time build tooling shared by every Xema repository
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Command-line tools that more than one Xema repository runs at build or check time. Nothing here ships in a service image; consumers declare it as a `devDependency`.
|
|
8
|
+
|
|
9
|
+
## When to use it
|
|
10
|
+
|
|
11
|
+
- To run the shared build and readiness checks in a repository, locally or in CI.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add -D @xemahq/repo-build-tooling
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm exec xema-readiness
|
|
23
|
+
pnpm exec xema-service-bootstrap --check
|
|
24
|
+
pnpm exec xema-scrub-swagger-paths dist
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Bin | What it does |
|
|
28
|
+
| --- | --- |
|
|
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 |
|
|
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
|
+
| `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
|
|
33
|
+
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
34
|
+
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
35
|
+
|
|
36
|
+
`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 `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. Run `xema-composed-image` with no arguments for the full usage.
|
|
37
|
+
|
|
38
|
+
Every step refuses rather than skips. The package has no dependencies.
|
|
39
|
+
|
|
40
|
+
## Resolution
|
|
41
|
+
|
|
42
|
+
Inside the `xema-monorepo` aggregator, `@xemahq/repo-build-tooling` resolves to this directory through a workspace override. In an independent repository's CI it resolves from npm by the declared range, so a change here reaches a consumer only after it is published and the consumer's range admits it.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
Apache-2.0 © Xema — [xema.dev](https://xema.dev)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies 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)",
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
"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/ghcr-manifest.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/registry-fetch.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs"
|
|
43
43
|
},
|
|
44
44
|
"xemaRelease": {
|
|
45
|
-
"sourceDigest": "sha256:
|
|
45
|
+
"sourceDigest": "sha256:6e85c2039576c5febe254e42d36eef92248dd33a88a8f86416ff654ff36a2765"
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -167,12 +167,15 @@ const FLAT_LEAK_REGEX = new RegExp(
|
|
|
167
167
|
// a relative path from the service source file back up to the workspace root
|
|
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
|
-
// Capture group 1:
|
|
170
|
+
// Capture group 1: relative path from the scrubbed file to the package directory.
|
|
171
171
|
// Capture group 2: first path segment after dist/ = the subpath export name.
|
|
172
|
+
// The public package name is NOT derived from the directory name or a fixed
|
|
173
|
+
// scope: it is read from the target package's own package.json `name`, so
|
|
174
|
+
// unscoped and differently-named workspace packages resolve correctly.
|
|
172
175
|
// Scoped to paths that pass through `packages/`, `biomes/`, or `kernel/` to
|
|
173
176
|
// avoid false-positive matches on intra-service relative requires.
|
|
174
177
|
const RELATIVE_WORKSPACE_REGEX = new RegExp(
|
|
175
|
-
`require\\("(?:\\.\\./)+(?:packages|biomes|kernel)/(?:[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-]*)(?:/[^"]*)?"\\)`,
|
|
176
179
|
'g',
|
|
177
180
|
);
|
|
178
181
|
|
|
@@ -416,6 +419,28 @@ function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
|
|
|
416
419
|
);
|
|
417
420
|
}
|
|
418
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The npm name of the workspace package at `packageDir`, read from the
|
|
424
|
+
* package.json AT that directory. No walk-up: an ancestor's manifest (usually
|
|
425
|
+
* the workspace root) names a different package. Never a fixed scope or the
|
|
426
|
+
* directory name.
|
|
427
|
+
*/
|
|
428
|
+
function packageNameAt(packageDir, original) {
|
|
429
|
+
const manifest = join(packageDir, 'package.json');
|
|
430
|
+
let raw;
|
|
431
|
+
try {
|
|
432
|
+
raw = readFileSync(manifest, 'utf8');
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
|
|
435
|
+
throw new ScrubFailure(`${packageDir} has no package.json, so no specifier can be derived for\n ${original}`);
|
|
436
|
+
}
|
|
437
|
+
const { name } = JSON.parse(raw);
|
|
438
|
+
if (typeof name !== 'string' || name === '') {
|
|
439
|
+
throw new ScrubFailure(`${manifest} has no "name", so no specifier can be derived for\n ${original}`);
|
|
440
|
+
}
|
|
441
|
+
return name;
|
|
442
|
+
}
|
|
443
|
+
|
|
419
444
|
async function* walk(dir) {
|
|
420
445
|
let entries;
|
|
421
446
|
try {
|
|
@@ -561,9 +586,10 @@ async function scrubFile(path) {
|
|
|
561
586
|
` rather than reaching through dist/.`,
|
|
562
587
|
);
|
|
563
588
|
}
|
|
564
|
-
out = out.replace(RELATIVE_WORKSPACE_REGEX, (
|
|
589
|
+
out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, subpath) => {
|
|
565
590
|
count += 1;
|
|
566
|
-
|
|
591
|
+
const name = packageNameAt(resolve(dirname(path), relPackageDir), match);
|
|
592
|
+
return `require("${name}/${subpath}")`;
|
|
567
593
|
});
|
|
568
594
|
if (count > 0) {
|
|
569
595
|
await writeFile(path, out, 'utf8');
|
|
@@ -796,6 +822,59 @@ async function selfTest() {
|
|
|
796
822
|
throw new Error(`self-test: rewritten file still leaks: ${greenAfter}`);
|
|
797
823
|
}
|
|
798
824
|
|
|
825
|
+
// 4b. RELATIVE-WORKSPACE — the rewritten specifier is the target package's
|
|
826
|
+
// own manifest `name`: scoped, unscoped, and name != directory.
|
|
827
|
+
const ws = join(root, 'ws');
|
|
828
|
+
const pkgs = [
|
|
829
|
+
['packages/contracts/scoped-dir', '@xemahq/scoped-dir'],
|
|
830
|
+
['packages/contracts/plain-dir', 'plain-contract'],
|
|
831
|
+
['packages/contracts/other-dir', '@xemahq/renamed'],
|
|
832
|
+
];
|
|
833
|
+
for (const [dir, name] of pkgs) {
|
|
834
|
+
await mkdir(join(ws, dir, 'dist/lib'), { recursive: true });
|
|
835
|
+
await writeFile(join(ws, dir, 'package.json'), JSON.stringify({ name }), 'utf8');
|
|
836
|
+
}
|
|
837
|
+
await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
|
|
838
|
+
const wsFile = join(ws, 'services/svc/dist/dto.js');
|
|
839
|
+
const relOf = (dir) => `../../../${dir}/dist/lib/x`;
|
|
840
|
+
await writeFile(
|
|
841
|
+
wsFile,
|
|
842
|
+
`class Dto {\n static _OPENAPI_METADATA_FACTORY() {\n return { ${pkgs
|
|
843
|
+
.map(([dir], i) => `k${i}: () => require("${relOf(dir)}")`)
|
|
844
|
+
.join(', ')} };\n }\n}\n`,
|
|
845
|
+
'utf8',
|
|
846
|
+
);
|
|
847
|
+
await run([join(ws, 'services/svc/dist')]);
|
|
848
|
+
const wsAfter = await readFile(wsFile, 'utf8');
|
|
849
|
+
for (const expected of ['require("@xemahq/scoped-dir/lib")', 'require("plain-contract/lib")', 'require("@xemahq/renamed/lib")']) {
|
|
850
|
+
if (!wsAfter.includes(expected)) {
|
|
851
|
+
throw new Error(`self-test: relative-workspace rewrite missing ${expected}: ${wsAfter}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// A target directory without its own package.json is a hard failure.
|
|
855
|
+
const orphan = join(root, 'orphan');
|
|
856
|
+
await mkdir(join(orphan, 'svc/dist'), { recursive: true });
|
|
857
|
+
await writeFile(
|
|
858
|
+
join(orphan, 'svc/dist/dto.js'),
|
|
859
|
+
'class D {\n static _OPENAPI_METADATA_FACTORY() {\n return { a: () => require("../../packages/none/dist/lib/x") };\n }\n}\n',
|
|
860
|
+
'utf8',
|
|
861
|
+
);
|
|
862
|
+
await expectFailure('relative workspace target without package.json', () => run([join(orphan, 'svc/dist')]));
|
|
863
|
+
|
|
864
|
+
// An ANCESTOR's package.json must not stand in for the target's own.
|
|
865
|
+
const walkup = join(root, 'walkup');
|
|
866
|
+
await mkdir(join(walkup, 'packages/none/dist/lib'), { recursive: true });
|
|
867
|
+
await mkdir(join(walkup, 'svc/dist'), { recursive: true });
|
|
868
|
+
await writeFile(join(walkup, 'package.json'), JSON.stringify({ name: 'workspace-root' }), 'utf8');
|
|
869
|
+
await writeFile(
|
|
870
|
+
join(walkup, 'svc/dist/dto.js'),
|
|
871
|
+
'class D {\n static _OPENAPI_METADATA_FACTORY() {\n return { a: () => require("../../packages/none/dist/lib/x") };\n }\n}\n',
|
|
872
|
+
'utf8',
|
|
873
|
+
);
|
|
874
|
+
await expectFailure('relative workspace target without package.json but with an ancestor that has one', () =>
|
|
875
|
+
run([join(walkup, 'svc/dist')]),
|
|
876
|
+
);
|
|
877
|
+
|
|
799
878
|
// 5. End-to-end RED — a DOUBLY-nested literal. One `String.replace` pass
|
|
800
879
|
// does not re-scan its own replacement, so the rewrite leaves a literal
|
|
801
880
|
// that is still a leak. This is the case the old script shipped
|