From 2c1f74195f443f60abc801b57c68a4c968b14009 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 02:39:04 +0000 Subject: [PATCH] fix: pre-landing review fixes (context-bill run-4 cycle 1) Checkout detection needs a gstack checkout outside the tree; identity taken during the walk and re-checked by realpath; discovery and calibration read through realpaths; refused-dir collapse is linear; all-refused wording; stale comments; tests for each. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 +- lib/context-bill.ts | 116 +++++++++++++++----------- test/context-bill.test.ts | 166 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 229 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1225fff..74386ec22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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. It sends only files inside the tree or inside the gstack checkout that the tree's `gstack/` entry links to; anything else a symlink reaches (a skill's `notes -> ~/Documents`, say) is not sent, its directory is named in the notice, the files are listed, and they are estimated offline. The notice also says how many sent files come from the checkout. Each file is read through the realpath its walk recorded and only while it is still that same file, so a link retargeted or a file swapped for a link mid-run is refused rather than sent. 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. The walk reads each directory through its realpath, so a chain of links deeper than the kernel's 40-link limit no longer drops files silently. Skill discovery has the same 200,000-entry cap as the walk. A container skill skips only nested directories whose `SKILL.md` is a file, as discovery does, so a `sub/` whose `SKILL.md` is a directory is billed to the container instead of to nobody. `--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. An existing tree with no skills sends nothing and writes no egress receipt, and a tree whose skills have no markdown file to send says the offline estimate was used. `--help` now says which `.md` files `--exact` sends and that others are named and estimated. +- **`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. It sends only files inside the tree or inside the gstack checkout that the tree's `gstack/` entry links to (a git checkout with `VERSION` and `setup` that does not contain the tree); anything else a symlink reaches (a skill's `notes -> ~/Documents`, say) is not sent, its directory is named in the notice, the files are listed, and they are estimated offline. The notice also says how many sent files come from the checkout. Each file is read through the realpath its walk recorded and only while it is still that same file, so a link retargeted or a file swapped for a link mid-run is refused rather than sent. 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. The walk and skill discovery read each directory through its realpath, so a chain of links deeper than the kernel's 40-link limit no longer drops files or skills silently. Skill discovery has the same 200,000-entry cap as the walk. A container skill skips only nested directories whose `SKILL.md` is a file, as discovery does, so a `sub/` whose `SKILL.md` is a directory is billed to the container instead of to nobody. `--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. An existing tree with no skills sends nothing and writes no egress receipt, a tree whose skills have no markdown file to send says the offline estimate was used, and a tree whose every billed file was refused says so. `--help` now says which `.md` files `--exact` sends and that others are named and estimated. ### 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 count of files from the gstack checkout, a skill whose `SKILL.md` another skill's walk reaches first 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), a skill whose `SKILL.md` links to a non-markdown file (no request, no receipt, and a line saying the estimate was used), a reference file with frontmatter (no frontmatter request for it), an install whose skill also links `notes` to a documents folder (the checkout files are sent; the documents file is refused, named, listed and estimated), a link or file swapped during `--diff --exact` (never sent; a swapped file stops the run), a 50-deep chain of links (every file walked and billed), skill discovery past its entry cap (refused), a `sub/` whose `SKILL.md` is a directory (billed to the container), and a tree with no skills under `--exact` (no estimate line). 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 count of files from the gstack checkout, a skill whose `SKILL.md` another skill's walk reaches first 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), a skill whose `SKILL.md` links to a non-markdown file (no request, no receipt, and a line saying the estimate was used), a reference file with frontmatter (no frontmatter request for it), an install whose skill also links `notes` to a documents folder (the checkout files are sent; the documents file is refused, named, listed and estimated), a `gstack/` entry that is not a gstack checkout outside the tree (a plain folder, a bare `.git` folder, or a checkout containing the tree: nothing behind it counts as the checkout), refused files in nested and sibling folders (each folder named once), every billed file refused (said so), a link retargeted, a file replaced by a link or a hard link, or a parent folder swapped during `--diff --exact` (never sent; a swapped file stops the run), a 50-deep chain of links (every file walked, billed and, under `--exact`, measured), a skill 45 directory links deep (discovered), skill discovery past its entry cap (refused), a `sub/` whose `SKILL.md` is a directory (billed to the container), and a tree with no skills under `--exact` (no estimate line). 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. diff --git a/lib/context-bill.ts b/lib/context-bill.ts index 0a805c57d..e4e8693ec 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -210,13 +210,7 @@ function parseFrontmatter(text: string): { bytes: number; keys: string[]; block: return { bytes: Buffer.byteLength(block, "utf8"), keys, block }; } -/** - * What a directory entry is once symlinks are followed. Installed skills are - * symlinks into the gstack checkout (`review/SKILL.md -> /review/SKILL.md`, - * `office-hours/sections -> /office-hours/sections`), so a plain - * Dirent type check sees none of them. Dangling links and anything that is - * neither a file nor a directory are null. - */ +/** Whether a path is a regular file once symlinks are followed; a dangling link is not. */ function isFile(p: string): boolean { try { return fs.statSync(p).isFile(); @@ -225,6 +219,13 @@ function isFile(p: string): boolean { } } +/** + * What a directory entry is once symlinks are followed. Installed skills are + * symlinks into the gstack checkout (`review/SKILL.md -> /review/SKILL.md`, + * `office-hours/sections -> /office-hours/sections`), so a plain + * Dirent type check sees none of them. Dangling links and anything that is + * neither a file nor a directory are null. + */ function entryKind(p: string, e: fs.Dirent): "file" | "dir" | null { if (e.isFile()) return "file"; if (e.isDirectory()) return "dir"; @@ -269,8 +270,8 @@ function encloses(outer: string, inner: string): boolean { * not entered, so a container skill keeps the files it shares with a nested * skill instead of losing them to whichever path sorted first. */ -export function walkMd(dir: string, opts: { billedRoot?: string; skipNestedSkills?: boolean } = {}): string[] { - return walkMdFiles(dir, opts).map((f) => f.path); +export function walkMd(dir: string): string[] { + return walkMdFiles(dir).map((f) => f.path); } export interface WalkedFile { @@ -278,6 +279,9 @@ export interface WalkedFile { path: string; /** The file's realpath when it was walked. */ real: string; + /** The file's identity, taken without following links when it was walked; --exact reads only that same file. */ + dev: number; + ino: number; } /** @@ -330,8 +334,15 @@ export function walkMdFiles(dir: string, opts: { billedRoot?: string; skipNested const p = path.join(current, e.name); if (kind === "file") { if (!real.endsWith(".md")) continue; + let st: fs.Stats; + try { + st = fs.lstatSync(real); + } catch { + continue; + } + if (!st.isFile()) continue; seen.add(real); - out.push({ path: p, real }); + out.push({ path: p, real, dev: st.dev, ino: st.ino }); continue; } if (encloses(real, rootReal) || encloses(real, billedReal)) continue; @@ -472,43 +483,41 @@ export function findSkillDirs(root: string, entryCap: number = WALK_ENTRY_CAP): } catch { return out; } - const walk = (dir: string, isRoot: boolean) => { + // Each directory is read through its realpath (see walkMdFiles), so a deep + // chain of links cannot hit the kernel's link limit and drop a skill. + const walk = (dir: string, real: string, isRoot: boolean) => { let entries: fs.Dirent[]; try { - entries = fs.readdirSync(dir, { withFileTypes: true }); + entries = fs.readdirSync(real, { withFileTypes: true }); } catch { return; } - if (entries.some((e) => e.name === "SKILL.md" && entryKind(path.join(dir, e.name), e) === "file")) { + if (entries.some((e) => e.name === "SKILL.md" && entryKind(path.join(real, e.name), e) === "file")) { out.push(dir); // Two symlinked paths to the same skill dir are BOTH billed (each is // real scanner load); only container recursion below is cycle-guarded. if (!isRoot) return; } // Cycle guard for container recursion (a symlink loop of directories). - let real: string; - try { - real = fs.realpathSync(dir); - } catch { - return; - } if (visited.has(real)) return; visited.add(real); for (const e of entries) { if (++entryCount > entryCap) throw entryCapError(rootDir, entryCap); if (e.name.startsWith(".") || e.name === "node_modules") continue; - const child = path.join(dir, e.name); - if (entryKind(child, e) !== "dir") continue; - if (fs.existsSync(path.join(child, ".git"))) continue; // repo checkout, not a skill + const viaReal = path.join(real, e.name); + if (entryKind(viaReal, e) !== "dir") continue; + if (fs.existsSync(path.join(viaReal, ".git"))) continue; // repo checkout, not a skill + let childReal: string; try { - if (encloses(fs.realpathSync(child), rootReal)) continue; // would climb out of the root + childReal = fs.realpathSync(viaReal); } catch { continue; } - walk(child, false); + if (encloses(childReal, rootReal)) continue; // would climb out of the root + walk(path.join(dir, e.name), childReal, false); } }; - walk(rootDir, true); + walk(rootDir, rootReal, true); return out.sort(); } @@ -815,15 +824,9 @@ async function countTokens(text: string, { model, apiKey, fetchImpl }: CountToke return json.input_tokens; } -export interface ExactFile extends WalkedFile { - /** The walked file's identity; --exact reads only that same file. */ - dev: number; - ino: number; -} - export interface ExactSendSet { /** Each real file once, under the first lexical path a skill's walk reaches it by. */ - files: ExactFile[]; + files: WalkedFile[]; /** Files the bill walks outside the root and the gstack checkout: never sent, estimated offline. */ refused: WalkedFile[]; /** Realpath of the gstack checkout the root's gstack/ entry links to, when there is one. */ @@ -846,13 +849,15 @@ export interface ExactSendSet { export function exactSendSet(root: string): ExactSendSet { const resolved = path.resolve(root); if (!fs.existsSync(resolved)) throw new Error(`No such tree: ${resolved}`); - const files: ExactFile[] = []; + const files: WalkedFile[] = []; const refused: WalkedFile[] = []; const frontmatter = new Set(); const seen = new Set(); const rootReal = fs.realpathSync(resolved); const gstackEntry = path.join(resolved, "gstack"); - const checkout = fs.existsSync(path.join(gstackEntry, ".git")) ? fs.realpathSync(gstackEntry) : undefined; + const looksLikeCheckout = ["VERSION", "setup"].every((name) => isFile(path.join(gstackEntry, name))); + const gstackReal = looksLikeCheckout && fs.existsSync(path.join(gstackEntry, ".git")) ? fs.realpathSync(gstackEntry) : undefined; + const checkout = gstackReal && !encloses(gstackReal, rootReal) ? gstackReal : undefined; for (const dir of findSkillDirs(resolved)) { frontmatter.add(fs.realpathSync(path.join(dir, "SKILL.md"))); for (const file of walkMdFiles(dir, { billedRoot: resolved, skipNestedSkills: true })) { @@ -862,8 +867,7 @@ export function exactSendSet(root: string): ExactSendSet { refused.push(file); continue; } - const { dev, ino } = fs.statSync(file.real); - files.push({ ...file, dev, ino }); + files.push(file); } } return { files, refused, frontmatter, checkout }; @@ -871,9 +875,10 @@ export function exactSendSet(root: string): ExactSendSet { /** * Reads the exact file the walk approved: its realpath, opened without - * following a final link, and only if it is still the same regular file. A - * link retargeted or a file swapped for a link after the walk is refused, so - * nothing the bill did not walk can be sent. + * following a final link, and only if it is still the same regular file at + * the same realpath. A link retargeted, a file replaced, or a parent directory + * swapped for a link after the walk is refused, so nothing the bill did not + * walk can be sent. */ function readWalkedFile(real: string, dev: number, ino: number, file: string, root: string): string { const changed = () => new Error(`${file} changed after it was walked; nothing from ${root} was sent. Run again.`); @@ -885,7 +890,7 @@ function readWalkedFile(real: string, dev: number, ino: number, file: string, ro } try { const st = fs.fstatSync(fd); - if (!st.isFile() || st.dev !== dev || st.ino !== ino) throw changed(); + if (!st.isFile() || st.dev !== dev || st.ino !== ino || fs.realpathSync(real) !== real) throw changed(); return fs.readFileSync(fd, "utf8"); } finally { fs.closeSync(fd); @@ -897,12 +902,15 @@ export interface ExactMeasurement { tokensOf: TokensOf; measuredFiles: number; counts: Map; + /** The realpath each measured file was read from, by its key. */ + realOf: Map; /** Keys priced by estimate because measurement missed them. Read after buildBill. */ missedKeys: Set; } /** - * Measures every text the bill will bill for. Returns a `tokensOf` lookup. + * Measures every text in the send set: what the bill prices inside the root + * or the linked gstack checkout. Returns a `tokensOf` lookup. * * count_tokens prices a whole request, so it includes a fixed message envelope. * That envelope is measured once and subtracted, leaving the tokens each file's @@ -938,11 +946,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(); + const realOf = new Map(); const { files, frontmatter } = sendSet ?? exactSendSet(root); for (const { path: file, real, dev, ino } of files) { const text = readWalkedFile(real, dev, ino, file, root); texts.set(file, text); lexicalOf.set(real, file); + realOf.set(file, real); if (!frontmatter.has(real)) continue; const fm = parseFrontmatter(text); if (fm.block) texts.set(frontmatterKey(file), fm.block); @@ -985,7 +995,8 @@ export async function measureExactTokens( }; await Promise.all(Array.from({ length: Math.min(EXACT_CONCURRENCY, keys.length) }, worker)); - // A key the walk never saw (a non-.md foreign-host file) falls back to the + // A key the send set does not hold (a non-.md foreign-host file, or a file + // refused as outside the root and the checkout) falls back to the // estimate rather than billing zero. Misses are counted, not swallowed: a bill // that is part-measured and part-estimated must not present itself as measured. const missed = new Set(); @@ -1013,6 +1024,7 @@ export async function measureExactTokens( tokensOf, measuredFiles: counts.size, counts, + realOf, missedKeys: missed, }; } @@ -1036,11 +1048,11 @@ export interface Calibration { * Estimate-vs-measured residual per file. This is what makes the divisor * auditable: run --exact and the tool grades its own offline estimate. */ -export function calibrationTable(counts: Map, root: string): Calibration { +export function calibrationTable(counts: Map, root: string, realOf?: Map): Calibration { const rows: Calibration["rows"] = []; for (const [key, tokens] of counts) { if (key.endsWith("#frontmatter") || tokens === 0) continue; - const bytes = bytesOf(key); + const bytes = bytesOf(realOf?.get(key) ?? key); if (bytes == null) continue; // Grade the estimate the tool actually uses, class divisor included. const estimated = Math.round(estimateTokensOf(key, bytes)); @@ -1154,8 +1166,16 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) if (!set) return {}; if (set.refused.length) { const outsideDirs: string[] = []; + const kept = new Set(); for (const d of [...new Set(set.refused.map((f) => path.dirname(f.real)))].sort()) { - if (!outsideDirs.some((kept) => encloses(kept, d))) outsideDirs.push(d); + let covered = false; + for (let a = d; !covered; a = path.dirname(a)) { + covered = kept.has(a); + if (path.dirname(a) === a) break; + } + if (covered) continue; + kept.add(d); + outsideDirs.push(d); } stderr.write( `--exact: not sending ${set.refused.length} .md file(s) that symlinks reach outside ${tree} and the gstack checkout ` + @@ -1164,7 +1184,9 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) ); } if (set.files.length === 0) { - if (set.refused.length || findSkillDirs(path.resolve(tree)).length) { + if (set.refused.length) { + stderr.write(`--exact: every billed .md file is outside ${tree} and the gstack checkout; nothing was sent, so the bill uses the offline estimate.\n`); + } else if (set.frontmatter.size) { stderr.write(`--exact: no markdown skill file to measure under ${tree}; nothing was sent, so the bill uses the offline estimate.\n`); } return {}; @@ -1187,7 +1209,7 @@ export async function contextBillMain(argv: string[], options: MainOptions = {}) return { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, - calibration: calibrationTable(measured.counts, tree), + calibration: calibrationTable(measured.counts, tree, measured.realOf), onDone: () => { if (measured.missedKeys.size) { stderr.write( diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 9466c2e07..ba7954f2c 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -42,6 +42,12 @@ import { skillCensus } from "./helpers/skill-census"; const ROOT = path.join(import.meta.dir, ".."); const TREE_A = path.join(import.meta.dir, "fixtures", "context-bill", "tree-a"); +function markCheckout(dir: string): void { + fs.mkdirSync(path.join(dir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(dir, "VERSION"), "1.0.0\n"); + fs.writeFileSync(path.join(dir, "setup"), "#!/bin/sh\n"); +} + function fileBytes(...segments: string[]): number { return fs.statSync(path.join(...segments)).size; } @@ -157,7 +163,7 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => { // Regression pin for the v1.63 double-count: totalMd on a skill dir that // CONTAINS other skill dirs (the gstack root wraps the whole tree) used to // swallow the children's .md bytes too, so the TOTAL line billed every - // nested skill twice. Dropping skipNestedSkills from totalMd's walkMd call + // nested skill twice. Dropping skipNestedSkills from totalMd's walkMdFiles call // (lib/context-bill.ts) must fail here. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-total-")); fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: parent\ndescription: p\n---\n# Parent\n"); @@ -237,7 +243,7 @@ describe("upstream fix b: installed-tree layout (repo-checkout subdir skip)", () const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-installed-")); // The gstack checkout lives outside the skills root, as on a real install. const checkout = path.join(tmp, "checkout"); - fs.mkdirSync(path.join(checkout, ".git"), { recursive: true }); + markCheckout(checkout); for (const name of ["review", "office-hours"]) { fs.mkdirSync(path.join(checkout, name, "sections"), { recursive: true }); fs.writeFileSync( @@ -413,6 +419,21 @@ describe("upstream fix b: installed-tree layout (repo-checkout subdir skip)", () fs.rmSync(tmp, { recursive: true, force: true }); }); + it("discovers a skill behind a chain of directory links deeper than the kernel's limit", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-deep-skill-")); + const root = path.join(tmp, "root"); + fs.mkdirSync(root); + const depth = 45; + for (let i = 0; i < depth; i++) fs.mkdirSync(path.join(tmp, `d${i}`)); + fs.symlinkSync(path.join(tmp, "d0"), path.join(root, "c0")); + for (let i = 0; i < depth - 1; i++) fs.symlinkSync(path.join(tmp, `d${i + 1}`), path.join(tmp, `d${i}`, `c${i + 1}`)); + fs.writeFileSync(path.join(tmp, `d${depth - 1}`, "SKILL.md"), "---\nname: deep\ndescription: d\n---\n# deep\n"); + const dirs = findSkillDirs(root); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(dirs.length).toBe(1); + }); + it("refuses skill discovery past its entry cap instead of walking on", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-discovery-cap-")); fs.mkdirSync(path.join(root, "vendor")); @@ -774,7 +795,7 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () if (process.platform === "win32") return; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-exact-installed-")); const checkout = path.join(tmp, "checkout"); - fs.mkdirSync(path.join(checkout, ".git"), { recursive: true }); + markCheckout(checkout); fs.mkdirSync(path.join(checkout, "qa"), { recursive: true }); fs.writeFileSync(path.join(checkout, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); fs.writeFileSync(path.join(checkout, "README.md"), "checkout only"); @@ -809,7 +830,7 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () fs.symlinkSync(path.join(checkout, "SKILL.md"), path.join(skills, name, "SKILL.md")); fs.symlinkSync(path.join(checkout, "shared"), path.join(skills, name, "refs")); } - fs.mkdirSync(path.join(checkout, ".git")); + markCheckout(checkout); fs.symlinkSync(checkout, path.join(skills, "gstack")); const calls: { body: string }[] = []; @@ -929,7 +950,7 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () fs.symlinkSync(path.join(tmp, "checkout", "qa", "SKILL.md"), path.join(skills, "qa", "SKILL.md")); fs.symlinkSync(path.join(tmp, "checkout", "qa", "sections"), path.join(skills, "qa", "sections")); fs.writeFileSync(path.join(skills, "qa", "notes.md"), "n"); - fs.mkdirSync(path.join(tmp, "checkout", ".git")); + markCheckout(path.join(tmp, "checkout")); fs.symlinkSync(path.join(tmp, "checkout"), path.join(skills, "gstack")); const stderr = capture(); const code = await contextBillMain([skills, "--exact", "--json"], { @@ -958,7 +979,7 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () fs.writeFileSync(path.join(ext, "private", "notes.md"), "PRIVATE-NOTES"); fs.symlinkSync(ext, path.join(ext, "cc", "up")); fs.symlinkSync(path.join(ext, "cc"), path.join(skills, "cc")); - fs.mkdirSync(path.join(ext, ".git")); + markCheckout(ext); fs.symlinkSync(ext, path.join(skills, "gstack")); const calls: { body: string }[] = []; const stderr = capture(); @@ -992,7 +1013,7 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () fs.mkdirSync(skills); fs.symlinkSync(path.join(dev, "m"), path.join(skills, "m")); fs.symlinkSync(path.join(dev, "zz"), path.join(skills, "zz")); - fs.mkdirSync(path.join(dev, ".git")); + markCheckout(dev); fs.symlinkSync(dev, path.join(skills, "gstack")); const calls: { body: string }[] = []; const measured = await measureExactTokens(skills, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(calls), egressHome }); @@ -1117,13 +1138,31 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(replaced.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); expect(replaced.code).toBe(1); expect(replaced.err).toContain("changed after it was walked"); + + const hardlinked = await run((store) => { + fs.unlinkSync(path.join(store, "safe.md")); + fs.linkSync(path.join(store, "secret.env"), path.join(store, "safe.md")); + }); + expect(hardlinked.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + expect(hardlinked.code).toBe(1); + expect(hardlinked.err).toContain("changed after it was walked"); + + const parentSwapped = await run((store) => { + const decoy = `${store}-decoy`; + fs.mkdirSync(decoy); + fs.writeFileSync(path.join(decoy, "safe.md"), "SYNTHETIC_PRIVATE_DECOY"); + fs.renameSync(store, `${store}-old`); + fs.symlinkSync(decoy, store); + }); + expect(parentSwapped.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + expect(parentSwapped.code).toBe(1); }); it("sends files in the root and the linked gstack checkout, and refuses and names anything else a link reaches", async () => { if (process.platform === "win32") return; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-")); const checkout = path.join(tmp, "checkout"); - fs.mkdirSync(path.join(checkout, ".git"), { recursive: true }); + markCheckout(checkout); fs.mkdirSync(path.join(checkout, "qa", "sections"), { recursive: true }); fs.writeFileSync(path.join(checkout, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# CHECKOUT-QA\n"); fs.writeFileSync(path.join(checkout, "qa", "sections", "flow.md"), "CHECKOUT-FLOW"); @@ -1163,6 +1202,117 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(bill.skills[0].totalMdBytes).toBe(skillMd.length + "CHECKOUT-FLOW".length + "PRIVATE-DIARY".length); }); + async function exactRun(skills: string) { + const calls: { body: string }[] = []; + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + }); + return { code, calls, err: stderr.text() }; + } + + it("names each outside directory once, collapsing nested ones, and lists every refused file", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-dirs-")); + const docs = path.join(tmp, "Documents"); + fs.mkdirSync(path.join(docs, "vault", "sub"), { recursive: true }); + fs.mkdirSync(path.join(docs, "vault-b"), { recursive: true }); + fs.writeFileSync(path.join(docs, "vault", "a.md"), "A"); + fs.writeFileSync(path.join(docs, "vault", "sub", "b.md"), "B"); + fs.writeFileSync(path.join(docs, "vault-b", "c.md"), "C"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(docs, "vault"), path.join(skills, "qa", "v1")); + fs.symlinkSync(path.join(docs, "vault-b"), path.join(skills, "qa", "v2")); + const vault = fs.realpathSync(path.join(docs, "vault")); + const vaultB = fs.realpathSync(path.join(docs, "vault-b")); + const run = await exactRun(skills); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(run.err).toContain(`not sending 3 .md file(s)`); + expect(run.err).toContain(`(under ${vault}, ${vaultB})`); + for (const f of [path.join("qa", "v1", "a.md"), path.join("qa", "v1", "sub", "b.md"), path.join("qa", "v2", "c.md")]) { + expect(run.err).toContain(` ${f}\n`); + } + }); + + it("does not take a gstack/ entry as the checkout unless it is a gstack checkout outside the root", async () => { + if (process.platform === "win32") return; + const cases: Array<(tmp: string, skills: string, docs: string) => void> = [ + (_tmp, skills, docs) => fs.symlinkSync(docs, path.join(skills, "gstack")), + (_tmp, skills, docs) => { + fs.mkdirSync(path.join(docs, ".git")); + fs.symlinkSync(docs, path.join(skills, "gstack")); + }, + (tmp, skills) => { + markCheckout(tmp); + fs.symlinkSync(tmp, path.join(skills, "gstack")); + }, + ]; + for (const plant of cases) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-not-checkout-")); + const docs = path.join(tmp, "Documents"); + fs.mkdirSync(path.join(docs, "vault"), { recursive: true }); + fs.writeFileSync(path.join(docs, "vault", "diary.md"), "PRIVATE-DIARY"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(docs, "vault"), path.join(skills, "qa", "notes")); + plant(tmp, skills, docs); + const run = await exactRun(skills); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(run.code).toBe(0); + expect(run.calls.some((c) => c.body.includes("PRIVATE-DIARY"))).toBe(false); + expect(run.err).toContain("not sending 1 .md file(s)"); + expect(run.err).not.toContain("in the gstack checkout"); + } + }); + + it("says every billed file was refused, not that there was none, when nothing is left to send", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-all-refused-")); + fs.mkdirSync(path.join(tmp, "ext", "qa"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "ext", "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(skills); + fs.symlinkSync(path.join(tmp, "ext", "qa"), path.join(skills, "qa")); + const run = await exactRun(skills); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(run.calls.length).toBe(0); + expect(run.err).toContain("not sending 1 .md file(s)"); + expect(run.err).toContain("every billed .md file is outside"); + expect(run.err).not.toContain("no markdown skill file"); + }); + + it("--exact measures every file behind a link chain deeper than the kernel's limit", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-exact-deep-")); + const levels = path.join(tmp, "levels"); + const depth = 50; + for (let i = 0; i < depth; i++) { + fs.mkdirSync(path.join(levels, `L${i}`), { recursive: true }); + fs.writeFileSync(path.join(levels, `L${i}`, `f${i}.md`), `F${i}-${"x".repeat(30)}`); + } + for (let i = 0; i < depth - 1; i++) fs.symlinkSync(path.join(levels, `L${i + 1}`), path.join(levels, `L${i}`, "next")); + markCheckout(levels); + const root = path.join(tmp, "skills"); + fs.mkdirSync(path.join(root, "qa"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(levels, "L0"), path.join(root, "qa", "next")); + fs.symlinkSync(levels, path.join(root, "gstack")); + const measured = await measureExactTokens(root, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); + buildBill(root, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource }); + const rows = calibrationTable(measured.counts, root, measured.realOf).rows.length; + fs.rmSync(tmp, { recursive: true, force: true }); + expect([...measured.missedKeys]).toEqual([]); + expect([...measured.counts.keys()].filter((k) => !k.endsWith("#frontmatter")).length).toBe(depth + 1); + expect(rows).toBe(depth + 1); + }); + 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();