@xemahq/repo-build-tooling 0.26.5 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,6 @@ pnpm exec xema-scrub-swagger-paths dist
34
34
  | `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
35
35
  | `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
36
36
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
37
- | `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents, and `test` and `openapi:current` (where defined) only on the packages the push changed; a fleet-wide change runs only `lint` and `typecheck` on the full graph; root `lint` only without turbo; a park-only push (`refs/xema-session/*`) skips |
38
37
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
39
38
 
40
39
  `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 `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.26.5",
3
+ "version": "0.27.0",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
@@ -25,8 +25,6 @@
25
25
  "xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
26
26
  "xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
27
27
  "xema-composed-image": "src/composed-image.mjs",
28
- "xema-install-git-hooks": "src/install-git-hooks.mjs",
29
- "xema-prepush-sanity": "src/prepush-sanity.mjs",
30
28
  "xema-promote-image": "src/promote-image.mjs",
31
29
  "xema-readiness": "src/readiness.mjs",
32
30
  "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
@@ -38,9 +36,7 @@
38
36
  "./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
39
37
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
40
38
  "./composed-image": "./src/composed-image.mjs",
41
- "./install-git-hooks": "./src/install-git-hooks.mjs",
42
39
  "./package.json": "./package.json",
43
- "./prepush-sanity": "./src/prepush-sanity.mjs",
44
40
  "./promote-image": "./src/promote-image.mjs",
45
41
  "./readiness": "./src/readiness.mjs",
46
42
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
@@ -51,9 +47,9 @@
51
47
  "@xemahq/release-policy": "^16.0.0"
52
48
  },
53
49
  "scripts": {
54
- "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs && node --test src/service-image.test.mjs && node --test src/install-git-hooks.test.mjs && node --test src/image-cpu-arch.test.mjs"
50
+ "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs"
55
51
  },
56
52
  "xemaRelease": {
57
- "sourceDigest": "sha256:f0df75e0b53e7d061fca29cca85baadc3e8d20ac245220164fe16d5e3f051adc"
53
+ "sourceDigest": "sha256:4a2cf6b565691b2837d7562f58925a40e3ad2284eded9395b72a74312788d417"
58
54
  }
59
55
  }
@@ -129,7 +129,7 @@
129
129
 
130
130
  import { createRequire } from 'node:module';
131
131
  import { readFileSync, realpathSync } from 'node:fs';
132
- import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
132
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
133
133
  import { tmpdir } from 'node:os';
134
134
  import { dirname, join, resolve } from 'node:path';
135
135
  import process from 'node:process';
@@ -168,14 +168,14 @@ const FLAT_LEAK_REGEX = new RegExp(
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
170
  // Capture group 1: relative path from the scrubbed file to the package directory.
171
- // Capture group 2: first path segment after dist/ = the subpath export name.
171
+ // Capture group 2: everything after dist/ (the physical subpath inside the package).
172
172
  // The public package name is NOT derived from the directory name or a fixed
173
173
  // scope: it is read from the target package's own package.json `name`, so
174
174
  // unscoped and differently-named workspace packages resolve correctly.
175
175
  // Scoped to paths that pass through `packages/`, `biomes/`, or `kernel/` to
176
176
  // avoid false-positive matches on intra-service relative requires.
177
177
  const RELATIVE_WORKSPACE_REGEX = new RegExp(
178
- `require\\("((?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*[a-z0-9][a-z0-9.-]*)/dist/([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-]*(?:/[^"]*)?)"\\)`,
179
179
  'g',
180
180
  );
181
181
 
@@ -323,6 +323,7 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
323
323
  * @param {string} pkg e.g. `@xemahq/kernel-contracts`
324
324
  * @param {string} subpath physical path inside the package, e.g. `dist/agent-composition/lib/x`
325
325
  * @param {string} original the full leaked specifier, for the proof and the error
326
+ * @param {string} [importer] the file holding the require, named in the refusal
326
327
  */
327
328
  // NUL is the separator in the cache key because it cannot occur in a package
328
329
  // name or a subpath, so no two distinct specifiers can collide on a
@@ -332,7 +333,7 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
332
333
  // silently, and a file no search can read is a file no audit, no review and no
333
334
  // boundary check covers. Enforced by the aggregator boundary gate
334
335
  // `grep-readable-sources`.
335
- function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
336
+ function publicSpecifierFor(resolveFrom, pkg, subpath, original, importer) {
336
337
  const cacheKey = `${pkg}\u0000${subpath}`;
337
338
  if (specifierCache.has(cacheKey)) return specifierCache.get(cacheKey);
338
339
 
@@ -412,6 +413,7 @@ function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
412
413
  throw new ScrubFailure(
413
414
  `no public specifier of ${pkg} resolves to the same file as\n` +
414
415
  ` ${original}\n` +
416
+ (importer ? ` required by ${importer}\n` : '') +
415
417
  ` tried: ${candidates.length > 0 ? candidates.join(', ') : '(none — the exports map admits no matching form)'}\n` +
416
418
  ` Add the needed entry to that package's "exports", or export the symbol from a\n` +
417
419
  ` path that is public. Rewriting to a specifier that resolves elsewhere would\n` +
@@ -586,10 +588,16 @@ async function scrubFile(path) {
586
588
  ` rather than reaching through dist/.`,
587
589
  );
588
590
  }
589
- out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, subpath) => {
591
+ out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, rest) => {
590
592
  count += 1;
591
593
  const name = packageNameAt(resolve(dirname(path), relPackageDir), match);
592
- return `require("${name}/${subpath}")`;
594
+ // Through the target's own `exports` map, never a hardcoded `<name>/<seg>`:
595
+ // a package that exports only "." has no such subpath, and the old form
596
+ // crashed at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED. When the root
597
+ // barrel re-exports the file, the root is the proved public form; when no
598
+ // exported subpath reaches it, the build refuses.
599
+ const original = match.slice('require("'.length, -2);
600
+ return `require("${publicSpecifierFor(resolveFrom, name, `dist/${rest}`, original, path)}")`;
593
601
  });
