fix(context-bill): --exact walks only what the bill walks, sends nothing for a skill-less tree, and measures only priced frontmatter

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 02:09:04 +00:00
1 parent 0ead0e1119
commit 804d0a6775
3 files changed
+107 -31

No files matched your search

+2 -2
View File
@@ -5,10 +5,10 @@
**`gstack-context-bill` now bills a real install.** On `~/.claude/skills` it reported "2 skills, ~134 tok" always-on, because setup installs each skill as a real directory whose `SKILL.md` and `sections/` are symlinks into the gstack checkout, and the walker only counted regular files. The tool built to answer the token-cost complaint gave a falsely tiny answer on the layout users actually have. On a fresh install it now reports 58 skills, ~6.8K tokens always-on, and 3.6 MB on disk (it was 37 KB across 2 skills). Billing the checkout itself is unchanged (66 skills, ~7.2K tokens).
### Fixed
- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` sends only files the bill prices, each real file once, and prices every link to it with that one measurement, and its notice says how many of the files it sends sit outside the tree behind symlinks (on an install, the checkout's skill files). A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. `--exact` on a path that does not exist now stops with `No such tree` before any request, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. `--help` now says `--exact` sends the `.md` files the bill prices, including files reached through symlinks.
- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` walks and sends only what the bill walks (the same per-skill walks and fences, so a link no skill bills is never walked), each real file once, measures frontmatter only for the `SKILL.md` files the bill prices it for, and prices every link to it with that one measurement, and its notice says how many of the files it sends sit outside the tree behind symlinks (on an install, the checkout's skill files). A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. `--exact` on a path that does not exist now stops with `No such tree` before any request, a tree with no skills sends nothing and writes no egress receipt, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. `--help` now says `--exact` sends the `.md` files the bill prices, including files reached through symlinks.
### Tests
- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's outside-the-tree count, a skill whose `SKILL.md` is first walked under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), and a symlinked skill with a link up to its real parent (the files there are not billed, so not sent). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix.
- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's outside-the-tree count, a skill whose `SKILL.md` is first walked under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), a symlinked skill with a link up to its real parent (the files there are not billed, so not sent, and no billed file is keyed through that link), an existing tree with no skills (no request, no receipt), and a reference file with frontmatter (no frontmatter request for it). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix.
### Gate integrity
- No threshold, budget, timeout, trial count, case kind or assertion changed. The bill of this repo's own tree, which the context-budget ratchet reads, is byte-for-byte unchanged.
+42 -29
View File
@@ -783,27 +783,37 @@ async function countTokens(text: string, { model, apiKey, fetchImpl }: CountToke
return json.input_tokens;
}
export interface ExactSendSet {
/** Each real file once, under the first lexical path a skill's walk reaches it by. */
files: string[];
/** Realpaths of the SKILL.md files whose frontmatter the bill prices. */
frontmatter: Set<string>;
}
/**
* The files --exact sends: the .md files the root walk reaches that the bill
* actually prices, each real file once. The root walk alone would also reach
* files a skill's own walk refuses (a symlinked skill's link up to its real
* parent), and those must not leave the machine when nothing bills them.
* What --exact sends: the same per-skill walks totalMd bills, with the same
* fences, so nothing is walked or sent that no skill bills.
*
* The root is resolved before keying. buildBill resolves its root, so a
* relative root here would produce keys that never match and every lookup
* would fall back to the estimate.
*/
export function exactSendFiles(root: string): string[] {
export function exactSendSet(root: string): ExactSendSet {
const resolved = path.resolve(root);
const fmSuffix = frontmatterKey("");
const priced = new Set<string>();
buildBill(resolved, {
tokensOf: (key) => {
priced.add(fs.realpathSync(key.endsWith(fmSuffix) ? key.slice(0, -fmSuffix.length) : key));
return 0;
},
});
return walkMd(resolved).filter((file) => priced.has(fs.realpathSync(file)));
if (!fs.existsSync(resolved)) throw new Error(`No such tree: ${resolved}`);
const files: string[] = [];
const frontmatter = new Set<string>();
const seen = new Set<string>();
for (const dir of findSkillDirs(resolved)) {
frontmatter.add(fs.realpathSync(path.join(dir, "SKILL.md")));
for (const file of walkMd(dir, { billedRoot: resolved, skipNestedSkills: true })) {
const real = fs.realpathSync(file);
if (seen.has(real)) continue;
seen.add(real);
files.push(file);
}
}
return { files, frontmatter };
}
export interface ExactMeasurement {
@@ -829,9 +839,9 @@ export interface ExactMeasurement {
*/
export async function measureExactTokens(
root: string,
{ files, model, apiKey, fetchImpl = fetch, onProgress, egressHome }: {
/** The send set from exactSendFiles(root), when the caller already computed it. */
files?: string[];
{ sendSet, model, apiKey, fetchImpl = fetch, onProgress, egressHome }: {
/** The send set from exactSendSet(root), when the caller already computed it. */
sendSet?: ExactSendSet;
model: string;
apiKey: string;
fetchImpl?: FetchLike;
@@ -852,10 +862,13 @@ export async function measureExactTokens(
// the same file by every lexical path it is billed under; lexicalOf maps a
// real file back to the one key that was measured.
const lexicalOf = new Map<string, string>();
for (const file of files ?? exactSendFiles(root)) {
const { files, frontmatter } = sendSet ?? exactSendSet(root);
for (const file of files) {
const text = fs.readFileSync(file, "utf8");
const real = fs.realpathSync(file);
texts.set(file, text);
lexicalOf.set(fs.realpathSync(file), file);
lexicalOf.set(real, file);
if (!frontmatter.has(real)) continue;
const fm = parseFrontmatter(text);
if (fm.block) texts.set(frontmatterKey(file), fm.block);
}
@@ -1054,24 +1067,24 @@ export async function contextBillMain(argv: string[], options: MainOptions = {})
// Exact mode is the only path that leaves the machine. Announce what is sent
// before sending it, and degrade to the estimate rather than failing the run.
const sendSet = (tree: string) => (flags.exact ? exactSendFiles(tree) : []);
const exactFor = async (tree: string, walked: string[]): Promise<{
const sendSet = (tree: string) => (flags.exact ? exactSendSet(tree) : undefined);
const exactFor = async (tree: string, set: ExactSendSet | undefined): Promise<{
tokensOf?: TokensOf;
tokenSource?: string;
calibration?: Calibration;
onDone?: () => void;
}> => {
if (!flags.exact) return {};
if (!set || set.files.length === 0) return {};
const treeReal = fs.realpathSync(tree);
const outside = walked.filter((f) => !encloses(treeReal, fs.realpathSync(f))).length;
const outside = set.files.filter((f) => !encloses(treeReal, fs.realpathSync(f))).length;
stderr.write(
`--exact: sending the content of ${walked.length} .md file(s) under ${tree}` +
`--exact: sending the content of ${set.files.length} .md file(s) under ${tree}` +
(outside ? ` (${outside} of them through symlinks to files outside it)` : "") +
` to api.anthropic.com for count_tokens (${flags.exactModel}). No other data leaves this machine.\n`,
);
try {
const measured = await measureExactTokens(tree, {
files: walked,
sendSet: set,
model: flags.exactModel,
apiKey: options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
fetchImpl: options.fetchImpl,
@@ -1105,10 +1118,10 @@ export async function contextBillMain(argv: string[], options: MainOptions = {})
}
const treeA = path.resolve(cwd, positional[0]);
const treeB = path.resolve(cwd, positional[1]);
const filesA = sendSet(treeA);
const filesB = sendSet(treeB);
const optsA = await exactFor(treeA, filesA);
const optsB = await exactFor(treeB, filesB);
const setA = sendSet(treeA);
const setB = sendSet(treeB);
const optsA = await exactFor(treeA, setA);
const optsB = await exactFor(treeB, setB);
const diff = diffBills(buildBill(treeA, optsA), buildBill(treeB, optsB));
optsA.onDone?.();
optsB.onDone?.();
+63
View File
@@ -928,6 +928,69 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", ()
fs.rmSync(tmp, { recursive: true, force: true });
});
it("walks for --exact only what the bill walks, so no file is keyed through a link no skill bills", async () => {
if (process.platform === "win32") return;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-send-walk-"));
const dev = path.join(tmp, "dev");
for (const name of ["m", "zz"]) {
fs.mkdirSync(path.join(dev, name), { recursive: true });
fs.writeFileSync(path.join(dev, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n# ${name}\n`);
}
fs.mkdirSync(path.join(dev, "other"));
fs.writeFileSync(path.join(dev, "other", "n1.md"), "n");
fs.symlinkSync(dev, path.join(dev, "m", "up"));
const skills = path.join(tmp, "skills");
fs.mkdirSync(skills);
fs.symlinkSync(path.join(dev, "m"), path.join(skills, "m"));
fs.symlinkSync(path.join(dev, "zz"), path.join(skills, "zz"));
const calls: { body: string }[] = [];
const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome });
const keys = [...measured.counts.keys()].map((k) => path.relative(skills, k)).sort();
expect(keys).toEqual([
path.join("m", "SKILL.md"),
path.join("m", "SKILL.md#frontmatter"),
path.join("zz", "SKILL.md"),
path.join("zz", "SKILL.md#frontmatter"),
]);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("sends nothing and writes no receipt for an existing tree with no skills", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-no-skills-"));
fs.mkdirSync(path.join(tmp, "docs"));
fs.writeFileSync(path.join(tmp, "docs", "a.md"), "a");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-no-skills-egress-"));
const out = capture();
const calls: { body: string }[] = [];
const code = await contextBillMain([tmp, "--exact"], {
stdout: out.stream,
stderr: out.stream,
apiKey: "sk-test",
fetchImpl: fakeFetch(calls),
egressHome: home,
});
const receipts = fs.readdirSync(home, { recursive: true });
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(home, { recursive: true, force: true });
expect(code).toBe(2);
expect(out.text()).toContain("No SKILL.md files found");
expect(out.text()).not.toContain("--exact: sending");
expect(calls.length).toBe(0);
expect(receipts).toEqual([]);
});
it("measures frontmatter only where the bill prices it", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fm-priced-"));
fs.mkdirSync(path.join(tmp, "qa", "references"), { recursive: true });
fs.writeFileSync(path.join(tmp, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n");
fs.writeFileSync(path.join(tmp, "qa", "references", "r.md"), "---\ntitle: ref\n---\nbody\n");
const calls: { body: string }[] = [];
const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome });
fs.rmSync(tmp, { recursive: true, force: true });
expect(measured.counts.has(path.join(tmp, "qa", "references", "r.md#frontmatter"))).toBe(false);
expect(calls.length).toBe(4);
});
it("names a missing tree, without a raw ENOENT or any request", async () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-missing-"));
const out = capture();