The #4893 hard-fail gate's loader-call detector gave WRONG VERDICTS IN BOTH
DIRECTIONS. It layered two regexes — a comment/string/template alternation that
blanked only the comment branch, and `\b(?:import|require(?:\.resolve)?)\s*\(`
over the result — then classified an argument as static from the FIRST CHARACTER
after the paren. All nine shapes below were reproduced against the real gate
before the rewrite:
false FAIL throw new Error("use require(path) instead")
false FAIL `import(${x})` inside a template
false FAIL o.import(y) / mod.require(x) (member calls, not loaders)
false PASS /https:\/\//; …import(n) (the regex's `//` blanked the
rest of the line, hiding a
real dynamic call)
false PASS import(`stream${n}`) (merely STARTS with a quote)
false PASS import("zo" + n) (same)
false PASS import(`${base}/v2/index.mjs`) (same — the fat entry)
false PASS __require(name) (no \b inside `__require`)
Replaced with `scanSource`, a single-pass tokenizer that classifies every
character as code / comment / string / template / regex and returns a
length-preserving masked view plus a literal-span list. The one surviving regex
now only ever sees code, so import-shaped TEXT cannot reach it at all; an
argument counts as static only when it is one COMPLETE literal with no
concatenation or interpolation; `__require` is matched; and a member call is
rejected both by lookbehind and by a whitespace-skipping back-scan (so
`m\n .import(x)` is not a loader either).
Proven in both directions: nine innocent/violation pairs run through the real
`assertEntryPurity`, each innocent form CLEAN and each matching real violation
FAIL. Re-proved end-to-end by prepending `import "streamdown"` to the real
dist/v2/headless.mjs — exit 1 naming all five families — then restoring it
byte-identically. On the untouched dist the scan sees 66 loader calls in the
`.cjs` graph and classifies all 66 static, so it passes because it LOOKED.
Also adds the first `.cjs` fixtures: every existing fixture was `.mjs`, leaving
the script's `format: "cjs"` branch and the `require()` shape asserted by
nothing. Tests 24 → 47.
`stripComments` is renamed `maskNonCode`, since it now blanks literals and
regexes too; it had no caller outside this script and its test. The RN guard
keeps its own copy, untouched.
dev-docs/bundle-size.md: the four holes a sibling agent documented as known
limitations this round are closed and removed from that list; what genuinely
remains (regex-vs-division heuristic, no JSX/TS, indirect loaders) replaces them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both scripts decided "am I the entrypoint?" by comparing `import.meta.url` to a
`file://`-concatenated `process.argv[1]`. `import.meta.url` is percent-encoded
and symlink-resolved; raw argv[1] is neither. So the comparison was false for
any checkout path containing a space, for any invocation through a symlink
(macOS /tmp is one), and on Windows — and a false guard skipped the whole CLI
block. Reproduced before fixing: the #4893 purity gate and the bundle-size
measurement both exited 0 having printed nothing and asserted nothing, which is
worse than a gate with holes because it manufactures confidence. The guard was
added by this PR so the modules could export internals to their new negative
tests; making the gates testable introduced a way for them not to run.
Both now compare real filesystem paths through an exported `isEntrypoint`:
`fileURLToPath` defeats the encoding and Windows forms, `fs.realpathSync` on
both sides defeats symlinks, and a `path.resolve` fallback keeps a nonexistent
argv[1] from throwing.
Each `node --test` suite gains five entry-guard tests, including an end-to-end
spawn of the real script through a symlinked package-root alias whose name
contains a space — the only case that catches the call site regressing back to
a string comparison (verified: it fails against the old expression). The unit
cases assert the naive comparison really would have failed, so none of them can
pass vacuously. Both negative gates were re-proven to still bite: a doctored
dist entry pulling streamdown fails the purity gate, and a stubbed dist entry
trips the measurement's plausibility floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`assert-headless-purity.mjs` resolved its dist directory with
`import.meta.dirname`, which landed in Node 20.11 and is `undefined` below it.
The root package.json declares `engines: { "node": ">=18" }`, so a contributor
or runner on Node 18 hit this hard-fail CI gate as:
TypeError [ERR_INVALID_ARG_TYPE]: The "paths[0]" argument must be of type
string. Received undefined
at Object.resolve (node:path:1115:7)
at .../scripts/assert-headless-purity.mjs:71:19
— a stack trace into node internals, at module load, that names neither the
gate nor the real problem. Reproduced against a real Node 18.20.8.
Switch to `path.dirname(fileURLToPath(import.meta.url))`, which both sibling
scripts in this CI job already use (react-core's measure-copilotchat.mjs and
react-native's measure-headless.mjs), so all three read the same and none of
them carries a hidden runtime floor its own package does not declare.
Verified under real Node 18.20.8: the script now walks all four entries (650 /
646 / 649 / 645 modules) and exits 0, and still exits 1 with the full
`links the heavy render stack` report when a forbidden dep is injected into a
dist entry. The metafile-driven graph walk, the loud failure on unresolvable
edges and all 17 negative tests are untouched (`test:scripts`: 19 pass).
Skill-staleness check (reskinnable-demo CLAUDE.md rule): not applicable — this
touches packages/react-core, nothing under .claude/skills/reskin/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`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>
Final review fix wave for the RN render-tool convergence branch.
Substantive:
- Extend packages/react-core/scripts/assert-headless-purity.mjs to also scan the
built /v2/context chunk (context.mjs/context.cjs), not just /v2/headless.
/v2/context carries CopilotKitCoreReact and is imported by react-native, so a
future shiki/mermaid/katex leak through it would bloat RN bundles (#4893) while
neither hard-fail guard fired. Comment and failure message updated to name both
RN-reachable entries. Mutation-verified against context.mjs.
- Document the closure-staleness convergence: render is now captured at
registration (passed into useFrontendTool) and only refreshed when deps change,
no longer re-read every render. Consumers whose render closes over changing
state must now pass deps. Documented in the useRenderTool JSDoc, the
useRenderTool.mdx reference, and the changeset migration notes.
Minor sweep:
- CopilotChat extraData now lists what renderItem actually reads
({ isRunning, renderToolCall, toolMessages }); drop unused executingToolCallIds.
- headless-type-exports.test-d.ts imports React explicitly instead of relying on
the ambient UMD global.
- useRenderTool.mdx migration heading no longer names the uncut 1.67.0 version.
- Changeset marks @copilotkit/react-core minor (new public type export), matching
its body.
Co-Authored-By: Claude <noreply@anthropic.com>