From ba7e3b4e4ffe8911a8661cfc4e5bb08acdcb0a43 Mon Sep 17 00:00:00 2001 From: Ercan Er Date: Thu, 10 Sep 2026 15:36:06 +0300 Subject: [PATCH 1/4] fix(savings): pin rendered numbers to one locale so graft can read its own footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit savingsLine() wrote the `[graft] tokens saved ≈ N` footer with the machine's locale; sumSavingsFooters() read it back with `([\d,]+)`. The two are fifteen lines apart, under a comment saying they are kept together so they cannot drift — and they had not drifted from each other, only from Intl. On a machine that groups thousands with anything but a comma, graft parsed its own footer as the first group: de-DE and tr-TR write `1.990` and get 1, fr-FR and ru-RU write `1 990` (narrow no-break space) and get 1. Everything downstream of savedTokens carried that number, not the real one — the statusline's tokens saved and its dollar value, `graft stats`, and the `saved_tokens_bucket` on session_summary telemetry, all short by ~1000x for every user outside a comma locale. One pinned formatter, used by all five sites that render one of these numbers: the three that write the parsed footer (context/savings.ts, ask/ask.ts, claude/format.ts) and the two displays beside it. The displays are never parsed, but a statusline reading `1.990` above a footer reading `1,990` would be its own small bug, and cli-epilogue.ts already pins the same way. The reader is untouched: widening it would mean guessing, since `1.990` is a valid en-US decimal. The test moves the *default* locale rather than the ambient one — which a process cannot set for itself — so it fails without this fix on an en-US runner too. That is the property CI was missing: its runners always had the comma, so the round trip could never break there. Fixes #338 Co-Authored-By: Claude Opus 5 --- src/ask/ask.ts | 8 +++--- src/claude/format.ts | 9 ++++--- src/claude/session-metrics.ts | 4 +-- src/context/savings.ts | 25 ++++++++++++++--- test/savings-locale.test.ts | 51 +++++++++++++++++++++++++++++++++++ test/savings.test.ts | 7 +++-- 6 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 test/savings-locale.test.ts diff --git a/src/ask/ask.ts b/src/ask/ask.ts index 7c3450ffd..287f29852 100644 --- a/src/ask/ask.ts +++ b/src/ask/ask.ts @@ -17,7 +17,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import matter from "gray-matter"; import { contextDirFor } from "../context/node-file.js"; -import { withSavings, savingsFor, savingsTurnNudge, type Savings } from "../context/savings.js"; +import { withSavings, savingsFor, savingsTurnNudge, groupDigits, type Savings } from "../context/savings.js"; import { loadGraphCached, loadAskIndexCached } from "../graph/load.js"; import { assertPrefixIndexed, @@ -1561,9 +1561,9 @@ function askSavingsLine(r: AskResult, body: string): string { const saved = base - pack; const pct = Math.round((saved / base) * 100); return ( - `[graft] tokens saved ≈ ${saved.toLocaleString()} (${pct}%) — this pack ≈ ` + - `${pack.toLocaleString()} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` + - `${base.toLocaleString()} tok. Estimate (baseline = those files read in full).` + + `[graft] tokens saved ≈ ${groupDigits(saved)} (${pct}%) — this pack ≈ ` + + `${groupDigits(pack)} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` + + `${groupDigits(base)} tok. Estimate (baseline = those files read in full).` + savingsTurnNudge(saved) ); } diff --git a/src/claude/format.ts b/src/claude/format.ts index e45bf9c52..beb2e33a4 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -6,6 +6,7 @@ import type { GraphV1, EdgeV1 } from '../graph/types.js'; // question in both places, and one set of calibrated numbers beats two. import { HIGH_FLOOR, STRONG_FLOOR } from '../ask/fuse.js'; import { dollarsSaved, formatDollars } from '../context/price.js'; +import { groupDigits } from '../context/savings.js'; const C = { indigo: (s: string) => `\x1b[38;2;84;111;255m${s}\x1b[0m`, @@ -39,7 +40,7 @@ export function renderStatusline( // stands alone rather than carrying a rate nobody measured. const usd = dollarsSaved(saved, session?.inputCostMicros, session?.inputTokensBilled); const money = usd === null ? '' : ` · ~${formatDollars(usd)}`; - top.push(C.indigo(`~${saved.toLocaleString()} tok saved${money}`)); + top.push(C.indigo(`~${groupDigits(saved)} tok saved${money}`)); } const bottom: string[] = []; @@ -133,9 +134,9 @@ export function formatRetrieval(ask: AskJson, cap = 5): string | null { const base = tokensOf(ask.saved!.baselineChars); const pct = Math.round((saved / base) * 100); return ( - `${body}\n[graft] tokens saved ≈ ${saved.toLocaleString()} (${pct}%); this pack ≈ ` + - `${tokensOf(body.length).toLocaleString()} tok vs reading the ${ask.saved!.files} file(s) whole ≈ ` + - `${base.toLocaleString()} tok (estimate).` + `${body}\n[graft] tokens saved ≈ ${groupDigits(saved)} (${pct}%); this pack ≈ ` + + `${groupDigits(tokensOf(body.length))} tok vs reading the ${ask.saved!.files} file(s) whole ≈ ` + + `${groupDigits(base)} tok (estimate).` ); } diff --git a/src/claude/session-metrics.ts b/src/claude/session-metrics.ts index 660b2c9fa..88e69c5ba 100644 --- a/src/claude/session-metrics.ts +++ b/src/claude/session-metrics.ts @@ -31,7 +31,7 @@ */ import { statSync } from 'node:fs'; import { join } from 'node:path'; -import { sumSavingsFooters } from '../context/savings.js'; +import { groupDigits, sumSavingsFooters } from '../context/savings.js'; import { dollarsSaved, formatDollars } from '../context/price.js'; import { readSession, writeSession, sessionDir, listSessionIds, type SessionState } from './state.js'; import type { AgentHost } from '../telemetry/contract.js'; @@ -199,7 +199,7 @@ export function formatSessionStats(s: SessionSummary | null): string { ` graft reads: ${graft}`, ` source reads: ${source} (Read / Grep / Glob)`, ` mix: ${mix}`, - ` tokens saved: ~${saved.toLocaleString()}`, + ` tokens saved: ~${groupDigits(saved)}`, ]; // Omitted, not zeroed, when no turn has been billed yet: a host whose hooks // name no transcript can't know what a token costs here, and a made-up rate diff --git a/src/context/savings.ts b/src/context/savings.ts index bacc7c25c..b888fffbd 100644 --- a/src/context/savings.ts +++ b/src/context/savings.ts @@ -105,6 +105,25 @@ export function savingsTurnNudge(savedTokens: number): string { ); } +/** + * Digits grouped for display, pinned to en-US. + * + * Not a style choice: the `[graft] tokens saved ≈ N` line below is read back by + * {@link sumSavingsFooters}, whose regex knows `,` and nothing else. Rendered + * with the machine's locale instead, a de-DE or tr-TR box wrote `1.990` and + * graft parsed its own footer as `1` — every savings total downstream, and the + * dollar value beside it, off by a factor of a thousand (#338). fr-FR and ru-RU + * group with a narrow no-break space and lost it the same way. + * + * Every number graft renders goes through here, not only the parsed one: a + * statusline that grouped differently from the footer right above it would be + * its own small bug. `src/cli-epilogue.ts` pins the same way, for the same + * reason. + */ +export function groupDigits(n: number): string { + return n.toLocaleString('en-US'); +} + /** The one-line savings estimate for a command's text output, so the agent gets * the number for free — no extra tool call. `body` is the exact rendered output * the agent reads (the pack). Returns "" when there's nothing honest to claim: @@ -118,9 +137,9 @@ export function savingsLine(body: string, saved: Savings | undefined): string { const delta = base - pack; const pct = Math.round((delta / base) * 100); return ( - `[graft] tokens saved ≈ ${delta.toLocaleString()} (${pct}%) — this output ≈ ` + - `${pack.toLocaleString()} tok vs reading the ${saved.files} file(s) it covers whole ≈ ` + - `${base.toLocaleString()} tok (estimate).` + + `[graft] tokens saved ≈ ${groupDigits(delta)} (${pct}%) — this output ≈ ` + + `${groupDigits(pack)} tok vs reading the ${saved.files} file(s) it covers whole ≈ ` + + `${groupDigits(base)} tok (estimate).` + savingsTurnNudge(delta) ); } diff --git a/test/savings-locale.test.ts b/test/savings-locale.test.ts new file mode 100644 index 000000000..59ac4c1f1 --- /dev/null +++ b/test/savings-locale.test.ts @@ -0,0 +1,51 @@ +/** + * #338: graft rendered its savings footer with the machine's locale and read it + * back with a regex that knows only `,`, so a `de-DE`/`tr-TR` box wrote `1.990` + * and parsed it as `1`. + * + * A process cannot change its own ambient locale — Node takes it from the + * environment on posix and from the OS on Windows — so these tests move the + * *default* instead: `toLocaleString()` with no argument answers the way a + * `.`-grouping locale would, while calls that name a locale are left alone. + * That is exactly the difference the fix turns on, and it behaves identically on + * an en-US CI runner, which is why CI never caught the original. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { savingsLine, sumSavingsFooters } from '../src/context/savings.js'; +import { formatSessionStats } from '../src/claude/session-metrics.js'; + +/** Run `fn` on a machine whose default locale groups thousands with `locale`'s + * separator. Restores the real method even if `fn` throws. */ +function withDefaultLocale(locale: string, fn: () => T): T { + const real = Number.prototype.toLocaleString; + Number.prototype.toLocaleString = function ( + this: number, + locales?: string | string[], + options?: Intl.NumberFormatOptions, + ): string { + return real.call(this, locales ?? locale, options); + }; + try { + return fn(); + } finally { + Number.prototype.toLocaleString = real; + } +} + +test('#338: the savings footer round-trips on a machine that is not en-US', () => { + // Sanity: the stand-in really does change what an unqualified call renders, + // or the rest of this file would pass for the wrong reason. + assert.equal(withDefaultLocale('de-DE', () => (1990).toLocaleString()), '1.990'); + + const line = withDefaultLocale('de-DE', () => savingsLine('x'.repeat(40), { files: 2, baselineChars: 8000 })); + assert.match(line, /tokens saved ≈ 1,990/, 'grouped for the reader, not for the machine'); + assert.equal(sumSavingsFooters(line), 1990, 'and read back whole, not as its first group'); +}); + +test('#338: what graft displays groups the same way as what it parses', () => { + const out = withDefaultLocale('de-DE', () => + formatSessionStats({ id: 'abc', perAgentQuery: {}, graftReads: 8, sourceReads: 2, savedTokens: 12345 }), + ); + assert.match(out, /tokens saved:\s+~12,345/); +}); diff --git a/test/savings.test.ts b/test/savings.test.ts index 4a9596bfe..4c862e57e 100644 --- a/test/savings.test.ts +++ b/test/savings.test.ts @@ -4,7 +4,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { savingsFor, savingsLine, withSavings, toTokens, setInputRate } from '../src/context/savings.js'; +import { savingsFor, savingsLine, withSavings, toTokens, setInputRate, groupDigits } from '../src/context/savings.js'; import { hasSavingsTally } from '../src/claude/tally.js'; import type { GraphV1, NodeV1 } from '../src/graph/types.js'; @@ -49,7 +49,10 @@ test('savingsLine: reports saved tokens and percent when the output is smaller', assert.match(footer, /tokens saved ≈ [\d,]+ \(\d+%\)/); assert.match(footer, /2 file\(s\)/); const base = toTokens(8000); - assert.ok(footer.includes((base - toTokens(body.length)).toLocaleString())); + // Through the same pinned formatter the footer uses: rendered with the test + // machine's locale instead, this asserted `1.990` against a footer that says + // `1,990` — the mirror image of #338, and just as invisible on an en-US runner. + assert.ok(footer.includes(groupDigits(base - toTokens(body.length)))); // The nudge rides along so the agent reports the turn total without SKILL.md. assert.match(footer, /end of your reply/i); assert.match(footer, /graft saved ~N tokens this turn/); From dcaefccca95c4da52d9fad105ca01e5bc55ffe6b Mon Sep 17 00:00:00 2001 From: Ercan Er Date: Fri, 11 Sep 2026 04:43:10 +0300 Subject: [PATCH 2/4] test(savings): cover the `graft ask` footer, the third writer of the parsed line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groupDigits() went into three places that write the `[graft] tokens saved ≈ N` line sumSavingsFooters() parses, but the tests only rendered two of them. The third, askSavingsLine() in ask/ask.ts, has its own wording of the footer and no test reached it at all — so reverting it to toLocaleString() would have brought #338 back for every `graft ask` while the rest of the suite stayed green, which is the kind of drift between writers #338 was. Renders it through formatAsk() under the same moved default locale as the other two tests. Fails with ask.ts reverted; passes as it is. Refs #338 Co-Authored-By: Claude Opus 5 --- test/savings-locale.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/savings-locale.test.ts b/test/savings-locale.test.ts index 59ac4c1f1..0e9440618 100644 --- a/test/savings-locale.test.ts +++ b/test/savings-locale.test.ts @@ -14,6 +14,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { savingsLine, sumSavingsFooters } from '../src/context/savings.js'; import { formatSessionStats } from '../src/claude/session-metrics.js'; +import { formatAsk, type AskResult } from '../src/ask/ask.js'; /** Run `fn` on a machine whose default locale groups thousands with `locale`'s * separator. Restores the real method even if `fn` throws. */ @@ -49,3 +50,18 @@ test('#338: what graft displays groups the same way as what it parses', () => { ); assert.match(out, /tokens saved:\s+~12,345/); }); + +test('#338: `graft ask` writes a footer its own reader can parse, on a machine that is not en-US', () => { + // The third writer of the parsed line, and the one no other test renders: ask.ts + // has its own wording of the footer, so a revert there would bring the bug back + // for every `graft ask` while the other two writers stayed green. + const result: AskResult = { + query: 'probe', + mode: 'lexical', + hits: [{ kind: 'symbol', title: 'probe', pointer: 'src/probe.ts:L1-L2', snippet: '', score: 1 }], + saved: { files: 3, baselineChars: 8000 }, // ≈ 2000 tok against a pack of a few dozen + }; + const out = withDefaultLocale('de-DE', () => formatAsk(result)); + assert.match(out, /tokens saved ≈ \d{1,3},\d{3} /, 'grouped for the reader, not for the machine'); + assert.ok(sumSavingsFooters(out) >= 1000, `read back whole, not as its first group (got ${sumSavingsFooters(out)})`); +}); From 21b2c62e63cf7b784ce175797c82c4d2aab31823 Mon Sep 17 00:00:00 2001 From: Ercan Er Date: Sat, 12 Sep 2026 03:19:02 +0300 Subject: [PATCH 3/4] Add ratchet for lazy grammar imports Add a regression test that walks the TypeScript AST for every file under `src/` and fails if any native `tree-sitter-*` grammar is imported statically. This protects the lazy-loading behavior that prevents missing native prebuilds from crashing the whole CLI, and includes fixtures to prove the scanner catches banned imports without flagging `tree-sitter` itself or lazy `require()` usage. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: sonnet5 <24634891+sonnet5@users.noreply.github.com> Co-Authored-By: opus5 <103333277+opus5@users.noreply.github.com> --- test/ratchet-lazy-grammar-import.test.ts | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 test/ratchet-lazy-grammar-import.test.ts diff --git a/test/ratchet-lazy-grammar-import.test.ts b/test/ratchet-lazy-grammar-import.test.ts new file mode 100644 index 000000000..c7a292a6c --- /dev/null +++ b/test/ratchet-lazy-grammar-import.test.ts @@ -0,0 +1,90 @@ +/** + * Repo-wide ratchet: a native grammar package must never be a static import. + * + * #323 — `import kotlin from "tree-sitter-kotlin"` (or any of its siblings) at + * the top of a module runs the instant that module is loaded, before argv is + * read. On a platform with no prebuild and no compiler, that import throws and + * takes the whole CLI down with a node-gyp-build stack trace that never says + * "graft" — `--version`, `--help`, and every language other than Kotlin died + * with it. #337 replaced the static imports in extract.ts with `createRequire` + * calls behind a per-language try/catch; #217 later moved the require itself + * behind a `grammarOf()` so the module loads on first *parse*, not at import. + * + * The regression this guards against is not "someone edits extract.ts wrong" — + * it is "someone adds an eighth call site". A grep over one file only proves + * the seven names it currently checks; a new file, or a refactor that + * re-exports one of these packages under a different name, sails past a scan + * scoped like that. So this walks the real TypeScript AST over every file in + * `src/`, not a fixed list, and keys on the import target, not the surrounding + * text — `import x from "tree-sitter-kotlin"`, `import * as k from "..."`, and + * a bare `import "tree-sitter-kotlin"` are the same node to this scan. + * + * `tree-sitter` itself (the core, JS-only package) is not on the banned list: + * it has no prebuild to be missing and loading it cannot reproduce #323. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +const SRC = join(import.meta.dirname, "..", "src"); + +// The exact packages GRAMMAR_MODULES in src/graph/extract.ts loads lazily. +// Kept as a literal list, not an import from extract.ts: importing that module +// here would defeat the point if it ever regressed back to eager loading. +const BANNED_GRAMMAR_PACKAGES = [ + "tree-sitter-typescript", + "tree-sitter-python", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-kotlin", + "tree-sitter-swift", + "tree-sitter-php", + "tree-sitter-r", +]; + +function walk(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? walk(join(dir, e.name)) : /\.tsx?$/.test(e.name) ? [join(dir, e.name)] : [], + ); +} + +/** Every static `import ... from ""` (or bare `import ""`) in one file. */ +function staticGrammarImports(path: string, source: string): string[] { + const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, false); + const hits: string[] = []; + const visit = (node: ts.Node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + if (BANNED_GRAMMAR_PACKAGES.includes(node.moduleSpecifier.text)) hits.push(node.moduleSpecifier.text); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return hits; +} + +test("no source file statically imports a native grammar package (#323)", () => { + const offenders: string[] = []; + for (const path of walk(SRC)) { + const hits = staticGrammarImports(path, readFileSync(path, "utf8")); + if (hits.length > 0) offenders.push(`${path.slice(SRC.length + 1)}: ${hits.join(", ")}`); + } + assert.deepEqual( + offenders, + [], + `a native grammar package is imported statically — it must be loaded lazily ` + + `(createRequire + try/catch, see src/graph/extract.ts) so a missing prebuild ` + + `costs one language, not the whole CLI:\n${offenders.join("\n")}`, + ); +}); + +test("the scanner can actually catch a static grammar import", () => { + const fixture = `import kotlin from "tree-sitter-kotlin";\nexport const x = kotlin;\n`; + assert.deepEqual(staticGrammarImports("fixture.ts", fixture), ["tree-sitter-kotlin"]); +}); + +test("the scanner does not flag the core tree-sitter package or a lazy require", () => { + const fixture = `import Parser from "tree-sitter";\nimport { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nrequire("tree-sitter-kotlin");\n`; + assert.deepEqual(staticGrammarImports("fixture.ts", fixture), []); +}); From 35c014890d765bef53b1e3e4432c9f184e895ca1 Mon Sep 17 00:00:00 2001 From: Ercan Er <140520567+Ercaner1988@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:24:29 +0300 Subject: [PATCH 4/4] test: remove unrelated lazy grammar ratchet from locale PR --- test/ratchet-lazy-grammar-import.test.ts | 90 ------------------------ 1 file changed, 90 deletions(-) delete mode 100644 test/ratchet-lazy-grammar-import.test.ts diff --git a/test/ratchet-lazy-grammar-import.test.ts b/test/ratchet-lazy-grammar-import.test.ts deleted file mode 100644 index c7a292a6c..000000000 --- a/test/ratchet-lazy-grammar-import.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Repo-wide ratchet: a native grammar package must never be a static import. - * - * #323 — `import kotlin from "tree-sitter-kotlin"` (or any of its siblings) at - * the top of a module runs the instant that module is loaded, before argv is - * read. On a platform with no prebuild and no compiler, that import throws and - * takes the whole CLI down with a node-gyp-build stack trace that never says - * "graft" — `--version`, `--help`, and every language other than Kotlin died - * with it. #337 replaced the static imports in extract.ts with `createRequire` - * calls behind a per-language try/catch; #217 later moved the require itself - * behind a `grammarOf()` so the module loads on first *parse*, not at import. - * - * The regression this guards against is not "someone edits extract.ts wrong" — - * it is "someone adds an eighth call site". A grep over one file only proves - * the seven names it currently checks; a new file, or a refactor that - * re-exports one of these packages under a different name, sails past a scan - * scoped like that. So this walks the real TypeScript AST over every file in - * `src/`, not a fixed list, and keys on the import target, not the surrounding - * text — `import x from "tree-sitter-kotlin"`, `import * as k from "..."`, and - * a bare `import "tree-sitter-kotlin"` are the same node to this scan. - * - * `tree-sitter` itself (the core, JS-only package) is not on the banned list: - * it has no prebuild to be missing and loading it cannot reproduce #323. - */ -import assert from "node:assert/strict"; -import test from "node:test"; -import { readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import ts from "typescript"; - -const SRC = join(import.meta.dirname, "..", "src"); - -// The exact packages GRAMMAR_MODULES in src/graph/extract.ts loads lazily. -// Kept as a literal list, not an import from extract.ts: importing that module -// here would defeat the point if it ever regressed back to eager loading. -const BANNED_GRAMMAR_PACKAGES = [ - "tree-sitter-typescript", - "tree-sitter-python", - "tree-sitter-go", - "tree-sitter-java", - "tree-sitter-kotlin", - "tree-sitter-swift", - "tree-sitter-php", - "tree-sitter-r", -]; - -function walk(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((e) => - e.isDirectory() ? walk(join(dir, e.name)) : /\.tsx?$/.test(e.name) ? [join(dir, e.name)] : [], - ); -} - -/** Every static `import ... from ""` (or bare `import ""`) in one file. */ -function staticGrammarImports(path: string, source: string): string[] { - const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, false); - const hits: string[] = []; - const visit = (node: ts.Node) => { - if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { - if (BANNED_GRAMMAR_PACKAGES.includes(node.moduleSpecifier.text)) hits.push(node.moduleSpecifier.text); - } - ts.forEachChild(node, visit); - }; - visit(sf); - return hits; -} - -test("no source file statically imports a native grammar package (#323)", () => { - const offenders: string[] = []; - for (const path of walk(SRC)) { - const hits = staticGrammarImports(path, readFileSync(path, "utf8")); - if (hits.length > 0) offenders.push(`${path.slice(SRC.length + 1)}: ${hits.join(", ")}`); - } - assert.deepEqual( - offenders, - [], - `a native grammar package is imported statically — it must be loaded lazily ` + - `(createRequire + try/catch, see src/graph/extract.ts) so a missing prebuild ` + - `costs one language, not the whole CLI:\n${offenders.join("\n")}`, - ); -}); - -test("the scanner can actually catch a static grammar import", () => { - const fixture = `import kotlin from "tree-sitter-kotlin";\nexport const x = kotlin;\n`; - assert.deepEqual(staticGrammarImports("fixture.ts", fixture), ["tree-sitter-kotlin"]); -}); - -test("the scanner does not flag the core tree-sitter package or a lazy require", () => { - const fixture = `import Parser from "tree-sitter";\nimport { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nrequire("tree-sitter-kotlin");\n`; - assert.deepEqual(staticGrammarImports("fixture.ts", fixture), []); -});