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>
e7f3d7644d rewrote the #4893 gate from a substring scan of four inlined entry
files into an esbuild `metafile` walk over the resolved graph, but the doc was
corrected one commit earlier (2158a6f382) and so described the deleted
implementation.
Rewritten to match the code:
- Tier 3 item 1 now describes the graph assertion: esbuild with `metafile: true`,
matching on resolved input paths (never file contents), `packageNameFor`'s
last-`node_modules/` rule, external specifiers collected from
`imports[].external`, fail-loud on an unresolvable edge / an entry missing from
its own graph / graph-blinding warnings, comment stripping before the one text
scan, and the negative tests in scripts/__tests__/assert-headless-purity.test.mjs.
- Dropped the now-false claims: "substring scan of four files", "cannot follow
edges", "a forbidden name in a comment fails it", and "a dep arriving
transitively through an externalized package would pass" (the gate re-bundles
with only react/react-dom external).
- `size:headless` no longer described as never hard-failing: it exits non-zero on
an unbuilt dist, an esbuild error, and a total of 0 or under
MIN_PLAUSIBLE_BYTES. `size:headline` keeps its zero-output guard. Phase 1's
"no hard-fail" is now scoped to size *thresholds*.
- Added a Known limitations list so the doc does not overclaim in the other
direction: the first-character-after-`(` literal check (a template literal or
concatenation starting with a quote is skipped), unmatched `__require(...)`,
string literals not stripped before the text scan, workspace-sibling `dist`
counted as first-party (so a dep inlined there yields no package name), only
the four .mjs/.cjs targets asserted, and the `@`-prefix family match's
over-reach.
- Tier 3 item 2 now reflects that the RN walker also extracts dynamic
import()/require() and fails loudly on unresolvable local edges.
Workflow claims verified against .github/workflows/static_bundle_size.yml: the
action pin (2.10.0) and the "step names /v2/headless, script covers /v2/context"
note were already correct and are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Tier 3 section shipped in this PR made claims the code contradicts:
- The action pin read v2.9.1; static_bundle_size.yml pins 2.10.0 (by SHA).
- assert-headless-purity.mjs asserts four targets — dist/v2/headless.{mjs,cjs}
AND dist/v2/context.{mjs,cjs} — not just the headless pair.
- The package inventory enumerated 9 packages while the workflow glob covers 10:
react-native was neither listed nor classified, and the "no size script" claim
was wrong for it (it ships size:headless).
- "Both directions of the #4893 regression" overclaimed. The purity script is a
substring scan of four emitted files with @copilotkit/core, @copilotkit/shared,
@ag-ui/*, rxjs, zod and uuid external, so it cannot see a heavy dep arriving
through an external edge; the RN test never resolves bare specifiers, so it
cannot enter node_modules. Both limits are now stated, along with the gap
neither guard covers.
- The heading said "two tiers" while three are documented.
Docs only — no change to the script or the workflow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
react-native was missing from static_bundle_size.yml's package glob, so its
dist/ has never been measured despite being the consumer most exposed to the
#4893 regression (Metro does not tree-shake). This adds coverage:
- Extend the compressed-size-action glob to include react-native.
- New scripts/measure-headless.mjs: an esbuild-driven gzip signal for the
@copilotkit/react-native/headless entry, mirroring react-core's
measure-copilotchat.mjs (stdin + resolveDir, gzip sum, job-summary output).
- Wire a build + measure step into the copilotchat-import-size CI job.
First baseline: @copilotkit/react-native/headless = 92.8 kB gzip
(esbuild regression signal, not a Metro figure).
No limit fields (Phase 1 policy — see dev-docs/bundle-size.md).
esbuild added as a react-native devDependency (^0.27.0, matching react-core);
the root ">=0.25.4" override keeps the monorepo on a single esbuild (0.27.3).
Two corrections to the drafted script, verified by running it:
- Fed the entry via esbuild stdin with resolveDir=pkgRoot; a temp-dir entry
cannot resolve @copilotkit/react-native/headless through workspace node_modules.
- Dropped useRenderToolCall from the import list — the RN headless surface
deliberately does not export it (DOM-dependent; see src/index.ts).
Co-Authored-By: Claude <noreply@anthropic.com>
Adds two CI signals for keeping the published packages small and broadly compatible:
- Bundle size: size-limit file-mode config across packages plus a
CopilotChat import-size regression signal (gzip) so growth in the
headline consumer entrypoint is visible on every PR. A bundle-size
workflow comments results on the PR (Phase 1: no hard-fail).
- ES compatibility: a compat-check (es-check) script across 9 packages
with a root .browserslistrc, validating built .mjs/.cjs against the
es2022 build target.
The measure script is importable (measureBundle) and unit-tested. Dev
docs live under dev-docs/ (bundle-size.md, browser-compat.md). All
action refs are pinned to full commit SHAs for supply-chain safety.