@xemahq/repo-build-tooling 0.5.0 → 0.7.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/package.json +6 -4
- package/src/readiness.mjs +623 -0
- package/src/readiness.test.mjs +202 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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)",
|
|
@@ -21,17 +21,19 @@
|
|
|
21
21
|
"src"
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
|
-
"xema-
|
|
24
|
+
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
25
25
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
26
|
-
"xema-
|
|
26
|
+
"xema-readiness": "src/readiness.mjs",
|
|
27
|
+
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs"
|
|
27
28
|
},
|
|
28
29
|
"exports": {
|
|
29
30
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
30
31
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
31
32
|
"./package.json": "./package.json",
|
|
33
|
+
"./readiness": "./src/readiness.mjs",
|
|
32
34
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs"
|
|
33
35
|
},
|
|
34
36
|
"scripts": {
|
|
35
|
-
"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"
|
|
37
|
+
"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"
|
|
36
38
|
}
|
|
37
39
|
}
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One command between a source change and a push.
|
|
4
|
+
*
|
|
5
|
+
* -- WHY THIS EXISTS --
|
|
6
|
+
*
|
|
7
|
+
* Adopting one dependency on 2026-09-18 took SIX pushes to land, and not one of
|
|
8
|
+
* them found a new defect. Each found the NEXT derived artifact:
|
|
9
|
+
*
|
|
10
|
+
* push -> CI: the OpenAPI specs no longer describe the code
|
|
11
|
+
* push -> CI: the generated clients no longer match those specs
|
|
12
|
+
* push -> CI: the biome index ledgers no longer describe those clients
|
|
13
|
+
* push -> CI: ... and the ledgers again, for the specs regenerated since
|
|
14
|
+
*
|
|
15
|
+
* That is not four bugs. It is ONE ordered chain, discovered one failure at a
|
|
16
|
+
* time because nothing ran it in order:
|
|
17
|
+
*
|
|
18
|
+
* prisma -> build -> specs -> clients -> ledgers -> checks
|
|
19
|
+
*
|
|
20
|
+
* CI's job is to CONFIRM a state, not to teach the dependency graph. This runs
|
|
21
|
+
* the repository's OWN generators, in the order the graph actually has, so the
|
|
22
|
+
* chain converges before the push instead of across it.
|
|
23
|
+
*
|
|
24
|
+
* -- PRISMA COMES FIRST, AND THAT WAS LEARNED BY THIS FILE FAILING --
|
|
25
|
+
*
|
|
26
|
+
* A service typechecks against its GENERATED Prisma client, and that client is
|
|
27
|
+
* produced by `postinstall`. So it is current only as of the last install --
|
|
28
|
+
* which means a checkout that moves to a commit adding a schema column has a
|
|
29
|
+
* STALE client and no step regenerates it. Measured 2026-09-18: a column was
|
|
30
|
+
* present in `schema.prisma` and absent from the generated `index.d.ts`, and
|
|
31
|
+
* the build reported five errors of the form "Property 'x' does not exist",
|
|
32
|
+
* naming the SERVICE rather than the stale artifact behind it. That reads
|
|
33
|
+
* exactly like a defect in somebody's commit, and it is not.
|
|
34
|
+
*
|
|
35
|
+
* It is the same class as everything below it: a derived artifact with an
|
|
36
|
+
* upstream, discovered one failure at a time because nothing ran it in order.
|
|
37
|
+
*
|
|
38
|
+
* -- THE ORDER IS NOT A PREFERENCE, AND THE FIRST STEP IS THE ONE PEOPLE SKIP --
|
|
39
|
+
*
|
|
40
|
+
* `build` comes first because spec extraction BOOTS each service's Nest
|
|
41
|
+
* AppModule, and a module whose workspace dependencies are unbuilt fails with
|
|
42
|
+
* `MODULE_NOT_FOUND` on a sibling's generated client. Skipping it produces a
|
|
43
|
+
* FAKE cascade: regenerate the five services whose specs drifted, and the run
|
|
44
|
+
* fails on a sixth; regenerate that, and it fails on a seventh. Every one of
|
|
45
|
+
* those is the same unbuilt closure wearing a different service's name. One
|
|
46
|
+
* `pnpm -r build` first, and the cascade does not exist.
|
|
47
|
+
*
|
|
48
|
+
* The graph is ACYCLIC, so ONE correctly ordered pass converges. This
|
|
49
|
+
* deliberately does not loop. If a second pass would change anything, that is a
|
|
50
|
+
* violated dependency to find and fix, not a retry to institutionalise — so the
|
|
51
|
+
* final check step is what proves convergence, and a red one is a real finding.
|
|
52
|
+
*
|
|
53
|
+
* -- WHAT THIS IS NOT --
|
|
54
|
+
*
|
|
55
|
+
* It is not a release engine, a second source of truth, or a new authority over
|
|
56
|
+
* generated artifacts. Every step below shells out to a script this repository
|
|
57
|
+
* already owns and already runs in CI. If a step here disagrees with CI, CI is
|
|
58
|
+
* right and this file is wrong.
|
|
59
|
+
*
|
|
60
|
+
* It is also not a gate. It regenerates files, so it must never run inside
|
|
61
|
+
* `pnpm check`: a check that rewrites the tree it is grading cannot fail
|
|
62
|
+
* honestly.
|
|
63
|
+
*/
|
|
64
|
+
import { execFileSync } from 'node:child_process';
|
|
65
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
66
|
+
import path from 'node:path';
|
|
67
|
+
import { fileURLToPath } from 'node:url';
|
|
68
|
+
import process from 'node:process';
|
|
69
|
+
|
|
70
|
+
// THE ROOT IS THE CONSUMER'S REPOSITORY, NEVER THIS PACKAGE'S LOCATION.
|
|
71
|
+
// This ships as a bin from `@xemahq/repo-build-tooling`, so `import.meta.url`
|
|
72
|
+
// resolves inside `node_modules` — deriving the root from it would grade the
|
|
73
|
+
// SDK's own directory and find nothing, which is the empty-scan pass this
|
|
74
|
+
// repository has shipped before. pnpm runs a `scripts` entry with cwd set to the
|
|
75
|
+
// directory of the manifest declaring it, so for a root `readiness` script that
|
|
76
|
+
// is exactly the repository root. It is asserted and PRINTED rather than assumed.
|
|
77
|
+
const ROOT = process.cwd();
|
|
78
|
+
if (!existsSync(path.join(ROOT, 'package.json'))) {
|
|
79
|
+
console.error(
|
|
80
|
+
`\nreadiness: REFUSING — no package.json at ${ROOT}.\n\n` +
|
|
81
|
+
'This must run from a repository root (`pnpm readiness`), because every\n' +
|
|
82
|
+
'step below is resolved relative to it. Grading the wrong tree silently is\n' +
|
|
83
|
+
'worse than refusing.\n',
|
|
84
|
+
);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const scripts = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).scripts ?? {};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Is this checkout's INSTALL actually the one its lockfile describes?
|
|
91
|
+
*
|
|
92
|
+
* Everything below regenerates artifacts from whatever `node_modules` currently
|
|
93
|
+
* holds. If that disagrees with the lockfile, every spec, client and ledger this
|
|
94
|
+
* run produces is derived from the wrong bytes — and the resulting failure names
|
|
95
|
+
* the CONSUMER, never the stale artifact behind it.
|
|
96
|
+
*
|
|
97
|
+
* Measured FOUR times on 2026-09-18, each producing a confident wrong diagnosis
|
|
98
|
+
* that blamed somebody else's commit:
|
|
99
|
+
*
|
|
100
|
+
* · `@xemahq/contracts` resolving to 0.7.0 out of a foreign scratchpad while
|
|
101
|
+
* the fleet was on 0.13.0 — a `.strict()` schema then REFUSED a valid
|
|
102
|
+
* contribution, which reads as the declaration being broken;
|
|
103
|
+
* · a generated Prisma client missing a column its own schema declared;
|
|
104
|
+
* · `@xemahq/xema-decorators` at 0.17.0 (`humanBaseline` in ZERO files)
|
|
105
|
+
* against a lockfile saying 0.18.0, after a `--lockfile-only` relock;
|
|
106
|
+
* · `@xemahq/biome-supply-chain` at 0.3.0 against a lockfile saying 0.4.0,
|
|
107
|
+
* after a REBASE — `Cannot find module '.../verdicts'`, which reads exactly
|
|
108
|
+
* like somebody importing an unpublished subpath. It was published.
|
|
109
|
+
*
|
|
110
|
+
* A RELOCK IS NOT AN ADOPTION. A REBASE IS NOT AN ADOPTION. Only an install is.
|
|
111
|
+
*
|
|
112
|
+
* One-directional on purpose: extra versions PRESENT in the store are ordinary
|
|
113
|
+
* pnpm residue. A version the lockfile NAMES and the store LACKS is the
|
|
114
|
+
* direction that misleads.
|
|
115
|
+
*/
|
|
116
|
+
function installBehindLockfile() {
|
|
117
|
+
const store = path.join(ROOT, 'node_modules', '.pnpm');
|
|
118
|
+
const lock = path.join(ROOT, 'pnpm-lock.yaml');
|
|
119
|
+
if (!existsSync(store) || !existsSync(lock)) return [];
|
|
120
|
+
const entries = readdirSync(store);
|
|
121
|
+
const missing = [];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
for (const [, name, version] of readFileSync(lock, 'utf8').matchAll(
|
|
124
|
+
/^\s{2}'?(@xemahq\/[a-z0-9-]+)@([0-9]+\.[0-9]+\.[0-9]+[^':(\s]*)'?:/gm,
|
|
125
|
+
)) {
|
|
126
|
+
const key = `${name}@${version}`;
|
|
127
|
+
if (seen.has(key)) continue;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
const dir = `${name.replace('/', '+')}@${version}`;
|
|
130
|
+
// A peer-suffixed directory is the SAME version, so presence is a prefix
|
|
131
|
+
// test. Exact matching would report every peer-resolved package as missing.
|
|
132
|
+
if (!existsSync(path.join(store, dir)) && !entries.some((e) => e.startsWith(`${dir}_`))) {
|
|
133
|
+
missing.push(key);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return missing;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A step is SKIPPED when this repository does not own that generator, never faked. */
|
|
140
|
+
const STEPS = [
|
|
141
|
+
{
|
|
142
|
+
id: 'prisma',
|
|
143
|
+
why: 'the generated Prisma clients the services TYPECHECK against',
|
|
144
|
+
perService: 'prisma:generate',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'build',
|
|
148
|
+
why: 'spec extraction boots each AppModule; an unbuilt closure fakes a cascade',
|
|
149
|
+
run: ['pnpm', '-r', 'build'],
|
|
150
|
+
always: true,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'specs',
|
|
154
|
+
why: 'the extracted OpenAPI documents',
|
|
155
|
+
perService: 'openapi',
|
|
156
|
+
gate: 'check:openapi-spec-current',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'clients',
|
|
160
|
+
why: 'the generated clients, which are derived FROM those specs',
|
|
161
|
+
perService: 'client:generate',
|
|
162
|
+
gate: 'check:client-spec-drift',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'ledgers',
|
|
166
|
+
why: "each biome's integrity claim over its own bytes, specs and clients included",
|
|
167
|
+
// `--allow-dirty` is REQUIRED here: without it the step is a no-op in exactly
|
|
168
|
+
// the case it exists for, because the biomes it must re-hash are the ones the
|
|
169
|
+
// specs and clients just dirtied, so the deriver skips precisely those and
|
|
170
|
+
// exits 0 — a green command over a skipped corpus.
|
|
171
|
+
//
|
|
172
|
+
// WHAT IT DOES NOT MEAN, corrected 2026-09-19. This said "SAFE HERE AND ONLY
|
|
173
|
+
// HERE, because `run()` refuses to start on a dirty tree". `run()` does no
|
|
174
|
+
// such thing: it REPORTS pre-existing modified paths and continues, and its
|
|
175
|
+
// only refusal is for an install behind its own lockfile. The claim made this
|
|
176
|
+
// authority internally contradictory with its own `run()` forty lines down,
|
|
177
|
+
// and it was the load-bearing half — it is the reason the flag reads as safe,
|
|
178
|
+
// so a reader who checked it would have stopped there.
|
|
179
|
+
//
|
|
180
|
+
// The honest statement is narrower. A ledger hashes the WORKING TREE, so
|
|
181
|
+
// anything already modified when this starts is hashed in too. For the
|
|
182
|
+
// workflow this serves — regenerating alongside edits you are about to commit
|
|
183
|
+
// — that is correct, and refusing would break the one loop the command exists
|
|
184
|
+
// for. In a SHARED checkout holding somebody else's modifications it IS a
|
|
185
|
+
// waived check, which is why `run()` prints those paths and asks the reader
|
|
186
|
+
// whether all of them are theirs.
|
|
187
|
+
run: ['node', 'tooling/codegen/derive-biome-index.mjs', '--allow-dirty'],
|
|
188
|
+
needs: 'derive:biome-index',
|
|
189
|
+
gate: 'check:biome-index-ledger',
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Surface classifications a human already decided, with the evidence.
|
|
195
|
+
*
|
|
196
|
+
* The client generator REFUSES to guess whether a retained export whose
|
|
197
|
+
* declaration moved is widening or narrowing — correctly, because a generated
|
|
198
|
+
* client does not record whether a type is a REQUEST or a RESPONSE, and those
|
|
199
|
+
* have opposite version consequences. That refusal stays.
|
|
200
|
+
*
|
|
201
|
+
* What this removes is the REPETITION. Before it, the same investigation —
|
|
202
|
+
* `npm pack` the published client, diff the declaration, decide — was owed by
|
|
203
|
+
* every run and every session, for a fact somebody had already established.
|
|
204
|
+
*
|
|
205
|
+
* It is deliberately NOT a global bump class. An entry names ONE export, and it
|
|
206
|
+
* is applied only when every export the generator names is recorded. Anything
|
|
207
|
+
* unrecognised still stops and asks, which is what keeps this from becoming
|
|
208
|
+
* `XEMA_CLIENT_BUMP_CLASS` set once and forgotten.
|
|
209
|
+
*/
|
|
210
|
+
function recordedSurfaceDecisions() {
|
|
211
|
+
const file = path.join(ROOT, 'tooling/release/client-surface-decisions.json');
|
|
212
|
+
if (!existsSync(file)) return new Map();
|
|
213
|
+
try {
|
|
214
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
215
|
+
return new Map((parsed.decisions ?? []).map((d) => [d.export, d]));
|
|
216
|
+
} catch {
|
|
217
|
+
return new Map(); // an unreadable ledger must not silently grant anything
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The exports a refusal names, e.g. `models/problemDetailsDto#ProblemDetailsDto`. */
|
|
222
|
+
function exportsNamedByRefusal(output) {
|
|
223
|
+
const named = new Set();
|
|
224
|
+
for (const line of output.split('\n')) {
|
|
225
|
+
const m = line.match(/exports whose DECLARATION changed \(\d+\): (.+)$/);
|
|
226
|
+
if (m) for (const e of m[1].split(/[,\s]+/).filter(Boolean)) named.add(e.trim());
|
|
227
|
+
}
|
|
228
|
+
return [...named];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sh(argv, { capture = false, env } = {}) {
|
|
232
|
+
return execFileSync(argv[0], argv.slice(1), {
|
|
233
|
+
cwd: ROOT,
|
|
234
|
+
encoding: 'utf8',
|
|
235
|
+
// `pipe` for stderr even when inheriting stdout: a generator REFUSAL has to be
|
|
236
|
+
// readable by the caller to be matched against a recorded decision, and an
|
|
237
|
+
// inherited stderr reaches the terminal and nothing else.
|
|
238
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['inherit', 'inherit', 'pipe'],
|
|
239
|
+
maxBuffer: 1 << 28,
|
|
240
|
+
...(env ? { env } : {}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Services this repository can extract, read from the workspace rather than listed. */
|
|
245
|
+
function servicesWith(script) {
|
|
246
|
+
const out = sh(['pnpm', '-r', '--depth', '-1', 'list', '--json'], { capture: true });
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(out);
|
|
250
|
+
} catch {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
const names = [];
|
|
254
|
+
for (const entry of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
255
|
+
const manifest = path.join(entry.path ?? '', 'package.json');
|
|
256
|
+
if (!existsSync(manifest)) continue;
|
|
257
|
+
try {
|
|
258
|
+
const json = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
259
|
+
if (json.scripts?.[script] && json.name) names.push(json.name);
|
|
260
|
+
} catch {
|
|
261
|
+
/* unreadable manifest is not a service */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The packages this working tree actually CHANGED, against the branch it will
|
|
269
|
+
* merge into.
|
|
270
|
+
*
|
|
271
|
+
* This exists because the chain above proves CONVERGENCE and nothing else.
|
|
272
|
+
* Measured 2026-09-19: `xema-base/develop` was red for four consecutive commits
|
|
273
|
+
* on `shell-published-capability-surface.spec.ts` — a declaration change whose
|
|
274
|
+
* only witness was a semantic test. `prisma`, `build`, `specs`, `clients`,
|
|
275
|
+
* `ledgers` and `pnpm check` are all GREEN on that tree, because none of them
|
|
276
|
+
* runs it. `typecheck green != tests green`, and readiness proved only the first.
|
|
277
|
+
*
|
|
278
|
+
* The set is derived from git rather than from a build-graph filter, because no
|
|
279
|
+
* repository in this fleet has a turbo affected-filter to copy (measured: zero
|
|
280
|
+
* `--filter=...[origin/...]` invocations fleet-wide) and inventing one here would
|
|
281
|
+
* be a second build authority. `git diff <merge-base>` needs no tooling, is the
|
|
282
|
+
* same question every reviewer asks, and includes UNCOMMITTED work — which is
|
|
283
|
+
* the whole point of a pre-push command.
|
|
284
|
+
*/
|
|
285
|
+
function changedPackages() {
|
|
286
|
+
let base;
|
|
287
|
+
for (const ref of ['origin/develop', 'origin/main']) {
|
|
288
|
+
try {
|
|
289
|
+
base = sh(['git', 'merge-base', 'HEAD', ref], { capture: true }).trim();
|
|
290
|
+
if (base) break;
|
|
291
|
+
} catch {
|
|
292
|
+
/* the ref is not fetched here; try the next one */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!base) return { base: undefined, paths: [], packages: [] };
|
|
296
|
+
const paths = sh(['git', 'diff', '--name-only', base, '--'], { capture: true })
|
|
297
|
+
.split('\n')
|
|
298
|
+
.map((l) => l.trim())
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
|
|
301
|
+
return { base, paths };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The workspace packages owning these repo-relative paths, by walking UP to the
|
|
306
|
+
* nearest `package.json`. Pure over the filesystem, so it is the half that is
|
|
307
|
+
* tested directly.
|
|
308
|
+
*
|
|
309
|
+
* Two rules, both deliberate:
|
|
310
|
+
* - the ROOT manifest never owns anything. It is not a workspace member, and
|
|
311
|
+
* its script is usually `turbo run <x>` — the whole fleet, which is the
|
|
312
|
+
* opposite of affected. The root is handled separately, as a FALLBACK.
|
|
313
|
+
* - a package not declaring THIS script owns nothing for this stage, so it is
|
|
314
|
+
* absent from the set rather than present-and-skipped. A skipped member
|
|
315
|
+
* inflates the corpus line and makes "0 ran" look like "all passed".
|
|
316
|
+
*/
|
|
317
|
+
export function packagesOwningPaths(root, relPaths, script = 'test') {
|
|
318
|
+
// CONTAINMENT IS A PATH QUESTION, NEVER A STRING PREFIX. `dir.startsWith(root)`
|
|
319
|
+
// admits a SIBLING whose name merely begins with the same characters — for
|
|
320
|
+
// root `/x/repo`, the path `/x/repo-legacy/pkg` passes — and this fleet keeps
|
|
321
|
+
// sibling repositories one directory apart, so a climbing path would have
|
|
322
|
+
// scheduled a NEIGHBOURING repository's test suite. Found by a mutation test
|
|
323
|
+
// whose escaping path was made to reach a real manifest.
|
|
324
|
+
const contains = (dir) => {
|
|
325
|
+
const rel = path.relative(root, dir);
|
|
326
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
327
|
+
};
|
|
328
|
+
const owners = new Set();
|
|
329
|
+
for (const rel of relPaths) {
|
|
330
|
+
let dir = path.dirname(path.resolve(root, rel));
|
|
331
|
+
while (contains(dir)) {
|
|
332
|
+
const manifest = path.join(dir, 'package.json');
|
|
333
|
+
// No `dir !== root` guard: `contains()` already excludes it, because
|
|
334
|
+
// `path.relative(root, root)` is the empty string. A mutation restoring
|
|
335
|
+
// that guard changed no behaviour, which is what identified it as inert
|
|
336
|
+
// rather than as an untested branch.
|
|
337
|
+
if (existsSync(manifest)) {
|
|
338
|
+
try {
|
|
339
|
+
const json = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
340
|
+
if (json.name && json.scripts?.[script]) owners.add(json.name);
|
|
341
|
+
} catch {
|
|
342
|
+
/* an unreadable manifest owns nothing */
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
const parent = path.dirname(dir);
|
|
347
|
+
if (parent === dir) break;
|
|
348
|
+
dir = parent;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return [...owners].sort();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Run the changed packages' own suites. NOT their dependents': this is the
|
|
356
|
+
* pre-push loop, and the dependent closure in this fleet reaches 144 packages —
|
|
357
|
+
* CI owns that question. What this owns is "the thing I just edited still
|
|
358
|
+
* passes its own tests", which is the one that was missing.
|
|
359
|
+
*/
|
|
360
|
+
/**
|
|
361
|
+
* Run ONE affected stage — the changed packages' own `<script>`.
|
|
362
|
+
*
|
|
363
|
+
* THREE STAGES, NOT ONE, because they are ORTHOGONAL PROOFS and this command is
|
|
364
|
+
* the only place that owns the ordering:
|
|
365
|
+
*
|
|
366
|
+
* tests green != typecheck green (a suite can compile nothing)
|
|
367
|
+
* typecheck green != tests green (compiling proves no behaviour)
|
|
368
|
+
* either green != lint green
|
|
369
|
+
*
|
|
370
|
+
* They run AFTER the generators, not before, and that order is not a preference:
|
|
371
|
+
* a service typechecks against its GENERATED client, so typechecking before
|
|
372
|
+
* `clients` grades a stale artifact and reports the CONSUMER — the same fake
|
|
373
|
+
* cascade the header above describes for spec extraction.
|
|
374
|
+
*
|
|
375
|
+
* Dependents are deliberately excluded: that closure reaches 144 packages in the
|
|
376
|
+
* largest repository here, and CI owns it. What this owns is "the thing I just
|
|
377
|
+
* edited still passes its own proofs".
|
|
378
|
+
*/
|
|
379
|
+
function runAffectedStage({ id, script, why }) {
|
|
380
|
+
console.log(`\n=== ${id} — ${why} ===`);
|
|
381
|
+
const { base, paths } = changedPackages();
|
|
382
|
+
if (!base) {
|
|
383
|
+
console.log(
|
|
384
|
+
' SKIPPED — neither origin/develop nor origin/main is fetched here, so\n' +
|
|
385
|
+
' there is no branch point to diff against. Fetch, or run this by hand.',
|
|
386
|
+
);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const packages = packagesOwningPaths(ROOT, paths, script);
|
|
390
|
+
// The CORPUS, printed with BOTH counts: "0 ran" and "0 failed" look identical
|
|
391
|
+
// otherwise, and a green command over an empty scan is not proof.
|
|
392
|
+
console.log(
|
|
393
|
+
` ${paths.length} changed path(s) since ${base.slice(0, 9)} -> ` +
|
|
394
|
+
`${packages.length} package(s) declaring \`${script}\``,
|
|
395
|
+
);
|
|
396
|
+
if (packages.length > 0) {
|
|
397
|
+
for (const name of packages) console.log(` - ${name}`);
|
|
398
|
+
for (const name of packages) {
|
|
399
|
+
// One at a time, so a failure NAMES the package; a single multi-filter
|
|
400
|
+
// invocation reports only the first.
|
|
401
|
+
sh(['pnpm', '--filter', name, script]);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// FALLBACK, and it is the difference between "inapplicable" and "not checked".
|
|
406
|
+
// Some repositories own a stage only at the root — `lint: eslint .` covers
|
|
407
|
+
// every package without any of them declaring `lint`. Skipping there would
|
|
408
|
+
// silently drop a real proof, so the root script runs, LABELLED as repo-wide
|
|
409
|
+
// rather than affected, so nobody reads it as an affected result.
|
|
410
|
+
if (paths.length > 0 && scripts[script]) {
|
|
411
|
+
console.log(
|
|
412
|
+
` no changed package declares \`${script}\`, but the ROOT does — running it\n` +
|
|
413
|
+
' REPO-WIDE. This is applicable-but-not-affected, and is reported as such.',
|
|
414
|
+
);
|
|
415
|
+
sh(['pnpm', 'run', script]);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
console.log(
|
|
419
|
+
` nothing to run — no changed package declares \`${script}\` and the root\n` +
|
|
420
|
+
' declares none either, so this stage is genuinely inapplicable here.',
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
function run() {
|
|
426
|
+
// ── WHAT `--allow-dirty` INCLUDES, STATED RATHER THAN ASSUMED ─────────────
|
|
427
|
+
// This regenerates derived artifacts and hashes them into biome ledgers, and a
|
|
428
|
+
// ledger hashes the WORKING TREE. So anything already modified when this starts
|
|
429
|
+
// is hashed in too.
|
|
430
|
+
//
|
|
431
|
+
// That is USUALLY exactly right — a developer regenerating alongside the edits
|
|
432
|
+
// they are about to commit is the case `--allow-dirty` was written for, and
|
|
433
|
+
// refusing it would break the one workflow this command exists to serve:
|
|
434
|
+
// change -> readiness -> push.
|
|
435
|
+
//
|
|
436
|
+
// It is wrong in ONE situation: a SHARED checkout where the modified files are
|
|
437
|
+
// somebody else's. That cannot be detected from here, so it is REPORTED rather
|
|
438
|
+
// than guessed at. A reader who sees a path they did not touch has the one fact
|
|
439
|
+
// they need; a refusal would have cost everyone else the workflow.
|
|
440
|
+
// PROVENANCE FIRST. Unlike a dirty tree, an install behind its own lockfile is
|
|
441
|
+
// never the intent — there is no workflow it serves, so this REFUSES rather
|
|
442
|
+
// than reporting. The remedy is one command and it is printed.
|
|
443
|
+
const behind = installBehindLockfile();
|
|
444
|
+
if (behind.length > 0) {
|
|
445
|
+
console.error(
|
|
446
|
+
`\nreadiness: REFUSING — this install is BEHIND its own lockfile.\n\n` +
|
|
447
|
+
`${behind.length} version(s) the lockfile records are NOT in the store:\n` +
|
|
448
|
+
behind.slice(0, 10).map((m) => ` ${m}`).join('\n') +
|
|
449
|
+
(behind.length > 10 ? `\n ... and ${behind.length - 10} more` : '') +
|
|
450
|
+
'\n\nEverything this command regenerates would be derived from the WRONG\n' +
|
|
451
|
+
'bytes, and the failure would name whichever file imports them rather\n' +
|
|
452
|
+
'than the stale package behind it.\n\n' +
|
|
453
|
+
' Remedy: pnpm install --frozen-lockfile\n',
|
|
454
|
+
);
|
|
455
|
+
return 1;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const preexisting = sh(['git', 'status', '--porcelain'], { capture: true })
|
|
459
|
+
.trim()
|
|
460
|
+
.split('\n')
|
|
461
|
+
.filter(Boolean);
|
|
462
|
+
if (preexisting.length > 0) {
|
|
463
|
+
console.log(
|
|
464
|
+
`readiness: ${preexisting.length} path(s) already modified — these are hashed into\n` +
|
|
465
|
+
'any biome ledger they belong to. Every one of them should be YOURS:\n' +
|
|
466
|
+
preexisting.slice(0, 10).map((l) => ` ${l}`).join('\n') +
|
|
467
|
+
(preexisting.length > 10 ? `\n ... and ${preexisting.length - 10} more` : '') +
|
|
468
|
+
'\n',
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const only = process.argv.find((a) => a.startsWith('--only='))?.slice('--only='.length);
|
|
473
|
+
console.log(`readiness: ${path.basename(ROOT)} — prisma -> build -> specs -> clients -> ledgers -> checks\n`);
|
|
474
|
+
|
|
475
|
+
for (const step of STEPS) {
|
|
476
|
+
if (only && only !== step.id) continue;
|
|
477
|
+
if (step.needs && !scripts[step.needs]) {
|
|
478
|
+
console.log(` - ${step.id}: SKIPPED — this repository has no \`${step.needs}\``);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
console.log(`\n=== ${step.id} — ${step.why} ===`);
|
|
482
|
+
if (step.run) {
|
|
483
|
+
sh(step.run);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const services = servicesWith(step.perService);
|
|
487
|
+
if (services.length === 0) {
|
|
488
|
+
console.log(` - no package declares \`${step.perService}\`; nothing to regenerate`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
console.log(` ${services.length} package(s) declare \`${step.perService}\``);
|
|
492
|
+
const decided = step.id === 'clients' ? recordedSurfaceDecisions() : new Map();
|
|
493
|
+
for (const name of services) {
|
|
494
|
+
// One at a time on purpose: a failure must name the service, and a single
|
|
495
|
+
// `--filter a --filter b` invocation reports only the first.
|
|
496
|
+
try {
|
|
497
|
+
sh(['pnpm', '--filter', name, step.perService]);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
const output = `${error?.stdout ?? ''}${error?.stderr ?? ''}${error?.message ?? ''}`;
|
|
500
|
+
const named = exportsNamedByRefusal(output);
|
|
501
|
+
if (named.length === 0) {
|
|
502
|
+
// stderr is PIPED so a refusal can be matched; an ordinary failure must
|
|
503
|
+
// not lose its diagnostics to that. Print what was captured before
|
|
504
|
+
// rethrowing, or the reader gets one line of `Command failed`.
|
|
505
|
+
if (output.trim()) console.error(output);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const unrecorded = named.filter((e) => !decided.has(e));
|
|
510
|
+
if (unrecorded.length > 0) {
|
|
511
|
+
console.error(
|
|
512
|
+
`\n ${name}: the generator cannot classify ${unrecorded.length} export(s), ` +
|
|
513
|
+
'and no recorded decision covers them:\n' +
|
|
514
|
+
unrecorded.map((e) => ` ${e}`).join('\n') +
|
|
515
|
+
'\n\n This needs a HUMAN, and the generator is right to ask. Decide it from the' +
|
|
516
|
+
'\n PUBLISHED tarball of the current version, never from this tree:' +
|
|
517
|
+
`\n npm pack <client>@<published> and diff the declaration.` +
|
|
518
|
+
'\n Then record it in tooling/release/client-surface-decisions.json with the' +
|
|
519
|
+
'\n evidence, so nobody has to establish it again.\n',
|
|
520
|
+
);
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const classes = [...new Set(named.map((e) => decided.get(e).class))];
|
|
525
|
+
if (classes.length !== 1) {
|
|
526
|
+
console.error(
|
|
527
|
+
`\n ${name}: recorded decisions disagree (${classes.join(', ')}). ` +
|
|
528
|
+
'Refusing to pick one.\n',
|
|
529
|
+
);
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Not a retry loop: ONE re-run, driven by recorded data rather than by hope,
|
|
534
|
+
// for a refusal whose every subject is already decided.
|
|
535
|
+
console.log(
|
|
536
|
+
` ${name}: applying recorded classification ${classes[0]} for ` +
|
|
537
|
+
`${named.join(', ')}`,
|
|
538
|
+
);
|
|
539
|
+
sh(['pnpm', '--filter', name, step.perService], {
|
|
540
|
+
env: { ...process.env, XEMA_CLIENT_BUMP_CLASS: classes[0] },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (only) {
|
|
547
|
+
console.log(`\nreadiness: ran --only=${only}. Run without it before pushing.`);
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
console.log('\n=== checks — the proof that the chain CONVERGED ===');
|
|
552
|
+
// A second pass must not be needed. If a gate here is red, a dependency was
|
|
553
|
+
// violated; find it rather than re-running this command.
|
|
554
|
+
sh(['pnpm', 'check']);
|
|
555
|
+
for (const gate of STEPS.map((s) => s.gate).filter(Boolean)) {
|
|
556
|
+
if (scripts[gate]) sh(['pnpm', gate]);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
for (const stage of [
|
|
560
|
+
{ id: 'typecheck', script: 'typecheck', why: 'the compiler, which a passing suite does not imply' },
|
|
561
|
+
{ id: 'lint', script: 'lint', why: 'the rules neither the compiler nor a suite enforces' },
|
|
562
|
+
{ id: 'tests', script: 'test', why: "the affected packages' own suites" },
|
|
563
|
+
]) {
|
|
564
|
+
runAffectedStage(stage);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
console.log(
|
|
568
|
+
'\nreadiness: converged. Every derived artifact describes the source beside it,\n' +
|
|
569
|
+
'and the repository\'s own checks agree. Safe to push.',
|
|
570
|
+
);
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Was this module RUN, or merely imported (by its own test)?
|
|
576
|
+
*
|
|
577
|
+
* BOTH SIDES ARE REALPATH'D, and that is the whole correctness of it. pnpm's
|
|
578
|
+
* isolated linker puts the package at `node_modules/.pnpm/<pkg>@<ver>/...` and
|
|
579
|
+
* symlinks `node_modules/@scope/<pkg>` to it, so `process.argv[1]` and
|
|
580
|
+
* `import.meta.url` can name the SAME FILE by two different paths. Comparing
|
|
581
|
+
* them unresolved gives an answer that depends on HOW THE PACKAGE WAS LINKED —
|
|
582
|
+
* true through pnpm's bin shim, which invokes the realpath, and false when the
|
|
583
|
+
* symlinked path is invoked. A predicate whose answer depends on the linker is
|
|
584
|
+
* not a predicate.
|
|
585
|
+
*
|
|
586
|
+
* The failure mode is the one this whole tool exists to prevent: a false answer
|
|
587
|
+
* takes the import branch, `run()` is never called, and the process exits 0 —
|
|
588
|
+
* a chain reporting success without running. Caught by a peer, not by me, and
|
|
589
|
+
* not by any test, which is why `invokedDirectly` is exported and asserted
|
|
590
|
+
* below against a REAL symlink.
|
|
591
|
+
*
|
|
592
|
+
* `fileURLToPath` rather than `new URL(...).pathname`: the latter is not the
|
|
593
|
+
* documented conversion and is wrong independently of the symlink — it breaks on
|
|
594
|
+
* a Windows drive letter and on any percent-encoded character, and a single
|
|
595
|
+
* SPACE in a checkout path is enough to produce one.
|
|
596
|
+
*/
|
|
597
|
+
export function invokedDirectly(argv1, metaUrl) {
|
|
598
|
+
if (argv1 === undefined) return false;
|
|
599
|
+
try {
|
|
600
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(metaUrl));
|
|
601
|
+
} catch {
|
|
602
|
+
// An unresolvable path cannot be this module; never guess YES, because a
|
|
603
|
+
// wrong YES runs the chain from a context that did not ask for it.
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const INVOKED_DIRECTLY = invokedDirectly(process.argv[1], import.meta.url);
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
if (!INVOKED_DIRECTLY) {
|
|
612
|
+
// imported for its exports; nothing to do
|
|
613
|
+
} else process.exit(run());
|
|
614
|
+
} catch (error) {
|
|
615
|
+
console.error(
|
|
616
|
+
`\nreadiness: FAILED at the step above. That is the ROOT cause — the steps after it\n` +
|
|
617
|
+
'were not reached, so do not read their absence as a second failure.\n' +
|
|
618
|
+
`\n ${error?.message?.split('\n')[0] ?? error}\n` +
|
|
619
|
+
'\nIf a CHECK failed rather than a generator, a dependency in\n' +
|
|
620
|
+
'prisma -> build -> specs -> clients -> ledgers was violated. Find it; do not re-run.\n',
|
|
621
|
+
);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { invokedDirectly, packagesOwningPaths } from './readiness.mjs';
|
|
8
|
+
|
|
9
|
+
/** A throwaway workspace: root + four members with different shapes. */
|
|
10
|
+
async function fixture() {
|
|
11
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'xema-readiness-'));
|
|
12
|
+
const write = async (rel, json) => {
|
|
13
|
+
await fs.mkdir(path.join(root, path.dirname(rel)), { recursive: true });
|
|
14
|
+
await fs.writeFile(path.join(root, rel), json, 'utf8');
|
|
15
|
+
};
|
|
16
|
+
// The ROOT manifest declares a test script ON PURPOSE: it must still own nothing.
|
|
17
|
+
await write('package.json', JSON.stringify({ name: 'root', scripts: { test: 'turbo run test' } }));
|
|
18
|
+
await write('packages/tested/package.json', JSON.stringify({ name: '@t/tested', scripts: { test: 'jest' } }));
|
|
19
|
+
await write('packages/untested/package.json', JSON.stringify({ name: '@t/untested', scripts: { build: 'tsc' } }));
|
|
20
|
+
await write('packages/outer/package.json', JSON.stringify({ name: '@t/outer', scripts: { test: 'jest' } }));
|
|
21
|
+
await write('packages/outer/nested/package.json', JSON.stringify({ name: '@t/nested', scripts: { test: 'jest' } }));
|
|
22
|
+
await write('packages/broken/package.json', '{ this is not json');
|
|
23
|
+
// Declares typecheck+lint but NOT test — the orthogonal-proof case.
|
|
24
|
+
await write('packages/compiled/package.json', JSON.stringify({ name: '@t/compiled', scripts: { typecheck: 'tsc --noEmit', lint: 'eslint .' } }));
|
|
25
|
+
return root;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('a changed file maps to the package that owns it', async () => {
|
|
29
|
+
const root = await fixture();
|
|
30
|
+
try {
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
packagesOwningPaths(root, ['packages/tested/src/a.ts']),
|
|
33
|
+
['@t/tested'],
|
|
34
|
+
);
|
|
35
|
+
// Two files in one package are ONE entry — the corpus line must not
|
|
36
|
+
// double-count, or "3 packages" would run one suite three times.
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
packagesOwningPaths(root, ['packages/tested/src/a.ts', 'packages/tested/src/b.ts']),
|
|
39
|
+
['@t/tested'],
|
|
40
|
+
);
|
|
41
|
+
} finally {
|
|
42
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a package with no test script owns nothing — absent, never present-and-skipped', async () => {
|
|
47
|
+
const root = await fixture();
|
|
48
|
+
try {
|
|
49
|
+
// Present-and-skipped would inflate the corpus line, and "0 ran" would then
|
|
50
|
+
// look exactly like "all passed".
|
|
51
|
+
assert.deepEqual(packagesOwningPaths(root, ['packages/untested/src/a.ts']), []);
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
packagesOwningPaths(root, ['packages/untested/src/a.ts', 'packages/tested/src/a.ts']),
|
|
54
|
+
['@t/tested'],
|
|
55
|
+
);
|
|
56
|
+
} finally {
|
|
57
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the ROOT manifest never owns anything, even declaring a test script', async () => {
|
|
62
|
+
const root = await fixture();
|
|
63
|
+
try {
|
|
64
|
+
// The fixture root declares `test: turbo run test` — the WHOLE fleet, which
|
|
65
|
+
// is the opposite of affected. Owning from the root would run everything on
|
|
66
|
+
// a README edit.
|
|
67
|
+
assert.deepEqual(packagesOwningPaths(root, ['README.md']), []);
|
|
68
|
+
assert.deepEqual(packagesOwningPaths(root, ['.github/workflows/ci.yaml']), []);
|
|
69
|
+
} finally {
|
|
70
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the NEAREST package wins, not an ancestor that also has a manifest', async () => {
|
|
75
|
+
const root = await fixture();
|
|
76
|
+
try {
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
packagesOwningPaths(root, ['packages/outer/nested/src/a.ts']),
|
|
79
|
+
['@t/nested'],
|
|
80
|
+
);
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
packagesOwningPaths(root, ['packages/outer/src/a.ts']),
|
|
83
|
+
['@t/outer'],
|
|
84
|
+
);
|
|
85
|
+
} finally {
|
|
86
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an unreadable manifest owns nothing and does not throw', async () => {
|
|
91
|
+
const root = await fixture();
|
|
92
|
+
try {
|
|
93
|
+
// A parse error here would abort the whole readiness run over one bad file.
|
|
94
|
+
assert.deepEqual(packagesOwningPaths(root, ['packages/broken/src/a.ts']), []);
|
|
95
|
+
// ...and it must not swallow its NEIGHBOURS.
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
packagesOwningPaths(root, ['packages/broken/src/a.ts', 'packages/tested/src/a.ts']),
|
|
98
|
+
['@t/tested'],
|
|
99
|
+
);
|
|
100
|
+
} finally {
|
|
101
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a path escaping the root owns nothing, even reaching a REAL sibling manifest', async () => {
|
|
106
|
+
const root = await fixture();
|
|
107
|
+
// The hazard is concrete, not theoretical: in the aggregator,
|
|
108
|
+
// `repos/xema-base/../xema-community/package.json` exists. A climbing path
|
|
109
|
+
// must not reach a NEIGHBOURING repository's package and schedule its tests.
|
|
110
|
+
const sibling = path.join(path.dirname(root), `${path.basename(root)}-sibling`);
|
|
111
|
+
await fs.mkdir(path.join(sibling, 'packages/foreign'), { recursive: true });
|
|
112
|
+
await fs.writeFile(
|
|
113
|
+
path.join(sibling, 'packages/foreign/package.json'),
|
|
114
|
+
JSON.stringify({ name: '@other/foreign', scripts: { test: 'jest' } }),
|
|
115
|
+
'utf8',
|
|
116
|
+
);
|
|
117
|
+
try {
|
|
118
|
+
const escaping = path.join('..', path.basename(sibling), 'packages/foreign/src/a.ts');
|
|
119
|
+
// Control: that manifest IS reachable when the sibling is the root, so the
|
|
120
|
+
// empty result below is the ROOT BOUND working, not a path that resolves
|
|
121
|
+
// to nothing.
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
packagesOwningPaths(sibling, ['packages/foreign/src/a.ts']),
|
|
124
|
+
['@other/foreign'],
|
|
125
|
+
);
|
|
126
|
+
assert.deepEqual(packagesOwningPaths(root, [escaping]), []);
|
|
127
|
+
} finally {
|
|
128
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
129
|
+
await fs.rm(sibling, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the owning scan is keyed on the SCRIPT, so the three stages see different sets', async () => {
|
|
134
|
+
const root = await fixture();
|
|
135
|
+
try {
|
|
136
|
+
const changed = ['packages/compiled/src/a.ts', 'packages/tested/src/a.ts'];
|
|
137
|
+
// `tests green != typecheck green` is not a slogan here — the two stages
|
|
138
|
+
// genuinely grade different packages, and a single set would silently run
|
|
139
|
+
// one package's suite and call the other proven.
|
|
140
|
+
assert.deepEqual(packagesOwningPaths(root, changed, 'test'), ['@t/tested']);
|
|
141
|
+
assert.deepEqual(packagesOwningPaths(root, changed, 'typecheck'), ['@t/compiled']);
|
|
142
|
+
assert.deepEqual(packagesOwningPaths(root, changed, 'lint'), ['@t/compiled']);
|
|
143
|
+
// Default stays `test`, so the original call shape is unchanged.
|
|
144
|
+
assert.deepEqual(packagesOwningPaths(root, changed), ['@t/tested']);
|
|
145
|
+
} finally {
|
|
146
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a script no package declares yields an EMPTY set, never the root', async () => {
|
|
151
|
+
const root = await fixture();
|
|
152
|
+
try {
|
|
153
|
+
// The root fixture declares `test`; asking for a script nothing declares must
|
|
154
|
+
// not fall back to it inside this function. The root FALLBACK is a decision
|
|
155
|
+
// made by the caller, which labels it repo-wide — folding it in here would
|
|
156
|
+
// report a repo-wide run as an affected one.
|
|
157
|
+
assert.deepEqual(packagesOwningPaths(root, ['packages/tested/src/a.ts'], 'nonexistent'), []);
|
|
158
|
+
} finally {
|
|
159
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The guard that decides whether this module RUNS. It had no test, and it was
|
|
165
|
+
* wrong: it compared `process.argv[1]` against `new URL(import.meta.url).pathname`
|
|
166
|
+
* UNRESOLVED, so under pnpm's isolated linker — which symlinks
|
|
167
|
+
* `node_modules/@scope/<pkg>` at `node_modules/.pnpm/<pkg>@<ver>/...` — the same
|
|
168
|
+
* file compared unequal whenever the symlinked path was the one invoked. The
|
|
169
|
+
* branch taken then does nothing and the process exits 0: a chain reporting
|
|
170
|
+
* success without running, which is the exact defect this tool exists to prevent.
|
|
171
|
+
*/
|
|
172
|
+
test('the run guard sees through a SYMLINK — pnpm links every package this way', async () => {
|
|
173
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'xema-guard-'));
|
|
174
|
+
try {
|
|
175
|
+
const real = path.join(root, 'real', 'readiness.mjs');
|
|
176
|
+
await fs.mkdir(path.dirname(real), { recursive: true });
|
|
177
|
+
await fs.writeFile(real, '// module\n', 'utf8');
|
|
178
|
+
const link = path.join(root, 'linked.mjs');
|
|
179
|
+
await fs.symlink(real, link);
|
|
180
|
+
|
|
181
|
+
const url = `file://${real}`;
|
|
182
|
+
// THE CASE THAT FAILED: invoked by the symlink, module loaded from the
|
|
183
|
+
// realpath. Unresolved string comparison says false; the answer is true.
|
|
184
|
+
assert.equal(invokedDirectly(link, url), true);
|
|
185
|
+
// And the plain case still holds.
|
|
186
|
+
assert.equal(invokedDirectly(real, url), true);
|
|
187
|
+
|
|
188
|
+
// A DIFFERENT file must stay false, or the guard would run the chain from
|
|
189
|
+
// any import — the opposite failure, and the louder one.
|
|
190
|
+
const other = path.join(root, 'other.mjs');
|
|
191
|
+
await fs.writeFile(other, '// not it\n', 'utf8');
|
|
192
|
+
assert.equal(invokedDirectly(other, url), false);
|
|
193
|
+
|
|
194
|
+
// No argv[1] at all (`node --eval`) is an import, never a run.
|
|
195
|
+
assert.equal(invokedDirectly(undefined, url), false);
|
|
196
|
+
// An unresolvable path must answer NO rather than throw: a wrong YES runs
|
|
197
|
+
// the whole chain from a context that never asked for it.
|
|
198
|
+
assert.equal(invokedDirectly(path.join(root, 'gone.mjs'), url), false);
|
|
199
|
+
} finally {
|
|
200
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|