Skip to content

Commit f98b350

Browse files
committed
fix(webapp): make the run-ops legacy guard classify clients independent of build state
The guard resolved the Prisma client packages through their built dist/, which the CI job does not build (install + generate only). That left every client type unresolved in CI, so the guard classified nothing as control-plane and reported 0 violations - blind, and would rubber-stamp a real leak. Pin resolution to the checked-in sources, match declaration files against absolute generated-client dirs, and add a preflight + classification anchors that exit non-zero rather than pass blind.
1 parent 618d497 commit f98b350

1 file changed

Lines changed: 145 additions & 1 deletion

File tree

apps/webapp/scripts/runOpsLegacyGuard.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,41 @@ const RUNOPS_SCHEMA = path.join(
4545
);
4646
const BASELINE_PATH = path.join(WEBAPP_DIR, "app", "v3", "runOpsMigration", "track1-baseline.json");
4747

48+
const CP_PACKAGE_DIR = path.dirname(path.dirname(CP_SCHEMA));
49+
const RUNOPS_PACKAGE_DIR = path.dirname(path.dirname(RUNOPS_SCHEMA));
50+
51+
/** Absolute path of the generated Prisma client for a schema, from its generator `output = "..."`. */
52+
function generatedClientDir(schemaFile: string): string {
53+
const m = /^\s*output\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(schemaFile, "utf8"));
54+
if (!m) {
55+
console.error(`No generator output path found in ${schemaFile}`);
56+
process.exit(2);
57+
}
58+
return path.resolve(path.dirname(schemaFile), m[1]);
59+
}
60+
61+
const CP_GENERATED_DIR = generatedClientDir(CP_SCHEMA);
62+
const RUNOPS_GENERATED_DIR = generatedClientDir(RUNOPS_SCHEMA);
63+
64+
function realpathIfExists(p: string): string {
65+
try {
66+
return fs.realpathSync(p);
67+
} catch {
68+
return p;
69+
}
70+
}
71+
72+
// Classification roots, realpath'd to match the checker's realpath'd file names.
73+
const RUNOPS_DECL_DIRS = [RUNOPS_GENERATED_DIR, RUNOPS_PACKAGE_DIR].map(realpathIfExists);
74+
const CP_DECL_DIRS = [CP_GENERATED_DIR, CP_PACKAGE_DIR].map(realpathIfExists);
75+
76+
// Both client packages are force-resolved to SOURCE (src/ + generated client) when building the
77+
// program, so classification never depends on a built/fresh `dist/` in the running environment.
78+
const FORCED_TYPE_RESOLUTIONS = new Map<string, string>([
79+
["@trigger.dev/database", path.join(CP_PACKAGE_DIR, "src", "index.ts")],
80+
["@internal/run-ops-database", path.join(RUNOPS_PACKAGE_DIR, "src", "index.ts")],
81+
]);
82+
4883
// Files excluded from the sweep. V1-only files come from .claude/rules/legacy-v3-code.md.
4984
const V1_FILES = new Set(
5085
[
@@ -254,7 +289,41 @@ function buildProgram(): ts.Program {
254289
};
255290
const parsed = ts.getParsedCommandLineOfConfigFile(TSCONFIG_PATH, undefined, host);
256291
if (!parsed) throw new Error(`Failed to parse ${TSCONFIG_PATH}`);
257-
return ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
292+
// Resolve workspace packages from source (tsconfig.check.json blanks this to target built dists,
293+
// which may not exist here), and pin the two client packages to their src entrypoints —
294+
// @trigger.dev/database has no exports map, so the condition alone cannot redirect it.
295+
const options: ts.CompilerOptions = {
296+
...parsed.options,
297+
customConditions: ["@triggerdotdev/source"],
298+
};
299+
const compilerHost = ts.createCompilerHost(options);
300+
const resolutionCache = ts.createModuleResolutionCache(
301+
compilerHost.getCurrentDirectory(),
302+
(f) => compilerHost.getCanonicalFileName(f),
303+
options
304+
);
305+
compilerHost.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirected, opts) =>
306+
moduleLiterals.map((lit) => {
307+
const forced = FORCED_TYPE_RESOLUTIONS.get(lit.text);
308+
if (forced) {
309+
return {
310+
resolvedModule: {
311+
resolvedFileName: forced,
312+
extension: ts.Extension.Ts,
313+
isExternalLibraryImport: false,
314+
},
315+
};
316+
}
317+
return ts.resolveModuleName(
318+
lit.text,
319+
containingFile,
320+
opts,
321+
compilerHost,
322+
resolutionCache,
323+
redirected
324+
);
325+
});
326+
return ts.createProgram({ rootNames: parsed.fileNames, options, host: compilerHost });
258327
}
259328

