mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user