From 996e15a70eea0ec353af27ac3a7f5594552aa47e Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 20 May 2026 14:51:17 -0400 Subject: [PATCH] chore(sync): cascade fleet template@8b6bcb1 Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-bin-85741. 23 file(s) touched: - .claude/skills/cascading-fleet/lib/cascade-template.sh - .claude/skills/cascading-fleet/lib/fleet-repos.json - .claude/skills/cascading-fleet/lib/fleet-repos.txt - .config/.markdownlint-cli2.jsonc - .config/.prettierignore - .config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs - .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs - .config/markdownlint-rules/socket-no-relative-sibling-script.mjs - .config/markdownlint-rules/socket-readme-required-sections.mjs - .config/oxlint-plugin/rules/prefer-cached-for-loop.mts - .config/oxlint-plugin/rules/socket-api-token-env.mts - .config/oxlint-plugin/rules/sort-boolean-chains.mts - .config/oxlint-plugin/test/no-underscore-identifier.test.mts - .config/oxlint-plugin/test/prefer-cached-for-loop.test.mts - .config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts - .config/oxlint-plugin/test/socket-api-token-env.test.mts - .config/oxlint-plugin/test/sort-boolean-chains.test.mts - docs/claude.md/fleet/sorting.md - packages/build-infra/lib/release-checksums/consumer.mts - packages/build-infra/lib/release-checksums/core.mts ... and 3 more --- .../cascading-fleet/lib/cascade-template.sh | 35 ++++++- .../cascading-fleet/lib/fleet-repos.json | 53 +++++++++++ .../cascading-fleet/lib/fleet-repos.txt | 5 +- .config/.markdownlint-cli2.jsonc | 51 ++++++++++ .config/.prettierignore | 14 +++ .../_shared/wheelhouse-self-skip.mjs | 39 ++++++++ .../socket-no-private-wheelhouse-leak.mjs | 63 ++++++++++++ .../socket-no-relative-sibling-script.mjs | 68 +++++++++++++ .../socket-readme-required-sections.mjs | 95 +++++++++++++++++++ .../rules/prefer-cached-for-loop.mts | 10 +- .../rules/socket-api-token-env.mts | 25 ++++- .../rules/sort-boolean-chains.mts | 33 ++++--- .../test/no-underscore-identifier.test.mts | 10 +- .../test/prefer-cached-for-loop.test.mts | 8 ++ .../test/prefer-spawn-over-execsync.test.mts | 27 ++---- .../test/socket-api-token-env.test.mts | 7 ++ .../test/sort-boolean-chains.test.mts | 8 ++ docs/claude.md/fleet/sorting.md | 2 +- .../lib/release-checksums/consumer.mts | 22 ++--- .../lib/release-checksums/core.mts | 78 +++++++-------- scripts/install-token-minifier.mts | 16 ++-- scripts/validate-bundle-deps.mts | 13 ++- scripts/validate-file-size.mts | 16 ++++ 23 files changed, 586 insertions(+), 112 deletions(-) create mode 100644 .claude/skills/cascading-fleet/lib/fleet-repos.json create mode 100644 .config/.markdownlint-cli2.jsonc create mode 100644 .config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs create mode 100644 .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs create mode 100644 .config/markdownlint-rules/socket-no-relative-sibling-script.mjs create mode 100644 .config/markdownlint-rules/socket-readme-required-sections.mjs diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.sh b/.claude/skills/cascading-fleet/lib/cascade-template.sh index 7972c2e..5ba1669 100755 --- a/.claude/skills/cascading-fleet/lib/cascade-template.sh +++ b/.claude/skills/cascading-fleet/lib/cascade-template.sh @@ -53,15 +53,46 @@ echo "══ Cascade ${TEMPLATE_SHA} ══" echo "Log: $LOG_FILE" echo +# Resolve a canonical fleet repo name to a local primary checkout. +# Mirrors scripts/sync-scaffolding/discover.mts directoryAliasesFor(): +# canonical `socket-` also resolves to `~/projects//`; canonical +# `` (no socket- prefix — sdxgen, stuie, ultrathink) also resolves +# to `~/projects/socket-/`. First primary checkout wins. Echoes +# the resolved absolute path, or empty when no primary checkout exists. +resolveLocalCheckout() { + local canonical="$1" + local candidate + # Exact canonical name first. + candidate="${PROJECTS}/${canonical}" + if [ -d "${candidate}/.git" ]; then + echo "$candidate" + return 0 + fi + # Alias: socket-. + case "$canonical" in + socket-*) + candidate="${PROJECTS}/${canonical#socket-}" + ;; + *) + candidate="${PROJECTS}/socket-${canonical}" + ;; + esac + if [ -d "${candidate}/.git" ]; then + echo "$candidate" + return 0 + fi + return 1 +} + while IFS= read -r repo; do [ -z "$repo" ] && continue case "$repo" in '#'*) continue ;; esac - src="${PROJECTS}/${repo}" + src="$(resolveLocalCheckout "$repo")" wt="/tmp/cascade-${repo}-$$" echo "── ${repo} ──" - if [ ! -d "${src}/.git" ]; then + if [ -z "$src" ]; then RESULTS+=("${repo}|skip:no-git") continue fi diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.json b/.claude/skills/cascading-fleet/lib/fleet-repos.json new file mode 100644 index 0000000..e632036 --- /dev/null +++ b/.claude/skills/cascading-fleet/lib/fleet-repos.json @@ -0,0 +1,53 @@ +{ + "$schema": "./fleet-repos.schema.json", + "repos": [ + { + "name": "socket-addon", + "description": "NAPI .node binaries for @socketaddon/* npm packages" + }, + { + "name": "socket-bin", + "description": "SEA-packed CLI distributions for @socketbin/* packages" + }, + { + "name": "socket-btm", + "description": "Build toolchain — produces signed prebuilt binaries for @socketaddon/* and @socketbin/*" + }, + { + "name": "socket-cli", + "description": "Command-line interface for socket.dev security analysis" + }, + { + "name": "socket-lib", + "description": "Core library: fs, processes, HTTP, logging, env detection" + }, + { + "name": "socket-mcp", + "description": "Model Context Protocol server for socket.dev integration" + }, + { + "name": "socket-packageurl-js", + "description": "purl spec implementation for JavaScript" + }, + { + "name": "socket-registry", + "description": "Optimized package overrides for Socket Optimize" + }, + { + "name": "socket-sdk-js", + "description": "JavaScript SDK for the socket.dev API" + }, + { + "name": "sdxgen", + "description": "CycloneDX and SPDX manifest generator (Socket dx gen)" + }, + { + "name": "stuie", + "description": "Terminal UI library: OpenTUI + yoga-layout + React" + }, + { + "name": "socket-wheelhouse", + "description": "Internal scaffolding template for socket-* repos" + } + ] +} diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.txt b/.claude/skills/cascading-fleet/lib/fleet-repos.txt index 52aaf95..84ea8f1 100644 --- a/.claude/skills/cascading-fleet/lib/fleet-repos.txt +++ b/.claude/skills/cascading-fleet/lib/fleet-repos.txt @@ -1,4 +1,5 @@ socket-addon +socket-bin socket-btm socket-cli socket-lib @@ -6,5 +7,5 @@ socket-mcp socket-packageurl-js socket-registry socket-sdk-js -socket-sdxgen -socket-stuie +sdxgen +stuie diff --git a/.config/.markdownlint-cli2.jsonc b/.config/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..225ec71 --- /dev/null +++ b/.config/.markdownlint-cli2.jsonc @@ -0,0 +1,51 @@ +// markdownlint-cli2 configuration for fleet repos. +// +// Loaded by `pnpm run lint` (template/scripts/lint.mts invokes +// markdownlint-cli2 with `-c .config/.markdownlint-cli2.jsonc`). +// +// Two concerns: +// 1. Stock markdownlint rules — disable a handful that bite real prose +// without adding value, leave the rest at defaults. +// 2. Fleet-canonical custom rules under markdownlint-rules/ — these +// enforce the fleet's README hygiene contract (no private-repo +// mentions, no relative-path commands, README skeleton structure). +{ + "config": { + "default": true, + // MD013 (line-length) bites code-block-heavy READMEs without warning. + // Disabled fleet-wide; reviewers catch genuinely-too-long prose. + "MD013": false, + // MD033 (no inline HTML) bites our
collapsed Development + // sections and Socket-badge img tags. Allow specific tags only. + "MD033": { "allowed_elements": ["details", "summary", "img", "br"] }, + // MD041 (first-line-h1) is the contract; keep it on. + // MD024 (no-duplicate-heading) — siblings-only mode so that ### subsections + // can repeat under different ## parents (common in API docs). + "MD024": { "siblings_only": true } + }, + // Globs: every *.md / *.mdx under the repo, except generated output, + // node_modules, vendored upstream trees, and CHANGELOG.md (auto-generated). + "globs": ["**/*.md", "**/*.mdx"], + "ignores": [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/vendor/**", + "**/upstream/**", + "**/third_party/**", + "**/CHANGELOG.md", + // .claude/ markdown is internal scaffolding (hook READMEs, agent + // role files, skill SKILL.mds, command reminders) — scoped docs + // with their own shape conventions. Excluded from the public- + // facing markdown lint surface. + "**/.claude/**" + ], + // Custom rule plugins live under markdownlint-rules/, byte-identical + // across the fleet via sync-scaffolding manifest registration. + "customRules": [ + "./markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", + "./markdownlint-rules/socket-no-relative-sibling-script.mjs", + "./markdownlint-rules/socket-readme-required-sections.mjs" + ] +} diff --git a/.config/.prettierignore b/.config/.prettierignore index f8193ad..6cbe4c6 100644 --- a/.config/.prettierignore +++ b/.config/.prettierignore @@ -31,3 +31,17 @@ template/.claude/hooks/_shared/acorn/acorn.wasm template/.claude/hooks/_shared/acorn/acorn-bindgen.cjs .claude/hooks/_shared/acorn/acorn-bindgen.cjs + +# Vendored / upstream trees — kept byte-identical with their source +# of truth. Per CLAUDE.md "Untracked-by-default for vendored / build- +# copied trees": these are someone else's source, not ours, and the +# formatter would happily rewrite (e.g.) an upstream HTML test +# fixture or shipped third-party JS into our local style. +**/upstream/** +upstream/** +**/vendor/** +vendor/** +**/third_party/** +third_party/** +**/external/** +external/** diff --git a/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs b/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs new file mode 100644 index 0000000..9cd445a --- /dev/null +++ b/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs @@ -0,0 +1,39 @@ +/** + * @file Shared helper for fleet markdown rules: detect whether the lint + * is running inside socket-wheelhouse itself, in which case the rule + * should bail. The custom rules in this directory exist to protect + * PUBLIC fleet consumers from leaking internal scaffolding; wheelhouse + * referencing itself in its own docs is the canonical case and must + * not trigger. + * + * Detection prefers explicit env override (CI sets + * SOCKET_FLEET_REPO_NAME) then falls back to checking the cwd's + * basename and git remote. + */ + +import { execSync } from 'node:child_process' +import path from 'node:path' + +export function isInsideWheelhouse() { + const envName = process.env['SOCKET_FLEET_REPO_NAME'] + if (envName) { + return envName === 'socket-wheelhouse' + } + const cwd = process.cwd() + if (path.basename(cwd) === 'socket-wheelhouse') { + return true + } + // Fallback: probe the git remote URL. Tolerates renamed local + // checkout dirs (`~/projects/wheelhouse/` would still match). + try { + const remote = execSync('git config --get remote.origin.url', { + cwd, + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim() + return /[/:]socket-wheelhouse(?:\.git)?$/.test(remote) + } catch { + return false + } +} diff --git a/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs b/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs new file mode 100644 index 0000000..3bbdab5 --- /dev/null +++ b/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs @@ -0,0 +1,63 @@ +/** + * @file Flag mentions of `socket-wheelhouse` in public-facing markdown. + * + * socket-wheelhouse is a private repo. Public READMEs / docs / release notes + * that link to it leak the internal tooling layout to users who can't access + * the link anyway. Whatever the markdown is trying to teach should be + * rewritten to not require the reference. + * + * Detects: + * - The literal token `socket-wheelhouse` (case-insensitive) anywhere + * in a line. + * - `https://github.com/SocketDev/socket-wheelhouse...` URL forms. + * + * Skips fenced code blocks because those are intentional examples + * (and fenced-block scanning would false-positive on the very + * markdownlint config that references this file). + * + * No autofix: the right rewrite is contextual. + */ + +import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' + +const RULE_NAME = 'socket-no-private-wheelhouse-leak' +const FORBIDDEN_TOKEN_RE = /socket-wheelhouse/i + +/** @type {import("markdownlint").Rule} */ +const rule = { + names: [RULE_NAME, 'socket/no-private-wheelhouse-leak'], + description: + 'socket-wheelhouse is a private repo — never reference it in public markdown', + tags: ['socket', 'privacy'], + parser: 'none', + function(params, onError) { + if (isInsideWheelhouse()) { + return + } + let inFence = false + for (let i = 0; i < params.lines.length; i += 1) { + const line = params.lines[i] + // Track fenced-code state. Open/close on lines that START with ``` or ~~~. + if (/^\s*(?:```|~~~)/.test(line)) { + inFence = !inFence + continue + } + if (inFence) { + continue + } + const match = FORBIDDEN_TOKEN_RE.exec(line) + if (!match) { + continue + } + onError({ + lineNumber: i + 1, + detail: + 'Rewrite to not mention socket-wheelhouse — it is a private repo and the link will 404 for outside readers.', + context: line.trim().slice(0, 120), + range: [match.index + 1, match[0].length], + }) + } + }, +} + +export default rule diff --git a/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs b/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs new file mode 100644 index 0000000..d1bc9ac --- /dev/null +++ b/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs @@ -0,0 +1,68 @@ +/** + * @file Flag commands that reference sibling repos via relative paths. + * + * `node ../socket-foo/scripts/bar.mts` in a fleet README assumes the + * reader has the sibling repo checked out at exactly the right level + * relative to the current repo. That's almost never true for an + * outside user, and the command silently fails. + * + * Detects (inside fenced code blocks and inline `code`): + * - `node ..//...` invocations + * - `pnpm ..//...` invocations + * - Bare `../socket-/...` references in code/inline-code + * + * Skips: relative paths to the current repo's own tree (`./scripts/`, + * `../package.json` within a monorepo), which are useful and don't + * leak sibling state. + * + * No autofix: the rewrite is to either inline the script's content or + * publish the helper to npm and reference the published name. + */ + +import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' + +const RULE_NAME = 'socket-no-relative-sibling-script' +const SIBLING_PATH_RES = [ + // Detect ` ..//...` where runner is node/pnpm/npm/yarn/bun + // (anything resembling a runtime invocation). + /\b(?:node|pnpm|npm|yarn|bun|deno)\s+\.\.\/[\w@-]+\//, + // Detect bare ..// where the first segment doesn't start with `.` + // (i.e. genuine sibling, not the current repo's `..` for monorepo packages). + /(?:^|\s)\.\.\/socket-[\w-]+\//i, + /(?:^|\s)\.\.\/sdxgen\//, + /(?:^|\s)\.\.\/stuie\//, +] + +/** @type {import("markdownlint").Rule} */ +const rule = { + names: [RULE_NAME, 'socket/no-relative-sibling-script'], + description: + 'Commands referencing sibling fleet repos via relative paths fail for outside readers', + tags: ['socket', 'fleet'], + parser: 'none', + function(params, onError) { + if (isInsideWheelhouse()) { + return + } + for (let i = 0; i < params.lines.length; i += 1) { + const line = params.lines[i] + for (let j = 0; j < SIBLING_PATH_RES.length; j += 1) { + const re = SIBLING_PATH_RES[j] + const match = re.exec(line) + if (!match) { + continue + } + onError({ + lineNumber: i + 1, + detail: + 'Rewrite the command to not depend on a sibling-repo checkout. Inline the script, link to its source on GitHub, or publish the helper to npm and reference the package name.', + context: line.trim().slice(0, 120), + range: [match.index + 1, match[0].length], + }) + break + } + } + }, +} + +export default rule diff --git a/.config/markdownlint-rules/socket-readme-required-sections.mjs b/.config/markdownlint-rules/socket-readme-required-sections.mjs new file mode 100644 index 0000000..d66fee3 --- /dev/null +++ b/.config/markdownlint-rules/socket-readme-required-sections.mjs @@ -0,0 +1,95 @@ +/** + * @file Enforce the canonical fleet README section list. + * + * Fires only on the repo-root `README.md` (skipped for nested READMEs + * under `packages/`, `docs/`, `.claude/`, etc. — those are scoped docs + * with their own shape). Every fleet root README must contain five + * level-2 sections in this order: + * + * 1. Why this repo exists + * 2. Install + * 3. Usage + * 4. Development + * 5. License + * + * The canonical skeleton lives at socket-wheelhouse/template/README.md. + * Additional sections between/after these are allowed; reordering / + * missing / typo'd sections are findings. + * + * No autofix: a missing section needs content, not just a heading. + */ + +import path from 'node:path' + +import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' + +const RULE_NAME = 'socket-readme-required-sections' +const REQUIRED_SECTIONS = [ + 'Why this repo exists', + 'Install', + 'Usage', + 'Development', + 'License', +] + +function isRootReadme(filePath) { + // markdownlint passes `params.name` as a path relative to the working + // dir. The root README is the one whose basename is README.md AND + // whose directory is the cwd or `.`. + if (!filePath) { + return false + } + const base = path.basename(filePath) + if (base !== 'README.md') { + return false + } + const dir = path.dirname(filePath) + return dir === '.' || dir === '' || dir === process.cwd() +} + +/** @type {import("markdownlint").Rule} */ +const rule = { + names: [RULE_NAME, 'socket/readme-required-sections'], + description: + 'Fleet root README must contain the canonical five sections in order', + tags: ['socket', 'fleet', 'readme'], + parser: 'none', + function(params, onError) { + if (isInsideWheelhouse()) { + return + } + if (!isRootReadme(params.name)) { + return + } + const headings = [] + for (let i = 0; i < params.lines.length; i += 1) { + const line = params.lines[i] + const m = /^##\s+(.+?)\s*$/.exec(line) + if (m) { + headings.push({ text: m[1], lineNumber: i + 1 }) + } + } + let cursor = 0 + for (let r = 0; r < REQUIRED_SECTIONS.length; r += 1) { + const want = REQUIRED_SECTIONS[r] + let found = -1 + for (let h = cursor; h < headings.length; h += 1) { + if (headings[h].text === want) { + found = h + break + } + } + if (found === -1) { + onError({ + lineNumber: 1, + detail: `Missing required section "## ${want}" (or it appears out of order). Canonical order: ${REQUIRED_SECTIONS.map((s) => `"## ${s}"`).join(' → ')}.`, + context: `README.md: required section "## ${want}" not found after position ${cursor}`, + }) + return + } + cursor = found + 1 + } + }, +} + +export default rule diff --git a/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts b/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts index 3f43f2a..94fbbcf 100644 --- a/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts +++ b/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts @@ -340,8 +340,16 @@ const rule = { // integer-indexable). The companion rule // socket/no-cached-for-on-iterable would then flag what THIS // rule just wrote. Skip silently rather than fight ourselves. + // + // Also skip when the kind can't be determined from the AST + // (e.g. `await fn()` / `someCall()` initializers without a + // type annotation). Without type info we can't prove the + // iterable is integer-indexable, and autofixing produces + // broken code (Set.length / Set[i]) on the wrong guess. + // Require explicit array shape (literal, type annotation, + // Array.from, Object.keys/values/entries) to opt in. const iterKind = resolveKind(node, iter.name as string) - if (FLAGGED_KINDS.has(iterKind)) { + if (FLAGGED_KINDS.has(iterKind) || iterKind === 'unknown') { return } if (node.body.type !== 'BlockStatement') { diff --git a/.config/oxlint-plugin/rules/socket-api-token-env.mts b/.config/oxlint-plugin/rules/socket-api-token-env.mts index 2f0e681..ed24ec8 100644 --- a/.config/oxlint-plugin/rules/socket-api-token-env.mts +++ b/.config/oxlint-plugin/rules/socket-api-token-env.mts @@ -56,12 +56,27 @@ const rule = { : context.sourceCode function hasBypassComment(node: AstNode) { - const before = sourceCode.getCommentsBefore(node) - const after = sourceCode.getCommentsAfter(node) - for (const c of [...before, ...after]) { - if (BYPASS_RE.test(c.value)) { - return true + // Walk up: literal -> array element -> array/declaration. The bypass + // comment can sit on the literal itself OR on any ancestor up to (and + // including) the nearest statement. This lets the entire alias-lookup + // array carry one bypass instead of needing one per element. + let cursor: AstNode | undefined = node + while (cursor) { + const before = sourceCode.getCommentsBefore(cursor) + const after = sourceCode.getCommentsAfter(cursor) + for (const c of [...before, ...after]) { + if (BYPASS_RE.test(c.value)) { + return true + } + } + if ( + cursor.type === 'ExpressionStatement' || + cursor.type === 'VariableDeclaration' || + cursor.type === 'ExportNamedDeclaration' + ) { + break } + cursor = cursor.parent } return false } diff --git a/.config/oxlint-plugin/rules/sort-boolean-chains.mts b/.config/oxlint-plugin/rules/sort-boolean-chains.mts index 3546bc7..b7fd69c 100644 --- a/.config/oxlint-plugin/rules/sort-boolean-chains.mts +++ b/.config/oxlint-plugin/rules/sort-boolean-chains.mts @@ -1,27 +1,33 @@ /** * @file Sort all-identifier boolean chains alphanumerically. Per CLAUDE.md - * "Sorting" rule, a chain like `agentshieldOk && zizmorOk && sfwOk` reads - * with the identifier names in alpha order: `agentshieldOk && sfwOk && + * "Sorting" rule, a flag-list chain like `agentshieldOk && zizmorOk && sfwOk` + * reads with the identifier names in alpha order: `agentshieldOk && sfwOk && * zizmorOk`. The runtime is short-circuit-insensitive to operand order _when * every operand is a plain identifier_ (no calls, no member access with * getters) — so reordering doesn't change semantics. Sorting reduces diff * churn when adding a new flag and makes "is everything ready?" checks - * visually consistent. Detects: chains of `&&` or `||` whose operands are ALL - * bare Identifiers (length ≥ 2, no duplicates, uniform operator across the - * flattened chain). Skipped (not reported, autofix-safe stays narrow): + * visually consistent. Scope: lists of flags, not guard pairs. The rule ONLY + * fires on chains of length ≥ 3. Two-operand chains like `useHttp && + * oauthEnabled` are guard patterns — the order carries narrative ("in HTTP + * mode, did OAuth get enabled?") that alpha-sort destroys. Three or more bare + * identifiers in a single chain is the structural signal that it's a flag + * list, not a guard. Detects: chains of `&&` or `||` whose operands are ALL + * bare Identifiers (length ≥ 3, no duplicates, uniform operator across the + * flattened chain). Skipped (not reported): * + * - Length 2 — guard patterns; narrative order is intentional. * - Any operand isn't a bare `Identifier` (Calls / member-access / literals / * negations / nested non-uniform logical exprs short-circuit, and a * `getter` on a member-access can have side effects — reordering would be * observable). * - Duplicate identifiers in the chain (rare, but rewriting through the * duplicate would silently drop one). - * - Comments live between operands (autofix would relocate them). - * - Chain length < 2 (nothing to sort). Why a separate rule from - * sort-equality-disjunctions: that rule sorts the right-hand string-literal - * of an equality chain (`x === 'a' || x === 'b'`); this rule sorts the - * bare-identifier operands of a pure-identifier chain. Structurally - * different ASTs, semantically different safety arguments. + * - Comments live between operands (autofix would relocate them). Why a + * separate rule from sort-equality-disjunctions: that rule sorts the + * right-hand string-literal of an equality chain (`x === 'a' || x === + * 'b'`); this rule sorts the bare-identifier operands of a pure-identifier + * chain. Structurally different ASTs, semantically different safety + * arguments. */ /** @@ -103,7 +109,10 @@ const rule = { const leaves: AstNode[] = [] flatten(rootNode, op, leaves) - if (leaves.length < 2) { + // Length 2 chains are guard patterns (`useHttp && oauthEnabled`) + // where order carries narrative; only length 3+ chains are flag + // lists where alpha-sort is unambiguously a readability win. + if (leaves.length < 3) { return } diff --git a/.config/oxlint-plugin/test/no-underscore-identifier.test.mts b/.config/oxlint-plugin/test/no-underscore-identifier.test.mts index 55c0baa..bffa1bf 100644 --- a/.config/oxlint-plugin/test/no-underscore-identifier.test.mts +++ b/.config/oxlint-plugin/test/no-underscore-identifier.test.mts @@ -1,9 +1,9 @@ /** - * @file Unit tests for the no-underscore-identifier oxlint rule. - * Spawns the real oxlint binary against fixture files in a tmp - * dir (see lib/rule-tester.mts). Skips silently when `oxlint` - * isn't on PATH so a fresh-laptop checkout doesn't false-fail - * before `pnpm install` materializes the bin link. + * @file Unit tests for the no-underscore-identifier oxlint rule. Spawns the + * real oxlint binary against fixture files in a tmp dir (see + * lib/rule-tester.mts). Skips silently when `oxlint` isn't on PATH so a + * fresh-laptop checkout doesn't false-fail before `pnpm install` materializes + * the bin link. */ import { describe, test } from 'node:test' diff --git a/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts b/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts index e848573..a24f962 100644 --- a/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts +++ b/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts @@ -19,6 +19,14 @@ describe('socket/prefer-cached-for-loop', () => { name: 'for-of', code: 'for (const x of [1,2,3]) {}\n', }, + { + name: 'for-of over awaited value — unknown kind, skip autofix', + code: + 'async function f() {\n' + + ' const items = await getThings()\n' + + ' for (const x of items) { console.log(x) }\n' + + '}\n', + }, ], invalid: [ { diff --git a/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts b/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts index 71d2f42..766cf9c 100644 --- a/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts +++ b/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts @@ -1,9 +1,9 @@ /** - * @file Unit tests for the prefer-spawn-over-execsync oxlint rule. - * Spawns the real oxlint binary against fixture files in a tmp - * dir (see lib/rule-tester.mts). Skips silently when `oxlint` - * isn't on PATH so a fresh-laptop checkout doesn't false-fail - * before `pnpm install` materializes the bin link. + * @file Unit tests for the prefer-spawn-over-execsync oxlint rule. Spawns the + * real oxlint binary against fixture files in a tmp dir (see + * lib/rule-tester.mts). Skips silently when `oxlint` isn't on PATH so a + * fresh-laptop checkout doesn't false-fail before `pnpm install` materializes + * the bin link. */ import { describe, test } from 'node:test' @@ -17,13 +17,11 @@ describe('socket/prefer-spawn-over-execsync', () => { valid: [ { name: 'lib-stable spawn import', - code: - "import { spawn } from '@socketsecurity/lib-stable/spawn'\n", + code: "import { spawn } from '@socketsecurity/lib-stable/spawn'\n", }, { name: 'lib-stable spawnSync import', - code: - "import { spawnSync } from '@socketsecurity/lib-stable/spawn'\n", + code: "import { spawnSync } from '@socketsecurity/lib-stable/spawn'\n", }, { name: 'node:child_process spawn (not exec*Sync) is acceptable', @@ -41,18 +39,13 @@ describe('socket/prefer-spawn-over-execsync', () => { }, { name: 'execFileSync from node:child_process', - code: - "import { execFileSync } from 'node:child_process'\n", + code: "import { execFileSync } from 'node:child_process'\n", errors: [{ messageId: 'preferSpawn' }], }, { name: 'mixed execSync + execFileSync', - code: - "import { execSync, execFileSync } from 'node:child_process'\n", - errors: [ - { messageId: 'preferSpawn' }, - { messageId: 'preferSpawn' }, - ], + code: "import { execSync, execFileSync } from 'node:child_process'\n", + errors: [{ messageId: 'preferSpawn' }, { messageId: 'preferSpawn' }], }, ], }) diff --git a/.config/oxlint-plugin/test/socket-api-token-env.test.mts b/.config/oxlint-plugin/test/socket-api-token-env.test.mts index dcfb34b..550bbca 100644 --- a/.config/oxlint-plugin/test/socket-api-token-env.test.mts +++ b/.config/oxlint-plugin/test/socket-api-token-env.test.mts @@ -15,6 +15,13 @@ describe('socket/socket-api-token-env', () => { name: 'canonical SOCKET_API_TOKEN', code: 'const t = process.env["SOCKET_API_TOKEN"]\nconsole.log(t)\n', }, + { + name: 'alias-lookup array with declaration-level bypass comment', + code: + '// socket-api-token-env: bootstrap -- alias-normalization shim.\n' + + "const ALIASES = ['SOCKET_API_TOKEN', 'SOCKET_API_KEY', 'SOCKET_SECURITY_API_TOKEN'] as const\n" + + 'console.log(ALIASES)\n', + }, ], invalid: [ { diff --git a/.config/oxlint-plugin/test/sort-boolean-chains.test.mts b/.config/oxlint-plugin/test/sort-boolean-chains.test.mts index 7c04eab..6f12230 100644 --- a/.config/oxlint-plugin/test/sort-boolean-chains.test.mts +++ b/.config/oxlint-plugin/test/sort-boolean-chains.test.mts @@ -31,6 +31,14 @@ describe('socket/sort-boolean-chains', () => { name: 'single operand — not a chain', code: 'export const r = (a: boolean) => a\n', }, + { + name: 'two-operand guard pair — narrative order preserved', + code: 'export const r = (useHttp: boolean, oauthEnabled: boolean) => useHttp && oauthEnabled\n', + }, + { + name: 'two-operand reversed guard pair — still not sorted', + code: 'export const r = (b: boolean, a: boolean) => b && a\n', + }, { name: 'duplicates skipped', code: 'export const r = (b: boolean, a: boolean) => b && a && b\n', diff --git a/docs/claude.md/fleet/sorting.md b/docs/claude.md/fleet/sorting.md index 2f7b5cc..9ea6ea6 100644 --- a/docs/claude.md/fleet/sorting.md +++ b/docs/claude.md/fleet/sorting.md @@ -12,7 +12,7 @@ Sort lists alphanumerically (literal byte order, ASCII before letters). - **`Set` constructor arguments** — `new Set([...])` and `new SafeSet([...])` literals. The runtime is order-insensitive, so source order is alphanumeric. Same rationale as Array literals: predictable diffs, no merge conflicts on insertions. - **Regex alternation groups** — `(foo|bar|baz)` reads as `(bar|baz|foo)`. Capturing, non-capturing, and named-capture groups all follow the rule. Auto-fixable when every alternative is a simple literal. The exception is order-bearing alternations where the regex engine MUST try one alternative before another (rare; the canonical example is markup parsers where `` would silently mismatch if reordered) — append `// socket-hook: allow regex-alternation-order` on those lines. - **String-equality disjunctions** — `x === 'a' || x === 'b' || x === 'c'` reads with the comparand strings in alpha order. The De Morgan dual `x !== 'a' && x !== 'b'` (negative-membership check) follows the same rule. The `||` chain short-circuits regardless of operand order; sorting reduces diff churn when adding new comparands and makes "is X in this set?" checks visually consistent. Auto-fixable when every clause has the same left operand and uses string-literal comparands. Mixed shape (different left, different operator, non-string right) is skipped — those are usually genuine ordering-sensitive predicates and the autofix would change semantics. -- **Boolean identifier chains** — `agentshieldOk && zizmorOk && sfwOk` reads with the names in alpha order: `agentshieldOk && sfwOk && zizmorOk`. Same rule for `||` chains. The lint rule fires only when every leaf is a bare `Identifier` (no calls, no member access, no literals, no negations) — those have side-effect or short-circuit semantics where order can be observable. Duplicate identifiers and chains with interior comments are skipped (the autofix would lose information). Enforced by `socket/sort-boolean-chains`. +- **Boolean identifier chains** — `agentshieldOk && zizmorOk && sfwOk` reads with the names in alpha order: `agentshieldOk && sfwOk && zizmorOk`. Same rule for `||` chains. The lint rule fires only when (1) every leaf is a bare `Identifier` (no calls, no member access, no literals, no negations) — those have side-effect or short-circuit semantics where order can be observable — AND (2) the chain has **3 or more operands**. Two-operand chains like `useHttp && oauthEnabled` are guard patterns where order carries narrative ("in HTTP mode, did OAuth get enabled?") that alpha-sort would destroy; only length-3+ chains are unambiguously flag lists. Duplicate identifiers and chains with interior comments are skipped (the autofix would lose information). Enforced by `socket/sort-boolean-chains`. - **TypeScript union of string literals** — `type Source = 'download' | 'path' | 'vfs'` (not `'vfs' | 'path' | 'download'`). Members are interchangeable at the type level; alpha order makes "which values can this take?" answerable without scanning. Applies to type aliases, inline parameter unions, and template-literal type alternatives. Position-bearing unions (rare — e.g. a discriminator where order encodes priority) keep their meaningful order; append `// socket-hook: allow union-order` on those lines. ## Default diff --git a/packages/build-infra/lib/release-checksums/consumer.mts b/packages/build-infra/lib/release-checksums/consumer.mts index 33222be..aac24d1 100644 --- a/packages/build-infra/lib/release-checksums/consumer.mts +++ b/packages/build-infra/lib/release-checksums/consumer.mts @@ -35,6 +35,13 @@ export interface ChecksumsResult { const checksumCache = new Map() +/** + * Clear the checksum cache. Useful for testing or forcing re-download. + */ +export function clearChecksumCache(): void { + checksumCache.clear() +} + interface GetChecksumsOptions { /** * The producing repo whose releases we're verifying against. @@ -47,22 +54,22 @@ interface GetChecksumsOptions { /** * Specific tag to fetch. If omitted, uses the embedded tag, then `latest`. */ - releaseTag?: string + releaseTag?: string | undefined /** * Where to cache the downloaded `checksums.txt`. Defaults to * `/build/temp`. */ - tempDir?: string + tempDir?: string | undefined /** * Suppress info/warn logging (errors still log). */ - quiet?: boolean + quiet?: boolean | undefined /** * If true (default), use embedded checksums when available even if a network * fetch could find newer ones. Set false to force a network fetch — useful * when bumping checksums. */ - preferEmbedded?: boolean + preferEmbedded?: boolean | undefined } /** @@ -208,10 +215,3 @@ export async function getReleaseChecksums( return { checksums: {}, source: 'network', tag } } } - -/** - * Clear the checksum cache. Useful for testing or forcing re-download. - */ -export function clearChecksumCache(): void { - checksumCache.clear() -} diff --git a/packages/build-infra/lib/release-checksums/core.mts b/packages/build-infra/lib/release-checksums/core.mts index 7be5e63..aaa1ae5 100644 --- a/packages/build-infra/lib/release-checksums/core.mts +++ b/packages/build-infra/lib/release-checksums/core.mts @@ -29,7 +29,7 @@ const __dirname = path.dirname(__filename) // --------------------------------------------------------------------------- export interface ToolConfig { - description?: string + description?: string | undefined tag: string checksums: Record } @@ -37,10 +37,10 @@ export interface ToolConfig { export type EmbeddedChecksums = Record export interface VerifyResult { - actual?: string - expected?: string - source?: string - skipped?: boolean + actual?: string | undefined + expected?: string | undefined + source?: string | undefined + skipped?: boolean | undefined valid: boolean } @@ -55,27 +55,16 @@ export interface VerifyResult { let embeddedChecksums: EmbeddedChecksums | undefined | null -export function getEmbeddedChecksums(): EmbeddedChecksums | undefined { - if (embeddedChecksums === null) { - return undefined - } - if (embeddedChecksums === undefined) { - try { - const checksumPath = path.join( - __dirname, - '..', - '..', - 'release-assets.json', - ) - embeddedChecksums = JSON.parse( - readFileSync(checksumPath, 'utf8'), - ) as EmbeddedChecksums - } catch { - embeddedChecksums = null - return undefined - } +/** + * Compute SHA256 hash of a file as lowercase hex. + */ +export async function computeFileHash(filePath: string): Promise { + const hash = crypto.createHash('sha256') + const stream = createReadStream(filePath) + for await (const chunk of stream) { + hash.update(chunk) } - return embeddedChecksums + return hash.digest('hex') } export function getEmbeddedChecksum( @@ -97,20 +86,27 @@ export function getEmbeddedChecksum( return { checksum, tag: toolConfig.tag } } -// --------------------------------------------------------------------------- -// Format primitives. -// --------------------------------------------------------------------------- - -/** - * Compute SHA256 hash of a file as lowercase hex. - */ -export async function computeFileHash(filePath: string): Promise { - const hash = crypto.createHash('sha256') - const stream = createReadStream(filePath) - for await (const chunk of stream) { - hash.update(chunk) +export function getEmbeddedChecksums(): EmbeddedChecksums | undefined { + if (embeddedChecksums === null) { + return undefined } - return hash.digest('hex') + if (embeddedChecksums === undefined) { + try { + const checksumPath = path.join( + __dirname, + '..', + '..', + 'release-assets.json', + ) + embeddedChecksums = JSON.parse( + readFileSync(checksumPath, 'utf8'), + ) as EmbeddedChecksums + } catch { + embeddedChecksums = undefined + return undefined + } + } + return embeddedChecksums } /** @@ -136,15 +132,11 @@ export function parseChecksums(content: string): Record { return checksums } -// --------------------------------------------------------------------------- -// Verify. -// --------------------------------------------------------------------------- - interface VerifyOptions { filePath: string assetName: string tool: string - quiet?: boolean + quiet?: boolean | undefined } /** diff --git a/scripts/install-token-minifier.mts b/scripts/install-token-minifier.mts index 2430a2e..46af914 100644 --- a/scripts/install-token-minifier.mts +++ b/scripts/install-token-minifier.mts @@ -94,7 +94,7 @@ interface CatalogYamlMap { * line-shaped (key: value) and we only need the @socketsecurity/* entries the * proxy actually references. */ -function readNeededCatalogEntries(): CatalogYamlMap { +export function readNeededCatalogEntries(): CatalogYamlMap { const yamlPath = path.join(WHEELHOUSE_ROOT, 'pnpm-workspace.yaml') const text = readFileSync(yamlPath, 'utf8') const lines = text.split('\n') @@ -140,7 +140,7 @@ function readNeededCatalogEntries(): CatalogYamlMap { * catalog aliases the package source declares. Keeps imports of * `@socketsecurity/lib-stable/...` resolvable from inside the install. */ -function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { +export function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { const lines = ['catalog:'] for (const [k, v] of Object.entries(catalog)) { // Quote values that aren't bare versions (e.g. `npm:foo@1.0.0`). @@ -161,7 +161,7 @@ function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { * Stripping `bin`/`exports` keeps pnpm from trying to wire global binaries at * install time — we drop our own shim explicitly. */ -function writeInstallPackageJson(sourceVersion: string): void { +export function writeInstallPackageJson(sourceVersion: string): void { const sourcePkg = JSON.parse( readFileSync(path.join(PKG_SOURCE_DIR, 'package.json'), 'utf8'), ) @@ -190,7 +190,7 @@ function writeInstallPackageJson(sourceVersion: string): void { * `fs.cp` with recursive + force is the cross-platform equivalent of `cp -r`. * Force overwrites stale files on reinstall. */ -function copySource(): void { +export function copySource(): void { // Use sync fs API for consistency with the rest of the script — this // is a one-shot install, not a hot path. `cpSync` exists since // Node 20; the recursive option is required for directories. @@ -207,14 +207,14 @@ function copySource(): void { * when the recorded x-source-version in the dest's package.json differs from * the source. */ -function readSourceVersion(): string { +export function readSourceVersion(): string { const pkg = JSON.parse( readFileSync(path.join(PKG_SOURCE_DIR, 'package.json'), 'utf8'), ) return pkg.version ?? '0.0.0' } -function readInstalledVersion(): string | undefined { +export function readInstalledVersion(): string | undefined { const installedPkgPath = path.join(INSTALL_DIR, 'package.json') if (!existsSync(installedPkgPath)) { return undefined @@ -227,7 +227,7 @@ function readInstalledVersion(): string | undefined { } } -function pnpmInstallAtDest(quiet: boolean): void { +export function pnpmInstallAtDest(quiet: boolean): void { const result = spawnSync( 'pnpm', [ @@ -248,7 +248,7 @@ function pnpmInstallAtDest(quiet: boolean): void { } } -function writeBinShim(): void { +export function writeBinShim(): void { // Shim execs the proxy's top-level bin/ entry. Source lives at // INSTALL_DIR/bin/, NOT under node_modules/ — so Node 22+ can strip // types from the .mts file at runtime. `node` is on PATH on every diff --git a/scripts/validate-bundle-deps.mts b/scripts/validate-bundle-deps.mts index 111007b..c71bb93 100644 --- a/scripts/validate-bundle-deps.mts +++ b/scripts/validate-bundle-deps.mts @@ -23,10 +23,15 @@ const logger = getDefaultLogger() const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootPath = path.join(__dirname, '..') -// Node.js builtins to ignore (including node: prefix variants) +// Node.js builtins to ignore (including node: prefix variants). +// node:smol-* are Socket SEA-bundled optional builtins (smol-util, smol-primordial); +// they appear in dist behind `mod.isBuiltin('node:smol-util')` guards and are only +// resolvable in SEA binaries, so they should never be expected in dependencies. +const SOCKET_SEA_BUILTINS = ['node:smol-util', 'node:smol-primordial'] const BUILTIN_MODULES = new Set([ ...builtinModules, ...builtinModules.map(m => `node:${m}`), + ...SOCKET_SEA_BUILTINS, ]) /** @@ -364,16 +369,14 @@ async function validateBundleDeps(): Promise { // externals + bundled are Set — use for...of, the // canonical fix for set / map / iterable iteration. - for (let i = 0, { length } = externals; i < length; i += 1) { - const ext = externals[i]! + for (const ext of externals) { const packageName = getPackageName(ext) if (packageName && !BUILTIN_MODULES.has(packageName)) { allExternals.add(packageName) } } - for (let i = 0, { length } = bundled; i < length; i += 1) { - const bun = bundled[i]! + for (const bun of bundled) { allBundled.add(bun) } } diff --git a/scripts/validate-file-size.mts b/scripts/validate-file-size.mts index 19b624a..41b3bcc 100644 --- a/scripts/validate-file-size.mts +++ b/scripts/validate-file-size.mts @@ -23,6 +23,19 @@ const rootPath = path.join(__dirname, '..') // Maximum file size: 2MB (2,097,152 bytes) const MAX_FILE_SIZE = 2 * 1024 * 1024 +// Allowlisted large files: fleet-canonical assets whose size is bounded by +// the upstream they ship, not by repo authoring. acorn.wasm is the AST +// parser shared by AST-based oxlint plugin rules + hooks; its ~3MB is the +// upstream build artifact. Two paths because socket-lib vendors its own +// copy at vendor/acorn-wasm/ (so the lib package's own AST helpers can +// load without a node_modules round-trip). Adding a path here is +// intentional — it should only happen for files the fleet jointly owns, +// not per-repo binary leaks. +const ALLOWED_LARGE_FILES = new Set([ + '.claude/hooks/_shared/acorn/acorn.wasm', + 'vendor/acorn-wasm/acorn.wasm', +]) + // Directories to skip const SKIP_DIRS = new Set([ '.cache', @@ -95,6 +108,9 @@ async function scanDirectory( const stats = await fs.stat(fullPath) if (stats.size > MAX_FILE_SIZE) { const relativePath = path.relative(rootPath, fullPath) + if (ALLOWED_LARGE_FILES.has(relativePath)) { + continue + } violations.push({ file: relativePath, size: stats.size,