diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/path-guard/index.mts index 9e7e99df1..ced9fcfc1 100644 --- a/.claude/hooks/path-guard/index.mts +++ b/.claude/hooks/path-guard/index.mts @@ -56,77 +56,12 @@ import process from 'node:process' -// "Stage" segments — appearing two or more in the same path.join / -// template literal is a Rule A violation. These come from -// build-infra/lib/constants.mts BUILD_STAGES plus their lowercase -// directory-name siblings used by some builders (yoga's `wasm/`, -// build-infra's `downloaded/`). -const STAGE_SEGMENTS = new Set([ - 'Final', - 'Release', - 'Stripped', - 'Compressed', - 'Optimized', - 'Synced', - 'wasm', - 'downloaded', -]) - -// "Build-root" segments — at least one must be present together with a -// stage segment to confirm we're constructing a build output path -// rather than something coincidental. Example: `path.join(SRC, -// 'wasm', 'lib')` shouldn't fire (no build root); `path.join(PKG, -// 'build', 'wasm', 'out', 'Final')` should (build root + wasm + out + -// Final). -const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) - -// Mode segments — appearing alongside stage + build-root tightens the -// match further. `'dev'` and `'prod'` alone are too generic; we count -// them as a confirming signal, not a trigger. -const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling Socket-fleet packages whose build output is reached via -// `path.join(*, '..', '', 'build', ...)`. Union of all packages -// across the Socket fleet — the hook is byte-identical via -// sync-scaffolding, so listing every fleet package keeps Rule B firing -// in any repo. When a new package joins the workspace, add it here -// and propagate via `node scripts/sync-scaffolding.mjs --all --fix` -// from socket-repo-template. -const KNOWN_SIBLING_PACKAGES = new Set([ - // socket-btm - 'binflate', - 'binject', - 'binpress', - 'bin-infra', - 'build-infra', - 'codet5-models-builder', - 'curl-builder', - 'iocraft-builder', - 'ink-builder', - 'libpq-builder', - 'lief-builder', - 'minilm-builder', - 'models', - 'napi-go', - 'node-smol-builder', - 'onnxruntime-builder', - 'opentui-builder', - 'stubs-builder', - 'ultraviolet-builder', - 'yoga-layout-builder', - // socket-cli - 'cli', - 'package-builder', - // socket-tui - 'core', - 'react', - 'renderer', - 'ultraviolet', - 'yoga', - // socket-registry / ultrathink - 'acorn', - 'npm', -]) +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from './segments.mts' // File-path patterns that are exempt from the hook entirely. Edits to // these files legitimately need to enumerate path segments. @@ -293,13 +228,58 @@ const checkRuleB = (calls: ReturnType): void => { } } +// Backtick template-literal detection. Path construction via +// `${buildDir}/out/Final/${binary}` follows the same shape as +// path.join() and constitutes the same Rule A violation. Placeholders +// (${...}) are stripped to a sentinel that won't match any segment +// set, so segments composed entirely of interpolation contribute +// nothing to the trigger. +const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g + +const checkRuleATemplate = (source: string): void => { + TEMPLATE_LITERAL_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEMPLATE_LITERAL_RE.exec(source)) !== null) { + const body = m[1] ?? '' + if (!body.includes('/')) { + continue + } + const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') + const segments = stripped + .split('/') + .filter(s => s.length > 0 && s !== '\x00') + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + // Template literal trigger is tighter than path.join() because + // backtick strings often appear in patch fixtures, error messages, + // and other multi-line content that incidentally contains stage + // tokens like `wasm`. Require the canonical build-output shape. + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggers = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggers) { + throw new BlockError( + 'A — multi-stage path constructed inline via template literal', + 'Construct this path in the owning `paths.mts` (or a build-infra helper) and import the computed value here. 1 path, 1 reference.', + m[0], + ) + } + } +} + const check = (source: string): void => { const calls = extractPathCalls(source) - if (calls.length === 0) { - return + if (calls.length > 0) { + checkRuleA(calls) + checkRuleB(calls) } - checkRuleA(calls) - checkRuleB(calls) + checkRuleATemplate(source) } const emitBlock = (filePath: string, err: BlockError): void => { diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/path-guard/segments.mts new file mode 100644 index 000000000..891d0b8b7 --- /dev/null +++ b/.claude/hooks/path-guard/segments.mts @@ -0,0 +1,80 @@ +// Canonical path-segment vocabulary shared by the path-guard hook +// (.claude/hooks/path-guard/index.mts) and gate (scripts/check-paths.mts). +// +// Mantra: 1 path, 1 reference. This module is the *one* place stage, +// build-root, mode, and sibling-package vocabulary is defined. Both +// consumers import from here so they can never drift apart. +// +// Synced byte-identically across the Socket fleet via +// socket-repo-template/scripts/sync-scaffolding.mjs (IDENTICAL_FILES). +// When adding a new stage/build-root/mode/sibling, edit this file in +// the template and re-sync. + +// "Stage" segments — Rule A core. Two of these spread via `path.join` +// or interpolated into a template literal is a finding outside a +// canonical `paths.mts`. Sourced from build-infra/lib/constants.mts +// `BUILD_STAGES` plus their lowercase directory-name siblings used by +// some builders. +export const STAGE_SEGMENTS = new Set([ + 'Compressed', + 'downloaded', + 'Final', + 'Optimized', + 'Release', + 'Stripped', + 'Synced', + 'wasm', +]) + +// "Build-root" segments — at least one must be present together with +// a stage segment to confirm we're constructing a build output path +// rather than something coincidental. Example: a join that yields +// `//` doesn't fire if no build-root segment is +// present; `/build//out/` does. +export const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) + +// Build-mode segments — a stage segment plus one of these is also a +// finding (`build///out/` is the canonical shape). +export const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) + +// Sibling fleet packages (Rule B). Union of all packages across the +// Socket fleet — the gate is byte-identical via sync-scaffolding, so +// listing every fleet package keeps Rule B firing in any repo. When a +// new package joins the workspace, add it here and propagate via +// `node scripts/sync-scaffolding.mjs --all --fix` from +// socket-repo-template. +export const KNOWN_SIBLING_PACKAGES = new Set([ + // socket-btm + 'bin-infra', + 'binflate', + 'binject', + 'binpress', + 'build-infra', + 'codet5-models-builder', + 'curl-builder', + 'ink-builder', + 'iocraft-builder', + 'libpq-builder', + 'lief-builder', + 'minilm-builder', + 'models', + 'napi-go', + 'node-smol-builder', + 'onnxruntime-builder', + 'opentui-builder', + 'stubs-builder', + 'ultraviolet-builder', + 'yoga-layout-builder', + // socket-cli + 'cli', + 'package-builder', + // socket-tui + 'core', + 'react', + 'renderer', + 'ultraviolet', + 'yoga', + // socket-registry / ultrathink + 'acorn', + 'npm', +]) diff --git a/.claude/hooks/path-guard/test/path-guard.test.mts b/.claude/hooks/path-guard/test/path-guard.test.mts index 2911ce42f..46c0ae1ec 100644 --- a/.claude/hooks/path-guard/test/path-guard.test.mts +++ b/.claude/hooks/path-guard/test/path-guard.test.mts @@ -190,6 +190,60 @@ describe('path-guard — paren-balance correctness', () => { }) }) +describe('path-guard — template literals', () => { + it('detects A in fully-literal template path', () => { + const source = '\n const p = `build/dev/out/Final/binary`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 2) + }) + + it('detects A in template with placeholders', () => { + const source = + '\n const p = `${PKG}/build/${mode}/${arch}/out/Final/${name}`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 2) + }) + + it('allows template with single non-stage segment', () => { + const source = '\n const url = `https://example.com/path`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) + + it('allows template with no stage segments', () => { + const source = '\n const tmp = `${packageRoot}/build/temp/cache`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) + + it('allows template that is purely interpolation', () => { + // `${a}/${b}/${c}` has no literal stage segments. + const source = '\n const p = `${a}/${b}/${c}`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) +}) + describe('path-guard — file-type filter', () => { it('skips .ts files', () => { const source = ` diff --git a/.claude/hooks/token-guard/index.mts b/.claude/hooks/token-guard/index.mts index c49a731f7..ea54abf5e 100644 --- a/.claude/hooks/token-guard/index.mts +++ b/.claude/hooks/token-guard/index.mts @@ -120,9 +120,19 @@ type ToolInput = { const hasRedaction = (command: string): boolean => REDACTION_MARKERS.some(re => re.test(command)) +// Word-boundary match so `PASS` doesn't fire on `PATHS-ALLOWLIST` and +// `AUTH` doesn't fire on `AUTHOR`. Env-var-style boundaries treat `_` +// as a separator (so `ACCESS_TOKEN` matches `TOKEN`) but require a +// non-alphanumeric character on each end (so `PATHS` doesn't match +// `PASS`). The pre-fix substring match created false positives +// whenever a path name happened to contain a sensitive keyword as a +// literal substring. +const sensitiveEnvBoundaryRes = SENSITIVE_ENV_NAMES.map( + frag => new RegExp(String.raw`(?:^|[^A-Z0-9])${frag}(?:[^A-Z0-9]|$)`), +) const referencesSensitiveEnv = (command: string): boolean => { const upper = command.toUpperCase() - return SENSITIVE_ENV_NAMES.some(frag => upper.includes(frag)) + return sensitiveEnvBoundaryRes.some(re => re.test(upper)) } const matchesAlwaysDangerous = (command: string): RegExp | null => { diff --git a/.claude/hooks/token-guard/test/token-guard.test.mts b/.claude/hooks/token-guard/test/token-guard.test.mts index 1c296eb84..b2ab67147 100644 --- a/.claude/hooks/token-guard/test/token-guard.test.mts +++ b/.claude/hooks/token-guard/test/token-guard.test.mts @@ -182,6 +182,25 @@ describe('token-guard hook', () => { }) }) + describe('does not false-positive on substring of sensitive name', () => { + // Regression: `PATHS-ALLOWLIST.YML` toUpperCase()d contains `PASS` + // as a substring, which the pre-fix unbounded match treated as + // a sensitive env reference. Word-boundary fix means `PASS` must + // be a standalone token (or at a `_`/`-`/`.`/`/` boundary). + it('paths-allowlist.yml does not trip PASS', () => { + assert.equal(runHook('cat .github/paths-allowlist.yml').code, 0) + }) + it('AUTHOR_NAME does not trip AUTH', () => { + // AUTHOR ends with R; the boundary-after match correctly skips + // it because the next char is `_`, but `AUTH` followed by `O` + // (alphanumeric) is not a token boundary. + assert.equal(runHook('echo $AUTHOR_NAME').code, 0) + }) + it('PASSAGE_TIME does not trip PASS', () => { + assert.equal(runHook('echo $PASSAGE_TIME').code, 0) + }) + }) + describe('fails open on malformed input', () => { it('empty stdin', () => { const r = spawnSync(nodeBin, [hookScript], { diff --git a/.claude/skills/path-guard/SKILL.md b/.claude/skills/path-guard/SKILL.md index f58382518..11d0e5ba7 100644 --- a/.claude/skills/path-guard/SKILL.md +++ b/.claude/skills/path-guard/SKILL.md @@ -199,6 +199,18 @@ pnpm run check:paths --explain Print the gate's findings without making any edits. Exit 0 if clean, 1 if findings present. Useful for CI / pre-merge inspection. +## Allowlisting a finding + +When a genuine exemption is needed (rare — most "false positives" should be reported as gate bugs), add an entry to `.github/paths-allowlist.yml`. Two ways to pin the entry to a specific site: + +- **`line:`** — exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces. +- **`snippet_hash:`** — 12-char SHA-256 prefix of the offending snippet (whitespace-normalized). Drift-resistant: survives reformatting, but any content-changing edit invalidates it. Get the hash: + ```bash + pnpm run check:paths --show-hashes + ``` + +Both may be set — either matching is sufficient. Prefer `snippet_hash` over raw `line:` when the exemption is expected to outlive routine reformatting; prefer `line:` when you specifically *want* the entry to fall off after any nearby edit. + ## Mode: install (new repo) When invoked as `/path-guard install` on a Socket repo that doesn't yet have the gate: diff --git a/.claude/skills/path-guard/reference/check-paths.mts.tmpl b/.claude/skills/path-guard/reference/check-paths.mts.tmpl index 41e9b2ac7..023b6ce14 100644 --- a/.claude/skills/path-guard/reference/check-paths.mts.tmpl +++ b/.claude/skills/path-guard/reference/check-paths.mts.tmpl @@ -67,6 +67,7 @@ * 2 — gate itself crashed */ +import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync } from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -75,6 +76,13 @@ import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from '../.claude/hooks/path-guard/segments.mts' + // Plain stderr/stdout output — no @socketsecurity/lib dependency so // the gate is self-contained and works in socket-lib itself (which // would otherwise import itself). @@ -90,63 +98,10 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const REPO_ROOT = path.resolve(__dirname, '..') -// Stage segments (Rule A core). Spreading any two of these via -// `path.join` is a finding outside `paths.mts`. -const STAGE_SEGMENTS = new Set([ - 'Final', - 'Release', - 'Stripped', - 'Compressed', - 'Optimized', - 'Synced', - 'wasm', - 'downloaded', -]) - -const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) -const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling fleet packages (Rule B). Union of all packages across the -// Socket fleet — the gate is byte-identical via sync-scaffolding, so -// listing every fleet package keeps Rule B firing in any repo. When -// a new package joins the workspace, add it here and propagate via -// `node scripts/sync-scaffolding.mjs --all --fix` from -// socket-repo-template. -const KNOWN_SIBLING_PACKAGES = new Set([ - // socket-btm - 'binflate', - 'binject', - 'binpress', - 'bin-infra', - 'build-infra', - 'codet5-models-builder', - 'curl-builder', - 'iocraft-builder', - 'ink-builder', - 'libpq-builder', - 'lief-builder', - 'minilm-builder', - 'models', - 'napi-go', - 'node-smol-builder', - 'onnxruntime-builder', - 'opentui-builder', - 'stubs-builder', - 'ultraviolet-builder', - 'yoga-layout-builder', - // socket-cli - 'cli', - 'package-builder', - // socket-tui - 'core', - 'react', - 'renderer', - 'ultraviolet', - 'yoga', - // socket-registry / ultrathink - 'acorn', - 'npm', -]) +// Stage / build-root / mode / sibling-package vocabularies are imported +// from `.claude/hooks/path-guard/segments.mts` (the canonical source). +// Both this gate and the path-guard hook share that single definition +// — Mantra: 1 path, 1 reference. // File-path patterns that legitimately enumerate path segments. const EXEMPT_FILE_PATTERNS: RegExp[] = [ @@ -179,6 +134,7 @@ const args = parseArgs({ explain: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, quiet: { type: 'boolean', default: false }, + 'show-hashes': { type: 'boolean', default: false }, }, strict: false, }) @@ -195,6 +151,7 @@ type AllowlistEntry = { pattern?: string rule?: string line?: number + snippet_hash?: string reason: string } @@ -315,6 +272,36 @@ const unquote = (s: string): string => { const ALLOWLIST = loadAllowlist() +/** + * Stable, normalized snippet hash. Whitespace-insensitive so trivial + * reformatting (indent change, trailing comma, line wrap) doesn't + * invalidate an allowlist entry, but content-changing edits do. The + * hash exposes only the first 12 hex chars (~48 bits) which is plenty + * for collision-resistance within a single repo's finding set and + * keeps the YAML readable. + */ +const snippetHash = (snippet: string): string => { + const normalized = snippet.replace(/\s+/g, ' ').trim() + return createHash('sha256').update(normalized).digest('hex').slice(0, 12) +} + +/** + * Allowlist matching trades off two failure modes: + * + * - Drift via reformatting (a line shift breaks an entry, the + * finding re-surfaces, devs paper over with a new entry). + * - Stealth allowlisting (an entry pinned to "anywhere in this file" + * silently exempts unrelated future violations). + * + * Strategy: exact line match OR `snippet_hash` match (whitespace- + * normalized SHA-256, first 12 hex). Either is sufficient. Lines stay + * exact (was ±2; the slack let reformatting silently slide), and + * `snippet_hash` provides reformatting-tolerant matching that's still + * tied to the literal text — paste-and-edit cheating would change the + * hash. If neither `line` nor `snippet_hash` is provided, the entry + * matches purely by `rule` + `file` + `pattern` (file-level exempt; + * use sparingly and always pair with a precise `pattern`). + */ const isAllowlisted = (finding: Finding): boolean => ALLOWLIST.some(entry => { if (entry.rule && entry.rule !== finding.rule) { @@ -326,8 +313,17 @@ const isAllowlisted = (finding: Finding): boolean => if (entry.pattern && !finding.snippet.includes(entry.pattern)) { return false } - if (entry.line !== undefined && Math.abs(entry.line - finding.line) > 2) { - return false + const lineProvided = entry.line !== undefined + const hashProvided = + typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 + if (lineProvided || hashProvided) { + const lineMatches = + lineProvided && entry.line === finding.line + const hashMatches = + hashProvided && entry.snippet_hash === snippetHash(finding.snippet) + if (!(lineMatches || hashMatches)) { + return false + } } return true }) @@ -382,6 +378,27 @@ const walk = function* ( const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g +// Template literal scanner. Captures backtick-delimited strings +// (including those with `${...}` placeholders) so Rule A also catches +// path construction via template literals like +// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. +const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g + +/** + * Convert a template-literal body into a synthetic forward-slash path + * by replacing `${...}` placeholders with a sentinel and normalizing + * separators. Returns the sequence of path segments split on `/`. The + * sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a + * placeholder-only segment (`${binaryName}`) won't match those sets. + */ +const templateLiteralSegments = (body: string): string[] => { + // Strip placeholders so they don't introduce noise in segments. + // Empty result for a placeholder is fine; downstream filters by set + // membership and skips empties. + const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') + return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') +} + /** * Extract every `path.join(...)` and `path.resolve(...)` call from the * source text, returning each call's literal start offset and argument @@ -530,6 +547,54 @@ const scanCodeFile = (relPath: string): void => { } } } + + // Rule A (template literal variant). Backtick strings like + // `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final` + // construct paths the same way `path.join(...)` does — flag the + // same shapes. Skip raw imports / template tag positions by + // filtering out leading `import.meta.url`-style / tag positions + // implicitly: TEMPLATE_LITERAL_RE matches any backtick string and + // we rely on segment composition to decide if it's a path. + TEMPLATE_LITERAL_RE.lastIndex = 0 + let tmpl: RegExpExecArray | null + while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { + const body = tmpl[1] ?? '' + if (!body.includes('/')) { + continue + } + const segments = templateLiteralSegments(body) + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + // Template literal trigger is tighter than path.join() because + // backtick strings often appear in patch fixtures, error messages, + // and other multi-line content that incidentally contains stage + // tokens like `wasm`. Require the canonical build-output shape: + // - 'build' + 'out' + stage (canonical multi-stage layout), OR + // - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR + // - 'build' + stage + literal mode (back-compat with path.join). + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggersA = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggersA) { + const line = offsetToLine(tmpl.index) + const snippet = (lines[line - 1] ?? '').trim() + findings.push({ + rule: 'A', + file: relPath, + line, + snippet, + message: + 'Multi-stage path constructed inline via template literal (outside paths.mts).', + fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', + }) + } + } } // ────────────────────────────────────────────────────────────────── @@ -853,6 +918,9 @@ const main = (): number => { logger.log(` [${f.rule}] ${f.file}:${f.line}`) logger.log(` ${f.snippet}`) logger.log(` → ${f.message}`) + if (args.values['show-hashes']) { + logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) + } if (args.values.explain) { logger.log(` Fix: ${f.fix}`) } @@ -863,6 +931,9 @@ const main = (): number => { logger.log( 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', ) + logger.log( + 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', + ) } return 1 } diff --git a/CLAUDE.md b/CLAUDE.md index 5619d8fd5..548acc221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,14 +221,14 @@ Core infrastructure library for Socket.dev security tools. ### 1 path, 1 reference -**A path is *constructed* exactly once. Everywhere else *references* the constructed value.** +**A path is _constructed_ exactly once. Everywhere else _references_ the constructed value.** -Referencing a single computed path many times is fine — that's the whole point of computing it once. What's banned is *re-constructing* the same path in multiple places, because that's where drift is born. +Referencing a single computed path many times is fine — that's the whole point of computing it once. What's banned is _re-constructing_ the same path in multiple places, because that's where drift is born. - **Within a package**: every script imports its own `scripts/paths.mts` (or `lib/paths.mts`). No `path.join('build', mode, ...)` outside that module. - **Across packages**: when package B consumes package A's output, B imports A's `paths.mts` via the workspace `exports` field. Never `path.join(PKG, '..', '', 'build', ...)`. - **Workflows, Dockerfiles, shell scripts**: they can't `import` TS, so they construct the string once and reference it everywhere downstream. Workflows: a "Compute paths" step exposes `steps.paths.outputs.final_dir`; later steps read `${{ steps.paths.outputs.final_dir }}`. Dockerfiles/shell: assign once to a variable / `ENV`, reference by name thereafter. Each canonical construction carries a comment naming the source-of-truth `paths.mts`. **Re-building** the same path in a second step is the violation, not referring to the constructed value many times. -- **Comments**: may describe path *structure* with placeholders ("`/`") but should not encode a complete literal path string. The import statement IS the comment. +- **Comments**: may describe path _structure_ with placeholders ("`/`") but should not encode a complete literal path string. The import statement IS the comment. Code execution takes priority over docs: violations in `.mts`/`.cts`, Makefiles, Dockerfiles, workflow YAML, and shell scripts are blocking. README and doc-comment violations are advisory unless they contain a fully-qualified path with no parametric placeholders. diff --git a/package.json b/package.json index 3061c0b90..9e0277269 100644 --- a/package.json +++ b/package.json @@ -679,7 +679,8 @@ "./package.json": "./package.json", "./tsconfig.dts.json": "./tsconfig.dts.json", "./tsconfig.json": "./tsconfig.json", - "./tsconfig.test.json": "./tsconfig.test.json" + "./tsconfig.test.json": "./tsconfig.test.json", + "./xport.schema.json": "./xport.schema.json" }, "files": [ "dist", @@ -758,6 +759,7 @@ "pacote": "21.5.0", "picomatch": "4.0.4", "pony-cause": "2.1.11", + "prim": "workspace:*", "semver": "7.7.2", "signal-exit": "4.1.0", "spdx-correct": "3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8786eb0c8..657b023e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -400,6 +400,9 @@ importers: pony-cause: specifier: 2.1.11 version: 2.1.11(patch_hash=39b2eb2567818b7c60126d03e252dbbabd8738082fca850582300d45b0a8cfb8) + prim: + specifier: workspace:* + version: link:tools/prim semver: specifier: 7.7.2 version: 7.7.2 @@ -468,6 +471,10 @@ importers: specifier: 24.9.2 version: 24.9.2 + .claude/hooks/path-guard: {} + + .claude/hooks/token-guard: {} + tools/prim: dependencies: acorn-wasm: diff --git a/scripts/check-paths.mts b/scripts/check-paths.mts index 41e9b2ac7..cbecc71e5 100644 --- a/scripts/check-paths.mts +++ b/scripts/check-paths.mts @@ -67,6 +67,7 @@ * 2 — gate itself crashed */ +import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync } from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -75,6 +76,13 @@ import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from '../.claude/hooks/path-guard/segments.mts' + // Plain stderr/stdout output — no @socketsecurity/lib dependency so // the gate is self-contained and works in socket-lib itself (which // would otherwise import itself). @@ -90,63 +98,10 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const REPO_ROOT = path.resolve(__dirname, '..') -// Stage segments (Rule A core). Spreading any two of these via -// `path.join` is a finding outside `paths.mts`. -const STAGE_SEGMENTS = new Set([ - 'Final', - 'Release', - 'Stripped', - 'Compressed', - 'Optimized', - 'Synced', - 'wasm', - 'downloaded', -]) - -const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) -const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling fleet packages (Rule B). Union of all packages across the -// Socket fleet — the gate is byte-identical via sync-scaffolding, so -// listing every fleet package keeps Rule B firing in any repo. When -// a new package joins the workspace, add it here and propagate via -// `node scripts/sync-scaffolding.mjs --all --fix` from -// socket-repo-template. -const KNOWN_SIBLING_PACKAGES = new Set([ - // socket-btm - 'binflate', - 'binject', - 'binpress', - 'bin-infra', - 'build-infra', - 'codet5-models-builder', - 'curl-builder', - 'iocraft-builder', - 'ink-builder', - 'libpq-builder', - 'lief-builder', - 'minilm-builder', - 'models', - 'napi-go', - 'node-smol-builder', - 'onnxruntime-builder', - 'opentui-builder', - 'stubs-builder', - 'ultraviolet-builder', - 'yoga-layout-builder', - // socket-cli - 'cli', - 'package-builder', - // socket-tui - 'core', - 'react', - 'renderer', - 'ultraviolet', - 'yoga', - // socket-registry / ultrathink - 'acorn', - 'npm', -]) +// Stage / build-root / mode / sibling-package vocabularies are imported +// from `.claude/hooks/path-guard/segments.mts` (the canonical source). +// Both this gate and the path-guard hook share that single definition +// — Mantra: 1 path, 1 reference. // File-path patterns that legitimately enumerate path segments. const EXEMPT_FILE_PATTERNS: RegExp[] = [ @@ -179,6 +134,7 @@ const args = parseArgs({ explain: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, quiet: { type: 'boolean', default: false }, + 'show-hashes': { type: 'boolean', default: false }, }, strict: false, }) @@ -195,6 +151,7 @@ type AllowlistEntry = { pattern?: string rule?: string line?: number + snippet_hash?: string reason: string } @@ -272,7 +229,8 @@ const loadAllowlist = (): AllowlistEntry[] => { blockLines = [] return } - ;(current as any)[key] = key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) + ;(current as any)[key] = + key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) } if (line.startsWith('- ')) { if (current && current.reason) { @@ -315,6 +273,36 @@ const unquote = (s: string): string => { const ALLOWLIST = loadAllowlist() +/** + * Stable, normalized snippet hash. Whitespace-insensitive so trivial + * reformatting (indent change, trailing comma, line wrap) doesn't + * invalidate an allowlist entry, but content-changing edits do. The + * hash exposes only the first 12 hex chars (~48 bits) which is plenty + * for collision-resistance within a single repo's finding set and + * keeps the YAML readable. + */ +const snippetHash = (snippet: string): string => { + const normalized = snippet.replace(/\s+/g, ' ').trim() + return createHash('sha256').update(normalized).digest('hex').slice(0, 12) +} + +/** + * Allowlist matching trades off two failure modes: + * + * - Drift via reformatting (a line shift breaks an entry, the + * finding re-surfaces, devs paper over with a new entry). + * - Stealth allowlisting (an entry pinned to "anywhere in this file" + * silently exempts unrelated future violations). + * + * Strategy: exact line match OR `snippet_hash` match (whitespace- + * normalized SHA-256, first 12 hex). Either is sufficient. Lines stay + * exact (was ±2; the slack let reformatting silently slide), and + * `snippet_hash` provides reformatting-tolerant matching that's still + * tied to the literal text — paste-and-edit cheating would change the + * hash. If neither `line` nor `snippet_hash` is provided, the entry + * matches purely by `rule` + `file` + `pattern` (file-level exempt; + * use sparingly and always pair with a precise `pattern`). + */ const isAllowlisted = (finding: Finding): boolean => ALLOWLIST.some(entry => { if (entry.rule && entry.rule !== finding.rule) { @@ -326,8 +314,16 @@ const isAllowlisted = (finding: Finding): boolean => if (entry.pattern && !finding.snippet.includes(entry.pattern)) { return false } - if (entry.line !== undefined && Math.abs(entry.line - finding.line) > 2) { - return false + const lineProvided = entry.line !== undefined + const hashProvided = + typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 + if (lineProvided || hashProvided) { + const lineMatches = lineProvided && entry.line === finding.line + const hashMatches = + hashProvided && entry.snippet_hash === snippetHash(finding.snippet) + if (!(lineMatches || hashMatches)) { + return false + } } return true }) @@ -382,6 +378,28 @@ const walk = function* ( const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g +// Template literal scanner. Captures backtick-delimited strings +// (including those with `${...}` placeholders) so Rule A also catches +// path construction via template literals like +// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. +const TEMPLATE_LITERAL_RE = + /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g + +/** + * Convert a template-literal body into a synthetic forward-slash path + * by replacing `${...}` placeholders with a sentinel and normalizing + * separators. Returns the sequence of path segments split on `/`. The + * sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a + * placeholder-only segment (`${binaryName}`) won't match those sets. + */ +const templateLiteralSegments = (body: string): string[] => { + // Strip placeholders so they don't introduce noise in segments. + // Empty result for a placeholder is fine; downstream filters by set + // membership and skips empties. + const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') + return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') +} + /** * Extract every `path.join(...)` and `path.resolve(...)` call from the * source text, returning each call's literal start offset and argument @@ -530,6 +548,54 @@ const scanCodeFile = (relPath: string): void => { } } } + + // Rule A (template literal variant). Backtick strings like + // `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final` + // construct paths the same way `path.join(...)` does — flag the + // same shapes. Skip raw imports / template tag positions by + // filtering out leading `import.meta.url`-style / tag positions + // implicitly: TEMPLATE_LITERAL_RE matches any backtick string and + // we rely on segment composition to decide if it's a path. + TEMPLATE_LITERAL_RE.lastIndex = 0 + let tmpl: RegExpExecArray | null + while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { + const body = tmpl[1] ?? '' + if (!body.includes('/')) { + continue + } + const segments = templateLiteralSegments(body) + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + // Template literal trigger is tighter than path.join() because + // backtick strings often appear in patch fixtures, error messages, + // and other multi-line content that incidentally contains stage + // tokens like `wasm`. Require the canonical build-output shape: + // - 'build' + 'out' + stage (canonical multi-stage layout), OR + // - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR + // - 'build' + stage + literal mode (back-compat with path.join). + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggersA = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggersA) { + const line = offsetToLine(tmpl.index) + const snippet = (lines[line - 1] ?? '').trim() + findings.push({ + rule: 'A', + file: relPath, + line, + snippet, + message: + 'Multi-stage path constructed inline via template literal (outside paths.mts).', + fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', + }) + } + } } // ────────────────────────────────────────────────────────────────── @@ -853,6 +919,9 @@ const main = (): number => { logger.log(` [${f.rule}] ${f.file}:${f.line}`) logger.log(` ${f.snippet}`) logger.log(` → ${f.message}`) + if (args.values['show-hashes']) { + logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) + } if (args.values.explain) { logger.log(` Fix: ${f.fix}`) } @@ -863,6 +932,9 @@ const main = (): number => { logger.log( 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', ) + logger.log( + 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', + ) } return 1 } diff --git a/tools/prim/README.md b/tools/prim/README.md index cac1434bc..d0e2259c9 100644 --- a/tools/prim/README.md +++ b/tools/prim/README.md @@ -44,10 +44,10 @@ development — it always picks up the live source under `tools/prim/`. `prim` has three subcommands, each focused on one operation: -| Subcommand | Purpose | -|---|---| -| `prim audit` | Find call sites where a primordial applies. Default shows both migration candidates (covered) and surface gaps (gap). Filter with `--coverage` or `--gaps`. | -| `prim mod` | Codemod **JavaScript** source files to use primordials. Dry-run by default; `--apply` to write. TypeScript is out of scope (rewriting `.ts` requires source-mapping between stripped-types and original byte offsets) — `prim audit` still walks TS, so candidates are visible. | +| Subcommand | Purpose | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prim audit` | Find call sites where a primordial applies. Default shows both migration candidates (covered) and surface gaps (gap). Filter with `--coverage` or `--gaps`. | +| `prim mod` | Codemod **JavaScript** source files to use primordials. Dry-run by default; `--apply` to write. TypeScript is out of scope (rewriting `.ts` requires source-mapping between stripped-types and original byte offsets) — `prim audit` still walks TS, so candidates are visible. | | `prim lint` | Structural lint rules for primordials destructure blocks. Currently: `ctor-rename` — constructor primordials (`Array`, `Set`, `TypeError`, …) must be aliased `: Ctor` when destructured from `primordials` (or any configured primordials-shaped source). Exits 1 on violations. | ```sh diff --git a/tools/prim/src/cli.mts b/tools/prim/src/cli.mts index f081ec7f1..1764ce4d7 100644 --- a/tools/prim/src/cli.mts +++ b/tools/prim/src/cli.mts @@ -253,11 +253,12 @@ export async function runCli(argv) { if (!wantGaps) { filtered = filtered.filter(f => f.kind !== 'gap') } - const mode = values.coverage && !values.gaps - ? 'coverage' - : values.gaps && !values.coverage - ? 'gaps' - : 'audit' + const mode = + values.coverage && !values.gaps + ? 'coverage' + : values.gaps && !values.coverage + ? 'gaps' + : 'audit' report(filtered, json, path.basename(targetRoot), mode) // Surface parse failures so silent skips don't mask audit gaps. const parseFailures = findings.parseFailures ?? 0 diff --git a/tools/prim/src/lint.mts b/tools/prim/src/lint.mts index ad5cffd83..7be87628e 100644 --- a/tools/prim/src/lint.mts +++ b/tools/prim/src/lint.mts @@ -324,9 +324,7 @@ export function formatLintFindings(findings, ctx) { if (findings.length === 0) { return `${ctx.targetName}: no lint violations.\n` } - const lines = [ - `${ctx.targetName} (lint): ${findings.length} violation(s)\n`, - ] + const lines = [`${ctx.targetName} (lint): ${findings.length} violation(s)\n`] for (const f of findings) { lines.push( ` [${f.rule}] ${f.file}:${f.line}:${f.column} destructured \`${f.name}\` from ${f.source}; expected \`${f.name}: ${f.expected}\``, diff --git a/tools/prim/src/surface.mts b/tools/prim/src/surface.mts index 95357dfcc..8e8c4a491 100644 --- a/tools/prim/src/surface.mts +++ b/tools/prim/src/surface.mts @@ -115,7 +115,11 @@ function deriveNodeBootstrapSurface() { // method. exports.add(name) for (const propName of Object.getOwnPropertyNames(original)) { - if (propName === 'prototype' || propName === 'name' || propName === 'length') { + if ( + propName === 'prototype' || + propName === 'name' || + propName === 'length' + ) { continue } exports.add(`${name}${capitalize(propName)}`) @@ -225,9 +229,7 @@ export function loadPrimordialsSurface(targetRoot, surfacePath) { if (surfacePath) { const resolved = path.resolve(surfacePath) if (!existsSync(resolved)) { - throw new Error( - `--surface path not found: ${resolved}`, - ) + throw new Error(`--surface path not found: ${resolved}`) } return { source: resolved, exports: parseExports(resolved) } } diff --git a/vendor/acorn-wasm/acorn_wasm.cjs b/vendor/acorn-wasm/acorn_wasm.cjs index eba1a27b7..b21b70b23 100644 --- a/vendor/acorn-wasm/acorn_wasm.cjs +++ b/vendor/acorn-wasm/acorn_wasm.cjs @@ -49,6 +49,10 @@ function getStringFromWasm0(ptr, len) { return decodeText(ptr, len); } +function isLikeNone(x) { + return x === undefined || x === null; +} + function debugString(val) { // primitive types const type = typeof val; @@ -177,10 +181,6 @@ function getDataViewMemory0() { return cachedDataViewMemory0; } -function isLikeNone(x) { - return x === undefined || x === null; -} - function dropObject(idx) { if (idx < 132) return; heap[idx] = heap_next; @@ -258,6 +258,27 @@ exports.is_valid = function(code) { return ret !== 0; }; +/** + * Get version information + * @returns {string} + */ +exports.version = function() { + let deferred1_0; + let deferred1_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.version(retptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + deferred1_0 = r0; + deferred1_1 = r1; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export_3(deferred1_0, deferred1_1, 1); + } +}; + /** * Find innermost node containing position * @param {string} code @@ -342,27 +363,6 @@ exports.findNodeBefore = function(code, pos, node_type, options_js) { } }; -/** - * Get version information - * @returns {string} - */ -exports.version = function() { - let deferred1_0; - let deferred1_1; - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - wasm.version(retptr); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - deferred1_0 = r0; - deferred1_1 = r1; - return getStringFromWasm0(r0, r1); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - wasm.__wbindgen_export_3(deferred1_0, deferred1_1, 1); - } -}; - /** * Simple walk - parse code and call visitor for each node type * @param {string} code @@ -578,7 +578,14 @@ class WasmParser { return this; } /** - * Parse JavaScript code and return AST as JsValue (WASM) or JSON string (native) + * Parse JavaScript code and return AST as JsValue (WASM) or JSON string (native). + * + * The WASM path goes: + * options_js (JS object) + * → options_from_jsvalue (Reflect-based reads, no serde_json) + * → parser → JSON string + * → JSON::parse (one cheap JS-side parse) + * → JsValue handed back to JS as the AST root * @param {string} code * @param {any} options_js * @returns {any} @@ -615,11 +622,31 @@ exports.__wbg_call_a5400b25a865cfd8 = function() { return handleError(function ( return addHeapObject(ret); }, arguments) }; +exports.__wbg_get_0da715ceaecea5c8 = function(arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0]; + return addHeapObject(ret); +}; + exports.__wbg_get_458e874b43b18b25 = function() { return handleError(function (arg0, arg1) { const ret = Reflect.get(getObject(arg0), getObject(arg1)); return addHeapObject(ret); }, arguments) }; +exports.__wbg_isArray_030cce220591fb41 = function(arg0) { + const ret = Array.isArray(getObject(arg0)); + return ret; +}; + +exports.__wbg_keys_ef52390b2ae0e714 = function(arg0) { + const ret = Object.keys(getObject(arg0)); + return addHeapObject(ret); +}; + +exports.__wbg_length_186546c51cd61acd = function(arg0) { + const ret = getObject(arg0).length; + return ret; +}; + exports.__wbg_new_19c25a3f2fa63a02 = function() { const ret = new Object(); return addHeapObject(ret); @@ -659,10 +686,11 @@ exports.__wbg_setname_832b43d4602cb930 = function(arg0, arg1, arg2) { getObject(arg0).name = getStringFromWasm0(arg1, arg2); }; -exports.__wbg_stringify_b98c93d0a190446a = function() { return handleError(function (arg0) { - const ret = JSON.stringify(getObject(arg0)); - return addHeapObject(ret); -}, arguments) }; +exports.__wbg_wbindgenbooleanget_3fe6f642c7d97746 = function(arg0) { + const v = getObject(arg0); + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; +}; exports.__wbg_wbindgendebugstring_99ef257a3ddda34d = function(arg0, arg1) { const ret = debugString(getObject(arg1)); @@ -677,6 +705,29 @@ exports.__wbg_wbindgenisfunction_8cee7dce3725ae74 = function(arg0) { return ret; }; +exports.__wbg_wbindgenisnull_f3037694abe4d97a = function(arg0) { + const ret = getObject(arg0) === null; + return ret; +}; + +exports.__wbg_wbindgenisobject_307a53c6bd97fbf8 = function(arg0) { + const val = getObject(arg0); + const ret = typeof(val) === 'object' && val !== null; + return ret; +}; + +exports.__wbg_wbindgenisundefined_c4b71d073b92f3c5 = function(arg0) { + const ret = getObject(arg0) === undefined; + return ret; +}; + +exports.__wbg_wbindgennumberget_f74b4c7525ac05cb = function(arg0, arg1) { + const obj = getObject(arg1); + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); +}; + exports.__wbg_wbindgenstringget_0f16a6ddddef376f = function(arg0, arg1) { const obj = getObject(arg1); const ret = typeof(obj) === 'string' ? obj : undefined; @@ -702,6 +753,11 @@ exports.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) { return addHeapObject(ret); }; +exports.__wbindgen_object_clone_ref = function(arg0) { + const ret = getObject(arg0); + return addHeapObject(ret); +}; + exports.__wbindgen_object_drop_ref = function(arg0) { takeObject(arg0); }; diff --git a/vendor/acorn-wasm/acorn_wasm.d.ts b/vendor/acorn-wasm/acorn_wasm.d.ts index 10b5a650f..7c40cb4e7 100644 --- a/vendor/acorn-wasm/acorn_wasm.d.ts +++ b/vendor/acorn-wasm/acorn_wasm.d.ts @@ -15,6 +15,10 @@ export function parse(code: string, options: any): any; * Check if code has syntax errors (returns true if valid) */ export function is_valid(code: string): boolean; +/** + * Get version information + */ +export function version(): string; /** * Find innermost node containing position */ @@ -27,10 +31,6 @@ export function findNodeAfter(code: string, pos: number, node_type: string | nul * Find outermost node ending before position */ export function findNodeBefore(code: string, pos: number, node_type: string | null | undefined, options_js: any): any; -/** - * Get version information - */ -export function version(): string; /** * Simple walk - parse code and call visitor for each node type */ @@ -68,7 +68,14 @@ export class WasmParser { [Symbol.dispose](): void; constructor(); /** - * Parse JavaScript code and return AST as JsValue (WASM) or JSON string (native) + * Parse JavaScript code and return AST as JsValue (WASM) or JSON string (native). + * + * The WASM path goes: + * options_js (JS object) + * → options_from_jsvalue (Reflect-based reads, no serde_json) + * → parser → JSON string + * → JSON::parse (one cheap JS-side parse) + * → JsValue handed back to JS as the AST root */ parse(code: string, options_js: any): any; } diff --git a/vendor/acorn-wasm/acorn_wasm.wasm b/vendor/acorn-wasm/acorn_wasm.wasm index 3ad3afccb..9967d71dc 100644 Binary files a/vendor/acorn-wasm/acorn_wasm.wasm and b/vendor/acorn-wasm/acorn_wasm.wasm differ diff --git a/xport.schema.json b/xport.schema.json index 719c5aadf..6cbd80197 100644 --- a/xport.schema.json +++ b/xport.schema.json @@ -4,9 +4,7 @@ "title": "xport lock-step manifest", "description": "Unified lock-step manifest shared across Socket repos. One schema, all cases — `kind` discriminator on each row selects which flavor of lock-step applies.", "type": "object", - "required": [ - "rows" - ], + "required": ["rows"], "properties": { "$schema": { "type": "string" @@ -32,10 +30,7 @@ "^(.*)$": { "additionalProperties": false, "type": "object", - "required": [ - "submodule", - "repo" - ], + "required": ["submodule", "repo"], "properties": { "submodule": { "description": "Submodule path, relative to repo root.", @@ -57,9 +52,7 @@ "^(.*)$": { "additionalProperties": false, "type": "object", - "required": [ - "path" - ], + "required": ["path"], "properties": { "path": { "description": "Path to the port's root directory, relative to repo root.", @@ -212,13 +205,7 @@ "additionalProperties": false, "description": "A behavioral feature reimplemented locally to match upstream behavior. Three-pillar validation: code patterns, test patterns, fixture snapshots.", "type": "object", - "required": [ - "kind", - "id", - "upstream", - "criticality", - "local_area" - ], + "required": ["kind", "id", "upstream", "criticality", "local_area"], "properties": { "kind": { "const": "feature-parity", @@ -273,9 +260,7 @@ "additionalProperties": false, "description": "Golden-input verification. Prefer snapshot-based diffs over hardcoded counts (brittleness lesson from sdxgen's lock-step-features).", "type": "object", - "required": [ - "fixture_path" - ], + "required": ["fixture_path"], "properties": { "fixture_path": { "type": "string" @@ -425,9 +410,7 @@ "additionalProperties": false, "description": "Per-port status for a lang-parity row. `implemented` = port meets assertions; `opt-out` = port consciously skips, requires non-empty `reason`.", "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "status": { "anyOf": [