fix(react-core): make the #4893 purity gate scan the graph it claimed to scan

`scripts/assert-headless-purity.mjs` is a hard-fail CI gate, and it did not do
what its header said. It read four built entry files and asked
`code.includes(dep)`. That is weaker than the claim in both directions, and every
item below was reproduced against a real build before this rewrite:

1. It never followed an edge out of those four files. Re-exporting one hook from
   the fat `@copilotkit/react-core/v2` entry — which links shiki, mermaid,
   cytoscape, katex and streamdown — left `dist/v2/headless.mjs` importing that
   entry by name, and the gate printed "clean" for all four files, exit 0. Same
   for a heavy dep reached through `@copilotkit/core`, which is external to this
   build: the entry says only `from "@copilotkit/core"` and there is nothing to
   grep. A split-out relative chunk escaped identically.
2. The header claimed the check "follows into node_modules". It followed nothing
   — not node_modules, not a relative sibling chunk.
3. `code.includes(dep)` is unanchored, so it matched comments and strings. Not
   hypothetical in either direction: the built artifact is comment-PRESERVING
   (233 lines of block comments survive in dist/v2/headless.mjs), and the five
   banned tokens sit in `src/v2/headless.ts`'s own banner. They are absent from
   dist only because that module is a re-export shell whose banner attaches to no
   retained code — moving the same sentence into a module that ships code
   hard-failed CI on all five tokens while linking none of them.

The gate now drives esbuild with `metafile: true` over each built entry and
matches on the RESOLVED graph, so it follows relative chunk edges and into
node_modules for real, resolves `exports` maps, subpaths and pnpm symlinks, and
cannot be fooled or tripped by a comment. Matching is anchored at the package
name (`@shikijs/langs` and `cytoscape-fcose` count; `shikimori` does not) and
also covers a forbidden dep left external, which resolves to no graph input at
all. Unresolvable edges FAIL LOUDLY instead of reading as clean, as does a graph
that does not contain its own entry.

One edge shape survives a bundler: `import(name)` with a non-literal argument,
which esbuild leaves alone without even warning. For that the gate reads text —
the only place it does — over the graph's first-party files, using the
`stripComments` helper ported from the sibling RN guard so a documented
counter-example cannot trip it.

