@xemahq/repo-build-tooling 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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,13 +21,15 @@
|
|
|
21
21
|
"README.md"
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
|
-
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs"
|
|
24
|
+
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
25
|
+
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs"
|
|
25
26
|
},
|
|
26
27
|
"exports": {
|
|
27
28
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
28
|
-
"./package.json": "./package.json"
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs"
|
|
29
31
|
},
|
|
30
32
|
"scripts": {
|
|
31
|
-
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test"
|
|
33
|
+
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs"
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3
|
+
// A `workspace:` range that the local package does NOT satisfy is not an
|
|
4
|
+
// error — it is a SILENTLY DROPPED BUILD EDGE.
|
|
5
|
+
//
|
|
6
|
+
// `pnpm install` honours the lockfile, so the symlink is created either way and
|
|
7
|
+
// the dependency resolves at runtime. But `pnpm -r run build` derives its
|
|
8
|
+
// topological order from the CURRENT manifests: a dependency whose local
|
|
9
|
+
// version falls outside the declared range is not counted as a workspace
|
|
10
|
+
// dependency at all, so the consumer is scheduled in the FIRST batch —
|
|
11
|
+
// before the dependency has emitted `dist/`.
|
|
12
|
+
//
|
|
13
|
+
// Nothing about that is visible locally. A developer tree almost always has a
|
|
14
|
+
// stale `dist/` on disk from an earlier build, so `tsc` resolves the types and
|
|
15
|
+
// the build passes. CI installs clean, and the consumer fails with
|
|
16
|
+
// `TS2307: Cannot find module '@xemahq/…'` naming a package that is right
|
|
17
|
+
// there in `node_modules` as a symlink — which reads as a broken install
|
|
18
|
+
// rather than a misdeclared range, and sends you looking in the wrong place.
|
|
19
|
+
//
|
|
20
|
+
// That is exactly how it presented: `biome-activity-sdk` declared
|
|
21
|
+
// `workspace:^0.2.0` against a client that had moved to `0.3.0`, and it was
|
|
22
|
+
// the FIRST package pnpm tried to build.
|
|
23
|
+
//
|
|
24
|
+
// Fixing one occurrence is worthless — 58 manifests carried the same skew,
|
|
25
|
+
// each a latent version of the same failure waiting for its package to be
|
|
26
|
+
// scheduled first. Hence a check rather than a patch.
|
|
27
|
+
//
|
|
28
|
+
// `workspace:*` / `^` / `~` are always satisfied by definition and are not
|
|
29
|
+
// examined.
|
|
30
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { join, relative } from 'node:path';
|
|
34
|
+
import { pathToFileURL } from 'node:url';
|
|
35
|
+
|
|
36
|
+
// The repository being CHECKED, not the package doing the checking. As a
|
|
37
|
+
// shipped bin this file lives in the consumer's node_modules, so resolving the
|
|
38
|
+
// root from its own location would scan the package itself — 1 manifest, 0
|
|
39
|
+
// findings, and a confident green over nothing.
|
|
40
|
+
//
|
|
41
|
+
// `process.cwd()` is where the consumer's script runs, and it must look like a
|
|
42
|
+
// repo root: a bin that silently scans whatever directory it was invoked from
|
|
43
|
+
// is the same defect one level up.
|
|
44
|
+
const REPO_ROOT = process.cwd();
|
|
45
|
+
if (!existsSync(join(REPO_ROOT, 'package.json'))) {
|
|
46
|
+
console.error(
|
|
47
|
+
`::error::${REPO_ROOT} has no package.json — run this from the repository root ` +
|
|
48
|
+
'so it scans the repository, not whatever directory it was invoked from.',
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies'];
|
|
53
|
+
|
|
54
|
+
// ── Minimal range satisfaction ────────────────────────────────────────────
|
|
55
|
+
// Deliberately dependency-free: this runs in CI before any install in some
|
|
56
|
+
// lanes, and a boundary check that needs `node_modules` to answer is a check
|
|
57
|
+
// that cannot run when it matters most.
|
|
58
|
+
const parse = (v) => v.split('-')[0].split('.').map(Number);
|
|
59
|
+
const cmp = (a, b) => {
|
|
60
|
+
for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
61
|
+
return 0;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Does `version` satisfy `range`? Covers `^`, `~`, `>=` and exact. */
|
|
65
|
+
export function satisfies(version, range) {
|
|
66
|
+
const v = parse(version);
|
|
67
|
+
if (range.startsWith('>=')) return cmp(v, parse(range.slice(2).trim())) >= 0;
|
|
68
|
+
const op = range[0] === '^' || range[0] === '~' ? range[0] : '=';
|
|
69
|
+
const base = parse(op === '=' ? range : range.slice(1));
|
|
70
|
+
if (op === '=') return cmp(v, base) === 0;
|
|
71
|
+
if (cmp(v, base) < 0) return false;
|
|
72
|
+
// `^0.y.z` pins the minor (npm's 0.x rule) — which is precisely why a client
|
|
73
|
+
// moving 0.2.0 → 0.3.0 falls out of `^0.2.0`. `~` always pins the minor.
|
|
74
|
+
if (op === '~' || base[0] === 0) return v[0] === base[0] && v[1] === base[1];
|
|
75
|
+
return v[0] === base[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listManifests() {
|
|
79
|
+
return execFileSync(
|
|
80
|
+
'find',
|
|
81
|
+
[
|
|
82
|
+
REPO_ROOT,
|
|
83
|
+
'-name',
|
|
84
|
+
'package.json',
|
|
85
|
+
'-not',
|
|
86
|
+
'-path',
|
|
87
|
+
'*/node_modules/*',
|
|
88
|
+
'-not',
|
|
89
|
+
'-path',
|
|
90
|
+
'*/dist/*',
|
|
91
|
+
],
|
|
92
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
|
|
93
|
+
)
|
|
94
|
+
.split('\n')
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every `workspace:<range>` declaration the local package does not satisfy. */
|
|
99
|
+
export function findViolations({ rewrite = false } = {}) {
|
|
100
|
+
const manifests = [];
|
|
101
|
+
const localVersions = new Map();
|
|
102
|
+
for (const file of listManifests()) {
|
|
103
|
+
let pkg;
|
|
104
|
+
try {
|
|
105
|
+
pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
106
|
+
} catch {
|
|
107
|
+
continue; // a malformed manifest is another check's problem, not this one's
|
|
108
|
+
}
|
|
109
|
+
if (pkg.name && pkg.version) localVersions.set(pkg.name, pkg.version);
|
|
110
|
+
manifests.push({ file, pkg });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const violations = [];
|
|
114
|
+
for (const { file, pkg } of manifests) {
|
|
115
|
+
let mutated = false;
|
|
116
|
+
for (const field of DEP_FIELDS) {
|
|
117
|
+
for (const [dep, spec] of Object.entries(pkg[field] ?? {})) {
|
|
118
|
+
if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue;
|
|
119
|
+
const range = spec.slice('workspace:'.length);
|
|
120
|
+
if (range === '*' || range === '^' || range === '~') continue;
|
|
121
|
+
const local = localVersions.get(dep);
|
|
122
|
+
if (!local) continue; // not a workspace package here; nothing to compare
|
|
123
|
+
if (satisfies(local, range)) continue;
|
|
124
|
+
|
|
125
|
+
violations.push({ file: relative(REPO_ROOT, file), dep, range, local });
|
|
126
|
+
if (rewrite) {
|
|
127
|
+
pkg[field][dep] = `workspace:${range[0] === '~' ? '~' : '^'}${local}`;
|
|
128
|
+
mutated = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (mutated) writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
133
|
+
}
|
|
134
|
+
return violations;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function main() {
|
|
138
|
+
const rewrite = process.argv.includes('--fix');
|
|
139
|
+
const violations = findViolations({ rewrite });
|
|
140
|
+
|
|
141
|
+
if (violations.length === 0) {
|
|
142
|
+
console.log(
|
|
143
|
+
'workspace ranges match their local packages — every build edge is visible to pnpm.',
|
|
144
|
+
);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (rewrite) {
|
|
149
|
+
console.log(`Rewrote ${violations.length} workspace range(s):`);
|
|
150
|
+
for (const v of violations) {
|
|
151
|
+
console.log(` ${v.file}: ${v.dep} ${v.range} -> ${v.local}`);
|
|
152
|
+
}
|
|
153
|
+
console.log('\nRun `pnpm install` so the lockfile records the new specifiers.');
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.error(
|
|
158
|
+
`${violations.length} workspace range(s) exclude their own local package.\n` +
|
|
159
|
+
'pnpm drops these edges from the build graph, so the consumer is built\n' +
|
|
160
|
+
'BEFORE its dependency emits dist/ — green locally on a stale dist/, and\n' +
|
|
161
|
+
'a TS2307 in CI that names a package which is present as a symlink.\n',
|
|
162
|
+
);
|
|
163
|
+
for (const v of violations) {
|
|
164
|
+
console.error(
|
|
165
|
+
` ${v.file}\n ${v.dep} wants ${v.range} local ${v.local}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
console.error(
|
|
169
|
+
'\nFix: xema-check-workspace-ranges --fix && pnpm install',
|
|
170
|
+
);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Importing this module must not run the scan — the test suite imports it for
|
|
175
|
+
// `satisfies` alone.
|
|
176
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '.').href) {
|
|
177
|
+
process.exit(main());
|
|
178
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole check rests on ONE predicate — `satisfies` — so that is what these
|
|
3
|
+
* tests attack.
|
|
4
|
+
*
|
|
5
|
+
* The failure this guard exists to prevent is subtle in exactly one place: npm's
|
|
6
|
+
* `^0.y.z` rule pins the MINOR, not the major. A `satisfies` that treats `^` as
|
|
7
|
+
* "same major" uniformly would call `0.3.0` a match for `^0.2.0` and report a
|
|
8
|
+
* clean scan over all 58 real violations — a check that reports green while
|
|
9
|
+
* examining everything and concluding nothing. Every 0.x case below is aimed at
|
|
10
|
+
* that specific wrong implementation.
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
|
|
15
|
+
import { satisfies } from './check-workspace-range-matches-local.mjs';
|
|
16
|
+
|
|
17
|
+
test('^0.y.z pins the MINOR — the rule the whole check turns on', () => {
|
|
18
|
+
// The real defect: a client moved 0.2.0 -> 0.3.0 and fell out of every
|
|
19
|
+
// consumer's range, silently dropping the build edge.
|
|
20
|
+
assert.equal(satisfies('0.3.0', '^0.2.0'), false);
|
|
21
|
+
assert.equal(satisfies('0.2.9', '^0.2.0'), true);
|
|
22
|
+
assert.equal(satisfies('0.2.0', '^0.2.1'), false); // below the floor
|
|
23
|
+
assert.equal(satisfies('1.0.0', '^0.2.0'), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('^x.y.z (x > 0) pins the MAJOR', () => {
|
|
27
|
+
assert.equal(satisfies('1.9.3', '^1.2.0'), true);
|
|
28
|
+
assert.equal(satisfies('2.0.0', '^1.2.0'), false);
|
|
29
|
+
assert.equal(satisfies('1.1.0', '^1.2.0'), false); // below the floor
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('~ pins the minor at every major', () => {
|
|
33
|
+
assert.equal(satisfies('1.2.9', '~1.2.0'), true);
|
|
34
|
+
assert.equal(satisfies('1.3.0', '~1.2.0'), false);
|
|
35
|
+
assert.equal(satisfies('0.2.9', '~0.2.0'), true);
|
|
36
|
+
assert.equal(satisfies('0.3.0', '~0.2.0'), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('>= is a floor with no ceiling', () => {
|
|
40
|
+
assert.equal(satisfies('7.5.0', '>=0.14.0'), true);
|
|
41
|
+
assert.equal(satisfies('0.13.0', '>=0.14.0'), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an exact range means exactly that version', () => {
|
|
45
|
+
assert.equal(satisfies('1.2.3', '1.2.3'), true);
|
|
46
|
+
assert.equal(satisfies('1.2.4', '1.2.3'), false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a prerelease is compared on its release part, never string-wise', () => {
|
|
50
|
+
// `0.3.0-rc.1` must not read as "less than 0.3.0 therefore outside ^0.2.0
|
|
51
|
+
// is fine" — the range check has to reach the same verdict as `0.3.0`.
|
|
52
|
+
assert.equal(satisfies('0.3.0-rc.1', '^0.2.0'), false);
|
|
53
|
+
assert.equal(satisfies('0.2.5-rc.1', '^0.2.0'), true);
|
|
54
|
+
});
|
|
@@ -133,6 +133,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/
|
|
|
133
133
|
import { tmpdir } from 'node:os';
|
|
134
134
|
import { dirname, join, resolve } from 'node:path';
|
|
135
135
|
import process from 'node:process';
|
|
136
|
+
import { fileURLToPath } from 'node:url';
|
|
136
137
|
|
|
137
138
|
// Matches the two known leak shapes. We intentionally scope to `@xemahq`
|
|
138
139
|
// because:
|
|
@@ -216,7 +217,7 @@ const PNPM_STORE_LEAK_REGEX = new RegExp(
|
|
|
216
217
|
//
|
|
217
218
|
// Mirrors `tooling/boundaries/check-no-deep-require-leaks.mjs` — if you change
|
|
218
219
|
// one, change both.
|
|
219
|
-
const LEAK_DETECTORS = [
|
|
220
|
+
export const LEAK_DETECTORS = [
|
|
220
221
|
// STRUCTURAL, and deliberately not a mirror of any rewrite regex above.
|
|
221
222
|
//
|
|
222
223
|
// The property is objective and shape-free: a require specifier in built
|
|
@@ -243,7 +244,7 @@ const LEAK_DETECTORS = [
|
|
|
243
244
|
];
|
|
244
245
|
|
|
245
246
|
/** Every leak literal in `content`, de-duplicated, tagged with its shape. */
|
|
246
|
-
function findLeaks(content) {
|
|
247
|
+
export function findLeaks(content) {
|
|
247
248
|
const found = [];
|
|
248
249
|
for (const [shape, regex] of LEAK_DETECTORS) {
|
|
249
250
|
const matches = content.match(regex);
|
|
@@ -321,8 +322,16 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
|
|
|
321
322
|
* @param {string} subpath physical path inside the package, e.g. `dist/agent-composition/lib/x`
|
|
322
323
|
* @param {string} original the full leaked specifier, for the proof and the error
|
|
323
324
|
*/
|
|
325
|
+
// NUL is the separator in the cache key because it cannot occur in a package
|
|
326
|
+
// name or a subpath, so no two distinct specifiers can collide on a
|
|
327
|
+
// concatenation. It MUST stay spelled as the unicode ESCAPE, never as a raw NUL
|
|
328
|
+
// byte in the source: one raw byte makes every binary-skipping search tool
|
|
329
|
+
// (ugrep, ripgrep, `grep -I`) classify the WHOLE file as binary and skip it
|
|
330
|
+
// silently, and a file no search can read is a file no audit, no review and no
|
|
331
|
+
// boundary check covers. Enforced by the aggregator boundary gate
|
|
332
|
+
// `grep-readable-sources`.
|
|
324
333
|
function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
|
|
325
|
-
const cacheKey = `${pkg}
|
|
334
|
+
const cacheKey = `${pkg}\u0000${subpath}`;
|
|
326
335
|
if (specifierCache.has(cacheKey)) return specifierCache.get(cacheKey);
|
|
327
336
|
|
|
328
337
|
let target;
|
|
@@ -711,6 +720,16 @@ async function main() {
|
|
|
711
720
|
}
|
|
712
721
|
}
|
|
713
722
|
|
|
723
|
+
// Importing this module gives you `findLeaks` / `LEAK_DETECTORS` and runs
|
|
724
|
+
// nothing. The aggregator's post-build leak sweep imports the detector from
|
|
725
|
+
// here rather than keeping its own copy of the regexes — it HAD its own copy,
|
|
726
|
+
// missing two shapes, and reported "clean, 12052 files scanned" over 474 real
|
|
727
|
+
// leaks. One definition of what a leak is, or the second one is decoration.
|
|
728
|
+
const invokedDirectly =
|
|
729
|
+
process.argv[1] !== undefined &&
|
|
730
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
731
|
+
|
|
732
|
+
if (invokedDirectly) {
|
|
714
733
|
main().catch((err) => {
|
|
715
734
|
process.stderr.write(
|
|
716
735
|
`scrub-swagger-plugin-paths: FAILED — ${
|
|
@@ -719,3 +738,4 @@ main().catch((err) => {
|
|
|
719
738
|
);
|
|
720
739
|
process.exit(1);
|
|
721
740
|
});
|
|
741
|
+
}
|