@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.0",
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}${subpath}`;
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
+ }