Adds `scripts/__tests__/assert-headless-purity.test.mjs` (17 tests, wired into
`test:scripts` next to measure-copilotchat's), because a hard-fail gate with no
coverage of its own failure mode is how this shipped. Proven after the fix: both
false negatives now exit 1, a clean build exits 0, and a banned token that
appears only in a comment exits 0.

esbuild is already this package's devDependency and already runs in the same CI
job, so the gate needs no workflow change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim
2026-08-10 23:16:35 +02:00
parent 60d3ef1071
commit e7f3d7644d
8 changed files with 672 additions and 30 deletions
+1 -1
View File
@@ -66,7 +66,7 @@
"compat-check": "es-check es2022 --module 'dist/**/!(*.umd).{mjs,cjs,js}' && es-check es2020 'dist/**/*.umd.js'",
"dev": "tsdown --watch",
"test": "vitest run && pnpm run test:scripts",
"test:scripts": "node --test scripts/__tests__/measure-copilotchat.test.mjs",
"test:scripts": "node --test scripts/__tests__/measure-copilotchat.test.mjs scripts/__tests__/assert-headless-purity.test.mjs",
"test:watch": "vitest",
"check-types": "tsc --noEmit",
"link:global": "pnpm link --global",
@@ -0,0 +1,224 @@
// Negative tests for the #4893 hard-fail CI gate in
// scripts/assert-headless-purity.mjs. The gate shipped with no test, and every
// case below is a defect it actually had: it read four files and never followed an
// edge out of them, so a violation behind a relative chunk or an external package
// passed; and it matched raw substrings, so a banned token in a comment failed CI
// on code that linked nothing.
//
// A guard is only worth having if its FAILURE mode is covered, so each test here
// asserts one direction of that: a real violation must be seen, and a mention must
// not be mistaken for one.
//
// Standalone Node test (not vitest), matching measure-copilotchat.test.mjs: the
// module under test drives esbuild, which trips vitest's jsdom env probe, and the
// package-wide vitest setup uses jsdom-only globals.
//
// Invoked from package.json `test:scripts` and the chained `test` command.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
assertEntryPurity,
collectModuleGraph,
forbiddenHits,
isForbiddenPackage,
packageNameFor,
stripComments,
unanalyzableLoaderCalls,
} from "../assert-headless-purity.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const pkgRoot = path.resolve(here, "../..");
const fixture = (name) => path.join(here, "fixtures", name);
// `zod` stands in for a forbidden dep: it is a real dependency of this package, so
// it resolves through node_modules exactly as shiki/streamdown would, without
// making the test bundle 5.5 MB of grammars.
const STAND_IN = ["zod"];
// The gate's real list, used to prove the no-false-positive direction.
const REAL_FORBIDDEN = ["shiki", "mermaid", "cytoscape", "katex", "streamdown"];
describe("packageNameFor", () => {
it("reads through pnpm's nested node_modules to the real package name", () => {
assert.equal(
packageNameFor(
"../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/lib/index.mjs",
),
"zod",
);
});
it("keeps the scope of a scoped package", () => {
assert.equal(
packageNameFor(
"../../node_modules/.pnpm/@shikijs+core@1.0.0/node_modules/@shikijs/core/dist/index.mjs",
),
"@shikijs/core",
);
});
it("returns null for first-party files", () => {
assert.equal(packageNameFor("dist/v2/headless.mjs"), null);
// The old guard's substring match would have flagged this file. A path is
// not a dependency.
assert.equal(packageNameFor("src/v2/katex-notes.ts"), null);
});
});
describe("isForbiddenPackage", () => {
it("matches the dep itself and the family it ships as", () => {
assert.ok(isForbiddenPackage("shiki", "shiki"));
assert.ok(isForbiddenPackage("@shikijs/langs", "shiki"));
assert.ok(isForbiddenPackage("cytoscape-fcose", "cytoscape"));
});
it("is anchored, so an unrelated package that merely starts the same way passes", () => {
assert.ok(!isForbiddenPackage("shikimori", "shiki"));
assert.ok(!isForbiddenPackage("react-katexish", "katex"));
});
});
describe("forbiddenHits", () => {
it("flags a dep resolved into the graph", () => {
const hits = forbiddenHits({
inputs: [
"dist/v2/headless.mjs",
"../../node_modules/.pnpm/streamdown@1.3.0/node_modules/streamdown/dist/index.js",
],
forbidden: REAL_FORBIDDEN,
});
assert.deepEqual(hits, [{ dep: "streamdown", via: ["streamdown"] }]);
});
it("does not flag a first-party path that merely contains the token", () => {
assert.deepEqual(
forbiddenHits({
inputs: ["dist/v2/headless.mjs", "src/v2/lib/katex-helpers.ts"],
forbidden: REAL_FORBIDDEN,
}),
[],
);
});
it("flags a dep left EXTERNAL, which resolves to no graph input at all", () => {
const hits = forbiddenHits({
inputs: ["dist/v2/headless.mjs"],
externalSpecifiers: ["react", "streamdown/lib/lazy"],
forbidden: REAL_FORBIDDEN,
});
assert.deepEqual(hits, [{ dep: "streamdown", via: ["streamdown"] }]);
});
});
describe("stripComments / unanalyzableLoaderCalls", () => {
it("blanks comments without disturbing string literals or line numbers", () => {
const code = [
'const a = "// not a comment";',
"// a comment",
"const b = 1;",
].join("\n");
const stripped = stripComments(code);
assert.ok(stripped.includes('"// not a comment"'));
assert.ok(!stripped.includes("a comment\n"));
assert.equal(stripped.split("\n").length, code.split("\n").length);
});
it("reports a loader call whose argument is not a string literal", () => {
const calls = unanalyzableLoaderCalls("export const f = (n) => import(n);");
assert.equal(calls.length, 1);
});
it("ignores literal loader calls and commented-out ones", () => {
assert.deepEqual(
unanalyzableLoaderCalls(
['import "streamdown";', "// const x = await import(name);"].join("\n"),
),
[],
);
});
});
describe("collectModuleGraph", () => {
it("follows a relative chunk edge and on into node_modules", async () => {
const graph = await collectModuleGraph({
entryFile: fixture("purity-entry.mjs"),
pkgRoot,
});
const entryText = fs.readFileSync(fixture("purity-entry.mjs"), "utf8");
// The whole point: the entry file itself names no dependency, so a scan that
// reads only the entry cannot see what the graph contains.
assert.ok(!entryText.includes("zod"));
assert.ok(
graph.inputs.some((input) => input.endsWith("purity-chunk.mjs")),
"the relative chunk must be in the graph",
);
assert.ok(
graph.inputs.some((input) => /node_modules\/zod\//.test(input)),
"the dep behind the chunk must be in the graph",
);
});
it("fails loudly on an edge it cannot resolve instead of reading as clean", async () => {
await assert.rejects(
collectModuleGraph({
entryFile: fixture("purity-broken-entry.mjs"),
pkgRoot,
}),
(error) => {
assert.match(error.message, /could not resolve the module graph/);
assert.match(error.message, /purity-does-not-exist/);
return true;
},
);
});
});
describe("assertEntryPurity", () => {
it("catches a forbidden dep reached only through a relative chunk edge", async () => {
const report = await assertEntryPurity({
entryFile: fixture("purity-entry.mjs"),
pkgRoot,
forbidden: STAND_IN,
});
assert.deepEqual(report.hits, [{ dep: "zod", via: ["zod"] }]);
});
it("passes the same graph when nothing forbidden is in it", async () => {
const report = await assertEntryPurity({
entryFile: fixture("purity-entry.mjs"),
pkgRoot,
forbidden: REAL_FORBIDDEN,
});
assert.deepEqual(report.hits, []);
assert.deepEqual(report.unanalyzable, []);
assert.deepEqual(report.blindingWarnings, []);
});
it("does NOT fail on banned tokens that appear only in comments and strings", async () => {
const text = fs.readFileSync(fixture("purity-comment-entry.mjs"), "utf8");
// Guard the guard's test: a fixture that lost its tokens would pass vacuously.
for (const dep of REAL_FORBIDDEN) {
assert.ok(text.includes(dep), `fixture must still mention ${dep}`);
}
const report = await assertEntryPurity({
entryFile: fixture("purity-comment-entry.mjs"),
pkgRoot,
forbidden: REAL_FORBIDDEN,
});
assert.deepEqual(report.hits, []);
assert.deepEqual(report.unanalyzable, []);
});
it("reports a loader call a bundler cannot see through, rather than calling it clean", async () => {
const report = await assertEntryPurity({
entryFile: fixture("purity-unanalyzable-entry.mjs"),
pkgRoot,
forbidden: REAL_FORBIDDEN,
});
assert.deepEqual(report.hits, []);
assert.equal(report.unanalyzable.length, 1);
assert.match(report.unanalyzable[0], /purity-unanalyzable-entry\.mjs/);
});
});
@@ -0,0 +1,3 @@
// Fixture for the fail-loud contract: an edge that cannot be resolved must never
// read as "clean", because it hides every module behind it.
export * from "./purity-does-not-exist.mjs";
@@ -0,0 +1,6 @@
// The split-out chunk the entry re-exports. `zod` stands in for a forbidden dep
// in the tests (it is a real dependency of this package, so it resolves through
// node_modules exactly as shiki/streamdown would).
import { z } from "zod";
export const schema = z.string();
@@ -0,0 +1,18 @@
// Fixture proving the guard matches the resolved module graph, never file text.
// Every banned token of the real FORBIDDEN list appears below — in a comment and
// in a string literal — while this module links none of them: no `streamdown`,
// no shiki, no mermaid, no cytoscape, no katex.
export const NOT_IMPORTS = [
"shiki",
"mermaid",
"cytoscape",
"katex",
"streamdown",
];
// Counter-examples, documented the way a reviewer would write them. Neither may
// register as an import, and the second must not register as an unanalyzable
// loader call either:
// import { Streamdown } from "streamdown";
// const heavy = await import(rendererName);
export * from "./purity-chunk.mjs";
@@ -0,0 +1,4 @@
// Fixture for assert-headless-purity.test.mjs: an entry that names no dependency
// at all and reaches one only through a RELATIVE chunk edge — the shape the old
// substring guard could not see, because it read this file and stopped here.
export * from "./purity-chunk.mjs";
@@ -0,0 +1,4 @@
// Fixture for the fail-loud contract: a loader whose argument is not a string
// literal is a hole in the graph, so the scan must report it rather than call the
// entry clean.
export const load = (name) => import(name);
@@ -2,51 +2,434 @@
// stack. @copilotkit/react-native imports two react-core entries — /v2/headless
// and /v2/context — and neither may drag the ~5.5 MB shiki grammars/themes plus
// mermaid, cytoscape and katex (issue #4893). This script guards both built
// chunks: the /v2/headless entry exists so consumers with a custom UI can import
// entries: the /v2/headless entry exists so consumers with a custom UI can import
// hooks without that weight, and /v2/context carries CopilotKitCoreReact, so its
// transitive subtree must stay clean too. (The RN import-graph guard in
// packages/react-native/src/__tests__/headless-entry-surface.test.ts allows these
// same two entries but cannot follow into node_modules — this script does.)
// same two entries but only walks that package's own source; this script walks
// the built graph, so it follows relative chunk edges and into node_modules.)
//
// This is a STRUCTURAL assertion, not a size budget: it names the specific
// regression instead of guarding a byte threshold, so it hard-fails legitimately
// and needs no baseline maintenance. Size *budgets* remain blocked on OSS-122 —
// see dev-docs/bundle-size.md.
//
// ─── Why it drives esbuild instead of grepping the entry files ───────────────
// The first version read the four entry files and asked `code.includes(dep)`.
// That was weaker than it claimed, in both directions, and every item below was
// reproduced against a real build before this rewrite:
//
// 1. It only inspected those four files, so any violation reached through an
// edge OUT of them was invisible. Proven: re-exporting one hook from the fat
// `@copilotkit/react-core/v2` entry (which links the whole render stack) left
// `dist/v2/headless.mjs` with an unresolved bare import of that entry — and
// the guard printed "clean" for all four files. Same for a heavy dep reached
// through `@copilotkit/core`, which is external to this build: the entry says
// only `from "@copilotkit/core"`, and grepping that string finds nothing.
// A split-out chunk (`import "./chunk-abc.mjs"`) escaped the same way.
// 2. The header used to claim the check "follows into node_modules". It did not
// follow anything — not node_modules, not even a relative sibling chunk.
// 3. `code.includes(dep)` is unanchored, so it matched comments, strings and
// identifiers. Not hypothetical: the five banned tokens sit in
// `src/v2/headless.ts`'s own file banner, and the built artifact IS
// comment-preserving (233 lines of block comments survive in
// dist/v2/headless.mjs). They are absent only because that module is a
// re-export shell whose banner attaches to no retained code. Moving the same
// sentence into any module that ships code hard-failed CI on all five tokens
// while linking none of them.
//
// So the graph — not the text — is the thing to assert. esbuild bundles each
// built entry with `metafile: true` and reports every file it had to load;
// forbidden-dep matching runs on those resolved paths, never on file contents,
// which is why this guard cannot be fooled (or tripped) by a comment. It resolves
// package `exports` maps, subpaths, extensions and pnpm symlinks itself, and an
// edge it cannot resolve is an esbuild *error*, which this script reports as a
// failure rather than as silence (fail loud: an unresolvable edge hides a whole
// subgraph).
//
// One edge shape stays invisible even to a bundler: `import(name)` /
// `require(name)` with a non-literal argument. esbuild leaves those alone and (for
// ESM `import()`) does not even warn, so the graph would look clean while a fat
// entry hid behind a variable. The one place this script therefore reads text is
// to find those calls in the graph's FIRST-PARTY files (our own and our workspace
// siblings' built output — third-party dynamic requires are endemic and say
// nothing about #4893), and it strips comments before looking, so a documented
// counter-example cannot trip it.
//
// esbuild is already this package's devDependency and already runs in the same CI
// job (scripts/measure-copilotchat.mjs), and react/react-dom stay external there
// for the same reason as here: a host app ships them, they cannot reach the
// render stack, and bundling them buries the signal in thousands of inputs.
//
// Covered by scripts/__tests__/assert-headless-purity.test.mjs (`test:scripts`).
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { build } from "esbuild";
const FORBIDDEN = ["shiki", "mermaid", "cytoscape", "katex", "streamdown"];
const dist = path.resolve(import.meta.dirname, "../dist/v2");
const targets = ["headless.mjs", "headless.cjs", "context.mjs", "context.cjs"];
let failed = false;
for (const file of targets) {
const full = path.join(dist, file);
if (!fs.existsSync(full)) {
console.error(
`✗ ${file} not found — run \`nx run @copilotkit/react-core:build\` first`,
);
failed = true;
continue;
}
const code = fs.readFileSync(full, "utf8");
const hits = FORBIDDEN.filter((dep) => code.includes(dep));
if (hits.length) {
console.error(
`✗ ${file} references the heavy render stack: ${hits.join(", ")}`,
);
failed = true;
} else {
console.log(`✓ ${file} (${fs.statSync(full).size} B) — clean`);
}
// A host React app already ships these, and none of them can reach the render
// stack. Mirrors scripts/measure-copilotchat.mjs.
const DEFAULT_EXTERNAL = [
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom/client",
"react-dom/server",
];
// We measure the JS graph, not CSS. Stubbing these keeps a `katex/dist/*.css`
// style import from crashing the bundle while STILL recording it as a graph
// input (the loader runs after resolution), so a CSS-only leak is still caught.
const EMPTY_LOADERS = {
".css": "empty",
".woff": "empty",
".woff2": "empty",
".ttf": "empty",
".eot": "empty",
".svg": "empty",
};
// esbuild warnings that mean "I could not see through this edge". Silence here
// is exactly how a lazily-required fat entry would hide, so they fail the guard
// instead of being logged. Anything else esbuild warns about is printed but not
// fatal — third-party code warns for reasons that say nothing about #4893.
const GRAPH_BLINDING_WARNINGS = [
/will not be bundled/i,
/could not be resolved/i,
];
/**
* Comment / string / template alternation, scanned left-to-right in ONE pass.
*
* Ported verbatim from the sibling guard in
* packages/react-native/src/__tests__/headless-entry-surface.test.ts, which hit
* the same class of bug. Matching strings with the SAME alternation is what makes
* comment stripping correct: a `//` inside a string literal is consumed as part of
* the string before the comment branch can see it, and a quote inside a comment is
* consumed as part of the comment. `'` and `"` deliberately do not cross a
* newline, so an unbalanced apostrophe in prose ("doesn't") cannot swallow the
* rest of a file.
*/
const COMMENT_OR_LITERAL =
/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
/** Every way a module can pull another one in at runtime. */
const LOADER_CALL = /\b(?:import|require(?:\.resolve)?)\s*\(\s*/g;
/**
* Blanks out comments, preserving newlines (and therefore line numbers) and
* leaving real string/template literals untouched.
*
* @param {string} code
* @returns {string}
*/
export function stripComments(code) {
return code.replace(COMMENT_OR_LITERAL, (match) =>
match.startsWith("//") || match.startsWith("/*")
? match.replace(/[^\n]/g, " ")
: match,
);
}
if (failed) {
console.error(
"\nThe React-Native-reachable entries (/v2/headless, /v2/context) must not link\n" +
"the chat-message rendering stack (#4893).\n" +
"If a hook you added needs it, it belongs in the main /v2 entry instead.",
);
process.exit(1);
/**
* Loader calls whose argument is not a string literal, and which therefore hide
* whatever they load from any static analysis — including a bundler's.
*
* @param {string} code
* @returns {string[]} A short excerpt per unanalyzable call.
*/
export function unanalyzableLoaderCalls(code) {
const stripped = stripComments(code);
const found = [];
for (const match of stripped.matchAll(LOADER_CALL)) {
const after = stripped.slice(match.index + match[0].length);
if (/^["'`]/.test(after)) continue;
found.push(
stripped
.slice(match.index, match.index + match[0].length + 48)
.replace(/\s+/g, " ")
.trim(),
);
}
return found;
}
/**
* The npm package a resolved graph input belongs to, or null for first-party
* files. Uses the LAST `node_modules/` segment so pnpm's
* `node_modules/.pnpm/zod@3.25.76/node_modules/zod/lib/index.mjs` yields `zod`
* rather than `.pnpm`.
*
* @param {string} inputPath
* @returns {string | null}
*/
export function packageNameFor(inputPath) {
const parts = inputPath.split("node_modules/");
if (parts.length < 2) return null;
const segments = parts[parts.length - 1].split("/");
if (segments[0].startsWith("@")) {
return segments.length > 1 ? `${segments[0]}/${segments[1]}` : null;
}
return segments[0] || null;
}
/**
* Whether a package name belongs to a forbidden dependency's family.
*
* Anchored at the START of the package NAME (never mid-string, and never over
* file contents), so it catches the family a dep ships as — `@shikijs/langs`,
* `cytoscape-fcose` — without matching an unrelated file that merely mentions
* the word.
*
* @param {string} packageName
* @param {string} dep
* @returns {boolean}
*/
export function isForbiddenPackage(packageName, dep) {
return (
packageName === dep ||
packageName.startsWith(`${dep}/`) ||
packageName.startsWith(`${dep}-`) ||
packageName.startsWith(`@${dep}`)
);
}
/**
* Match a resolved module graph against the forbidden list.
*
* @param {object} options
* @param {string[]} options.inputs - Resolved graph input paths (esbuild metafile keys).
* @param {string[]} [options.externalSpecifiers] - Bare specifiers left external, which resolve to no input.
* @param {string[]} options.forbidden - Dependency names that must not appear.
* @returns {{ dep: string, via: string[] }[]} One entry per forbidden dep that is present.
*/
export function forbiddenHits({ inputs, externalSpecifiers = [], forbidden }) {
const names = new Set();
for (const input of inputs) {
const name = packageNameFor(input);
if (name) names.add(name);
}
// An external specifier never becomes an input, so it would otherwise be a
// blind spot in exactly the direction that already bit this guard once.
for (const specifier of externalSpecifiers) {
if (specifier.startsWith(".") || path.isAbsolute(specifier)) continue;
const segments = specifier.split("/");
names.add(
segments[0].startsWith("@") && segments.length > 1
? `${segments[0]}/${segments[1]}`
: segments[0],
);
}
const hits = [];
for (const dep of forbidden) {
const via = [...names].filter((name) => isForbiddenPackage(name, dep));
if (via.length) hits.push({ dep, via: via.sort() });
}
return hits;
}
/**
* Bundle one built entry with esbuild and return everything it had to load.
*
* Throws (loudly, with esbuild's own text) when an edge cannot be resolved: a
* dropped edge hides a whole subgraph, so it must never read as clean.
*
* @param {object} options
* @param {string} options.entryFile - Path to a built entry (.mjs or .cjs).
* @param {string} options.pkgRoot - Working directory for esbuild resolution.
* @param {string[]} [options.external]
* @param {Record<string, string>} [options.loader]
* @returns {Promise<{ inputs: string[], externalSpecifiers: string[], blindingWarnings: string[], otherWarnings: string[] }>}
*/
export async function collectModuleGraph({
entryFile,
pkgRoot,
external = DEFAULT_EXTERNAL,
loader = EMPTY_LOADERS,
}) {
let result;
try {
result = await build({
entryPoints: [entryFile],
absWorkingDir: pkgRoot,
bundle: true,
write: false,
metafile: true,
format: entryFile.endsWith(".cjs") ? "cjs" : "esm",
platform: "browser",
target: "es2022",
external,
loader,
logLevel: "silent",
});
} catch (error) {
const texts = (error.errors ?? []).map((e) => e.text);
throw new Error(
`could not resolve the module graph of ${path.basename(entryFile)} — ` +
`an unresolvable edge hides everything behind it, so this is a failure, ` +
`not a pass:\n ${texts.length ? texts.join("\n ") : String(error.message ?? error)}`,
{ cause: error },
);
}
const inputs = Object.keys(result.metafile.inputs);
// A graph that does not even contain its own entry means we measured nothing.
const entryKey = path.relative(pkgRoot, path.resolve(pkgRoot, entryFile));
if (!inputs.includes(entryKey) && !inputs.includes(entryFile)) {
throw new Error(
`the module graph of ${path.basename(entryFile)} does not contain the entry ` +
`itself (${entryKey}) — the scan measured nothing`,
);
}
const externalSpecifiers = new Set();
for (const input of Object.values(result.metafile.inputs)) {
for (const edge of input.imports ?? []) {
if (edge.external) externalSpecifiers.add(edge.path);
}
}
const warnings = (result.warnings ?? []).map((w) => w.text);
return {
inputs,
externalSpecifiers: [...externalSpecifiers],
blindingWarnings: warnings.filter((text) =>
GRAPH_BLINDING_WARNINGS.some((pattern) => pattern.test(text)),
),
otherWarnings: warnings.filter(
(text) => !GRAPH_BLINDING_WARNINGS.some((pattern) => pattern.test(text)),
),
};
}
/**
* Every unanalyzable loader call in the graph's first-party files, i.e. the ones
* a bundler cannot see through. Third-party files are skipped on purpose: their
* dynamic requires are endemic and say nothing about #4893.
*
* A first-party input that is not readable is itself reported — silence about a
* file we were supposed to check is the failure mode this guard shipped with.
*
* @param {object} options
* @param {string[]} options.inputs
* @param {string} options.pkgRoot
* @returns {string[]}
*/
export function unanalyzableEdgesIn({ inputs, pkgRoot }) {
const found = [];
for (const input of inputs) {
if (input.includes("node_modules")) continue;
const absolute = path.resolve(pkgRoot, input);
if (!fs.existsSync(absolute)) {
found.push(`${input} — in the graph but not readable on disk`);
continue;
}
for (const call of unanalyzableLoaderCalls(
fs.readFileSync(absolute, "utf8"),
)) {
found.push(`${input} — ${call}`);
}
}
return found;
}
/**
* Walk one built entry's graph and report forbidden dependencies in it.
*
* @param {object} options
* @param {string} options.entryFile
* @param {string} options.pkgRoot
* @param {string[]} options.forbidden
* @returns {Promise<{ hits: { dep: string, via: string[] }[], inputCount: number, unanalyzable: string[], blindingWarnings: string[], otherWarnings: string[] }>}
*/
export async function assertEntryPurity({ entryFile, pkgRoot, forbidden }) {
const { inputs, externalSpecifiers, blindingWarnings, otherWarnings } =
await collectModuleGraph({ entryFile, pkgRoot });
return {
hits: forbiddenHits({ inputs, externalSpecifiers, forbidden }),
inputCount: inputs.length,
unanalyzable: unanalyzableEdgesIn({ inputs, pkgRoot }),
blindingWarnings,
otherWarnings,
};
}
// CLI entry — only runs when invoked directly, so importing this module from
// tests does not walk the real dist graph at module-load time.
const isMain =
import.meta.url === `file://${process.argv[1]}` ||
import.meta.url === `file://${path.resolve(process.argv[1] ?? "")}`;
if (isMain) {
const pkgRoot = path.resolve(dist, "../..");
let failed = false;
for (const file of targets) {
const full = path.join(dist, file);
if (!fs.existsSync(full)) {
console.error(
`✗ ${file} not found — run \`nx run @copilotkit/react-core:build\` first`,
);
failed = true;
continue;
}
let report;
try {
report = await assertEntryPurity({
entryFile: full,
pkgRoot,
forbidden: FORBIDDEN,
});
} catch (error) {
console.error(`✗ ${file}: ${error.message}`);
failed = true;
continue;
}
for (const text of report.otherWarnings) {
console.warn(` (esbuild warning, not fatal) ${file}: ${text}`);
}
const opaque = [...report.blindingWarnings, ...report.unanalyzable];
if (opaque.length) {
console.error(
`✗ ${file} has edges this scan cannot follow, so it cannot be called clean:\n ` +
opaque.join("\n "),
);
failed = true;
continue;
}
if (report.hits.length) {
const detail = report.hits
.map(({ dep, via }) =>
via.length === 1 && via[0] === dep
? dep
: `${dep} (via ${via.join(", ")})`,
)
.join(", ");
console.error(
`✗ ${file} links the heavy render stack: ${detail}\n` +
` (${report.inputCount} modules in its graph)`,
);
failed = true;
continue;
}
console.log(
`✓ ${file} (${fs.statSync(full).size} B, ${report.inputCount} modules in graph) — clean`,
);
}
if (failed) {
console.error(
"\nThe React-Native-reachable entries (/v2/headless, /v2/context) must not link\n" +
"the chat-message rendering stack (#4893).\n" +
"If a hook you added needs it, it belongs in the main /v2 entry instead.",
);
process.exit(1);
}
}