594
602
  if (count > 0) {
595
603
  await writeFile(path, out, 'utf8');
@@ -712,6 +720,13 @@ async function expectFailure(label, fn) {
712
720
  return threw;
713
721
  }
714
722
 
723
+ /** Make `name` resolvable from `consumerDir` the way a workspace install does. */
724
+ async function linkWorkspacePackage(consumerDir, name, packageDir) {
725
+ const link = join(consumerDir, 'node_modules', name);
726
+ await mkdir(dirname(link), { recursive: true });
727
+ await symlink(packageDir, link, 'dir');
728
+ }
729
+
715
730
  async function selfTest() {
716
731
  const root = await mkdtemp(join(tmpdir(), 'scrub-swagger-selftest-'));
717
732
  try {
@@ -830,11 +845,18 @@ async function selfTest() {
830
845
  ['packages/contracts/plain-dir', 'plain-contract'],
831
846
  ['packages/contracts/other-dir', '@xemahq/renamed'],
832
847
  ];
848
+ await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
833
849
  for (const [dir, name] of pkgs) {
834
850
  await mkdir(join(ws, dir, 'dist/lib'), { recursive: true });
835
- await writeFile(join(ws, dir, 'package.json'), JSON.stringify({ name }), 'utf8');
851
+ await writeFile(
852
+ join(ws, dir, 'package.json'),
853
+ JSON.stringify({ name, exports: { './*': './dist/*/index.js' } }),
854
+ 'utf8',
855
+ );
856
+ await writeFile(join(ws, dir, 'dist/lib/index.js'), 'module.exports = require("./x");\n', 'utf8');
857
+ await writeFile(join(ws, dir, 'dist/lib/x.js'), 'module.exports = {};\n', 'utf8');
858
+ await linkWorkspacePackage(join(ws, 'services/svc'), name, join(ws, dir));
836
859
  }
837
- await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
838
860
  const wsFile = join(ws, 'services/svc/dist/dto.js');
839
861
  const relOf = (dir) => `../../../${dir}/dist/lib/x`;
840
862
  await writeFile(
@@ -851,6 +873,42 @@ async function selfTest() {
851
873
  throw new Error(`self-test: relative-workspace rewrite missing ${expected}: ${wsAfter}`);
852
874
  }
853
875
  }
876
+
877
+ // 4c. A package that exports ONLY "." (xema-base authorization-nest). The
878
+ // swagger plugin requires the enum's declaring file; `<name>/<file>` is not
879
+ // exported and crashed at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED.
880
+ // - root barrel re-exports the file -> the root is the proved form;
881
+ // - no exported subpath reaches the file -> the build refuses, naming the
882
+ // package, the file and the importing module.
883
+ const dot = join(root, 'dot');
884
+ const dotPkg = join(dot, 'packages/sdks/dot-only');
885
+ await mkdir(join(dotPkg, 'dist'), { recursive: true });
886
+ await mkdir(join(dot, 'svc/dist'), { recursive: true });
887
+ await writeFile(
888
+ join(dotPkg, 'package.json'),
889
+ JSON.stringify({ name: '@xemahq/dot-only', exports: { '.': './dist/index.js' } }),
890
+ 'utf8',
891
+ );
892
+ await writeFile(join(dotPkg, 'dist/index.js'), 'module.exports = require("./grant-wire");\n', 'utf8');
893
+ await writeFile(join(dotPkg, 'dist/grant-wire.js'), 'module.exports = { Kind: {} };\n', 'utf8');
894
+ await writeFile(join(dotPkg, 'dist/internal-only.js'), 'module.exports = { Hidden: {} };\n', 'utf8');
895
+ await linkWorkspacePackage(join(dot, 'svc'), '@xemahq/dot-only', dotPkg);
896
+ const dotDto = (file) =>
897
+ `class Dto {\n static _OPENAPI_METADATA_FACTORY() {\n return { kind: { type: () => require("../../packages/sdks/dot-only/dist/${file}").Kind } };\n }\n}\n`;
898
+ const dotFile = join(dot, 'svc/dist/dto.js');
899
+ await writeFile(dotFile, dotDto('grant-wire'), 'utf8');
900
+ await run([join(dot, 'svc/dist')]);
901
+ const dotAfter = await readFile(dotFile, 'utf8');
902
+ if (!dotAfter.includes('require("@xemahq/dot-only").Kind') || dotAfter.includes('dot-only/')) {
903
+ throw new Error(`self-test: a "."-only package must map to its root, got: ${dotAfter}`);
904
+ }
905
+ await writeFile(dotFile, dotDto('internal-only'), 'utf8');
906
+ const dotRefusal = await expectFailure('unexported file no exported subpath reaches', () => run([join(dot, 'svc/dist')]));
907
+ for (const named of ['@xemahq/dot-only', 'internal-only', dotFile]) {
908
+ if (!dotRefusal.message.includes(named)) {
909
+ throw new Error(`self-test: the refusal must name ${named}; got: ${dotRefusal.message}`);
910
+ }
911
+ }
854
912
  // A target directory without its own package.json is a hard failure.
855
913
  const orphan = join(root, 'orphan');
856
914
  await mkdir(join(orphan, 'svc/dist'), { recursive: true });
@@ -1,323 +0,0 @@
1
- #!/usr/bin/env node
2
- // ═══════════════════════════════════════════════════════════════════════════
3
- // `xema-install-git-hooks` — run from `prepare`: install the lefthook git hooks, but only where hooks can exist.
4
- //
5
- // `prepare` runs on EVERY `pnpm install`, and one of those installs happens
6
- // inside the service Docker image build, which has neither a `.git` directory
7
- // (the build context excludes it) nor a `git` binary. A bare
8
- // `"prepare": "lefthook install"` therefore fails the image build outright:
9
- //
10
- // . prepare: Error: exec: "git": executable file not found in $PATH
11
- // ERROR: failed to solve: process "... pnpm install --offline
12
- // --filter ${SERVICE}... --frozen-lockfile" exit code: 1
13
- //
14
- // That is not a hook problem to work around — it is a category error. Git
15
- // hooks are a property of a developer's WORKING TREE; an image build has no
16
- // working tree to hook, so there is nothing to install and nothing has gone
17
- // wrong. This script says exactly that, out loud, and exits 0.
18
- //
19
- // What it does NOT do is swallow a real failure. In an actual git work tree
20
- // the install must succeed: a non-zero `lefthook install` propagates, because
21
- // a developer whose pre-push hook silently failed to install is a developer
22
- // who finds out from CI instead. The two outcomes are distinguished by a
23
- // deterministic fact — "is there a git work tree here" — never by an `|| true`.
24
- //
25
- // ── WHY THIS SCRIPT RESOLVES THE BINARY ITSELF ────────────────────────────
26
- //
27
- // It used to `spawnSync('lefthook', …)`, i.e. rely on PATH / the caller's
28
- // `.bin/`. Inside the AGGREGATOR checkout that is unreachable for a carved
29
- // repository, and the failure was silent in both halves:
30
- //
31
- // 1. `lefthook install` bakes an ABSOLUTE path to the binary that ran it
32
- // into the generated hook (measured: re-running `install` from a
33
- // different binary rewrites that line). A carved repo's hook was baked by
34
- // a `pnpm install` run INSIDE that repo — the escape hatch — so it points
35
- // at `repos/<repo>/node_modules/.pnpm/lefthook-*/bin/lefthook`.
36
- // 2. That store is exactly what `.claude/rules/package-store-ownership.md`
37
- // forbids and `tooling/dev/repair-package-stores.mjs` deletes. Afterwards
38
- // the baked path dangles, and every remaining fallback in lefthook's
39
- // generated hook misses too: lefthook is not on PATH, and a carved repo
40
- // root is not an aggregator workspace member, so it has no `node_modules`
41
- // and no `.bin/` at all.
42
- // 3. lefthook's generated hook ends its search with
43
- // `echo "Can't find lefthook in PATH"` — and NO non-zero exit. So the
44
- // hook printed a warning and returned 0. Measured 2026-08-17: every one
45
- // of the ten carved repositories' `pre-push` hooks was in this state, so
46
- // every local push in this checkout was unguarded while LOOKING guarded.
47
- //
48
- // The fix is the pattern `package-store-ownership.md` already documents for
49
- // carved root scripts: resolve by PATH-INDEPENDENT node resolution instead of
50
- // a binary name. `createRequire(...).resolve('lefthook/bin/index.js')` walks up
51
- // from this file — `repos/<repo>/node_modules` (absent) → the aggregator's
52
- // `node_modules` (present) — and in a standalone clone it finds the repo's own
53
- // copy on the first hop. Same script, both topologies, no per-machine config.
54
- //
55
- // ── AND WHY IT HARDENS THE GENERATED HOOK ─────────────────────────────────
56
- //
57
- // Resolving the binary fixes today's breakage. It does not fix the SHAPE of
58
- // the defect: a baked absolute path can dangle again — another store repair,
59
- // a `pnpm store prune`, a different machine, a moved checkout — and lefthook's
60
- // template would once more warn-and-return-0. A gate whose only failure mode
61
- // is a message nobody reads is the failure this repository names most often.
62
- //
63
- // So after installing, every generated hook's dead-end branch is rewritten to
64
- // print the reason and `exit 1`. An unresolvable lefthook now REFUSES the
65
- // push instead of waving it through; `git push --no-verify` remains the
66
- // deliberate, visible bypass.
67
- //
68
- // The rewrite ASSERTS rather than assumes: the marker must be present exactly
69
- // once in freshly generated content, and if lefthook's template ever changes
70
- // so it is not, this fails loudly instead of leaving hooks quietly unhardened.
71
- //
72
- // ── AND WHY IT SWEEPS SUBMODULES ──────────────────────────────────────────
73
- //
74
- // A carved repository's root is not an aggregator workspace member, so its own
75
- // `prepare` NEVER runs from an aggregator install — which is why the fix above
76
- // would otherwise be inert in the only checkout that has the bug. The
77
- // aggregator install already owns every `node_modules` beneath `repos/`
78
- // (`package-store-ownership.md`); it therefore owns the hooks baked against
79
- // them. Each submodule that carries a lefthook config gets the same install +
80
- // hardening, from the same resolved binary.
81
- //
82
- // In a standalone carved clone this loop is naturally inert — those
83
- // repositories have no submodules — so one byte-identical script serves both.
84
- // ═══════════════════════════════════════════════════════════════════════════
85
-
86
- import { spawnSync } from 'node:child_process';
87
- import { createRequire } from 'node:module';
88
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
89
- import { dirname, join, resolve } from 'node:path';
90
-
91
- /** Printed with a stable prefix so the reason is greppable in build logs. */
92
- const TAG = '[prepare:git-hooks]';
93
-
94
- /** The directory `prepare` runs in: the repository (or aggregator) root. */
95
- const ROOT = process.cwd();
96
-
97
- function skip(reason) {
98
- console.log(`${TAG} skipped — ${reason}. Nothing to install here.`);
99
- process.exit(0);
100
- }
101
-
102
- function die(message) {
103
- console.error(`${TAG} ${message}`);
104
- process.exit(1);
105
- }
106
-
107
- // ── 1. Is there anything to hook at all? ──────────────────────────────────
108
- //
109
- // `git rev-parse --git-dir` is the cheapest total answer to "is this a git
110
- // work tree": it is non-zero outside one, and ENOENT when git is absent
111
- // entirely. Both are the same verdict — no tree, no hooks.
112
- // This is a dev tool, not service code, so it may read the process env.
113
- if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
114
- skip("CI runner: git hooks belong to a developer's working tree");
115
- }
116
-
117
- const probe = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' });
118
-
119
- if (probe.error?.code === 'ENOENT') {
120
- skip('no `git` executable on PATH (image build / CI container)');
121
- }
122
- if (probe.error) {
123
- skip(`could not probe for a git work tree (${probe.error.message})`);
124
- }
125
- if (probe.status !== 0) {
126
- skip('not inside a git work tree (installed as a dependency or tarball)');
127
- }
128
-
129
- // ── 2. One-time migration off the hand-rolled `.husky/` directory ─────────
130
- //
131
- // This repository used to point `core.hooksPath` at a tracked `.husky/`, wired
132
- // up by every developer running `git config core.hooksPath .husky` BY HAND —
133
- // which is why almost nobody was wired up. lefthook writes into `.git/hooks`,
134
- // and a stale `core.hooksPath` silently wins over it, so the install would
135
- // report success while no hook ever fired.
136
- //
137
- // Cleared ONLY when it points at a directory that no longer exists. A
138
- // `core.hooksPath` someone set deliberately, to somewhere real, is left alone.
139
- const hooksPath = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
140
- encoding: 'utf8',
141
- });
142
- const configured = (hooksPath.stdout ?? '').trim();
143
- if (configured.length > 0 && !existsSync(configured)) {
144
- console.log(
145
- `${TAG} clearing core.hooksPath -> ${configured} (that directory is gone; ` +
146
- 'it would silently win over the hooks installed below)',
147
- );
148
- const unset = spawnSync('git', ['config', '--unset', 'core.hooksPath'], {
149
- stdio: 'inherit',
150
- });
151
- if (unset.status !== 0) {
152
- die(
153
- 'could not clear core.hooksPath. Run `git config --unset core.hooksPath` ' +
154
- 'yourself, or the hooks below will not fire.',
155
- );
156
- }
157
- }
158
-
159
- // ── 3. Resolve the lefthook launcher, PATH-independently ──────────────────
160
- //
161
- // See the header. `lefthook`'s npm package exposes `bin/index.js`, a Node
162
- // launcher that execs the platform binary; the binary reports its OWN absolute
163
- // path to `lefthook install`, which is the path that gets baked into the hook.
164
- // So resolving the launcher is enough to fix what gets baked.
165
- //
166
- // Resolved from the repository root, not from this file: when installed as a
167
- // dependency this file lives in a package store that cannot see the repo's
168
- // own `lefthook` devDependency.
169
- const require = createRequire(join(ROOT, 'package.json'));
170
-
171
- let launcher;
172
- try {
173
- launcher = require.resolve('lefthook/bin/index.js');
174
- } catch {
175
- // Reachable when the tree was installed with `--ignore-scripts` semantics or
176
- // a filtered/production install that omits root devDependencies. Loud, but
177
- // not fatal: the hooks are a dev affordance, and the install itself is fine.
178
- skip('`lefthook` is not installed (root devDependencies were not installed)');
179
- }
180
-
181
- // ── 4. Hardening the generated hook ───────────────────────────────────────
182
-
183
- /**
184
- * lefthook's dead end. Present verbatim in every hook it generates (2.1.10),
185
- * as the `else` arm of the binary search in `call_lefthook`. It prints and
186
- * falls out of the function, so the hook exits 0 and the operation proceeds
187
- * completely unverified.
188
- */
189
- const DEAD_END = ' echo "Can\'t find lefthook in PATH"\n';
190
-
191
- /** Proof our rewrite is already applied — makes the pass idempotent. */
192
- const SENTINEL = '# xema:lefthook-unresolved-is-fatal';
193
-
194
- /**
195
- * The replacement. Single-quoted so nothing in it is expanded by `sh`, and
196
- * written to stderr because that is where a refusal belongs. `exit 1` inside a
197
- * shell function exits the whole hook, which is exactly the intent.
198
- */
199
- const REFUSAL = [
200
- ` ${SENTINEL}`,
201
- " echo 'lefthook is installed as a git hook here, but its binary cannot' >&2",
202
- " echo 'be found — so THIS HOOK JUST VERIFIED NOTHING.' >&2",
203
- " echo '' >&2",
204
- " echo 'Refusing the operation rather than letting it through unguarded.' >&2",
205
- " echo '' >&2",
206
- " echo ' Fix: run `pnpm install` in the checkout that owns this tree' >&2",
207
- " echo ' node_modules. That re-runs prepare, which re-bakes this' >&2",
208
- " echo ' hook against a binary that exists.' >&2",
209
- " echo ' Deliberate bypass: git push --no-verify' >&2",
210
- ' exit 1',
211
- '',
212
- ].join('\n');
213
-
214
- /**
215
- * Rewrite one generated hook so an unresolvable lefthook is fatal.
216
- * @returns {'hardened'|'already'} never silently "nothing to do".
217
- */
218
- function hardenHookFile(file) {
219
- const source = readFileSync(file, 'utf8');
220
-
221
- if (source.includes(SENTINEL)) return 'already';
222
-
223
- const occurrences = source.split(DEAD_END).length - 1;
224
- if (occurrences !== 1) {
225
- die(
226
- `cannot harden ${file}: expected lefthook's "can't find" branch exactly ` +
227
- `once, found ${occurrences}. lefthook's hook template has changed — ` +
228
- 're-derive this rewrite rather than leaving hooks unhardened.',
229
- );
230
- }
231
-
232
- writeFileSync(file, source.replace(DEAD_END, REFUSAL), { mode: 0o755 });
233
- return 'hardened';
234
- }
235
-
236
- // ── 5. Install + harden, here and in every hooked submodule ───────────────
237
-
238
- /** Absolute path to a work tree's hooks directory, per git itself. */
239
- function hooksDirOf(cwd) {
240
- const out = spawnSync(
241
- 'git',
242
- ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
243
- { cwd, encoding: 'utf8' },
244
- );
245
- if (out.status !== 0) return null;
246
- const dir = (out.stdout ?? '').trim();
247
- return dir.length > 0 ? dir : null;
248
- }
249
-
250
- function installIn(cwd, label) {
251
- const install = spawnSync(process.execPath, [launcher, 'install'], {
252
- cwd,
253
- stdio: 'inherit',
254
- });
255
-
256
- if (install.error) {
257
- die(`failed to run \`lefthook install\` in ${label}: ${install.error.message}`);
258
- }
259
- if (install.status !== 0) {
260
- die(
261
- `\`lefthook install\` exited ${install.status} in ${label}. Git hooks are ` +
262
- 'NOT installed; fix this rather than pushing without them.',
263
- );
264
- }
265
-
266
- const dir = hooksDirOf(cwd);
267
- if (dir === null || !existsSync(dir)) {
268
- die(`\`lefthook install\` reported success in ${label} but ${dir ?? 'its hooks directory'} does not exist.`);
269
- }
270
-
271
- // Harden every hook lefthook generated — identified by the search function
272
- // it writes, so a pre-existing hand-written hook is never touched.
273
- let hardened = 0;
274
- for (const name of readdirSync(dir)) {
275
- if (name.endsWith('.sample')) continue;
276
- const file = join(dir, name);
277
- if (!statSync(file).isFile()) continue;
278
- const body = readFileSync(file, 'utf8');
279
- if (!body.includes('call_lefthook')) continue;
280
- if (hardenHookFile(file) === 'hardened') hardened += 1;
281
- }
282
-
283
- if (hardened > 0) {
284
- console.log(`${TAG} ${label}: hardened ${hardened} hook(s) — an unresolvable lefthook now exits 1.`);
285
- }
286
- }
287
-
288
- installIn(ROOT, '.');
289
-
290
- // Submodules that carry their own lefthook config. Read from `.gitmodules`
291
- // rather than `git submodule foreach`, which would spawn a shell per entry
292
- // across a 28-submodule tree for a question two greps answer.
293
- const CONFIGS = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml'];
294
-
295
- const gitmodules = resolve(ROOT, '.gitmodules');
296
- if (existsSync(gitmodules)) {
297
- const listed = spawnSync(
298
- 'git',
299
- ['config', '--file', gitmodules, '--get-regexp', String.raw`^submodule\..*\.path$`],
300
- { encoding: 'utf8' },
301
- );
302
-
303
- // Exit 1 means "no matching keys", which is a legitimate empty answer.
304
- if (listed.status !== 0 && listed.status !== 1) {
305
- die(`could not read submodule paths from ${gitmodules} (exit ${listed.status}).`);
306
- }
307
-
308
- const paths = (listed.stdout ?? '')
309
- .split('\n')
310
- .map((line) => line.split(' ').slice(1).join(' ').trim())
311
- .filter((p) => p.length > 0);
312
-
313
- for (const rel of paths) {
314
- const dir = resolve(dirname(gitmodules), rel);
315
- if (!CONFIGS.some((name) => existsSync(join(dir, name)))) continue;
316
- // An uninitialised submodule is an empty directory, not a work tree.
317
- if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd: dir, stdio: 'ignore' }).status !== 0) {
318
- console.log(`${TAG} ${rel}: not initialised — skipping (run \`git submodule update --init\`).`);
319
- continue;
320
- }
321
- installIn(dir, rel);
322
- }
323
- }
@@ -1,85 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { spawnSync } from 'node:child_process';
3
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { after, describe, it } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'install-git-hooks.mjs');
10
- const made = [];
11
-
12
- after(() => {
13
- for (const dir of made) rmSync(dir, { recursive: true, force: true });
14
- });
15
-
16
- function tempDir() {
17
- const dir = mkdtempSync(join(tmpdir(), 'xema-hooks-'));
18
- made.push(dir);
19
- return dir;
20
- }
21
-
22
- function run(cwd, env = {}) {
23
- const base = { ...process.env, CI: '', GITHUB_ACTIONS: '' };
24
- return spawnSync(process.execPath, [SCRIPT], { cwd, env: { ...base, ...env }, encoding: 'utf8' });
25
- }
26
-
27
- /** A git work tree whose `lefthook` is a stub that writes a lefthook-shaped hook. */
28
- function repoWithFakeLefthook() {
29
- const dir = tempDir();
30
- spawnSync('git', ['init', '-q'], { cwd: dir });
31
- writeFileSync(join(dir, 'package.json'), '{}');
32
- const bin = join(dir, 'node_modules', 'lefthook', 'bin');
33
- mkdirSync(bin, { recursive: true });
34
- writeFileSync(join(dir, 'node_modules', 'lefthook', 'package.json'), '{"name":"lefthook"}');
35
- writeFileSync(
36
- join(bin, 'index.js'),
37
- [
38
- "const fs = require('node:fs');",
39
- "fs.mkdirSync('.git/hooks', { recursive: true });",
40
- "fs.writeFileSync('.git/hooks/pre-push', 'call_lefthook() {\\n if x; then\\n :\\n else\\n echo \"Can\\'t find lefthook in PATH\"\\n fi\\n}\\n');",
41
- ].join('\n'),
42
- );
43
- return dir;
44
- }
45
-
46
- describe('xema-install-git-hooks', () => {
47
- for (const env of [{ CI: 'true' }, { GITHUB_ACTIONS: 'true' }]) {
48
- it(`is a no-op on CI (${Object.keys(env)[0]})`, () => {
49
- const dir = repoWithFakeLefthook();
50
- const out = run(dir, env);
51
- assert.equal(out.status, 0);
52
- assert.match(out.stdout, /skipped — CI runner/);
53
- assert.equal(existsSync(join(dir, '.git', 'hooks', 'pre-push')), false);
54
- });
55
- }
56
-
57
- it('is a no-op outside a git work tree', () => {
58
- const dir = tempDir();
59
- const out = run(dir, { GIT_CEILING_DIRECTORIES: dirname(dir) });
60
- assert.equal(out.status, 0);
61
- assert.match(out.stdout, /not inside a git work tree/);
62
- });
63
-
64
- it('skips when lefthook is not installed', () => {
65
- const dir = tempDir();
66
- spawnSync('git', ['init', '-q'], { cwd: dir });
67
- writeFileSync(join(dir, 'package.json'), '{}');
68
- const out = run(dir);
69
- assert.equal(out.status, 0);
70
- assert.match(out.stdout, /`lefthook` is not installed/);
71
- });
72
-
73
- it('installs and hardens the hook outside CI, idempotently', () => {
74
- const dir = repoWithFakeLefthook();
75
- const first = run(dir);
76
- assert.equal(first.status, 0, first.stderr);
77
- assert.match(first.stdout, /hardened 1 hook/);
78
- const hook = readFileSync(join(dir, '.git', 'hooks', 'pre-push'), 'utf8');
79
- assert.match(hook, /xema:lefthook-unresolved-is-fatal/);
80
- assert.match(hook, /exit 1/);
81
- // Re-running regenerates then re-hardens; the stub rewrites the hook each time.
82
- const second = run(dir);
83
- assert.equal(second.status, 0, second.stderr);
84
- });
85
- });
@@ -1,473 +0,0 @@
1
- #!/usr/bin/env node
2
- // ═══════════════════════════════════════════════════════════════════════════
3
- // Pre-push: cheap sanity on what the push changes. CI is the proof (owner
4
- // 10-13/10-14). One verb for every repository's lefthook `pre-push`, so the
5
- // logic is not copied per repo.
6
- //
7
- // With turbo (a `turbo.json` and a turbo dependency): turbo `lint`, `typecheck`,
8
- // `test` (unit) and `openapi:current`, each where turbo.json defines it (D136: a cheap
9
- // pre-push let a stale openapi spec through, and every red costs ~1h of CI). `lint` and
10
- // `typecheck` run on the packages the pushed commits change plus their dependents; `test`
11
- // and `openapi:current` only on the packages the push itself changes, and one line names the
12
- // dependents whose tests are left to CI (an affected-set run took 36 min for a widely used package). A change to the lockfile,
13
- // turbo.json, or anything in the root package.json other than its `scripts`
14
- // table invalidates everything and runs the full graph. A repo whose turbo carries no `lint` task also runs its root `lint`
15
- // script. Without turbo: the root `lint` script only (typecheck needs a build, which the hook never runs; CI has it).
16
- //
17
- // The pushed SHA is what is graded. In place when the tree is clean and the SHA
18
- // is HEAD, or when no differing path lies in a package the run reads or at the
19
- // repository root; otherwise from a `git archive` export. A delete-only push
20
- // (or no ref on stdin) grades nothing. A push whose every remote ref is a
21
- // session park (`refs/xema-session/*`) grades nothing either: a park is not a
22
- // landing, and the work is graded when it lands on a branch.
23
- //
24
- // A fleet-wide change runs `lint` and `typecheck` on the full graph and says it
25
- // leaves `test` and `openapi:current` to CI: never the full suite. Never builds
26
- // for its own sake, never runs docker, image builds or `pnpm check`.
27
- //
28
- // Usage (lefthook `pre-push` with `use_stdin: true`):
29
- // xema-prepush-sanity
30
- // ═══════════════════════════════════════════════════════════════════════════
31
- import { spawnSync } from 'node:child_process';
32
- import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
33
- import { tmpdir } from 'node:os';
34
- import { dirname, join, delimiter } from 'node:path';
35
- import { fileURLToPath } from 'node:url';
36
-
37
- const ZERO_SHA = /^0+$/;
38
- const GIT_ENV = [
39
- 'GIT_DIR',
40
- 'GIT_WORK_TREE',
41
- 'GIT_COMMON_DIR',
42
- 'GIT_INDEX_FILE',
43
- 'GIT_OBJECT_DIRECTORY',
44
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
45
- 'GIT_PREFIX',
46
- ];
47
-
48
- /** turbo.json is JSONC: strip comments outside strings. */
49
- export function stripJsonc(text) {
50
- let out = '';
51
- for (let i = 0; i < text.length; i++) {
52
- const c = text[i];
53
- if (c === '"') {
54
- let j = i + 1;
55
- while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
56
- out += text.slice(i, j + 1);
57
- i = j;
58
- } else if (c === '/' && text[i + 1] === '/') {
59
- while (i < text.length && text[i] !== '\n') i++;
60
- out += '\n';
61
- } else if (c === '/' && text[i + 1] === '*') {
62
- const end = text.indexOf('*/', i + 2);
63
- i = end === -1 ? text.length : end + 1;
64
- } else {
65
- out += c;
66
- }
67
- }
68
- return out;
69
- }
70
-
71
- /** The tasks pre-push runs on the affected packages, in this order, where turbo.json defines them. */
72
- export const SANITY_TASKS = ['lint', 'typecheck', 'test', 'openapi:current'];
73
- /** The subset that also runs on the full graph and on dependents; the rest would be the full suite, which is CI's. */
74
- export const FULL_GRAPH_TASKS = ['lint', 'typecheck'];
75
-
76
- /** Package directories (a package.json below the root) among tracked file paths; node_modules never counts. */
77
- export function packageDirs(files) {
78
- return [...new Set(files.filter((f) => f.endsWith('/package.json') && !f.includes('node_modules/')).map((f) => f.slice(0, -'/package.json'.length)))];
79
- }
80
-
81
- /** Paths no task reads: root-level markdown and changesets. */
82
- export function isInert(path) {
83
- return /^[^/]*\.md$/.test(path) || path.startsWith('.changeset/');
84
- }
85
-
86
- /** Changed paths that sit in no workspace package and that some task may read (tsconfig.base.json, biome.json, tooling/**). They invalidate every package. */
87
- export function outsidePackages(changed, dirs) {
88
- return changed.filter((p) => !isInert(p) && !dirs.some((d) => p.startsWith(`${d}/`)));
89
- }
90
-
91
- /** A push with changes whose plan holds no task checked nothing, and must say so rather than pass; null when it is fine. */
92
- export function emptyPlanFailure(changed, plannedTasks) {
93
- if (changed.length === 0 || plannedTasks > 0) return null;
94
- return `no task was planned for a push that changes ${changed.slice(0, 10).join(', ')}${changed.length > 10 ? ` (+${changed.length - 10} more)` : ''} — nothing was checked`;
95
- }
96
-
97
- /**
98
- * The turbo runs for a push. `range` '' is the full graph: lint and typecheck only. Otherwise lint and
99
- * typecheck run on the affected set (changed packages plus dependents), while test and openapi:current
100
- * run only on the packages the push itself changed: a widely used package has dozens of dependents and
101
- * their tests cost ~36 min (authorization-nest: 78 packages, 195 tasks). CI runs those.
102
- */
103
- export function planRuns(tasks, range) {
104
- if (!range) {
105
- const full = tasks.filter((t) => FULL_GRAPH_TASKS.includes(t));
106
- return { runs: full.length > 0 ? [{ tasks: full, filter: null }] : [], leftToCi: tasks.filter((t) => !FULL_GRAPH_TASKS.includes(t)) };
107
- }
108
- const affected = tasks.filter((t) => FULL_GRAPH_TASKS.includes(t));
109
- const changedOnly = tasks.filter((t) => !FULL_GRAPH_TASKS.includes(t));
110
- const runs = [];
111
- if (affected.length > 0) runs.push({ tasks: affected, filter: `...[${range}]` });
112
- if (changedOnly.length > 0) runs.push({ tasks: changedOnly, filter: `[${range}]` });
113
- return { runs, leftToCi: [] };
114
- }
115
-
116
- /** Package names in `affected` that are not in `changed`, sorted; the root workspace `//` is never a package. */
117
- export function dependentsOnly(affected, changed) {
118
- const own = new Set(changed);
119
- return [...new Set(affected)].filter((n) => n !== '//' && !own.has(n)).sort();
120
- }
121
-
122
- /** The one line naming the dependents whose test and openapi:current are left to CI. */
123
- export function dependentsLine(dependents, tasks) {
124
- if (dependents.length === 0 || tasks.length === 0) return '';
125
- return `pre-push: ${tasks.join(' ')} ran only on the changed packages; ${dependents.length} dependent(s) are left to CI: ${dependents.join(', ')}`;
126
- }
127
-
128
- /** The sanity tasks a turbo.json defines. An unparsable file throws: "no tasks" would report green. */
129
- export function turboTasks(text) {
130
- const cfg = JSON.parse(stripJsonc(text));
131
- const defined = cfg.tasks ?? cfg.pipeline ?? {};
132
- return SANITY_TASKS.filter((t) => t in defined);
133
- }
134
-
135
- /** The refs git writes on a pre-push hook's stdin, one sha graded once, deletes dropped. */
136
- export function parseRefs(stdin) {
137
- const seen = new Set();
138
- const refs = [];
139
- for (const line of stdin.split('\n')) {
140
- const [, sha, remoteRef, remoteSha] = line.trim().split(/\s+/);
141
- if (!sha || !/^[0-9a-f]+$/.test(sha) || ZERO_SHA.test(sha) || seen.has(sha)) continue;
142
- seen.add(sha);
143
- refs.push({ sha, remoteRef: remoteRef ?? '', remoteSha: remoteSha ?? '' });
144
- }
145
- return refs;
146
- }
147
-
148
- /** Remote refs under this prefix are session parks, not landings. */
149
- export const PARK_REF_PREFIX = 'refs/xema-session/';
150
-
151
- /** True when stdin names at least one ref and every remote ref is a session park. A branch anywhere in the push grades the push. */
152
- export function parkOnly(stdin) {
153
- const remoteRefs = stdin
154
- .split('\n')
155
- .map((line) => line.trim().split(/\s+/)[2])
156
- .filter(Boolean);
157
- return remoteRefs.length > 0 && remoteRefs.every((ref) => ref.startsWith(PARK_REF_PREFIX));
158
- }
159
-
160
- /** True when two root package.json texts differ only in the top-level `scripts` key; a missing side or unparseable JSON is false. */
161
- export function scriptsOnlyChange(baseText, tipText) {
162
- try {
163
- const { scripts: _baseScripts, ...base } = JSON.parse(baseText);
164
- const { scripts: _tipScripts, ...tip } = JSON.parse(tipText);
165
- return JSON.stringify(base) === JSON.stringify(tip);
166
- } catch {
167
- return false;
168
- }
169
- }
170
-
171
- /** True when a changed path invalidates every package. `rootScriptsOnly`: the root package.json change is the `scripts` table alone. */
172
- export function isFleetWide(changedPaths, rootScriptsOnly = false) {
173
- return changedPaths.some((p) => p === 'pnpm-lock.yaml' || p === 'turbo.json' || (p === 'package.json' && !rootScriptsOnly));
174
- }
175
-
176
- /**
177
- * First differing path that stops the working tree standing in for the pushed
178
- * SHA, or null. `taskDirs` are the package directories the run reads;
179
- * `isOwned(path)` says a package.json sits above the path. Root-level *.md files
180
- * are the one root exception: no task reads them.
181
- */
182
- export function blockingPath(paths, taskDirs, isOwned) {
183
- const dirs = taskDirs.filter((d) => d && d !== '.');
184
- for (const p of paths) {
185
- if (dirs.some((d) => p === d || p.startsWith(`${d}/`))) return { path: p, why: 'is in a package this run reads' };
186
- if (!isOwned(p) && !/^[^/]*\.md$/.test(p)) return { path: p, why: 'is at the repository root' };
187
- }
188
- return null;
189
- }
190
-
191
- /** One turbo cache for the in-place run and every scratch export; an inherited value wins. */
192
- export function turboCacheDir(inherited, root) {
193
- return inherited || join(root, '.turbo', 'cache');
194
- }
195
-
196
- function cleanEnv(root) {
197
- const env = { ...process.env };
198
- for (const k of GIT_ENV) delete env[k];
199
- env.TURBO_CACHE_DIR = turboCacheDir(env.TURBO_CACHE_DIR, root);
200
- // Appended, never prepended: it only fills a gap (the aggregator owns node_modules).
201
- const sup = spawnSync('git', ['rev-parse', '--show-superproject-working-tree'], { cwd: root, env, encoding: 'utf8' });
202
- const superproject = sup.status === 0 ? sup.stdout.trim() : '';
203
- if (superproject && existsSync(join(superproject, 'node_modules', '.bin'))) {
204
- env.PATH = `${env.PATH ?? ''}${delimiter}${join(superproject, 'node_modules', '.bin')}`;
205
- }
206
- return env;
207
- }
208
-
209
- function createContext(root) {
210
- const env = cleanEnv(root);
211
- const git = (args, cwd = root) => spawnSync('git', args, { cwd, env, encoding: 'utf8', maxBuffer: 1 << 28 });
212
- const gitOut = (args, cwd = root) => {
213
- const r = git(args, cwd);
214
- return r.status === 0 ? r.stdout : null;
215
- };
216
- const inherit = (cmd, args, cwd) => spawnSync(cmd, args, { cwd, env, stdio: ['ignore', 'inherit', 'inherit'] }).status === 0;
217
- return { root, env, git, gitOut, inherit };
218
- }
219
-
220
- function pickBase(ctx, { remoteSha, remoteRef }) {
221
- if (remoteSha && /^[0-9a-f]+$/.test(remoteSha) && !ZERO_SHA.test(remoteSha) && ctx.git(['cat-file', '-e', `${remoteSha}^{commit}`]).status === 0) {
222
- return remoteSha;
223
- }
224
- const target = remoteRef.replace(/^refs\/heads\//, '');
225
- for (const cand of [`origin/${target}`, 'origin/develop']) {
226
- const out = ctx.gitOut(['rev-parse', '--verify', '-q', `${cand}^{commit}`]);
227
- if (out) return out.trim();
228
- }
229
- return '';
230
- }
231
-
232
- /** `A...B` naming the change, or '' for the full graph. */
233
- function rangeFor(ctx, base, sha) {
234
- if (!base) {
235
- console.error('pre-push: no base to diff against — running the FULL graph.');
236
- return '';
237
- }
238
- const changed = (ctx.gitOut(['diff', '--name-only', `${base}...${sha}`]) ?? '').split('\n').filter(Boolean);
239
- const rootScriptsOnly = changed.includes('package.json') && scriptsOnlyChange(ctx.gitOut(['show', `${base}:package.json`]) ?? '', ctx.gitOut(['show', `${sha}:package.json`]) ?? '');
240
- if (isFleetWide(changed, rootScriptsOnly)) {
241
- console.error('pre-push: lockfile, turbo.json or root package.json beyond its scripts changed — fleet-wide, running the FULL graph.');
242
- return '';
243
- }
244
- const files = (rev) => (ctx.gitOut(['ls-tree', '-r', '--name-only', rev]) ?? '').split('\n').filter(Boolean);
245
- const outside = outsidePackages(changed, packageDirs([...files(base), ...files(sha)]));
246
- if (outside.length > 0) {
247
- console.error(`pre-push: ${outside.slice(0, 10).join(', ')}${outside.length > 10 ? ` (+${outside.length - 10} more)` : ''} sit outside any workspace package — fleet-wide, running the FULL graph.`);
248
- return '';
249
- }
250
- return `${base}...${sha}`;
251
- }
252
-
253
- function changedPaths(ctx, base, sha) {
254
- return base ? (ctx.gitOut(['diff', '--name-only', `${base}...${sha}`]) ?? '').split('\n').filter(Boolean) : [];
255
- }
256
-
257
- function turboBin(root, env) {
258
- const local = join(root, 'node_modules', '.bin', 'turbo');
259
- if (existsSync(local)) return local;
260
- const r = spawnSync('sh', ['-c', 'command -v turbo'], { env, encoding: 'utf8' });
261
- return r.status === 0 ? r.stdout.trim() : '';
262
- }
263
-
264
- function declaresTurbo(dir) {
265
- try {
266
- const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
267
- return Boolean(pkg.dependencies?.turbo ?? pkg.devDependencies?.turbo);
268
- } catch {
269
- return false;
270
- }
271
- }
272
-
273
- function hasRootLint(dir) {
274
- try {
275
- return Boolean(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts?.lint);
276
- } catch {
277
- return false;
278
- }
279
- }
280
-
281
- /** How many tasks turbo plans for `tasks` under `filter`. */
282
- function dryTaskCount(ctx, bin, dir, tasks, filter) {
283
- const dry = spawnSync(bin, ['run', ...tasks, `--filter=${filter}`, '--dry=json'], { cwd: dir, env: ctx.env, encoding: 'utf8', maxBuffer: 1 << 28 });
284
- if (dry.status !== 0) throw new Error(`turbo --dry=json failed for --filter=${filter}`);
285
- return (JSON.parse(dry.stdout).tasks ?? []).length;
286
- }
287
-
288
- /** Package names turbo would run `tasks` for under `filter`; an unreadable plan throws rather than hide a dependent. */
289
- function dryPackages(ctx, bin, dir, tasks, filter) {
290
- const dry = spawnSync(bin, ['run', ...tasks, `--filter=${filter}`, '--dry=json'], { cwd: dir, env: ctx.env, encoding: 'utf8', maxBuffer: 1 << 28 });
291
- if (dry.status !== 0) throw new Error(`turbo --dry=json failed for --filter=${filter}`);
292
- return JSON.parse(dry.stdout).packages ?? [];
293
- }
294
-
295
- /** Run the sanity tasks in `dir`; `range` '' is the full graph. Returns the verdict. */
296
- function runHere(ctx, dir, range, bin, changed) {
297
- if (!bin) {
298
- // Without turbo nothing orders the builds a workspace typecheck needs, and the hook never
299
- // builds: typecheck there is CI's.
300
- console.log('pre-push: pnpm run lint (this repository declares no turbo; typecheck is CI-only)');
301
- return ctx.inherit('pnpm', ['run', 'lint'], dir);
302
- }
303
- let tasks;
304
- try {
305
- tasks = turboTasks(readFileSync(join(dir, 'turbo.json'), 'utf8'));
306
- } catch (error) {
307
- console.log(`pre-push: could not read the tasks from turbo.json (${error.message}) — FAILED`);
308
- return false;
309
- }
310
- if (tasks.length === 0) {
311
- console.log(`pre-push: turbo.json defines none of ${SANITY_TASKS.join(', ')} — FAILED`);
312
- return false;
313
- }
314
- if (range) {
315
- const failure = emptyPlanFailure(changed, dryTaskCount(ctx, bin, dir, tasks, `...[${range}]`));
316
- if (failure) {
317
- console.log(`pre-push: ${failure} — FAILED`);
318
- return false;
319
- }
320
- }
321
- const { runs, leftToCi } = planRuns(tasks, range);
322
- if (leftToCi.length > 0) console.log(`pre-push: ${leftToCi.join(' ')} not run on the full graph — that is the full suite, CI runs it.`);
323
- let ok = true;
324
- for (const run of runs) {
325
- const where = run.filter ? (run.filter.startsWith('...') ? `affected by ${range}, plus dependents` : `only the packages ${range} changes`) : 'full graph';
326
- console.log(`pre-push: turbo run ${run.tasks.join(' ')} (${where})`);
327
- const filterArgs = run.filter ? [`--filter=${run.filter}`] : [];
328
- ok = ctx.inherit(bin, ['run', ...run.tasks, ...filterArgs, '--continue=dependencies-successful'], dir) && ok;
329
- }
330
- if (range) {
331
- const changedOnly = tasks.filter((t) => !FULL_GRAPH_TASKS.includes(t));
332
- const line = dependentsLine(dependentsOnly(dryPackages(ctx, bin, dir, tasks, `...[${range}]`), dryPackages(ctx, bin, dir, tasks, `[${range}]`)), changedOnly);
333
- if (line) console.log(line);
334
- }
335
- // Lint that turbo does not carry is the root `lint` script CI runs.
336
- if (!tasks.includes('lint') && hasRootLint(dir)) {
337
- console.log('pre-push: pnpm run lint');
338
- ok = ctx.inherit('pnpm', ['run', 'lint'], dir) && ok;
339
- }
340
- return ok;
341
- }
342
-
343
- function inPlaceSafe(ctx, sha, range, tasks, bin) {
344
- if (!range || !bin) return false;
345
- const dry = spawnSync(bin, ['run', ...tasks, `--filter=...[${range}]`, '--dry=json'], { cwd: ctx.root, env: ctx.env, encoding: 'utf8', maxBuffer: 1 << 28 });
346
- if (dry.status !== 0) return false;
347
- let taskDirs;
348
- try {
349
- taskDirs = (JSON.parse(dry.stdout).tasks ?? []).map((t) => t.directory);
350
- } catch {
351
- return false;
352
- }
353
- const paths = [
354
- ctx.gitOut(['diff', '--name-only', '-z', 'HEAD']) ?? '',
355
- ctx.gitOut(['ls-files', '-z', '--others', '--exclude-standard']) ?? '',
356
- ctx.gitOut(['diff', '--name-only', '-z', 'HEAD', sha]) ?? '',
357
- ].flatMap((s) => s.split('\0').filter(Boolean));
358
- const isOwned = (p) => {
359
- let dir = dirname(p);
360
- while (dir !== '.' && dir !== '/') {
361
- if (existsSync(join(ctx.root, dir, 'package.json'))) return true;
362
- dir = dirname(dir);
363
- }
364
- return false;
365
- };
366
- const blocked = blockingPath(paths, taskDirs, isOwned);
367
- if (blocked) console.error(`pre-push: ${blocked.path} ${blocked.why}`);
368
- return blocked === null;
369
- }
370
-
371
- function exportTo(ctx, rev, dir) {
372
- return spawnSync('sh', ['-c', 'git archive "$1" | tar -x -C "$2"', 'sh', rev, dir], { cwd: ctx.root, env: ctx.env }).status === 0;
373
- }
374
-
375
- function gradeFromExport(ctx, sha, base, range, bin, changed) {
376
- const scratch = mkdtempSync(join(tmpdir(), 'xema-prepush-'));
377
- try {
378
- const sh = (args) => ctx.git(['-c', 'user.email=gate@local', '-c', 'user.name=gate', ...args], scratch).status === 0;
379
- if (!sh(['init', '-q', '.'])) return false;
380
- let exportRange = '';
381
- if (range) {
382
- // Two commits (base, then the graded tree) so turbo can diff them as it would a checkout.
383
- if (!exportTo(ctx, base, scratch) || !sh(['add', '-A']) || !sh(['commit', '-qm', 'base'])) return false;
384
- for (const name of readdirSync(scratch)) if (name !== '.git') rmSync(join(scratch, name), { recursive: true, force: true });
385
- exportRange = 'HEAD~1...HEAD';
386
- }
387
- if (!exportTo(ctx, sha, scratch) || !sh(['add', '-A']) || !sh(['commit', '-qm', 'graded', '--allow-empty'])) return false;
388
- if (!ctx.inherit('pnpm', ['install', '--frozen-lockfile', '--prefer-offline'], scratch)) {
389
- console.log('pre-push: install of the export failed');
390
- return false;
391
- }
392
- return runHere(ctx, scratch, exportRange, bin, changed);
393
- } finally {
394
- rmSync(scratch, { recursive: true, force: true });
395
- }
396
- }
397
-
398
- function gradeSha(ctx, ref, bin) {
399
- const { sha } = ref;
400
- const base = pickBase(ctx, ref);
401
- const range = rangeFor(ctx, base, sha);
402
- const changed = changedPaths(ctx, base, sha);
403
- if (range && changed.length > 0 && changed.every(isInert)) {
404
- console.log(`pre-push: only paths no task reads changed (${changed.slice(0, 10).join(', ')}) — nothing to grade.`);
405
- return true;
406
- }
407
- const head = (ctx.gitOut(['rev-parse', 'HEAD']) ?? '').trim();
408
- const short = (rev) => (ctx.gitOut(['rev-parse', '--short', rev]) ?? rev).trim();
409
- const clean = (ctx.gitOut(['status', '--porcelain']) ?? '') === '';
410
- if (sha === head && clean) {
411
- console.log(`pre-push: grading HEAD ${short(head)} in place (clean tree).`);
412
- return runHere(ctx, ctx.root, range, bin, changed);
413
- }
414
- if (bin) {
415
- let tasks = [];
416
- try {
417
- tasks = turboTasks(readFileSync(join(ctx.root, 'turbo.json'), 'utf8'));
418
- } catch {
419
- // runHere reports an unreadable turbo.json as the failure it is.
420
- }
421
- if (tasks.length > 0 && inPlaceSafe(ctx, sha, range, tasks, bin)) {
422
- console.log(`pre-push: grading ${short(sha)} in place — no differing path is in a package this run reads.`);
423
- return runHere(ctx, ctx.root, range, bin, changed);
424
- }
425
- }
426
- console.log(
427
- sha === head
428
- ? `pre-push: grading ${short(sha)} from an export — the tree is dirty, so it is not what is being pushed.`
429
- : `pre-push: grading ${short(sha)} from an export — the commit being PUSHED, which is not HEAD ${short(head)}.`,
430
- );
431
- return gradeFromExport(ctx, sha, base, bin ? range : '', bin, changed);
432
- }
433
-
434
- export function main(stdin) {
435
- const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', env: Object.fromEntries(Object.entries(process.env).filter(([k]) => !GIT_ENV.includes(k))) });
436
- if (top.status !== 0) {
437
- console.error('pre-push: not inside a git repository — FAILED');
438
- return 1;
439
- }
440
- if (parkOnly(stdin)) {
441
- console.log(`pre-push: skipped — every ref is a session park under ${PARK_REF_PREFIX}; parks are not landings, the gate runs when the work lands.`);
442
- return 0;
443
- }
444
- const ctx = createContext(top.stdout.trim());
445
- const refs = parseRefs(stdin);
446
- if (refs.length === 0) {
447
- console.log('pre-push: no ref on stdin — this push updates nothing, nothing to grade.');
448
- return 0;
449
- }
450
- // Turbo is used only where the repository declares it; a declared turbo that does not
451
- // resolve is an uninstalled checkout, and fails rather than skipping the proof.
452
- let bin = '';
453
- if (existsSync(join(ctx.root, 'turbo.json')) && declaresTurbo(ctx.root)) {
454
- bin = turboBin(ctx.root, ctx.env);
455
- if (!bin) {
456
- console.log('pre-push: no turbo binary resolves — run `pnpm install --frozen-lockfile` — FAILED');
457
- console.log('::error::pre-push FAILED — turbo:unavailable');
458
- return 1;
459
- }
460
- }
461
- const failed = refs.filter((ref) => !gradeSha(ctx, ref, bin)).map((ref) => ctx.gitOut(['rev-parse', '--short', ref.sha])?.trim() ?? ref.sha);
462
- if (failed.length === 0) {
463
- console.log(`pre-push: GREEN — sanity passed on ${refs.length} pushed commit(s).`);
464
- return 0;
465
- }
466
- console.log(`::error::pre-push FAILED — ${failed.join(' ')}`);
467
- console.log(`pre-push: FAILED — ${failed.join(' ')}`);
468
- return 1;
469
- }
470
-
471
- if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
472
- process.exitCode = main(process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
473
- }
@@ -1,205 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync, spawnSync } from 'node:child_process';
3
- import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
- import { blockingPath, dependentsLine, emptyPlanFailure, isInert, outsidePackages, packageDirs, dependentsOnly, isFleetWide, planRuns, parkOnly, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
9
-
10
- const VERB = fileURLToPath(new URL('./prepush-sanity.mjs', import.meta.url));
11
- const SHA_A = 'a'.repeat(40);
12
- const SHA_B = 'b'.repeat(40);
13
-
14
- test('stripJsonc drops comments outside strings only', () => {
15
- const text = '{ // c\n "a": "x//y", /* b */ "b": 1 }';
16
- assert.deepEqual(JSON.parse(stripJsonc(text)), { a: 'x//y', b: 1 });
17
- });
18
-
19
- test('turboTasks keeps lint and typecheck, and throws on unparsable turbo.json', () => {
20
- assert.deepEqual(turboTasks('{"tasks":{"build":{},"typecheck":{},"lint":{}}}'), ['lint', 'typecheck']);
21
- assert.deepEqual(turboTasks('{"tasks":{"openapi:current":{},"test":{},"build":{},"typecheck":{},"lint":{}}}'), ['lint', 'typecheck', 'test', 'openapi:current']);
22
- assert.deepEqual(turboTasks('{"pipeline":{"typecheck":{}}}'), ['typecheck']);
23
- assert.throws(() => turboTasks('{ nope'));
24
- });
25
-
26
- test('parseRefs dedupes shas and drops deletes and junk', () => {
27
- const zero = '0'.repeat(40);
28
- const stdin = [`refs/heads/x ${SHA_A} refs/heads/develop ${SHA_B}`, `refs/heads/y ${SHA_A} refs/heads/z ${SHA_B}`, `(delete) ${zero} refs/heads/old ${SHA_B}`, ''].join('\n');
29
- assert.deepEqual(parseRefs(stdin), [{ sha: SHA_A, remoteRef: 'refs/heads/develop', remoteSha: SHA_B }]);
30
- assert.deepEqual(parseRefs(''), []);
31
- });
32
-
33
- test('isFleetWide flags the lockfile, turbo.json and a root package.json change beyond scripts', () => {
34
- assert.equal(isFleetWide(['pnpm-lock.yaml']), true);
35
- assert.equal(isFleetWide(['turbo.json']), true);
36
- assert.equal(isFleetWide(['package.json']), true);
37
- assert.equal(isFleetWide(['package.json'], true), false);
38
- assert.equal(isFleetWide(['package.json', 'pnpm-lock.yaml'], true), true);
39
- assert.equal(isFleetWide(['packages/a/package.json', 'src/x.ts']), false);
40
- });
41
-
42
- test('scriptsOnlyChange: only the scripts key may differ; anything else or unreadable is fleet-wide', () => {
43
- const base = { name: 't', scripts: { a: '1' }, dependencies: { x: '1' }, packageManager: 'pnpm@10.0.0' };
44
- const text = (o) => JSON.stringify(o);
45
- assert.equal(scriptsOnlyChange(text(base), text({ ...base, scripts: { a: '2', b: '3' } })), true);
46
- for (const change of [{ dependencies: { x: '2' } }, { packageManager: 'pnpm@10.1.0' }, { pnpm: { overrides: {} } }, { engines: { node: '22' } }]) {
47
- assert.equal(scriptsOnlyChange(text(base), text({ ...base, ...change })), false, Object.keys(change)[0]);
48
- }
49
- assert.equal(scriptsOnlyChange('', text(base)), false);
50
- assert.equal(scriptsOnlyChange(text(base), '{ nope'), false);
51
- });
52
-
53
- test('blockingPath: package reads and repo-root files block, root markdown does not', () => {
54
- const owned = (p) => p.startsWith('packages/');
55
- assert.equal(blockingPath(['packages/a/src/x.ts'], ['packages/a'], owned)?.why, 'is in a package this run reads');
56
- assert.equal(blockingPath(['packages/b/src/x.ts'], ['packages/a'], owned), null);
57
- assert.equal(blockingPath(['tsconfig.base.json'], [], owned)?.why, 'is at the repository root');
58
- assert.equal(blockingPath(['README.md'], [], owned), null);
59
- });
60
-
61
- function repo(scripts) {
62
- const dir = mkdtempSync(join(tmpdir(), 'prepush-sanity-test-'));
63
- const run = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
64
- run('init', '-q', '-b', 'develop');
65
- run('config', 'user.email', 't@t');
66
- run('config', 'user.name', 't');
67
- writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't', private: true, scripts }));
68
- run('add', '-A');
69
- run('commit', '-qm', 'init');
70
- const sha = run('rev-parse', 'HEAD').toString().trim();
71
- return { dir, sha };
72
- }
73
-
74
- function hook(dir, stdin) {
75
- return spawnSync('node', [VERB], { cwd: dir, input: stdin, encoding: 'utf8' });
76
- }
77
-
78
- test('no turbo: lint only. A failing lint refuses the push; a failing typecheck is CI-only', () => {
79
- const badLint = repo({ typecheck: 'exit 0', lint: 'exit 1' });
80
- const badTypecheck = repo({ typecheck: 'exit 1', lint: 'exit 0' });
81
- try {
82
- const refused = hook(badLint.dir, `refs/heads/x ${badLint.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
83
- assert.equal(refused.status, 1, refused.stdout + refused.stderr);
84
- assert.match(refused.stdout, /pre-push: FAILED/);
85
- const green = hook(badTypecheck.dir, `refs/heads/x ${badTypecheck.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
86
- assert.equal(green.status, 0, green.stdout + green.stderr);
87
- assert.match(green.stdout, /GREEN/);
88
- } finally {
89
- rmSync(badLint.dir, { recursive: true, force: true });
90
- rmSync(badTypecheck.dir, { recursive: true, force: true });
91
- }
92
- });
93
-
94
- test('a delete-only push and a manual run grade nothing', () => {
95
- const r = repo({ typecheck: 'exit 1', lint: 'exit 1' });
96
- try {
97
- const del = hook(r.dir, `(delete) ${'0'.repeat(40)} refs/heads/old ${r.sha}\n`);
98
- assert.equal(del.status, 0, del.stdout + del.stderr);
99
- assert.match(del.stdout, /nothing to grade/);
100
- assert.equal(hook(r.dir, '').status, 0);
101
- } finally {
102
- rmSync(r.dir, { recursive: true, force: true });
103
- }
104
- });
105
-
106
- test('turboCacheDir defaults to the repo root and keeps an inherited value', () => {
107
- assert.equal(turboCacheDir('', '/r'), '/r/.turbo/cache');
108
- assert.equal(turboCacheDir(undefined, '/r'), '/r/.turbo/cache');
109
- assert.equal(turboCacheDir('/shared', '/r'), '/shared');
110
- });
111
-
112
- test('the turbo invocation receives TURBO_CACHE_DIR', () => {
113
- const r = repo({});
114
- try {
115
- writeFileSync(join(r.dir, 'package.json'), JSON.stringify({ name: 't', private: true, devDependencies: { turbo: '^2' } }));
116
- writeFileSync(join(r.dir, 'turbo.json'), JSON.stringify({ tasks: { typecheck: {} } }));
117
- mkdirSync(join(r.dir, 'node_modules', '.bin'), { recursive: true });
118
- const fake = join(r.dir, 'node_modules', '.bin', 'turbo');
119
- const seen = join(r.dir, 'seen.txt');
120
- writeFileSync(fake, `#!/bin/sh\nprintf '%s' "$TURBO_CACHE_DIR" > '${seen}'\n`);
121
- chmodSync(fake, 0o755);
122
- writeFileSync(join(r.dir, '.gitignore'), 'node_modules\nseen.txt\n');
123
- execFileSync('git', ['add', '-A'], { cwd: r.dir });
124
- execFileSync('git', ['commit', '-qm', 'turbo'], { cwd: r.dir });
125
- const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir }).toString().trim();
126
- const stdin = `refs/heads/x ${sha} refs/heads/develop ${'0'.repeat(40)}\n`;
127
- const env = { ...process.env };
128
- delete env.TURBO_CACHE_DIR;
129
- const run = (e) => spawnSync('node', [VERB], { cwd: r.dir, input: stdin, encoding: 'utf8', env: e });
130
- const first = run(env);
131
- assert.equal(first.status, 0, first.stdout + first.stderr);
132
- assert.equal(readFileSync(seen, 'utf8'), join(realpathSync(r.dir), '.turbo', 'cache'));
133
- const second = run({ ...env, TURBO_CACHE_DIR: '/inherited/cache' });
134
- assert.equal(second.status, 0, second.stdout + second.stderr);
135
- assert.equal(readFileSync(seen, 'utf8'), '/inherited/cache');
136
- } finally {
137
- rmSync(r.dir, { recursive: true, force: true });
138
- }
139
- });
140
-
141
- test('parkOnly: only a push whose every remote ref is a session park', () => {
142
- const park = `refs/heads/x ${SHA_A} refs/xema-session/lead/T1/x ${'0'.repeat(40)}`;
143
- const dev = `refs/heads/x ${SHA_A} refs/heads/develop ${SHA_B}`;
144
- assert.equal(parkOnly(`${park}\n`), true);
145
- assert.equal(parkOnly(`(delete) ${'0'.repeat(40)} refs/xema-session/lead/T1/x ${SHA_A}\n`), true);
146
- assert.equal(parkOnly(`${park}\n${dev}\n`), false);
147
- assert.equal(parkOnly(`${dev}\n`), false);
148
- assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/main ${SHA_B}\n`), false);
149
- assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/xema-session/x ${SHA_B}\n`), false);
150
- assert.equal(parkOnly(''), false);
151
- });
152
-
153
- test('a park-only push skips and says why; a park beside develop still grades', () => {
154
- const r = repo({ typecheck: 'exit 1', lint: 'exit 1' });
155
- try {
156
- const parked = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\n`);
157
- assert.equal(parked.status, 0, parked.stdout + parked.stderr);
158
- assert.match(parked.stdout, /skipped — every ref is a session park/);
159
- const mixed = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\nrefs/heads/x ${r.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
160
- assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
161
- assert.match(mixed.stdout, /pre-push: FAILED/);
162
- } finally {
163
- rmSync(r.dir, { recursive: true, force: true });
164
- }
165
- });
166
-
167
- test('planRuns: affected set for lint and typecheck, changed packages only for test and openapi:current', () => {
168
- const tasks = ['lint', 'typecheck', 'test', 'openapi:current'];
169
- assert.deepEqual(planRuns(tasks, 'A...B'), {
170
- runs: [
171
- { tasks: ['lint', 'typecheck'], filter: '...[A...B]' },
172
- { tasks: ['test', 'openapi:current'], filter: '[A...B]' },
173
- ],
174
- leftToCi: [],
175
- });
176
- assert.deepEqual(planRuns(['test'], 'A...B').runs, [{ tasks: ['test'], filter: '[A...B]' }]);
177
- });
178
-
179
- test('planRuns: a root-file change runs lint and typecheck on all and leaves the rest to CI', () => {
180
- assert.deepEqual(planRuns(['lint', 'typecheck', 'test'], ''), { runs: [{ tasks: ['lint', 'typecheck'], filter: null }], leftToCi: ['test'] });
181
- assert.deepEqual(planRuns(['test'], ''), { runs: [], leftToCi: ['test'] });
182
- });
183
-
184
- test('dependentsOnly and dependentsLine name the dependents left to CI', () => {
185
- const dependents = dependentsOnly(['//', 'b', 'a', 'c', 'a'], ['c']);
186
- assert.deepEqual(dependents, ['a', 'b']);
187
- assert.match(dependentsLine(dependents, ['test', 'openapi:current']), /test openapi:current ran only on the changed packages; 2 dependent\(s\) are left to CI: a, b/);
188
- assert.equal(dependentsLine([], ['test']), '');
189
- assert.equal(dependentsLine(['a'], []), '');
190
- });
191
-
192
- test('a path outside every workspace package is fleet-wide; package files and inert files are not', () => {
193
- const dirs = packageDirs(['package.json', 'packages/a/package.json', 'packages/a/node_modules/x/package.json', 'apps/b/package.json']);
194
- assert.deepEqual(dirs.sort(), ['apps/b', 'packages/a']);
195
- assert.deepEqual(outsidePackages(['tsconfig.base.json', 'biome.json', 'tooling/ci/x.mjs'], dirs), ['tsconfig.base.json', 'biome.json', 'tooling/ci/x.mjs']);
196
- assert.deepEqual(outsidePackages(['packages/a/src/x.ts', 'apps/b/package.json', 'README.md', '.changeset/x.md'], dirs), []);
197
- assert.equal(isInert('README.md'), true);
198
- assert.equal(isInert('tooling/README.md'), false);
199
- });
200
-
201
- test('zero planned tasks for a push with changes is a failure naming the paths, never green', () => {
202
- assert.match(emptyPlanFailure(['tsconfig.base.json'], 0), /tsconfig\.base\.json.*nothing was checked/);
203
- assert.equal(emptyPlanFailure(['packages/a/x.ts'], 3), null);
204
- assert.equal(emptyPlanFailure([], 0), null);
205
- });