diff --git a/CHANGELOG.md b/CHANGELOG.md index 951f3e7..4a2c4a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256 checksums are published at . -The current public release is [v0.2.39 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.39), +The current public release is [v0.2.40 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.40), distributed from . The npm `latest` tag is still [`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24): @@ -16,6 +16,90 @@ were superseded before distribution. No unreleased changes. +## 0.2.40 — 2026-08-07 + +- **Python can now be analyzed locally, if you ask for it.** `codetruss + grammars install python` downloads the `web-tree-sitter` runtime and the + compiled Python grammar (722 KB) into your data directory — XDG on macOS and + Linux, `LOCALAPPDATA` on Windows. Nothing is bundled in the tarball, nothing + is fetched during an analysis, and no other command installs it for you. The + CLI ships a hand-written JavaScript parser precisely because these grammars + are several times its entire release budget, and that trade is unchanged for + anyone who does not run this command. `codetruss grammars list|status| + uninstall` round out the group; `status` exits non-zero when a pack is + missing or fails verification, so it can gate a setup script. +- **The pack is pinned, verified as it arrives, and verified again every time + it is loaded.** Each artifact's SHA-256 is compiled into the CLI at build + time. The download is hashed as it streams, with the pinned length enforced + mid-stream so a wrong or hostile origin cannot write an unbounded file to + disk; artifacts land in a scratch directory and are moved into place only + after every one of them verifies, so a pack directory is never half-installed. + The only download origin is `codetruss.com` — no third-party CDN, and + redirects are refused. Hashing is streamed in-process, never shelled out to + `shasum` or `Get-FileHash`. **Every** failure — absent, truncated, over-long, + wrong digest, unreadable, or an unexpected extra file in the pack directory — + resolves to "pack unavailable", and the run reports Python as skipped. There + is no path on which unverified bytes are executed. +- **Python runs the complete rule pack, not the reduced JavaScript subset.** + That subset exists because a hand-written parser might disagree with + tree-sitter, and only rules proven to agree were admitted. A grammar pack *is* + the hosted parser and the hosted grammar, so there is no divergence to guard + against — and narrowing it would report less than the same code receives in a + hosted scan, for no gain in precision. Command injection, path traversal, + SSRF and insecure deserialization are checked in Python locally; they remain + unchecked in JavaScript, TypeScript and TSX, and the receipt keeps saying so. +- **Verified against the hosted path over 233 real Python files** — the + full-stack FastAPI template, three further repositories, and a synthetic + fixture covering each rule class. Both parsers produced the same 11 findings, + with **zero divergence in either direction**. +- **Receipts move to the `local-registry-v4` profile, which states what the run + actually did about Python.** The pass set is unchanged from v3; the wording + had to change, because v3 says flatly that the local pass covers "JavaScript, + TypeScript and TSX only" and that Python received no security analysis, and + that is false whenever a pack is installed. There are now three + distinguishable statements instead of one frozen sentence: **absent** names + the Python file count and the command that would cover them, **verified** + names the rule pack and the file count while keeping the JavaScript subset's + limits scoped to JavaScript, and a **failed** pack now says *which* kind of + failure it was — a digest mismatch (the pack does not match what this CLI + published, so reinstall), a runtime that would not start on this machine even + though the digests matched, or a scan that threw partway and had its partial + results discarded. Only a real digest mismatch renders the tampering sentence; + an out-of-memory error no longer accuses your install of not matching the + published digests. Every failure branch closes with the provable "No findings + from this pack were reported" in place of the wider absolute claim. + `local-registry-v3` keeps a frozen renderer, so receipts signed by 0.2.39 + still verify byte-for-byte. +- **The bytes that are verified are now the exact bytes that execute.** The + loader used to hash each artifact by path and then re-open the same path to + `require()` it, so the file that was hashed and the file that ran were two + separate reads with a window between them — three digests and a directory + listing wide enough for another process with write access to the pack + directory to swap a hostile `tree-sitter.js` in after the check and have it + executed. `inspectGrammarPack` now reads each artifact once and returns the + buffer it hashed; the runtime is compiled from that buffer and the two WASM + artifacts are handed to `web-tree-sitter` as in-memory `Uint8Array`s + (`wasmBinary` and `Language.load`), so nothing is ever resolved from a path a + second time. Artifacts are opened `O_NOFOLLOW` and rejected unless they are + regular files; a symlinked pack root, a pack root not owned by the current + user, or one writable by group or other is refused, and a loose root created + by an earlier CLI is tightened to `0700` on install. A local same-user race + that reliably executed attacker code against the previous loader now fails + every attempt. +- **Fixed: Python was silently dropped from the second half of every review.** + The tree-sitter runtime reassigns its own entry in Node's module cache while + initializing, so loading it a second time in one process returned the wrong + object. A review analyzes twice — once for the baseline tree, once for the + final tree — which meant the final analysis quietly failed to load the grammar + and reported Python as unanalyzable even with a healthy pack installed. The + runtime is now loaded once per process. Digests are still re-checked on every + load; only the runtime construction is reused. +- **Fixed: the Windows data directory was resolved with POSIX path rules.** + `LOCALAPPDATA` was checked with a path test that treats `C:\Users\…` as + relative anywhere other than Windows, which made the branch correct on Windows + and unverifiable everywhere else. It now names the Windows path flavour + explicitly, and is covered by a test that runs on every platform. + ## 0.2.39 — 2026-08-07 - **Two analyzers join the registry, which now holds 15.** Both come from a diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 51aa11e..f5f7b1a 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,90 @@ checksums are published at !name.endsWith('.sha256')).sort() + const expected = pack.files.map((file) => file.name).sort() + if (present.join('\n') !== expected.join('\n')) { + throw new Error(`${directoryName} holds unexpected files: ${present.join(', ')}`) + } + + manifestPacks.push({ + name: pack.name, + version: GRAMMAR_PACK_VERSION, + language: pack.language, + runtime: GRAMMAR_PACK_PROVENANCE.runtime, + grammar: GRAMMAR_PACK_PROVENANCE.grammar, + files, + }) +} + +export const GRAMMAR_MANIFEST_NAME = 'codetruss-grammars-latest.json' + +const manifest = `${JSON.stringify({ packs: manifestPacks }, null, 2)}\n` +await writeFile(join(grammarDir, GRAMMAR_MANIFEST_NAME), manifest, 'utf8') + +/** + * The compiled-in pin. + * + * Generated rather than hand-maintained because a hand-copied digest is a digest + * that eventually disagrees with the bytes, and this one is the only thing + * standing between a user and executing whatever a compromised origin served. + */ +const pin = `/** + * Pinned grammar-pack digests. GENERATED by scripts/build-grammar-packs.mjs. + * + * Do not edit by hand. These digests are what \`codetruss grammars install\` + * checks a download against, and what every subsequent load re-checks on disk. + * A pack whose bytes do not hash to exactly these values is never loaded, and + * the run discloses Python as skipped instead. + */ + +export interface PinnedGrammarFile { + name: string + /** Path under the downloads origin, e.g. \`/downloads/grammars/python-1.0.0/…\`. */ + url: string + bytes: number + sha256: string +} + +export interface PinnedGrammarPack { + name: string + version: string + /** The \`SastLanguage\` this pack enables. */ + language: string + runtime: { package: string; version: string } + grammar: { package: string; version: string } + files: PinnedGrammarFile[] +} + +export const PINNED_GRAMMAR_PACKS: readonly PinnedGrammarPack[] = ${JSON.stringify(manifestPacks, null, 2) + .split('\n') + .join('\n')} + +/** Pack names this CLI build knows how to install. */ +export const GRAMMAR_PACK_NAMES: readonly string[] = PINNED_GRAMMAR_PACKS.map((pack) => pack.name) + +export function pinnedGrammarPack(name: string): PinnedGrammarPack | undefined { + return PINNED_GRAMMAR_PACKS.find((pack) => pack.name === name) +} +` +await writeFile(join(packageDir, 'src', 'grammar-pack-manifest.ts'), pin, 'utf8') + +for (const pack of manifestPacks) { + const total = pack.files.reduce((sum, file) => sum + file.bytes, 0) + process.stdout.write(`grammar pack ${pack.name}-${pack.version}: ${pack.files.length} files, ${total} bytes\n`) +} diff --git a/packages/cli/scripts/grammar-pack-sources.mjs b/packages/cli/scripts/grammar-pack-sources.mjs new file mode 100644 index 0000000..1ebf4be --- /dev/null +++ b/packages/cli/scripts/grammar-pack-sources.mjs @@ -0,0 +1,58 @@ +/** + * What a grammar pack contains, and where its bytes come from. + * + * A pack is the tree-sitter machinery the CLI deliberately does NOT ship: the + * `web-tree-sitter` runtime plus one compiled grammar. Together they are ~722 KB + * for Python alone, against a 1 MB budget for the entire CLI tarball — which is + * why the hand-written JS parser exists and why this is a separate, opt-in + * download rather than a dependency. + * + * Every source is a file already pinned in the workspace lockfile at an exact + * version (no `^`). The pack therefore has no resolution step of its own: the + * bytes published here are the bytes the hosted audit loads out of + * `node_modules`, which is what makes the two paths comparable at all. + */ + +/** + * Pack version, bumped independently of the CLI. + * + * A pack is immutable once published, exactly like a CLI tarball: changing what + * `python-1.0.0` means would invalidate every sha256 pinned in an already + * released CLI. New runtime or grammar bytes require a new version here. + */ +export const GRAMMAR_PACK_VERSION = '1.0.0' + +/** Runtime and grammar provenance, recorded so a pack can be traced to a lockfile entry. */ +export const GRAMMAR_PACK_PROVENANCE = { + runtime: { package: 'web-tree-sitter', version: '0.22.6' }, + grammar: { package: 'tree-sitter-wasms', version: '0.1.11' }, +} + +/** + * The packs this repository publishes. + * + * `language` is the {@link SastLanguage} the pack enables. `files` are copied + * byte-for-byte; nothing is recompiled, minified or repackaged, so a reviewer + * can diff a published artifact against `node_modules` directly. + */ +export const GRAMMAR_PACKS = [ + { + name: 'python', + language: 'python', + files: [ + { name: 'tree-sitter.js', source: ['web-tree-sitter', 'tree-sitter.js'] }, + { name: 'tree-sitter.wasm', source: ['web-tree-sitter', 'tree-sitter.wasm'] }, + { name: 'tree-sitter-python.wasm', source: ['tree-sitter-wasms', 'out', 'tree-sitter-python.wasm'] }, + ], + }, +] + +/** Directory name a pack is published under, e.g. `python-1.0.0`. */ +export function packDirectoryName(pack) { + return `${pack.name}-${GRAMMAR_PACK_VERSION}` +} + +/** Public URL path for one file in a pack. */ +export function packFileUrl(pack, fileName) { + return `/downloads/grammars/${packDirectoryName(pack)}/${fileName}` +} diff --git a/packages/cli/scripts/verify-grammar-packs.mjs b/packages/cli/scripts/verify-grammar-packs.mjs new file mode 100644 index 0000000..0c67032 --- /dev/null +++ b/packages/cli/scripts/verify-grammar-packs.mjs @@ -0,0 +1,116 @@ +/** + * Gate: the site may not advertise a grammar pack it is not serving, and the + * CLI may not ship a digest that no published artifact hashes to. + * + * Runs in the root `build` chain beside `cli:artifact:verify`. A checksum alone + * would only prove the published file is internally consistent; this also + * re-reads the upstream bytes out of `node_modules` and compares them, so a + * pack cannot silently diverge from the runtime and grammar the hosted audit + * loads. That equality is the entire basis for calling the two paths the same + * analysis. + */ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + GRAMMAR_PACKS, + GRAMMAR_PACK_PROVENANCE, + GRAMMAR_PACK_VERSION, + packDirectoryName, + packFileUrl, +} from './grammar-pack-sources.mjs' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const packageDir = resolve(scriptDir, '..') +const repoRoot = resolve(packageDir, '../..') + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +export async function verifyGrammarPacks({ + grammarDir = join(repoRoot, 'public', 'downloads', 'grammars'), + moduleDir = join(repoRoot, 'node_modules'), + pinPath = join(packageDir, 'src', 'grammar-pack-manifest.ts'), +} = {}) { + const manifestPacks = [] + + for (const pack of GRAMMAR_PACKS) { + const directoryName = packDirectoryName(pack) + const packDir = join(grammarDir, directoryName) + const files = [] + + for (const file of pack.files) { + let published + try { + published = await readFile(join(packDir, file.name)) + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error( + `grammar pack ${directoryName} is missing ${file.name}; run pnpm grammars:release`, + ) + } + throw error + } + + // The published byte must be the byte the hosted audit loads. Comparing + // digests of the published file against itself would prove nothing. + const upstream = await readFile(join(moduleDir, ...file.source)) + if (!published.equals(upstream)) { + throw new Error( + `grammar pack ${directoryName}/${file.name} does not match ${file.source.join('/')} in node_modules; ` + + 'bump GRAMMAR_PACK_VERSION and run pnpm grammars:release', + ) + } + + const digest = sha256(published) + const sidecar = await readFile(`${join(packDir, file.name)}.sha256`, 'utf8') + if (sidecar !== `${digest} ${file.name}\n`) { + throw new Error(`grammar pack ${directoryName}/${file.name}.sha256 does not match its artifact`) + } + files.push({ name: file.name, url: packFileUrl(pack, file.name), bytes: published.length, sha256: digest }) + } + + manifestPacks.push({ + name: pack.name, + version: GRAMMAR_PACK_VERSION, + language: pack.language, + runtime: GRAMMAR_PACK_PROVENANCE.runtime, + grammar: GRAMMAR_PACK_PROVENANCE.grammar, + files, + }) + } + + const manifestPath = join(grammarDir, 'codetruss-grammars-latest.json') + const manifest = await readFile(manifestPath, 'utf8') + const expectedManifest = `${JSON.stringify({ packs: manifestPacks }, null, 2)}\n` + if (manifest !== expectedManifest) { + throw new Error('codetruss-grammars-latest.json does not match the published packs; run pnpm grammars:release') + } + + // The CLI's compiled-in pin is the security boundary. If it disagrees with the + // published artifact, either every install fails closed or — worse, if the pin + // were stale in the other direction — the CLI would accept bytes nobody + // reviewed. Neither may reach a build. + const pin = await readFile(pinPath, 'utf8') + for (const pack of manifestPacks) { + for (const file of pack.files) { + if (!pin.includes(`"sha256": "${file.sha256}"`)) { + throw new Error( + `src/grammar-pack-manifest.ts does not pin ${pack.name}-${pack.version}/${file.name}; ` + + 'run pnpm grammars:release', + ) + } + } + } + + return manifestPacks +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + const packs = await verifyGrammarPacks() + for (const pack of packs) { + process.stdout.write(`grammar pack ${pack.name}-${pack.version}: verified ${pack.files.length} artifacts\n`) + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6ae1e3a..beb9d5b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -52,6 +52,7 @@ import { revokeVerifyCommands, trustVerifyCommands, verifyCommandTrustStatus } f import { CLI_VERSION } from './version.js' import { assertLocalEvidencePathsIgnored, ensureLocalEvidenceProtected } from './local-evidence.js' import { guidedSetup } from './setup.js' +import { runGrammarsCommand } from './grammar-command.js' interface Parsed { command: string; positionals: string[]; values: Map; booleans: Set; agent: string[] } @@ -77,6 +78,7 @@ const COMMAND_OPTION_SCHEMAS: Readonly> = { 'verify-policy': { maxPositionals: 1, agent: 'forbidden' }, sync: { booleans: ['dry-run'], maxPositionals: 1, agent: 'forbidden' }, hooks: { maxPositionals: 2, agent: 'forbidden' }, + grammars: { maxPositionals: 2, agent: 'forbidden' }, } interface EvidenceTarget { @@ -314,6 +316,7 @@ Usage: codetruss auth login|status|logout codetruss verify-policy [status|trust|trust-key|revoke] codetruss hooks install|status|doctor|uninstall [pre-commit|claude|codex|all] + codetruss grammars list|status|install|uninstall [python] Exit codes: PASS=0, REVIEW_REQUIRED=1, FAILED=2, usage/environment=3.` } @@ -713,6 +716,12 @@ async function main(argv = process.argv.slice(2)): Promise { } throw new Error('auth requires login, status, or logout') } + // Grammar packs live in the user's data directory, not a repository, so this + // must resolve before the repo-root lookup below — installing a pack from + // outside a git checkout is a legitimate thing to do. + if (parsed.command === 'grammars') { + return runGrammarsCommand(parsed.positionals[0] ?? 'status', parsed.positionals[1]) + } const root = findRepoRoot() if (parsed.command === 'setup') { if (parsed.positionals.length || parsed.agent.length) throw new Error('setup does not accept positional arguments or a command after --') diff --git a/packages/cli/src/grammar-command.ts b/packages/cli/src/grammar-command.ts new file mode 100644 index 0000000..abe76b9 --- /dev/null +++ b/packages/cli/src/grammar-command.ts @@ -0,0 +1,103 @@ +import { PINNED_GRAMMAR_PACKS } from './grammar-pack-manifest.js' +import { + grammarPackDir, + inspectGrammarPack, + installGrammarPack, + resolveGrammarOrigin, + uninstallGrammarPack, +} from './grammar-pack.js' + +/** + * `codetruss grammars` — the only surface that downloads anything at analysis + * time's expense but never during analysis itself. + * + * Installation is a separate, explicit act for two reasons. It is a network + * operation, and a tool that promises local-first analysis must not reach out + * mid-review; and it puts executable code on the machine, which is a decision a + * person makes, not a side effect of running a scan. Nothing here is invoked + * implicitly, and no other command falls back to it. + */ + +function packSummary(bytes: number): string { + return `${(bytes / 1024).toFixed(0)} KB` +} + +export async function runGrammarsCommand( + action: string, + name: string | undefined, + write: (text: string) => void = (text) => process.stdout.write(text), + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (action === 'list') { + write('Available grammar packs:\n') + for (const pack of PINNED_GRAMMAR_PACKS) { + const bytes = pack.files.reduce((sum, file) => sum + file.bytes, 0) + write( + ` ${pack.name}-${pack.version} ${packSummary(bytes)} ` + + `${pack.runtime.package}@${pack.runtime.version} + ${pack.grammar.package}@${pack.grammar.version}\n`, + ) + } + write(`\nInstall with: codetruss grammars install ${PINNED_GRAMMAR_PACKS[0]?.name ?? 'python'}\n`) + return 0 + } + + if (action === 'status') { + let allHealthy = true + for (const pack of PINNED_GRAMMAR_PACKS) { + const state = await inspectGrammarPack(pack.name, env) + if (state.status === 'verified') { + write(`${pack.name}-${pack.version}: installed and verified\n ${state.dir}\n`) + } else if (state.status === 'absent') { + allHealthy = false + write( + `${pack.name}-${pack.version}: not installed\n` + + ` ${pack.language} is skipped locally and disclosed as such on every receipt.\n` + + ` Install with: codetruss grammars install ${pack.name}\n`, + ) + } else { + allHealthy = false + // A failed pack is louder than a missing one on purpose: absent is a + // choice, failed means the bytes on disk are not what this CLI published. + write( + `${pack.name}-${pack.version}: FAILED VERIFICATION — not loaded\n` + + ` ${state.reason}\n` + + ` ${state.dir}\n` + + ` Reinstall with: codetruss grammars install ${pack.name}\n`, + ) + } + } + return allHealthy ? 0 : 1 + } + + if (action === 'install') { + if (!name) throw new Error(`grammars install requires a pack name (${PINNED_GRAMMAR_PACKS.map((pack) => pack.name).join(', ')})`) + const origin = resolveGrammarOrigin(env.CODETRUSS_DEV_GRAMMAR_ORIGIN) + const result = await installGrammarPack(name, env) + if (result.alreadyInstalled) { + write(`${result.pack.name}-${result.pack.version} is already installed and verified.\n ${result.dir}\n`) + return 0 + } + write( + `Installed ${result.pack.name}-${result.pack.version} (${packSummary(result.bytes)}) from ${origin}.\n` + + ` ${result.dir}\n` + + ` ${result.pack.runtime.package}@${result.pack.runtime.version}, ` + + `${result.pack.grammar.package}@${result.pack.grammar.version}\n` + + ` Every artifact matched the SHA-256 pinned in this CLI, and is re-checked on each load.\n` + + ` ${result.pack.language} now runs the full security rule pack locally.\n`, + ) + return 0 + } + + if (action === 'uninstall') { + if (!name) throw new Error('grammars uninstall requires a pack name') + const result = await uninstallGrammarPack(name, env) + write( + result.removed + ? `Removed ${result.pack.name}-${result.pack.version}. ${result.pack.language} is skipped locally again.\n` + : `${result.pack.name}-${result.pack.version} was not installed (${grammarPackDir(result.pack, env)}).\n`, + ) + return 0 + } + + throw new Error('grammars requires list, status, install, or uninstall') +} diff --git a/packages/cli/src/grammar-pack-manifest.ts b/packages/cli/src/grammar-pack-manifest.ts new file mode 100644 index 0000000..1ab907b --- /dev/null +++ b/packages/cli/src/grammar-pack-manifest.ts @@ -0,0 +1,69 @@ +/** + * Pinned grammar-pack digests. GENERATED by scripts/build-grammar-packs.mjs. + * + * Do not edit by hand. These digests are what `codetruss grammars install` + * checks a download against, and what every subsequent load re-checks on disk. + * A pack whose bytes do not hash to exactly these values is never loaded, and + * the run discloses Python as skipped instead. + */ + +export interface PinnedGrammarFile { + name: string + /** Path under the downloads origin, e.g. `/downloads/grammars/python-1.0.0/…`. */ + url: string + bytes: number + sha256: string +} + +export interface PinnedGrammarPack { + name: string + version: string + /** The `SastLanguage` this pack enables. */ + language: string + runtime: { package: string; version: string } + grammar: { package: string; version: string } + files: PinnedGrammarFile[] +} + +export const PINNED_GRAMMAR_PACKS: readonly PinnedGrammarPack[] = [ + { + "name": "python", + "version": "1.0.0", + "language": "python", + "runtime": { + "package": "web-tree-sitter", + "version": "0.22.6" + }, + "grammar": { + "package": "tree-sitter-wasms", + "version": "0.1.11" + }, + "files": [ + { + "name": "tree-sitter.js", + "url": "/downloads/grammars/python-1.0.0/tree-sitter.js", + "bytes": 74197, + "sha256": "ddcacb69cd26c07322c51b798a63805fd99c272177c9633a978f3886358ca070" + }, + { + "name": "tree-sitter.wasm", + "url": "/downloads/grammars/python-1.0.0/tree-sitter.wasm", + "bytes": 188635, + "sha256": "29208e71028ab0c11dfcc941255075aad75545394467aa22d817a6356714090f" + }, + { + "name": "tree-sitter-python.wasm", + "url": "/downloads/grammars/python-1.0.0/tree-sitter-python.wasm", + "bytes": 476105, + "sha256": "9056d0fb0c337810d019fae350e8167786119da98f0f282aceae7ab89ee8253b" + } + ] + } +] + +/** Pack names this CLI build knows how to install. */ +export const GRAMMAR_PACK_NAMES: readonly string[] = PINNED_GRAMMAR_PACKS.map((pack) => pack.name) + +export function pinnedGrammarPack(name: string): PinnedGrammarPack | undefined { + return PINNED_GRAMMAR_PACKS.find((pack) => pack.name === name) +} diff --git a/packages/cli/src/grammar-pack.ts b/packages/cli/src/grammar-pack.ts new file mode 100644 index 0000000..38a932c --- /dev/null +++ b/packages/cli/src/grammar-pack.ts @@ -0,0 +1,416 @@ +import { createHash } from 'node:crypto' +import { constants as fsConstants, type Stats } from 'node:fs' +import { chmod, lstat, mkdir, mkdtemp, open, readdir, rename, rm } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join, win32 } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createWriteStream } from 'node:fs' +import { PINNED_GRAMMAR_PACKS, pinnedGrammarPack, type PinnedGrammarPack } from './grammar-pack-manifest.js' + +/** + * Opt-in tree-sitter grammar packs. + * + * The CLI ships a hand-written JS-family parser because the WASM grammars are + * several times its entire release budget. That trade is the right one for the + * common case, but it is the reason Python has been disclosed as a hosted-only + * gap. A pack closes that gap for people who want it, WITHOUT putting the bytes + * in the tarball: they are downloaded once, on an explicit command, into the + * user's data directory. + * + * The security position is deliberate and narrow. This module downloads code + * that will later be executed in the user's process, so it is not enough to + * fetch over TLS and hope: + * + * - every artifact is pinned to an exact sha256 compiled into this binary + * ({@link PINNED_GRAMMAR_PACKS}), so the origin cannot choose what arrives; + * - the digest is checked on download AND re-checked on every load, because a + * file verified in March is not a file verified today; + * - the bytes that are hashed are the bytes that are RETURNED, and the loader + * executes those buffers rather than re-opening the path. Hashing by path and + * then executing by path is a time-of-check/time-of-use gap wide enough to + * drive a `rename(2)` loop through: an attacker who can write in the pack + * directory swaps the artifact between the two reads and gets arbitrary code + * execution while every digest check still passes. There is exactly one read + * of each artifact per load, from one file descriptor; + * - artifacts are opened `O_NOFOLLOW` and every directory this CLI creates is + * `lstat`ed, so a symlinked artifact or pack root cannot redirect either the + * verification or the install somewhere the digest never covered; + * - the only origin is the CodeTruss downloads host — never a third-party CDN, + * never a redirect off-origin; + * - any failure — absent, short, over-long, wrong digest, unreadable — resolves + * to "pack unavailable", and the run discloses Python as skipped. There is no + * degraded mode in which unverified bytes are executed. + */ + +const PRODUCTION_DOWNLOAD_ORIGIN = 'https://codetruss.com' +export const DEV_GRAMMAR_ORIGIN_ENV = 'CODETRUSS_DEV_GRAMMAR_ORIGIN' + +/** + * Where a pack is fetched from. + * + * The override exists so tests and local development can serve artifacts from a + * loopback fixture. It is restricted to loopback for the same reason the sync + * origin is: an environment variable that could point the downloader at an + * arbitrary host would hand an attacker with environment access the ability to + * choose which bytes get executed — and the digest pin is the only thing that + * would then be standing in the way. + */ +export function resolveGrammarOrigin(explicitDevOrigin = process.env[DEV_GRAMMAR_ORIGIN_ENV]): string { + if (explicitDevOrigin === undefined || explicitDevOrigin.trim() === '') return PRODUCTION_DOWNLOAD_ORIGIN + let parsed: URL + try { + parsed = new URL(explicitDevOrigin) + } catch { + throw new Error(`${DEV_GRAMMAR_ORIGIN_ENV} must be an http(s) loopback origin`) + } + const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + const hasOnlyOrigin = parsed.pathname === '/' && !parsed.search && !parsed.hash && !parsed.username && !parsed.password + if (!['http:', 'https:'].includes(parsed.protocol) || !loopbackHosts.has(parsed.hostname) || !hasOnlyOrigin) { + throw new Error(`${DEV_GRAMMAR_ORIGIN_ENV} must be an http(s) loopback origin without credentials, path, query, or fragment`) + } + return parsed.origin +} + +/** + * The user-level data directory, XDG on POSIX and LOCALAPPDATA on Windows. + * + * Packs are DATA, not config: they are reproducible downloads a user can delete + * without losing anything they authored, which is exactly the XDG data/config + * split. Windows has no XDG, and its nearest equivalent for machine-local, + * non-roaming data is LOCALAPPDATA — roaming would copy 700 KB of WASM to every + * machine the profile touches. + */ +export function grammarDataDir(env: NodeJS.ProcessEnv = process.env): string { + if (process.platform === 'win32') { + // The win32 flavor explicitly, rather than the platform-default `join`. + // `C:\Users\…` is not an absolute path to the POSIX variant, so a bare + // `isAbsolute` here means "correct on Windows, and unverifiable anywhere + // else" — which is how a Windows-only path bug survives a green CI run. + const localAppData = env.LOCALAPPDATA?.trim() + if (localAppData) { + if (!win32.isAbsolute(localAppData)) throw new Error('LOCALAPPDATA must be an absolute path') + return win32.join(localAppData, 'codetruss', 'grammars') + } + return win32.join(homedir(), 'AppData', 'Local', 'codetruss', 'grammars') + } + const configured = env.XDG_DATA_HOME?.trim() + if (configured && !isAbsolute(configured)) { + throw new Error('XDG_DATA_HOME must be an absolute user data path') + } + return join(configured || join(homedir(), '.local', 'share'), 'codetruss', 'grammars') +} + +/** On-disk directory for one pack version, mirroring its published URL path. */ +export function grammarPackDir(pack: PinnedGrammarPack, env: NodeJS.ProcessEnv = process.env): string { + return join(grammarDataDir(env), `${pack.name}-${pack.version}`) +} + +/** + * Read one artifact and hash what was read — never re-open the path. + * + * The whole artifact is held in memory on purpose. Streaming the hash and then + * letting the loader re-read the file is the bug this function exists to make + * impossible: the returned Buffer is the only thing that is ever executed, so + * "verified" and "executed" are the same bytes by construction rather than by + * the hope that nothing rewrote the file in between. 738 KB is a cheap price. + * + * `O_NOFOLLOW` refuses a symlinked artifact atomically, and `O_NONBLOCK` keeps a + * FIFO left in the artifact's place from parking the process on `open`. The size + * and type come from `fstat` on the open descriptor rather than from a second + * `stat` of the name, so they describe the file that was actually read. + */ +async function readVerifiedArtifact( + path: string, + expected: { name: string; bytes: number; sha256: string }, +): Promise<{ bytes: Buffer } | { reason: string }> { + const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0) + let handle + try { + handle = await open(path, flags) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return { reason: `${expected.name} is missing from the installed pack` } + if (code === 'ELOOP') return { reason: `${expected.name} is a symbolic link, not a regular file` } + return { reason: `${expected.name} is unreadable: ${(error as Error).message}` } + } + try { + const info = await handle.stat() + if (!info.isFile()) return { reason: `${expected.name} is not a regular file` } + // Size first: it bounds the read, so a padded artifact is rejected before + // this process commits to holding it. + if (info.size !== expected.bytes) { + return { reason: `${expected.name} is ${info.size} bytes, expected ${expected.bytes}` } + } + let bytes: Buffer + try { + bytes = await handle.readFile() + } catch (error) { + return { reason: `${expected.name} could not be read: ${(error as Error).message}` } + } + // A file that grew between fstat and read would hash differently anyway, but + // saying so by length is a clearer reason than a digest mismatch. + if (bytes.length !== expected.bytes) { + return { reason: `${expected.name} is ${bytes.length} bytes, expected ${expected.bytes}` } + } + const digest = createHash('sha256').update(bytes).digest('hex') + if (digest !== expected.sha256) { + return { reason: `${expected.name} has digest ${digest}, expected ${expected.sha256}` } + } + return { bytes } + } finally { + await handle.close().catch(() => {}) + } +} + +/** + * Whether a directory this CLI created is still one it can trust. + * + * `mkdir`'s `mode` only applies to directories it actually creates, so a root + * pre-created loose stays loose — and POSIX write permission on the PARENT of a + * pack is enough to `rename` the pack away and substitute one, no permission on + * the files required. A symlinked root is worse still: it redirects the install + * and every later read somewhere the pinned digests were never computed over. + * + * The ownership and mode checks are POSIX-only; Windows ACLs are not expressible + * in `st_mode` and pretending otherwise would reject every Windows install. + */ +function directoryTrustProblem(info: Stats, path: string): string | undefined { + if (info.isSymbolicLink()) return `${path} is a symbolic link, not a real directory` + if (!info.isDirectory()) return `${path} is not a directory` + if (typeof process.getuid !== 'function') return undefined + if (info.uid !== process.getuid()) return `${path} is owned by uid ${info.uid}, not by this user` + // WRITABLE, not merely readable. A world-readable root leaks nothing that is + // not already published, and failing a load over it would be a dead end for no + // security gain; a writable one is what lets a pack be renamed away and + // replaced without any permission on the files themselves. + if ((info.mode & 0o022) !== 0) { + return `${path} is writable by group or other (mode ${(info.mode & 0o777).toString(8)})` + } + return undefined +} + +/** + * The directories this CLI creates and therefore gets to have opinions about. + * + * Deliberately not the whole path: the user's data home is theirs, and on macOS + * `os.tmpdir()` is already a symlink, so demanding a link-free ancestry would + * reject ordinary systems while proving nothing. What matters is that the two + * components CodeTruss makes — and the pack directory inside them — are real, + * ours, and not writable by anyone else. + */ +function codetrussOwnedRoots(env: NodeJS.ProcessEnv): string[] { + const root = grammarDataDir(env) + return [dirname(root), root] +} + +export type GrammarPackState = + /** No pack directory on disk. The user has never installed it, or removed it. */ + | { status: 'absent'; pack: PinnedGrammarPack } + /** + * Present and every artifact matches its pinned digest. + * + * `contents` holds the exact buffers that were hashed, keyed by artifact name. + * The loader executes these and never re-opens the paths — that identity is + * what makes "nothing unverified was executed" a fact rather than a wish. + */ + | { status: 'verified'; pack: PinnedGrammarPack; dir: string; contents: ReadonlyMap } + /** Present but not trustworthy. Never loaded; the reason is disclosed verbatim. */ + | { status: 'failed'; pack: PinnedGrammarPack; dir: string; reason: string } + +/** + * Check an installed pack against the digests compiled into this binary. + * + * Called before every load, not only at install time. A pack sits in a + * user-writable directory for months; re-verifying is the difference between + * "these bytes were trustworthy when downloaded" and "these bytes are + * trustworthy now". + */ +export async function inspectGrammarPack( + name: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const pack = pinnedGrammarPack(name) + if (!pack) throw new Error(`unknown grammar pack ${name}; expected ${PINNED_GRAMMAR_PACKS.map((entry) => entry.name).join(', ')}`) + const dir = grammarPackDir(pack, env) + + // Absence is decided by the pack directory alone, so a user who never + // installed anything is told "not installed" rather than handed a complaint + // about a directory they have no reason to care about. + try { + await lstat(dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'absent', pack } + return { status: 'failed', pack, dir, reason: `pack directory is unreadable: ${(error as Error).message}` } + } + + for (const path of [...codetrussOwnedRoots(env), dir]) { + let info: Stats + try { + info = await lstat(path) + } catch (error) { + return { status: 'failed', pack, dir, reason: `${path} is unreadable: ${(error as Error).message}` } + } + const problem = directoryTrustProblem(info, path) + if (problem) return { status: 'failed', pack, dir, reason: problem } + } + + const contents = new Map() + for (const file of pack.files) { + const read = await readVerifiedArtifact(join(dir, file.name), file) + if ('reason' in read) return { status: 'failed', pack, dir, reason: read.reason } + contents.set(file.name, read.bytes) + } + + // An extra file in the pack directory is not loaded, but it does mean the + // directory is not what this CLI published, and saying so is cheaper than + // explaining later why a tampered install looked healthy. + let present: string[] + try { + present = (await readdir(dir)).sort() + } catch (error) { + return { status: 'failed', pack, dir, reason: `pack directory is unreadable: ${(error as Error).message}` } + } + const expected = pack.files.map((file) => file.name).sort() + if (present.join('\n') !== expected.join('\n')) { + return { status: 'failed', pack, dir, reason: `pack directory holds unexpected files: ${present.join(', ')}` } + } + + return { status: 'verified', pack, dir, contents } +} + +/** + * Download one artifact into `target`, verifying as the bytes arrive. + * + * The pinned length is enforced DURING the stream rather than after it, so a + * hostile or broken origin cannot fill the user's disk before the digest gets a + * chance to disagree. + */ +async function downloadVerified(url: string, target: string, expected: { bytes: number; sha256: string }): Promise { + const response = await fetch(url, { redirect: 'error', headers: { accept: 'application/octet-stream' } }) + if (!response.ok) throw new Error(`${url} responded ${response.status}`) + if (!response.body) throw new Error(`${url} returned no body`) + + const hash = createHash('sha256') + let received = 0 + const measured = new Readable({ read() {} }) + const source = Readable.fromWeb(response.body as Parameters[0]) + source.on('data', (chunk: Buffer) => { + received += chunk.length + if (received > expected.bytes) { + measured.destroy(new Error(`${url} sent more than the pinned ${expected.bytes} bytes`)) + source.destroy() + return + } + hash.update(chunk) + measured.push(chunk) + }) + source.on('end', () => measured.push(null)) + source.on('error', (error: Error) => measured.destroy(error)) + + await pipeline(measured, createWriteStream(target, { mode: 0o600 })) + + if (received !== expected.bytes) throw new Error(`${url} sent ${received} bytes, expected ${expected.bytes}`) + const digest = hash.digest('hex') + if (digest !== expected.sha256) throw new Error(`${url} has digest ${digest}, expected ${expected.sha256}`) +} + +/** + * Make one of this CLI's own directories safe to install through, or refuse. + * + * Install is the one moment the CLI may FIX what it finds rather than only + * report it, so a root that is not already the 0700 `mkdir` asked for is + * tightened to it — a dead end is a bad answer to a condition one `chmod` + * resolves, and a root left 0755 by an earlier version is the common case. A + * symlink or a directory owned by someone else is refused outright: both mean + * the install would land somewhere this process does not control, and no mode + * bit makes that acceptable. + */ +async function secureExistingRoot(path: string): Promise { + const info = await lstat(path) + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error(`${path} is not a real directory; refusing to install through it`) + } + if (typeof process.getuid !== 'function') return + if (info.uid !== process.getuid()) { + throw new Error(`${path} is owned by uid ${info.uid}, not by this user; refusing to install into it`) + } + if ((info.mode & 0o077) !== 0) await chmod(path, 0o700) +} + +export interface GrammarInstallResult { + pack: PinnedGrammarPack + dir: string + /** True when a verified copy was already present and nothing was downloaded. */ + alreadyInstalled: boolean + bytes: number +} + +/** + * Install a pack: download every artifact to a scratch directory, verify each, + * and only then move the whole thing into place. + * + * Staging matters. A per-file install that fails halfway leaves a directory that + * is neither absent nor verified, and the next run has to reason about a partial + * state. Assembling in scratch means the pack directory only ever appears + * complete and verified. + */ +export async function installGrammarPack( + name: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const existing = await inspectGrammarPack(name, env) + const pack = existing.pack + const bytes = pack.files.reduce((sum, file) => sum + file.bytes, 0) + if (existing.status === 'verified') { + return { pack, dir: existing.dir, alreadyInstalled: true, bytes } + } + + const origin = resolveGrammarOrigin(env[DEV_GRAMMAR_ORIGIN_ENV]) + const root = grammarDataDir(env) + await mkdir(root, { recursive: true, mode: 0o700 }) + // `mkdir` said nothing about a directory that already existed, and this is the + // one moment the CLI is allowed to fix that rather than only report it. + for (const path of codetrussOwnedRoots(env)) await secureExistingRoot(path) + const scratch = await mkdtemp(join(root, `.${pack.name}-${pack.version}.`)) + try { + for (const file of pack.files) { + await downloadVerified(`${origin}${file.url}`, join(scratch, file.name), file) + } + const target = grammarPackDir(pack, env) + // A failed or partial previous install is replaced wholesale rather than + // patched, so the result is exactly what this CLI expects or nothing. + await rm(target, { recursive: true, force: true }) + await rename(scratch, target) + const verified = await inspectGrammarPack(name, env) + if (verified.status !== 'verified') { + const reason = verified.status === 'failed' ? verified.reason : 'the installed pack disappeared' + throw new Error(`installed pack failed verification: ${reason}`) + } + return { pack, dir: verified.dir, alreadyInstalled: false, bytes } + } catch (error) { + await rm(scratch, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +/** Remove an installed pack. Absent is success — the requested end state holds. */ +export async function uninstallGrammarPack( + name: string, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ pack: PinnedGrammarPack; removed: boolean }> { + const pack = pinnedGrammarPack(name) + if (!pack) throw new Error(`unknown grammar pack ${name}`) + const dir = grammarPackDir(pack, env) + // `lstat`, so a symlink standing in for the pack directory counts as present + // and gets removed, rather than reporting "not installed" and leaving the + // redirect in place for the next install to write through. + try { + await lstat(dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { pack, removed: false } + throw error + } + await rm(dir, { recursive: true, force: true }) + return { pack, removed: true } +} diff --git a/packages/cli/src/grammar-parser.ts b/packages/cli/src/grammar-parser.ts new file mode 100644 index 0000000..885510b --- /dev/null +++ b/packages/cli/src/grammar-parser.ts @@ -0,0 +1,212 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { compileFunction } from 'node:vm' +import type { ParsedTree, SastLanguage, SastParser, SyntaxNode } from '@codetruss/analyzer-engine/security/lang' +import { MAX_SOURCE_BYTES } from '@codetruss/analyzer-engine/security/lang' +import { inspectGrammarPack, type GrammarPackState } from './grammar-pack.js' + +/** + * Turn a verified grammar pack into the {@link SastParser} the engine expects. + * + * This is the same `web-tree-sitter` runtime, the same compiled grammar and the + * same load sequence the hosted audit uses — deliberately, because that identity + * is the whole claim: with a pack installed, Python is not analyzed by a local + * approximation of the hosted pass, it is analyzed by the hosted pass's own + * machinery. The only difference is where the bytes were resolved from. + * + * Nothing here reads the pack from disk. {@link inspectGrammarPack} performs the + * single read of each artifact and hands back the buffers it hashed; this module + * executes those buffers and nothing else. That is the point: a loader that + * hashes a path and then re-opens it to execute it has verified one file and run + * another, and the gap between the two reads is long enough — three digests and + * a `readdir` — for a `rename(2)` loop to land a hostile module in it. There is + * no path here for such a swap to reach, because there is no second read. + */ + +/** The subset of the web-tree-sitter 0.22.x surface this loader drives. */ +interface TreeSitterLanguage { readonly __brand?: never } +interface TreeSitterTree { + rootNode: SyntaxNode & { hasError: boolean } + delete(): void +} +interface TreeSitterParser { + setLanguage(language: TreeSitterLanguage): void + parse(content: string): TreeSitterTree | null +} +interface TreeSitterModule { + new(): TreeSitterParser + init(moduleOptions?: { wasmBinary?: Uint8Array; locateFile?: () => string }): Promise + Language: { load(input: string | Uint8Array): Promise } +} + +export type GrammarParserLoad = + | { status: 'verified'; parser: SastParser; languages: ReadonlySet } + | { status: 'absent' } + /** + * Why the pack is unusable, kept apart from WHAT went wrong. + * + * `digest` means the pack on disk is not what this CLI published. `runtime` + * means it is exactly what this CLI published and emscripten still could not + * start — an OOM, a Node build without the WASM features the runtime needs, a + * `MAP_FAILED` under memory pressure. Collapsing the two lets a receipt accuse + * a user's install of tampering because their machine ran out of memory, which + * is both false and unfalsifiable from the reader's side. + */ + | { status: 'failed'; kind: 'digest' | 'runtime'; reason: string } + +/** + * One tree-sitter runtime per pack directory, per process. + * + * This cache is CORRECTNESS, not an optimization. `web-tree-sitter`'s emscripten + * glue reassigns `module.exports = Module` from its Node branch once the runtime + * initializes, which overwrites its own entry in Node's require cache. A second + * `require()` of the same file therefore hands back the emscripten Module object + * instead of the Parser class, and `Parser.init` is suddenly not a function. + * + * A single review loads the pack TWICE — once for the baseline tree and once for + * the final tree — so without this the second analysis of every run would fail + * to load the grammar and silently report Python as unanalyzable. Requiring the + * runtime exactly once per process also matches the hosted loader, which reuses + * one Parser instance because a fresh one never returns its WASM working memory. + * + * Reusing the parser across loads is sound only because the digests are pinned + * CONSTANTS: any two runs that reach `verified` did so over byte-identical + * artifacts, so the cached parser cannot be older than what was just verified in + * any way that matters. Verification itself is deliberately NOT cached — + * {@link loadGrammarParser} re-inspects on every call. + */ +const runtimeCache = new Map>() + +/** + * Load a pack's parser, or explain why it is unavailable. + * + * Never throws for an unavailable pack. Absence and tampering are COVERAGE + * answers, not errors: the caller turns them into a disclosure that Python was + * skipped and why, which is the only honest outcome. Throwing here would either + * fail an otherwise clean run or, worse, invite a catch that silently continues + * as though the language had been analyzed. + */ +export async function loadGrammarParser( + name: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + let state: GrammarPackState + try { + state = await inspectGrammarPack(name, env) + } catch (error) { + return { status: 'failed', kind: 'digest', reason: (error as Error).message } + } + if (state.status === 'absent') return { status: 'absent' } + if (state.status === 'failed') return { status: 'failed', kind: 'digest', reason: state.reason } + + const language = state.pack.language as SastLanguage + const grammarFile = state.pack.files.find((file) => file.name.startsWith('tree-sitter-')) + if (!grammarFile) return { status: 'failed', kind: 'digest', reason: 'pack declares no grammar artifact' } + + let cached = runtimeCache.get(state.dir) + if (!cached) { + cached = buildParser(state, grammarFile.name, language) + runtimeCache.set(state.dir, cached) + } + const result = await cached + if ('reason' in result) return { status: 'failed', kind: 'runtime', reason: result.reason } + return { status: 'verified', parser: result.parser, languages: new Set([language]) } +} + +/** + * A name emscripten can carry around for the runtime WASM that resolves to no + * file anywhere. + * + * `locateFile` is called unconditionally at setup, so it cannot simply throw — + * but every read of its result is short-circuited by the `wasmBinary` below. A + * sentinel rather than the real on-disk path means that if a future runtime ever + * stopped honouring `wasmBinary`, the load would fail loudly instead of quietly + * reading bytes nobody verified. + */ +const IN_MEMORY_WASM_SENTINEL = '\0codetruss-verified-in-memory\0tree-sitter.wasm' + +/** + * Execute a verified buffer as a CommonJS module. + * + * `vm.compileFunction` with the CJS parameter list is what `require()` does + * internally, minus the file read — which is exactly the part that must not + * happen. The source compiled here is the Buffer that was hashed, so there is no + * window between the check and the use at all, and `filename`/`__dirname` still + * point at the pack so the module's own `require` and any stack trace read the + * way they would have. + * + * Compiling into a module object this function owns also keeps the emscripten + * `module.exports = Module` clobber out of Node's require cache entirely. + */ +function runVerifiedModule(source: Buffer, filename: string, dirname: string): unknown { + const compiled = compileFunction( + source.toString('utf8'), + ['exports', 'require', 'module', '__filename', '__dirname'], + { filename }, + ) + const shim = { exports: {} as unknown, id: filename, filename, path: dirname, loaded: false, paths: [] } + compiled(shim.exports, createRequire(pathToFileURL(filename)), shim, filename, dirname) + shim.loaded = true + return shim.exports +} + +async function buildParser( + state: Extract, + grammarFileName: string, + language: SastLanguage, +): Promise<{ parser: SastParser } | { reason: string }> { + const runtimeSource = state.contents.get('tree-sitter.js') + const runtimeWasm = state.contents.get('tree-sitter.wasm') + const grammarWasm = state.contents.get(grammarFileName) + if (!runtimeSource || !runtimeWasm || !grammarWasm) { + return { reason: 'verified pack did not carry its own bytes' } + } + + let treeSitterParser: TreeSitterParser + try { + const TreeSitter = runVerifiedModule( + runtimeSource, + join(state.dir, 'tree-sitter.js'), + state.dir, + ) as TreeSitterModule + if (typeof TreeSitter?.init !== 'function') { + return { reason: 'grammar runtime did not export a parser' } + } + // `wasmBinary` hands emscripten the verified runtime WASM directly, and + // `Language.load` takes the grammar as a `Uint8Array` — so neither WASM + // artifact is ever resolved from a path either. + await TreeSitter.init({ wasmBinary: runtimeWasm, locateFile: () => IN_MEMORY_WASM_SENTINEL }) + const grammar = await TreeSitter.Language.load(new Uint8Array(grammarWasm)) + // One parser instance, reused for every file. A fresh `new Parser()` per + // file never returns its WASM working memory, which is how the hosted path + // used to OOM large scans. + treeSitterParser = new TreeSitter() + treeSitterParser.setLanguage(grammar) + } catch (error) { + return { reason: `grammar runtime failed to load: ${(error as Error).message}` } + } + + return { + parser: { + languages: new Set([language]), + async parse(lang: SastLanguage, content: string): Promise { + if (lang !== language) return null + if (content.length > MAX_SOURCE_BYTES) return null + try { + const tree = treeSitterParser.parse(content) + if (!tree) return null + return { + rootNode: tree.rootNode, + hasError: tree.rootNode.hasError, + release: () => { + try { tree.delete() } catch {} + }, + } + } catch { + return null + } + }, + }, + } +} diff --git a/packages/cli/src/local-sast.ts b/packages/cli/src/local-sast.ts index fc2faf9..52a5460 100644 --- a/packages/cli/src/local-sast.ts +++ b/packages/cli/src/local-sast.ts @@ -1,9 +1,11 @@ import type { AnalyzerFinding, AnalyzerPass, RepoIndex } from '@codetruss/analyzer-engine' -import { scanFiles, type ScanInput } from '@codetruss/analyzer-engine/security/engine' +import { mergeSastResults, scanFiles, type ScanInput } from '@codetruss/analyzer-engine/security/engine' import { zeroDependencyJsParser } from '@codetruss/analyzer-engine/security/js-parse/index' import { mapSastFinding } from '@codetruss/analyzer-engine/security/finding-map' import { CLI_SAST_RULE_IDS } from '@codetruss/analyzer-engine/security/local-profile' -import { sastLanguageForPath } from '@codetruss/analyzer-engine/security/lang' +import { sastLanguageForPath, type SastLanguage } from '@codetruss/analyzer-engine/security/lang' +import type { SastResult } from '@codetruss/analyzer-engine/security/types' +import { loadGrammarParser } from './grammar-parser.js' /** * The CLI's local security pass. @@ -13,12 +15,20 @@ import { sastLanguageForPath } from '@codetruss/analyzer-engine/security/lang' * rule subset that has been differentially validated against the hosted parser * at zero false positives. * + * Since 0.2.40 the pass has a SECOND parser: an opt-in grammar pack the user + * installs explicitly (`codetruss grammars install python`). When present and + * verified, Python is analyzed by the hosted runtime and grammar themselves, so + * it runs the full rule pack rather than a subset — see {@link pythonRuleIds}. + * * This is NOT one of the registry analyzers. Keeping it a separate pass is what * lets the receipt state the registry count truthfully while naming this pass * and its limits alongside them. */ export const LOCAL_SAST_PASS_ID = 'local-sast' +/** The grammar pack that extends this pass beyond the JS family. */ +export const PYTHON_GRAMMAR_PACK = 'python' + /** * Declaration files (`*.d.ts`) carry no executable code, so no rule can fire in * them. Excluding them keeps the parser's coverage number honest instead of @@ -28,8 +38,8 @@ function isDeclarationFile(path: string): boolean { return /\.d\.[cm]?ts$/i.test(path) } -/** Production JS-family source the local parser can analyze. */ -export function localSastInputs(index: RepoIndex): ScanInput[] { +/** Production source in `languages`, in a deterministic order. */ +function inputsForLanguages(index: RepoIndex, languages: ReadonlySet): ScanInput[] { const inputs: ScanInput[] = [] for (const file of index.files) { if (!file.content) continue @@ -39,7 +49,7 @@ export function localSastInputs(index: RepoIndex): ScanInput[] { if (file.kind === 'vendored' || file.kind === 'generated' || file.kind === 'test') continue if (isDeclarationFile(file.path)) continue const language = sastLanguageForPath(file.path) - if (!language || !zeroDependencyJsParser.languages.has(language)) continue + if (!language || !languages.has(language)) continue inputs.push({ filePath: file.path, content: file.content }) } // Deterministic order so findings and diagnostics are identical run to run. @@ -47,63 +57,158 @@ export function localSastInputs(index: RepoIndex): ScanInput[] { return inputs } +/** Production JS-family source the local parser can analyze. */ +export function localSastInputs(index: RepoIndex): ScanInput[] { + return inputsForLanguages(index, zeroDependencyJsParser.languages) +} + +/** Production Python source, analyzable only when the grammar pack is installed. */ +export function localPythonInputs(index: RepoIndex): ScanInput[] { + return inputsForLanguages(index, new Set(['python'])) +} + +/** + * Which rules the Python scan may report: all of them. + * + * The JS-family subset ({@link CLI_SAST_RULE_IDS}) exists because the CLI parses + * JavaScript with a hand-written parser, and only rules proven to agree with + * tree-sitter on real repositories were allowed through. That reasoning does not + * transfer here. A grammar pack IS the hosted parser and the hosted grammar, so + * there is no divergence to guard against, and narrowing the pack would report + * less than the same code would receive hosted — for no precision gain. + */ +const pythonRuleIds: ReadonlySet | undefined = undefined + +/** Why Python was or was not analyzed on this run. Rendered verbatim on receipts. */ +export type PythonCoverageStatus = 'not-applicable' | 'absent' | 'verified' | 'failed' + +/** + * WHICH of the three unrelated things that all end in `failed` actually failed. + * + * `digest` — the pack on disk is not what this CLI published. + * `runtime` — the pack verified and emscripten still would not start. + * `scan` — the pack verified, loaded, and then threw partway through. + * + * Kept apart because the receipt is a signed customer-facing document and the + * three warrant different sentences. Collapsing them is how an out-of-memory + * error on the user's laptop gets published as an accusation that their install + * does not match the published digests — false, alarming, and unfalsifiable from + * the reader's side. + */ +export type PythonPackFailureKind = 'digest' | 'runtime' | 'scan' + export interface LocalSastResult { findings: AnalyzerFinding[] pass: AnalyzerPass } -export async function runLocalSast(index: RepoIndex): Promise { - const inputs = localSastInputs(index) - if (inputs.length === 0) { - return { - findings: [], - pass: { - id: LOCAL_SAST_PASS_ID, - result: { - findings: [], - complete: true, - metrics: { inputFiles: 0, filesScanned: 0, filesSkipped: 0, rules: CLI_SAST_RULE_IDS.size }, - }, - }, +const EMPTY_SCAN: SastResult = { + findings: [], + diagnostics: { + inputFiles: 0, + filesScanned: 0, + filesSkipped: 0, + degradedLanguages: [], + truncatedFiles: 0, + findingsTruncated: false, + }, +} + +export async function runLocalSast(index: RepoIndex, env: NodeJS.ProcessEnv = process.env): Promise { + const jsInputs = localSastInputs(index) + const pythonInputs = localPythonInputs(index) + + // The pack is only consulted when there is Python to analyze. A repository + // with no Python gets no disclosure about Python, because there is nothing + // there to have missed. + let pythonStatus: PythonCoverageStatus = 'not-applicable' + let pythonReason: string | undefined + let pythonFailureKind: PythonPackFailureKind | undefined + let pythonScan: SastResult = EMPTY_SCAN + let pythonError: string | undefined + + if (pythonInputs.length > 0) { + const load = await loadGrammarParser(PYTHON_GRAMMAR_PACK, env) + if (load.status === 'absent') { + pythonStatus = 'absent' + pythonReason = 'the Python grammar pack is not installed' + } else if (load.status === 'failed') { + pythonStatus = 'failed' + pythonReason = load.reason + // The loader already knows whether this was the pack or the machine. Carry + // that answer instead of re-deriving a worse one from the message. + pythonFailureKind = load.kind + } else { + try { + pythonScan = await scanFiles(pythonInputs, load.parser, { ruleIds: pythonRuleIds }) + pythonStatus = 'verified' + } catch (error) { + // A crash inside the grammar runtime is lost coverage, not a clean + // Python result. Say so rather than let an empty finding list stand in. + pythonStatus = 'failed' + pythonReason = `grammar pack scan failed: ${error instanceof Error ? error.message : String(error)}` + pythonFailureKind = 'scan' + pythonError = pythonReason + pythonScan = EMPTY_SCAN + } } } - try { - const result = await scanFiles(inputs, zeroDependencyJsParser, { ruleIds: CLI_SAST_RULE_IDS }) - const diagnostics = result.diagnostics - // A file the parser could not read is coverage lost, and the receipt has to - // say so rather than let silence read as "nothing found there". - const truncated = - diagnostics.filesSkipped > 0 || diagnostics.truncatedFiles > 0 || diagnostics.findingsTruncated - const findings = result.findings.map((finding) => ({ - ...mapSastFinding(finding), - analyzerId: LOCAL_SAST_PASS_ID, - })) - return { - findings, - pass: { - id: LOCAL_SAST_PASS_ID, - result: { - findings, - complete: !truncated && diagnostics.degradedLanguages.length === 0, - truncated, - detail: truncated - ? `${diagnostics.filesSkipped} file(s) could not be parsed locally and were not analyzed` - : undefined, - metrics: { - inputFiles: diagnostics.inputFiles, - filesScanned: diagnostics.filesScanned, - filesSkipped: diagnostics.filesSkipped, - rules: CLI_SAST_RULE_IDS.size, - }, + let jsScan: SastResult = EMPTY_SCAN + let jsError: string | undefined + if (jsInputs.length > 0) { + try { + jsScan = await scanFiles(jsInputs, zeroDependencyJsParser, { ruleIds: CLI_SAST_RULE_IDS }) + } catch (error) { + jsError = error instanceof Error ? error.message : String(error) + } + } + + const inputFiles = jsInputs.length + pythonInputs.length + const merged = mergeSastResults([jsScan, pythonScan], inputFiles) + const diagnostics = merged.diagnostics + // A file a parser could not read is coverage lost, and the receipt has to say + // so rather than let silence read as "nothing found there". + const truncated = + diagnostics.filesSkipped > 0 || diagnostics.truncatedFiles > 0 || diagnostics.findingsTruncated + const findings = merged.findings.map((finding) => ({ + ...mapSastFinding(finding), + analyzerId: LOCAL_SAST_PASS_ID, + })) + + const details = [ + jsError ? `the JavaScript pass failed: ${jsError}` : undefined, + pythonError, + truncated ? `${diagnostics.filesSkipped} file(s) could not be parsed locally and were not analyzed` : undefined, + ].filter((entry): entry is string => Boolean(entry)) + + const error = jsError ?? pythonError + + return { + findings, + pass: { + id: LOCAL_SAST_PASS_ID, + result: { + findings, + complete: !truncated && !error && diagnostics.degradedLanguages.length === 0, + truncated, + ...(details.length ? { detail: details.join('; ') } : {}), + metrics: { + inputFiles: diagnostics.inputFiles, + filesScanned: diagnostics.filesScanned, + filesSkipped: diagnostics.filesSkipped, + rules: CLI_SAST_RULE_IDS.size, + // The receipt renders its Python disclosure from these, so what a + // reader is told about coverage is derived from what actually ran on + // this machine rather than from a sentence frozen at release time. + pythonFiles: pythonInputs.length, + pythonFilesScanned: pythonScan.diagnostics.filesScanned, + pythonPackStatus: pythonStatus, + ...(pythonReason ? { pythonPackReason: pythonReason } : {}), + ...(pythonFailureKind ? { pythonPackFailureKind: pythonFailureKind } : {}), }, }, - } - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - return { - findings: [], - pass: { id: LOCAL_SAST_PASS_ID, result: { findings: [], complete: false, detail }, error: detail }, - } + ...(error ? { error } : {}), + }, } } diff --git a/packages/cli/src/receipt.ts b/packages/cli/src/receipt.ts index e18648f..4c2e955 100644 --- a/packages/cli/src/receipt.ts +++ b/packages/cli/src/receipt.ts @@ -104,12 +104,150 @@ function analysisProfileLines(receipt: Receipt): string[] { } if (current.id === 'local-registry-v1') return omittedSastProfileLines(receipt, current.id) if (current.id === 'local-registry-v2') return thirteenAnalyzerProfileLines(receipt, current.id) + if (current.id === 'local-registry-v3') return jsOnlySastProfileLines(receipt, current.id) + const python = pythonCoverage(receipt) return [ '## Analysis profile', '', `Profile: \`${current.id}\`.`, '', + `The 15 deterministic registry analyzers ran locally on this machine, plus a local security pass: the shared SAST engine — the same rules and the same source-to-sink taint tracking as the hosted audit — over the ${python.analyzed ? 'JavaScript, TypeScript, TSX and Python' : 'JavaScript, TypeScript and TSX'} in this repository.`, + '', + '### What the local security pass checked', + '', + '- **SQL injection (CWE-89).** Untrusted input tracked from request sources through string building into query execution.', + '- **Mass assignment (CWE-915).** A raw request body spread into a database write, and write helpers whose payload type accepts arbitrary keys.', + '- **Un-awaited database writes, swallowed errors, coercion-prone `==` comparisons, and N+1 queries in loops** — the defect classes coding agents most often introduce.', + ...(python.analyzed ? [ + `- **The complete rule pack over ${python.scanned} Python file(s).** The installed grammar pack is the same \`web-tree-sitter\` runtime and the same compiled grammar the hosted audit loads, so Python here was checked by the hosted machinery rather than an approximation of it — including the injection, traversal, SSRF and deserialization classes the JavaScript subset below omits.`, + ] : []), + '', + '### What did not run', + '', + `- **The rest of the security rule pack${python.analyzed ? ', for JavaScript, TypeScript and TSX' : ''}.** Command injection, code injection, path traversal, SSRF, open redirect, XSS and insecure deserialization were **not** checked ${python.analyzed ? 'in those languages' : 'here'}. Those rules run in a hosted scan; absence of a finding in those classes means they were not analyzed, not that the code is clean.`, + ...pythonDisclosureLines(python), + '- **Hosted symbol graph.** No cross-file call or data-flow graph was built, so architecture and dead-code conclusions cover only what the local passes can see in isolation.', + '- **Abstraction-shape analysis.** Single-implementation interfaces, options nobody overrides, and parameters never varied at any call site were not checked. They require the cross-file symbol graph, which does not run locally. This receipt says nothing either way about those shapes.', + ...(receipt.llm ? [] : [ + '- **Optional LLM review.** No model read this diff. It is opt-in via `--llm` and is force-disabled under agent hooks, so a hook receipt is always deterministic evidence only.', + ]), + '- **Hosted Health scores.** Not calculated, reported as **N/A**. The scores are defined over the graph and the complete SAST pass; a number derived from this pass set would overstate what ran.', + '', + 'Local security findings are reported for review and do not fail the verdict on their own.', + '', + 'A PASS verdict means the passes listed above never ran and the passes that did run found nothing new. It is not a statement that this change is secure.', + '', + '[Run a hosted full audit](https://codetruss.com/dashboard/repos/new?source=cli-receipt).', + ] +} + +interface PythonCoverage { + status: string + /** Python files offered to the pass. Zero means the repository has none. */ + files: number + scanned: number + reason: string + /** Which kind of failure, when the status is `failed`. See `local-sast.ts`. */ + failureKind: string + analyzed: boolean +} + +/** + * What this run actually did about Python, read from the pass that did it. + * + * Derived from the signed JSON rather than from a constant, so the same receipt + * always renders the same bytes while different runs can honestly say different + * things. A receipt whose pass carries no Python metrics — the shape every + * pre-0.2.40 client wrote — reads as "no Python", which is what those clients + * meant, and the wording for that case is unchanged from v3. + */ +function pythonCoverage(receipt: Receipt): PythonCoverage { + const metrics = receipt.analyzers.passes.find((pass) => pass.id === 'local-sast')?.result.metrics + const status = typeof metrics?.pythonPackStatus === 'string' ? metrics.pythonPackStatus : 'not-applicable' + const files = Number(metrics?.pythonFiles) + const scanned = Number(metrics?.pythonFilesScanned) + const reason = typeof metrics?.pythonPackReason === 'string' ? metrics.pythonPackReason : '' + // A `failed` pass from a client that predates the taxonomy carries no kind. + // `digest` is the safe default there only because it is what those clients + // already rendered; nothing new is asserted about an old receipt. + const failureKind = typeof metrics?.pythonPackFailureKind === 'string' ? metrics.pythonPackFailureKind : 'digest' + return { + status, + files: Number.isFinite(files) ? files : 0, + scanned: Number.isFinite(scanned) ? scanned : 0, + reason, + failureKind, + analyzed: status === 'verified' && Number.isFinite(scanned) && scanned > 0, + } +} + +/** + * The non-JavaScript-language paragraph, in the states it can be in. + * + * "Skipped" and "skipped because the bytes did not verify" are different facts + * and a reader has to be able to tell them apart: the first is a choice they can + * reverse with one command, the second means something on their machine is not + * what this CLI published. The three FAILURE kinds are different facts for the + * same reason — only one of them is about the pack at all, and a receipt that + * blames a user's install for their machine's out-of-memory error has published + * an accusation it cannot support. + * + * The closing sentence in every failure branch is deliberately the provable one. + * "Nothing was analyzed with unverified bytes" is true of this loader — it holds + * one buffer per artifact and executes that buffer — but it is an assertion + * about the CLI's internals that a receipt's reader has no way to check, and it + * makes a claim wider than this pass can speak to. "No findings from this pack + * were reported" says less and is verifiable against the receipt in hand. + */ +function pythonDisclosureLines(python: PythonCoverage): string[] { + const others = 'Go, Java, C#, PHP, Ruby and Rust' + if (python.status === 'verified' && python.analyzed) { + return [ + `- **Non-JavaScript languages other than Python.** ${others} in this repository received secret scanning and the other registry passes, but no security rule or taint analysis.`, + ] + } + if (python.status === 'absent') { + return [ + `- **Python.** ${python.files} Python file(s) here received secret scanning and the other registry passes, but no security rule or taint analysis: the optional Python grammar pack is not installed. Install it with \`codetruss grammars install python\` to analyze them locally, or run a hosted scan.`, + `- **Other non-JavaScript languages.** ${others} in this repository were likewise not covered by any security rule or taint analysis.`, + ] + } + if (python.status === 'failed') { + return [ + `- **Python.** ${python.files} Python file(s) were **not** analyzed. ${pythonFailureSentence(python)} No findings from this pack were reported.`, + `- **Other non-JavaScript languages.** ${others} in this repository received secret scanning and the other registry passes, but no security rule or taint analysis.`, + ] + } + return [ + `- **Non-JavaScript languages.** The local pass covered JavaScript, TypeScript and TSX. Python, ${others} in this repository received secret scanning and the other registry passes, but no security rule or taint analysis.`, + ] +} + +/** The one sentence that differs between the three ways a pack run can fail. */ +function pythonFailureSentence(python: PythonCoverage): string { + if (python.failureKind === 'runtime') { + // The digests PASSED here. Saying so is the point: the user's install is + // fine and the problem is this machine or this Node build. + return `The installed Python grammar pack matched the digests compiled into this CLI, but its runtime could not start on this machine, so no Python was parsed: ${python.reason}.` + } + if (python.failureKind === 'scan') { + return `The installed Python grammar pack verified and loaded, but the scan failed partway through and its partial results were discarded rather than reported as a complete Python result: ${python.reason}.` + } + return `The installed Python grammar pack did not verify against what this CLI published, so it was not loaded: ${python.reason}. Reinstall it with \`codetruss grammars install python\`.` +} + +/** + * The profile block exactly as CLI 0.2.39 wrote it, when the local pass could + * only ever reach the JS family. Frozen so those receipts still reproduce + * byte-for-byte. + */ +function jsOnlySastProfileLines(receipt: Receipt, profileId: string): string[] { + return [ + '## Analysis profile', + '', + `Profile: \`${profileId}\`.`, + '', 'The 15 deterministic registry analyzers ran locally on this machine, plus a local security pass: the shared SAST engine — the same rules and the same source-to-sink taint tracking as the hosted audit — over the JavaScript, TypeScript and TSX in this repository.', '', '### What the local security pass checked', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 3ef81f9..38342c4 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -33,10 +33,16 @@ export const MAX_LLM_DIFF_BYTES = 2_000_000 * Honest local-analysis contract: which passes ran on this machine, which did * not, and whether scores may be inferred. * - * `local-registry-v3` supersedes `local-registry-v2`, which ran thirteen - * registry analyzers; the registry now holds fifteen, and the profile block - * states that count. `local-registry-v2` had itself superseded `v1`, in which - * SAST was omitted entirely. + * `local-registry-v4` supersedes `local-registry-v3`. The pass SET is identical + * — fifteen registry analyzers, the local security pass, no graph — but v3's + * block states flatly that the local pass "covers JavaScript, TypeScript and + * TSX only" and that Python "received no security rule or taint analysis". With + * an installed grammar pack that sentence is false, so the wording had to change + * and therefore the id had to change with it. v4 renders its Python paragraph + * from what the run recorded rather than from a constant. + * + * `local-registry-v3` had superseded `v2`, which ran thirteen registry + * analyzers; `v2` had superseded `v1`, in which SAST was omitted entirely. * * The id is bumped rather than the wording quietly changed, and it is bumped * for a count as readily as for a pass: this shape sits inside signed receipts, @@ -45,7 +51,7 @@ export const MAX_LLM_DIFF_BYTES = 2_000_000 * with. Every superseded version keeps a frozen renderer in `receipt.ts`. */ export const LOCAL_ANALYSIS_PROFILE = { - id: 'local-registry-v3', + id: 'local-registry-v4', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed', @@ -65,10 +71,18 @@ export interface LegacyLocalAnalysisProfileV2 { localPasses: readonly ['local-sast'] scoreStatus: 'not-computed' } +/** The v3 shape, retained so JS-only-local-SAST receipts still parse. */ +export interface LegacyLocalAnalysisProfileV3 { + id: 'local-registry-v3' + omittedPasses: readonly ['graph'] + localPasses: readonly ['local-sast'] + scoreStatus: 'not-computed' +} export type AnyLocalAnalysisProfile = | LocalAnalysisProfile | LegacyLocalAnalysisProfileV1 | LegacyLocalAnalysisProfileV2 + | LegacyLocalAnalysisProfileV3 export interface CliConfig { version: 1 diff --git a/packages/cli/test/grammar-command.test.ts b/packages/cli/test/grammar-command.test.ts new file mode 100644 index 0000000..67c04e2 --- /dev/null +++ b/packages/cli/test/grammar-command.test.ts @@ -0,0 +1,118 @@ +import { createServer, type Server } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { DEV_GRAMMAR_ORIGIN_ENV, installGrammarPack } from '../src/grammar-pack.js' +import { runGrammarsCommand } from '../src/grammar-command.js' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const publishedDir = join(repoRoot, 'public', 'downloads', 'grammars') + +const cleanup: string[] = [] +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +let server: Server +let origin: string + +beforeAll(async () => { + server = createServer((req, res) => { + const path = decodeURIComponent((req.url ?? '').split('?')[0]) + const prefix = '/downloads/grammars/' + if (!path.startsWith(prefix) || path.includes('..')) { + res.writeHead(404).end() + return + } + readFile(join(publishedDir, path.slice(prefix.length))).then( + (bytes) => res.writeHead(200).end(bytes), + () => res.writeHead(404).end(), + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}` +}) + +afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +}) + +/** + * LOCALAPPDATA as well as XDG_DATA_HOME: `grammarDataDir` reads the former on + * Windows and the latter everywhere else, so setting one alone leaves the suite + * reading and writing the developer's real pack directory on the other platform. + */ +async function scratchEnv(): Promise { + const home = await mkdtemp(join(tmpdir(), 'codetruss-grammars-cmd-')) + cleanup.push(home) + return { XDG_DATA_HOME: home, LOCALAPPDATA: home, [DEV_GRAMMAR_ORIGIN_ENV]: origin } +} + +function capture() { + const chunks: string[] = [] + return { write: (text: string) => { chunks.push(text) }, get text() { return chunks.join('') } } +} + +describe('codetruss grammars', () => { + it('lists what can be installed, with its provenance and size', async () => { + const out = capture() + expect(await runGrammarsCommand('list', undefined, out.write, await scratchEnv())).toBe(0) + expect(out.text).toContain('python-1.0.0') + // A user deciding whether to download 700 KB of WASM deserves to see what + // it is and where it came from before they run the command. + expect(out.text).toContain('web-tree-sitter@0.22.6') + expect(out.text).toContain('tree-sitter-wasms@0.1.11') + expect(out.text).toContain('codetruss grammars install python') + }) + + it('reports an uninstalled pack as a disclosed gap, and exits non-zero', async () => { + const out = capture() + expect(await runGrammarsCommand('status', undefined, out.write, await scratchEnv())).toBe(1) + expect(out.text).toContain('not installed') + expect(out.text).toContain('skipped locally and disclosed as such') + }) + + it('installs, then reports verified', async () => { + const env = await scratchEnv() + const install = capture() + expect(await runGrammarsCommand('install', 'python', install.write, env)).toBe(0) + expect(install.text).toContain('Installed python-1.0.0') + expect(install.text).toContain('matched the SHA-256 pinned in this CLI') + + const status = capture() + expect(await runGrammarsCommand('status', undefined, status.write, env)).toBe(0) + expect(status.text).toContain('installed and verified') + }) + + it('shouts about a tampered pack rather than reporting it as merely missing', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + await writeFile(join(dir, 'tree-sitter.wasm'), Buffer.alloc(4)) + + const out = capture() + expect(await runGrammarsCommand('status', undefined, out.write, env)).toBe(1) + expect(out.text).toContain('FAILED VERIFICATION') + expect(out.text).toContain('not loaded') + }) + + it('uninstalls and says what that costs', async () => { + const env = await scratchEnv() + await installGrammarPack('python', env) + const out = capture() + expect(await runGrammarsCommand('uninstall', 'python', out.write, env)).toBe(0) + expect(out.text).toContain('python is skipped locally again') + }) + + it('rejects an unknown action instead of doing nothing quietly', async () => { + await expect(runGrammarsCommand('frobnicate', undefined, capture().write, await scratchEnv())) + .rejects.toThrow(/list, status, install, or uninstall/) + }) + + it('requires a pack name for install', async () => { + await expect(runGrammarsCommand('install', undefined, capture().write, await scratchEnv())) + .rejects.toThrow(/requires a pack name/) + }) +}) diff --git a/packages/cli/test/grammar-loader.test.ts b/packages/cli/test/grammar-loader.test.ts new file mode 100644 index 0000000..5a15a7d --- /dev/null +++ b/packages/cli/test/grammar-loader.test.ts @@ -0,0 +1,129 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pinnedGrammarPack } from '../src/grammar-pack-manifest.js' +import type { GrammarPackState } from '../src/grammar-pack.js' + +/** + * What the loader EXECUTES, as opposed to what the inspector verified. + * + * These tests stub the inspection so the two can be told apart: the state names + * a directory holding a hostile payload while carrying the genuine verified + * buffers. A loader that re-opens the path to execute it runs the payload; a + * loader that executes the buffers it was handed cannot. That distinction is + * the entire security position of the grammar pack, and nothing about it is + * observable when verification and execution both read the same healthy file. + */ +const stub = vi.hoisted(() => ({ current: null as GrammarPackState | null })) + +vi.mock('../src/grammar-pack.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + inspectGrammarPack: async (name: string, env?: NodeJS.ProcessEnv) => ( + stub.current ?? actual.inspectGrammarPack(name, env) + ), + } +}) + +const { loadGrammarParser } = await import('../src/grammar-parser.js') + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const pack = pinnedGrammarPack('python')! +const publishedPack = join(repoRoot, 'public', 'downloads', 'grammars', `${pack.name}-${pack.version}`) + +const cleanup: string[] = [] +afterEach(async () => { + stub.current = null + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +/** The real published bytes, as `inspectGrammarPack` would have returned them. */ +async function verifiedContents(): Promise> { + const contents = new Map() + for (const file of pack.files) contents.set(file.name, await readFile(join(publishedPack, file.name))) + return contents +} + +async function scratchDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'codetruss-loader-')) + cleanup.push(dir) + return dir +} + +describe('the loader executes verified bytes, never a path', () => { + /** + * The adversarial review's proof-of-concept in deterministic form: the pack + * directory holds a module that writes a marker file and exports nothing + * usable, while the state carries the genuine buffers. Against a loader that + * does `createRequire(runtimePath)(runtimePath)` this writes the marker and + * owns the process. Against this one the payload is unreachable. + */ + it('parses Python from the buffers even when every file on disk is a payload', async () => { + const dir = await scratchDir() + const marker = join(dir, 'PWNED') + await writeFile( + join(dir, 'tree-sitter.js'), + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'pwned')\nmodule.exports = {}\n`, + ) + await writeFile(join(dir, 'tree-sitter.wasm'), Buffer.from('not wasm')) + await writeFile(join(dir, 'tree-sitter-python.wasm'), Buffer.from('not wasm')) + + stub.current = { status: 'verified', pack, dir, contents: await verifiedContents() } + const load = await loadGrammarParser('python') + + expect(load.status).toBe('verified') + if (load.status !== 'verified') throw new Error('expected a verified load') + const tree = await load.parser.parse('python', 'import os\nos.system(name)\n') + expect(tree).not.toBeNull() + expect(tree!.hasError).toBe(false) + tree!.release() + // The single fact this whole design exists to guarantee. + expect(existsSync(marker)).toBe(false) + }) + + it('loads with nothing at the pack path at all', async () => { + const dir = await scratchDir() + // Deleting the directory between verification and execution is the same + // race with the timing removed: the load must not notice. + await rm(dir, { recursive: true, force: true }) + + stub.current = { status: 'verified', pack, dir, contents: await verifiedContents() } + const load = await loadGrammarParser('python') + expect(load.status).toBe('verified') + }) +}) + +describe('why a pack was unusable is carried, not collapsed', () => { + it('calls a pack that failed inspection a digest failure', async () => { + const dir = await scratchDir() + stub.current = { + status: 'failed', + pack, + dir, + reason: 'tree-sitter.js has digest deadbeef, expected ddcacb69', + } + const load = await loadGrammarParser('python') + expect(load).toMatchObject({ status: 'failed', kind: 'digest' }) + }) + + /** + * The failure the review cared about: every digest matched and the runtime + * still would not start. An OOM inside emscripten must never be published as + * an accusation that the user's pack does not match the pinned digests. + */ + it('calls a verified pack whose runtime will not start a runtime failure', async () => { + const dir = await scratchDir() + const contents = await verifiedContents() + contents.set('tree-sitter.js', Buffer.from('module.exports = {}\n')) + + stub.current = { status: 'verified', pack, dir, contents } + const load = await loadGrammarParser('python') + expect(load).toMatchObject({ status: 'failed', kind: 'runtime' }) + if (load.status !== 'failed') throw new Error('expected a failed load') + expect(load.reason).not.toContain('digest') + }) +}) diff --git a/packages/cli/test/grammar-pack.test.ts b/packages/cli/test/grammar-pack.test.ts new file mode 100644 index 0000000..c84cc22 --- /dev/null +++ b/packages/cli/test/grammar-pack.test.ts @@ -0,0 +1,396 @@ +import { createHash } from 'node:crypto' +import { createServer, type Server } from 'node:http' +import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { PINNED_GRAMMAR_PACKS, pinnedGrammarPack } from '../src/grammar-pack-manifest.js' +import { + DEV_GRAMMAR_ORIGIN_ENV, + grammarDataDir, + grammarPackDir, + inspectGrammarPack, + installGrammarPack, + resolveGrammarOrigin, + uninstallGrammarPack, +} from '../src/grammar-pack.js' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const publishedDir = join(repoRoot, 'public', 'downloads', 'grammars') + +const cleanup: string[] = [] +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +/** + * Serve the real published artifacts over loopback. + * + * The install path is only worth testing against the actual bytes: a fixture + * with invented content would verify against an invented pin and prove nothing + * about whether the CLI can install what this repository publishes. + */ +let server: Server +let origin: string +/** Bytes to answer with instead of the published file, for tamper cases. */ +let override: Buffer | null = null + +beforeAll(async () => { + server = createServer((req, res) => { + const path = decodeURIComponent((req.url ?? '').split('?')[0]) + const prefix = '/downloads/grammars/' + if (!path.startsWith(prefix) || path.includes('..')) { + res.writeHead(404).end() + return + } + if (override) { + res.writeHead(200, { 'content-type': 'application/octet-stream' }).end(override) + return + } + readFile(join(publishedDir, path.slice(prefix.length))).then( + (bytes) => res.writeHead(200, { 'content-type': 'application/octet-stream' }).end(bytes), + () => res.writeHead(404).end(), + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}` +}) + +afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +}) + +/** + * An environment whose grammar data directory is a throwaway temp dir on EVERY + * platform. + * + * `grammarDataDir` reads LOCALAPPDATA on Windows and XDG_DATA_HOME everywhere + * else, so setting one alone isolates one platform and leaves the other pointed + * at the developer's real data directory — where this suite installs a 722 KB + * pack, tampers with it mid-suite, and abandons it. + */ +async function scratchEnv(): Promise { + const home = await mkdtemp(join(tmpdir(), 'codetruss-grammars-')) + cleanup.push(home) + return { XDG_DATA_HOME: home, LOCALAPPDATA: home, [DEV_GRAMMAR_ORIGIN_ENV]: origin } +} + +/** + * Run `body` with `process.platform` pinned. + * + * `grammarDataDir` branches on the platform, so an unpinned assertion about one + * branch only asserts anything on the machine that happens to run it. Pinning is + * what makes the POSIX expectations below mean the same thing on Windows as they + * do here, instead of quietly asserting the other branch's behavior. + */ +function onPlatform(platform: NodeJS.Platform, body: () => T): T { + const original = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + try { + return body() + } finally { + Object.defineProperty(process, 'platform', original) + } +} + +describe('grammar pack storage location', () => { + it('puts packs under XDG_DATA_HOME, not the config directory', () => { + const dir = onPlatform('linux', () => grammarDataDir({ XDG_DATA_HOME: '/data' })) + // Packs are reproducible downloads, not authored settings. + expect(dir).toBe(join('/data', 'codetruss', 'grammars')) + }) + + it('refuses a relative XDG_DATA_HOME rather than resolving it against the cwd', () => { + expect(() => onPlatform('linux', () => grammarDataDir({ XDG_DATA_HOME: 'relative/path' }))) + .toThrow(/absolute/) + }) + + it('resolves the Windows data directory from LOCALAPPDATA', () => { + onPlatform('win32', () => { + // LOCALAPPDATA, not APPDATA: 700 KB of WASM must not roam between machines. + expect(grammarDataDir({ LOCALAPPDATA: 'C:\\Users\\x\\AppData\\Local' })) + .toBe('C:\\Users\\x\\AppData\\Local\\codetruss\\grammars') + expect(() => grammarDataDir({ LOCALAPPDATA: 'Local' })).toThrow(/absolute/) + }) + }) +}) + +describe('the download origin is not a free parameter', () => { + it('defaults to the CodeTruss downloads host', () => { + expect(resolveGrammarOrigin(undefined)).toBe('https://codetruss.com') + expect(resolveGrammarOrigin('')).toBe('https://codetruss.com') + }) + + it('accepts a loopback override for development and rejects anything else', () => { + expect(resolveGrammarOrigin('http://127.0.0.1:8080')).toBe('http://127.0.0.1:8080') + // Environment access must not become "choose which code gets executed". + expect(() => resolveGrammarOrigin('https://evil.example.com')).toThrow(/loopback/) + expect(() => resolveGrammarOrigin('http://localhost/path')).toThrow(/loopback/) + expect(() => resolveGrammarOrigin('not a url')).toThrow(/loopback/) + }) +}) + +describe('installing a pack', () => { + it('downloads every pinned artifact and verifies each digest', async () => { + const env = await scratchEnv() + const result = await installGrammarPack('python', env) + + expect(result.alreadyInstalled).toBe(false) + const pack = pinnedGrammarPack('python')! + for (const file of pack.files) { + const info = await stat(join(result.dir, file.name)) + expect(info.size).toBe(file.bytes) + } + await expect(inspectGrammarPack('python', env)).resolves.toMatchObject({ status: 'verified' }) + }) + + it('is idempotent: a verified pack is not re-downloaded', async () => { + const env = await scratchEnv() + await installGrammarPack('python', env) + const second = await installGrammarPack('python', env) + expect(second.alreadyInstalled).toBe(true) + }) + + it('refuses bytes that do not match the pin, and leaves nothing behind', async () => { + const env = await scratchEnv() + override = Buffer.from('not a grammar') + try { + await expect(installGrammarPack('python', env)).rejects.toThrow(/bytes|digest/) + } finally { + override = null + } + // A rejected download must not leave a half-installed pack that a later run + // could mistake for a real one. + await expect(inspectGrammarPack('python', env)).resolves.toMatchObject({ status: 'absent' }) + }) + + it('rejects an unknown pack name instead of inventing a download URL', async () => { + const env = await scratchEnv() + await expect(installGrammarPack('haskell', env)).rejects.toThrow(/unknown grammar pack/) + }) +}) + +describe('a pack is re-verified on every load, not only at install', () => { + it('reports a flipped byte as failed, naming the artifact', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + const target = join(dir, 'tree-sitter-python.wasm') + const bytes = await readFile(target) + bytes[100] ^= 0xff + await writeFile(target, bytes) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('tree-sitter-python.wasm') + expect(state.reason).toContain('digest') + }) + + it('reports a truncated artifact by size before hashing it', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + await writeFile(join(dir, 'tree-sitter.wasm'), Buffer.alloc(10)) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toMatch(/is 10 bytes, expected/) + }) + + it('reports a missing artifact rather than loading a partial pack', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + await rm(join(dir, 'tree-sitter.js')) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('tree-sitter.js is missing') + }) + + it('reports an unexpected file in the pack directory', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + await writeFile(join(dir, 'extra.js'), 'console.log(1)') + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('extra.js') + }) + + /** + * The load-bearing property of the whole design: the bytes that were hashed + * are the bytes the caller gets. A loader handed a path instead has verified + * one file and will execute whatever is at that name a moment later, and the + * gap is long enough — two more digests and a `readdir` — for a `rename(2)` + * loop to land a hostile module in it. + */ + it('returns the exact buffers it hashed, not a path to re-read', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + const state = await inspectGrammarPack('python', env) + if (state.status !== 'verified') throw new Error('expected a verified pack') + + const pack = pinnedGrammarPack('python')! + for (const file of pack.files) { + const held = state.contents.get(file.name) + expect(held).toBeInstanceOf(Buffer) + expect(createHash('sha256').update(held!).digest('hex')).toBe(file.sha256) + expect(held!.equals(await readFile(join(dir, file.name)))).toBe(true) + } + + // Rewriting every artifact after the fact cannot reach the buffers already + // returned — which is precisely why the loader is safe to execute them. + for (const file of pack.files) await writeFile(join(dir, file.name), Buffer.alloc(file.bytes, 0x41)) + for (const file of pack.files) { + expect(createHash('sha256').update(state.contents.get(file.name)!).digest('hex')).toBe(file.sha256) + } + }) + + it('replaces a failed pack wholesale on reinstall', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + await writeFile(join(dir, 'extra.js'), 'console.log(1)') + expect((await inspectGrammarPack('python', env)).status).toBe('failed') + + const reinstalled = await installGrammarPack('python', env) + expect(reinstalled.alreadyInstalled).toBe(false) + await expect(inspectGrammarPack('python', env)).resolves.toMatchObject({ status: 'verified' }) + }) +}) + +/** + * `stat` follows symlinks and `mkdir`'s mode only applies to directories it + * creates, so the naive version of this module accepts a symlinked artifact as + * verified, installs straight through a symlinked pack root, and leaves a + * pre-existing loose root loose. Each one turns "needs write inside a 0700 + * directory" into something much cheaper — and on a shared host, into a + * cross-user problem. + * + * POSIX-only: `symlink` needs a privilege on Windows, and `st_mode` says nothing + * useful about an ACL there. + */ +describe.skipIf(process.platform === 'win32')('symlinks and loose permissions', () => { + it('rejects a symlinked artifact instead of hashing what it points at', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + // The link target holds the genuine, pinned bytes: following it would + // verify perfectly, which is exactly the trap. + const elsewhere = await mkdtemp(join(tmpdir(), 'codetruss-link-target-')) + cleanup.push(elsewhere) + const target = join(elsewhere, 'tree-sitter.js') + await rename(join(dir, 'tree-sitter.js'), target) + await symlink(target, join(dir, 'tree-sitter.js')) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('tree-sitter.js') + expect(state.reason).toContain('symbolic link') + }) + + it('rejects a symlinked pack root even when the pack behind it verifies', async () => { + const env = await scratchEnv() + await installGrammarPack('python', env) + const root = grammarDataDir(env) + await rename(root, `${root}-real`) + await symlink(`${root}-real`, root) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('symbolic link') + }) + + it('rejects a group- or other-writable root, where the parent alone lets a pack be swapped', async () => { + const env = await scratchEnv() + await installGrammarPack('python', env) + // No permission on the files is needed: write on the parent is enough to + // `rename` the pack away and put another directory in its place. + await chmod(grammarDataDir(env), 0o775) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('writable by group or other') + }) + + it('rejects a group-writable pack directory too', async () => { + const env = await scratchEnv() + const { dir } = await installGrammarPack('python', env) + await chmod(dir, 0o770) + + const state = await inspectGrammarPack('python', env) + expect(state.status).toBe('failed') + if (state.status !== 'failed') throw new Error('expected a failed pack') + expect(state.reason).toContain('writable by group or other') + }) + + it('refuses to install through a pre-created symlinked root', async () => { + const env = await scratchEnv() + const root = grammarDataDir(env) + const elsewhere = await mkdtemp(join(tmpdir(), 'codetruss-link-root-')) + cleanup.push(elsewhere) + await mkdir(dirname(root), { recursive: true }) + await symlink(elsewhere, root) + + await expect(installGrammarPack('python', env)).rejects.toThrow(/not a real directory/) + // Nothing may land in the attacker's directory on the way to failing. + await expect(readFile(join(elsewhere, 'tree-sitter.js'))).rejects.toThrow() + }) + + it('tightens a loose root on install rather than dead-ending the user', async () => { + const env = await scratchEnv() + const root = grammarDataDir(env) + await mkdir(root, { recursive: true }) + await chmod(root, 0o755) + + await installGrammarPack('python', env) + expect((await lstat(root)).mode & 0o777).toBe(0o700) + await expect(inspectGrammarPack('python', env)).resolves.toMatchObject({ status: 'verified' }) + }) +}) + +describe('uninstalling', () => { + it('removes the pack and reports absence as the end state', async () => { + const env = await scratchEnv() + await installGrammarPack('python', env) + await expect(uninstallGrammarPack('python', env)).resolves.toMatchObject({ removed: true }) + await expect(inspectGrammarPack('python', env)).resolves.toMatchObject({ status: 'absent' }) + // Already-absent is success: the requested end state holds. + await expect(uninstallGrammarPack('python', env)).resolves.toMatchObject({ removed: false }) + }) +}) + +describe('the pinned manifest', () => { + it('pins a digest and a length for every artifact it will execute', () => { + expect(PINNED_GRAMMAR_PACKS.length).toBeGreaterThan(0) + for (const pack of PINNED_GRAMMAR_PACKS) { + expect(pack.files.length).toBeGreaterThan(0) + for (const file of pack.files) { + expect(file.sha256).toMatch(/^[0-9a-f]{64}$/) + expect(file.bytes).toBeGreaterThan(0) + // Same-origin only: a third-party CDN is never a download target. + expect(file.url.startsWith('/downloads/grammars/')).toBe(true) + } + } + }) + + it('matches the artifacts this repository publishes', async () => { + for (const pack of PINNED_GRAMMAR_PACKS) { + for (const file of pack.files) { + const bytes = await readFile(join(publishedDir, `${pack.name}-${pack.version}`, file.name)) + expect(bytes.length).toBe(file.bytes) + } + } + }) + + it('derives the on-disk directory from the pack version, so packs coexist', () => { + const pack = pinnedGrammarPack('python')! + expect(onPlatform('linux', () => grammarPackDir(pack, { XDG_DATA_HOME: '/data' }))) + .toBe(join('/data', 'codetruss', 'grammars', `python-${pack.version}`)) + }) +}) diff --git a/packages/cli/test/grammar-sast.test.ts b/packages/cli/test/grammar-sast.test.ts new file mode 100644 index 0000000..5fe3522 --- /dev/null +++ b/packages/cli/test/grammar-sast.test.ts @@ -0,0 +1,195 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { indexRepository } from '../src/indexer.js' +import { DEV_GRAMMAR_ORIGIN_ENV, installGrammarPack } from '../src/grammar-pack.js' +import { runLocalSast } from '../src/local-sast.js' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const publishedDir = join(repoRoot, 'public', 'downloads', 'grammars') + +const cleanup: string[] = [] +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +let server: Server +let origin: string + +beforeAll(async () => { + server = createServer((req, res) => { + const path = decodeURIComponent((req.url ?? '').split('?')[0]) + const prefix = '/downloads/grammars/' + if (!path.startsWith(prefix) || path.includes('..')) { + res.writeHead(404).end() + return + } + readFile(join(publishedDir, path.slice(prefix.length))).then( + (bytes) => res.writeHead(200).end(bytes), + () => res.writeHead(404).end(), + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}` +}) + +afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +}) + +/** + * A repository holding one injectable Python module and one clean TS module. + * + * The Python here deliberately exercises classes the JS-family subset is + * disclosed as NOT checking — command injection, path traversal, SSRF — because + * that is the whole point of the pack: not "Python too", but "Python with the + * rules the local JS pass cannot run". + */ +async function pythonRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'codetruss-grammar-sast-')) + cleanup.push(root) + await mkdir(join(root, 'app')) + await writeFile(join(root, 'app', 'views.py'), [ + 'import os', + 'import requests', + 'from flask import request', + '', + '', + 'def run_cmd():', + ' name = request.args.get("name")', + ' os.system("ls " + name)', + '', + '', + 'def read_file():', + ' p = request.args.get("p")', + ' return open("/data/" + p).read()', + '', + '', + 'def fetch():', + ' url = request.args.get("url")', + ' return requests.get(url).text', + '', + ].join('\n')) + await writeFile(join(root, 'app', 'clean.ts'), 'export const a = 1\n') + return root +} + +/** + * An environment whose grammar data directory is a throwaway temp dir on EVERY + * platform. + * + * This returns the whole env rather than the directory so that callers cannot + * spell the isolation themselves and get it half right: `grammarDataDir` reads + * LOCALAPPDATA on Windows and XDG_DATA_HOME everywhere else, and naming only one + * of them isolates only one platform. The tests below install a pack and then + * corrupt it, so the platform that misses out does not merely leak state — it + * leaves a tampered pack in the developer's real data directory. + */ +async function scratchDataEnv(): Promise { + const home = await mkdtemp(join(tmpdir(), 'codetruss-grammar-data-')) + cleanup.push(home) + return { XDG_DATA_HOME: home, LOCALAPPDATA: home } +} + +describe('the local pass without a grammar pack', () => { + it('skips Python, analyzes nothing there, and records why', async () => { + const root = await pythonRepo() + const env = await scratchDataEnv() + const result = await runLocalSast(await indexRepository(root), env) + + expect(result.findings).toHaveLength(0) + const metrics = result.pass.result.metrics! + expect(metrics.pythonPackStatus).toBe('absent') + expect(metrics.pythonFiles).toBe(1) + expect(metrics.pythonFilesScanned).toBe(0) + // Skipping a language is not a failure of the pass: it is disclosed + // coverage, and a clean JS/TS repo must still be able to reach PASS. + expect(result.pass.result.complete).toBe(true) + expect(result.pass.error).toBeUndefined() + }) + + it('says nothing about Python for a repository that has none', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-no-python-')) + cleanup.push(root) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.ts'), 'export const a = 1\n') + const env = await scratchDataEnv() + + const result = await runLocalSast(await indexRepository(root), env) + expect(result.pass.result.metrics!.pythonPackStatus).toBe('not-applicable') + expect(result.pass.result.metrics!.pythonFiles).toBe(0) + }) +}) + +describe('the local pass with the grammar pack installed', () => { + it('finds Python injection the JS parser could never reach', async () => { + const root = await pythonRepo() + const env = { ...await scratchDataEnv(), [DEV_GRAMMAR_ORIGIN_ENV]: origin } + await installGrammarPack('python', env) + + const result = await runLocalSast(await indexRepository(root), env) + const metrics = result.pass.result.metrics! + expect(metrics.pythonPackStatus).toBe('verified') + expect(metrics.pythonFilesScanned).toBe(1) + + // The Python scan runs the FULL pack, not the JS subset. Each of these is a + // class the JS-family pass is explicitly disclosed as not checking, so their + // presence is the pack doing the thing it exists for. + const rules = result.findings.map((finding) => finding.metadata?.ruleId) + expect(rules).toContain('command-injection') + expect(rules).toContain('path-traversal') + expect(rules).toContain('ssrf') + for (const finding of result.findings) { + expect(finding.analyzerId).toBe('local-sast') + expect(finding.filePath).toBe('app/views.py') + } + }) + + /** + * A single `codetruss review` analyzes twice — the baseline tree, then the + * final tree — so the pack is loaded twice in one process. The emscripten + * runtime reassigns its own `module.exports` during init, so a naive second + * `require()` returns the Module object instead of the Parser class and the + * whole second analysis silently loses Python. That failure is invisible to a + * one-shot test and to the receipt, which would just say "not analyzed". + */ + it('analyzes Python on a second load in the same process', async () => { + const root = await pythonRepo() + const env = { ...await scratchDataEnv(), [DEV_GRAMMAR_ORIGIN_ENV]: origin } + await installGrammarPack('python', env) + const index = await indexRepository(root) + + const first = await runLocalSast(index, env) + const second = await runLocalSast(index, env) + + expect(second.pass.result.metrics!.pythonPackStatus).toBe('verified') + expect(second.pass.result.metrics!.pythonFilesScanned).toBe(1) + // The baseline and final analyses must see the same code the same way, or + // the finding delta is computed against a tree that was never analyzed. + expect(second.findings.map((finding) => finding.metadata?.ruleId).sort()) + .toEqual(first.findings.map((finding) => finding.metadata?.ruleId).sort()) + }) + + it('refuses to analyze Python with a tampered pack, and says so', async () => { + const root = await pythonRepo() + const env = { ...await scratchDataEnv(), [DEV_GRAMMAR_ORIGIN_ENV]: origin } + const { dir } = await installGrammarPack('python', env) + const target = join(dir, 'tree-sitter.js') + await writeFile(target, `${await readFile(target, 'utf8')}\n// injected`) + + const result = await runLocalSast(await indexRepository(root), env) + const metrics = result.pass.result.metrics! + expect(metrics.pythonPackStatus).toBe('failed') + expect(metrics.pythonFilesScanned).toBe(0) + expect(String(metrics.pythonPackReason)).toContain('tree-sitter.js') + // WHICH failure, so the receipt can avoid accusing a healthy install of + // tampering when the real problem was the runtime or the scan. + expect(metrics.pythonPackFailureKind).toBe('digest') + // Nothing was executed from the modified pack, so no Python finding exists. + expect(result.findings.filter((finding) => finding.filePath?.endsWith('.py'))).toHaveLength(0) + }) +}) diff --git a/packages/cli/test/receipt.test.ts b/packages/cli/test/receipt.test.ts index 8bdebb9..26bd1e4 100644 --- a/packages/cli/test/receipt.test.ts +++ b/packages/cli/test/receipt.test.ts @@ -60,6 +60,40 @@ function profileV2Fixture(root: string, patch = 'diff evidence'): Receipt { } } +/** A receipt as CLI 0.2.39 signed it, when the local pass reached the JS family only. */ +function profileV3Fixture(root: string, patch = 'diff evidence'): Receipt { + const receipt = fixture(root, patch) + return { + ...receipt, + analyzers: { + passes: receipt.analyzers.passes, + findings: receipt.analyzers.findings, + index: receipt.analyzers.index, + analysisProfile: { + id: 'local-registry-v3', + omittedPasses: ['graph'], + localPasses: ['local-sast'], + scoreStatus: 'not-computed', + }, + }, + } +} + +/** A current receipt whose local pass recorded a Python coverage outcome. */ +function pythonFixture( + metrics: Record, + root = '/tmp/repo', +): Receipt { + const receipt = fixture(root) + return { + ...receipt, + analyzers: { + ...receipt.analyzers, + passes: [{ id: 'local-sast', result: { findings: [], complete: true, truncated: false, metrics } }], + }, + } +} + function legacyFixture(root: string, patch = 'diff evidence'): Receipt { const receipt = fixture(root, patch) return { @@ -91,7 +125,7 @@ describe('signed receipts', () => { await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) const markdown = await readFile(paths.markdown, 'utf8') expect(markdown).toContain('Policy SHA-256') - expect(markdown).toContain('Profile: `local-registry-v3`') + expect(markdown).toContain('Profile: `local-registry-v4`') expect(markdown).not.toContain('Final scores:') await writeFile(paths.markdown, `${await readFile(paths.markdown, 'utf8')}tampered`) await expect(verifyReceipt(dir, receipt.sessionId)).rejects.toThrow('Markdown receipt does not match') @@ -175,14 +209,141 @@ describe('signed receipts', () => { await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) }) + it('reproduces the v3 wording for a receipt signed before the grammar pack existed', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-profile-v3-receipt-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = profileV3Fixture(root) + const paths = await writeReceipt(dir, receipt, 'diff evidence') + + const markdown = await readFile(paths.markdown, 'utf8') + expect(markdown).toContain('Profile: `local-registry-v3`') + // v3 could only ever mean this, so it must keep saying exactly this. + expect(markdown).toContain('The local pass covers JavaScript, TypeScript and TSX only.') + expect(markdown).not.toContain('grammar pack') + await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) + }) + it('states the new registry count and the abstraction-shape limit on a current receipt', () => { const markdown = renderMarkdown(fixture('/tmp/repo')) - expect(markdown).toContain('Profile: `local-registry-v3`') + expect(markdown).toContain('Profile: `local-registry-v4`') expect(markdown).toContain('The 15 deterministic registry analyzers ran locally on this machine') expect(markdown).toContain('**Abstraction-shape analysis.**') expect(markdown).toContain('says nothing either way about those shapes') }) + describe('the Python disclosure states what this run actually did', () => { + it('names the pack and the command when it is simply not installed', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 12, + pythonFilesScanned: 0, + pythonPackStatus: 'absent', + pythonPackReason: 'the Python grammar pack is not installed', + })) + expect(markdown).toContain('12 Python file(s) here received secret scanning') + expect(markdown).toContain('codetruss grammars install python') + // Absence is a gap, never a clean result. + expect(markdown).toContain('no security rule or taint analysis') + expect(markdown).not.toContain('JavaScript, TypeScript, TSX and Python') + }) + + it('claims Python coverage only when the pack actually scanned files', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 47, + pythonFilesScanned: 47, + pythonPackStatus: 'verified', + })) + expect(markdown).toContain('JavaScript, TypeScript, TSX and Python') + expect(markdown).toContain('The complete rule pack over 47 Python file(s)') + expect(markdown).toContain('**Non-JavaScript languages other than Python.**') + // The JS subset limit survives: Python coverage must not be read as + // covering the injection classes the JS pass still skips. + expect(markdown).toContain('for JavaScript, TypeScript and TSX') + expect(markdown).not.toContain('grammars install python') + }) + + it('distinguishes a tampered pack from an absent one, and never loads it', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 5, + pythonFilesScanned: 0, + pythonPackStatus: 'failed', + pythonPackFailureKind: 'digest', + pythonPackReason: 'tree-sitter-python.wasm has digest deadbeef, expected 9056d0fb', + })) + expect(markdown).toContain('did not verify against what this CLI published') + expect(markdown).toContain('tree-sitter-python.wasm has digest deadbeef') + expect(markdown).toContain('Reinstall it with `codetruss grammars install python`') + // Provable, and checkable against the finding list in this same receipt. + expect(markdown).toContain('No findings from this pack were reported.') + expect(markdown).not.toContain('JavaScript, TypeScript, TSX and Python') + }) + + /** + * The three failure kinds are three unrelated events, and only one of them + * is about the pack's bytes. A receipt that renders the digest sentence for + * an out-of-memory error has published a false accusation of tampering — in + * a signed document, over the product's own core claim. + */ + it('blames the machine, not the install, when the pack verified and the runtime would not start', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 5, + pythonFilesScanned: 0, + pythonPackStatus: 'failed', + pythonPackFailureKind: 'runtime', + pythonPackReason: 'grammar runtime failed to load: Aborted(OOM)', + })) + expect(markdown).toContain('matched the digests compiled into this CLI, but its runtime could not start on this machine') + expect(markdown).toContain('Aborted(OOM)') + expect(markdown).not.toContain('did not verify') + // Nothing is wrong with the install, so nothing suggests reinstalling it. + expect(markdown).not.toContain('Reinstall it with') + expect(markdown).toContain('No findings from this pack were reported.') + }) + + it('says the scan failed partway when the pack loaded and the scan threw', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 5, + pythonFilesScanned: 0, + pythonPackStatus: 'failed', + pythonPackFailureKind: 'scan', + pythonPackReason: 'grammar pack scan failed: out of memory', + })) + expect(markdown).toContain('verified and loaded, but the scan failed partway through') + expect(markdown).toContain('partial results were discarded') + expect(markdown).not.toContain('did not verify') + expect(markdown).toContain('No findings from this pack were reported.') + }) + + it('renders a failed pass from a client that predates the taxonomy as before', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 5, + pythonFilesScanned: 0, + pythonPackStatus: 'failed', + pythonPackReason: 'tree-sitter.js is missing from the installed pack', + })) + // No kind recorded means no new claim may be invented for it. + expect(markdown).toContain('did not verify against what this CLI published') + expect(markdown).not.toContain('runtime could not start') + }) + + it('says nothing about a pack for a repository with no Python in it', () => { + const markdown = renderMarkdown(pythonFixture({ + pythonFiles: 0, + pythonFilesScanned: 0, + pythonPackStatus: 'not-applicable', + })) + expect(markdown).toContain('The local pass covered JavaScript, TypeScript and TSX.') + expect(markdown).not.toContain('grammar pack') + expect(markdown).not.toContain('grammars install') + }) + + it('reads a pre-0.2.40 pass with no Python metrics as the JS-only case', () => { + const markdown = renderMarkdown(pythonFixture({ inputFiles: 3, filesScanned: 3 })) + expect(markdown).toContain('The local pass covered JavaScript, TypeScript and TSX.') + expect(markdown).not.toContain('grammar pack') + }) + }) + it('renders the comment-signal measurement from pass metrics, and nothing without them', () => { const receipt = fixture('/tmp/repo') expect(renderMarkdown(receipt)).not.toContain('## Comment signal') diff --git a/public/downloads/codetruss-cli-0.2.40.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.40.sbom.cdx.json new file mode 100644 index 0000000..fe75706 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.40.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:87fe717b-5581-5c87-9d3e-571c96b13a30", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.40", + "name": "@codetruss/cli", + "version": "0.2.40", + "description": "Local-first scope, quality, and verification receipts for coding agents", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/cli@0.2.40" + }, + "properties": [ + { + "name": "codetruss:distribution", + "value": "single-file JavaScript bundle" + }, + { + "name": "codetruss:runtimeDependencies", + "value": "0" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "name": "@codetruss/analyzer-engine", + "version": "0.1.0", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/balanced-match@4.0.4", + "name": "balanced-match", + "version": "4.0.4", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/balanced-match@4.0.4", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/brace-expansion@5.0.7", + "name": "brace-expansion", + "version": "5.0.7", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/brace-expansion@5.0.7", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/minimatch@10.2.5", + "name": "minimatch", + "version": "10.2.5", + "licenses": [ + { + "license": { + "id": "BlueOak-1.0.0" + } + } + ], + "purl": "pkg:npm/minimatch@10.2.5", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/yaml@2.9.0", + "name": "yaml", + "version": "2.9.0", + "licenses": [ + { + "license": { + "id": "ISC" + } + } + ], + "purl": "pkg:npm/yaml@2.9.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "dependsOn": [] + }, + { + "ref": "pkg:npm/%40codetruss/cli@0.2.40", + "dependsOn": [ + "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "pkg:npm/minimatch@10.2.5", + "pkg:npm/yaml@2.9.0" + ] + }, + { + "ref": "pkg:npm/balanced-match@4.0.4", + "dependsOn": [] + }, + { + "ref": "pkg:npm/brace-expansion@5.0.7", + "dependsOn": [ + "pkg:npm/balanced-match@4.0.4" + ] + }, + { + "ref": "pkg:npm/minimatch@10.2.5", + "dependsOn": [ + "pkg:npm/brace-expansion@5.0.7" + ] + }, + { + "ref": "pkg:npm/yaml@2.9.0", + "dependsOn": [] + } + ] +} diff --git a/public/downloads/codetruss-cli-0.2.40.tgz b/public/downloads/codetruss-cli-0.2.40.tgz new file mode 100644 index 0000000..4b5b0c6 Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.40.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.40.tgz.sha256 b/public/downloads/codetruss-cli-0.2.40.tgz.sha256 new file mode 100644 index 0000000..1c0fc48 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.40.tgz.sha256 @@ -0,0 +1 @@ +5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2 codetruss-cli-0.2.40.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index 2b23b49..ffc0697 100644 --- a/public/downloads/codetruss-cli-latest.json +++ b/public/downloads/codetruss-cli-latest.json @@ -1,13 +1,13 @@ { "name": "@codetruss/cli", - "version": "0.2.39", - "url": "/downloads/codetruss-cli-0.2.39.tgz", + "version": "0.2.40", + "url": "/downloads/codetruss-cli-0.2.40.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0", - "sbomUrl": "/downloads/codetruss-cli-0.2.39.sbom.cdx.json", - "sbomSha256": "8bd47df2b79d979883cb0372abfc26d380b3ad2fd10327538da8cb15e838d1b4", + "sha256": "5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2", + "sbomUrl": "/downloads/codetruss-cli-0.2.40.sbom.cdx.json", + "sbomSha256": "a78a5a08993e2266cee606f892d2afe118880605dd911d3f2149f99e4e8001bd", "node": ">=20.9.0", "repository": "https://github.com/DeliriumPulse/codetruss-cli", - "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.39", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.39.tgz --repo DeliriumPulse/codetruss-cli" + "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.40", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.40.tgz --repo DeliriumPulse/codetruss-cli" } diff --git a/public/downloads/codetruss-cli-latest.sbom.cdx.json b/public/downloads/codetruss-cli-latest.sbom.cdx.json index ba79216..fe75706 100644 --- a/public/downloads/codetruss-cli-latest.sbom.cdx.json +++ b/public/downloads/codetruss-cli-latest.sbom.cdx.json @@ -1,15 +1,15 @@ { "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", - "serialNumber": "urn:uuid:be975d22-e2ae-5460-993a-2839a1058dab", + "serialNumber": "urn:uuid:87fe717b-5581-5c87-9d3e-571c96b13a30", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.39", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.40", "name": "@codetruss/cli", - "version": "0.2.39", + "version": "0.2.40", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.39" + "purl": "pkg:npm/%40codetruss/cli@0.2.40" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.39", + "ref": "pkg:npm/%40codetruss/cli@0.2.40", "dependsOn": [ "pkg:npm/%40codetruss/analyzer-engine@0.1.0", "pkg:npm/minimatch@10.2.5", diff --git a/public/downloads/codetruss-cli-latest.tgz b/public/downloads/codetruss-cli-latest.tgz index f8ad92c..4b5b0c6 100644 Binary files a/public/downloads/codetruss-cli-latest.tgz and b/public/downloads/codetruss-cli-latest.tgz differ diff --git a/public/downloads/codetruss-cli-latest.tgz.sha256 b/public/downloads/codetruss-cli-latest.tgz.sha256 index b13f42c..a8db5aa 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0 codetruss-cli-latest.tgz +5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2 codetruss-cli-latest.tgz diff --git a/public/downloads/grammars/codetruss-grammars-latest.json b/public/downloads/grammars/codetruss-grammars-latest.json new file mode 100644 index 0000000..6f865bb --- /dev/null +++ b/public/downloads/grammars/codetruss-grammars-latest.json @@ -0,0 +1,37 @@ +{ + "packs": [ + { + "name": "python", + "version": "1.0.0", + "language": "python", + "runtime": { + "package": "web-tree-sitter", + "version": "0.22.6" + }, + "grammar": { + "package": "tree-sitter-wasms", + "version": "0.1.11" + }, + "files": [ + { + "name": "tree-sitter.js", + "url": "/downloads/grammars/python-1.0.0/tree-sitter.js", + "bytes": 74197, + "sha256": "ddcacb69cd26c07322c51b798a63805fd99c272177c9633a978f3886358ca070" + }, + { + "name": "tree-sitter.wasm", + "url": "/downloads/grammars/python-1.0.0/tree-sitter.wasm", + "bytes": 188635, + "sha256": "29208e71028ab0c11dfcc941255075aad75545394467aa22d817a6356714090f" + }, + { + "name": "tree-sitter-python.wasm", + "url": "/downloads/grammars/python-1.0.0/tree-sitter-python.wasm", + "bytes": 476105, + "sha256": "9056d0fb0c337810d019fae350e8167786119da98f0f282aceae7ab89ee8253b" + } + ] + } + ] +} diff --git a/public/downloads/grammars/python-1.0.0/tree-sitter-python.wasm b/public/downloads/grammars/python-1.0.0/tree-sitter-python.wasm new file mode 100644 index 0000000..1423763 Binary files /dev/null and b/public/downloads/grammars/python-1.0.0/tree-sitter-python.wasm differ diff --git a/public/downloads/grammars/python-1.0.0/tree-sitter-python.wasm.sha256 b/public/downloads/grammars/python-1.0.0/tree-sitter-python.wasm.sha256 new file mode 100644 index 0000000..69d74fc --- /dev/null +++ b/public/downloads/grammars/python-1.0.0/tree-sitter-python.wasm.sha256 @@ -0,0 +1 @@ +9056d0fb0c337810d019fae350e8167786119da98f0f282aceae7ab89ee8253b tree-sitter-python.wasm diff --git a/public/downloads/grammars/python-1.0.0/tree-sitter.js b/public/downloads/grammars/python-1.0.0/tree-sitter.js new file mode 100644 index 0000000..eba859e --- /dev/null +++ b/public/downloads/grammars/python-1.0.0/tree-sitter.js @@ -0,0 +1 @@ +var Module=void 0!==Module?Module:{},TreeSitter=function(){var initPromise,document="object"==typeof window?{currentScript:window.document.currentScript}:null;class Parser{constructor(){this.initialize()}initialize(){throw new Error("cannot construct a Parser before calling `init()`")}static init(moduleOptions){return initPromise||(Module=Object.assign({},Module,moduleOptions),initPromise=new Promise((resolveInitPromise=>{var moduleOverrides=Object.assign({},Module),arguments_=[],thisProgram="./this.program",quit_=(e,t)=>{throw t},ENVIRONMENT_IS_WEB="object"==typeof window,ENVIRONMENT_IS_WORKER="function"==typeof importScripts,ENVIRONMENT_IS_NODE="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,scriptDirectory="",read_,readAsync,readBinary;function locateFile(e){return Module.locateFile?Module.locateFile(e,scriptDirectory):scriptDirectory+e}if(ENVIRONMENT_IS_NODE){var fs=require("fs"),nodePath=require("path");scriptDirectory=ENVIRONMENT_IS_WORKER?nodePath.dirname(scriptDirectory)+"/":__dirname+"/",read_=(e,t)=>(e=isFileURI(e)?new URL(e):nodePath.normalize(e),fs.readFileSync(e,t?void 0:"utf8")),readBinary=e=>{var t=read_(e,!0);return t.buffer||(t=new Uint8Array(t)),t},readAsync=(e,t,_,s=!0)=>{e=isFileURI(e)?new URL(e):nodePath.normalize(e),fs.readFile(e,s?void 0:"utf8",((e,r)=>{e?_(e):t(s?r.buffer:r)}))},!Module.thisProgram&&process.argv.length>1&&(thisProgram=process.argv[1].replace(/\\/g,"/")),arguments_=process.argv.slice(2),"undefined"!=typeof module&&(module.exports=Module),quit_=(e,t)=>{throw process.exitCode=e,t}}else(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)&&(ENVIRONMENT_IS_WORKER?scriptDirectory=self.location.href:void 0!==document&&document.currentScript&&(scriptDirectory=document.currentScript.src),scriptDirectory=scriptDirectory.startsWith("blob:")?"":scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1),read_=e=>{var t=new XMLHttpRequest;return t.open("GET",e,!1),t.send(null),t.responseText},ENVIRONMENT_IS_WORKER&&(readBinary=e=>{var t=new XMLHttpRequest;return t.open("GET",e,!1),t.responseType="arraybuffer",t.send(null),new Uint8Array(t.response)}),readAsync=(e,t,_)=>{var s=new XMLHttpRequest;s.open("GET",e,!0),s.responseType="arraybuffer",s.onload=()=>{200==s.status||0==s.status&&s.response?t(s.response):_()},s.onerror=_,s.send(null)});var out=Module.print||console.log.bind(console),err=Module.printErr||console.error.bind(console);Object.assign(Module,moduleOverrides),moduleOverrides=null,Module.arguments&&(arguments_=Module.arguments),Module.thisProgram&&(thisProgram=Module.thisProgram),Module.quit&&(quit_=Module.quit);var dynamicLibraries=Module.dynamicLibraries||[],wasmBinary,wasmMemory;Module.wasmBinary&&(wasmBinary=Module.wasmBinary),"object"!=typeof WebAssembly&&abort("no native wasm support detected");var ABORT=!1,EXITSTATUS,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var e=wasmMemory.buffer;Module.HEAP8=HEAP8=new Int8Array(e),Module.HEAP16=HEAP16=new Int16Array(e),Module.HEAPU8=HEAPU8=new Uint8Array(e),Module.HEAPU16=HEAPU16=new Uint16Array(e),Module.HEAP32=HEAP32=new Int32Array(e),Module.HEAPU32=HEAPU32=new Uint32Array(e),Module.HEAPF32=HEAPF32=new Float32Array(e),Module.HEAPF64=HEAPF64=new Float64Array(e)}var INITIAL_MEMORY=Module.INITIAL_MEMORY||33554432;wasmMemory=Module.wasmMemory?Module.wasmMemory:new WebAssembly.Memory({initial:INITIAL_MEMORY/65536,maximum:32768}),updateMemoryViews(),INITIAL_MEMORY=wasmMemory.buffer.byteLength;var __ATPRERUN__=[],__ATINIT__=[],__ATMAIN__=[],__ATPOSTRUN__=[],__RELOC_FUNCS__=[],runtimeInitialized=!1;function preRun(){if(Module.preRun)for("function"==typeof Module.preRun&&(Module.preRun=[Module.preRun]);Module.preRun.length;)addOnPreRun(Module.preRun.shift());callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=!0,callRuntimeCallbacks(__RELOC_FUNCS__),callRuntimeCallbacks(__ATINIT__)}function preMain(){callRuntimeCallbacks(__ATMAIN__)}function postRun(){if(Module.postRun)for("function"==typeof Module.postRun&&(Module.postRun=[Module.postRun]);Module.postRun.length;)addOnPostRun(Module.postRun.shift());callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(e){__ATPRERUN__.unshift(e)}function addOnInit(e){__ATINIT__.unshift(e)}function addOnPostRun(e){__ATPOSTRUN__.unshift(e)}var runDependencies=0,runDependencyWatcher=null,dependenciesFulfilled=null;function getUniqueRunDependency(e){return e}function addRunDependency(e){runDependencies++,Module.monitorRunDependencies?.(runDependencies)}function removeRunDependency(e){if(runDependencies--,Module.monitorRunDependencies?.(runDependencies),0==runDependencies&&(null!==runDependencyWatcher&&(clearInterval(runDependencyWatcher),runDependencyWatcher=null),dependenciesFulfilled)){var t=dependenciesFulfilled;dependenciesFulfilled=null,t()}}function abort(e){throw Module.onAbort?.(e),err(e="Aborted("+e+")"),ABORT=!0,EXITSTATUS=1,e+=". Build with -sASSERTIONS for more info.",new WebAssembly.RuntimeError(e)}var dataURIPrefix="data:application/octet-stream;base64,",isDataURI=e=>e.startsWith(dataURIPrefix),isFileURI=e=>e.startsWith("file://"),wasmBinaryFile;function getBinarySync(e){if(e==wasmBinaryFile&&wasmBinary)return new Uint8Array(wasmBinary);if(readBinary)return readBinary(e);throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(e){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if("function"==typeof fetch&&!isFileURI(e))return fetch(e,{credentials:"same-origin"}).then((t=>{if(!t.ok)throw`failed to load wasm binary file at '${e}'`;return t.arrayBuffer()})).catch((()=>getBinarySync(e)));if(readAsync)return new Promise(((t,_)=>{readAsync(e,(e=>t(new Uint8Array(e))),_)}))}return Promise.resolve().then((()=>getBinarySync(e)))}function instantiateArrayBuffer(e,t,_){return getBinaryPromise(e).then((e=>WebAssembly.instantiate(e,t))).then(_,(e=>{err(`failed to asynchronously prepare wasm: ${e}`),abort(e)}))}function instantiateAsync(e,t,_,s){return e||"function"!=typeof WebAssembly.instantiateStreaming||isDataURI(t)||isFileURI(t)||ENVIRONMENT_IS_NODE||"function"!=typeof fetch?instantiateArrayBuffer(t,_,s):fetch(t,{credentials:"same-origin"}).then((e=>WebAssembly.instantiateStreaming(e,_).then(s,(function(e){return err(`wasm streaming compile failed: ${e}`),err("falling back to ArrayBuffer instantiation"),instantiateArrayBuffer(t,_,s)}))))}function createWasm(){var e={env:wasmImports,wasi_snapshot_preview1:wasmImports,"GOT.mem":new Proxy(wasmImports,GOTHandler),"GOT.func":new Proxy(wasmImports,GOTHandler)};function t(e,t){wasmExports=e.exports,wasmExports=relocateExports(wasmExports,1024);var _=getDylinkMetadata(t);return _.neededDynlibs&&(dynamicLibraries=_.neededDynlibs.concat(dynamicLibraries)),mergeLibSymbols(wasmExports,"main"),LDSO.init(),loadDylibs(),addOnInit(wasmExports.__wasm_call_ctors),__RELOC_FUNCS__.push(wasmExports.__wasm_apply_data_relocs),removeRunDependency("wasm-instantiate"),wasmExports}if(addRunDependency("wasm-instantiate"),Module.instantiateWasm)try{return Module.instantiateWasm(e,t)}catch(e){return err(`Module.instantiateWasm callback failed with error: ${e}`),!1}return instantiateAsync(wasmBinary,wasmBinaryFile,e,(function(e){t(e.instance,e.module)})),{}}wasmBinaryFile="tree-sitter.wasm",isDataURI(wasmBinaryFile)||(wasmBinaryFile=locateFile(wasmBinaryFile));var ASM_CONSTS={};function ExitStatus(e){this.name="ExitStatus",this.message=`Program terminated with exit(${e})`,this.status=e}var GOT={},currentModuleWeakSymbols=new Set([]),GOTHandler={get(e,t){var _=GOT[t];return _||(_=GOT[t]=new WebAssembly.Global({value:"i32",mutable:!0})),currentModuleWeakSymbols.has(t)||(_.required=!0),_}},callRuntimeCallbacks=e=>{for(;e.length>0;)e.shift()(Module)},UTF8Decoder="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,UTF8ArrayToString=(e,t,_)=>{for(var s=t+_,r=t;e[r]&&!(r>=s);)++r;if(r-t>16&&e.buffer&&UTF8Decoder)return UTF8Decoder.decode(e.subarray(t,r));for(var a="";t>10,56320|1023&d)}}else a+=String.fromCharCode((31&o)<<6|n)}else a+=String.fromCharCode(o)}return a},getDylinkMetadata=e=>{var t=0,_=0;function s(){for(var _=0,s=1;;){var r=e[t++];if(_+=(127&r)*s,s*=128,!(128&r))break}return _}function r(){var _=s();return UTF8ArrayToString(e,(t+=_)-_,_)}function a(e,t){if(e)throw new Error(t)}var o="dylink.0";if(e instanceof WebAssembly.Module){var n=WebAssembly.Module.customSections(e,o);0===n.length&&(o="dylink",n=WebAssembly.Module.customSections(e,o)),a(0===n.length,"need dylink section"),_=(e=new Uint8Array(n[0])).length}else{a(!(1836278016==new Uint32Array(new Uint8Array(e.subarray(0,24)).buffer)[0]),"need to see wasm magic number"),a(0!==e[8],"need the dylink section to be first"),t=9;var l=s();_=t+l,o=r()}var d={neededDynlibs:[],tlsExports:new Set,weakImports:new Set};if("dylink"==o){d.memorySize=s(),d.memoryAlign=s(),d.tableSize=s(),d.tableAlign=s();for(var u=s(),m=0;m>1];case"i32":return HEAP32[e>>2];case"i64":abort("to do getValue(i64) use WASM_BIGINT");case"float":return HEAPF32[e>>2];case"double":return HEAPF64[e>>3];case"*":return HEAPU32[e>>2];default:abort(`invalid type for getValue: ${t}`)}}var newDSO=(e,t,_)=>{var s={refcount:1/0,name:e,exports:_,global:!0};return LDSO.loadedLibsByName[e]=s,null!=t&&(LDSO.loadedLibsByHandle[t]=s),s},LDSO={loadedLibsByName:{},loadedLibsByHandle:{},init(){newDSO("__main__",0,wasmImports)}},___heap_base=78096,zeroMemory=(e,t)=>(HEAPU8.fill(0,e,e+t),e),alignMemory=(e,t)=>Math.ceil(e/t)*t,getMemory=e=>{if(runtimeInitialized)return zeroMemory(_malloc(e),e);var t=___heap_base,_=t+alignMemory(e,16);return ___heap_base=_,GOT.__heap_base.value=_,t},isInternalSym=e=>["__cpp_exception","__c_longjmp","__wasm_apply_data_relocs","__dso_handle","__tls_size","__tls_align","__set_stack_limits","_emscripten_tls_init","__wasm_init_tls","__wasm_call_ctors","__start_em_asm","__stop_em_asm","__start_em_js","__stop_em_js"].includes(e)||e.startsWith("__em_js__"),uleb128Encode=(e,t)=>{e<128?t.push(e):t.push(e%128|128,e>>7)},sigToWasmTypes=e=>{for(var t={i:"i32",j:"i64",f:"f32",d:"f64",e:"externref",p:"i32"},_={parameters:[],results:"v"==e[0]?[]:[t[e[0]]]},s=1;s{var _=e.slice(0,1),s=e.slice(1),r={i:127,p:127,j:126,f:125,d:124,e:111};t.push(96),uleb128Encode(s.length,t);for(var a=0;a{if("function"==typeof WebAssembly.Function)return new WebAssembly.Function(sigToWasmTypes(t),e);var _=[1];generateFuncType(t,_);var s=[0,97,115,109,1,0,0,0,1];uleb128Encode(_.length,s),s.push(..._),s.push(2,7,1,1,101,1,102,0,0,7,5,1,1,102,0,0);var r=new WebAssembly.Module(new Uint8Array(s));return new WebAssembly.Instance(r,{e:{f:e}}).exports.f},wasmTableMirror=[],wasmTable=new WebAssembly.Table({initial:27,element:"anyfunc"}),getWasmTableEntry=e=>{var t=wasmTableMirror[e];return t||(e>=wasmTableMirror.length&&(wasmTableMirror.length=e+1),wasmTableMirror[e]=t=wasmTable.get(e)),t},updateTableMap=(e,t)=>{if(functionsInTableMap)for(var _=e;_(functionsInTableMap||(functionsInTableMap=new WeakMap,updateTableMap(0,wasmTable.length)),functionsInTableMap.get(e)||0),freeTableIndexes=[],getEmptyTableSlot=()=>{if(freeTableIndexes.length)return freeTableIndexes.pop();try{wasmTable.grow(1)}catch(e){if(!(e instanceof RangeError))throw e;throw"Unable to grow wasm table. Set ALLOW_TABLE_GROWTH."}return wasmTable.length-1},setWasmTableEntry=(e,t)=>{wasmTable.set(e,t),wasmTableMirror[e]=wasmTable.get(e)},addFunction=(e,t)=>{var _=getFunctionAddress(e);if(_)return _;var s=getEmptyTableSlot();try{setWasmTableEntry(s,e)}catch(_){if(!(_ instanceof TypeError))throw _;var r=convertJsFunctionToWasm(e,t);setWasmTableEntry(s,r)}return functionsInTableMap.set(e,s),s},updateGOT=(e,t)=>{for(var _ in e)if(!isInternalSym(_)){var s=e[_];_.startsWith("orig$")&&(_=_.split("$")[1],t=!0),GOT[_]||=new WebAssembly.Global({value:"i32",mutable:!0}),(t||0==GOT[_].value)&&("function"==typeof s?GOT[_].value=addFunction(s):"number"==typeof s?GOT[_].value=s:err(`unhandled export type for '${_}': ${typeof s}`))}},relocateExports=(e,t,_)=>{var s={};for(var r in e){var a=e[r];"object"==typeof a&&(a=a.value),"number"==typeof a&&(a+=t),s[r]=a}return updateGOT(s,_),s},isSymbolDefined=e=>{var t=wasmImports[e];return!(!t||t.stub)},dynCallLegacy=(e,t,_)=>(0,Module["dynCall_"+e])(t,..._),dynCall=(e,t,_=[])=>e.includes("j")?dynCallLegacy(e,t,_):getWasmTableEntry(t)(..._),createInvokeFunction=e=>function(){var t=stackSave();try{return dynCall(e,arguments[0],Array.prototype.slice.call(arguments,1))}catch(e){if(stackRestore(t),e!==e+0)throw e;_setThrew(1,0)}},resolveGlobalSymbol=(e,t=!1)=>{var _;return t&&"orig$"+e in wasmImports&&(e="orig$"+e),isSymbolDefined(e)?_=wasmImports[e]:e.startsWith("invoke_")&&(_=wasmImports[e]=createInvokeFunction(e.split("_")[1])),{sym:_,name:e}},UTF8ToString=(e,t)=>e?UTF8ArrayToString(HEAPU8,e,t):"",loadWebAssemblyModule=(binary,flags,libName,localScope,handle)=>{var metadata=getDylinkMetadata(binary);function loadModule(){var firstLoad=!handle||!HEAP8[handle+8];if(firstLoad){var memAlign=Math.pow(2,metadata.memoryAlign),memoryBase=metadata.memorySize?alignMemory(getMemory(metadata.memorySize+memAlign),memAlign):0,tableBase=metadata.tableSize?wasmTable.length:0;handle&&(HEAP8[handle+8]=1,HEAPU32[handle+12>>2]=memoryBase,HEAP32[handle+16>>2]=metadata.memorySize,HEAPU32[handle+20>>2]=tableBase,HEAP32[handle+24>>2]=metadata.tableSize)}else memoryBase=HEAPU32[handle+12>>2],tableBase=HEAPU32[handle+20>>2];var tableGrowthNeeded=tableBase+metadata.tableSize-wasmTable.length,moduleExports;function resolveSymbol(e){var t=resolveGlobalSymbol(e).sym;return!t&&localScope&&(t=localScope[e]),t||(t=moduleExports[e]),t}tableGrowthNeeded>0&&wasmTable.grow(tableGrowthNeeded);var proxyHandler={get(e,t){switch(t){case"__memory_base":return memoryBase;case"__table_base":return tableBase}if(t in wasmImports&&!wasmImports[t].stub)return wasmImports[t];var _;t in e||(e[t]=(...e)=>(_||=resolveSymbol(t),_(...e)));return e[t]}},proxy=new Proxy({},proxyHandler),info={"GOT.mem":new Proxy({},GOTHandler),"GOT.func":new Proxy({},GOTHandler),env:proxy,wasi_snapshot_preview1:proxy};function postInstantiation(module,instance){function addEmAsm(addr,body){for(var args=[],arity=0;arity<16&&-1!=body.indexOf("$"+arity);arity++)args.push("$"+arity);args=args.join(",");var func=`(${args}) => { ${body} };`;ASM_CONSTS[start]=eval(func)}if(updateTableMap(tableBase,metadata.tableSize),moduleExports=relocateExports(instance.exports,memoryBase),flags.allowUndefined||reportUndefinedSymbols(),"__start_em_asm"in moduleExports)for(var start=moduleExports.__start_em_asm,stop=moduleExports.__stop_em_asm;start ${body};`;moduleExports[name]=eval(func)}for(var name in moduleExports)if(name.startsWith("__em_js__")){var start=moduleExports[name],jsString=UTF8ToString(start),parts=jsString.split("<::>");addEmJs(name.replace("__em_js__",""),parts[0],parts[1]),delete moduleExports[name]}var applyRelocs=moduleExports.__wasm_apply_data_relocs;applyRelocs&&(runtimeInitialized?applyRelocs():__RELOC_FUNCS__.push(applyRelocs));var init=moduleExports.__wasm_call_ctors;return init&&(runtimeInitialized?init():__ATINIT__.push(init)),moduleExports}if(flags.loadAsync){if(binary instanceof WebAssembly.Module){var instance=new WebAssembly.Instance(binary,info);return Promise.resolve(postInstantiation(binary,instance))}return WebAssembly.instantiate(binary,info).then((e=>postInstantiation(e.module,e.instance)))}var module=binary instanceof WebAssembly.Module?binary:new WebAssembly.Module(binary),instance=new WebAssembly.Instance(module,info);return postInstantiation(module,instance)}return currentModuleWeakSymbols=metadata.weakImports,flags.loadAsync?metadata.neededDynlibs.reduce(((e,t)=>e.then((()=>loadDynamicLibrary(t,flags)))),Promise.resolve()).then(loadModule):(metadata.neededDynlibs.forEach((e=>loadDynamicLibrary(e,flags,localScope))),loadModule())},mergeLibSymbols=(e,t)=>{for(var[_,s]of Object.entries(e)){const e=e=>{isSymbolDefined(e)||(wasmImports[e]=s)};e(_);const t="__main_argc_argv";"main"==_&&e(t),_==t&&e("main"),_.startsWith("dynCall_")&&!Module.hasOwnProperty(_)&&(Module[_]=s)}},asyncLoad=(e,t,_,s)=>{var r=s?"":getUniqueRunDependency(`al ${e}`);readAsync(e,(e=>{t(new Uint8Array(e)),r&&removeRunDependency(r)}),(t=>{if(!_)throw`Loading data file "${e}" failed.`;_()})),r&&addRunDependency(r)};function loadDynamicLibrary(e,t={global:!0,nodelete:!0},_,s){var r=LDSO.loadedLibsByName[e];if(r)return t.global?r.global||(r.global=!0,mergeLibSymbols(r.exports,e)):_&&Object.assign(_,r.exports),t.nodelete&&r.refcount!==1/0&&(r.refcount=1/0),r.refcount++,s&&(LDSO.loadedLibsByHandle[s]=r),!t.loadAsync||Promise.resolve(!0);function a(){if(s){var _=HEAPU32[s+28>>2],r=HEAPU32[s+32>>2];if(_&&r){var a=HEAP8.slice(_,_+r);return t.loadAsync?Promise.resolve(a):a}}var o=locateFile(e);if(t.loadAsync)return new Promise((function(e,t){asyncLoad(o,e,t)}));if(!readBinary)throw new Error(`${o}: file not found, and synchronous loading of external files is not available`);return readBinary(o)}function o(){return t.loadAsync?a().then((r=>loadWebAssemblyModule(r,t,e,_,s))):loadWebAssemblyModule(a(),t,e,_,s)}function n(t){r.global?mergeLibSymbols(t,e):_&&Object.assign(_,t),r.exports=t}return(r=newDSO(e,s,"loading")).refcount=t.nodelete?1/0:1,r.global=t.global,t.loadAsync?o().then((e=>(n(e),!0))):(n(o()),!0)}var reportUndefinedSymbols=()=>{for(var[e,t]of Object.entries(GOT))if(0==t.value){var _=resolveGlobalSymbol(e,!0).sym;if(!_&&!t.required)continue;if("function"==typeof _)t.value=addFunction(_,_.sig);else{if("number"!=typeof _)throw new Error(`bad export type for '${e}': ${typeof _}`);t.value=_}}},loadDylibs=()=>{dynamicLibraries.length?(addRunDependency("loadDylibs"),dynamicLibraries.reduce(((e,t)=>e.then((()=>loadDynamicLibrary(t,{loadAsync:!0,global:!0,nodelete:!0,allowUndefined:!0})))),Promise.resolve()).then((()=>{reportUndefinedSymbols(),removeRunDependency("loadDylibs")}))):reportUndefinedSymbols()},noExitRuntime=Module.noExitRuntime||!0;function setValue(e,t,_="i8"){switch(_.endsWith("*")&&(_="*"),_){case"i1":case"i8":HEAP8[e]=t;break;case"i16":HEAP16[e>>1]=t;break;case"i32":HEAP32[e>>2]=t;break;case"i64":abort("to do setValue(i64) use WASM_BIGINT");case"float":HEAPF32[e>>2]=t;break;case"double":HEAPF64[e>>3]=t;break;case"*":HEAPU32[e>>2]=t;break;default:abort(`invalid type for setValue: ${_}`)}}var ___memory_base=new WebAssembly.Global({value:"i32",mutable:!1},1024),___stack_pointer=new WebAssembly.Global({value:"i32",mutable:!0},78096),___table_base=new WebAssembly.Global({value:"i32",mutable:!1},1),nowIsMonotonic=1,__emscripten_get_now_is_monotonic=()=>nowIsMonotonic;__emscripten_get_now_is_monotonic.sig="i";var _abort=()=>{abort("")};_abort.sig="v";var _emscripten_date_now=()=>Date.now(),_emscripten_get_now;_emscripten_date_now.sig="d",_emscripten_get_now=()=>performance.now(),_emscripten_get_now.sig="d";var _emscripten_memcpy_js=(e,t,_)=>HEAPU8.copyWithin(e,t,t+_);_emscripten_memcpy_js.sig="vppp";var getHeapMax=()=>2147483648,growMemory=e=>{var t=(e-wasmMemory.buffer.byteLength+65535)/65536;try{return wasmMemory.grow(t),updateMemoryViews(),1}catch(e){}},_emscripten_resize_heap=e=>{var t=HEAPU8.length;e>>>=0;var _=getHeapMax();if(e>_)return!1;for(var s,r,a=1;a<=4;a*=2){var o=t*(1+.2/a);o=Math.min(o,e+100663296);var n=Math.min(_,(s=Math.max(e,o))+((r=65536)-s%r)%r);if(growMemory(n))return!0}return!1};_emscripten_resize_heap.sig="ip";var _fd_close=e=>52;_fd_close.sig="ii";var convertI32PairToI53Checked=(e,t)=>t+2097152>>>0<4194305-!!e?(e>>>0)+4294967296*t:NaN;function _fd_seek(e,t,_,s,r){convertI32PairToI53Checked(t,_);return 70}_fd_seek.sig="iiiiip";var printCharBuffers=[null,[],[]],printChar=(e,t)=>{var _=printCharBuffers[e];0===t||10===t?((1===e?out:err)(UTF8ArrayToString(_,0)),_.length=0):_.push(t)},SYSCALLS={varargs:void 0,get(){var e=HEAP32[+SYSCALLS.varargs>>2];return SYSCALLS.varargs+=4,e},getp:()=>SYSCALLS.get(),getStr:e=>UTF8ToString(e)},_fd_write=(e,t,_,s)=>{for(var r=0,a=0;a<_;a++){var o=HEAPU32[t>>2],n=HEAPU32[t+4>>2];t+=8;for(var l=0;l>2]=r,0};function _tree_sitter_log_callback(e,t){if(currentLogCallback){const _=UTF8ToString(t);currentLogCallback(_,0!==e)}}function _tree_sitter_parse_callback(e,t,_,s,r){const a=currentParseCallback(t,{row:_,column:s});"string"==typeof a?(setValue(r,a.length,"i32"),stringToUTF16(a,e,10240)):setValue(r,0,"i32")}_fd_write.sig="iippp";var runtimeKeepaliveCounter=0,keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0,_proc_exit=e=>{EXITSTATUS=e,keepRuntimeAlive()||(Module.onExit?.(e),ABORT=!0),quit_(e,new ExitStatus(e))};_proc_exit.sig="vi";var exitJS=(e,t)=>{EXITSTATUS=e,_proc_exit(e)},handleException=e=>{if(e instanceof ExitStatus||"unwind"==e)return EXITSTATUS;quit_(1,e)},lengthBytesUTF8=e=>{for(var t=0,_=0;_=55296&&s<=57343?(t+=4,++_):t+=3}return t},stringToUTF8Array=(e,t,_,s)=>{if(!(s>0))return 0;for(var r=_,a=_+s-1,o=0;o=55296&&n<=57343)n=65536+((1023&n)<<10)|1023&e.charCodeAt(++o);if(n<=127){if(_>=a)break;t[_++]=n}else if(n<=2047){if(_+1>=a)break;t[_++]=192|n>>6,t[_++]=128|63&n}else if(n<=65535){if(_+2>=a)break;t[_++]=224|n>>12,t[_++]=128|n>>6&63,t[_++]=128|63&n}else{if(_+3>=a)break;t[_++]=240|n>>18,t[_++]=128|n>>12&63,t[_++]=128|n>>6&63,t[_++]=128|63&n}}return t[_]=0,_-r},stringToUTF8=(e,t,_)=>stringToUTF8Array(e,HEAPU8,t,_),stringToUTF8OnStack=e=>{var t=lengthBytesUTF8(e)+1,_=stackAlloc(t);return stringToUTF8(e,_,t),_},stringToUTF16=(e,t,_)=>{if(_??=2147483647,_<2)return 0;for(var s=t,r=(_-=2)<2*e.length?_/2:e.length,a=0;a>1]=o,t+=2}return HEAP16[t>>1]=0,t-s},AsciiToString=e=>{for(var t="";;){var _=HEAPU8[e++];if(!_)return t;t+=String.fromCharCode(_)}},wasmImports={__heap_base:___heap_base,__indirect_function_table:wasmTable,__memory_base:___memory_base,__stack_pointer:___stack_pointer,__table_base:___table_base,_emscripten_get_now_is_monotonic:__emscripten_get_now_is_monotonic,abort:_abort,emscripten_get_now:_emscripten_get_now,emscripten_memcpy_js:_emscripten_memcpy_js,emscripten_resize_heap:_emscripten_resize_heap,fd_close:_fd_close,fd_seek:_fd_seek,fd_write:_fd_write,memory:wasmMemory,tree_sitter_log_callback:_tree_sitter_log_callback,tree_sitter_parse_callback:_tree_sitter_parse_callback},wasmExports=createWasm(),___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports.__wasm_call_ctors)(),___wasm_apply_data_relocs=()=>(___wasm_apply_data_relocs=wasmExports.__wasm_apply_data_relocs)(),_malloc=Module._malloc=e=>(_malloc=Module._malloc=wasmExports.malloc)(e),_calloc=Module._calloc=(e,t)=>(_calloc=Module._calloc=wasmExports.calloc)(e,t),_realloc=Module._realloc=(e,t)=>(_realloc=Module._realloc=wasmExports.realloc)(e,t),_free=Module._free=e=>(_free=Module._free=wasmExports.free)(e),_ts_language_symbol_count=Module._ts_language_symbol_count=e=>(_ts_language_symbol_count=Module._ts_language_symbol_count=wasmExports.ts_language_symbol_count)(e),_ts_language_state_count=Module._ts_language_state_count=e=>(_ts_language_state_count=Module._ts_language_state_count=wasmExports.ts_language_state_count)(e),_ts_language_version=Module._ts_language_version=e=>(_ts_language_version=Module._ts_language_version=wasmExports.ts_language_version)(e),_ts_language_field_count=Module._ts_language_field_count=e=>(_ts_language_field_count=Module._ts_language_field_count=wasmExports.ts_language_field_count)(e),_ts_language_next_state=Module._ts_language_next_state=(e,t,_)=>(_ts_language_next_state=Module._ts_language_next_state=wasmExports.ts_language_next_state)(e,t,_),_ts_language_symbol_name=Module._ts_language_symbol_name=(e,t)=>(_ts_language_symbol_name=Module._ts_language_symbol_name=wasmExports.ts_language_symbol_name)(e,t),_ts_language_symbol_for_name=Module._ts_language_symbol_for_name=(e,t,_,s)=>(_ts_language_symbol_for_name=Module._ts_language_symbol_for_name=wasmExports.ts_language_symbol_for_name)(e,t,_,s),_strncmp=Module._strncmp=(e,t,_)=>(_strncmp=Module._strncmp=wasmExports.strncmp)(e,t,_),_ts_language_symbol_type=Module._ts_language_symbol_type=(e,t)=>(_ts_language_symbol_type=Module._ts_language_symbol_type=wasmExports.ts_language_symbol_type)(e,t),_ts_language_field_name_for_id=Module._ts_language_field_name_for_id=(e,t)=>(_ts_language_field_name_for_id=Module._ts_language_field_name_for_id=wasmExports.ts_language_field_name_for_id)(e,t),_ts_lookahead_iterator_new=Module._ts_lookahead_iterator_new=(e,t)=>(_ts_lookahead_iterator_new=Module._ts_lookahead_iterator_new=wasmExports.ts_lookahead_iterator_new)(e,t),_ts_lookahead_iterator_delete=Module._ts_lookahead_iterator_delete=e=>(_ts_lookahead_iterator_delete=Module._ts_lookahead_iterator_delete=wasmExports.ts_lookahead_iterator_delete)(e),_ts_lookahead_iterator_reset_state=Module._ts_lookahead_iterator_reset_state=(e,t)=>(_ts_lookahead_iterator_reset_state=Module._ts_lookahead_iterator_reset_state=wasmExports.ts_lookahead_iterator_reset_state)(e,t),_ts_lookahead_iterator_reset=Module._ts_lookahead_iterator_reset=(e,t,_)=>(_ts_lookahead_iterator_reset=Module._ts_lookahead_iterator_reset=wasmExports.ts_lookahead_iterator_reset)(e,t,_),_ts_lookahead_iterator_next=Module._ts_lookahead_iterator_next=e=>(_ts_lookahead_iterator_next=Module._ts_lookahead_iterator_next=wasmExports.ts_lookahead_iterator_next)(e),_ts_lookahead_iterator_current_symbol=Module._ts_lookahead_iterator_current_symbol=e=>(_ts_lookahead_iterator_current_symbol=Module._ts_lookahead_iterator_current_symbol=wasmExports.ts_lookahead_iterator_current_symbol)(e),_memset=Module._memset=(e,t,_)=>(_memset=Module._memset=wasmExports.memset)(e,t,_),_memcpy=Module._memcpy=(e,t,_)=>(_memcpy=Module._memcpy=wasmExports.memcpy)(e,t,_),_ts_parser_delete=Module._ts_parser_delete=e=>(_ts_parser_delete=Module._ts_parser_delete=wasmExports.ts_parser_delete)(e),_ts_parser_reset=Module._ts_parser_reset=e=>(_ts_parser_reset=Module._ts_parser_reset=wasmExports.ts_parser_reset)(e),_ts_parser_set_language=Module._ts_parser_set_language=(e,t)=>(_ts_parser_set_language=Module._ts_parser_set_language=wasmExports.ts_parser_set_language)(e,t),_ts_parser_timeout_micros=Module._ts_parser_timeout_micros=e=>(_ts_parser_timeout_micros=Module._ts_parser_timeout_micros=wasmExports.ts_parser_timeout_micros)(e),_ts_parser_set_timeout_micros=Module._ts_parser_set_timeout_micros=(e,t,_)=>(_ts_parser_set_timeout_micros=Module._ts_parser_set_timeout_micros=wasmExports.ts_parser_set_timeout_micros)(e,t,_),_ts_parser_set_included_ranges=Module._ts_parser_set_included_ranges=(e,t,_)=>(_ts_parser_set_included_ranges=Module._ts_parser_set_included_ranges=wasmExports.ts_parser_set_included_ranges)(e,t,_),_memmove=Module._memmove=(e,t,_)=>(_memmove=Module._memmove=wasmExports.memmove)(e,t,_),_memcmp=Module._memcmp=(e,t,_)=>(_memcmp=Module._memcmp=wasmExports.memcmp)(e,t,_),_ts_query_new=Module._ts_query_new=(e,t,_,s,r)=>(_ts_query_new=Module._ts_query_new=wasmExports.ts_query_new)(e,t,_,s,r),_ts_query_delete=Module._ts_query_delete=e=>(_ts_query_delete=Module._ts_query_delete=wasmExports.ts_query_delete)(e),_iswspace=Module._iswspace=e=>(_iswspace=Module._iswspace=wasmExports.iswspace)(e),_iswalnum=Module._iswalnum=e=>(_iswalnum=Module._iswalnum=wasmExports.iswalnum)(e),_ts_query_pattern_count=Module._ts_query_pattern_count=e=>(_ts_query_pattern_count=Module._ts_query_pattern_count=wasmExports.ts_query_pattern_count)(e),_ts_query_capture_count=Module._ts_query_capture_count=e=>(_ts_query_capture_count=Module._ts_query_capture_count=wasmExports.ts_query_capture_count)(e),_ts_query_string_count=Module._ts_query_string_count=e=>(_ts_query_string_count=Module._ts_query_string_count=wasmExports.ts_query_string_count)(e),_ts_query_capture_name_for_id=Module._ts_query_capture_name_for_id=(e,t,_)=>(_ts_query_capture_name_for_id=Module._ts_query_capture_name_for_id=wasmExports.ts_query_capture_name_for_id)(e,t,_),_ts_query_string_value_for_id=Module._ts_query_string_value_for_id=(e,t,_)=>(_ts_query_string_value_for_id=Module._ts_query_string_value_for_id=wasmExports.ts_query_string_value_for_id)(e,t,_),_ts_query_predicates_for_pattern=Module._ts_query_predicates_for_pattern=(e,t,_)=>(_ts_query_predicates_for_pattern=Module._ts_query_predicates_for_pattern=wasmExports.ts_query_predicates_for_pattern)(e,t,_),_ts_query_disable_capture=Module._ts_query_disable_capture=(e,t,_)=>(_ts_query_disable_capture=Module._ts_query_disable_capture=wasmExports.ts_query_disable_capture)(e,t,_),_ts_tree_copy=Module._ts_tree_copy=e=>(_ts_tree_copy=Module._ts_tree_copy=wasmExports.ts_tree_copy)(e),_ts_tree_delete=Module._ts_tree_delete=e=>(_ts_tree_delete=Module._ts_tree_delete=wasmExports.ts_tree_delete)(e),_ts_init=Module._ts_init=()=>(_ts_init=Module._ts_init=wasmExports.ts_init)(),_ts_parser_new_wasm=Module._ts_parser_new_wasm=()=>(_ts_parser_new_wasm=Module._ts_parser_new_wasm=wasmExports.ts_parser_new_wasm)(),_ts_parser_enable_logger_wasm=Module._ts_parser_enable_logger_wasm=(e,t)=>(_ts_parser_enable_logger_wasm=Module._ts_parser_enable_logger_wasm=wasmExports.ts_parser_enable_logger_wasm)(e,t),_ts_parser_parse_wasm=Module._ts_parser_parse_wasm=(e,t,_,s,r)=>(_ts_parser_parse_wasm=Module._ts_parser_parse_wasm=wasmExports.ts_parser_parse_wasm)(e,t,_,s,r),_ts_parser_included_ranges_wasm=Module._ts_parser_included_ranges_wasm=e=>(_ts_parser_included_ranges_wasm=Module._ts_parser_included_ranges_wasm=wasmExports.ts_parser_included_ranges_wasm)(e),_ts_language_type_is_named_wasm=Module._ts_language_type_is_named_wasm=(e,t)=>(_ts_language_type_is_named_wasm=Module._ts_language_type_is_named_wasm=wasmExports.ts_language_type_is_named_wasm)(e,t),_ts_language_type_is_visible_wasm=Module._ts_language_type_is_visible_wasm=(e,t)=>(_ts_language_type_is_visible_wasm=Module._ts_language_type_is_visible_wasm=wasmExports.ts_language_type_is_visible_wasm)(e,t),_ts_tree_root_node_wasm=Module._ts_tree_root_node_wasm=e=>(_ts_tree_root_node_wasm=Module._ts_tree_root_node_wasm=wasmExports.ts_tree_root_node_wasm)(e),_ts_tree_root_node_with_offset_wasm=Module._ts_tree_root_node_with_offset_wasm=e=>(_ts_tree_root_node_with_offset_wasm=Module._ts_tree_root_node_with_offset_wasm=wasmExports.ts_tree_root_node_with_offset_wasm)(e),_ts_tree_edit_wasm=Module._ts_tree_edit_wasm=e=>(_ts_tree_edit_wasm=Module._ts_tree_edit_wasm=wasmExports.ts_tree_edit_wasm)(e),_ts_tree_included_ranges_wasm=Module._ts_tree_included_ranges_wasm=e=>(_ts_tree_included_ranges_wasm=Module._ts_tree_included_ranges_wasm=wasmExports.ts_tree_included_ranges_wasm)(e),_ts_tree_get_changed_ranges_wasm=Module._ts_tree_get_changed_ranges_wasm=(e,t)=>(_ts_tree_get_changed_ranges_wasm=Module._ts_tree_get_changed_ranges_wasm=wasmExports.ts_tree_get_changed_ranges_wasm)(e,t),_ts_tree_cursor_new_wasm=Module._ts_tree_cursor_new_wasm=e=>(_ts_tree_cursor_new_wasm=Module._ts_tree_cursor_new_wasm=wasmExports.ts_tree_cursor_new_wasm)(e),_ts_tree_cursor_delete_wasm=Module._ts_tree_cursor_delete_wasm=e=>(_ts_tree_cursor_delete_wasm=Module._ts_tree_cursor_delete_wasm=wasmExports.ts_tree_cursor_delete_wasm)(e),_ts_tree_cursor_reset_wasm=Module._ts_tree_cursor_reset_wasm=e=>(_ts_tree_cursor_reset_wasm=Module._ts_tree_cursor_reset_wasm=wasmExports.ts_tree_cursor_reset_wasm)(e),_ts_tree_cursor_reset_to_wasm=Module._ts_tree_cursor_reset_to_wasm=(e,t)=>(_ts_tree_cursor_reset_to_wasm=Module._ts_tree_cursor_reset_to_wasm=wasmExports.ts_tree_cursor_reset_to_wasm)(e,t),_ts_tree_cursor_goto_first_child_wasm=Module._ts_tree_cursor_goto_first_child_wasm=e=>(_ts_tree_cursor_goto_first_child_wasm=Module._ts_tree_cursor_goto_first_child_wasm=wasmExports.ts_tree_cursor_goto_first_child_wasm)(e),_ts_tree_cursor_goto_last_child_wasm=Module._ts_tree_cursor_goto_last_child_wasm=e=>(_ts_tree_cursor_goto_last_child_wasm=Module._ts_tree_cursor_goto_last_child_wasm=wasmExports.ts_tree_cursor_goto_last_child_wasm)(e),_ts_tree_cursor_goto_first_child_for_index_wasm=Module._ts_tree_cursor_goto_first_child_for_index_wasm=e=>(_ts_tree_cursor_goto_first_child_for_index_wasm=Module._ts_tree_cursor_goto_first_child_for_index_wasm=wasmExports.ts_tree_cursor_goto_first_child_for_index_wasm)(e),_ts_tree_cursor_goto_first_child_for_position_wasm=Module._ts_tree_cursor_goto_first_child_for_position_wasm=e=>(_ts_tree_cursor_goto_first_child_for_position_wasm=Module._ts_tree_cursor_goto_first_child_for_position_wasm=wasmExports.ts_tree_cursor_goto_first_child_for_position_wasm)(e),_ts_tree_cursor_goto_next_sibling_wasm=Module._ts_tree_cursor_goto_next_sibling_wasm=e=>(_ts_tree_cursor_goto_next_sibling_wasm=Module._ts_tree_cursor_goto_next_sibling_wasm=wasmExports.ts_tree_cursor_goto_next_sibling_wasm)(e),_ts_tree_cursor_goto_previous_sibling_wasm=Module._ts_tree_cursor_goto_previous_sibling_wasm=e=>(_ts_tree_cursor_goto_previous_sibling_wasm=Module._ts_tree_cursor_goto_previous_sibling_wasm=wasmExports.ts_tree_cursor_goto_previous_sibling_wasm)(e),_ts_tree_cursor_goto_descendant_wasm=Module._ts_tree_cursor_goto_descendant_wasm=(e,t)=>(_ts_tree_cursor_goto_descendant_wasm=Module._ts_tree_cursor_goto_descendant_wasm=wasmExports.ts_tree_cursor_goto_descendant_wasm)(e,t),_ts_tree_cursor_goto_parent_wasm=Module._ts_tree_cursor_goto_parent_wasm=e=>(_ts_tree_cursor_goto_parent_wasm=Module._ts_tree_cursor_goto_parent_wasm=wasmExports.ts_tree_cursor_goto_parent_wasm)(e),_ts_tree_cursor_current_node_type_id_wasm=Module._ts_tree_cursor_current_node_type_id_wasm=e=>(_ts_tree_cursor_current_node_type_id_wasm=Module._ts_tree_cursor_current_node_type_id_wasm=wasmExports.ts_tree_cursor_current_node_type_id_wasm)(e),_ts_tree_cursor_current_node_state_id_wasm=Module._ts_tree_cursor_current_node_state_id_wasm=e=>(_ts_tree_cursor_current_node_state_id_wasm=Module._ts_tree_cursor_current_node_state_id_wasm=wasmExports.ts_tree_cursor_current_node_state_id_wasm)(e),_ts_tree_cursor_current_node_is_named_wasm=Module._ts_tree_cursor_current_node_is_named_wasm=e=>(_ts_tree_cursor_current_node_is_named_wasm=Module._ts_tree_cursor_current_node_is_named_wasm=wasmExports.ts_tree_cursor_current_node_is_named_wasm)(e),_ts_tree_cursor_current_node_is_missing_wasm=Module._ts_tree_cursor_current_node_is_missing_wasm=e=>(_ts_tree_cursor_current_node_is_missing_wasm=Module._ts_tree_cursor_current_node_is_missing_wasm=wasmExports.ts_tree_cursor_current_node_is_missing_wasm)(e),_ts_tree_cursor_current_node_id_wasm=Module._ts_tree_cursor_current_node_id_wasm=e=>(_ts_tree_cursor_current_node_id_wasm=Module._ts_tree_cursor_current_node_id_wasm=wasmExports.ts_tree_cursor_current_node_id_wasm)(e),_ts_tree_cursor_start_position_wasm=Module._ts_tree_cursor_start_position_wasm=e=>(_ts_tree_cursor_start_position_wasm=Module._ts_tree_cursor_start_position_wasm=wasmExports.ts_tree_cursor_start_position_wasm)(e),_ts_tree_cursor_end_position_wasm=Module._ts_tree_cursor_end_position_wasm=e=>(_ts_tree_cursor_end_position_wasm=Module._ts_tree_cursor_end_position_wasm=wasmExports.ts_tree_cursor_end_position_wasm)(e),_ts_tree_cursor_start_index_wasm=Module._ts_tree_cursor_start_index_wasm=e=>(_ts_tree_cursor_start_index_wasm=Module._ts_tree_cursor_start_index_wasm=wasmExports.ts_tree_cursor_start_index_wasm)(e),_ts_tree_cursor_end_index_wasm=Module._ts_tree_cursor_end_index_wasm=e=>(_ts_tree_cursor_end_index_wasm=Module._ts_tree_cursor_end_index_wasm=wasmExports.ts_tree_cursor_end_index_wasm)(e),_ts_tree_cursor_current_field_id_wasm=Module._ts_tree_cursor_current_field_id_wasm=e=>(_ts_tree_cursor_current_field_id_wasm=Module._ts_tree_cursor_current_field_id_wasm=wasmExports.ts_tree_cursor_current_field_id_wasm)(e),_ts_tree_cursor_current_depth_wasm=Module._ts_tree_cursor_current_depth_wasm=e=>(_ts_tree_cursor_current_depth_wasm=Module._ts_tree_cursor_current_depth_wasm=wasmExports.ts_tree_cursor_current_depth_wasm)(e),_ts_tree_cursor_current_descendant_index_wasm=Module._ts_tree_cursor_current_descendant_index_wasm=e=>(_ts_tree_cursor_current_descendant_index_wasm=Module._ts_tree_cursor_current_descendant_index_wasm=wasmExports.ts_tree_cursor_current_descendant_index_wasm)(e),_ts_tree_cursor_current_node_wasm=Module._ts_tree_cursor_current_node_wasm=e=>(_ts_tree_cursor_current_node_wasm=Module._ts_tree_cursor_current_node_wasm=wasmExports.ts_tree_cursor_current_node_wasm)(e),_ts_node_symbol_wasm=Module._ts_node_symbol_wasm=e=>(_ts_node_symbol_wasm=Module._ts_node_symbol_wasm=wasmExports.ts_node_symbol_wasm)(e),_ts_node_field_name_for_child_wasm=Module._ts_node_field_name_for_child_wasm=(e,t)=>(_ts_node_field_name_for_child_wasm=Module._ts_node_field_name_for_child_wasm=wasmExports.ts_node_field_name_for_child_wasm)(e,t),_ts_node_children_by_field_id_wasm=Module._ts_node_children_by_field_id_wasm=(e,t)=>(_ts_node_children_by_field_id_wasm=Module._ts_node_children_by_field_id_wasm=wasmExports.ts_node_children_by_field_id_wasm)(e,t),_ts_node_first_child_for_byte_wasm=Module._ts_node_first_child_for_byte_wasm=e=>(_ts_node_first_child_for_byte_wasm=Module._ts_node_first_child_for_byte_wasm=wasmExports.ts_node_first_child_for_byte_wasm)(e),_ts_node_first_named_child_for_byte_wasm=Module._ts_node_first_named_child_for_byte_wasm=e=>(_ts_node_first_named_child_for_byte_wasm=Module._ts_node_first_named_child_for_byte_wasm=wasmExports.ts_node_first_named_child_for_byte_wasm)(e),_ts_node_grammar_symbol_wasm=Module._ts_node_grammar_symbol_wasm=e=>(_ts_node_grammar_symbol_wasm=Module._ts_node_grammar_symbol_wasm=wasmExports.ts_node_grammar_symbol_wasm)(e),_ts_node_child_count_wasm=Module._ts_node_child_count_wasm=e=>(_ts_node_child_count_wasm=Module._ts_node_child_count_wasm=wasmExports.ts_node_child_count_wasm)(e),_ts_node_named_child_count_wasm=Module._ts_node_named_child_count_wasm=e=>(_ts_node_named_child_count_wasm=Module._ts_node_named_child_count_wasm=wasmExports.ts_node_named_child_count_wasm)(e),_ts_node_child_wasm=Module._ts_node_child_wasm=(e,t)=>(_ts_node_child_wasm=Module._ts_node_child_wasm=wasmExports.ts_node_child_wasm)(e,t),_ts_node_named_child_wasm=Module._ts_node_named_child_wasm=(e,t)=>(_ts_node_named_child_wasm=Module._ts_node_named_child_wasm=wasmExports.ts_node_named_child_wasm)(e,t),_ts_node_child_by_field_id_wasm=Module._ts_node_child_by_field_id_wasm=(e,t)=>(_ts_node_child_by_field_id_wasm=Module._ts_node_child_by_field_id_wasm=wasmExports.ts_node_child_by_field_id_wasm)(e,t),_ts_node_next_sibling_wasm=Module._ts_node_next_sibling_wasm=e=>(_ts_node_next_sibling_wasm=Module._ts_node_next_sibling_wasm=wasmExports.ts_node_next_sibling_wasm)(e),_ts_node_prev_sibling_wasm=Module._ts_node_prev_sibling_wasm=e=>(_ts_node_prev_sibling_wasm=Module._ts_node_prev_sibling_wasm=wasmExports.ts_node_prev_sibling_wasm)(e),_ts_node_next_named_sibling_wasm=Module._ts_node_next_named_sibling_wasm=e=>(_ts_node_next_named_sibling_wasm=Module._ts_node_next_named_sibling_wasm=wasmExports.ts_node_next_named_sibling_wasm)(e),_ts_node_prev_named_sibling_wasm=Module._ts_node_prev_named_sibling_wasm=e=>(_ts_node_prev_named_sibling_wasm=Module._ts_node_prev_named_sibling_wasm=wasmExports.ts_node_prev_named_sibling_wasm)(e),_ts_node_descendant_count_wasm=Module._ts_node_descendant_count_wasm=e=>(_ts_node_descendant_count_wasm=Module._ts_node_descendant_count_wasm=wasmExports.ts_node_descendant_count_wasm)(e),_ts_node_parent_wasm=Module._ts_node_parent_wasm=e=>(_ts_node_parent_wasm=Module._ts_node_parent_wasm=wasmExports.ts_node_parent_wasm)(e),_ts_node_descendant_for_index_wasm=Module._ts_node_descendant_for_index_wasm=e=>(_ts_node_descendant_for_index_wasm=Module._ts_node_descendant_for_index_wasm=wasmExports.ts_node_descendant_for_index_wasm)(e),_ts_node_named_descendant_for_index_wasm=Module._ts_node_named_descendant_for_index_wasm=e=>(_ts_node_named_descendant_for_index_wasm=Module._ts_node_named_descendant_for_index_wasm=wasmExports.ts_node_named_descendant_for_index_wasm)(e),_ts_node_descendant_for_position_wasm=Module._ts_node_descendant_for_position_wasm=e=>(_ts_node_descendant_for_position_wasm=Module._ts_node_descendant_for_position_wasm=wasmExports.ts_node_descendant_for_position_wasm)(e),_ts_node_named_descendant_for_position_wasm=Module._ts_node_named_descendant_for_position_wasm=e=>(_ts_node_named_descendant_for_position_wasm=Module._ts_node_named_descendant_for_position_wasm=wasmExports.ts_node_named_descendant_for_position_wasm)(e),_ts_node_start_point_wasm=Module._ts_node_start_point_wasm=e=>(_ts_node_start_point_wasm=Module._ts_node_start_point_wasm=wasmExports.ts_node_start_point_wasm)(e),_ts_node_end_point_wasm=Module._ts_node_end_point_wasm=e=>(_ts_node_end_point_wasm=Module._ts_node_end_point_wasm=wasmExports.ts_node_end_point_wasm)(e),_ts_node_start_index_wasm=Module._ts_node_start_index_wasm=e=>(_ts_node_start_index_wasm=Module._ts_node_start_index_wasm=wasmExports.ts_node_start_index_wasm)(e),_ts_node_end_index_wasm=Module._ts_node_end_index_wasm=e=>(_ts_node_end_index_wasm=Module._ts_node_end_index_wasm=wasmExports.ts_node_end_index_wasm)(e),_ts_node_to_string_wasm=Module._ts_node_to_string_wasm=e=>(_ts_node_to_string_wasm=Module._ts_node_to_string_wasm=wasmExports.ts_node_to_string_wasm)(e),_ts_node_children_wasm=Module._ts_node_children_wasm=e=>(_ts_node_children_wasm=Module._ts_node_children_wasm=wasmExports.ts_node_children_wasm)(e),_ts_node_named_children_wasm=Module._ts_node_named_children_wasm=e=>(_ts_node_named_children_wasm=Module._ts_node_named_children_wasm=wasmExports.ts_node_named_children_wasm)(e),_ts_node_descendants_of_type_wasm=Module._ts_node_descendants_of_type_wasm=(e,t,_,s,r,a,o)=>(_ts_node_descendants_of_type_wasm=Module._ts_node_descendants_of_type_wasm=wasmExports.ts_node_descendants_of_type_wasm)(e,t,_,s,r,a,o),_ts_node_is_named_wasm=Module._ts_node_is_named_wasm=e=>(_ts_node_is_named_wasm=Module._ts_node_is_named_wasm=wasmExports.ts_node_is_named_wasm)(e),_ts_node_has_changes_wasm=Module._ts_node_has_changes_wasm=e=>(_ts_node_has_changes_wasm=Module._ts_node_has_changes_wasm=wasmExports.ts_node_has_changes_wasm)(e),_ts_node_has_error_wasm=Module._ts_node_has_error_wasm=e=>(_ts_node_has_error_wasm=Module._ts_node_has_error_wasm=wasmExports.ts_node_has_error_wasm)(e),_ts_node_is_error_wasm=Module._ts_node_is_error_wasm=e=>(_ts_node_is_error_wasm=Module._ts_node_is_error_wasm=wasmExports.ts_node_is_error_wasm)(e),_ts_node_is_missing_wasm=Module._ts_node_is_missing_wasm=e=>(_ts_node_is_missing_wasm=Module._ts_node_is_missing_wasm=wasmExports.ts_node_is_missing_wasm)(e),_ts_node_is_extra_wasm=Module._ts_node_is_extra_wasm=e=>(_ts_node_is_extra_wasm=Module._ts_node_is_extra_wasm=wasmExports.ts_node_is_extra_wasm)(e),_ts_node_parse_state_wasm=Module._ts_node_parse_state_wasm=e=>(_ts_node_parse_state_wasm=Module._ts_node_parse_state_wasm=wasmExports.ts_node_parse_state_wasm)(e),_ts_node_next_parse_state_wasm=Module._ts_node_next_parse_state_wasm=e=>(_ts_node_next_parse_state_wasm=Module._ts_node_next_parse_state_wasm=wasmExports.ts_node_next_parse_state_wasm)(e),_ts_query_matches_wasm=Module._ts_query_matches_wasm=(e,t,_,s,r,a,o,n,l,d)=>(_ts_query_matches_wasm=Module._ts_query_matches_wasm=wasmExports.ts_query_matches_wasm)(e,t,_,s,r,a,o,n,l,d),_ts_query_captures_wasm=Module._ts_query_captures_wasm=(e,t,_,s,r,a,o,n,l,d)=>(_ts_query_captures_wasm=Module._ts_query_captures_wasm=wasmExports.ts_query_captures_wasm)(e,t,_,s,r,a,o,n,l,d),_iswalpha=Module._iswalpha=e=>(_iswalpha=Module._iswalpha=wasmExports.iswalpha)(e),_iswblank=Module._iswblank=e=>(_iswblank=Module._iswblank=wasmExports.iswblank)(e),_iswdigit=Module._iswdigit=e=>(_iswdigit=Module._iswdigit=wasmExports.iswdigit)(e),_iswlower=Module._iswlower=e=>(_iswlower=Module._iswlower=wasmExports.iswlower)(e),_iswupper=Module._iswupper=e=>(_iswupper=Module._iswupper=wasmExports.iswupper)(e),_iswxdigit=Module._iswxdigit=e=>(_iswxdigit=Module._iswxdigit=wasmExports.iswxdigit)(e),_memchr=Module._memchr=(e,t,_)=>(_memchr=Module._memchr=wasmExports.memchr)(e,t,_),_strlen=Module._strlen=e=>(_strlen=Module._strlen=wasmExports.strlen)(e),_strcmp=Module._strcmp=(e,t)=>(_strcmp=Module._strcmp=wasmExports.strcmp)(e,t),_strncat=Module._strncat=(e,t,_)=>(_strncat=Module._strncat=wasmExports.strncat)(e,t,_),_strncpy=Module._strncpy=(e,t,_)=>(_strncpy=Module._strncpy=wasmExports.strncpy)(e,t,_),_towlower=Module._towlower=e=>(_towlower=Module._towlower=wasmExports.towlower)(e),_towupper=Module._towupper=e=>(_towupper=Module._towupper=wasmExports.towupper)(e),_setThrew=(e,t)=>(_setThrew=wasmExports.setThrew)(e,t),stackSave=()=>(stackSave=wasmExports.stackSave)(),stackRestore=e=>(stackRestore=wasmExports.stackRestore)(e),stackAlloc=e=>(stackAlloc=wasmExports.stackAlloc)(e),dynCall_jiji=Module.dynCall_jiji=(e,t,_,s,r)=>(dynCall_jiji=Module.dynCall_jiji=wasmExports.dynCall_jiji)(e,t,_,s,r),_orig$ts_parser_timeout_micros=Module._orig$ts_parser_timeout_micros=e=>(_orig$ts_parser_timeout_micros=Module._orig$ts_parser_timeout_micros=wasmExports.orig$ts_parser_timeout_micros)(e),_orig$ts_parser_set_timeout_micros=Module._orig$ts_parser_set_timeout_micros=(e,t)=>(_orig$ts_parser_set_timeout_micros=Module._orig$ts_parser_set_timeout_micros=wasmExports.orig$ts_parser_set_timeout_micros)(e,t),calledRun;function callMain(e=[]){var t=resolveGlobalSymbol("main").sym;if(t){e.unshift(thisProgram);var _=e.length,s=stackAlloc(4*(_+1)),r=s;e.forEach((e=>{HEAPU32[r>>2]=stringToUTF8OnStack(e),r+=4})),HEAPU32[r>>2]=0;try{var a=t(_,s);return exitJS(a,!0),a}catch(e){return handleException(e)}}}function run(e=arguments_){function t(){calledRun||(calledRun=!0,Module.calledRun=!0,ABORT||(initRuntime(),preMain(),Module.onRuntimeInitialized&&Module.onRuntimeInitialized(),shouldRunNow&&callMain(e),postRun()))}runDependencies>0||(preRun(),runDependencies>0||(Module.setStatus?(Module.setStatus("Running..."),setTimeout((function(){setTimeout((function(){Module.setStatus("")}),1),t()}),1)):t()))}if(Module.AsciiToString=AsciiToString,Module.stringToUTF16=stringToUTF16,dependenciesFulfilled=function e(){calledRun||run(),calledRun||(dependenciesFulfilled=e)},Module.preInit)for("function"==typeof Module.preInit&&(Module.preInit=[Module.preInit]);Module.preInit.length>0;)Module.preInit.pop()();var shouldRunNow=!0;Module.noInitialRun&&(shouldRunNow=!1),run();const C=Module,INTERNAL={},SIZE_OF_INT=4,SIZE_OF_CURSOR=4*SIZE_OF_INT,SIZE_OF_NODE=5*SIZE_OF_INT,SIZE_OF_POINT=2*SIZE_OF_INT,SIZE_OF_RANGE=2*SIZE_OF_INT+2*SIZE_OF_POINT,ZERO_POINT={row:0,column:0},QUERY_WORD_REGEX=/[\w-.]*/g,PREDICATE_STEP_TYPE_CAPTURE=1,PREDICATE_STEP_TYPE_STRING=2,LANGUAGE_FUNCTION_REGEX=/^_?tree_sitter_\w+/;let VERSION,MIN_COMPATIBLE_VERSION,TRANSFER_BUFFER,currentParseCallback,currentLogCallback;class ParserImpl{static init(){TRANSFER_BUFFER=C._ts_init(),VERSION=getValue(TRANSFER_BUFFER,"i32"),MIN_COMPATIBLE_VERSION=getValue(TRANSFER_BUFFER+SIZE_OF_INT,"i32")}initialize(){C._ts_parser_new_wasm(),this[0]=getValue(TRANSFER_BUFFER,"i32"),this[1]=getValue(TRANSFER_BUFFER+SIZE_OF_INT,"i32")}delete(){C._ts_parser_delete(this[0]),C._free(this[1]),this[0]=0,this[1]=0}setLanguage(e){let t;if(e){if(e.constructor!==Language)throw new Error("Argument must be a Language");{t=e[0];const _=C._ts_language_version(t);if(_e.slice(t);else{if("function"!=typeof e)throw new Error("Argument must be a string or a function");currentParseCallback=e}this.logCallback?(currentLogCallback=this.logCallback,C._ts_parser_enable_logger_wasm(this[0],1)):(currentLogCallback=null,C._ts_parser_enable_logger_wasm(this[0],0));let s=0,r=0;if(_?.includedRanges){s=_.includedRanges.length,r=C._calloc(s,SIZE_OF_RANGE);let e=r;for(let t=0;t0){let s=t;for(let t=0;t0){let e=_;for(let _=0;_0){let s=t;for(let t=0;t0){let e=_;for(let _=0;_0){let _=t;for(let t=0;t0){let _=t;for(let t=0;t0){let e=n;for(let t=0;t0){if("string"!==r[0].type)throw new Error("Predicates must begin with a literal value");const t=r[0].value;let _,s=!0,a=!0;switch(t){case"any-not-eq?":case"not-eq?":s=!1;case"any-eq?":case"eq?":if(3!==r.length)throw new Error(`Wrong number of arguments to \`#${t}\` predicate. Expected 2, got ${r.length-1}`);if("capture"!==r[1].type)throw new Error(`First argument of \`#${t}\` predicate must be a capture. Got "${r[1].value}"`);if(a=!t.startsWith("any-"),"capture"===r[2].type){const t=r[1].name,_=r[2].name;w[e].push((e=>{const r=[],o=[];for(const s of e)s.name===t&&r.push(s.node),s.name===_&&o.push(s.node);const n=(e,t,_)=>_?e.text===t.text:e.text!==t.text;return a?r.every((e=>o.some((t=>n(e,t,s))))):r.some((e=>o.some((t=>n(e,t,s)))))}))}else{_=r[1].name;const t=r[2].value,o=e=>e.text===t,n=e=>e.text!==t;w[e].push((e=>{const t=[];for(const s of e)s.name===_&&t.push(s.node);const r=s?o:n;return a?t.every(r):t.some(r)}))}break;case"any-not-match?":case"not-match?":s=!1;case"any-match?":case"match?":if(3!==r.length)throw new Error(`Wrong number of arguments to \`#${t}\` predicate. Expected 2, got ${r.length-1}.`);if("capture"!==r[1].type)throw new Error(`First argument of \`#${t}\` predicate must be a capture. Got "${r[1].value}".`);if("string"!==r[2].type)throw new Error(`Second argument of \`#${t}\` predicate must be a string. Got @${r[2].value}.`);_=r[1].name;const o=new RegExp(r[2].value);a=!t.startsWith("any-"),w[e].push((e=>{const t=[];for(const s of e)s.name===_&&t.push(s.node.text);const r=(e,t)=>t?o.test(e):!o.test(e);return 0===t.length?!s:a?t.every((e=>r(e,s))):t.some((e=>r(e,s)))}));break;case"set!":if(r.length<2||r.length>3)throw new Error(`Wrong number of arguments to \`#set!\` predicate. Expected 1 or 2. Got ${r.length-1}.`);if(r.some((e=>"string"!==e.type)))throw new Error('Arguments to `#set!` predicate must be a strings.".');d[e]||(d[e]={}),d[e][r[1].value]=r[2]?r[2].value:null;break;case"is?":case"is-not?":if(r.length<2||r.length>3)throw new Error(`Wrong number of arguments to \`#${t}\` predicate. Expected 1 or 2. Got ${r.length-1}.`);if(r.some((e=>"string"!==e.type)))throw new Error(`Arguments to \`#${t}\` predicate must be a strings.".`);const n="is?"===t?u:m;n[e]||(n[e]={}),n[e][r[1].value]=r[2]?r[2].value:null;break;case"not-any-of?":s=!1;case"any-of?":if(r.length<2)throw new Error(`Wrong number of arguments to \`#${t}\` predicate. Expected at least 1. Got ${r.length-1}.`);if("capture"!==r[1].type)throw new Error(`First argument of \`#${t}\` predicate must be a capture. Got "${r[1].value}".`);for(let e=2;ee.value));w[e].push((e=>{const t=[];for(const s of e)s.name===_&&t.push(s.node.text);return 0===t.length?!s:t.every((e=>l.includes(e)))===s}));break;default:c[e].push({operator:t,operands:r.slice(1)})}r.length=0}}Object.freeze(d[e]),Object.freeze(u[e]),Object.freeze(m[e])}return C._free(_),new Query(INTERNAL,s,n,w,c,Object.freeze(d),Object.freeze(u),Object.freeze(m))}static load(e){let t;if(e instanceof Uint8Array)t=Promise.resolve(e);else{const _=e;if("undefined"!=typeof process&&process.versions&&process.versions.node){const e=require("fs");t=Promise.resolve(e.readFileSync(_))}else t=fetch(_).then((e=>e.arrayBuffer().then((t=>{if(e.ok)return new Uint8Array(t);{const _=new TextDecoder("utf-8").decode(t);throw new Error(`Language.load failed with status ${e.status}.\n\n${_}`)}}))))}return t.then((e=>loadWebAssemblyModule(e,{loadAsync:!0}))).then((e=>{const t=Object.keys(e),_=t.find((e=>LANGUAGE_FUNCTION_REGEX.test(e)&&!e.includes("external_scanner_")));_||console.log(`Couldn't find language function in WASM file. Symbols:\n${JSON.stringify(t,null,2)}`);const s=e[_]();return new Language(INTERNAL,s)}))}}class LookaheadIterable{constructor(e,t,_){assertInternal(e),this[0]=t,this.language=_}get currentTypeId(){return C._ts_lookahead_iterator_current_symbol(this[0])}get currentType(){return this.language.types[this.currentTypeId]||"ERROR"}delete(){C._ts_lookahead_iterator_delete(this[0]),this[0]=0}resetState(e){return C._ts_lookahead_iterator_reset_state(this[0],e)}reset(e,t){return!!C._ts_lookahead_iterator_reset(this[0],e[0],t)&&(this.language=e,!0)}[Symbol.iterator](){const e=this;return{next:()=>C._ts_lookahead_iterator_next(e[0])?{done:!1,value:e.currentType}:{done:!0,value:""}}}}class Query{constructor(e,t,_,s,r,a,o,n){assertInternal(e),this[0]=t,this.captureNames=_,this.textPredicates=s,this.predicates=r,this.setProperties=a,this.assertedProperties=o,this.refutedProperties=n,this.exceededMatchLimit=!1}delete(){C._ts_query_delete(this[0]),this[0]=0}matches(e,{startPosition:t=ZERO_POINT,endPosition:_=ZERO_POINT,startIndex:s=0,endIndex:r=0,matchLimit:a=4294967295,maxStartDepth:o=4294967295}={}){if("number"!=typeof a)throw new Error("Arguments must be numbers");marshalNode(e),C._ts_query_matches_wasm(this[0],e.tree[0],t.row,t.column,_.row,_.column,s,r,a,o);const n=getValue(TRANSFER_BUFFER,"i32"),l=getValue(TRANSFER_BUFFER+SIZE_OF_INT,"i32"),d=getValue(TRANSFER_BUFFER+2*SIZE_OF_INT,"i32"),u=new Array(n);this.exceededMatchLimit=Boolean(d);let m=0,c=l;for(let t=0;te(s)))){u[m]={pattern:t,captures:s};const e=this.setProperties[t];e&&(u[m].setProperties=e);const _=this.assertedProperties[t];_&&(u[m].assertedProperties=_);const r=this.refutedProperties[t];r&&(u[m].refutedProperties=r),m++}}return u.length=m,C._free(l),u}captures(e,{startPosition:t=ZERO_POINT,endPosition:_=ZERO_POINT,startIndex:s=0,endIndex:r=0,matchLimit:a=4294967295,maxStartDepth:o=4294967295}={}){if("number"!=typeof a)throw new Error("Arguments must be numbers");marshalNode(e),C._ts_query_captures_wasm(this[0],e.tree[0],t.row,t.column,_.row,_.column,s,r,a,o);const n=getValue(TRANSFER_BUFFER,"i32"),l=getValue(TRANSFER_BUFFER+SIZE_OF_INT,"i32"),d=getValue(TRANSFER_BUFFER+2*SIZE_OF_INT,"i32"),u=[];this.exceededMatchLimit=Boolean(d);const m=[];let c=l;for(let t=0;te(m)))){const e=m[s],_=this.setProperties[t];_&&(e.setProperties=_);const r=this.assertedProperties[t];r&&(e.assertedProperties=r);const a=this.refutedProperties[t];a&&(e.refutedProperties=a),u.push(e)}}return C._free(l),u}predicatesForPattern(e){return this.predicates[e]}disableCapture(e){const t=lengthBytesUTF8(e),_=C._malloc(t+1);stringToUTF8(e,_,t+1),C._ts_query_disable_capture(this[0],_,t),C._free(_)}didExceedMatchLimit(){return this.exceededMatchLimit}}function getText(e,t,_){const s=_-t;let r=e.textCallback(t,null,_);for(t+=r.length;t<_;){const s=e.textCallback(t,null,_);if(!(s&&s.length>0))break;t+=s.length,r+=s}return t>_&&(r=r.slice(0,s)),r}function unmarshalCaptures(e,t,_,s){for(let r=0,a=s.length;r>>0,column:getValue(e+SIZE_OF_INT,"i32")>>>0}}function marshalRange(e,t){marshalPoint(e,t.startPosition),marshalPoint(e+=SIZE_OF_POINT,t.endPosition),setValue(e+=SIZE_OF_POINT,t.startIndex,"i32"),setValue(e+=SIZE_OF_INT,t.endIndex,"i32"),e+=SIZE_OF_INT}function unmarshalRange(e){const t={};return t.startPosition=unmarshalPoint(e),e+=SIZE_OF_POINT,t.endPosition=unmarshalPoint(e),e+=SIZE_OF_POINT,t.startIndex=getValue(e,"i32")>>>0,e+=SIZE_OF_INT,t.endIndex=getValue(e,"i32")>>>0,t}function marshalEdit(e){let t=TRANSFER_BUFFER;marshalPoint(t,e.startPosition),t+=SIZE_OF_POINT,marshalPoint(t,e.oldEndPosition),t+=SIZE_OF_POINT,marshalPoint(t,e.newEndPosition),t+=SIZE_OF_POINT,setValue(t,e.startIndex,"i32"),t+=SIZE_OF_INT,setValue(t,e.oldEndIndex,"i32"),t+=SIZE_OF_INT,setValue(t,e.newEndIndex,"i32"),t+=SIZE_OF_INT}for(const e of Object.getOwnPropertyNames(ParserImpl.prototype))Object.defineProperty(Parser.prototype,e,{value:ParserImpl.prototype[e],enumerable:!1,writable:!1});Parser.Language=Language,Module.onRuntimeInitialized=()=>{ParserImpl.init(),resolveInitPromise()}})))}}return Parser}();"object"==typeof exports&&(module.exports=TreeSitter); diff --git a/public/downloads/grammars/python-1.0.0/tree-sitter.js.sha256 b/public/downloads/grammars/python-1.0.0/tree-sitter.js.sha256 new file mode 100644 index 0000000..51639d1 --- /dev/null +++ b/public/downloads/grammars/python-1.0.0/tree-sitter.js.sha256 @@ -0,0 +1 @@ +ddcacb69cd26c07322c51b798a63805fd99c272177c9633a978f3886358ca070 tree-sitter.js diff --git a/public/downloads/grammars/python-1.0.0/tree-sitter.wasm b/public/downloads/grammars/python-1.0.0/tree-sitter.wasm new file mode 100644 index 0000000..0ac880b Binary files /dev/null and b/public/downloads/grammars/python-1.0.0/tree-sitter.wasm differ diff --git a/public/downloads/grammars/python-1.0.0/tree-sitter.wasm.sha256 b/public/downloads/grammars/python-1.0.0/tree-sitter.wasm.sha256 new file mode 100644 index 0000000..f785099 --- /dev/null +++ b/public/downloads/grammars/python-1.0.0/tree-sitter.wasm.sha256 @@ -0,0 +1 @@ +29208e71028ab0c11dfcc941255075aad75545394467aa22d817a6356714090f tree-sitter.wasm diff --git a/public/install.ps1 b/public/install.ps1 index fcc72d2..d56b1c1 100644 --- a/public/install.ps1 +++ b/public/install.ps1 @@ -75,5 +75,42 @@ if (-not $Command) { exit 0 } +# `Get-Command codetruss` succeeding is not the same as it being the binary just +# installed: an older install earlier in PATH shadows this one, and the hooks +# invoke `codetruss` by name. Compare where PATH resolves it against npm's global +# prefix. On Windows the shims land directly in the prefix (no bin/ subdirectory), +# so compare the directory rather than an exact file name. +function Get-NormalizedPath { + param([string]$Path) + if (-not $Path) { return $null } + $Trimmed = $Path.Trim() + if (-not $Trimmed) { return $null } + try { + return ([System.IO.Path]::GetFullPath($Trimmed)).TrimEnd([char[]]('\', '/')) + } catch { + return $Trimmed.TrimEnd([char[]]('\', '/')) + } +} + +$Prefix = if ($env:NPM_CONFIG_PREFIX) { + $env:NPM_CONFIG_PREFIX +} else { + (& npm prefix --global | Select-Object -First 1) +} +if ($Prefix) { $Prefix = $Prefix.Trim() } + +# ApplicationInfo (.cmd) and ExternalScriptInfo (.ps1) both expose the full path +# on .Source; fall back to .Path, and skip the check if neither is present. +$ResolvedPath = if ($Command.Source) { $Command.Source } else { $Command.Path } +if ($Prefix -and $ResolvedPath) { + $InstalledDir = Get-NormalizedPath $Prefix + $ResolvedDir = Get-NormalizedPath (Split-Path -Parent $ResolvedPath) + if ($InstalledDir -and $ResolvedDir -and ($InstalledDir -ine $ResolvedDir)) { + Write-Warning "codetruss on your PATH resolves to $ResolvedPath, not the install that just completed in $Prefix." + Write-Host "Running codetruss would use that one instead. Put $Prefix ahead of it in PATH (or remove the shadowing install), then run: codetruss setup" + exit 0 + } +} + & codetruss --version Write-Host "Ready. Run inside your Git repository: codetruss setup" diff --git a/public/install.sh b/public/install.sh index 11a7db5..1bfdff3 100755 --- a/public/install.sh +++ b/public/install.sh @@ -61,8 +61,26 @@ if [ ! -x "$EXECUTABLE" ]; then fi "$EXECUTABLE" --version -if ! command -v codetruss >/dev/null 2>&1; then + +# `command -v codetruss` succeeding is not the same as it being the binary just +# installed: an older install earlier in PATH shadows this one, and the hooks +# invoke `codetruss` by name. Compare the resolved targets before saying Ready. +real_path() { + node -e 'const fs = require("node:fs"); const p = process.argv[1]; try { process.stdout.write(fs.realpathSync(p)) } catch { process.stdout.write(p) }' "$1" +} + +RESOLVED="$(command -v codetruss 2>/dev/null || true)" +if [ -z "$RESOLVED" ]; then printf 'Add %s/bin to PATH, then run: codetruss setup\n' "$PREFIX" -else - printf '%s\n' 'Ready. Run inside your Git repository: codetruss setup' + exit 0 fi + +RESOLVED_TARGET="$(real_path "$RESOLVED")" +INSTALLED_TARGET="$(real_path "$EXECUTABLE")" +if [ "$RESOLVED_TARGET" != "$INSTALLED_TARGET" ]; then + printf 'Warning: `codetruss` on your PATH is %s, not the install that just completed at %s.\n' "$RESOLVED" "$EXECUTABLE" >&2 + printf 'Running `codetruss` would use the older one. Put %s/bin ahead of it in PATH (or remove the shadowing install), then run: codetruss setup\n' "$PREFIX" >&2 + exit 0 +fi + +printf '%s\n' 'Ready. Run inside your Git repository: codetruss setup' diff --git a/release-reference.json b/release-reference.json index 8ccb631..17042be 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.39", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.39.tgz", - "archiveSha256": "feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0", - "sbomSha256": "8bd47df2b79d979883cb0372abfc26d380b3ad2fd10327538da8cb15e838d1b4", - "bundleSha256": "85f3475cb84d4ce349d43a0c81c68cbf40bd573e731c5e731eeff55ae7405c4b" + "version": "0.2.40", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.40.tgz", + "archiveSha256": "5d64313b8b60acbd1f93e2246557967885a98fdc8c486ea7b2a6417fd8acdac2", + "sbomSha256": "a78a5a08993e2266cee606f892d2afe118880605dd911d3f2149f99e4e8001bd", + "bundleSha256": "7dcf9a22457c6790c08b2ddc1186f1c73213e58ad6920aa7f164c8ce2d3e9076" }