260329
// ─────────────────────────────────────────────────────────────────────────────
@@ -263,7 +332,17 @@ function buildProgram(): ts.Program {
263332

264333
type ClientKind = "cp" | "runops" | "other";
265334

335+
function underDir(file: string, dir: string): boolean {
336+
const rel = path.relative(dir, file);
337+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
338+
}
339+
340+
// Absolute-dir match against the two packages first (environment-independent); substring match is
341+
// only a fallback for non-workspace layouts (pnpm store paths) and the virtual self-test files.
266342
function declFileKind(fileName: string): ClientKind | undefined {
343+
const abs = path.resolve(fileName);
344+
if (RUNOPS_DECL_DIRS.some((d) => underDir(abs, d))) return "runops";
345+
if (CP_DECL_DIRS.some((d) => underDir(abs, d))) return "cp";
267346
const f = fileName.split(path.sep).join("/");
268347
if (f.includes("run-ops-database")) return "runops";
269348
if (f.includes("internal-packages/database")) return "cp";
@@ -629,9 +708,65 @@ function collectCrossSeamHits(
629708
// Scan
630709
// ─────────────────────────────────────────────────────────────────────────────
631710

711+
// Known-kind receivers in db.server.ts. If either fails to classify, the checker cannot see the
712+
// client types (e.g. generated clients missing) and every verdict would be a silent false-clean,
713+
// so the guard refuses to report instead of rubber-stamping.
714+
const CLASSIFICATION_ANCHORS: Array<{ name: string; expected: ClientKind }> = [
715+
{ name: "runOpsLegacyPrisma", expected: "cp" },
716+
{ name: "runOpsNewPrismaClient", expected: "runops" },
717+
];
718+
719+
function assertClassificationAnchors(program: ts.Program, checker: ts.TypeChecker): void {
720+
const anchorFile = path.join(WEBAPP_DIR, "app", "db.server.ts");
721+
const sf = program.getSourceFile(anchorFile);
722+
const problems: string[] = [];
723+
if (!sf) {
724+
problems.push(`${repoRel(anchorFile)} is not part of the program`);
725+
} else {
726+
for (const anchor of CLASSIFICATION_ANCHORS) {
727+
let ident: ts.Identifier | undefined;
728+
const find = (node: ts.Node): void => {
729+
if (ident) return;
730+
if (
731+
ts.isVariableDeclaration(node) &&
732+
ts.isIdentifier(node.name) &&
733+
node.name.text === anchor.name
734+
) {
735+
ident = node.name;
736+
return;
737+
}
738+
ts.forEachChild(node, find);
739+
};
740+
find(sf);
741+
if (!ident) {
742+
problems.push(`anchor "${anchor.name}" not found in ${repoRel(anchorFile)}`);
743+
continue;
744+
}
745+
const receiverType = checker.getTypeAtLocation(ident);
746+
const delegateSym = receiverType.getProperty("taskRun");
747+
const got = delegateSym
748+
? classifyType(checker, checker.getTypeOfSymbolAtLocation(delegateSym, ident))
749+
: "unresolved";
750+
if (got !== anchor.expected) {
751+
problems.push(`${anchor.name}.taskRun classified "${got}", expected "${anchor.expected}"`);
752+
}
753+
}
754+
}
755+
if (problems.length) {
756+
console.error(
757+
`[runops-guard] CLASSIFICATION ANCHORS FAILED — the guard cannot distinguish the ` +
758+
`control-plane and run-ops clients in this environment, so its verdicts would be ` +
759+
`meaningless:\n ${problems.join("\n ")}\n` +
760+
`Ensure the generated Prisma clients exist (pnpm run generate) and retry.`
761+
);
762+
process.exit(2);
763+
}
764+
}
765+
632766
function scan(): ScanResult {
633767
const program = buildProgram();
634768
const checker = program.getTypeChecker();
769+
assertClassificationAnchors(program, checker);
635770
return scanProgram(program, checker, isInScope);
636771
}
637772

@@ -1134,6 +1269,15 @@ function main(): void {
11341269
process.exit(2);
11351270
}
11361271

1272+
for (const dir of [CP_GENERATED_DIR, RUNOPS_GENERATED_DIR]) {
1273+
if (!fs.existsSync(path.join(dir, "index.d.ts"))) {
1274+
console.error(
1275+
`[runops-guard] generated Prisma client missing at ${repoRel(dir)} — run: pnpm run generate`
1276+
);
1277+
process.exit(2);
1278+
}
1279+
}
1280+
11371281
console.error(`[runops-guard] repo root: ${REPO_ROOT}`);
11381282
console.error(
11391283
`[runops-guard] run-graph delegates: ${Array.from(RUN_GRAPH_DELEGATES).join(", ")}`

0 commit comments

Comments
 (0)