diff --git a/.claude/settings.json b/.claude/settings.json index 94c7e30e..dfbc3029 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,4 @@ { - "// ": "Managed by socket-wheelhouse; edit the template, then cascade.", - "// auth": "No apiKeyHelper. Claude Code spawns that runner with neither env nor cwd, so it could never read AI_BALANCER_ENABLED, and a helper that returns nothing renders as 'apiKeyHelper failed: did not return a value' on every launch. The balancer route instead exports ANTHROPIC_API_KEY into CLAUDE_ENV_FILE from the ai-balancer-proxy-start SessionStart hook, which DOES receive the env and so can gate on the flag. Nothing persists in settings, so the claude.ai login is the default and needs no undo.", - "// env": "The fleet no-phone-home knobs, one copy per delivery surface. FLEET_ENV (.github/actions/fleet/setup/fleet-env.json) is the source; the shell-rc bridge and the CI workflow env already derive from it, and this block is the third surface — a Claude Code session and every tool it spawns. Kept in lockstep by agent/settings-env-matches-fleet-env.mts, so a knob added there fails this file until it is added here too.", "env": { "AI_BALANCER_ENABLED": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", @@ -12,67 +9,6 @@ "OTEL_SDK_DISABLED": "true", "SFW_TELEMETRY_DISABLED": "true" }, - "hooks": { - "PostToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/index.cjs PostToolUse" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": ".*", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/index.cjs PreToolUse" - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/scripts/repo/bootstrap/fetch-session.mts" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/index.cjs SessionStart" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/index.cjs Stop" - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/index.cjs UserPromptSubmit" - } - ] - } - ] - }, "permissions": { "allow": [ "Bash(gh release create:*)", @@ -88,11 +24,5 @@ "Bash(git push -f:*)", "Bash(git push --force-with-lease:*)" ] - }, - "// statusLine": "Fail-soft on purpose. scripts/fleet is FETCHED from the release bundle, never tracked, so the script is absent until a fetch has run. Measured without the -f test: the statusline exited 1 on module-not-found, once per render. The test makes an absent script render nothing instead.", - "statusLine": { - "type": "command", - "command": "bin=\"$CLAUDE_PROJECT_DIR/.claude/hooks/fleet/_dist/statusline/statusline-render-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')\" && [ -x \"$bin\" ] && exec \"$bin\" || script=\"$CLAUDE_PROJECT_DIR/scripts/fleet/spend-statusline.mts\" && [ -f \"$script\" ] && node \"$script\" || true" - }, - "// ": "Repository-owned Claude settings belong below this marker." + } } diff --git a/.config/fleet/.prettierignore b/.config/fleet/.prettierignore deleted file mode 100644 index e80a039a..00000000 --- a/.config/fleet/.prettierignore +++ /dev/null @@ -1,133 +0,0 @@ -**/build/** -!**/scripts/fleet/build/**/ -!**/scripts/fleet/build/**/*.mts -!**/scripts/repo/build/**/ -!**/scripts/repo/build/**/*.mts -**/build/**/build/** -**/build/**/*.d.mts - -# Format-ignore (but track) — files we keep byte-identical with their upstream / -# vendored / generated source. oxfmt reads this file via -# `--ignore-path .config/fleet/.prettierignore`; the lint runner threads the flag -# so the convention works from any working directory. -# -# EVERY pattern here is `**/`-anchored. oxfmt builds the matcher with -# `Gitignore::new(path)` (oxc apps/oxfmt/src/cli/resolve.rs), which roots it at -# THIS file's directory (.config/fleet/) — so a bare slashed pattern like -# `upstream/**` anchors to `.config/fleet/upstream/**` and silently matches nothing. -# A leading `**/` matches at any depth. Enforced by -# scripts/fleet/check/prettierignore-globs-are-anchored.mts. -# -# `.claude/` is treated like node_modules — never formatted, never linted, no -# matter whether the files are git-tracked. Hooks, skills, settings, and the -# acorn AST helper (`_shared/ast/`, a thin npm-backed wrapper over the -# `@ultrathink/acorn.rs.wasm` wasm parser) are all opaque to the formatter under -# this single exclusion. -**/.claude/** - -# `.agents/skills/` is the GENERATED cross-tool skill mirror -# (gen/agents-skills-mirror.mts flattens .claude/skills/{fleet,repo}// to -# .agents/skills/-/ for Codex + OpenCode). Generated output whose -# source (.claude/, above) is itself unformatted-by-design, so the byte-identical -# mirror is ignored too. agents-skills-mirror-is-current keeps it in sync. -**/.agents/** - -# Tooling mirrors are formatted at their canonical template source. -# Test ownership does not imply distribution. The formatter reads distributed -# test paths from the generated fleet ignore block in oxlintrc.json. -**/.config/fleet/** -**/.github/actions/fleet/** -**/.git-hooks/fleet/** -**/assets/fleet/** -**/docs/fleet/** -**/scripts/fleet/** - -# The rolldown-inlined dep-0 fetcher — a build artifact (from template/bootstrap/ -# src/* via scripts/repo/gen/bootstrap.mts), not hand-written source; oxfmt would -# fight the bundler output. The template/bootstrap/src/* modules ARE formatted. -# The .d.mts sibling is the generator's rolldown-plugin-dts declaration emit — -# same contract. -**/bootstrap/fleet.mjs -**/bootstrap/fleet.d.mts -# The SessionStart fetch-kernel's declaration emit, from the same generator. - -# The rolldown-bundled fleet oxlint plugin (scripts/fleet/build-oxlint-bundle.mts -# output) — generated + gitignored; oxfmt would fight the bundler output. The -# `.config/fleet/**` glob above does not reach it (the whole-tree walk's matcher skips -# the dotdir `.config`), so it needs an explicit line — twin of isNeverGated() in -# scripts/fleet/_shared/format-scope.mts and the oxfmtrc.json ignorePatterns. -# NB: this matcher is rooted at THIS file's dir (.config/fleet/), so the pattern -# is anchored to the BASENAME — a `**/.config/fleet/oxlint-plugin.generated.mjs` form is a -# silent no-op here (the file's path relative to the matcher root is just -# `oxlint-plugin.generated.mjs`). The `.gitignore` twin keeps the repo-root-anchored -# `**/.config/fleet/oxlint-plugin.generated.mjs` form because THAT matcher roots at the -# repo root. -**/oxlint-plugin.generated.mjs - -# The ownership registry, generated by emit-ownership.mts — twin of -# isNeverGated() in scripts/fleet/_shared/format-scope.mts. Its JSON output -# differs from oxfmt's (sorted keys, no trailing spaces), so a plain format -# run dirties it from a clean tree every time. The generator owns the bytes. -**/commit-cascade/manifest/fleet-files.json - -# The wheelhouse dogfood oxlint config, generated by -# scripts/repo/gen/dogfood-oxlint-config.mts, which serializes canonical -# 2-space JSON and holds its own staleness check. Same split as the registry -# above: isNeverGated() already skips it on a scoped run, so only oxfmt's own -# --all walk reached it, and the two disagreed the moment the generator emitted -# a long array. The generator owns the bytes. -**/.config/repo/oxlintrc.dogfood.json - -# The repo-tier external-tools pin file, written by -# scripts/fleet/update/external-tools.mts on every soak-plan apply. Same split -# again: the updater serializes it, so formatting it from a clean tree only -# dirties what the next apply rewrites. -**/.config/repo/external-tools.json - -# The per-repo config JSON Schema, emitted from the TypeBox source by -# scripts/fleet/socket-wheelhouse-emit-schema.mts. Same split as its two -# siblings above: the explicit-path formatter already skips it, so leaving it -# in the --all walk let the two disagree the moment a description grew long -# enough to rewrap. The emitter owns the bytes. -**/.config/repo/socket-wheelhouse-schema.json - -# Generated build/test output — machine-written trees whose generators own the -# bytes; formatting them is churn against the next build. These are the -# GENERATED_GLOBS twins (scripts/fleet/constants/generated-globs.mts is the -# single source; scripts/fleet/check/generated-globs-are-consistent.mts asserts -# this file covers every entry). -**/dist/** -**/out/** -**/target/** -**/test/fixtures/** - -# Vendored / upstream trees — kept byte-identical with their source of truth. Per -# CLAUDE.md "untracked-by-default for vendored / build-copied trees": someone -# else's source, not ours; the formatter would rewrite it into our local style. -# Fixture trees are test data, including conformance corpora and tiered fleet -# fixtures. Their harness or owning sync controls their byte representation. -**/fixtures/** -**/upstream/** - -# gh-aw generated workflows. `gh aw compile` turns a `.md` agentic workflow -# into a hardened `.lock.yml`; that artifact is tool-owned and must stay -# byte-identical to the compiler output (the .md is the source of truth). -**/.github/workflows/*.lock.yml - -# Everything from the top of this file through the end sentinel below is -# fleet-canonical: cascade and bundle placement rewrite that whole span from -# the wheelhouse source on every refresh — see -# scripts/fleet/_shared/fleet-canonical-splice.mts. Member-generated content, -# like the lockstep-mirrors block scripts/fleet/lockstep/emit-mirror-globs.mts -# appends, must live BELOW the end sentinel; placement preserves that tail -# byte-for-byte. - -**/*.generated.cjs -**/*.generated.cts -**/*.generated.js -**/*.generated.json -**/*.generated.mjs -**/*.generated.mts -**/*.generated.ts -**/template/generated/** -#fleet-canonical-end diff --git a/.config/fleet/oxlintrc.json b/.config/fleet/oxlintrc.json deleted file mode 100644 index 516b0ba6..00000000 --- a/.config/fleet/oxlintrc.json +++ /dev/null @@ -1,373 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/5306f24d9e82ae36ad9c3c964f33075bc589c799/npm/oxlint/configuration_schema.json", - "plugins": ["typescript", "unicorn", "import"], - "jsPlugins": ["./oxlint-plugin.generated.mjs"], - "categories": { - "correctness": "error", - "suspicious": "error" - }, - "rules": { - "eslint/complexity": ["error", 15], - "eslint/curly": "error", - "eslint/no-await-in-loop": "off", - "eslint/no-console": "off", - "eslint/no-control-regex": "off", - "eslint/no-empty": [ - "error", - { - "allowEmptyCatch": true - } - ], - "eslint/no-new": "error", - "eslint/no-proto": "error", - "eslint/no-shadow": "error", - "eslint/no-underscore-dangle": "off", - "eslint/no-unmodified-loop-condition": "off", - "eslint/no-unused-vars": [ - "error", - { - "args": "all", - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^$", - "caughtErrors": "all", - "caughtErrorsIgnorePattern": "^_", - "destructuredArrayIgnorePattern": "^_", - "ignoreRestSiblings": false - } - ], - "eslint/no-useless-catch": "off", - "eslint/no-var": "error", - "eslint/prefer-const": "error", - "eslint/prefer-regex-literals": [ - "error", - { - "disallowRedundantWrapping": true - } - ], - "eslint/preserve-caught-error": "off", - "eslint/sort-imports": "off", - "import/no-cycle": "off", - "import/no-named-as-default": "off", - "import/no-named-as-default-member": "off", - "import/no-self-import": "error", - "import/no-unassigned-import": "off", - "socket/backtick-commands-in-comments": "error", - "socket/bag-param-optionality-naming": "error", - "socket/const-enum-shape": "error", - "socket/export-top-level-functions": "error", - "socket/exported-name-has-domain-word": "error", - "socket/git-directories-are-hierarchical": "error", - "socket/guard-contract": "error", - "socket/import-block-is-hoisted": "error", - "socket/inclusive-language": "error", - "socket/lint-disable-precedes-code": "error", - "socket/match-assertions-normalize-paths": "error", - "socket/max-comment-block-lines": "error", - "socket/max-file-lines": "error", - "socket/mcp-tool-names-are-conventional": "error", - "socket/no-agent-brand-assumption": "error", - "socket/no-ai-slop-prose": "error", - "socket/no-bare-crypto-named-usage": "error", - "socket/no-bare-spawn-childproc-access": "error", - "socket/no-boolean-trap-param": "error", - "socket/no-cached-for-on-iterable": "error", - "socket/no-comment-glob-star-slash": "error", - "socket/no-console-prefer-logger": "error", - "socket/no-default-export": "error", - "socket/no-deprecation": "error", - "socket/no-dynamic-import-in-snapshot-hook": "error", - "socket/no-dynamic-import-outside-bundle": "error", - "socket/no-error-message-assertions": "error", - "socket/no-eslint-biome-config-ref": "error", - "socket/no-fetch-prefer-http-request": "error", - "socket/no-file-scope-oxlint-disable": "error", - "socket/no-fileoverview-prefer-file": "error", - "socket/no-force-delete": "error", - "socket/no-handbuilt-file-url": "error", - "socket/no-inline-defer-async": "error", - "socket/no-inline-logger": "error", - "socket/no-literal-control-char": "error", - "socket/no-logger-glyph-prefix": "error", - "socket/no-logger-newline-literal": "error", - "socket/no-malformed-bypass-marker": "error", - "socket/no-map-async-callback": "error", - "socket/no-minified-bundler-output": "error", - "socket/no-module-eval-side-effects": "error", - "socket/no-namespace-import": "error", - "socket/no-npx-dlx": "error", - "socket/no-optional-param-before-options-bag": ["warn"], - "socket/no-optional-positional-trap": "error", - "socket/no-options-param-mutation": "error", - "socket/no-package-manager-auto-update-reenable": "error", - "socket/no-parenthetical-aside": ["error"], - "socket/no-placeholders": "error", - "socket/no-platform-specific-import": "error", - "socket/no-private-path-in-source": "error", - "socket/no-process-chdir": "error", - "socket/no-process-cwd-in-scripts-hooks": "error", - "socket/no-promise-race": "error", - "socket/no-promise-race-in-loop": "error", - "socket/no-prose-jargon": "error", - "socket/no-redundant-spread-fallback": "error", - "socket/no-required-in-options-bag": ["error"], - "socket/no-runtime-features-below-engine-floor": "error", - "socket/no-snapshot-hostile-builtin": "error", - "socket/no-source-content-tests": "error", - "socket/no-source-sniffing": "error", - "socket/no-spawn-stream-double-consume": "error", - "socket/no-spawnsync-code-property": "error", - "socket/no-src-import-in-test-expect": "error", - "socket/no-status-emoji": "error", - "socket/no-structured-clone-prefer-json": "error", - "socket/no-sync-rm-in-test-lifecycle": "error", - "socket/no-top-level-await": "error", - "socket/no-truncated-lint-disable-reason": "error", - "socket/no-truthy-object-guard": "error", - "socket/no-underscore-identifier": "error", - "socket/no-use-strict-in-esm": "error", - "socket/no-vendored-alias-in-test": "error", - "socket/no-vitest-empty-test": "error", - "socket/no-vitest-focused-tests": "error", - "socket/no-vitest-identical-title": "error", - "socket/no-vitest-skipped-tests": "error", - "socket/no-vitest-standalone-expect": [ - "error", - { - "additionalTestBlockFunctions": ["cmdit"] - } - ], - "socket/no-which-for-local-bin": "error", - "socket/optional-explicit-undefined": "error", - "socket/options-null-proto": "error", - "socket/options-param-naming": "error", - "socket/paths-are-normalized-before-match-at-edit": "error", - "socket/personal-path-placeholders": "error", - "socket/prefer-all-settled": "error", - "socket/prefer-array-from": "error", - "socket/prefer-async-spawn": "error", - "socket/prefer-cached-for-loop": "error", - "socket/prefer-char-code-comparison": "error", - "socket/prefer-crlf-safe-split": "error", - "socket/prefer-ellipsis-char": "error", - "socket/prefer-env-as-boolean": "error", - "socket/prefer-error-message-helper": "error", - "socket/prefer-exec-path-over-node": "error", - "socket/prefer-exists-sync": "error", - "socket/prefer-find-repo-root": "error", - "socket/prefer-find-up-package-json": "error", - "socket/prefer-function-declaration": "error", - "socket/prefer-lib-versions-over-semver": "error", - "socket/prefer-mock-import": "error", - "socket/prefer-modern-syntax": "error", - "socket/prefer-node-builtin-imports": "error", - "socket/prefer-non-capturing-group": "error", - "socket/prefer-object-array-destructure": "error", - "socket/prefer-optional-chain": "error", - "socket/prefer-pure-call-form": "error", - "socket/prefer-refined-record": "error", - "socket/prefer-replace-function": "error", - "socket/prefer-repo-root-dot-cache": "error", - "socket/prefer-safe-delete": "error", - "socket/prefer-separate-type-import": "error", - "socket/prefer-setup-phrasing": "error", - "socket/prefer-shell-win32": "error", - "socket/prefer-socket-lib-compare-str": "error", - "socket/prefer-socket-lib-env": "error", - "socket/prefer-socket-lib-error-message": "error", - "socket/prefer-socket-lib-json-format": "error", - "socket/prefer-socket-lib-normalize-path": "error", - "socket/prefer-socket-lib-predicates": "error", - "socket/prefer-spawn-over-execsync": "error", - "socket/prefer-stable-self-import": "error", - "socket/prefer-static-type-import": "error", - "socket/prefer-typebox-schema": "error", - "socket/prefer-undefined-over-null": "error", - "socket/prefer-windows-test-helpers": "error", - "socket/repository-paths-are-contained": "error", - "socket/require-async-iife-entry": "error", - "socket/require-regex-comment": "error", - "socket/require-vitest-globals-import": "error", - "socket/require-void-run-hook": "error", - "socket/returned-object-null-proto": "error", - "socket/socket-api-token-env": "error", - "socket/sort-array-literals": "error", - "socket/sort-boolean-chains": "error", - "socket/sort-equality-disjunctions": "error", - "socket/sort-named-imports": "error", - "socket/sort-object-literal-properties": "error", - "socket/sort-regex-alternations": "error", - "socket/sort-set-args": "error", - "socket/sort-source-methods": "error", - "socket/terse-lint-disable-reason": "error", - "socket/use-fleet-canonical-api-token-getter": "error", - "typescript/array-type": [ - "error", - { - "default": "array-simple" - } - ], - "typescript/consistent-type-assertions": [ - "error", - { - "assertionStyle": "as" - } - ], - "typescript/consistent-type-imports": "error", - "typescript/no-duplicate-enum-values": "error", - "typescript/no-duplicate-type-constituents": [ - "error", - { - "ignoreUnions": true - } - ], - "typescript/no-explicit-any": "error", - "typescript/no-extra-non-null-assertion": "error", - "typescript/no-extraneous-class": "off", - "typescript/no-misused-new": "error", - "typescript/no-non-null-asserted-optional-chain": "off", - "typescript/no-redundant-type-constituents": "off", - "typescript/no-this-alias": [ - "error", - { - "allowDestructuring": true - } - ], - "typescript/no-unnecessary-type-assertion": "off", - "typescript/no-useless-empty-export": "error", - "typescript/no-wrapper-object-types": "error", - "typescript/prefer-as-const": "error", - "typescript/triple-slash-reference": "error", - "unicorn/consistent-function-scoping": "off", - "unicorn/no-array-for-each": "off", - "unicorn/no-array-reverse": "error", - "unicorn/no-array-sort": "error", - "unicorn/no-empty-file": "off", - "unicorn/no-null": "off", - "unicorn/no-useless-fallback-in-spread": "off", - "unicorn/numeric-separators-style": "error", - "unicorn/prefer-node-protocol": "error", - "unicorn/prefer-spread": "off" - }, - "overrides": [ - { - "files": [ - "**/scripts/**", - "**/test/**", - "**/tests/**", - "**/.config/**", - "**/.git-hooks/**", - "**/.github/**", - "**/.claude/hooks/**", - "**/.claude/skills/**", - "**/.claude/workflows/**" - ], - "rules": { - "socket/export-top-level-functions": "off", - "socket/inclusive-language": "off", - "socket/no-default-export": "off", - "socket/no-dynamic-import-outside-bundle": "off", - "socket/no-npx-dlx": "off", - "socket/no-placeholders": "off", - "socket/no-status-emoji": "off", - "socket/prefer-function-declaration": "off", - "socket/sort-source-methods": "off" - } - }, - { - "files": [ - "**/template/base/universal/.claude/**", - "**/template/base/universal/.config/fleet/**", - "**/template/base/universal/.git-hooks/**", - "**/template/base/universal/scripts/fleet/**", - "**/template/base/universal/test/fleet/**" - ], - "rules": { - "socket/max-file-lines": "off" - } - }, - { - "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - "rules": { - "eslint/no-unused-vars": "off" - } - }, - { - "files": [ - "**/scripts/fleet/build-hook-bundle.mts", - "**/scripts/fleet/build-hook-snapshot.mts", - "**/scripts/fleet/build-snapshot-launcher.mts", - "**/scripts/fleet/fetch-fleet-pack.mts", - "**/scripts/fleet/gen/hook-dispatch.mts", - "**/scripts/fleet/gen/hook-validators.mts", - "**/scripts/fleet/lib/stable-alias.mts", - "**/scripts/fleet/lockstep/emit-mirror-globs.mts", - "**/scripts/fleet/sync-oxlint-rules.mts", - "**/scripts/fleet/update.mts", - "**/scripts/fleet/update/fleet-pins.mts", - "**/scripts/repo/dogfood.mts", - "**/scripts/repo/commit-cascade/fixers/mirror-mode.mts" - ], - "rules": { - "socket/prefer-mirror-lock-write": "error" - } - } - ], - "ignorePatterns": [ - "**/.agents/**", - "**/.cache/**", - "**/.opencode-tmp/**", - "**/.claude/**", - "**/coverage/**", - "**/coverage-isolated/**", - "**/dist/**", - "**/node_modules/**", - "**/patches/**", - "**/test/fixtures/**", - "**/test/packages/**", - "**/test/repo/**", - "**/tests/fixtures/**", - "**/wasm_exec.js", - "**/.config/fleet/oxlint-plugin.generated.mjs", - "**/*.d.ts", - "**/*.d.ts.map", - "**/*.tsbuildinfo", - "#fleet-canonical-begin (managed by socket-wheelhouse sync)", - "**/.claude/agents/fleet/**", - "**/.claude/commands/fleet/**", - "**/.claude/hooks/fleet/**", - "**/.claude/rules/fleet/**", - "**/.claude/skills/fleet/**", - "**/.config/fleet/**", - "**/.config/fleet/oxlint-plugin/**", - "**/.config/repo/rolldown/**", - "**/.git-hooks/**", - "**/.github/actions/fleet/**", - "**/.pnpm-store/**", - "**/bootstrap/**", - "**/docs/fleet/agents.md/**", - "**/scripts/fleet/**", - "**/scripts/repo/bootstrap/**", - "**/test/fleet/_shared/**", - "**/test/fleet/scripts/**", - "**/wasm_exec.js", - "**/.config/repo/vitest.config.mts", - "**/.config/repo/vitest.settings.mts", - "**/.mcp.json", - "**/test/fleet/e2e/comment-voice.test.mts", - "**/test/fleet/integration/comment-voice.test.mts", - "**/test/fleet/nock-loopback-passthrough.test.mts", - "**/test/fleet/unit/ci/gates/run.test.mts", - "**/test/fleet/unit/comment-voice.test.mts", - "**/test/fleet/unit/credentials/otp/bindings.test.mts", - "**/test/fleet/unit/fix/plan.test.mts", - "**/test/fleet/unit/fix/run.test.mts", - "**/test/fleet/unit/lockstep/emit-mirror-globs.test.mts", - "**/test/fleet/unit/registry/apple/ci/keychain.test.mts", - "**/test/fleet/unit/registry/cargo/placeholder.test.mts", - "**/test/fleet/unit/registry/npm/placeholder.test.mts", - "**/test/fleet/unit/setup/lib/pnpm-path.test.mts", - "#fleet-canonical-end", - "", - "" - ] -} diff --git a/.config/fleet/tsconfig.check.json b/.config/fleet/tsconfig.check.json deleted file mode 100644 index 62c0749e..00000000 --- a/.config/fleet/tsconfig.check.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "//": "The repo half comes last so a member's own compilerOptions win. Path aliases live there: without it the type gate resolved none of a member's src/external/* mappings, because the root tsconfig is a sibling of this chain rather than an ancestor.", - "extends": ["./tsconfig.check.base.json", "../repo/tsconfig.check.json"], - "compilerOptions": { - "rootDir": "../.." - } -} diff --git a/.config/repo/external-tools.json b/.config/repo/external-tools.json deleted file mode 100644 index 9ea117a3..00000000 --- a/.config/repo/external-tools.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "tools": { - "bun": { - "description": "Bun — the runtime this scanner plugs into via bunfig [install.security].scanner; the version the test suite and CI run against.", - "notes": [ - "Release bun-v1.4.2, published 2026-09-05 — the exact runtime pin used by tests and CI.", - "Integrity values are the upstream release's own SHASUMS256.txt entries converted to sha256 SRI.", - "CI parity: .github/workflows/ci-gates.yml resolves Bun through the shared external-tool manager." - ], - "platforms": { - "darwin-arm64": { - "asset": "bun-darwin-aarch64.zip", - "integrity": "sha512-uhAxGmAhtxO7Le+Jr34WCtXRuA4wwOkAThkYKr/t79uBxP7aP7yyJSBmg2oMAaiXmXTlckzkJJGMOkcIRZofEA==" - }, - "darwin-x64": { - "asset": "bun-darwin-x64.zip", - "integrity": "sha512-4+hvbfmFztAqMzjhWHJubMdTIk7cx9OK2ApcarbMHq1FL6GxiWenCMEm8834mg53n0/cE/Ggj/dbmWMsABdVUw==" - }, - "linux-arm64": { - "asset": "bun-linux-aarch64.zip", - "integrity": "sha512-eT1ChCtUpunmqaTwHslVzvS/3jCWzLVo/OTTzukW+dRpmJaRWoZqEdO8HsS3J0h27FoUeaJK1PwqTzbIPSXNRQ==" - }, - "linux-arm64-musl": { - "asset": "bun-linux-aarch64-musl.zip", - "integrity": "sha512-qp6U7ooz94Pb667ZSwZS/DS0IU2jn9ORv8wEoMnMyWWIg2CSMPPN30FYxhnKpV0cWfGCAblXJF/K0kdcgmBi6Q==" - }, - "linux-x64": { - "asset": "bun-linux-x64.zip", - "integrity": "sha512-aJ4/M5sTwlIYm305Q3Vflb2zHk1Ku016qJpfj+8CqtoJo3jqmUpzB2ZIt11UbTGbJigof0nIec/WLk5Kq589hA==" - }, - "linux-x64-musl": { - "asset": "bun-linux-x64-musl.zip", - "integrity": "sha512-sQwSY7Vsv4+yQq0HJ7yS+E1EWg7BiJJQnafhMrMVD7heDfmBUBqrrqEVVedY5swxqsyHiRXMcoE/uC1HMyLS0g==" - }, - "win32-arm64": { - "asset": "bun-windows-aarch64.zip", - "integrity": "sha512-MraeB20K74TTqAFhmPJi5RK2IS2eahsGOOp9A4BBJvsigi7vDVXD5S1u82keugmXIbnmFIIcPlZ+dmeeS6G+Cw==" - }, - "win32-x64": { - "asset": "bun-windows-x64.zip", - "integrity": "sha512-RueeSnqQXoa1lptEWbvBBAkmj+anWb7htTj8VMS57gk6Om445Ac5egHEDUt1fyIT/qTVRKZkhbOQ/28anMDCEw==" - } - }, - "origin": "gh-asset", - "repository": "github:oven-sh/bun", - "tag": "bun-v1.4.2", - "version": "1.4.2", - "versionDate": "2026-09-05" - } - } -} diff --git a/.config/repo/lockstep.json b/.config/repo/lockstep.json deleted file mode 100644 index 31eaaf40..00000000 --- a/.config/repo/lockstep.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../fleet/lockstep.schema.json", - "area": "bun-security-scanner", - "description": "Lock-step manifest. bun-security-scanner is a single npm package with no submodule upstreams or sibling language ports \u2014 empty rows is the expected state.", - "rows": [] -} diff --git a/.config/repo/rolldown.config.mts b/.config/repo/rolldown.config.mts index 19cabad5..d422cdcf 100644 --- a/.config/repo/rolldown.config.mts +++ b/.config/repo/rolldown.config.mts @@ -13,7 +13,7 @@ import path from 'node:path' import process from 'node:process' import { createLibStubPlugin } from './rolldown/lib-stub.mts' -import { REPO_ROOT } from '../../scripts/fleet/paths.mts' +import { REPO_ROOT } from '../../scripts/repo/runtime.mts' import type { OutputOptions, Plugin, RolldownOptions } from 'rolldown' diff --git a/.config/repo/root-files.json b/.config/repo/root-files.json deleted file mode 100644 index 6f3f8646..00000000 --- a/.config/repo/root-files.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "allow": { - "bun.lock": "Bun's lockfile for the repo's own dependency graph; `bun test` and the scanner-under-test install flow resolve from it, and Bun writes it only at the package root.", - "bunfig.toml": "Bun requires its config at the package root; wires `[install.security] scanner = \"./src/index.mts\"` so bun install exercises the scanner under test, per README setup.", - "DISCLOSURE": "npm's dual-use content policy reads the disclosure from the package root, and the fleet gate scripts/fleet/check/dual-use-declarations-are-complete.mts requires it there plus in the package.json files allowlist for this dual-use scanner." - } -} diff --git a/.config/repo/socket-wheelhouse.json b/.config/repo/socket-wheelhouse.json deleted file mode 100644 index 60fc216a..00000000 --- a/.config/repo/socket-wheelhouse.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "ai": { - "localAssist": true - }, - "$schema": "./socket-wheelhouse-schema.json", - "schemaVersion": 1, - "repoName": "bun-security-scanner", - "repo": { - "type": "solo" - }, - "build": { - "from": "npm-registry", - "type": "js" - }, - "cover": { - "runner": "bun", - "thresholds": { - "functions": 100, - "lines": 100 - } - }, - "contentPolicy": { - "class": "dual-use", - "packages": ["."] - }, - "vitest": { - "lanes": { - "fast": ["test/*.test.mts", "test/modes/**/*.test.mts"], - "mid": ["test/repo/**/*.test.mts"] - }, - "nodeTestExclude": [ - "test/*.test.mts", - "test/modes/**", - "test/repo/integration/gitignore.test.mts", - "test/repo/unit/advisories.test.mts", - "test/repo/unit/build.test.mts", - "test/repo/unit/clean.test.mts", - "test/repo/unit/fetch-session.test.mts", - "test/repo/unit/network-preload.test.mts", - "test/repo/unit/prepare.test.mts", - "test/repo/unit/rolldown.config.test.mts", - "test/repo/unit/scanner-factory.test.mts" - ] - }, - "sharedModuleTests": { - "grandfathered": [ - "action-port-map", - "actions-runtime", - "active-run-marker", - "agent-offload", - "ai-infra", - "backoff", - "bot-directives", - "cargo-workspaces", - "cascade-mirror-scope", - "cascaded-mirrors", - "changelog-path", - "check-steps", - "check-steps-hooks", - "check-steps-paths", - "check-steps-release", - "chrome-cdp", - "claude-model", - "claude-usage", - "claude-usage-breakdowns", - "client-limits", - "codex-model", - "codex-usage", - "comment-voice-store", - "dispatch-scan", - "duration-budgets", - "errors", - "external-tools", - "fireconnect-claude", - "fireconnect-config", - "fireworks-usage", - "fixer-lock", - "fixture-names", - "fleet-canonical-splice", - "fleet-membership", - "fleet-ports", - "fleet-source-present", - "format-scope", - "gauge-ramp", - "ghcr-package", - "git-exec", - "git-lock-retry", - "git-mutex", - "git-porcelain", - "github-raw-url", - "github-token", - "github-tracked-surface", - "gitmodules", - "gitmodules-pin-contract", - "go-workspaces", - "guard-block-shape", - "hook-wiring", - "human-gate", - "is-main-module", - "launcher-variants", - "lifecycle-scripts", - "lint-runners", - "managed-ruleset-identity", - "member-release-probe", - "mirror-lock", - "model-catalog", - "model-choices", - "model-list-cache", - "model-target-ids", - "model-targets", - "model-training-policy", - "never-walked-entries", - "odai", - "offload-spend", - "offload-spend-read", - "open-url", - "outbound-surfaces", - "pack-fetcher", - "pack-files", - "pack-inspect", - "pack-ref", - "pack-structure", - "path-to-repo", - "picker", - "pinned-ref", - "pnpm-lockfile", - "poll-with-decay", - "pr-body-law", - "process-lifecycle", - "prose-em-dash", - "provider-apis", - "provider-availability", - "provider-credentials", - "provider-models", - "quiescence", - "release-channels", - "release-gap-recovery", - "release-subject", - "release-version-source", - "repo-checks", - "repo-filter", - "repo-setup", - "repo-visibility", - "report-url", - "review-comment-law", - "run-main", - "rust-tool-pins", - "scope-flags", - "security-posture-law", - "security-posture-probe", - "snapshot-hostile-builtins", - "socket-state", - "spawn-env-scan", - "spend-projection", - "spend-report-charts", - "spend-report-html", - "spend-report-path", - "spend-report-theme", - "spend-statusline-render", - "statusline-launcher", - "statusline-render", - "swift-targets", - "synthetic-quota", - "tar-executable", - "template-payload-scope", - "terminal-link", - "test-collection", - "test-isolation-law", - "tracked-globs", - "unix-path", - "untrack-offenders" - ] - } -} diff --git a/.config/repo/tsconfig.check.json b/.config/repo/tsconfig.check.json deleted file mode 100644 index bbe79667..00000000 --- a/.config/repo/tsconfig.check.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "//": "Member-owned half of the type-check config. .config/fleet/tsconfig.check.json extends this LAST, so anything declared here wins over the fleet defaults. Its main job is compilerOptions.paths: a member's package aliases resolve for `pnpm run type` only when they are declared here, because the root tsconfig.json is a sibling of the check chain rather than an ancestor. Paths are relative to THIS file, so a src/ wrapper reads ../../src/external/. Seeded empty and extended unconditionally, so leave the file in place even with nothing in it.", - "compilerOptions": { - "types": ["node", "vitest", "bun-types"] - } -} diff --git a/.config/repo/vitest.config.mts b/.config/repo/vitest.config.mts deleted file mode 100644 index b7137d50..00000000 --- a/.config/repo/vitest.config.mts +++ /dev/null @@ -1,592 +0,0 @@ -/** - * @file Vitest configuration. Isolation: the fleet default is `isolate: true` — - * each test file gets a fresh module registry + globals, so cross-file - * leakage (process.env, path-rewire overrides, vi.mock state, nock - * interceptors) is impossible. Correctness by default. A repo that wants the - * faster shared-worker mode for a known-safe subset opts those files OUT by - * listing globs in the `vitest.nonIsolated` array of the settings file, - * `.config/repo/socket-wheelhouse.json`. When set, those globs run in a - * second, non-isolated project and the default isolated project excludes - * them. No globs → everything isolated. - */ -import { existsSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { envAsBoolean } from '@socketsecurity/lib-stable/env/boolean' -import { isCI } from '@socketsecurity/lib-stable/env/ci' -import { defineConfig } from 'vitest/config' - -import { GENERATED_GLOBS } from '../../scripts/fleet/constants/generated-globs.mts' -import { resolveGeneratedTestExcludes } from '../../scripts/fleet/test-runner/discovery.mts' -import { resolveCoverageConfig } from '../../.config/fleet/vitest.coverage.fleet.config.mts' -import { - discoverSharedTestFiles, - readBunTestFiles, - readConformanceExcludeGlobs, - readNonIsolatedGlobs, - readVitestLanes, - readVitestSettings, - repoNodeTestExcludeGlobs, - stringArray, -} from './vitest.settings.mts' - -// Pin TZ here, at config-eval time in the MAIN process, so every worker -// inherits TZ=UTC from its spawn env. This has to happen before a worker -// starts: under the default `threads` pool a worker cannot change its own -// timezone afterwards — V8 caches it at the first Date op — so a `test.env.TZ` -// entry or a setupFiles assignment LOOKS right and does nothing, because both -// run inside the worker. Measured: `main after set TZ=UTC` reads day 19 while -// `worker after self-set TZ=UTC` still reads day 18. -// -// Why it matters beyond flakiness: an unpinned TZ makes a date-formatting bug -// invisible to whoever wrote it and reproducible only for readers in another -// offset. socket-cli's analytics report labeled every row by LOCAL day while -// the API buckets by UTC day, so the same command emitted different dates -// west of UTC; its per-package config pinned TZ and hid the bug from the -// fleet runner. `??=` so an operator probing another zone can still override. -process.env['TZ'] ??= 'UTC' - -// Coverage is on when the COVERAGE env is set (cover.mts) or the `--coverage` -// flag is passed. Match the FLAG, not any argv containing the substring -// "coverage" — a nested test run whose file-path args happen to include -// "coverage" must not silently turn coverage on and clean the shared -// coverage/.tmp (see test.mts resolveVitestEnv). -const isCoverageEnabled = - envAsBoolean(process.env['COVERAGE']) || - process.argv.some(arg => arg.startsWith('--coverage')) - -// Ceiling on the contention multiplier. A starved spawn is queued, not hung, so -// it deserves more time — but a genuinely STUCK test must still fail in -// bounded time instead of hanging the run behind a growing budget. -const BUDGET_LOAD_CAP = 4 - -// Below this fraction of the core count the box counts as quiet and the base -// budget stands unchanged. Half the cores busy is normal for a test run. -const BUDGET_QUIET_LOAD_RATIO = 0.5 - -/** - * How much to stretch a test budget for the machine's current contention. - * `1` on a quiet box, rising toward {@link BUDGET_LOAD_CAP} as load climbs. - * - * A background build — a parallel `cargo build` saturating every core — starves - * a spawn-per-case suite: the child is queued behind the compiler, so a fixed - * ceiling turns machine load into a red suite with no code change. Observed: - * one hook spec went from 1 failure to 5, 6, then 7 across three consecutive - * runs as an unrelated `rustc` ramped to 779% CPU. - * - * Both readings are injectable so the arithmetic is testable without depending - * on the load of whatever machine runs the suite. - */ -export function resolveBudgetLoadFactor( - options?: - | { - cores?: number | undefined - loadAvg?: number | undefined - workers?: number | undefined - } - | undefined, -): number { - const opts = { __proto__: null, ...options } as { - cores?: number | undefined - loadAvg?: number | undefined - workers?: number | undefined - } - const cores = Math.max(1, opts.cores ?? os.availableParallelism()) - // loadavg() is [0, 0, 0] on win32, which yields the base budget — correct, - // since there is no signal to scale by. - const observed = Math.max(0, opts.loadAvg ?? os.loadavg()[0] ?? 0) - // The reading is taken as the config loads, BEFORE the run creates its own - // contention, so a quiet box reads quiet and the whole suite then runs at - // maxWorkers. Treat the run's own parallelism as a floor on load: a spawn in - // worker 7 competes with six siblings whatever the box looked like a second - // ago. Without this floor a full-suite run measured 74s against a 60s budget - // on a box that read 5.26 at startup. - const workers = Math.max(0, opts.workers ?? resolveMaxWorkers()) - const effective = Math.max(observed, workers) - const ratio = effective / (cores * BUDGET_QUIET_LOAD_RATIO) - return Math.min(BUDGET_LOAD_CAP, Math.max(1, ratio)) -} - -/** - * The per-test budget: the CI/coverage base ladder, stretched by current - * machine contention. Used for both `testTimeout` and `hookTimeout` so a - * starved `beforeAll` fixture gets the same headroom as the tests it feeds. - */ -export function resolveTestBudgetMs( - options?: - | { - cores?: number | undefined - loadAvg?: number | undefined - workers?: number | undefined - } - | undefined, -): number { - const ci = isCI() - const base = - ci && isCoverageEnabled - ? 120_000 - : ci - ? 60_000 - : isCoverageEnabled - ? 30_000 - : 10_000 - return Math.round(base * resolveBudgetLoadFactor(options)) -} - -// Local ceiling on the core-scaled worker count. Past this the suite stops -// getting faster and only costs memory: measured on a 14-core box, the -// integration tier ran 147s at 16 workers, 129s at 24 and 126s at 32 — a 2% -// gain for a third more Node heaps. -const LOCAL_MAX_WORKERS_CAP = 24 -const LOCAL_MAX_WORKERS_FLOOR = 8 -// Workers per core. Deliberately oversubscribed: a fleet test mostly WAITS on a -// spawned script rather than computing, so a blocked worker leaves a core idle -// and a sibling can use it. Under-subscribing measurably hurt — the same tier -// took 170s at 6 workers and 169s at 10, slower than the 16-worker default it -// was meant to improve on. -const LOCAL_WORKERS_PER_CORE = 2 - -/** - * Worker cap for the pool before any per-repo override, which - * {@link capMaxWorkers} floors this against. - */ -export function resolveFallbackMaxWorkers( - options?: - | { cores?: number | undefined; isCI?: boolean | undefined } - | undefined, -): number { - const opts = { __proto__: null, ...options } as { - cores?: number | undefined - isCI?: boolean | undefined - } - if (opts.isCI ?? isCI()) { - return 4 - } - // Coverage instrumentation is memory-hungry, so it keeps the flat, lower cap - // rather than scaling up into a heap-exhaustion failure. - if (isCoverageEnabled) { - return 8 - } - const cores = Math.max(1, opts.cores ?? os.availableParallelism()) - return Math.min( - LOCAL_MAX_WORKERS_CAP, - Math.max(LOCAL_MAX_WORKERS_FLOOR, cores * LOCAL_WORKERS_PER_CORE), - ) -} -export function resolveConfiguredMaxWorkers(): number | undefined { - const configured = readVitestSettings().maxWorkers - return typeof configured === 'number' && configured > 0 - ? configured - : undefined -} -export function capMaxWorkers( - configuredMaxWorkers: number | undefined, - fallbackMaxWorkers: number, -): number { - return configuredMaxWorkers === undefined - ? fallbackMaxWorkers - : Math.min(configuredMaxWorkers, fallbackMaxWorkers) -} -export function resolveMaxWorkers(): number { - return capMaxWorkers( - resolveConfiguredMaxWorkers(), - resolveFallbackMaxWorkers(), - ) -} -/** - * Fast-fail bail count. A coverage run MUST execute the FULL suite to measure - * it, so bail is INERT under coverage: bailing on the - * first failure aborts ~half the suite and its subprocess coverage, collapsing - * the aggregate to a phantom partial (#79: CI read 36% vs the true ~73% because - * one failing test bailed the run after 249 of 1224 files). Plain CI test jobs - * no coverage, keep fast-fail bail=1; local (no CI) runs the whole suite. - * Pure so the resolution is unit-testable without a real CI/coverage env. - */ -export function resolveBail(config: { - readonly isCI: boolean - readonly isCoverage: boolean -}): number { - const cfg = { __proto__: null, ...config } - return !cfg.isCoverage && cfg.isCI ? 1 : 0 -} -/** - * Resolve-alias merge. This config is CASCADED — a member repo that edited it - * directly lost the edit on the next cascade: socket-webext's - * `@socketsecurity/sdk` → browser-build alias was wiped exactly that way. The - * settings file's `vitest.alias` map is the repo-owned surface that survives. - * Two maps merge per key, the second winning, which is what lets a caller layer - * a fleet default under a repo's own entries. Dot-relative replacements — `./` - * or `../` — resolve against `root`, the repo root at config-load time, because - * vite substitutes alias replacements verbatim: left relative, the result would - * resolve against each importer instead of the repo root. Bare package names - * and absolute paths pass through untouched. - */ -export function mergeVitestAlias( - fleet: unknown, - repo: unknown, - root: string = process.cwd(), -): Record { - const entries = (tier: unknown): Array<[string, string]> => - tier !== null && typeof tier === 'object' && !Array.isArray(tier) - ? Object.entries(tier).filter( - (e): e is [string, string] => typeof e[1] === 'string', - ) - : [] - return Object.fromEntries( - [...entries(fleet), ...entries(repo)].map(([find, replacement]) => [ - find, - replacement.startsWith('./') || replacement.startsWith('../') - ? path.resolve(root, replacement) - : replacement, - ]), - ) -} -export function resolveVitestAlias(): Record { - return mergeVitestAlias(undefined, readVitestSettings().alias) -} -export function resolvePool(): 'forks' | 'threads' { - const chosen = readVitestSettings().pool - return chosen === 'forks' || chosen === 'threads' ? chosen : 'threads' -} -// Vite's own server-side resolve conditions, mirrored as a literal because -// `vite` is a transitive dep here, not a direct one — importing -// `defaultServerConditions` from it would not typecheck, and `vitest/config` -// does not re-export it. Verified against the installed vite 8.1.5, whose -// DEFAULT_SERVER_CONDITIONS is exactly this list. drift-watch: re-verify on a -// vite major bump. -const VITE_DEFAULT_SERVER_CONDITIONS = [ - 'module', - 'node', - 'development|production', -] as const - -/** - * Extra resolve conditions, from `vitest.conditions`. Vite REPLACES its default - * condition list rather than appending to it, so the repo's entries are listed - * FIRST and vite's own server defaults are appended — dropping them would break - * plain node resolution for every dependency. - */ -export function resolveVitestConditions(): string[] { - const configured = stringArray(readVitestSettings().conditions) - return configured.length > 0 - ? [...configured, ...VITE_DEFAULT_SERVER_CONDITIONS] - : [] -} -const nonIsolatedGlobs = readNonIsolatedGlobs() -const repoResolveAlias = resolveVitestAlias() -const repoResolveConditions = resolveVitestConditions() - -// Lane resolution. The runner sets FLEET_LANE (bare `pnpm test` → 'fast'); the -// filter also applies under coverage. An unset lane traverses every lane. -const vitestLanes = readVitestLanes() -const activeLane = process.env['FLEET_LANE'] -// A lane's dir globs → test-file include patterns (`--lane mid|slow` runs ONLY -// that lane; a trailing `/**` becomes `/**/*.test.{…}`). -export function laneToTestGlobs(globs: string[]): string[] { - return globs.map(g => - /\.[cm]?[jt]s(?:\}|$)/u.test(g) - ? g - : `${g.replace(/\/\*+$/, '')}/**/*.test.{js,ts,mjs,mts,cjs}`, - ) -} -const ALL_TEST_GLOBS = ['**/test/**/*.test.{js,ts,mjs,mts,cjs}'] - -/** - * Resolve one speed lane without dropping unclassified tests. - * - * Slow takes precedence over mid. Fast owns every test outside mid and slow, - * so a new test stays in the development loop until measurement moves it. - */ -export function resolveLaneSelection( - lanes: ReturnType, - lane: string | undefined, -): { exclude: string[]; include: string[] } { - const mid = lanes.mid ?? [] - const slow = lanes.slow ?? [] - if (lane === 'fast') { - return { exclude: [...mid, ...slow], include: [...ALL_TEST_GLOBS] } - } - if (lane === 'mid') { - return { - exclude: [...slow], - include: laneToTestGlobs(mid), - } - } - if (lane === 'slow') { - return { - exclude: [], - include: laneToTestGlobs(slow), - } - } - return { exclude: [], include: [...ALL_TEST_GLOBS] } -} - -const laneSelection = resolveLaneSelection(vitestLanes, activeLane) -// The conformance tier's dir globs, and whether THIS run is the explicit -// conformance run. Set by scripts/repo/test-conformance.mts, never by hand. -const conformanceGlobs = readConformanceExcludeGlobs() -const conformanceTier = process.env['FLEET_TEST_CONFORMANCE'] === '1' - -/** - * Vitest-runnable property/fuzz harnesses (`*.fuzz.test.mts`). The Tier-2 - * vitiate form (`*.fuzz.mts`) is already outside the include glob and runs on - * its own runner, so it needs no entry here. - */ -export const FUZZ_GLOBS: readonly string[] = [ - '**/test/**/*.fuzz.test.{js,ts,mjs,mts,cjs}', -] -export const ORDINARY_TEST_EXCLUDES: readonly string[] = ['**/test/e2e/**'] -// Whether THIS run is the explicit fuzz tier. Set by the weekly fuzz workflow, -// never by hand. -// -// Fuzzing is not a per-commit gate. A randomized harness is budgeted by -// wall-clock, so a red run depends on machine load and cannot be replayed; a -// deterministic one replays but costs far more than the signal is worth per -// commit (the seeded parity harness alone ran 5-20s). Both belong to a -// scheduled run that can afford the full budget and file what it finds. -const fuzzTier = process.env['FLEET_TEST_FUZZ'] === '1' - -const config = defineConfig({ - // Repo-owned resolution from the settings file's `vitest.alias` + - // `vitest.conditions` — see mergeVitestAlias and resolveVitestConditions. - // Spread conditionally so a repo declaring neither keeps vite's own - // resolution untouched. - ...(Object.keys(repoResolveAlias).length || repoResolveConditions.length - ? { - resolve: { - ...(Object.keys(repoResolveAlias).length - ? { alias: repoResolveAlias } - : {}), - ...(repoResolveConditions.length - ? { conditions: repoResolveConditions } - : {}), - }, - } - : {}), - test: { - // Reuse content-keyed transforms while each isolated file keeps fresh state. - fsModuleCache: true, - deps: { - interopDefault: false, - }, - server: { - deps: { - // Treat @socketsecurity/lib-stable as external — bypass vite's - // transform pipeline so Node resolves it natively (CJS default - // condition). Without this, vite's `development` condition resolves - // lib-stable via its `source` exports field (TypeScript source), and - // the TS source files reference `./external/semver` sub-paths that are - // not listed in the lib-stable exports map, producing an unhandled - // EnvironmentPluginContainer.resolveId error that kills the test run. - external: [/node_modules\/@socketsecurity\/lib-stable/], - }, - }, - globals: false, - environment: 'node', - // Test setup lives under test/scripts/{fleet,repo}/setup.mts — fleet-canonical - // setup, nock fail-closed, env scrubbing, in fleet/, repo-specific setup in - // repo/. Both are optional: vitest skips a setupFile that doesn't exist via - // the existsSync filter so scaffolding-only repos don't error. - setupFiles: [ - 'test/fleet/scripts/setup.mts', - 'test/repo/scripts/setup.mts', - ].filter(p => existsSync(p)), - // Slow takes precedence over mid; fast owns every remaining test. - // `**/`-anchored so a - // monorepo's nested `packages//test/**` trees are discovered from this - // one root config — a bare `test/**/*.test...` only anchors at the repo - // root, silently missing every sub-package's tests (each scoped `vitest run` - // returns "No test files found" and a full run "passes" having executed - // zero of them). - include: fuzzTier - ? [...FUZZ_GLOBS] - : conformanceTier - ? laneToTestGlobs(conformanceGlobs) - : laneSelection.include, - // Vitest treats `test/**` as `**/test/**`, so without an explicit - // exclude it picks up every nested `test/` directory in the repo - // — including the `.git-hooks/test/`, the oxlint plugin's per-rule - // `.config/fleet/oxlint-plugin/fleet//test/` suites, - // and `scripts/**/test/` suites that run under `node --test`, not - // vitest. Those tests use `import { test } from 'node:test'` and - // produce zero vitest suites, which vitest reports as failures. - // List the known node:test homes here so vitest skips them cleanly - // (their own `node --test` runners pick them up separately). - exclude: [ - '**/node_modules/**', - ...(conformanceTier ? [] : ORDINARY_TEST_EXCLUDES), - // The conformance tier is opt-in via `pnpm run test:conformance`. Every - // other lane drops it: these wrappers each spawn a FULL external corpus - // (Test262 is ~92k scenarios per implementation), which is minutes to - // hours, not a unit suite. Lifted only for the explicit conformance run, - // where the include above targets exactly these globs. - ...(conformanceTier ? [] : conformanceGlobs), - // Property/fuzz harnesses run ONLY in the fuzz tier, never in the - // ordinary suite or a pre-commit lane. Lifted only for the explicit fuzz - // run, where the include above targets exactly these globs. - ...(fuzzTier ? [] : FUZZ_GLOBS), - // Generated/vendored trees (dist, build, upstream, test/fixtures, …) — - // shared with lint + format from one source (constants/generated-globs.mts) - // so the ignore surfaces can't drift. vite's default loader can't - // transform many of these (a module-graph walk into a vendored tree or a - // wasm blob fails "ESM integration proposal for Wasm"), so discovery AND - // `vitest related` must skip them; scripts/fleet/test.mts filters the same - // set from the staged pre-commit run. - ...GENERATED_GLOBS, - '**/.{idea,git,cache,output,temp}/**', - '**/.claude/**', - '.git-hooks/**', - '.config/fleet/oxlint-plugin/**', - 'scripts/**/test/**', - // `template/**` holds CANONICAL non-test sources (the cascaded LIVE - // copies are what the suite runs); live test/repo is the sole test - // authoring home, so template is excluded unconditionally. - 'template/**', - // `test/isolated/**` is the isolated SUITE's turf — its own forks / longer - // -timeout config (`vitest.config.isolated.mts`), run as a separate suite - // by cover.mts. Exclude it from this shared suite ONLY when the repo ships - // that config, so a repo without the isolated suite still runs any - // `test/isolated` files here instead of silently dropping them. This is the - // isolated DIRECTORY tier — distinct from the `isolate:` state-isolation - // split (the `nonIsolated` projects) further down. - ...(existsSync('.config/repo/vitest.config.isolated.mts') - ? ['test/isolated/**'] - : []), - // Repo-tunable node:test homes (e.g. `tools/**/test/**`) from the - // settings file's `vitest.nodeTestExclude`. The same key feeds - // prefer-vitest-guard's allowlist so the two never drift. - ...repoNodeTestExcludeGlobs(), - ...readBunTestFiles(), - ...laneSelection.exclude, - ], - // Some repos in the fleet (scaffolding-only, hook-only, etc.) ship - // this config but don't yet have a `test/` directory — vitest's - // default behavior would fail "no tests found" there. Repos that - // do have tests still error on actual test failures; this flag - // only affects the empty-suite case. - // Zero discovered files is normal for a scoped run, but it is a FAILURE - // for the conformance tier: that run exists to execute those globs, so - // discovering none means the tier is misconfigured and a silent pass would - // report the heavy suites green without running one of them. - // - // The fuzz tier is deliberately NOT held to that: a repo can fuzz only at - // Tier 2 (`*.fuzz.mts`, its own runner), which discovers nothing here and - // is not a misconfiguration. Whether a repo fuzzes at all is - // fuzz-tiers-are-covered's question, and which legs fire is the workflow's. - passWithNoTests: !conformanceTier, - // Reporters left unset so vitest applies its own default: - // `[isAgent() ? 'minimal' : 'default', ...(GITHUB_ACTIONS ? ['github-actions'] : [])]` - // (vitest/src/defaults.ts). That yields the token-lean `minimal` reporter - // inside an AI coding agent (std-env `isAgent`: CLAUDECODE/CURSOR_/…), - // `default` for humans, and the `github-actions` annotations reporter in CI. - // Hard-coding `reporters: ['default']` would override that default and - // defeat all three. https://vitest.dev/guide/reporters - pool: resolvePool(), - // Vitest 4 removed `poolOptions`; the per-pool worker knobs are now - // top-level. `maxThreads`/`maxForks` → `maxWorkers`; `singleThread`/ - // `singleFork` → `fileParallelism: false` (forces maxWorkers to 1); - // `minThreads` and `useAtomics` were dropped with no replacement. - // Worker count tuned to physical CPUs: GH Actions ubuntu-latest has - // 4 cores, dev laptops typically 8-16. `isCI()` (rewire-aware - // presence check on `CI`) is truthy even for CI="" or CI=0, matching - // the fleet convention that any CI value means CI. - // - // Isolation: true by default (correctness — no cross-file state leak). A - // repo lists safe-to-share globs in the `vitest.nonIsolated` array of - // .config/repo/socket-wheelhouse.json; when set, this default project - // EXCLUDES them (the second project runs them non-isolated). When unset, - // every file is isolated. - isolate: true, - // Keep coverage file-parallel. Worker setup removes the already-consumed - // COVERAGE flag before test code runs, so a nested Vitest child cannot turn - // coverage back on and clean the outer run's shared .tmp reports. Ordinary - // Node children still inherit NODE_V8_COVERAGE for subprocess merging. - // Local coverage caps at 8 workers because this spawn-heavy suite saturates - // there; 16 workers add filesystem/process contention. Ordinary local tests - // retain 16 workers, while CI matches its 4 available cores. - maxWorkers: resolveMaxWorkers(), - // Coverage runs with V8 instrumentation that spawned children inherit, so - // spawn-heavy tests, hook integration specs launch a node child per case - // legitimately exceed 10s there. CI gets a 60s budget unconditionally: - // 2-core runners × parallel workers starve spawn-per-case suites - // (RuleTester spawns one oxlint child per case) — the 10s/30s ceilings - // killed lint-rule suites mid-queue on every OS while the same files pass - // locally. CI *with* coverage is strictly heavier than either alone - // (instrumentation + 4-core contention + thousands of instrumented child - // spawns in one run), so it gets the longest budget — the plain-CI 60s - // still timed out the spawn-per-case hook specs (npm-2fa-needs-pty-guard, - // single-lander-guard) under peak release-cover contention, losing their - // coverage and failing the gate while all four metrics were above - // threshold. Complete the ladder rather than shave the threshold. - testTimeout: resolveTestBudgetMs(), - hookTimeout: resolveTestBudgetMs(), - bail: resolveBail({ - isCI: isCI(), - isCoverage: isCoverageEnabled, - }), - // Coverage shape comes from the fleet base merged with the repo-owned - // `coverage` section of .config/repo/socket-wheelhouse.json (include - // replace, exclude add/remove) — one canonical exclude list instead of a - // drifted copy here. - coverage: { - enabled: isCoverageEnabled, - // Ordinary tests do not need the workspace scan for coverage aliases. - ...(isCoverageEnabled ? resolveCoverageConfig() : {}), - }, - }, -}) - -// The fast lane excludes every build-test home through its mid/slow lane -// filters. Avoid the repository-wide authored-build scan during the ten-second -// fast loop; mid, slow, and full runs retain the complete exception handling. -if (config.test && activeLane !== 'fast') { - config.test.exclude = resolveGeneratedTestExcludes({ - repoRoot: process.cwd(), - include: config.test.include ?? [], - exclude: config.test.exclude ?? [], - }) -} - -// Construct complete project options explicitly: Vite's extends merge concatenates -// include arrays, which otherwise makes the shared project rerun isolated files. -if (nonIsolatedGlobs.length && config.test) { - const baseTest = config.test - const include = baseTest.include ?? [] - const exclude = baseTest.exclude ?? [] - const sharedFiles = discoverSharedTestFiles(nonIsolatedGlobs, { - include, - exclude, - }) - - config.test = { - ...baseTest, - projects: [ - { - ...config, - extends: false, - test: { - ...baseTest, - name: 'isolated', - isolate: true, - exclude: [...exclude, ...nonIsolatedGlobs], - }, - }, - { - ...config, - extends: false, - test: { - ...baseTest, - name: 'non-isolated', - isolate: false, - include: sharedFiles.length - ? sharedFiles - : ['__fleet_no_shared_tests__'], - }, - }, - ], - } -} - -export default config diff --git a/.config/repo/vitest.settings.mts b/.config/repo/vitest.settings.mts deleted file mode 100644 index fab61ab4..00000000 --- a/.config/repo/vitest.settings.mts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @file The repo-tunable vitest settings surface — the `vitest` section of - * the canonical per-repo settings file - * (.config/repo/socket-wheelhouse.json), split from vitest.config.mts along - * its natural boundary: everything here READS settings, everything there - * RESOLVES runtime config from them. One file, one parse; each key's - * contract is on its field below, and docs/fleet/agents.md/test-layout.md - * carries the tier rationale. - * Exported readers are ordered alphabetically, which `socket/sort-source- - * methods` enforces in every consuming member. Neither copy of this file is - * linted in the wheelhouse itself, so the order is a downstream contract. - */ - -import { existsSync, globSync, lstatSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { - parseBunTestFiles, - readBunTestFiles as readConfiguredBunTestFiles, -} from '../../scripts/fleet/test-runner/bun-tests.mts' - -export interface VitestRepoConfig { - // Exact repo-relative test files run by Bun and excluded from Vitest. - bunTests?: string[] | undefined - // Module resolve aliases for the test transform, e.g. - // `{ "@socketsecurity/sdk": "./dist/index.browser.js" }`. A key is a LITERAL - // specifier, never a glob, so a monorepo lists one entry per package; see - // mergeVitestAlias for the dot-relative semantics. - alias?: Record | undefined - // Extra `resolve.conditions`. The route for a monorepo whose `exports` map - // carries a `source` condition: without it vitest resolves a workspace - // package to its built `dist`, the instrumented `src` never runs, and the - // repo reports 0% coverage. - conditions?: string[] | undefined - conformanceExclude?: string[] | undefined - lanes?: VitestLanes | undefined - // Worker cap for the pool, floored against the CI/coverage-aware fallback. - maxWorkers?: number | undefined - // Globs safe to run in the faster non-isolated pool. - nonIsolated?: string[] | undefined - // node:test homes excluded from vitest discovery, e.g. `tools/**/test/**` for - // a `node --test` tool corpus. prefer-vitest-guard reads the SAME key so its - // allowlist and this exclude never drift. - nodeTestExclude?: string[] | undefined - pool?: 'forks' | 'threads' | undefined -} - -/** - * Test LANES — a SPEED category, orthogonal to test TYPE (unit/integration/e2e) - * — from the `vitest.lanes` section of the canonical per-repo settings file - * (.config/repo/socket-wheelhouse.json; see paths.mts's resolver order for the - * fallbacks). `slow` = heavy suites (subprocess-per-case, e.g. hook integration - * specs); `mid` = isolated in-process suites (env-mutating / vi.mock / - * fs-heavy); `fast` = pure in-process. `slow` selects its declared patterns. - * `mid` selects its declared patterns except tests selected by `slow`. - * `fast` selects every remaining test, regardless of its declared patterns. - * The runner's - * `--lane ` flag (scripts/fleet/test.mts) selects one, and bare - * `pnpm test` defaults to `fast` for a quick local loop. The lane filter is - * active under coverage too. An unset FLEET_LANE traverses every lane; the - * coverage runner selects each lane in turn and merges their reports. - */ -export interface VitestLanes { - fast?: string[] | undefined - mid?: string[] | undefined - slow?: string[] | undefined -} - -// The settings file, canonical location first and the repo-root dotfile as the -// one fallback a member may ship. -export const SETTINGS_FILES = [ - '.config/repo/socket-wheelhouse.json', - '.socket-wheelhouse.json', -] as const - -/** - * Resolve the shared project while retaining Node's glob semantics. Most lane - * exclusions are exact test files: checking those in a Set avoids repeatedly - * matching hundreds of literal patterns during glob traversal. Only ordinary - * relative file paths qualify; escapes, glob syntax, unusual names, missing - * paths and directories stay with Node's original exclusion implementation. - */ -export function discoverSharedTestFiles( - patterns: string[], - options: { include: string[]; exclude: string[]; cwd?: string | undefined }, -): string[] { - const exactFiles = new Set() - const globExcludes: string[] = [] - for (const pattern of options.exclude) { - const normalizedPattern = normalizePath(pattern) - // Positive literal grammar: no escape, extglob, brace or bracket syntax; - // segments cannot be dot/dotdot. Unrecognized forms use native glob. - const literal = - /^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.test\.(?:js|ts|mjs|mts|cjs)$/u.test( - normalizedPattern, - ) - let regularFile = false - if (literal) { - try { - regularFile = lstatSync( - path.resolve(options.cwd ?? '.', normalizedPattern), - ).isFile() - } catch { - // Missing or unreadable candidates retain native exclusion behavior. - } - } - if (regularFile) { - exactFiles.add(normalizedPattern) - } else { - globExcludes.push(normalizedPattern) - } - } - return [ - ...globSync(patterns, { cwd: options.cwd, exclude: globExcludes }), - ].filter( - file => - !exactFiles.has(file.split(path.sep).join('/')) && - options.include.some(pattern => path.matchesGlob(file, pattern)), - ) -} - -/** - * The CONFORMANCE tier — heavy external-suite wrappers (a full Test262 corpus - * per implementation, upstream conformance harnesses) named by - * `vitest.conformanceExclude` in the settings file. - * - * `scripts/repo/test-conformance.mts` runs this tier explicitly with - * FLEET_TEST_CONFORMANCE=1; every other run must EXCLUDE it. Both halves live - * here so neither can sit unwired: an env var the runner sets and nothing - * reads, or a setting that names the tier while no lane excludes it, would — - * so `pnpm run cover` spawned a ~92k-scenario corpus per BUILT implementation, - * three at once. Against the 60s unit budget that reads as a hung run rather - * than the multi-hour sweep it actually is. - */ -export function readConformanceExcludeGlobs(): string[] { - return stringArray(readVitestSettings().conformanceExclude) -} - -export function readNonIsolatedGlobs(): string[] { - return stringArray(readVitestSettings().nonIsolated) -} - -export function readVitestLanes(): VitestLanes { - const lanes = readVitestSettings().lanes - return lanes !== null && typeof lanes === 'object' && !Array.isArray(lanes) - ? { - ...(Array.isArray(lanes.fast) ? { fast: stringArray(lanes.fast) } : {}), - mid: stringArray(lanes.mid), - slow: stringArray(lanes.slow), - } - : {} -} - -export function readBunTestFiles(repoRoot?: string | undefined): string[] { - return repoRoot === undefined - ? parseBunTestFiles(readVitestSettings().bunTests) - : readConfiguredBunTestFiles(repoRoot) -} - -/** - * The `vitest` section of the settings file. The ONE settings-file parse in - * this config: every resolver reads its key off this, so a torn or absent - * file degrades to fleet defaults in exactly one place instead of six. - */ -export function readVitestSettings(): VitestRepoConfig { - for (let i = 0, { length } = SETTINGS_FILES; i < length; i += 1) { - const file = SETTINGS_FILES[i]! - if (!existsSync(file)) { - continue - } - try { - const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) - const section = - parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as { vitest?: VitestRepoConfig | undefined }).vitest - : undefined - return section !== null && - typeof section === 'object' && - !Array.isArray(section) - ? section - : {} - } catch { - return {} - } - } - return {} -} - -export function repoNodeTestExcludeGlobs(): string[] { - return stringArray(readVitestSettings().nodeTestExclude) -} - -/** - * A settings value read as a string array; anything else reads as empty. - */ -export function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((g): g is string => typeof g === 'string') - : [] -} diff --git a/.git-hooks/_shared/canonical/bundle.mts b/.git-hooks/_shared/canonical/bundle.mts deleted file mode 100644 index 8ee826df..00000000 --- a/.git-hooks/_shared/canonical/bundle.mts +++ /dev/null @@ -1,123 +0,0 @@ -import { canonicalOriginAllowed } from './source.mts' -import crypto from 'node:crypto' -import { readFileSync, statSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { canonicalPathIsSafe } from './git.mts' -import type { CanonicalIndexEntry } from './git.mts' - -function record(value: unknown): Record | undefined { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? Object.fromEntries(Object.entries(value)) - : undefined -} - -function unscopedTarget( - manifest: Record, - file: string, -): boolean { - for (const field of [ - 'capabilityScopedFiles', - 'conditionalScopedFiles', - 'shapeScopedFiles', - ]) { - const groups = manifest[field] - if (groups === undefined) { - continue - } - if (!Array.isArray(groups)) { - return false - } - for (const value of groups) { - const files = record(value)?.['files'] - if (!Array.isArray(files) || files.includes(file)) { - return false - } - } - } - return true -} - -export function canonicalBundleBytesMatch( - ref: string, - file: string, - entry: CanonicalIndexEntry, - manifest: unknown, -): boolean { - const parsed = record(manifest) - const files = record(parsed?.['files']) - const expected = files?.[file] - return ( - /^fleet-pack-[0-9a-f]{40}$/u.test(ref) && - parsed?.['templateSha'] === ref.slice('fleet-pack-'.length) && - unscopedTarget(parsed, file) && - canonicalPathIsSafe(file) && - entry.mode === '100644' && - typeof expected === 'string' && - /^[0-9a-f]{64}$/u.test(expected) && - crypto.hash('sha256', entry.content, 'hex') === expected - ) -} - -const manifests = new Map() -function fetchManifest(member: string, ref: string): unknown { - const key = `${member}:${ref}` - if (!manifests.has(key)) { - const result = spawnSync( - process.execPath, - [path.join(member, 'scripts/fleet/pack/canonical-proof.mts'), ref], - { - cwd: member, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 30_000, - maxBuffer: 4_194_304, - }, - ) - if (result.status !== 0) { - return undefined - } - const lines = result.stdout.trim().split(/\r?\n/u) - const manifest: unknown = JSON.parse(lines.at(-1) ?? '') - if (record(manifest)?.['templateSha'] !== ref.slice('fleet-pack-'.length)) { - return undefined - } - manifests.set(key, manifest) - } - return manifests.get(key) -} - -export function canonicalBundleCopyMatches( - member: string, - file: string, - entry: CanonicalIndexEntry, - options: { - fetchManifest?: ((member: string, ref: string) => unknown) | undefined - } = {}, -): boolean { - if (!canonicalOriginAllowed(member)) { - return false - } - try { - const appliedPath = path.join( - member, - '.cache/fleet/socket-wheelhouse/bundle-applied', - ) - if (statSync(appliedPath).size > 128) { - return false - } - const ref = readFileSync(appliedPath, 'utf8').trim() - if (!/^fleet-pack-[0-9a-f]{40}$/u.test(ref)) { - return false - } - return canonicalBundleBytesMatch( - ref, - file, - entry, - (options.fetchManifest ?? fetchManifest)(member, ref), - ) - } catch { - return false - } -} diff --git a/.git-hooks/_shared/canonical/fork-scan.mts b/.git-hooks/_shared/canonical/fork-scan.mts deleted file mode 100644 index 938497af..00000000 --- a/.git-hooks/_shared/canonical/fork-scan.mts +++ /dev/null @@ -1,260 +0,0 @@ -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { readCanonicalIndexEntry, readCanonicalTreeEntry } from './git.mts' -import { canonicalMemberCopyMatches } from './proof.mts' -import type { CanonicalIndexEntry } from './git.mts' - -import { - fleetCanonicalEntries, - isOperatorLocalPath, - isPerRepoMarkerPath, -} from '../../../.claude/hooks/fleet/_shared/fleet-fork.mts' -import { - findFleetRegions, - textHasFleetBlockMarkers, -} from '../../../.claude/hooks/fleet/_shared/fleet-markers.mts' - -// Each child names one capability or member, never an arbitrary generated -// bucket. Generated universal files map directly to the destination tree. -const TEMPLATE_ROOTS: readonly string[] = [ - path.join('base', 'conditional'), - 'overrides', - path.join('generated', 'conditional'), -] - -/** - * Candidate template sources for a live repo-relative path. - */ -export function templateTwinPaths(repoRoot: string, file: string): string[] { - const candidates = [ - path.join(repoRoot, 'template', 'base', 'universal', file), - ] - const generatedUniversal = path.join( - repoRoot, - 'template', - 'generated', - 'universal', - ) - if (existsSync(generatedUniversal)) { - candidates.push(path.join(generatedUniversal, file)) - } - for (let i = 0, { length } = TEMPLATE_ROOTS; i < length; i += 1) { - const root = path.join(repoRoot, 'template', TEMPLATE_ROOTS[i]!) - let names: string[] - try { - names = readdirSync(root) - } catch { - continue - } - for (let j = 0, { length: namesLength } = names; j < namesLength; j += 1) { - candidates.push(path.join(root, names[j]!, file)) - } - } - return candidates -} - -/** - * Whether the live content is byte-identical to one of its template twins. - * - * Then it is cascade OUTPUT, not a fork: a fork is a live copy that DIVERGED - * from canonical. The cascade lands its own mirrors outside this hook chain, - * but when it loses the index lock to a parallel session it leaves them staged, - * and only the operator can land them. Refusing that commit leaves no reachable - * fix - the tool-call guard forbids writing the mirror by hand, and the cascade - * cannot retry while the lock is held - so the operator's only remaining route - * is skipping every hook, which is strictly worse than this exemption. - */ -export function matchesTemplateTwin( - repoRoot: string, - file: string, - content: string | Uint8Array, - mode = '100644', -): boolean { - const candidates = templateTwinPaths(repoRoot, file) - for (let i = 0, { length } = candidates; i < length; i += 1) { - try { - const stat = lstatSync(candidates[i]!) - const candidateMode = stat.mode & 0o111 ? '100755' : '100644' - if (!stat.isFile() || candidateMode !== mode) { - continue - } - if ( - readFileSync(candidates[i]!).equals( - typeof content === 'string' ? Buffer.from(content) : content, - ) - ) { - return true - } - } catch { - continue - } - } - return false -} - -function readForkGit(repoRoot: string, args: string[]): string { - const result = spawnSync('git', ['--literal-pathspecs', ...args], { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000, - }) - return result.status === 0 ? (result.stdout ?? '') : '' -} - -function mergeParentIds(repoRoot: string): string[] { - const mergeHeadPath = readForkGit(repoRoot, [ - 'rev-parse', - '--git-path', - 'MERGE_HEAD', - ]).trim() - const parents = readFileSync(path.resolve(repoRoot, mergeHeadPath), 'utf8') - .trim() - .split(/\r?\n/) - // Each parent is a full SHA-1 (40 hex digits) or SHA-256 (64 hex digits). - if ( - !parents.every(parent => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(parent)) - ) { - return [] - } - return parents -} - -export function matchesMergeParentIndex( - repoRoot: string, - file: string, -): boolean { - try { - const parents = mergeParentIds(repoRoot) - if (parents.length === 0) { - return false - } - const staged = readForkGit(repoRoot, [ - 'ls-files', - '--stage', - '-z', - '--', - file, - ]) - // Match one stage-zero entry: mode, object ID, stage, then a tab. - const entry = /^(\d+) ([a-f0-9]+) 0\t/.exec(staged) - if (!entry || staged !== `${entry[0]}${file}\0`) { - return false - } - const expected = `${entry[1]} blob ${entry[2]}\t${file}\0` - for (const parent of parents) { - if ( - readForkGit(repoRoot, ['cat-file', '-t', parent]).trim() !== 'commit' - ) { - return false - } - if ( - readForkGit(repoRoot, ['ls-tree', '-z', parent, '--', file]) === - expected - ) { - return true - } - } - } catch { - return false - } - return false -} - -export interface CanonicalForkFinding { - file: string -} - -function isInsideTemplateRelative(file: string): boolean { - return file === 'template' || file.startsWith('template/') -} - -/** - * Every staged path (repo-relative, POSIX-normalized, add/change/modify - * only — a caller filters deletions out via `--diff-filter=ACM`) that is - * fleet-canonical and differs from its canonical source or merge parent. - */ -export function scanCanonicalForkPaths( - stagedFiles: readonly string[], - repoRoot: string, -): CanonicalForkFinding[] { - const entries = fleetCanonicalEntries(repoRoot) - if (entries.length === 0) { - return [] - } - const findings: CanonicalForkFinding[] = [] - for (let i = 0, { length } = stagedFiles; i < length; i += 1) { - const file = stagedFiles[i]! - if (isInsideTemplateRelative(file)) { - continue - } - if (isPerRepoMarkerPath(file) || isOperatorLocalPath(file)) { - continue - } - let isCanonical = false - for ( - let j = 0, { length: entriesLength } = entries; - j < entriesLength; - j += 1 - ) { - const entry = entries[j]! - // Glob entries are best-effort excluded here too — same conservative - // call `isCanonicalRelativePath` makes, so a bad pattern can never - // over-block a commit. - if (entry.includes('*')) { - continue - } - if (file === entry || file.startsWith(`${entry}/`)) { - isCanonical = true - break - } - } - if (!isCanonical) { - continue - } - const entry = readCanonicalIndexEntry(repoRoot, file) - if (entry && stagedCanonicalFileIsAllowed(repoRoot, file, entry)) { - continue - } - findings.push({ file }) - } - return findings -} - -function fleetRegionBodies(content: Uint8Array): string[] { - const text = Buffer.from(content).toString('utf8') - if (!textHasFleetBlockMarkers(text)) { - return [] - } - const lines = text.split(/(?<=\n)/u) - return findFleetRegions(lines).map(region => - lines.slice(region.start, region.end + 1).join(''), - ) -} - -function stagedCanonicalFileIsAllowed( - repoRoot: string, - file: string, - entry: CanonicalIndexEntry, -): boolean { - if (matchesMergeParentIndex(repoRoot, file)) { - return true - } - const baseline = readCanonicalTreeEntry(repoRoot, 'HEAD', file) - if (baseline && baseline.mode === entry.mode) { - const before = fleetRegionBodies(baseline.content) - const after = fleetRegionBodies(entry.content) - if ( - before.length > 0 && - before.length === after.length && - before.every((body, index) => body === after[index]) - ) { - return true - } - } - return ( - matchesTemplateTwin(repoRoot, file, entry.content, entry.mode) || - canonicalMemberCopyMatches(repoRoot, file, entry) - ) -} diff --git a/.git-hooks/_shared/canonical/git.mts b/.git-hooks/_shared/canonical/git.mts deleted file mode 100644 index e9396dda..00000000 --- a/.git-hooks/_shared/canonical/git.mts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @file Read immutable Git blobs and the caller's active stage-zero index. - */ -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { GIT_CONTEXT_VARS } from '../git-context-vars.mts' - -export interface CanonicalGitOptions { - input?: Uint8Array | undefined - preserveIndex?: boolean | undefined -} -export interface CanonicalGitResult { - status: number - stdout: Uint8Array -} -export type CanonicalGitRead = ( - root: string, - args: readonly string[], - options?: CanonicalGitOptions | undefined, -) => CanonicalGitResult -export interface CanonicalIndexEntry { - mode: string - oid: string - content: Uint8Array -} - -export function readCanonicalGit( - root: string, - args: readonly string[], - options: CanonicalGitOptions = {}, -): CanonicalGitResult { - const env = { ...process.env } - for (let i = 0, { length } = GIT_CONTEXT_VARS; i < length; i += 1) { - const name = GIT_CONTEXT_VARS[i]! - if (name !== 'GIT_INDEX_FILE' || !options.preserveIndex) { - delete env[name] - } - } - const result = spawnSync( - 'git', - ['--no-replace-objects', '--literal-pathspecs', ...args], - { - cwd: root, - encoding: 'buffer', - env, - input: options.input, - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - maxBuffer: 4_194_304, - }, - ) - return { - status: result.status ?? 1, - stdout: Buffer.isBuffer(result.stdout) - ? result.stdout - : Buffer.from(result.stdout ?? ''), - } -} - -export function canonicalGitText( - root: string, - args: readonly string[], - readGit: CanonicalGitRead = readCanonicalGit, -): string | undefined { - const result = readGit(root, args) - return result.status === 0 - ? Buffer.from(result.stdout).toString('utf8') - : undefined -} - -export function canonicalPathIsSafe(file: string): boolean { - const normalized = normalizePath(file) - return ( - file === normalized && - file.length > 0 && - file.length < 1024 && - !normalized.startsWith('/') && - !normalized.includes('\\') && - !/[\x00-\x1f\x7f:]/u.test(file) && - normalizePath(file) - .split('/') - .every( - part => - part !== '' && - part !== '.' && - part !== '..' && - part.toLowerCase() !== '.git', - ) - ) -} - -function readBlobEntry( - root: string, - oid: string, - mode: string, - readGit: CanonicalGitRead, -): CanonicalIndexEntry | undefined { - if (!['100644', '100755'].includes(mode)) { - return undefined - } - const result = readGit(root, ['cat-file', 'blob', oid]) - return result.status === 0 ? { mode, oid, content: result.stdout } : undefined -} - -export function readCanonicalIndexEntry( - root: string, - file: string, - readGit: CanonicalGitRead = readCanonicalGit, -): CanonicalIndexEntry | undefined { - if (!canonicalPathIsSafe(file)) { - return undefined - } - const result = readGit(root, ['ls-files', '--stage', '-z', '--', file], { - preserveIndex: true, - }) - if (result.status !== 0) { - return undefined - } - // A single stage-zero regular blob, with its exact NUL-terminated path. - const match = - /^(100644|100755) ((?:[a-f0-9]{40}|[a-f0-9]{64})) 0\t([^\0]+)\0$/u.exec( - Buffer.from(result.stdout).toString('utf8'), - ) - return match?.[3] === file - ? readBlobEntry(root, match[2]!, match[1]!, readGit) - : undefined -} - -export function readCanonicalTreeEntry( - root: string, - ref: string, - file: string, - readGit: CanonicalGitRead = readCanonicalGit, -): CanonicalIndexEntry | undefined { - if (!canonicalPathIsSafe(file)) { - return undefined - } - const text = canonicalGitText( - root, - ['ls-tree', '-z', ref, '--', file], - readGit, - ) - if (text === undefined) { - return undefined - } - // A single regular tree blob, with its exact NUL-terminated path. - const match = - /^(100644|100755) blob ((?:[a-f0-9]{40}|[a-f0-9]{64}))\t([^\0]+)\0$/u.exec( - text, - ) - return match?.[3] === file - ? readBlobEntry(root, match[2]!, match[1]!, readGit) - : undefined -} - -export function canonicalEntriesEqual( - left: CanonicalIndexEntry, - right: CanonicalIndexEntry, -): boolean { - return ( - left.mode === right.mode && Buffer.from(left.content).equals(right.content) - ) -} diff --git a/.git-hooks/_shared/canonical/patch.mts b/.git-hooks/_shared/canonical/patch.mts deleted file mode 100644 index ef3bf7ad..00000000 --- a/.git-hooks/_shared/canonical/patch.mts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @file Select complete context-preserving hunks from an immutable Git patch. - */ -export function canonicalHunksAreValid(value: unknown): value is number[] { - return ( - Array.isArray(value) && - value.length > 0 && - value.length <= 128 && - value.every( - (index, position) => - Number.isSafeInteger(index) && - index >= 0 && - (position === 0 || index > value[position - 1]), - ) - ) -} - -export function selectCanonicalPatch( - text: string, - source: string, - selected?: readonly number[] | undefined, -): Uint8Array | undefined { - if ( - !text.startsWith(`diff --git a/${source} b/${source}\n`) || - text.split('\ndiff --git ').length !== 1 - ) { - return undefined - } - if ( - /^(?:Binary files |GIT binary patch|copy |deleted file mode|new file mode|new mode|old mode|rename )/mu.test( - text, - ) - ) { - return undefined - } - const sections = text.split(/(?=^@@ )/mu) - const header = sections.shift() - if ( - !header?.includes(`--- a/${source}\n+++ b/${source}\n`) || - sections.length === 0 - ) { - return undefined - } - const indexes = selected ?? [...sections.keys()] - if ( - !canonicalHunksAreValid(indexes) || - indexes.some(index => index >= sections.length) - ) { - return undefined - } - return Buffer.from(header + indexes.map(index => sections[index]).join('')) -} diff --git a/.git-hooks/_shared/canonical/proof.mts b/.git-hooks/_shared/canonical/proof.mts deleted file mode 100644 index f01724b7..00000000 --- a/.git-hooks/_shared/canonical/proof.mts +++ /dev/null @@ -1,357 +0,0 @@ -import { canonicalBundleCopyMatches } from './bundle.mts' -/** - * @file Prove stage-zero bytes from signed producer trees or ordered template - * patches. - */ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { selectCanonicalPatch } from './patch.mts' -import { applyCanonicalReplacements } from './replacements.mts' -import path from 'node:path' -import { - canonicalEntriesEqual, - canonicalGitText, - readCanonicalGit, - readCanonicalTreeEntry, -} from './git.mts' -import { - canonicalEligibleSources, - canonicalProducerAllowed, - canonicalSourceAllowed, - findCanonicalProducer, -} from './source.mts' -import { readCanonicalReceipt, validateCanonicalRecipe } from './receipt.mts' -import type { CanonicalGitRead, CanonicalIndexEntry } from './git.mts' -import type { CanonicalPatchRecipe, CanonicalPatchStep } from './receipt.mts' -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' -export { readCanonicalIndexEntry } from './git.mts' -export { validateCanonicalRecipe } from './receipt.mts' -export type { - CanonicalGitRead, - CanonicalIndexEntry, - CanonicalGitOptions, - CanonicalGitResult, -} from './git.mts' -export type { CanonicalPatchRecipe, CanonicalPatchStep } from './receipt.mts' - -export interface CanonicalProofOptions { - readGit?: CanonicalGitRead | undefined - producerAllowed?: ((root: string) => boolean) | undefined - sourceAllowed?: - | (( - member: string, - producer: string, - commit: string, - source: string, - target: string, - ) => boolean) - | undefined -} - -function signedParent( - root: string, - commit: string, - readGit: CanonicalGitRead, -): string | undefined { - if ( - canonicalGitText( - root, - ['log', '-1', '--format=%G?', commit], - readGit, - )?.trim() !== 'G' - ) { - return undefined - } - const parents = canonicalGitText( - root, - ['rev-list', '--parents', '-n', '1', commit], - readGit, - ) - ?.trim() - .split(' ') - return parents?.length === 2 && parents[0] === commit ? parents[1] : undefined -} - -function stepSourceAllowed( - member: string, - recipe: CanonicalPatchRecipe, - step: CanonicalPatchStep, - config: CanonicalProofOptions, -): boolean { - const readGit = config.readGit ?? readCanonicalGit - const allowed = - config.sourceAllowed ?? - ((memberRoot, producerRoot, commit, source, target) => - canonicalSourceAllowed( - memberRoot, - producerRoot, - commit, - source, - target, - readGit, - )) - return allowed( - member, - recipe.producerRoot, - step.commit, - step.sourcePath, - step.targetPath, - ) -} - -function sourcePatch( - member: string, - recipe: CanonicalPatchRecipe, - step: CanonicalPatchStep, - config: CanonicalProofOptions, -): Uint8Array | undefined { - const readGit = config.readGit ?? readCanonicalGit - const parent = signedParent(recipe.producerRoot, step.commit, readGit) - if (!parent || !stepSourceAllowed(member, recipe, step, config)) { - return undefined - } - const before = readCanonicalTreeEntry( - recipe.producerRoot, - parent, - step.sourcePath, - readGit, - ) - const after = readCanonicalTreeEntry( - recipe.producerRoot, - step.commit, - step.sourcePath, - readGit, - ) - if (!before || !after || before.mode !== after.mode) { - return undefined - } - const patch = readGit(recipe.producerRoot, [ - 'diff', - '--no-ext-diff', - '--no-textconv', - '--src-prefix=a/', - '--dst-prefix=b/', - parent, - step.commit, - '--', - step.sourcePath, - ]) - if (patch.status !== 0 || patch.stdout.length === 0) { - return undefined - } - return selectCanonicalPatch( - Buffer.from(patch.stdout).toString('utf8'), - step.sourcePath, - step.hunks, - ) -} - -function applyStrictPatch( - scratch: string, - patch: Uint8Array, - strip: number, -): boolean { - return ( - readCanonicalGit( - scratch, - ['apply', '--whitespace=nowarn', `-p${strip}`, '-'], - { input: patch }, - ).status === 0 - ) -} - -function applyIndividualHunks( - scratch: string, - destination: string, - patch: Uint8Array, - strip: number, -): boolean { - const sections = Buffer.from(patch) - .toString('utf8') - .split(/(?=^@@ )/mu) - const header = sections.shift() - if (!header || sections.length === 0) { - return false - } - for (let i = 0, { length } = sections; i < length; i += 1) { - const hunk = Buffer.from(header + sections[i]) - if (applyStrictPatch(scratch, hunk, strip)) { - continue - } - const replacement = applyCanonicalReplacements( - readFileSync(destination), - hunk, - ) - if (!replacement) { - return false - } - writeFileSync(destination, replacement) - } - return true -} - -function applyProofSteps( - member: string, - file: string, - recipe: CanonicalPatchRecipe, - baseline: CanonicalIndexEntry, - config: CanonicalProofOptions, -): Uint8Array | undefined { - const scratch = mkdtempSync(path.join(os.tmpdir(), 'fleet-canonical-proof-')) - try { - const destination = path.join(scratch, file) - mkdirSync(path.dirname(destination), { recursive: true }) - writeFileSync(destination, baseline.content) - chmodSync(destination, baseline.mode === '100755' ? 0o755 : 0o644) - const seen = new Set() - const steps = recipe.patches.filter(item => item.targetPath === file) - for ( - let stepIndex = 0, { length } = steps; - stepIndex < length; - stepIndex += 1 - ) { - const step = steps[stepIndex]! - if (seen.has(step.commit)) { - return undefined - } - seen.add(step.commit) - const patch = sourcePatch(member, recipe, step, config) - if (!patch) { - return undefined - } - const strip = - normalizePath(step.sourcePath).split('/').length - - normalizePath(file).split('/').length + - 1 - if ( - !applyStrictPatch(scratch, patch, strip) && - (step.application !== 'unique-replacement' || - !applyIndividualHunks(scratch, destination, patch, strip)) - ) { - return undefined - } - } - return readFileSync(destination) - } finally { - safeDeleteSync(scratch) - } -} - -export function canonicalRecipeMatches( - member: string, - file: string, - entry: CanonicalIndexEntry, - value: unknown, - options: CanonicalProofOptions = {}, -): boolean { - const recipe = validateCanonicalRecipe(value) - if (!recipe || !recipe.patches.some(step => step.targetPath === file)) { - return false - } - const readGit = options.readGit ?? readCanonicalGit - if ( - !(options.producerAllowed ?? canonicalProducerAllowed)(recipe.producerRoot) - ) { - return false - } - if ( - canonicalGitText( - member, - ['rev-parse', '--verify', 'HEAD'], - readGit, - )?.trim() !== recipe.memberHead - ) { - return false - } - const baseline = readCanonicalTreeEntry(member, 'HEAD', file, readGit) - if (!baseline || baseline.mode !== entry.mode) { - return false - } - try { - const result = applyProofSteps(member, file, recipe, baseline, options) - return result !== undefined && Buffer.from(result).equals(entry.content) - } catch { - return false - } -} - -export function canonicalRecipeCopyMatches( - member: string, - file: string, - entry: CanonicalIndexEntry, - value: unknown, - options: CanonicalProofOptions = {}, -): boolean { - const recipe = validateCanonicalRecipe(value) - const readGit = options.readGit ?? readCanonicalGit - if ( - !recipe || - !(options.producerAllowed ?? canonicalProducerAllowed)( - recipe.producerRoot, - ) || - canonicalGitText( - member, - ['rev-parse', '--verify', 'HEAD'], - readGit, - )?.trim() !== recipe.memberHead - ) { - return false - } - return recipe.patches.some(step => { - if ( - step.targetPath !== file || - !signedParent(recipe.producerRoot, step.commit, readGit) || - !stepSourceAllowed(member, recipe, step, options) - ) { - return false - } - const source = readCanonicalTreeEntry( - recipe.producerRoot, - step.commit, - step.sourcePath, - readGit, - ) - return source !== undefined && canonicalEntriesEqual(entry, source) - }) -} - -export function canonicalMemberCopyMatches( - member: string, - file: string, - entry: CanonicalIndexEntry, -): boolean { - const producer = findCanonicalProducer(member) - if (producer) { - const head = canonicalGitText(producer, [ - 'rev-parse', - '--verify', - 'HEAD', - ])?.trim() - if (head && signedParent(producer, head, readCanonicalGit)) { - for (const source of canonicalEligibleSources(member, file)) { - if (!canonicalSourceAllowed(member, producer, head, source, file)) { - continue - } - const committed = readCanonicalTreeEntry(producer, head, source) - if (committed && canonicalEntriesEqual(entry, committed)) { - return true - } - } - } - } - if (canonicalBundleCopyMatches(member, file, entry)) { - return true - } - const receipt = readCanonicalReceipt(member) - return ( - canonicalRecipeCopyMatches(member, file, entry, receipt) || - canonicalRecipeMatches(member, file, entry, receipt) - ) -} diff --git a/.git-hooks/_shared/canonical/receipt.mts b/.git-hooks/_shared/canonical/receipt.mts deleted file mode 100644 index 08438264..00000000 --- a/.git-hooks/_shared/canonical/receipt.mts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @file Validate runtime-only recipes for reproducible signed template deltas. - */ -import { readFileSync } from 'node:fs' -import path from 'node:path' -import { canonicalHunksAreValid } from './patch.mts' -import { - canonicalGitText, - canonicalPathIsSafe, - readCanonicalGit, -} from './git.mts' -import type { CanonicalGitRead } from './git.mts' - -export interface CanonicalPatchStep { - commit: string - sourcePath: string - targetPath: string - hunks?: number[] | undefined - application?: 'unique-replacement' | undefined -} -export interface CanonicalPatchRecipe { - version: 1 - memberHead: string - producerRoot: string - patches: CanonicalPatchStep[] -} -// Accept immutable SHA-1 or SHA-256 object IDs only. -const SHA_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u -function recordValue(value: unknown): Record | undefined { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined -} -function validStep(value: unknown): value is CanonicalPatchStep { - const record = recordValue(value) - if ( - record?.['application'] !== undefined && - record['application'] !== 'unique-replacement' - ) { - return false - } - if ( - record?.['hunks'] !== undefined && - !canonicalHunksAreValid(record['hunks']) - ) { - return false - } - return Boolean( - record && - typeof record['commit'] === 'string' && - SHA_PATTERN.test(record['commit']) && - typeof record['sourcePath'] === 'string' && - canonicalPathIsSafe(record['sourcePath']) && - typeof record['targetPath'] === 'string' && - canonicalPathIsSafe(record['targetPath']) && - record['sourcePath'].endsWith(`/${record['targetPath']}`), - ) -} -export function validateCanonicalRecipe( - value: unknown, -): CanonicalPatchRecipe | undefined { - const record = recordValue(value) - if ( - !record || - record['version'] !== 1 || - typeof record['memberHead'] !== 'string' || - !SHA_PATTERN.test(record['memberHead']) - ) { - return undefined - } - if ( - typeof record['producerRoot'] !== 'string' || - !path.isAbsolute(record['producerRoot']) || - record['producerRoot'].includes('\0') - ) { - return undefined - } - if ( - !Array.isArray(record['patches']) || - record['patches'].length === 0 || - record['patches'].length > 64 || - !record['patches'].every(validStep) - ) { - return undefined - } - return record as unknown as CanonicalPatchRecipe -} -export function canonicalReceiptPath( - root: string, - readGit: CanonicalGitRead = readCanonicalGit, -): string | undefined { - const relative = canonicalGitText( - root, - ['rev-parse', '--git-path', 'fleet/canonical-patch-receipt.json'], - readGit, - )?.trim() - return relative ? path.resolve(root, relative) : undefined -} -export function readCanonicalReceipt( - root: string, -): CanonicalPatchRecipe | undefined { - const receipt = canonicalReceiptPath(root) - if (!receipt) { - return undefined - } - try { - const bytes = readFileSync(receipt) - return bytes.length <= 65_536 - ? validateCanonicalRecipe(JSON.parse(bytes.toString('utf8'))) - : undefined - } catch { - return undefined - } -} diff --git a/.git-hooks/_shared/canonical/replacements.mts b/.git-hooks/_shared/canonical/replacements.mts deleted file mode 100644 index 1608ebcd..00000000 --- a/.git-hooks/_shared/canonical/replacements.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * @file Apply uniquely located replacement runs from verified signed hunks. - */ -import { selectCanonicalPatch } from './patch.mts' - -interface ReplacementRun { - before: string - after: string -} -interface HunkState { - before: number - after: number - previous: string - run: ReplacementRun - runs: ReplacementRun[] -} - -function finishRun(state: HunkState): boolean { - const run = state.run - if (!run.before && !run.after) { - return true - } - if (!run.before) { - return false - } - state.runs.push(run) - state.run = { before: '', after: '' } - return true -} - -function consumeHunkLine(state: HunkState, line: string): boolean { - const tag = line[0] - if (tag === ' ') { - state.before += 1 - state.after += 1 - state.previous = tag - return finishRun(state) - } - if (tag === '-') { - state.before += 1 - state.run.before += line.slice(1) - } else if (tag === '+') { - state.after += 1 - state.run.after += line.slice(1) - } else if ( - line === '\\ No newline at end of file' || - line === '\\ No newline at end of file\n' - ) { - if (state.previous === '-') { - state.run.before = state.run.before.slice(0, -1) - } else if (state.previous === '+') { - state.run.after = state.run.after.slice(0, -1) - } else if (state.previous !== ' ') { - return false - } - return true - } else { - return false - } - state.previous = tag - return true -} - -function hunkReplacements(section: string): ReplacementRun[] | undefined { - // Preserve each line terminator, including a final unterminated line. - const lines = section.match(/[^\n]*\n|[^\n]+$/gu) ?? [] - const header = lines.shift() - // Capture old and new line counts from one complete unified hunk header. - const match = - header && /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@[^\n]*\n$/u.exec(header) - if (!match) { - return undefined - } - const state: HunkState = { - before: 0, - after: 0, - previous: '', - run: { before: '', after: '' }, - runs: [], - } - if (!lines.every(line => consumeHunkLine(state, line)) || !finishRun(state)) { - return undefined - } - if ( - state.before !== Number(match[1] ?? 1) || - state.after !== Number(match[2] ?? 1) - ) { - return undefined - } - return state.runs -} - -function utf8Text(bytes: Uint8Array): string | undefined { - const text = Buffer.from(bytes).toString('utf8') - return !text.includes('\0') && Buffer.from(text).equals(bytes) - ? text - : undefined -} - -function replaceUniqueRun( - text: string, - run: ReplacementRun, -): string | undefined { - const index = text.indexOf(run.before) - if ( - index < 0 || - (index > 0 && text.charCodeAt(index - 1) !== 10) /* '\n' */ || - text.indexOf(run.before, index + 1) !== -1 || - (!run.before.endsWith('\n') && index + run.before.length !== text.length) - ) { - return undefined - } - return ( - text.slice(0, index) + run.after + text.slice(index + run.before.length) - ) -} - -export function applyCanonicalReplacements( - content: Uint8Array, - patch: Uint8Array, -): Uint8Array | undefined { - let text = utf8Text(content) - const delta = utf8Text(patch) - if (text === undefined || delta === undefined) { - return undefined - } - const first = delta.split(/\r?\n/, 1)[0] - const source = first?.startsWith('diff --git a/') - ? first.slice('diff --git a/'.length).split(' b/', 1)[0] - : undefined - if (!source || !selectCanonicalPatch(delta, source)) { - return undefined - } - const sections = delta.split(/(?=^@@ )/mu).slice(1) - let total = 0 - for ( - let sectionIndex = 0, { length } = sections; - sectionIndex < length; - sectionIndex += 1 - ) { - const runs = hunkReplacements(sections[sectionIndex]!) - if (!runs) { - return undefined - } - for ( - let runIndex = 0, { length: runLength } = runs; - runIndex < runLength; - runIndex += 1 - ) { - total += 1 - text = replaceUniqueRun(text, runs[runIndex]!) - if (text === undefined) { - return undefined - } - } - } - return total > 0 ? Buffer.from(text) : undefined -} diff --git a/.git-hooks/_shared/canonical/source.mts b/.git-hooks/_shared/canonical/source.mts deleted file mode 100644 index f8a67f7d..00000000 --- a/.git-hooks/_shared/canonical/source.mts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @file Authorize producer origins and member-specific committed template - * sources. - */ -import { findWheelhouseRoot } from '../../../.claude/hooks/fleet/_shared/wheelhouse-root.mts' -import fleetRosterJson from '../../../.claude/skills/fleet/cascading-commits/lib/fleet-repos.json' with { type: 'json' } -import { SOCKET_GITHUB_ORGS } from '../../../scripts/fleet/constants/socket-scopes.mts' -import { isFleetPackProducerSlug } from '../../../scripts/fleet/member/fleet-membership.mts' -import { - canonicalGitText, - canonicalPathIsSafe, - readCanonicalGit, - readCanonicalTreeEntry, -} from './git.mts' -import type { CanonicalGitRead } from './git.mts' - -export function canonicalMemberSlug(root: string): string | undefined { - const remote = canonicalGitText(root, ['remote', 'get-url', 'origin'])?.trim() - // Accept the three GitHub transports, retaining the organization segment. - const match = - remote && - /^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec( - remote, - ) - if (!match) { - return undefined - } - const member = fleetRosterJson.repos.find( - repo => repo.name.toLowerCase() === match[2]!.toLowerCase(), - ) - if (!member) { - return undefined - } - const owners = - 'owner' in member && typeof member.owner === 'string' - ? [member.owner] - : SOCKET_GITHUB_ORGS - return owners.some(owner => owner.toLowerCase() === match[1]!.toLowerCase()) - ? member.name - : undefined -} -export function canonicalOriginAllowed(root: string): boolean { - return canonicalMemberSlug(root) !== undefined -} -export function canonicalProducerAllowed(root: string): boolean { - return isFleetPackProducerSlug(canonicalMemberSlug(root)) -} -export function findCanonicalProducer(memberRoot: string): string | undefined { - const root = findWheelhouseRoot({ startDir: memberRoot }) - return root && canonicalProducerAllowed(root) ? root : undefined -} - -function memberConfig( - member: string, - readGit: CanonicalGitRead, -): Record | undefined { - const source = canonicalGitText( - member, - ['show', 'HEAD:.config/repo/socket-wheelhouse.json'], - readGit, - ) - try { - const parsed: unknown = - source === undefined ? undefined : JSON.parse(source) - return parsed !== null && - typeof parsed === 'object' && - !Array.isArray(parsed) - ? (parsed as Record) - : undefined - } catch { - return undefined - } -} - -function objectValue(value: unknown): Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - -function memberLayers(member: string, readGit: CanonicalGitRead): string[] { - const slug = canonicalMemberSlug(member) - if (!slug) { - return [] - } - const config = memberConfig(member, readGit) - if (!config || config['repoName'] !== slug) { - return [] - } - const kind = objectValue(config['repo'])['type'] - const capabilities = Object.entries(objectValue(config['capabilities'])) - .filter(([, scopes]) => Array.isArray(scopes) && scopes.length > 0) - .map(([name]) => name) - const layers = [`template/overrides/${slug}`] - const generated = ['template/generated/conditional/npm'] - const release = objectValue(config['release']) - if (release['github'] !== false) { - generated.push('template/generated/conditional/github-release') - layers.push('template/base/conditional/github-release') - } - for (let i = 0, { length } = capabilities; i < length; i += 1) { - const capability = capabilities[i]! - if (/^[a-z][a-z0-9-]*$/u.test(capability)) { - if (capability !== 'npm') { - generated.push(`template/generated/conditional/${capability}`) - } - layers.push(`template/base/conditional/${capability}`) - } - } - if (kind === 'mono' || kind === 'solo') { - layers.push(`template/${kind}`) - } - layers.push('template/base/universal') - return [...generated, 'template/generated/universal', ...layers] -} - -export function canonicalSourceAllowed( - member: string, - producer: string, - commit: string, - source: string, - target: string, - readGit: CanonicalGitRead = readCanonicalGit, -): boolean { - if (!canonicalPathIsSafe(source) || !canonicalPathIsSafe(target)) { - return false - } - for (const layer of memberLayers(member, readGit)) { - const candidate = `${layer}/${target}` - if (readCanonicalTreeEntry(producer, commit, candidate, readGit)) { - return source === candidate - } - } - return false -} - -export function canonicalEligibleSources( - member: string, - file: string, - readGit: CanonicalGitRead = readCanonicalGit, -): string[] { - return canonicalPathIsSafe(file) - ? memberLayers(member, readGit).map(layer => `${layer}/${file}`) - : [] -} diff --git a/.git-hooks/_shared/check-output.mts b/.git-hooks/_shared/check-output.mts deleted file mode 100644 index 48855d5c..00000000 --- a/.git-hooks/_shared/check-output.mts +++ /dev/null @@ -1,48 +0,0 @@ -import { readFileSync } from 'node:fs' -import process from 'node:process' -import { stripVTControlCharacters } from 'node:util' - -import { isDebugNs } from '@socketsecurity/lib-stable/debug/namespace' -import { debugNs } from '@socketsecurity/lib-stable/debug/output' - -import { isMainModule } from '../../scripts/fleet/process/is-main-module.mts' - -export const CHECK_DEBUG_NS = '*' - -export function debugCheck(...args: unknown[]): void { - if (isDebugNs(CHECK_DEBUG_NS)) { - debugNs(CHECK_DEBUG_NS, ...args) - } -} - -export function showCheckOutput(status: number | null, output: string): void { - if (!output) { - return - } - if ( - status !== 0 || - // Keep incomplete gates, Node warning classes, and tool warning labels. - /(?:NOT a pass|Warning:|\bwarn(?:ing)?s?\b)/i.test( - stripVTControlCharacters(output).replace(/\b0 warnings?\b/gi, ''), - ) - ) { - process.stderr.write(output) - } else if (isDebugNs(CHECK_DEBUG_NS)) { - debugCheck(output.trimEnd()) - } -} - -export function showCheckResult(result: { - status: number | null - stdout: string | Buffer | null - stderr: string | Buffer | null -}): void { - showCheckOutput(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`) -} - -if (isMainModule(import.meta.url)) { - showCheckOutput( - Number(process.argv[2]), - readFileSync(process.argv[3]!, 'utf8'), - ) -} diff --git a/.git-hooks/_shared/commit-format.mts b/.git-hooks/_shared/commit-format.mts deleted file mode 100644 index d2633752..00000000 --- a/.git-hooks/_shared/commit-format.mts +++ /dev/null @@ -1,146 +0,0 @@ -// Conventional Commits 1.0 header validation, shared by both enforcement -// surfaces: -// - the commit-message-format-guard PreToolUse hook (.claude/hooks/), which -// inspects `git commit -m` tool calls at Claude Bash time, and -// - the commit-msg git-stage backstop (.git-hooks/), which inspects the -// subject regardless of how the commit was made (subprocess / worktree / -// CI / test harness) — the layer the tool-call guard never sees. -// Canonical home: .git-hooks/_shared/; the .claude/hooks/ guard imports this -// cross-tree (the shared thing is this code, per the fleet "DRY across the two -// hook trees" rule). This module is side-effect-free — it reads no stdin, spawns -// nothing, and calls process.exit nowhere — so the git-stage hook can import it -// without triggering the guard's stdin-reading `main()`. -// -// Spec: https://www.conventionalcommits.org/en/v1.0.0/ - -// Real Claude-specific integration: "the Claude tool-call guard" names the -// .claude/hooks/ PreToolUse guard, which only exists for Claude Code, not -// generic agent guidance. -// oxlint-disable-next-line socket/no-agent-brand-assumption -- real -export const ALLOWED_TYPES = [ - 'build', - 'chore', - 'ci', - 'docs', - 'feat', - 'fix', - 'perf', - 'refactor', - 'revert', - 'style', - 'test', -] as const - -export const ALLOWED_TYPE_SET: ReadonlySet = new Set(ALLOWED_TYPES) - -// Header form: [(scope)][!]: -// - type: lowercase letters -// - optional (scope) in parens -// - optional `!` breaking-change marker -// - `: ` separator (colon + space) -// - non-empty description -export const HEADER_RE = /^([a-z]+)(\([^)]+\))?(!)?: (.+)$/ - -// Subjects git itself writes for non-`-m` commits. These are never -// Conventional Commits and must not be format-blocked at the git-stage twin -// (the Claude tool-call guard never sees them — they carry no inline -m -// message). `git merge` → `Merge branch …`/`Merge pull request …`; -// `git revert` → `Revert "…"`; autosquash → `fixup! …`/`squash! …`/`amend! …`. -const AUTO_SUBJECT_RE = /^(?:(?:amend|fixup|squash)! |Merge\b|Revert\b)/ - -/** - * True when the subject is one git auto-generates for a merge / revert / - * autosquash commit. Those are exempt from Conventional Commits enforcement. - */ -export function isAutoGeneratedSubject(subject: string): boolean { - return AUTO_SUBJECT_RE.test(subject.trim()) -} - -/** - * Result of validating a single message header. - * - * - Kind: 'ok' — header passes - * - Kind: 'no-type' — first line has no `: ` prefix at all - * - Kind: 'bad-type' — first line has a `: ` prefix but word isn't - * lowercase / not in the type set - * - Kind: 'uppercase-type' — type letters are present but include uppercase - * - Kind: 'empty-description' — header has `: ` but description is - * empty/whitespace - */ -export type HeaderCheck = - | { kind: 'ok' } - | { kind: 'no-type'; line: string } - | { kind: 'bad-type'; line: string; type: string } - | { kind: 'uppercase-type'; line: string; type: string } - | { kind: 'empty-description'; line: string; type: string } - -export function validateHeader(line: string): HeaderCheck { - // Quick pre-check: does the line look like a Conventional header at all? - // We accept any leading word-token before `: ` for diagnosis even if the - // case is wrong; the strict HEADER_RE then refines. - const looseMatch = /^([A-Za-z]+)(\([^)]+\))?(!)?:\s*(.*)$/.exec(line) - if (!looseMatch) { - return { kind: 'no-type', line } - } - const type = looseMatch[1]! - const desc = looseMatch[4]! - // Type must be all-lowercase. - if (type !== type.toLowerCase()) { - return { kind: 'uppercase-type', line, type } - } - // Type must be in the allowed set. - if (!ALLOWED_TYPE_SET.has(type)) { - return { kind: 'bad-type', line, type } - } - // Strict format check (catches "feat:description" without space, etc.). - const strictMatch = HEADER_RE.exec(line) - if (!strictMatch) { - // The loose pattern matched but the strict one didn't — that means - // either the `: ` separator is missing the space, or the description - // is empty. - if (!desc.trim()) { - return { kind: 'empty-description', line, type } - } - return { kind: 'no-type', line } - } - const description = strictMatch[4]! - if (!description.trim()) { - return { kind: 'empty-description', line, type } - } - return { kind: 'ok' } -} - -/** - * Build a context-appropriate suggestion for an invalid header. We look at the - * user's input and propose ONE example of a valid replacement based on what - * they typed. - */ -export function suggestReplacement(check: HeaderCheck): string { - if (check.kind === 'ok') { - return '' - } - const text = check.line.trim() - // Lowercase variant: try to recover the intent. - if (check.kind === 'uppercase-type') { - return `${check.type.toLowerCase()}: ${text.slice(text.indexOf(':') + 1).trim()}` - } - if (check.kind === 'bad-type') { - // Suggest 'feat' as a generic recoverable type, keep the rest. - const rest = - text.slice(text.indexOf(':') + 1).trim() || 'describe the change' - return `feat: ${rest}` - } - if (check.kind === 'empty-description') { - return `${check.type}: describe the change` - } - // no-type: try to fold whatever the user typed into a feat header. - const words = text.split(/\s+/).filter(Boolean) - const first = (words[0] ?? '').toLowerCase() - // If the first word looks like a noun (e.g. "parser", "extension"), use it - // as a scope and keep the rest as the description. - if (words.length >= 2 && /^[a-z][a-z0-9-]*$/.test(first)) { - const rest = words.slice(1).join(' ') - return `feat(${first}): ${rest}` - } - return `feat: ${text || 'describe the change'}` -} diff --git a/.git-hooks/_shared/commit-subject.mts b/.git-hooks/_shared/commit-subject.mts deleted file mode 100644 index ac64c0a1..00000000 --- a/.git-hooks/_shared/commit-subject.mts +++ /dev/null @@ -1,74 +0,0 @@ -// Placeholder commit-subject detection, shared by both enforcement surfaces: -// - the no-placeholder-commit-subject-guard PreToolUse hook (.claude/hooks/), -// which inspects `git commit -m` tool calls, and -// - the commit-msg git-stage backstop (.git-hooks/), which inspects the -// subject regardless of how the commit was made (subprocess / worktree / -// CI / test harness). -// Canonical home: .git-hooks/_shared/; the .claude/hooks/ guard imports this -// cross-tree (the shared thing is this code, per the fleet "DRY across the two -// hook trees" rule). - -// Subjects that say nothing about the change — the fingerprint of a -// test-harness / replayed / sandbox commit (a batch of `initial` commits once -// reached a fleet repo's main). Matched case-insensitively against the whole -// trimmed subject, after stripping one trailing period. -const PLACEHOLDER_SUBJECTS = new Set([ - '.', - 'changes', - 'commit', - 'fix', - 'fixes', - 'fixup', - 'init', - 'initial', - 'initial commit', - 'temp', - 'test', - 'tmp', - 'update', - 'updates', - 'wip', -]) - -/** - * The subject line of a commit message: the first non-blank, non-comment line. - */ -export function commitSubject(message: string): string { - return ( - message - .split(/\r?\n/) - .find(l => l.trim() && !l.trimStart().startsWith('#')) - ?.trim() ?? '' - ) -} - -/** - * Why a commit subject is unusable, or `ok`. - * - * `empty` and `placeholder` are separated because they have DIFFERENT causes - * and different fixes. An empty subject is almost never someone typing nothing: - * it is a `-F` path that did not resolve, a truncated command line, or an - * editor closed without saving. Telling that author their subject looks like - * `wip` sends them looking for a denylist entry that was never involved. - */ -export type CommitSubjectVerdict = 'empty' | 'ok' | 'placeholder' - -/** - * Classify a commit subject. Strips a single trailing period and lowercases - * before matching the denylist. - */ -export function commitSubjectVerdict(subject: string): CommitSubjectVerdict { - const norm = subject.trim().replace(/\.$/, '').trim().toLowerCase() - if (!norm) { - return 'empty' - } - return PLACEHOLDER_SUBJECTS.has(norm) ? 'placeholder' : 'ok' -} - -/** - * True when a commit subject is unusable, either kind. Callers that need to - * explain WHICH problem it is read {@link commitSubjectVerdict} instead. - */ -export function isPlaceholderSubject(subject: string): boolean { - return commitSubjectVerdict(subject) !== 'ok' -} diff --git a/.git-hooks/_shared/cross-repo-expressions.mts b/.git-hooks/_shared/cross-repo-expressions.mts deleted file mode 100644 index ac7fb2b1..00000000 --- a/.git-hooks/_shared/cross-repo-expressions.mts +++ /dev/null @@ -1,186 +0,0 @@ -import { fileURLToPath } from 'node:url' - -import type { AcornNode } from '../../.claude/hooks/fleet/_shared/ast/core.mts' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { - repositoryPathApi, - resolveRepositoryPath, -} from './repo-containment.mts' - -export interface RepositoryExpressionContext { - root: string - file: string - bindings: Map -} - -export function repositoryAstNode(value: unknown): AcornNode | undefined { - return value !== null && - typeof value === 'object' && - typeof (value as AcornNode).type === 'string' - ? (value as AcornNode) - : undefined -} - -export function repositoryExpressionName(node: AcornNode | undefined): string { - if (!node) { - return '' - } - if (node.type === 'Identifier') { - return String(node['name']) - } - if (node.type === 'MetaProperty') { - return 'import.meta' - } - if (node.type === 'MemberExpression' && !node['computed']) { - return `${repositoryExpressionName(repositoryAstNode(node['object']))}.${repositoryExpressionName(repositoryAstNode(node['property']))}` - } - return '' -} - -export function isRepositoryPathExpression(node: AcornNode): boolean { - const name = repositoryExpressionName(repositoryAstNode(node['callee'])) - return ( - (node.type === 'CallExpression' && - // Match path namespace methods and named imports of the same operations. - /^(?:(?:path|posix|win32)(?:\.(?:posix|win32))?\.)?(?:dirname|fileURLToPath|join|resolve)$/u.test( - name, - )) || - (node.type === 'NewExpression' && name === 'URL') - ) -} - -function evaluateRepositoryName( - node: AcornNode, - context: RepositoryExpressionContext, - visiting: Set, -): string | undefined { - const name = repositoryExpressionName(node) - if (name === '__dirname' || name === 'import.meta.dirname') { - return repositoryPathApi(context.file).dirname(context.file) - } - if (name === '__filename' || name === 'import.meta.url') { - return context.file - } - if (node.type !== 'Identifier') { - return undefined - } - if (!context.bindings.has(name)) { - return name === 'REPO_ROOT' ? context.root : undefined - } - if (visiting.has(name)) { - return undefined - } - const next = new Set(visiting) - next.add(name) - return evaluateRepositoryValue(context.bindings.get(name), context, next) -} - -function evaluateRepositoryTemplate( - node: AcornNode, - context: RepositoryExpressionContext, - visiting: Set, -): string | undefined { - const expressions = node['expressions'] as AcornNode[] - const quasis = node['quasis'] as Array<{ value: { cooked: string } }> - let value = quasis[0]?.value.cooked ?? '' - for (let index = 0; index < expressions.length; index += 1) { - const part = evaluateRepositoryValue(expressions[index], context, visiting) - if (part === undefined) { - return undefined - } - value += part + (quasis[index + 1]?.value.cooked ?? '') - } - return value -} - -function evaluateRepositoryUrl(args: string[]): string | undefined { - if (args[0]?.startsWith('file:')) { - try { - return fileURLToPath(args[0]) - } catch { - return undefined - } - } - if (!args[0] || !args[1] || /^[a-z]+:/iu.test(args[1])) { - return undefined - } - return resolveRepositoryPath( - repositoryPathApi(args[1]).dirname(args[1]), - args[0], - ) -} - -function evaluateRepositoryCall( - node: AcornNode, - context: RepositoryExpressionContext, - visiting: Set, -): string | undefined { - const callee = repositoryExpressionName(repositoryAstNode(node['callee'])) - if (node.type === 'CallExpression' && callee === 'process.cwd') { - return context.root - } - if (!isRepositoryPathExpression(node)) { - return undefined - } - const values = (node['arguments'] as AcornNode[]).map(argument => - evaluateRepositoryValue(argument, context, visiting), - ) - if (values.some(value => value === undefined)) { - return undefined - } - const args = values as string[] - if (node.type === 'NewExpression') { - return evaluateRepositoryUrl(args) - } - if (callee.endsWith('fileURLToPath')) { - return args[0]?.startsWith('file:') ? evaluateRepositoryUrl(args) : args[0] - } - const api = repositoryPathApi(context.root) - if (callee.endsWith('dirname')) { - return args[0] === undefined ? undefined : api.dirname(args[0]) - } - const normalized = args.map(argument => normalizePath(argument)) - return callee.endsWith('resolve') - ? api.resolve(context.root, ...normalized) - : api.join(...normalized) -} - -function evaluateRepositoryValue( - node: AcornNode | undefined, - context: RepositoryExpressionContext, - visiting: Set, -): string | undefined { - if (!node) { - return undefined - } - if (node.type === 'Literal' && typeof node['value'] === 'string') { - return node['value'] - } - if (node.type === 'TemplateLiteral') { - return evaluateRepositoryTemplate(node, context, visiting) - } - if (node.type === 'BinaryExpression' && node['operator'] === '+') { - const left = evaluateRepositoryValue( - repositoryAstNode(node['left']), - context, - visiting, - ) - const right = evaluateRepositoryValue( - repositoryAstNode(node['right']), - context, - visiting, - ) - return left === undefined || right === undefined ? undefined : left + right - } - return ( - evaluateRepositoryName(node, context, visiting) ?? - evaluateRepositoryCall(node, context, visiting) - ) -} - -export function evaluateRepositoryExpression( - node: AcornNode | undefined, - context: RepositoryExpressionContext, -): string | undefined { - return evaluateRepositoryValue(node, context, new Set()) -} diff --git a/.git-hooks/_shared/cross-repo.mts b/.git-hooks/_shared/cross-repo.mts deleted file mode 100644 index 6c298b25..00000000 --- a/.git-hooks/_shared/cross-repo.mts +++ /dev/null @@ -1,303 +0,0 @@ -import { repositorySourceRoot } from './repository-source-root.mts' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { - offsetToLineCol, - tryParse, -} from '../../.claude/hooks/fleet/_shared/ast/core.mts' -import type { AcornNode } from '../../.claude/hooks/fleet/_shared/ast/core.mts' -import { - evaluateRepositoryExpression, - repositoryAstNode, - repositoryExpressionName, -} from './cross-repo-expressions.mts' -import type { RepositoryExpressionContext } from './cross-repo-expressions.mts' -import { - containsRepositoryPath, - findRepositoryRoot, - repositoryContainsTarget, - repositoryPathApi, - repositoryRealPath, - resolveRepositoryPath, -} from './repo-containment.mts' - -export interface CrossRepositoryHit { - lineNumber: number - line: string - matched: string -} - -export interface CrossRepositoryAnalysis { - hits: CrossRepositoryHit[] - unproven: number[] -} - -function repositoryPathLiteral(value: string): boolean { - const normalized = normalizePath(value) - return ( - value.startsWith('./') || - value.startsWith('.\\') || - // Recognize drive, relative, absolute, and local dependency prefixes. - /^(?:[a-z]:\/|\.\.?\/|\/|file:|link:)/iu.test(normalized) - ) -} - -function isRepositorySourceRead(name: string): boolean { - const separator = name.indexOf('.') - if ( - separator !== -1 && - !['fs', 'fsp', 'fsPromises', 'promises'].includes(name.slice(0, separator)) - ) { - return false - } - const qualifiedMethod = name.slice(separator + 1) - const method = qualifiedMethod.endsWith('Sync') - ? qualifiedMethod.slice(0, -4) - : qualifiedMethod - return ['createReadStream', 'readFile', 'readlink'].includes(method) -} - -function isKernelRuntimePath(value: string): boolean { - // Match the two virtual kernel roots and every path below them. - return /^\/(?:proc|sys)(?:\/|$)/u.test(normalizePath(value)) -} - -function repositoryAstChildren(node: AcornNode): AcornNode[] { - const children: AcornNode[] = [] - const values = Object.values(node) - for (let index = 0, { length } = values; index < length; index += 1) { - const value = values[index] - for (const candidate of Array.isArray(value) ? value : [value]) { - const child = repositoryAstNode(candidate) - if (child) { - children.push(child) - } - } - } - return children -} - -function collectRepositoryBindings( - node: AcornNode, - context: RepositoryExpressionContext, -): void { - if (node.type === 'VariableDeclarator') { - const id = repositoryAstNode(node['id']) - if (id?.type === 'Identifier') { - const name = String(id['name']) - context.bindings.set( - name, - context.bindings.has(name) - ? undefined - : repositoryAstNode(node['init']), - ) - } - } - if ( - node.type === 'AssignmentExpression' || - node.type === 'UpdateExpression' - ) { - const target = repositoryAstNode(node['left'] ?? node['argument']) - if (target?.type === 'Identifier') { - context.bindings.set(String(target['name']), undefined) - } - } - if (/^(?:ArrowFunction|Function)/u.test(node.type)) { - for (const parameter of (node['params'] ?? []) as AcornNode[]) { - if (parameter.type === 'Identifier') { - context.bindings.set(String(parameter['name']), undefined) - } - } - } - for (const child of repositoryAstChildren(node)) { - collectRepositoryBindings(child, context) - } -} - -function repositoryReferenceEscapes( - value: string, - base: string, - root: string, - physicalRoot: string, -): boolean { - const target = resolveRepositoryPath( - base, - value.replace(/^(?:file:|link:)/u, ''), - ) - const modules = resolveRepositoryPath(root, 'node_modules') - if (containsRepositoryPath(modules, target)) { - return false - } - if (base !== root) { - const resolvedPhysicalRoot = repositoryRealPath(physicalRoot) - const resolvedTarget = repositoryRealPath(target) - if ( - resolvedPhysicalRoot !== undefined && - resolvedTarget !== undefined && - containsRepositoryPath(resolvedPhysicalRoot, resolvedTarget) - ) { - return false - } - } - if (root === physicalRoot) { - return !repositoryContainsTarget(root, target) - } - const resolvedRoot = repositoryRealPath(root) - const resolvedTarget = repositoryRealPath(target) - return ( - resolvedRoot === undefined || - resolvedTarget === undefined || - !containsRepositoryPath(resolvedRoot, resolvedTarget) - ) -} - -export function analyzeCrossRepositoryPaths( - source: string, - fileAbsPath: string, - options?: { root?: string | undefined } | undefined, -): CrossRepositoryAnalysis { - const settings = { __proto__: null, ...options } - const file = resolveRepositoryPath( - settings.root ?? - findRepositoryRoot(fileURLToPath(import.meta.url)) ?? - path.dirname(fileURLToPath(import.meta.url)), - fileAbsPath, - ) - const physicalRoot = - settings.root ?? - findRepositoryRoot(file) ?? - repositoryPathApi(file).dirname(file) - const root = repositorySourceRoot(physicalRoot, file) - const context: RepositoryExpressionContext = { - root, - file, - bindings: new Map(), - } - const analysis: CrossRepositoryAnalysis = { hits: [], unproven: [] } - const lines = source.split(/\r?\n/u) - const seen = new Set() - const unproven = new Set() - function recordUnproven(start: number): void { - const line = offsetToLineCol(source, start).line - if (!unproven.has(line)) { - unproven.add(line) - analysis.unproven.push(line) - } - } - function record(value: string, start: number, base: string): void { - if ( - !repositoryPathLiteral(value) || - isKernelRuntimePath(value) || - !repositoryReferenceEscapes(value, base, root, physicalRoot) || - seen.has(start) - ) { - return - } - seen.add(start) - const lineNumber = offsetToLineCol(source, start).line - analysis.hits.push({ - lineNumber, - line: lines[lineNumber - 1] ?? '', - matched: value, - }) - } - function visitRepositorySink(node: AcornNode, base: string): boolean { - if (node.type.startsWith('Import') || node.type.startsWith('Export')) { - const target = repositoryAstNode(node['source']) - if (target) { - visitPathSink(target, base) - return true - } - } - if (node.type === 'CallExpression') { - const name = repositoryExpressionName(repositoryAstNode(node['callee'])) - if (name === 'require' || isRepositorySourceRead(name)) { - const argument = repositoryAstNode( - (node['arguments'] as unknown[] | undefined)?.[0], - ) - if (argument) { - visitPathSink(argument, name === 'require' ? base : root) - } - return true - } - } - return false - } - function visitPathSink(node: AcornNode, base: string): void { - const value = evaluateRepositoryExpression(node, context) - if (value === undefined) { - recordUnproven(node.start) - } else { - record(value, node.start, base) - } - } - function visit(node: AcornNode, base: string): void { - if (visitRepositorySink(node, base)) { - return - } - for (const child of repositoryAstChildren(node)) { - visit(child, base) - } - } - const ast = parseRepositorySource(source) - if (ast) { - collectRepositoryBindings(ast, context) - visit(ast, repositoryPathApi(file).dirname(file)) - } else if ( - // Dependency manifests carry quoted paths and file/link values. - /\.(?:json|toml|ya?ml)$/iu.test(file) || - path.basename(file) === '.gitmodules' - ) { - let offset = 0 - for ( - let lineIndex = 0, { length } = lines; - lineIndex < length; - lineIndex += 1 - ) { - const line = lines[lineIndex]! - if (!line.trimStart().startsWith('#')) { - for (const match of line.matchAll( - // Capture each local dependency value or quoted filesystem path. - /(?:file:|link:)(?:[/\\]|[a-z]:[/\\]|\.\.?[/\\])[^\s'",}]+|(?:["'])(?:[a-z]:[/\\](?![*?"<>|:])|\.\.?[/\\])[^"'\r\n]+["']/giu, - )) { - // Remove only the opening and closing quote delimiters. - const value = match[0].replace(/^["']|["']$/gu, '') - record( - value, - Buffer.byteLength(source.slice(0, offset + match.index)), - repositoryPathApi(file).dirname(file), - ) - } - } - offset += line.length + 1 - } - } else if (source.trim()) { - recordUnproven(0) - } - analysis.hits.sort((left, right) => left.lineNumber - right.lineNumber) - return analysis -} - -export function scanRepositoryReferences( - source: string, - file: string, -): CrossRepositoryHit[] { - return analyzeCrossRepositoryPaths(source, file).hits -} - -const repositoryAstCache = new Map>() -function parseRepositorySource(source: string): ReturnType { - if (repositoryAstCache.has(source)) { - return repositoryAstCache.get(source) - } - const ast = tryParse(source) - if (source.length <= 262_144) { - if (repositoryAstCache.size >= 32) { - repositoryAstCache.clear() - } - repositoryAstCache.set(source, ast) - } - return ast -} diff --git a/.git-hooks/_shared/external-issue-ref.mts b/.git-hooks/_shared/external-issue-ref.mts deleted file mode 100644 index c02dd20f..00000000 --- a/.git-hooks/_shared/external-issue-ref.mts +++ /dev/null @@ -1,108 +0,0 @@ -// External-issue-ref matcher — shared by the commit-msg git-stage backstop -// AND the Claude-side no-ext-issue-ref-guard. Gate-free (no Node-25 hard-exit, -// unlike helpers.mts) so the Claude hook can import it on the operator's Node. -// Flags foreign /# refs + github.com issue/PR URLs that -// would auto-link a backref into an upstream maintainer's issue. - -// CRLF-tolerant line split. Inlined (not imported from helpers.mts) so this -// module stays free of the Node-25 hard-exit gate that helpers.mts carries — -// a Claude hook importing it must run on the operator's, possibly older, Node. -function splitLines(text: string): string[] { - return text.replace(/\r\n/g, '\n').split(/\r?\n/) -} - -// -// Foreign `/#` tokens (and full github.com issue/PR -// URLs) auto-link on GitHub and post an `added N commits that reference -// this issue` event back to the target. A fleet cascade of N commits = -// N pings to a maintainer who never asked to be tagged. The canonical -// CLAUDE.md "public-surface hygiene" block documents the policy; this -// scanner makes it mechanical on the commit-msg git-stage so a foreign -// ref can't slip past `--no-verify` or onto a subprocess / worktree / -// CI commit the Bash-time no-ext-issue-ref-guard never sees. -// -// Canonical home: this module. The Claude-side -// .claude/hooks/fleet/no-ext-issue-ref-guard/index.mts imports the same -// matcher cross-tree so the two surfaces never diverge. -// -// Allowed (NOT reported): -// - bare `#123` (resolves against the current repo — no cross-repo leak) -// - `SocketDev/#` (same org — case-insensitive) -// - `https://github.com/SocketDev/...`, same org -// -// Blocked (reported): -// - `/#` -// - `https://github.com///{issues,pull}/` - -export interface ExternalIssueRef { - kind: 'token' | 'url' - owner: string - repo: string - num: string - raw: string -} - -// Org allowlist — case-insensitive, stored lowercase for comparison. -// GitHub resolves orgs case-insensitively in URLs and refs, so -// `socketdev` / `SocketDev` / `SOCKETDEV` all name the same org. -export const ALLOWED_ISSUE_REF_ORGS = new Set(['socketdev']) - -// Detect `/#` token. Owner and repo names follow -// GitHub's rules: alphanumerics, dashes, underscores, dots (no leading -// dot/dash). Permissive on boundaries since we pattern-match prose, not -// validate canonical refs. -// -// (^|\s|\() — anchor at start, whitespace, or open paren so we don't -// re-match the owner/repo fragment already inside a URL. -// ([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?) — owner / repo -// #(\d+) — issue/PR number -// (?=\b|[\s.,;:)\]]|$) — terminate cleanly -const OWNER_REPO_REF_RE = - /(?:^|\s|\()([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)#(\d+)(?=\b|[\s.,;:)\]]|$)/g - -// Detect full GitHub issue/PR URLs to non-SocketDev orgs. -const GITHUB_ISSUE_URL_RE = - /https?:\/\/github\.com\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\/(?:issues|pull)\/(\d+)/g - -// Walk the text and collect every external-org reference. Returns an -// empty array when the text only references same-repo (`#123`) or -// SocketDev-owned (`SocketDev/socket-lib#42`) issues. Comment lines -// (after leading whitespace, start with `#`) are skipped — git inlines -// the diff snippet + "Please enter the commit message" hint there, and a -// foreign ref quoted in that snippet isn't part of the authored message. -export function scanExternalIssueRefs(text: string): ExternalIssueRef[] { - const out: ExternalIssueRef[] = [] - for (const rawLine of splitLines(text)) { - if (rawLine.trimStart().startsWith('#')) { - continue - } - let m: RegExpExecArray | null - OWNER_REPO_REF_RE.lastIndex = 0 - while ((m = OWNER_REPO_REF_RE.exec(rawLine)) !== null) { - const owner = m[1]! - const repo = m[2]! - const num = m[3]! - if (!ALLOWED_ISSUE_REF_ORGS.has(owner.toLowerCase())) { - out.push({ - kind: 'token', - owner, - repo, - num, - raw: `${owner}/${repo}#${num}`, - }) - } - } - GITHUB_ISSUE_URL_RE.lastIndex = 0 - while ((m = GITHUB_ISSUE_URL_RE.exec(rawLine)) !== null) { - const owner = m[1]! - const repo = m[2]! - const num = m[3]! - if (!ALLOWED_ISSUE_REF_ORGS.has(owner.toLowerCase())) { - out.push({ kind: 'url', owner, repo, num, raw: m[0]! }) - } - } - } - return out -} - -// ── File classification ──────────────────────────────────────────── diff --git a/.git-hooks/_shared/file-scan.mts b/.git-hooks/_shared/file-scan.mts deleted file mode 100644 index 613e2628..00000000 --- a/.git-hooks/_shared/file-scan.mts +++ /dev/null @@ -1,90 +0,0 @@ -// File classification + content reading for the git-hook scanners: which paths -// to skip, and how to read a file's text (binaries run through `strings`). -// Gate-free. - -import { existsSync, readFileSync, statSync } from 'node:fs' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -// ── File classification ──────────────────────────────────────────── - -// Files we never scan: hooks themselves (both the .mts files and the -// shell shims under .git-hooks/), test fixtures, vendored lockfiles. -const SKIP_FILE_RE = - /\.(?:spec|test)\.(?:cts|m?[jt]s|mts|tsx?)$|\.example$|\/test\/|\/tests\/|fixtures\/|\.git-hooks\/|node_modules\/|pnpm-lock\.yaml/ - -export const shouldSkipFile = (filePath: string): boolean => - SKIP_FILE_RE.test(filePath) - -/** - * Source-code extensions. THE canonical definition for the fleet's source-only - * convention scanners — the commit-time hook and the - * `private-paths-are-absent-at-commit` check gate import this one constant so - * they cannot disagree about what "source code" means. - * - * Markdown, docs, JSON, and YAML are deliberately out of scope: they reference - * these patterns legitimately. That divergence was real — the check script - * carried a private copy of this regex under a comment claiming it was - * "lock-step with the hook", while the hook had no such constant and scanned - * every non-skipped file. Generated JSON therefore passed the gate and failed - * the hook, and the hook's suggested fix would have corrupted the data. - */ -export const SOURCE_FILE_RE = - /\.(?:[ch]|[cm]?[jt]sx?|bash|cc|cpp|cxx|go|hh|hpp|java|kt|py|rb|rs|sh|swift|zsh)$/ - -export const isSourceCodeFile = (filePath: string): boolean => - SOURCE_FILE_RE.test(filePath) - -/** - * The composed predicate for a SOURCE-ONLY convention scan: skip the universal - * exclusions, then skip anything that is not source code. - */ -export const shouldSkipSourceScan = (filePath: string): boolean => - shouldSkipFile(filePath) || !isSourceCodeFile(filePath) - -// Structured DATA payloads. A generated rule table, detector corpus, or -// fixture blob legitimately CONTAINS the very strings a convention scanner -// hunts for — a detection regex spelling `/Users/`, a rule description warning -// about `npx` — and applying the scanner's suggested fix would corrupt the -// data rather than repair a violation. -const DATA_FILE_RE = /\.(?:jsonc?|ya?ml)$/ -// The one exception: a package manifest is data, but an `npx` in its `scripts` -// really is the violation the rule exists to catch. -const PACKAGE_MANIFEST_RE = /(?:^|\/)package\.json$/ - -export const isStructuredDataFile = (filePath: string): boolean => { - const p = normalizePath(filePath) - return DATA_FILE_RE.test(p) && !PACKAGE_MANIFEST_RE.test(p) -} - -// Returns file content as a string. Text files stay in-process; binaries run -// through `strings` to catch paths embedded in WASM or compiled artifacts. -// A NUL byte is the stable cross-platform binary signal for the artifacts this -// hook scans, and avoids spawning Git-for-Windows `grep` for every text file. -export const readFileForScan = (filePath: string): string => { - if (!existsSync(filePath)) { - return '' - } - try { - if (statSync(filePath).isDirectory()) { - return '' - } - } catch { - return '' - } - let bytes: Buffer - try { - bytes = readFileSync(filePath) - } catch { - return '' - } - if (!bytes.includes(0)) { - return bytes.toString('utf8') - } - // NUL-bearing binary — extract printable strings. - const stringsResult = spawnSync('strings', [filePath], { - encoding: 'utf8', - }) - return stringsResult.stdout || '' -} diff --git a/.git-hooks/_shared/git-context-vars.mts b/.git-hooks/_shared/git-context-vars.mts deleted file mode 100644 index e00df058..00000000 --- a/.git-hooks/_shared/git-context-vars.mts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @file Git environment variables that override repository discovery. - */ -export const GIT_CONTEXT_VARS: readonly string[] = [ - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_CEILING_DIRECTORIES', - 'GIT_COMMON_DIR', - 'GIT_DIR', - 'GIT_INDEX_FILE', - 'GIT_NAMESPACE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_PREFIX', - 'GIT_WORK_TREE', -] diff --git a/.git-hooks/_shared/git-identity.mts b/.git-hooks/_shared/git-identity.mts deleted file mode 100644 index 88bc9e7d..00000000 --- a/.git-hooks/_shared/git-identity.mts +++ /dev/null @@ -1,207 +0,0 @@ -// Git author/committer identity policy reader. The single source of truth is -// the wheelhouse-cascaded config FILE, resolved repo-scoped only (no machine- -// local fallback, by design — minimize outside-wheelhouse settings): -// -// .config/repo/socket-wheelhouse.json → "gitAuthors", per-repo, optional -// .config/fleet/git-authors.json, cascaded fleet default -// -// The per-repo half is a SECTION of the member settings file rather than its -// own JSON: per-repo config lives in ONE surface, and a second `.config/repo/ -// *.json` is the fragmentation `no-new-config-guard` refuses. -// -// Shape: { denylist: { emails[], names[] }, canonical: {name,email}, aliases[] }. -// -// Two checks, deliberately distinct: -// - isDeniedIdentity: a placeholder/sandbox identity (test@example.com, Test, -// empty) that is NEVER valid anywhere — the universal fleet denylist. -// - isAllowedAuthor: when an allowlist (canonical/aliases) is configured, the -// email must be in it. With no allowlist configured, only the denylist -// applies (so a repo without a .config/repo allowlist still blocks junk). -// -// This is the .git-hooks/ copy; .claude/hooks/fleet/_shared/git-identity.mts is -// a byte-equivalent copy for the other (separately-cascaded) hook tree — the -// shared thing is the config file, not cross-tree code. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -export interface GitAuthor { - readonly name?: string | undefined - readonly email?: string | undefined -} - -export interface IdentityPolicy { - readonly denyEmails: readonly string[] - readonly denyNames: readonly string[] - readonly canonical: GitAuthor - readonly aliases: readonly GitAuthor[] -} - -interface RawConfig { - denylist?: - | { emails?: string[] | undefined; names?: string[] | undefined } - | undefined - canonical?: GitAuthor | undefined - aliases?: GitAuthor[] | undefined -} - -// The per-repo half lives as a SECTION of the one member settings file, not -// as its own `.config/repo/*.json`. Per-repo config lives in ONE surface -// (`config-segregation`); a second file there is exactly the fragmentation -// `no-new-config-guard` exists to refuse. -const REPO_SETTINGS = '.config/repo/socket-wheelhouse.json' -const REPO_SETTINGS_KEY = 'gitAuthors' -const FLEET_CONFIG = '.config/fleet/git-authors.json' - -function loadJson(file: string): RawConfig | undefined { - if (!existsSync(file)) { - return undefined - } - try { - return JSON.parse(readFileSync(file, 'utf8')) as RawConfig - } catch { - return undefined - } -} - -/** - * The `gitAuthors` section of the member settings file, or undefined when the - * file or the section is absent. - */ -function loadRepoSection(repoRoot: string): RawConfig | undefined { - const settings = loadJson(path.join(repoRoot, REPO_SETTINGS)) as - | { [REPO_SETTINGS_KEY]?: RawConfig | undefined } - | undefined - return settings?.[REPO_SETTINGS_KEY] -} - -/** - * Split a `git var GIT_AUTHOR_IDENT` / `GIT_COMMITTER_IDENT` string - * (`Name `) into its name and email. A field git left - * empty comes back as undefined rather than an empty string, so the denylist - * and allowlist checks see the same "unset" shape either way. - */ -export function parseGitIdentLine(ident: string): GitAuthor { - const match = /^(.*?)\s*<([^>]*)>/.exec(ident) - return { - name: match?.[1]?.trim() || undefined, - email: match?.[2]?.trim() || undefined, - } -} - -/** - * One config's denylist halves, each defaulted to empty. - */ -function readDenyEntries(config: RawConfig | undefined): { - emails: string[] - names: string[] -} { - return { - emails: config?.denylist?.emails ?? [], - names: config?.denylist?.names ?? [], - } -} - -/** - * The union of both denylists, lowercased. A repo can ADD denied identities; - * the fleet denylist always applies, so neither side can shorten the other. - */ -function mergeIdentityDenyList( - fleet: RawConfig | undefined, - repo: RawConfig | undefined, -): { denyEmails: string[]; denyNames: string[] } { - const fleetDeny = readDenyEntries(fleet) - const repoDeny = readDenyEntries(repo) - return { - denyEmails: [...fleetDeny.emails, ...repoDeny.emails].map(e => - e.toLowerCase(), - ), - denyNames: [...fleetDeny.names, ...repoDeny.names].map(n => - n.toLowerCase(), - ), - } -} - -/** - * The allowlist half, taken whole from the first config that declares one: - * the repo override when it names a canonical email or any alias, else the - * cascaded fleet default. - */ -function selectIdentityAllowlist( - fleet: RawConfig | undefined, - repo: RawConfig | undefined, -): { canonical: GitAuthor; aliases: GitAuthor[] } { - const repoHasAllow = !!repo?.canonical?.email || !!repo?.aliases?.length - const src = repoHasAllow ? repo! : (fleet ?? {}) - return { - aliases: Array.isArray(src.aliases) ? src.aliases : [], - canonical: src.canonical ?? {}, - } -} - -/** - * Resolve the identity policy: a repo override (.config/repo) takes precedence - * over the cascaded fleet default (.config/fleet). The denylist merges both (a - * repo can ADD denied identities but the fleet denylist always applies); the - * allowlist is taken from the first config that declares a non-empty one. - * `repoRoot` is the directory both config paths resolve against. - */ -export function readIdentityPolicy(repoRoot: string): IdentityPolicy { - const fleet = loadJson(path.join(repoRoot, FLEET_CONFIG)) - const repo = loadRepoSection(repoRoot) - const { denyEmails, denyNames } = mergeIdentityDenyList(fleet, repo) - const { aliases, canonical } = selectIdentityAllowlist(fleet, repo) - return { denyEmails, denyNames, canonical, aliases } -} - -/** - * True when an identity is on the universal denylist — a placeholder email - * (exact, or a `*@domain` whole-domain wildcard) or a placeholder name. - */ -export function isDeniedIdentity( - candidate: GitAuthor, - policy: IdentityPolicy, -): boolean { - const email = candidate.email?.toLowerCase() ?? '' - const name = candidate.name?.toLowerCase() ?? '' - for (let i = 0, { length } = policy.denyEmails; i < length; i += 1) { - const pat = policy.denyEmails[i]! - if (pat.startsWith('*@')) { - if (email.endsWith(pat.slice(1))) { - return true - } - } else if (email === pat) { - return true - } - } - return !!name && policy.denyNames.includes(name) -} - -/** - * True when `candidate`'s email is the canonical identity or an alias. When no - * allowlist is configured (empty canonical + aliases), returns true — only the - * denylist gates that repo. A candidate with no email is treated as allowed - * git fails on its own when no identity is set. - */ -export function isAllowedAuthor( - candidate: GitAuthor, - policy: IdentityPolicy, -): boolean { - const email = candidate.email?.toLowerCase() - if (!email) { - return true - } - const hasAllowlist = !!policy.canonical.email || policy.aliases.length > 0 - if (!hasAllowlist) { - return true - } - if (policy.canonical.email?.toLowerCase() === email) { - return true - } - for (let i = 0, { length } = policy.aliases; i < length; i += 1) { - if (policy.aliases[i]!.email?.toLowerCase() === email) { - return true - } - } - return false -} diff --git a/.git-hooks/_shared/git.mts b/.git-hooks/_shared/git.mts deleted file mode 100644 index 6bc847c1..00000000 --- a/.git-hooks/_shared/git.mts +++ /dev/null @@ -1,66 +0,0 @@ -// Git subprocess wrappers for the hooks. Gate-free. -// -// Two flavors: -// -// git(...) — loose. Returns '' on failure. Used by callers that -// legitimately tolerate a missing ref (e.g. probing -// remote default-branch HEAD which may not be set up -// locally) and provide their own fallback. Silent -// by design — _shared/util.mts can't import the canonical -// logger because it runs before the Node-version -// gate has cleared, and a fire-and-forget dynamic -// import races process exit. Callers that need to -// know about failure should use gitOrThrow(). -// -// gitOrThrow(...) — strict. Throws on either spawn error (git not on -// PATH, EAGAIN, …) or non-zero exit. Used by gitLines -// and every security-gate caller in pre-commit / -// pre-push: if `git diff --cached --name-only` fails -// we MUST refuse to greenlight the commit, not pass -// it with "no files to check." -// -// gitLines goes through gitOrThrow because every call site we have -// staged-file iteration, push-range walking, repo-toplevel lookup -// makes a security or correctness decision based on the result; an -// empty array from a failed git invocation is a fail-open. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { splitLines } from './scan-core.mts' - -export const git = (...args: string[]): string => { - const result = spawnSync('git', args, { encoding: 'utf8' }) - return (result.stdout ?? '').trim() -} - -export const gitOrThrow = (...args: string[]): string => { - const result = spawnSync('git', args, { encoding: 'utf8' }) - if (result.error) { - throw new Error(`git ${args.join(' ')}: ${result.error.message}`) - } - if (typeof result.status !== 'number' || result.status !== 0) { - const err = result.stderr?.trim() || `exit ${result.status}` - throw new Error(`git ${args.join(' ')}: ${err}`) - } - return (result.stdout ?? '').trim() -} - -export const gitLines = (...args: string[]): string[] => { - const out = gitOrThrow(...args) - return out ? splitLines(out) : [] -} - -/** - * Run git with `stdin` piped in, returning the raw status and stdout. - * - * Unlike `git`/`gitOrThrow`, this surfaces the exit status instead of - * swallowing it or throwing — a query like `git check-ignore` uses its status - * as the ANSWER (0 = matched, 1 = no match), not as a failure. - */ -export function gitWithInput( - args: readonly string[], - stdin: string, -): { status: number | undefined; stdout: string } { - const result = spawnSync('git', [...args], { encoding: 'utf8', input: stdin }) - return { status: result.status ?? undefined, stdout: result.stdout ?? '' } -} diff --git a/.git-hooks/_shared/isolate-git-env.mts b/.git-hooks/_shared/isolate-git-env.mts deleted file mode 100644 index bb71cba5..00000000 --- a/.git-hooks/_shared/isolate-git-env.mts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * @file Neutralize the inherited git environment so a test's `git` spawns can - * never touch the live repo. Importing this module runs the SAFE default - * strip discovery vars, as a side effect; call `isolateGitEnv({ … })` for - * the stronger variant. Why this is load-bearing: when a suite runs from the - * pre-commit / pre-push hook, or just inherits the ambient env, git exports - * `GIT_DIR` / `GIT_WORK_TREE` / `GIT_INDEX_FILE` pointing at THE LIVE repo, - * and git honors those above cwd-based discovery. A fixture that does `git - * init` + `git config user.email …` in a `cwd: tmpDir` then escapes onto the - * real `.git/config` and HEAD — observed damage: `core.bare=true` (breaks - * every worktree op with "must be run in a work tree"), a junk - * `test@example.com` identity, and stray commits on the working branch. Two - * consumers: - * - * - `node --test` git-fixture suites (`.git-hooks/fleet/test/*`, etc.) do NOT - * load the vitest setup, so each imports this module — the side-effect - * default (strip-only) stops the escape while leaving each fixture free to - * scope its own `GIT_CONFIG_GLOBAL` per-spawn (the signing-gate tests need - * that). `no-unisolated-git-fixture-guard` recognizes the import. - * - vitest, via `setupFiles` (`test/fleet/scripts/setup.mts`), calls - * `isolateGitEnv({ pinConfigToNull: true })` for the stronger form (no - * fixture there manipulates a controlled global config). Lives in - * `.git-hooks/_shared/` (alongside `git-identity.mts`) so the git-hook test - * tree imports it within-tree; the vitest setup reaches it cross-tree (both - * cascade together). - */ - -import process from 'node:process' -import { GIT_CONTEXT_VARS } from './git-context-vars.mts' - -export interface IsolateGitEnvOptions { - /** - * Also pin `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` to `/dev/null` so `git - * config` (without `--local`) can't reach a real config file at all. - * Stronger, but it OVERRIDES any per-spawn global a fixture sets — only use - * it where no fixture manipulates a controlled global config (vitest). The - * strip alone already prevents escape; this is belt-and-suspenders. - */ - pinConfigToNull?: boolean | undefined -} - -/** - * Strip the inherited git context vars (always). Optionally pin the config - * files to `/dev/null`. Idempotent — safe to call or import more than once. - */ -export function isolateGitEnv(options: IsolateGitEnvOptions = {}): void { - for (let i = 0, { length } = GIT_CONTEXT_VARS; i < length; i += 1) { - delete process.env[GIT_CONTEXT_VARS[i]!] - } - if (options.pinConfigToNull) { - process.env['GIT_CONFIG_GLOBAL'] = '/dev/null' - process.env['GIT_CONFIG_SYSTEM'] = '/dev/null' - } -} - -// Side-effect default for the bare `import '…/isolate-git-env.mts'` form: the -// safe strip-only isolation. Consumers wanting the stronger pin call -// isolateGitEnv({ pinConfigToNull: true }) explicitly. -isolateGitEnv() diff --git a/.git-hooks/_shared/logger-leaks.mts b/.git-hooks/_shared/logger-leaks.mts deleted file mode 100644 index 9c8b6e80..00000000 --- a/.git-hooks/_shared/logger-leaks.mts +++ /dev/null @@ -1,189 +0,0 @@ -// Logger-leak matcher — shared by the commit-time scanLoggerLeaks -// (.git-hooks/_shared/util.mts) and the edit-time logger-guard -// (.claude/hooks/fleet/). Both flag direct `console.*` / `process.std*.write` -// calls (the fleet rule: use getDefaultLogger()). Previously the commit side -// used a REGEX and the edit side an AST walk — divergent engines that could -// disagree (a regex flags `console.log` inside a string literal or comment; -// the AST walk does not). This is the single AST-based source so they agree. -// -// AST (acorn-wasm) not regex: the parser is loaded for other commit-time -// checks anyway, and it eliminates the string/comment false positives the -// regex had. Gate-free: imports only the vendored acorn (no Node-25 exit), so -// either hook tree can use it. - -import { findMemberCalls } from '../../.claude/hooks/fleet/_shared/ast/calls.mts' - -export interface LoggerLeak { - // 1-based line of the call. - line: number - // Source text of the line, trimmed by the caller as needed. - text: string - // The dotted call, e.g. `console.log` / `process.stderr.write`. - fullCall: string - // Canonical logger replacement, e.g. `logger.info`. - replacement: string -} - -// The forbidden direct-output calls and their canonical logger replacement. -// Two-segment (`console.log`) and three-segment (`process.stderr.write`) -// chains — findMemberCalls handles both via a dotted `object`. -export const FORBIDDEN_LOGGER_CALLS: ReadonlyArray<{ - object: string - property: string - replacement: string -}> = [ - { object: 'console', property: 'debug', replacement: 'logger.debug' }, - { object: 'console', property: 'error', replacement: 'logger.error' }, - { object: 'console', property: 'info', replacement: 'logger.info' }, - { object: 'console', property: 'log', replacement: 'logger.info' }, - { object: 'console', property: 'warn', replacement: 'logger.warn' }, - { object: 'process.stderr', property: 'write', replacement: 'logger.error' }, - { object: 'process.stdout', property: 'write', replacement: 'logger.info' }, -] - -// Find every direct logger-leak call in `source` via the AST. Returns one entry -// per call site with its line, the dotted call, and the canonical replacement. -// Per-line `// oxlint-disable-next-line socket/no-console-prefer-logger` suppression is the CALLER's job -// each tree applies its own marker semantics. -export function findLoggerLeaks(source: string): LoggerLeak[] { - const leaks: LoggerLeak[] = [] - for (let i = 0, { length } = FORBIDDEN_LOGGER_CALLS; i < length; i += 1) { - const spec = FORBIDDEN_LOGGER_CALLS[i]! - const matches = findMemberCalls(source, spec.object, spec.property) - for (let j = 0, mlen = matches.length; j < mlen; j += 1) { - const m = matches[j]! - leaks.push({ - line: m.line, - text: m.text, - fullCall: `${spec.object}.${spec.property}`, - replacement: spec.replacement, - }) - } - } - return leaks -} - -export interface LoggerDecoration { - // 1-based line of the call. - line: number - // Source text of the line. - text: string - // The logger method called (e.g. `error`, `fail`, `log`). - method: string - // What kind of hand-rolled decoration leads the first argument. - kind: 'glyph' | 'indent' | 'bullet' - // For 'glyph': the leading glyph + the logger method that OWNS it, the fix. - glyph: string | undefined - ownerMethod: string | undefined -} - -// Logger methods whose first string/template arg we inspect for a hand-rolled -// prefix. Excludes structural methods (group/groupEnd/dir/table). -const DECORATION_SCAN_METHODS = [ - 'error', - 'fail', - 'info', - 'log', - 'progress', - 'skip', - 'step', - 'substep', - 'success', - 'warn', -] - -// Status glyph → the logger method that OWNS it. The method renders the glyph; -// hand-writing it double-marks, and skips theme-aware color. Keep in lockstep -// with @socketsecurity/lib-stable/logger symbols-builder + the no-status-emoji rule. -const GLYPH_OWNER: Readonly> = { - '‼': 'warn', - '×': 'fail', - '√': 'success', - '☑': 'success', - '⚠': 'warn', - '⛔': 'warn', - '✅': 'success', - '✓': 'success', - '✔': 'success', - '✖': 'fail', - '✗': 'fail', - '✘': 'fail', - '❌': 'fail', - '❎': 'fail', - '❕': 'warn', - '❗': 'warn', - '🚨': 'warn', - ℹ: 'info', -} - -// Leading status glyph, after optional whitespace. One named capture, consumed -// below to look up the owning method. Built dynamically from GLYPH_OWNER so the -// table is the single source. -const GLYPH_LEAD_RE = new RegExp( - `^\\s*(?${Object.keys(GLYPH_OWNER) - .map(g => g.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|')})`, -) -// Leading indentation: a tab or 2+ spaces. The logger owns indentation via -// group()/substep(); a hand-rolled indent fights it. -const INDENT_LEAD_RE = /^(?: {2,}|\t)/ -// Leading bullet glyph — use logger.substep() for indented sub-items. -const BULLET_LEAD_RE = /^\s*[•‣◦·]\s/ - -// Find every `logger.(...)` call whose first argument's leading static -// text is hand-rolled decoration the logger/group already owns — a status -// glyph, leading indentation, or a bullet. Works for string literals AND -// template literals (`\` ✗ ${x}\``). Per-line suppression is the CALLER's job. -export function findLoggerDecoration(source: string): LoggerDecoration[] { - const out: LoggerDecoration[] = [] - for (let i = 0, { length } = DECORATION_SCAN_METHODS; i < length; i += 1) { - const method = DECORATION_SCAN_METHODS[i]! - const calls = findMemberCalls(source, 'logger', method) - for (let j = 0, clen = calls.length; j < clen; j += 1) { - const call = calls[j]! - const lead = call.firstArgLeadingText - if (typeof lead !== 'string' || lead.length === 0) { - continue - } - const glyphMatch = GLYPH_LEAD_RE.exec(lead) - const glyph = glyphMatch?.groups?.['glyph'] - if (glyph) { - out.push({ - __proto__: null, - line: call.line, - text: call.text, - method, - kind: 'glyph', - glyph, - ownerMethod: GLYPH_OWNER[glyph], - } as LoggerDecoration) - continue - } - if (BULLET_LEAD_RE.test(lead)) { - out.push({ - __proto__: null, - line: call.line, - text: call.text, - method, - kind: 'bullet', - glyph: undefined, - ownerMethod: undefined, - } as LoggerDecoration) - continue - } - if (INDENT_LEAD_RE.test(lead)) { - out.push({ - __proto__: null, - line: call.line, - text: call.text, - method, - kind: 'indent', - glyph: undefined, - ownerMethod: undefined, - } as LoggerDecoration) - } - } - } - out.sort((a, b) => a.line - b.line) - return out -} diff --git a/.git-hooks/_shared/node-version.mts b/.git-hooks/_shared/node-version.mts deleted file mode 100644 index 5ec40c3f..00000000 --- a/.git-hooks/_shared/node-version.mts +++ /dev/null @@ -1,24 +0,0 @@ -import process from 'node:process' - -// Hard-fail if Node is below 24. This runs at module load — every hook -// invocation imports this module before doing anything. -const NODE_MIN_MAJOR = 24 -const nodeMajor = Number.parseInt( - process.versions.node.split('.')[0] || '0', - 10, -) -if (nodeMajor < NODE_MIN_MAJOR) { - // This import-light shared helper does not own a logger. Use raw - // process.stderr with ASCII (no status-emoji glyph) so the no-status-emoji - // lint rule stays clean — the recommendation to use logger.fail() does not - // apply when the entire branch is the logger-unavailable bail. - // oxlint-disable-next-line socket/no-module-eval-side-effects -- floor bail - process.stderr.write( - `\x1b[0;31mHook requires Node >= ${NODE_MIN_MAJOR}.0.0 (have v${process.versions.node})\x1b[0m\n`, - ) - // oxlint-disable-next-line socket/no-module-eval-side-effects -- floor bail - process.stderr.write( - 'Install Node 24+ — these hooks rely on default-on .mts type stripping.\n', - ) - process.exit(1) -} diff --git a/.git-hooks/_shared/personal-path.mts b/.git-hooks/_shared/personal-path.mts deleted file mode 100644 index 37d39b19..00000000 --- a/.git-hooks/_shared/personal-path.mts +++ /dev/null @@ -1,57 +0,0 @@ -// Personal-path leak matcher — shared by the commit-time scanPersonalPaths -// (.git-hooks/_shared/util.mts) and the edit-time personal-path-guard -// (.claude/hooks/fleet/). Both surfaces import THESE regexes + helpers so the -// two can't drift, which lock-step inline copies cannot promise. Gate-free -// (no Node-25 hard-exit like helpers.mts) so the Claude hook can import it on -// the operator's possibly-older Node. -// -// Flags a hardcoded USERNAME leak: /Users//, /home//, -// C:\Users\\. Username-free forms (`~/`, `$HOME/`) are the OPPOSITE — the -// recommended shapes — and are NOT flagged. Pure-placeholder lines -// (/Users//, $USER) are documentation, not leaks. - -// Real personal paths to flag. NFKC-normalize the line before matching (the -// caller does this) so full-width / ligature variants of `/Users` don't slip -// past the ASCII-only class. -export const PERSONAL_PATH_RE = - /(?:\/Users\/[^/\s]+\/|\/home\/[^/\s]+\/|C:\\Users\\[^\\]+\\)/ - -// Placeholder forms we ALLOW, documentation, not leaks: `<...>` components and -// `$VAR` / `${VAR}` under the platform user dir. Canonical fleet style: -// /Users//... /home//... C:\Users\\... -export const PERSONAL_PATH_PLACEHOLDER_RE = - /(?:\/Users\/<[^>]*>\/|\/home\/<[^>]*>\/|C:\\Users\\<[^>]*>\\|\/Users\/\$\{?[A-Z_]+\}?\/|\/home\/\$\{?[A-Z_]+\}?\/)/ - -// Well-known CI / system home dirs whose "username" is a service account, not a -// person, so the GitHub Actions and Ubuntu CI homes below are not personal -// leaks. gh-aw's compiled `.lock.yml` emits tool-cache mounts under the runner -// home; those are correct, not a leak. Matched as the path's username segment -// only. -// oxlint-disable-next-line socket/personal-path-placeholders -- `runner` is CI -export const KNOWN_NON_PERSONAL_PATH_RE = - /(?:\/Users\/(?:runner)\/|\/home\/(?:circleci|runner|ubuntu|vscode|vsts)\/)/ - -// True when a line is a PURE placeholder: it matches the placeholder shape AND -// nothing real remains after stripping every placeholder. Such lines are -// documentation, so the scanners skip them. A line whose only "personal" paths -// are well-known CI/system homes (e.g. `/home//` for a CI service -// account) is also pure, because those usernames are not people. -export function isPurePlaceholder(line: string): boolean { - const hasPlaceholder = PERSONAL_PATH_PLACEHOLDER_RE.test(line) - const hasCiHome = KNOWN_NON_PERSONAL_PATH_RE.test(line) - if (!hasPlaceholder && !hasCiHome) { - return false - } - let stripped = line.replace(new RegExp(PERSONAL_PATH_PLACEHOLDER_RE, 'g'), '') - stripped = stripped.replace(new RegExp(KNOWN_NON_PERSONAL_PATH_RE, 'g'), '') - return !PERSONAL_PATH_RE.test(stripped) -} - -// Rewrite the real personal paths on a line into the canonical placeholders, so -// both surfaces print the same fix recipe. -export function suggestPlaceholder(line: string): string { - return line - .replace(/\/Users\/[^/\s]+\//g, '/Users//') - .replace(/\/home\/[^/\s]+\//g, '/home//') - .replace(/C:\\Users\\[^\\]+\\/g, 'C:\\Users\\\\') -} diff --git a/.git-hooks/_shared/pkg-script-target.mts b/.git-hooks/_shared/pkg-script-target.mts deleted file mode 100644 index a56514eb..00000000 --- a/.git-hooks/_shared/pkg-script-target.mts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file Resolve a package.json script to the node script it delegates to, so - * the pre-commit hook can run that script directly instead of through `pnpm - * run`. `pnpm` on PATH is the Socket Firewall shim: it boots the sfw proxy, - * which boots pnpm, which re-resolves the workspace — seconds of startup - * before the script's first line, many times the work a staged-scope gate - * does, and the same wrapper whose deadlock the step budget exists to - * survive. Prints the script path when the body is exactly `node ` — - * that command runs identically under the repo-pinned node - * `_shared/resolve-node.sh` already put on PATH. Prints nothing for any other - * body (a `bun test`, a body carrying extra flags, a shell pipeline), so - * those keep the wrapper: the hook must run what `pnpm run