mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
21ab65ba14
* 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.
212 lines
8.9 KiB
JavaScript
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);
|