Files
civitai__civitai/scripts/check-server-graph-singletons.mjs
T
Justin Maier 21ab65ba14 fix(tests): make the packages and unit suites readable on Windows (48 failures -> 0) (#3961)
* fix(tests): run the drift CLIs through node, not the POSIX bin shim

`node_modules/.bin/tsx` is a shell script, so `execFile` on Windows failed
ENOENT before any CLI started — 32 tests across the three schema-drift files,
i.e. every case in `test:packages:run` that spawns a CLI, red on any Windows
checkout. `execFile` reports that in the same `code` field as a real exit
status, so they read `expected 'ENOENT' to be 2`: an assertion about the CLI's
behaviour rather than about a CLI that never ran.

Resolve tsx's own entry and spawn it under `process.execPath` instead, and
throw when a spawn produced no numeric exit code so the two can never be
confused again.

Mutation-tested: dropping `process.exitCode` in gate-cli gives
`expected +0 to be 1`, and dropping the `capturedAt` stamp in cli.ts gives
`expected undefined to be truthy`.

* fix(tests): give the source-scanning guards posix path identifiers

Four drift guards build a repo-relative path with `path.relative()` and then
use it as an IDENTIFIER — matched against `/`-separated literals, or split on
`/`. On Windows `path.relative()` returns backslashes, so the match can never
hit and 14 tests were red on any Windows checkout. Normalise separators at the
point the path stops being a filesystem path; nothing asserted changes, and the
paths handed to the filesystem are untouched.

One of these did more than go red. In app-spend-tier-privilege the mismatched
key is `alwaysDecode`, whose stated job is to read the publisher-facing modules
unconditionally so that a renamed or deleted path is loud rather than a quietly
vacuous pass. With backslash keys it never hit, so it force-read nothing and
could not tell a stale path from a present one — the mechanism against a vacuous
pass was itself vacuous. Any Windows checkout has been in that state since the
guard was written.

Mutation-tested, each fix separately:
- rename a PUBLISHER_REACHABLE entry -> `blocks.router.RENAMED.ts was not read
  — is the path stale?: expected undefined to be defined`
- drop 'generation-resources' from KNOWN_STATIC_ENDPOINT_SEGMENTS ->
  `expected '/api/v1/blocks/:seg' to be '/api/v1/blocks/generation-resources'`
- drop the 'user-settings:write' label -> `expected [ 'user-settings:write' ]
  to deeply equal []`
- change orchestrator-chat's wait to 60000 -> `expected 60000 to be less than
  or equal to 150`, and the ledger diff names
  `server/services/comics/orchestrator-chat.ts:60000`

* fix(scripts): name emitted chunks with posix separators, and cover exit 137

The server-graph gate keyed its chunk map on `relative()` output, so on Windows
every violation named `chunks\ssr\b.js`. That key is what the report prints, so
it is a name, not a path: normalise it, and keep the absolute path beside it so
reading a chunk never goes back through the key.

`typecheck.test.ts` simulated an outside kill with SIGKILL, which Windows cannot
deliver — the child exits 1 with `signal === null` and the wrapper correctly
reports a generic crash instead. Skip that case there for the stated reason and
add the other half of the same branch, `exit 137`, which is what a container
actually reports and which runs everywhere.

Mutation-tested: dropping `|| code === 137` from the wrapper's classifier fails
the new case with `expected '...TYPECHECK CRASHED...' to contain 'killed from
outside'`. The gate's own synthetic negative control covers the chunk name.

* fix(tests): resolve tsx per call, read the walked path, name a signal kill

Three review findings on this branch, all one-liners, all the same shape as the
bug the branch fixes: a failure reported as something other than what it is.

`tsx/cli` was resolved at MODULE scope. All three drift test files import that
module, so a resolve that throws — a tsx release dropping the `./cli` export
subpath, a partial install — would take all three down during module evaluation
and each would collect ZERO tests while the failure count stayed 0. Resolving
inside the call surfaces it as a test failure naming the module instead. Latent,
not live: tsx 4.20.3 declares `./cli`.

`app-spend-tier-privilege` now reads the absolute path the walk produced rather
than `join(ROOT, <normalised key>)`. The key is an identifier; handing a
posix-separated string back to the filesystem works today and would not under a
`\?\` prefixed path. This is also what the PR body already claimed it did.

`runTsxCli` announced a signal-killed process as "did not run", which sends the
reader looking for a spawn failure. A signal kill did run.
2026-08-15 14:16:26 -06:00

212 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Server-graph module-identity gate.
*
* ---------------------------------------------------------------------------
* What this exists to catch
* ---------------------------------------------------------------------------
* A module's top-level bindings are per RUNTIME MODULE, and the bundler decides how many
* runtime modules a source file becomes. Turbopack merges source modules into larger
* runtime modules, so one `.ts` file is routinely INLINED into many of them: on a
* production build of this repo `src/server/logging/client.ts` is emitted 14 times.
*
* Anything that has to be a process-wide singleton therefore cannot live in a
* module-scope `const`. It has to be reached through `globalThis`, which is the only
* thing every copy shares.
*
* This failure is STRUCTURALLY INVISIBLE to every other gate we run. `tsc` type-checks
* one source file, not N emitted copies of it. ESLint reads source. Vitest loads each
* module exactly once, so a module-scope singleton looks like a singleton. Only the
* bundler knows, and it does not complain — it just quietly hands each copy its own
* object. That is how the OTel logs bridge shipped delivering 1.3% of records with a
* green typecheck, a green lint and a green suite: `setStructuredLogSink()` armed one of
* 14 sink objects, and nothing anywhere could see it.
*
* ---------------------------------------------------------------------------
* How it measures
* ---------------------------------------------------------------------------
* Turbopack's production output uses opaque NUMERIC module ids, so the emitted JS cannot
* be attributed to a source file by reading it. The source maps can: each emitted chunk
* ships a `.js.map` whose `sources` array names every source module inlined into it. A
* source file that appears in N chunks' `sources` is N runtime modules.
*
* This needs server source maps, which this repo emits in production
* (`productionBrowserSourceMaps: true` → Turbopack's `turbopackSourceMaps`, which covers
* `.next/server/**` too). If no maps are found the gate EXITS NON-ZERO rather than
* passing: a scan that cannot see anything must never be reported as "nothing wrong".
*
* ---------------------------------------------------------------------------
* Rules
* ---------------------------------------------------------------------------
* SHARED_STATE — the module may be emitted any number of times, but every emitted copy
* must reference the named `globalThis` key. A copy without it owns
* private state that the registration path can never reach.
*
* SINGLETON — the module must be emitted EXACTLY once. Its module scope is
* deliberately graph-local (a memoized handle, a set of counters), so a
* second copy is a second, silently-unregistered instance.
*
* Usage: node scripts/check-server-graph-singletons.mjs [--next-dir .next] [--json]
* Exit: 0 = pass · 1 = violation · 2 = the gate could not run (env/usage)
*/
import { readFileSync, existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
// The watchlist lives in its own module so the gate and its test suite share ONE list —
// see the header of ./server-graph-watchlist.mjs for why that matters.
import { WATCHLIST } from './server-graph-watchlist.mjs';
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
function argValue(flag, fallback) {
const i = args.indexOf(flag);
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
}
const NEXT_DIR = argValue('--next-dir', process.env.NEXT_DIR || '.next');
const SERVER_DIR = join(NEXT_DIR, 'server');
const AS_JSON = args.includes('--json');
function die(code, message) {
console.error(`server-graph-singletons: ${message}`);
process.exit(code);
}
if (!existsSync(SERVER_DIR)) {
die(2, `no server output at ${SERVER_DIR} — was \`next build\` run first?`);
}
async function walk(dir, out = []) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) await walk(p, out);
else out.push(p);
}
return out;
}
const files = await walk(SERVER_DIR);
const mapFiles = files.filter((f) => f.endsWith('.js.map'));
if (mapFiles.length === 0) {
// A zero here is indistinguishable from "everything is fine" unless we refuse it.
die(
2,
`found 0 source maps under ${SERVER_DIR}. This gate reads \`.js.map\` \`sources\` to attribute emitted code to source modules; without them it can see nothing. Server maps come from \`productionBrowserSourceMaps: true\` in next.config.mjs (Turbopack maps both client and server chunks from that flag).`
);
}
// chunk (relative .js path) -> Set(source modules inlined into it)
const chunkSources = new Map();
// The same key -> the absolute .js path, so reading a chunk never goes back through
// the key. The key is a NAME: it is what every violation above prints, so it carries
// posix separators on all platforms rather than whatever the host filesystem uses.
const chunkFiles = new Map();
for (const m of mapFiles) {
let sources;
try {
sources = JSON.parse(readFileSync(m, 'utf8')).sources;
} catch {
continue; // an unparseable map is not evidence of a violation
}
if (!Array.isArray(sources)) continue;
const abs = m.replace(/\.map$/, '');
const rel = relative(SERVER_DIR, abs).replace(/\\/g, '/');
chunkSources.set(rel, sources);
chunkFiles.set(rel, abs);
}
const readChunk = (rel) => {
try {
return readFileSync(chunkFiles.get(rel) ?? join(SERVER_DIR, rel), 'utf8');
} catch {
return '';
}
};
// ---------------------------------------------------------------------------
const results = [];
let failed = 0;
for (const entry of WATCHLIST) {
const copies = [];
for (const [chunk, sources] of chunkSources) {
if (sources.some((s) => typeof s === 'string' && s.endsWith(entry.module))) copies.push(chunk);
}
const result = { ...entry, copies: copies.length, violations: [] };
// POSITIVE CONTROL, always. A watchlist entry that matches nothing means the path was
// renamed/deleted or the scan is looking in the wrong place — either way the rule below
// would "pass" without having examined anything, which is the failure mode this whole
// gate exists to prevent. Refuse it.
if (copies.length === 0) {
result.violations.push(
`matched 0 emitted chunks — this rule examined NOTHING. Either \`${entry.module}\` no longer exists / is no longer reachable from the server build, or its path in this watchlist is stale. Fix the path or drop the entry; do not leave a rule that cannot observe its subject.`
);
} else if (entry.rule === 'SINGLETON') {
if (copies.length > 1) {
result.violations.push(
`emitted ${copies.length} times, expected exactly 1. ${entry.why}\n copies: ${copies
.slice(0, 10)
.join(', ')}${copies.length > 10 ? `, +${copies.length - 10} more` : ''}`
);
}
} else if (entry.rule === 'SHARED_STATE') {
const unpinned = copies.filter((c) => !readChunk(c).includes(entry.globalKey));
result.pinned = copies.length - unpinned.length;
if (unpinned.length > 0) {
result.violations.push(
`${unpinned.length} of ${copies.length} emitted copies do NOT reference \`globalThis.${
entry.globalKey
}\`, so each of those holds PRIVATE module-scope state. ${
entry.why
}\n unpinned copies: ${unpinned.slice(0, 10).join(', ')}${
unpinned.length > 10 ? `, +${unpinned.length - 10} more` : ''
}`
);
}
} else {
result.violations.push(`unknown rule \`${entry.rule}\` — the gate cannot evaluate it.`);
}
if (result.violations.length) failed++;
results.push(result);
}
// ---------------------------------------------------------------------------
if (AS_JSON) {
console.log(JSON.stringify({ serverDir: SERVER_DIR, maps: mapFiles.length, results }, null, 2));
} else {
console.log('===== server-graph module identity =====');
console.log(
`scanned ${chunkSources.size} emitted chunks (${mapFiles.length} source maps) under ${SERVER_DIR}`
);
console.log('');
for (const r of results) {
const detail =
r.rule === 'SHARED_STATE'
? `${r.copies} emitted cop${r.copies === 1 ? 'y' : 'ies'}, ${
r.pinned ?? 0
} referencing globalThis.${r.globalKey}`
: `${r.copies} emitted cop${r.copies === 1 ? 'y' : 'ies'}`;
console.log(` ${r.violations.length ? 'FAIL' : ' OK '} ${r.rule.padEnd(12)} ${r.module}`);
console.log(` ${detail}`);
for (const v of r.violations) console.log(`${v}`);
}
console.log('');
}
if (failed) {
console.error(`server-graph-singletons: ${failed} violation(s) — FAIL`);
process.exit(1);
}
console.log('server-graph-singletons: PASS');
process.exit(0);