From 55f7559d70ec0d572757abb85769913fbde657ac Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:31:52 +0000 Subject: [PATCH 1/3] feat: configurable tool commands + opt-in go-test runner Add a dedicated two-tier config (.interlinked/tool-commands.json + tool-commands.local.json, keyed by check/config names) so a project can pin the exact argv Interlinked spawns for its build/lint/test tools. - Trust split mirrors guard-rules merge.ts: committed TEAM tier may set base_args (flags for a fixed binary) and timeout_ms; command/env stay personal-tier (arbitrary executable / runtime rewiring). - Check-engine catalog runners (go-build, golangci-lint, new go-test) honor the override; a full command argv wins, otherwise base_args replace the default scope so Go flag ordering stays correct. - affected_tests go dispatcher carries the go_test tags, replacing the full-suite ./... scope token with the touched package. - New 'go-test' catalog tool: project-wide, opt-in (auto-runs only when the project configures go_test or is explicitly requested), sync + async runners, generic fallback + Go test parsers producing [proven] findings. - verify streaming gains --only go-test (requestedOnly) with the resolved command; check --only/--tools and check --report list go-test. - Regression tests: config resolver (trust/precedence/validation), go-test runner argv+parser, engine mocks, verify-tools resolution pin. --- docs/interlinked-test-runners-plan.md | 233 ++++++++++++++++++ src/commands/check.test.ts | 2 +- src/commands/check.ts | 1 + src/commands/verify/tool-ids.ts | 1 + src/commands/verify/verify-tools.test.ts | 44 ++++ src/commands/verify/verify-tools.ts | 53 +++- .../test-dispatchers.integration.test.ts | 20 ++ .../check-engine/__tests__/types.test.ts | 1 + src/harness/check-engine/index-runtime.ts | 4 +- src/harness/check-engine/index.test.ts | 10 + src/harness/check-engine/index.ts | 55 ++++- .../check-engine/output-parsers-extra.ts | 50 ++++ src/harness/check-engine/output-parsers.ts | 1 + src/harness/check-engine/tool-catalog.test.ts | 3 + src/harness/check-engine/tool-catalog.ts | 19 +- .../check-engine/tool-commands.test.ts | 192 +++++++++++++++ src/harness/check-engine/tool-commands.ts | 221 +++++++++++++++++ .../tool-runners/go-test.integration.test.ts | 165 +++++++++++++ src/harness/check-engine/tool-runners/go.ts | 177 ++++++++++++- src/harness/check-engine/types.ts | 30 +++ .../quality-checks/test-dispatchers.ts | 31 ++- src/harness/quality-checks/tool-check-loop.ts | 5 + 22 files changed, 1297 insertions(+), 21 deletions(-) create mode 100644 docs/interlinked-test-runners-plan.md create mode 100644 src/harness/check-engine/tool-commands.test.ts create mode 100644 src/harness/check-engine/tool-commands.ts create mode 100644 src/harness/check-engine/tool-runners/go-test.integration.test.ts diff --git a/docs/interlinked-test-runners-plan.md b/docs/interlinked-test-runners-plan.md new file mode 100644 index 00000000..6aa45f75 --- /dev/null +++ b/docs/interlinked-test-runners-plan.md @@ -0,0 +1,233 @@ +# Custom build/test commands — design plan + +Status: proposal (pre-PR design) +Target repo: `github.com/QuentinCody/interlinked-cli` +Updated: 2026-09-02 (trimmed to v1 scope: custom build/test commands) + +Operator material — not part of the public docs tree. In-source citations use +repo-relative paths. + +## 1. Goal + +Let a project declare its own **build and test commands** once, and have +Interlinked honor them everywhere it spawns tooling — PostToolUse per-edit +checks, `affected_tests`, `interlinked verify`/`check`, and the per-edit +coverage gate. + +Motivating case (an internal Go workspace): the project's verification must run +`go test -tags 'dev devaccounts' ./...` — the same tags `air` uses for the dev +build (`go build -tags 'dev devaccounts'`), so both share Go's build cache. +Today the CLI cannot express that: every runner hard-codes its argv, and there +is no full-suite test tool at all. + +## 2. Current state (what must be reused, what's actually missing) + +| Concern | Where it lives today | Reality | +|---|---|---| +| Per-check `command` config | `QualityCheckConfig.command` (`src/harness/types/config.ts`) | **Not a real override.** `tool-check-loop.ts` routes command-backed checks through `runCommandCheck`, which ignores the string and maps the check *name* to a catalog runner (`configNameToToolId`) running its **hard-coded** argv. | +| Build/lint/tool runners | `src/harness/check-engine/tool-runners/{go,rust,python,…}.ts` | Hard-coded argv (`go build ./...`, `golangci-lint run --out-format=json ./...`, …). | +| `affected_tests` test execution | `src/harness/quality-checks/test-dispatchers.ts` | Per-language dispatch (vitest/pytest/cargo/go) with hard-coded argv (`go test -count=1 ./`, `pytest -x …`). | +| Full-suite test driver | — | **Missing.** No `*-test` tool id in the catalog; only the coverage gate runs suites. | +| argv-form suite override (the good precedent) | `CoverageRunOpts.testCommand?: string[]` (`src/harness/coverage-runner.ts`) + per-language defaults in `coverage-runner-commands.ts` | Correct shape (argv, no shell, bare-bin→`node_modules/.bin` resolution), but only exercised by tests — **never wired to config**. | +| Heavy lane + no-verdict semantics | `src/harness/project-heavy-process-lock.ts`, `verify.ts` (`verify deferred`), `test-process-gate.ts` | Present and reused as-is. | +| Tool catalog + surface guards | `src/harness/check-engine/tool-catalog.ts`, `check.ts` (`ALL_TOOL_IDS` drift guard), `verify/tool-ids.ts`, `verify/verify-tools.ts` | Adding one tool id is mechanical; guards catch missed wiring. | + +**Delta:** (1) one config surface, (2) thread it through the three executors, +(3) one new full-suite test tool (`go-test`). + +## 3. Config surface + +Dedicated two-tier pair — `.interlinked/tool-commands.json` (team, committed) ++ `.interlinked/tool-commands.local.json` (personal, gitignored). Not +`guard-rules.json` (that is PreToolUse *guard* policy with a security +whitelist that forbids team commands — a different axis) and not +`check-policy.json` (per-check *action* policy). + +Keyed by the existing **check/tool config names** (`go_build`, `go_test`, +`golangci_lint`, …), resolved to tool ids via the existing +`configNameToToolId` map. The shared vocabulary lets one key reach the check +engine, `interlinked verify`'s streaming phase, and the `affected_tests` +dispatchers. + +```jsonc +// .interlinked/tool-commands.json +{ + "version": 1, + "tool_commands": { + "go_build": { + "base_args": ["-tags", "dev devaccounts", "./..."], + "timeout_ms": 300000 + }, + "go_test": { + "base_args": ["-tags", "dev devaccounts", "./..."], + "timeout_ms": 300000 + } + } +} +``` + +Note the argv is NOT shell-interpolated: air's `-tags 'dev devaccounts'` is ONE +argv token (`-tags`, `dev devaccounts`) — two separate tokens would make +`devaccounts` a bogus package pattern. + +Per-entry fields (all optional): + +| Field | Meaning | Team tier? | +|---|---|---| +| `command` | Full argv override. Wins over default prefix + `base_args`. | **No** — arbitrary executable; personal-tier only (mirrors `QUALITY_CHECK_SAFE_FIELDS`). | +| `base_args` | Appended after the detected default prefix, REPLACING the default scope, e.g. `go build`/`go test` + `["-tags","dev devaccounts","./..."]`. | Yes (flags for a fixed binary — trusted like a committed Makefile). | +| `env` | Extra/overriding env vars for the spawned process. | **No** — can rewire the runtime; personal-tier only. | +| `timeout_ms` | Per-run cap, within a hard CLI ceiling (10 min) that config cannot exceed. | Yes (a bounded cap, never executable — same tier as `quality_checks.timeout_ms`). | + +Precedence: `command` > default prefix + `base_args` > default. + +Validation (enforced on load, surfaced by `interlinked doctor`): + +- **Unknown tool keys allowed** (forward compat — a newer config on an older + binary) → reported as `tool not available on this version`, never fails the + whole verify. +- **Unknown fields inside a known tool are schema errors** naming the key + (a `base_argz` typo fails loudly, not silently). +- **Team-tier trust violations** (`command`/`env` in the committed file) are + errors telling the author to move them to `tool-commands.local.json`. +- Commands are argv arrays. No strings-in-shell, no `&&` chains — matches the + `CoverageRunOpts.testCommand` contract. + +## 4. Thread the override into the executors + +One resolver, three consumers: + +`resolveToolCommand(toolId, projectRoot, defaultPrefix)` — reads the two-tier +`tool-commands*.json` pair, applies the team/local trust split, and returns +`{ argv, baseArgs, env, timeoutMs }` (or the `defaultPrefix` when unconfigured). +Pure, unit-testable (`src/harness/check-engine/tool-commands.ts`). + +1. **Check-engine catalog runners.** `ToolRunnerInput` (`src/harness/check-engine/types.ts`) + gains the resolved command; `runGoBuild`/`runGolangciLint`/`runGoTest` spawn + `override.argv ?? defaultPrefix + baseArgs`. `CheckEngine` resolves once per + instance from `projectRoot`. Sync + async variants (`runGoBuildAsync` …) so + PostToolUse's async engine path runs the configured command too. +2. **`affected_tests` dispatchers.** `runAffectedTests` (`tool-check-loop.ts`) + feeds the resolved `go_test` entry (base_args, env, timeout) into the go + dispatcher via its existing input shape, so PostToolUse on a `.go` edit runs + `go test -count=1 ./` — the full-suite `./...` scope token is + replaced by the touched package. Other dispatchers unchanged in v1. The + `runBoundedTestProcess` lane and pre-existing-failure classification stay + as-is. An explicit `command` override is used verbatim (caller owns argv). +3. **Coverage gate.** Deferred in v1 — `CoverageRunOpts.testCommand` is not yet + wired (the per-edit coverage gate covers js/ts/python only, which this Go + feature does not touch). + +## 5. New full-suite test driver: `go-test` + +The genuinely new capability — nothing except the coverage gate can run a +project's test command today, and the coverage gate's shape isn't a +verification surface. + +- **Catalog row** (`src/harness/check-engine/tool-catalog.ts`): id `go-test`, + config name `go_test`, project-wide (no `extensions`), `concurrencySafe: + false`, `requiresConfig: ["go.mod"]`, default prefix `["go","test","./..."]`, + version probe `go version`. Follows the `go-build` row exactly. +- **Runner** (`tool-runners/go.ts` or a sibling `go-test.ts`): `defaultPrefix` + + resolved `tool_commands.go_test.base_args`/`command`/`env`/`timeout_ms`, + spawned argv-form via the existing process helpers. +- **Parsers** (`tool-runners/test-parsers.ts`): a **generic fallback** (exit + code + last N stderr lines) so any test command yields a verdict, plus a + **Go parser** (`--- FAIL: TestX`, `FAIL ` package trailers, `panic:`) + for per-unit blame. Non-zero exit → one finding for the run plus one per + parsed failing test. + +Verdict semantics (shared with the existing surfaces): + +| Outcome | Meaning | Report | +|---|---|---| +| `ok` | Ran; exit 0 | Checked clean (`[proven]`) | +| `skipped` | Not a Go project (`go.mod` absent — `requiresConfig` unmet), or a forward-compat key this version can't run | NOT CHECKED / skip entry | +| `unavailable` | Should have run but didn't (timeout, lane held, binary missing) | No verdict — never clean; deferred/exit 1 like today's `verify deferred` | + +Full-suite runs ride the existing project heavy lane (`tryAcquireProjectHeavyProcessLease`); +a held lane yields `verify deferred` unchanged. `interlinked write` / +`verify-changeset` / `multi-edit` stay content gates and never run suites. + +## 6. Surface wiring (mechanical) + +- `ToolId` union (`src/harness/check-engine/types.ts`): `go-test`. +- `go-build`/`golangci-lint`/`go-test` rows honor `tool_commands` for build and + test. +- `ALL_TOOL_IDS` (`src/commands/check.ts`): `go-test` — the compile-time + `_MissingToolIds` guard fails the build if missed. +- `TOOL_IDS` (`src/commands/verify/tool-ids.ts`): `go-test`, so + `verify --only go-test` works on streaming and `--json` paths. +- `TOOLS_TO_RUN` (`src/commands/verify/verify-tools.ts`): a `go-test` row with + its resolved command, so the streaming phase runs it with the same + spinner/summary treatment as tsc/biome. +- `check --report` / discovery: free, via the catalog row. +- `tool-catalog.test.ts` pins the new derivations; update in the same PR. + +## 7. Milestones (PR-sized) + +1. **Config:** `tool_commands` types + two-tier loader + precedence + validation + (forward-compat unknown keys, schema errors on bad fields); `doctor` + reporting. Unit tests. +2. **Build overrides:** `ToolRunnerInput` gains resolved command; `go_build` + and `golangci_lint` honor it; `affected_tests` go dispatcher honors + `go_test` tags. Integration tests assert the *spawned argv*, e.g. that a + configured `-tags dev devaccounts` shows up in the actual command. + (Coverage-gate wiring stays deferred — it covers js/ts/python only.) +3. **`go-test` tool:** catalog row + runner + generic/Go parsers + verdict + semantics + `check`/`verify` wiring + `check --report`. Heavier-lane reuse. +4. **Docs/tests/changelog:** README example (the acceptance config below), + `npm run docs` for any generated tool/reference output, registry-parity and + catalog-drift test updates, CHANGELOG. + +## 8. Deferred (explicitly out of v1) + +- Full-suite runners for rust/python/node, and per-unit blame parsers for them + (the generic fallback already yields an honest verdict for any language that + gets a `command` override later). +- `verify --json` per-runner blocks, advisory/default-gate policy debates, + consolidating `TOOLS_TO_RUN` onto `TOOL_CATALOG`. +- Go coverage in the per-edit gate (stays js/ts/python). + +Extension path is unchanged: a new language = one catalog row + one resolver +lookup, no core rewiring. + +## 9. Acceptance example + +```jsonc +// .interlinked/tool-commands.json +{ + "version": 1, + "tool_commands": { + "go_build": { + "base_args": ["-tags", "dev devaccounts", "./..."], + "timeout_ms": 300000 + }, + "go_test": { + "base_args": ["-tags", "dev devaccounts", "./..."], + "timeout_ms": 300000 + } + } +} +``` + +`interlinked verify`/`check` run `go build`/`go test` with the air-aligned +tags (sharing its Go build cache), PostToolUse on a `.go` edit runs the +touched package's tests with the same tags, and a failing test or package +produces `[proven]` findings — while a held lane or timeout reports deferred, +never clean. + +Measured on the motivating Go workspace (isolated `GOCACHE`, 2026-09-03): + +| Surface | Command | Cache state | Wall | +|---|---|---|---| +| BEFORE (stock install) | `check --only go-build` | untagged `go build ./...`, no shared cache | 8.7 s | +| AFTER (feature) | `check --only go-build` | tagged, air-warmed cache reused | 6.0 s | +| AFTER (feature) | `check --only go-test` | tagged, warm | 21.0 s | +| Reference | `go build -tags 'dev devaccounts' ./...` | no pre-warm | 29.3 s | +| Reference | `go test -tags 'dev devaccounts' ./...` | no pre-warm | 84.4 s | + +The after-run hug the warm baselines (6.0 vs 5.5 s build; 21 vs 23 s suite) +because air's rebuild leaves the exact tagged cache the project commands reuse; +the cold references (29.3 s / 84.4 s) are what the same commands cost without +that reuse. \ No newline at end of file diff --git a/src/commands/check.test.ts b/src/commands/check.test.ts index ed348e0a..9b2c3e97 100644 --- a/src/commands/check.test.ts +++ b/src/commands/check.test.ts @@ -1283,7 +1283,7 @@ describe("checkCommand — mutation-targeted branch isolation", () => { await checkCommand({ only: "not-a-real-check", cwd: "/abs" }); const { stdout, stderr, exitCode } = io.mocks(); expect(stderr).toBe( - `Unknown check: "not-a-real-check". Available: broken-imports, cycles, duplicates, missing-tests, secrets, any-types, blast-radius, dead-imports, tsc, biome, eslint, oxlint, knip, semgrep, gitleaks, dep-audit, mypy, ruff, ruff-format, cargo-check, cargo-clippy, rustfmt, go-build, golangci-lint, c-compile, clang-tidy, shellcheck, actionlint, hadolint, taplo, swiftlint, swift-build, lizard, docs-check\n`, + `Unknown check: "not-a-real-check". Available: broken-imports, cycles, duplicates, missing-tests, secrets, any-types, blast-radius, dead-imports, tsc, biome, eslint, oxlint, knip, semgrep, gitleaks, dep-audit, mypy, ruff, ruff-format, cargo-check, cargo-clippy, rustfmt, go-build, golangci-lint, go-test, c-compile, clang-tidy, shellcheck, actionlint, hadolint, taplo, swiftlint, swift-build, lizard, docs-check\n`, ); expect(stdout).toBe(""); expect(exitCode).toBe(1); diff --git a/src/commands/check.ts b/src/commands/check.ts index 8973c114..b984ad65 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -266,6 +266,7 @@ const ALL_TOOL_IDS = [ "rustfmt", "go-build", "golangci-lint", + "go-test", "c-compile", "clang-tidy", "shellcheck", diff --git a/src/commands/verify/tool-ids.ts b/src/commands/verify/tool-ids.ts index b49364c3..dce5cdc3 100644 --- a/src/commands/verify/tool-ids.ts +++ b/src/commands/verify/tool-ids.ts @@ -16,6 +16,7 @@ export const TOOL_IDS = [ "cargo-clippy", "go-build", "golangci-lint", + "go-test", "c-compile", "clang-tidy", "oxlint", diff --git a/src/commands/verify/verify-tools.test.ts b/src/commands/verify/verify-tools.test.ts index ed3fe188..10561e19 100644 --- a/src/commands/verify/verify-tools.test.ts +++ b/src/commands/verify/verify-tools.test.ts @@ -12,6 +12,9 @@ // spawning a process or touching disk. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { CheckEngine } from "../../harness/check-engine/index.js"; import type { @@ -47,6 +50,9 @@ const parseGitleaksJson = vi.fn<(o: string) => CheckResult[]>(() => parserReturn const parseOxlintJson = vi.fn<(o: string) => CheckResult[]>(() => parserReturn); const parseNpmAuditJson = vi.fn<(o: string) => AuditResult | null>(() => npmAuditReturn); const parseDocsCheckOutput = vi.fn<(o: string) => CheckResult[]>(() => parserReturn); +const parseGoTestOutput = vi.fn<(o: string, status: number) => CheckResult[]>( + () => parserReturn, +); vi.mock("../../harness/check-engine/output-parsers.js", () => ({ parseTscOutput: (o: string) => parseTscOutput(o), parseBiomeOutput: (o: string) => parseBiomeOutput(o), @@ -57,6 +63,7 @@ vi.mock("../../harness/check-engine/output-parsers.js", () => ({ parseOxlintJson: (o: string) => parseOxlintJson(o), parseNpmAuditJson: (o: string) => parseNpmAuditJson(o), parseDocsCheckOutput: (o: string) => parseDocsCheckOutput(o), + parseGoTestOutput: (o: string, s: number) => parseGoTestOutput(o, s), })); // streaming-output: the two subprocess runners + spinner frames. Each runner @@ -228,6 +235,40 @@ describe("TOOLS_TO_RUN", () => { }); }); +describe("streamExternalTools — go-test command resolution", () => { + it("--only go-test spawns the project's configured go_test argv (build tags)", async () => { + const tmp = mkdtempSync(join(tmpdir(), "verify-go-test-")); + mkdirSync(join(tmp, ".interlinked"), { recursive: true }); + writeFileSync( + join(tmp, ".interlinked", "tool-commands.json"), + JSON.stringify({ go_test: { base_args: ["-tags", "dev", "devaccounts", "./..."] } }), + "utf-8", + ); + try { + runnerScript["go-test"] = { output: "ok", status: 0 }; + const summary: Array<{ label: string; count: number; color: string }> = []; + const flagged = new Set(); + const p = streamExternalTools({ + engine: fakeEngine(["go-test"]), + cwd: tmp, + opts: { only: "go-test" }, + skipChecks: new Set(["sca", "dep-audit"]), + summary, + allFlaggedFiles: flagged, + details: false, + }); + await vi.runAllTimersAsync(); + await p; + // The static placeholder cmd is replaced by the resolved argv. + const call = nonNull(runToolWithSpinner.mock.calls[0]); + const args = nonNull(call[0]) as { cmd: string[] }; + expect(args.cmd).toEqual(["go", "test", "-tags", "dev", "devaccounts", "./..."]); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + // ========================================================================= // 2. streamExternalTools — single-tool fast path // ========================================================================= @@ -734,6 +775,9 @@ describe("TOOLS_TO_RUN — exact command vectors", () => { ], knip: ["npx", "knip", "--no-progress", "--reporter", "json"], "docs-check": ["node", "scripts/check-docs.mjs"], + // Static placeholder — streamExternalTools resolves the real argv + // from .interlinked/tool-commands*.json before spawning. + "go-test": ["go", "test", "./..."], }; const actual: Record = {}; for (const t of TOOLS_TO_RUN) actual[t.id] = t.cmd; diff --git a/src/commands/verify/verify-tools.ts b/src/commands/verify/verify-tools.ts index 41f0dc69..1560bff2 100644 --- a/src/commands/verify/verify-tools.ts +++ b/src/commands/verify/verify-tools.ts @@ -13,6 +13,10 @@ import { join } from "node:path"; import type { CheckEngine, CheckResult } from "../../harness/check-engine/index.js"; +import { + buildToolCommandArgv, + resolveToolCommand, +} from "../../harness/check-engine/tool-commands.js"; import { loadFileSuppressions } from "../../harness/suppressions.js"; import { nonNull } from "../../lib/non-null.js"; import { @@ -28,6 +32,14 @@ export interface ToolSpec { noun: string; severity: string; cmd: string[]; + /** True when the tool runs only under an explicit `--only ` on the + * default streaming path (never in an unfiltered `interlinked verify`). + * The JSON path and `interlinked check` gate the same tool via + * CheckEngine.shouldRunByDefault instead. */ + requestedOnly?: boolean; + /** Per-run timeout; falls back to DEFAULT_TOOL_TIMEOUT_MS. Filled from + * tool-commands `timeout_ms` for configurable tools at stream time. */ + timeoutMs?: number; } export const TOOLS_TO_RUN: readonly ToolSpec[] = [ @@ -131,6 +143,19 @@ export const TOOLS_TO_RUN: readonly ToolSpec[] = [ severity: "31", cmd: ["node", "scripts/check-docs.mjs"], }, + { + // Full-suite Go test runner (opt-in). `requestedOnly` keeps it out of + // unfiltered `interlinked verify` (the default gate debate is deferred); + // the cmd is resolved below from .interlinked/tool-commands*.json so a + // configured `go_test` (build tags etc.) is honored exactly. + id: "go-test", + label: "go test", + passLabel: "all tests passed", + noun: "failing tests", + severity: "31", + cmd: ["go", "test", "./..."], + requestedOnly: true, + }, ]; const DEFAULT_TOOL_TIMEOUT_MS = 60_000; @@ -176,6 +201,7 @@ export async function streamExternalTools(args: StreamExternalToolsArgs): Promis parseOxlintJson, parseNpmAuditJson, parseDocsCheckOutput, + parseGoTestOutput, } = await import("../../harness/check-engine/output-parsers.js"); const toolParsers: Record CheckResult[]> = { @@ -189,7 +215,22 @@ export async function streamExternalTools(args: StreamExternalToolsArgs): Promis "docs-check": (out) => parseDocsCheckOutput(out), }; - const availableTools = TOOLS_TO_RUN.filter((tool) => { + // Resolve configurable tools (go-test) against .interlinked/tool-commands*: + // the configured argv replaces the static placeholder so `--only go-test` + // runs the project's exact test command (tags etc.). + const toolsToRun = TOOLS_TO_RUN.map((tool) => { + if (tool.id !== "go-test") return tool; + const override = resolveToolCommand(cwd, "go_test", ["go", "test"], ["./..."]); + return { + ...tool, + cmd: buildToolCommandArgv(override, ["go", "test"], ["./..."]), + timeoutMs: override?.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS, + }; + }); + + const availableTools = toolsToRun.filter((tool) => { + // requestedOnly tools (go-test) never participate in an unfiltered run. + if (tool.requestedOnly && !opts.only) return false; if (opts.only && opts.only !== tool.id && opts.only !== tool.label) return false; if (skipChecks.has(tool.id)) return false; const avail = engine.discoverTools().find((t) => t.id === tool.id); @@ -273,6 +314,12 @@ export async function streamExternalTools(args: StreamExternalToolsArgs): Promis } function parseToolOutput(tool: ToolSpec, output: string, status: number | null): CheckResult[] { + if (tool.id === "go-test") { + // Go test: exit 0 = green; non-zero = parsed failing units (the + // parser emits a generic whole-run finding when no unit isolates). + if (status === 0) return []; + return parseGoTestOutput(output, status ?? -1); + } if ( tool.id === "gitleaks" && status === 1 && @@ -292,7 +339,7 @@ export async function streamExternalTools(args: StreamExternalToolsArgs): Promis label: tool.label, cmd: tool.cmd, cwd, - timeoutMs: DEFAULT_TOOL_TIMEOUT_MS, + timeoutMs: tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS, parseOutput: (output, status) => parseToolOutput(tool, output, status), }); displayToolResult(tool, rawResults); @@ -322,7 +369,7 @@ export async function streamExternalTools(args: StreamExternalToolsArgs): Promis runToolSilent({ cmd: tool.cmd, cwd, - timeoutMs: DEFAULT_TOOL_TIMEOUT_MS, + timeoutMs: tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS, parseOutput: (output, status) => parseToolOutput(tool, output, status), }).then((rawResults) => { process.stderr.write("\r\x1b[K"); diff --git a/src/harness/__tests__/test-dispatchers.integration.test.ts b/src/harness/__tests__/test-dispatchers.integration.test.ts index b2d9e252..1b724eab 100644 --- a/src/harness/__tests__/test-dispatchers.integration.test.ts +++ b/src/harness/__tests__/test-dispatchers.integration.test.ts @@ -450,6 +450,26 @@ describe("runGoTestDispatcher", () => { ); }); + it("carries configured go_test base_args (build tags) into the package run", async () => { + spawnSyncMock.mockReturnValue(mkSpawnResult({ status: 0 })); + const out = await dispatcher({ + filePath, + absPath: "/repo/src/pkg/m.go", + profile, + checkCwd: "/repo", + timeoutMs: 15000, + severity: "error", + checkName: "affected_tests", + // `.interlinked/tool-commands.json` go_test entry — full-suite scope + // token replaced by the touched package so flags keep precedence. + commandOverride: { baseArgs: ["-tags", "dev", "devaccounts", "./..."] }, + }); + expect(out).toEqual([]); + const args = nonNull(spawnSyncMock.mock.calls[0])[1] as string[]; + expect(args).toEqual(["test", "-tags", "dev", "devaccounts", "-count=1", "./src/pkg"]); + expect(args).not.toContain("./..."); + }); + it("reports no verdict for an errored go process even when status is nonzero", async () => { spawnSyncMock.mockReturnValue( mkSpawnResult({ diff --git a/src/harness/check-engine/__tests__/types.test.ts b/src/harness/check-engine/__tests__/types.test.ts index bd58289a..5f7595b0 100644 --- a/src/harness/check-engine/__tests__/types.test.ts +++ b/src/harness/check-engine/__tests__/types.test.ts @@ -24,6 +24,7 @@ describe("check-engine types", () => { "cargo-clippy", "go-build", "golangci-lint", + "go-test", "c-compile", "clang-tidy", "oxlint", diff --git a/src/harness/check-engine/index-runtime.ts b/src/harness/check-engine/index-runtime.ts index e5c0b962..b1f016d5 100644 --- a/src/harness/check-engine/index-runtime.ts +++ b/src/harness/check-engine/index-runtime.ts @@ -4,6 +4,7 @@ import type { CheckReport, CheckResult, CheckScope, + ResolvedToolCommand, SkipEntry, ToolAvailability, ToolId, @@ -42,6 +43,7 @@ export async function runAsyncTool( tool: ToolAvailability, scope: CheckScope, timeoutMs: number, + commandOverride?: ResolvedToolCommand, ): Promise<{ results: CheckResult[]; metric: ToolMetrics; skipped?: SkipEntry }> { const meta = toolRunnerMetaFor(tool.id); if (!meta?.runnerAsync) { @@ -59,7 +61,7 @@ export async function runAsyncTool( } const toolStart = Date.now(); try { - const results = await meta.runnerAsync({ scope, timeoutMs }); + const results = await meta.runnerAsync({ scope, timeoutMs, commandOverride }); return { results, metric: { diff --git a/src/harness/check-engine/index.test.ts b/src/harness/check-engine/index.test.ts index 62962f3e..1f80c816 100644 --- a/src/harness/check-engine/index.test.ts +++ b/src/harness/check-engine/index.test.ts @@ -37,6 +37,12 @@ vi.mock("node:fs", () => ({ } return { mtimeMs: v }; }, + // Tool-commands config is absent in this fixture world (no + // .interlinked/tool-commands*.json exists), so the resolver reads nothing. + existsSync: () => false, + readFileSync: (): string => { + throw new Error("readFileSync not mocked — no tool-commands files expected"); + }, })); // --------------------------------------------------------------------------- @@ -179,7 +185,11 @@ vi.mock("./tool-runners/rust.js", () => ({ vi.mock("./tool-runners/go.js", () => ({ runGoBuild: mkSyncRunner("go-build"), + runGoBuildAsync: mkAsyncRunner("go-build"), runGolangciLint: mkSyncRunner("golangci-lint"), + runGolangciLintAsync: mkAsyncRunner("golangci-lint"), + runGoTest: mkSyncRunner("go-test"), + runGoTestAsync: mkAsyncRunner("go-test"), })); vi.mock("./tool-runners/c-cpp.js", () => ({ diff --git a/src/harness/check-engine/index.ts b/src/harness/check-engine/index.ts index 1401505c..1f837774 100644 --- a/src/harness/check-engine/index.ts +++ b/src/harness/check-engine/index.ts @@ -22,6 +22,11 @@ import { import { tryAcquireProjectHeavyProcessLease } from "../project-heavy-process-lock.js"; import { runBiomeOverlay } from "./tool-runners/biome.js"; import { runDepAudit } from "./tool-runners/generic.js"; +import { + configNameForTool, + loadToolCommands, + toResolvedToolCommand, +} from "./tool-commands.js"; import { clearTscOverlayCache, runTscOverlayTyped, @@ -33,7 +38,9 @@ import type { CheckReport, CheckResult, CheckScope, + ResolvedToolCommand, ToolAvailability, + ToolCommandConfig, ToolId, ToolMetrics, } from "./types.js"; @@ -43,7 +50,9 @@ export { formatToolReport } from "./discovery.js"; export type { CheckReport, CheckResult, + ResolvedToolCommand, SkipEntry, + ToolCommandConfig, ToolId, ToolMetrics, } from "./types.js"; @@ -61,11 +70,42 @@ export class CheckEngine { readonly projectRoot: string; private toolsCache: ToolAvailability[] | null = null; private singleToolCache = new Map(); + private toolCommands: Record | null = null; constructor(projectRoot: string) { this.projectRoot = projectRoot; } + /** Lazy two-tier tool-commands view (loaded once per engine instance). */ + private loadCommands(): Record { + if (!this.toolCommands) this.toolCommands = loadToolCommands(this.projectRoot); + return this.toolCommands; + } + + /** Resolve the command override for one tool, if the project configured it. */ + private commandOverrideFor(tool: ToolAvailability): ResolvedToolCommand | undefined { + const name = configNameForTool(tool.id); + if (!name) return undefined; + const entry = this.loadCommands()[name]; + return entry ? toResolvedToolCommand(entry) : undefined; + } + + /** + * Default-gate participation for tools in an UNFILTERED run. + * + * `go-test` is deliberately opt-in: a whole-suite run is heavyweight and has + * no file-dispatch surface, so an unfiltered `interlinked check`/verify run + * does not suddenly execute the project's full test suite. It auto-runs only + * when the project configures `go_test` in `.interlinked/tool-commands*.json` + * (the repo declaring "this is my test command"), or when explicitly + * requested via options.tools. Discovery still lists it either way. + */ + private shouldRunByDefault(tool: ToolAvailability, options: CheckOptions | undefined): boolean { + if (tool.id !== "go-test") return true; + if (options?.tools?.includes("go-test")) return true; + return Boolean(this.loadCommands()["go_test"]); + } + /** Discover which tools are available. Cached per engine instance. */ discoverTools(): ToolAvailability[] { if (!this.toolsCache) { @@ -151,6 +191,7 @@ export class CheckEngine { const toolsToRun = available.filter((t) => { if (!t.available) return false; if (options?.tools && !options.tools.includes(t.id)) return false; + if (!this.shouldRunByDefault(t, options)) return false; if (options?.skipTools?.includes(t.id)) return false; return true; }); @@ -164,7 +205,11 @@ export class CheckEngine { if (!runner) continue; const toolStart = Date.now(); - const results = runner({ scope, timeoutMs: timeout }); + const results = runner({ + scope, + timeoutMs: timeout, + commandOverride: this.commandOverrideFor(tool), + }); metrics.push({ tool: tool.id, elapsedMs: Date.now() - toolStart, @@ -228,6 +273,7 @@ export class CheckEngine { const toolsToRun = available.filter((t) => { if (!t.available) return false; if (options?.tools && !options.tools.includes(t.id)) return false; + if (!this.shouldRunByDefault(t, options)) return false; if (options?.skipTools?.includes(t.id)) return false; return true; }); @@ -238,7 +284,12 @@ export class CheckEngine { // runner failures and returns an explicit no-verdict skip, so the loop can // continue without either rejecting the batch or reading a crash as clean. const allRuns: Awaited>[] = []; - for (const tool of toolsToRun) allRuns.push(await runAsyncTool(tool, scope, timeout)); + // One child at a time inside the admitted finite batch. `runOne` catches + // runner failures and returns an explicit no-verdict skip, so the loop can + // continue without either rejecting the batch or reading a crash as clean. + for (const tool of toolsToRun) { + allRuns.push(await runAsyncTool(tool, scope, timeout, this.commandOverrideFor(tool))); + } const allResults = allRuns.flatMap((r) => r.results); const metrics = allRuns.map((r) => r.metric); diff --git a/src/harness/check-engine/output-parsers-extra.ts b/src/harness/check-engine/output-parsers-extra.ts index 2dd6517c..621c6a54 100644 --- a/src/harness/check-engine/output-parsers-extra.ts +++ b/src/harness/check-engine/output-parsers-extra.ts @@ -336,6 +336,56 @@ export function parseGoBuildOutput(output: string): CheckResult[] { return results; } +/** Parse `go test` output into per-failing-unit findings, falling back to a + * generic whole-run finding when no unit can be isolated — any non-zero exit + * still produces a [proven] verdict (never a silent clean). */ +export function parseGoTestOutput(output: string, status: number): CheckResult[] { + const results: CheckResult[] = []; + for (const line of output.split("\n")) { + const fail = line.match(/^--- FAIL:\s+(\S+)\s+\(([^)]*)\)/); + if (fail) { + results.push({ + tool: "go-test", + severity: "error", + file: "", + line: 0, + message: `FAIL: ${nonNull(fail[1])} (${nonNull(fail[2])})`, + }); + continue; + } + const pkg = line.match(/^FAIL\s+(\S+)\s/); + if (pkg) { + results.push({ + tool: "go-test", + severity: "error", + file: nonNull(pkg[1]), + line: 0, + message: `package ${nonNull(pkg[1])} failed`, + }); + continue; + } + if (/^panic:/.test(line)) { + results.push({ tool: "go-test", severity: "error", file: "", line: 0, message: line.trim() }); + } + } + if (results.length === 0) { + const tail = output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .slice(-6) + .join("\n"); + results.push({ + tool: "go-test", + severity: "error", + file: "", + line: 0, + message: `go test failed (exit ${status}): ${tail}`, + }); + } + return results; +} + // ------------------------------------------- // golangci-lint (golangci-lint run --out-format=json) // ------------------------------------------- diff --git a/src/harness/check-engine/output-parsers.ts b/src/harness/check-engine/output-parsers.ts index 5699af20..4ade5b27 100644 --- a/src/harness/check-engine/output-parsers.ts +++ b/src/harness/check-engine/output-parsers.ts @@ -277,6 +277,7 @@ export { parseClangTidyOutput, parseGccOutput, parseGoBuildOutput, + parseGoTestOutput, parseGolangciLintJson, parseKnipJson, parseMypyOutput, diff --git a/src/harness/check-engine/tool-catalog.test.ts b/src/harness/check-engine/tool-catalog.test.ts index 26aa6a19..35fc9cfc 100644 --- a/src/harness/check-engine/tool-catalog.test.ts +++ b/src/harness/check-engine/tool-catalog.test.ts @@ -36,6 +36,7 @@ describe("tool catalog — derived registry", () => { "rustfmt", "go-build", "golangci-lint", + "go-test", "c-compile", "clang-tidy", "shellcheck", @@ -55,6 +56,7 @@ describe("tool catalog — derived registry", () => { "cargo-clippy", "go-build", "golangci-lint", + "go-test", "c-compile", "clang-tidy", "swift-build", @@ -95,6 +97,7 @@ describe("tool catalog — derived config map", () => { rustfmt_check: "rustfmt", go_build: "go-build", golangci_lint: "golangci-lint", + go_test: "go-test", c_compile: "c-compile", clang_tidy: "clang-tidy", shellcheck: "shellcheck", diff --git a/src/harness/check-engine/tool-catalog.ts b/src/harness/check-engine/tool-catalog.ts index 6fc8ca1d..4c75593c 100644 --- a/src/harness/check-engine/tool-catalog.ts +++ b/src/harness/check-engine/tool-catalog.ts @@ -34,7 +34,7 @@ import { runSemgrep, runSemgrepAsync, } from "./tool-runners/generic.js"; -import { runGoBuild, runGolangciLint } from "./tool-runners/go.js"; +import { runGoBuild, runGoBuildAsync, runGolangciLint, runGolangciLintAsync, runGoTest, runGoTestAsync } from "./tool-runners/go.js"; import { runHadolint, runHadolintAsync } from "./tool-runners/hadolint.js"; import { runLizard, runLizardAsync } from "./tool-runners/lizard.js"; import { @@ -197,6 +197,7 @@ export const TOOL_CATALOG: ToolCatalogEntry[] = [ configNames: ["go_build"], extensions: [".go"], runner: runGoBuild, + runnerAsync: runGoBuildAsync, concurrencySafe: false, versionCmd: ["go", "version"], versionRegex: /go(\d+\.\d+\.\d+)/, @@ -208,11 +209,27 @@ export const TOOL_CATALOG: ToolCatalogEntry[] = [ configNames: ["golangci_lint"], extensions: [".go"], runner: runGolangciLint, + runnerAsync: runGolangciLintAsync, concurrencySafe: false, versionCmd: ["golangci-lint", "--version"], versionRegex: /(\d+\.\d+\.\d+)/, configFiles: [".golangci.yml", ".golangci.yaml", ".golangci.json", ".golangci.toml"], }, + { + // Full-suite Go test runner (project-wide, opt-in — see + // CheckEngine.shouldRunByDefault: auto-runs only when the project + // configures `go_test` in .interlinked/tool-commands*.json, or when + // explicitly requested via --only go-test / --tools go-test). + id: "go-test", + configNames: ["go_test"], + runner: runGoTest, + runnerAsync: runGoTestAsync, + concurrencySafe: false, + versionCmd: ["go", "version"], + versionRegex: /go(\d+\.\d+\.\d+)/, + configFiles: ["go.mod"], + requiresConfig: true, + }, // --- C/C++ --- { id: "c-compile", diff --git a/src/harness/check-engine/tool-commands.test.ts b/src/harness/check-engine/tool-commands.test.ts new file mode 100644 index 00000000..1f32cb2c --- /dev/null +++ b/src/harness/check-engine/tool-commands.test.ts @@ -0,0 +1,192 @@ +// Unit tests for the two-tier tool-commands config (check-engine/tool-commands.ts): +// trust split (team base_args only), local-wins precedence, argv assembly, +// and doctor-facing validation. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { nonNull } from "../../lib/non-null.js"; +import { + buildToolCommandArgv, + HARD_TOOL_TIMEOUT_CAP_MS, + loadToolCommands, + resolveToolCommand, + toResolvedToolCommand, + toolCommandConfigIssues, +} from "./tool-commands.js"; + +function writeConfig(cwd: string, file: string, content: object): void { + writeFileSync(join(cwd, ".interlinked", file), JSON.stringify(content, null, 2), "utf-8"); +} + +describe("tool-commands — loading + trust split", () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "tool-commands-")); + mkdirSync(join(cwd, ".interlinked"), { recursive: true }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it("resolves to nothing when no config exists", () => { + expect(loadToolCommands(cwd)).toEqual({}); + expect(resolveToolCommand(cwd, "go_build", ["go", "build"], ["./..."])).toBeUndefined(); + }); + + it("reads the canonical nested `tool_commands` section (with version)", () => { + writeConfig(cwd, "tool-commands.json", { + version: 1, + tool_commands: { + go_build: { base_args: ["-tags", "dev devaccounts", "./..."] }, + }, + }); + expect(loadToolCommands(cwd).go_build?.base_args).toEqual([ + "-tags", + "dev devaccounts", + "./...", + ]); + }); + + it("tolerates a flat tool map (no top-level `tool_commands` wrapper)", () => { + writeConfig(cwd, "tool-commands.json", { + go_test: { base_args: ["./..."] }, + }); + expect(loadToolCommands(cwd).go_test?.base_args).toEqual(["./..."]); + }); + + it("TEAM tier may set base_args and timeout_ms — command/env are dropped", () => { + writeConfig(cwd, "tool-commands.json", { + go_build: { + base_args: ["-tags", "dev", "./..."], + timeout_ms: 120000, + command: ["curl", "https://evil.example"], + env: { PATH: "/evil" }, + }, + }); + const resolved = toResolvedToolCommand(nonNull(loadToolCommands(cwd).go_build)); + expect(resolved.baseArgs).toEqual(["-tags", "dev", "./..."]); + expect(resolved.timeoutMs).toBe(120_000); + expect(resolved.argv).toBeUndefined(); + expect(resolved.env).toBeUndefined(); + }); + + it("TEAM unknown tool keys are forward-compatible (no error)", () => { + writeConfig(cwd, "tool-commands.json", { future_lang: { base_args: ["--future"] } }); + expect(toolCommandConfigIssues(cwd)).toEqual([]); + expect(loadToolCommands(cwd).future_lang?.base_args).toEqual(["--future"]); + }); + + it("LOCAL tier is trusted: command/env pass through and override team wholesale", () => { + writeConfig(cwd, "tool-commands.json", { + go_build: { base_args: ["-team", "./..."] }, + }); + writeConfig(cwd, "tool-commands.local.json", { + go_build: { command: ["go", "build", "-pers", "./..."] }, + }); + const merged = loadToolCommands(cwd); + const resolved = toResolvedToolCommand(nonNull(merged.go_build)); + expect(resolved.argv).toEqual(["go", "build", "-pers", "./..."]); + expect(resolved.baseArgs).toEqual([]); + }); + + it("malformed JSON degrades to no config, not a crash", () => { + writeFileSync(join(cwd, ".interlinked", "tool-commands.json"), "{ not json", "utf-8"); + expect(loadToolCommands(cwd)).toEqual({}); + }); + + it("non-object entries and non-object config are skipped", () => { + writeConfig(cwd, "tool-commands.json", { go_build: "nope", broken: 7 }); + expect(loadToolCommands(cwd)).toEqual({}); + }); +}); + +describe("tool-commands — argv assembly", () => { + it("no override → prefix + default scope", () => { + expect(buildToolCommandArgv(undefined, ["go", "build"], ["./..."])).toEqual([ + "go", + "build", + "./...", + ]); + }); + + it("base_args REPLACE the default scope (flags precede the package pattern)", () => { + const override = toResolvedToolCommand({ + base_args: ["-tags", "dev", "devaccounts", "./..."], + }); + expect(buildToolCommandArgv(override, ["go", "test"], ["./..."])).toEqual([ + "go", + "test", + "-tags", + "dev", + "devaccounts", + "./...", + ]); + }); + + it("a full command argv wins verbatim", () => { + const override = toResolvedToolCommand({ command: ["/custom/go", "test", "-v"] }); + expect(buildToolCommandArgv(override, ["go", "test"], ["./..."])).toEqual([ + "/custom/go", + "test", + "-v", + ]); + }); + + it("empty base_args falls back to the default scope", () => { + const override = toResolvedToolCommand({}); + expect(buildToolCommandArgv(override, ["go", "build"], ["./..."])).toEqual([ + "go", + "build", + "./...", + ]); + }); + + it("timeout_ms is capped by the hard CLI ceiling", () => { + expect(toResolvedToolCommand({ timeout_ms: 10_000_000 }).timeoutMs).toBe( + HARD_TOOL_TIMEOUT_CAP_MS, + ); + expect(toResolvedToolCommand({ timeout_ms: 5_000 }).timeoutMs).toBe(5_000); + }); +}); + +describe("tool-commands — doctor-facing validation", () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "tool-commands-issues-")); + mkdirSync(join(cwd, ".interlinked"), { recursive: true }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it("reports unknown fields inside a known tool entry", () => { + writeConfig(cwd, "tool-commands.json", { go_build: { base_argz: ["-tags"] } }); + const issues = toolCommandConfigIssues(cwd); + expect(issues.some((i) => i.message.includes("base_argz"))).toBe(true); + }); + + it("reports team-tier command/env as personal-tier-only", () => { + writeConfig(cwd, "tool-commands.json", { + go_build: { base_args: ["./..."], command: ["go", "build", "./..."] }, + }); + const issues = toolCommandConfigIssues(cwd); + expect(issues.some((i) => i.message.includes("personal-tier only"))).toBe(true); + }); + + it("reports type errors for base_args / timeout_ms", () => { + writeConfig(cwd, "tool-commands.local.json", { + go_build: { base_args: "not-an-array", timeout_ms: -1 }, + }); + const issues = toolCommandConfigIssues(cwd); + expect(issues.some((i) => i.message.includes("base_args must be an array"))).toBe(true); + expect(issues.some((i) => i.message.includes("timeout_ms must be a positive number"))).toBe( + true, + ); + }); +}); \ No newline at end of file diff --git a/src/harness/check-engine/tool-commands.ts b/src/harness/check-engine/tool-commands.ts new file mode 100644 index 00000000..d5005d63 --- /dev/null +++ b/src/harness/check-engine/tool-commands.ts @@ -0,0 +1,221 @@ +// =========================================== +// Tool Commands — project-defined argv overrides +// =========================================== +// `.interlinked/tool-commands.json` (team, committed) + +// `.interlinked/tool-commands.local.json` (personal, gitignored) let a +// project pin the EXACT argv Interlinked spawns for its build, lint, and +// test tools — e.g. `go build -tags 'dev devaccounts' ./...` so Interlinked's +// checks share the dev server's Go build cache. +// +// Trust split mirrors guard-rules merge.ts (QUALITY_CHECK_SAFE_FIELDS): +// - TEAM file (`tool-commands.json`) may only set `base_args` for a known +// tool. The executable is the runner's fixed binary, so a malicious PR +// cannot inject an arbitrary command — flags only, same tier as a +// committed Makefile. +// - LOCAL file (`tool-commands.local.json`) is personal and trusted; it may +// set `command` (full argv, arbitrary executable) and `env` as well. +// A personal override wins wholesale over the team entry for the same key. +// +// Validation philosophy: unknown TOOL keys are allowed (forward compat — a +// newer config on an older binary) and reported as "not available on this +// version"; unknown FIELDS inside a known entry are schema errors surfaced by +// `interlinked doctor` via toolCommandConfigIssues(). + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { TOOL_CATALOG } from "./tool-catalog.js"; +import type { ResolvedToolCommand, ToolCommandConfig } from "./types.js"; + +/** Hard per-run cap in ms that project config cannot exceed (mirrors the + * heavy-process lease design). */ +export const HARD_TOOL_TIMEOUT_CAP_MS = 600_000; + +const TEAM_FILE = "tool-commands.json"; +const LOCAL_FILE = "tool-commands.local.json"; + +/** Fields a TOOL entry may carry (any tier). */ +const ALLOWED_FIELDS = new Set(["command", "base_args", "env", "timeout_ms"]); + +/** Fields the TEAM (committed) tier may set — `base_args` (flags for a fixed + * binary) and `timeout_ms` (a bounded cap, never executable — mirrors + * QUALITY_CHECK_SAFE_FIELDS, which allows team timeout_ms). `command` would + * let a malicious PR execute an arbitrary binary on every developer machine, + * and `env` can rewire the runtime; both stay personal-tier. */ +const TEAM_ALLOWED_FIELDS = new Set(["base_args", "timeout_ms"]); + +// =========================================== +// Loading + trust split +// =========================================== + +function readToolCommandsFile(cwd: string, file: string): Record { + const path = join(cwd, ".interlinked", file); + if (!existsSync(path)) return {}; + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + // Canonical shape: `{ "version": 1, "tool_commands": { "": {...} } }`. + // A flat map (`{ "": {...} }`) is tolerated for backwards friendliness. + const section: unknown = + (parsed as Record).tool_commands ?? + parsed; + if (section === null || typeof section !== "object" || Array.isArray(section)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(section)) { + // Entries must be objects; anything else is skipped (reported by + // toolCommandConfigIssues when the file parses at all). + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + out[key] = value as ToolCommandConfig; + } + } + return out; + } catch { + return {}; + } +} + +function allowTeamFields(entry: ToolCommandConfig): ToolCommandConfig { + const out: ToolCommandConfig = {}; + for (const field of TEAM_ALLOWED_FIELDS) { + const value = entry[field as keyof ToolCommandConfig]; + if (value !== undefined) { + (out as Record)[field] = value; + } + } + return out; +} + +/** Merged two-tier view: team entries are field-whitelisted to `base_args`, + * local entries are trusted, and a local entry wins wholesale over team. */ +export function loadToolCommands(cwd: string): Record { + const merged: Record = {}; + for (const [key, entry] of Object.entries(readToolCommandsFile(cwd, TEAM_FILE))) { + merged[key] = allowTeamFields(entry); + } + for (const [key, entry] of Object.entries(readToolCommandsFile(cwd, LOCAL_FILE))) { + merged[key] = entry; + } + return merged; +} + +// =========================================== +// Resolution + argv assembly +// =========================================== + +/** Convert a raw config entry into its resolved form (timeout capped). */ +export function toResolvedToolCommand(entry: ToolCommandConfig): ResolvedToolCommand { + const timeoutMs = + entry.timeout_ms === undefined + ? undefined + : Math.min(Math.max(0, entry.timeout_ms), HARD_TOOL_TIMEOUT_CAP_MS); + const out: ResolvedToolCommand = { + ...(Array.isArray(entry.command) && entry.command.length > 0 + ? { argv: entry.command } + : {}), + baseArgs: Array.isArray(entry.base_args) ? entry.base_args : [], + ...(entry.env && typeof entry.env === "object" && Object.keys(entry.env).length > 0 + ? { env: entry.env } + : {}), + ...(timeoutMs !== undefined && timeoutMs > 0 ? { timeoutMs } : {}), + }; + return out; +} + +/** First config-name alias for a tool id (see TOOL_CATALOG `configNames`). + * Returns undefined for tools with no config-name alias (e.g. lizard). */ +export function configNameForTool(toolId: string): string | undefined { + const row = TOOL_CATALOG.find((entry) => entry.id === toolId); + return row?.configNames?.[0]; +} + +/** + * Assemble the argv a runner should spawn. + * + * Merge rule: a full `command` override wins outright; otherwise the runner's + * FIXED prefix (binary + subcommand, e.g. `go build`) is followed by the + * configured `base_args` — which REPLACE the runner's default scope (e.g. + * `./...`) rather than being appended after it, so projects keep full control + * of ordering (Go tool flags must precede the package pattern). An entry with + * no base_args falls back to the default scope. + */ +export function buildToolCommandArgv( + override: ResolvedToolCommand | undefined, + prefix: readonly string[], + defaultScope: readonly string[], +): string[] { + if (!override) return [...prefix, ...defaultScope]; + if (override.argv) return override.argv; + const scope = override.baseArgs.length > 0 ? override.baseArgs : defaultScope; + return [...prefix, ...scope]; +} + +/** Resolve the override for one config name at a project root (or undefined + * when the tool has no tool-commands entry). */ +export function resolveToolCommand( + cwd: string, + configName: string, + prefix: readonly string[], + defaultScope: readonly string[], +): ResolvedToolCommand | undefined { + const entry = loadToolCommands(cwd)[configName]; + if (!entry) return undefined; + return toResolvedToolCommand(entry); +} + +// =========================================== +// Validation (doctor-facing) +// =========================================== + +export interface ToolCommandsIssue { + file: "team" | "local"; + key: string; + message: string; +} + +/** Validation issues across both tiers for `interlinked doctor`. Unknown + * TOOL keys are forward-compat (not errors); unknown FIELDS and cross-tier + * trust violations are. */ +export function toolCommandConfigIssues(cwd: string): ToolCommandsIssue[] { + const issues: ToolCommandsIssue[] = []; + for (const [tier, file, trusted] of [ + ["team", TEAM_FILE, false], + ["local", LOCAL_FILE, true], + ] as const) { + const entries = readToolCommandsFile(cwd, file); + for (const [key, entry] of Object.entries(entries)) { + for (const field of Object.keys(entry)) { + if (!ALLOWED_FIELDS.has(field)) { + issues.push({ + file: tier, + key, + message: `unknown field "${field}" (allowed: command, base_args, env, timeout_ms)`, + }); + } else if (!trusted && field !== "base_args") { + issues.push({ + file: tier, + key, + message: `"${field}" is personal-tier only — move it to .interlinked/tool-commands.local.json`, + }); + } + } + if (entry.base_args !== undefined && !Array.isArray(entry.base_args)) { + issues.push({ file: tier, key, message: "base_args must be an array of strings" }); + } + if (entry.command !== undefined && !Array.isArray(entry.command)) { + issues.push({ file: tier, key, message: "command must be an array of strings" }); + } + if ( + entry.env !== undefined && + (entry.env === null || typeof entry.env !== "object" || Array.isArray(entry.env)) + ) { + issues.push({ file: tier, key, message: "env must be an object of string values" }); + } + if ( + entry.timeout_ms !== undefined && + (typeof entry.timeout_ms !== "number" || entry.timeout_ms <= 0) + ) { + issues.push({ file: tier, key, message: "timeout_ms must be a positive number" }); + } + } + } + return issues; +} \ No newline at end of file diff --git a/src/harness/check-engine/tool-runners/go-test.integration.test.ts b/src/harness/check-engine/tool-runners/go-test.integration.test.ts new file mode 100644 index 00000000..aa6efe9e --- /dev/null +++ b/src/harness/check-engine/tool-runners/go-test.integration.test.ts @@ -0,0 +1,165 @@ +// Behavioral unit tests for the Go full-suite test runner (runGoTest) and its +// output parser (parseGoTestOutput). The subprocess boundary is mocked at the +// module edge (same pattern as go.integration.test.ts) so tests are +// deterministic and never spawn a real `go`. + +import type { SpawnSyncReturns } from "node:child_process"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { nonNull } from "../../../lib/non-null.js"; +import { parseGoTestOutput } from "../output-parsers.js"; +import type { ResolvedToolCommand } from "../types.js"; +import type { CheckScope, ToolRunnerInput } from "../types.js"; + +const spawnSyncMock = vi.fn(); + +vi.mock("node:child_process", () => ({ + spawnSync: (...args: unknown[]) => spawnSyncMock(...args), +})); + +const { runGoTest } = await import("./go.js"); + +const PROJECT_ROOT = "/work/repo"; + +function projectScope(): CheckScope { + return { projectRoot: PROJECT_ROOT, mode: "project" }; +} + +function runInput(override?: ResolvedToolCommand): ToolRunnerInput { + return { scope: projectScope(), timeoutMs: 30_000, commandOverride: override }; +} + +function spawnResult(partial: Partial>): SpawnSyncReturns { + const base: SpawnSyncReturns = { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + }; + return { ...base, ...partial }; +} + +describe("runGoTest — spawned argv", () => { + beforeEach(() => { + spawnSyncMock.mockReset(); + spawnSyncMock.mockReturnValue(spawnResult({ status: 0 })); + }); + + it("spawns the default `go test ./...`", () => { + runGoTest(runInput()); + const [bin, args] = spawnSyncMock.mock.calls[0] ?? []; + expect(bin).toBe("go"); + expect(args).toEqual(["test", "./..."]); + }); + + it("honors configured base_args (project build tags) in the spawned argv", () => { + runGoTest( + runInput({ + baseArgs: ["-tags", "dev", "devaccounts", "./..."], + }), + ); + const [bin, args] = spawnSyncMock.mock.calls[0] ?? []; + expect(bin).toBe("go"); + expect(args).toEqual(["test", "-tags", "dev", "devaccounts", "./..."]); + }); + + it("uses a full command override verbatim", () => { + runGoTest(runInput({ baseArgs: [], argv: ["/custom/go", "test", "-v", "./..."] })); + const [bin, args] = spawnSyncMock.mock.calls[0] ?? []; + expect(bin).toBe("/custom/go"); + expect(args).toEqual(["test", "-v", "./..."]); + }); + + it("exit 0 is a clean run", () => { + spawnSyncMock.mockReturnValue(spawnResult({ status: 0, stdout: "ok package 0.1s\n" })); + expect(runGoTest(runInput())).toEqual([]); + }); + + it("applies the configured timeout cap", () => { + runGoTest(runInput({ baseArgs: [], timeoutMs: 300_000 })); + const options = spawnSyncMock.mock.calls[0]?.[2]; + expect(options?.timeout).toBe(300_000); + }); + + it("missing binary (ENOENT) returns no-verdict [] like go-build", () => { + spawnSyncMock.mockReturnValue( + spawnResult({ status: null, error: Object.assign(new Error("x"), { code: "ENOENT" }) }), + ); + expect(runGoTest(runInput())).toEqual([]); + }); +}); + +describe("runGoTest — red-suite findings", () => { + beforeEach(() => spawnSyncMock.mockReset()); + + it("maps a failing test into a per-unit finding", () => { + spawnSyncMock.mockReturnValue( + spawnResult({ + status: 1, + stdout: "--- FAIL: TestTombstone (0.01s)\nFAIL\nFAIL\tgithub.com/x/internal/history\t0.014s\n", + }), + ); + const findings = runGoTest(runInput()); + expect(findings.some((f) => f.message.includes("FAIL: TestTombstone"))).toBe(true); + expect(findings.some((f) => f.message.includes("package github.com/x/internal/history failed"))).toBe( + true, + ); + expect(findings.every((f) => f.tool === "go-test")).toBe(true); + }); + + it("a non-zero exit with unparseable output still yields a [proven] verdict", () => { + spawnSyncMock.mockReturnValue( + spawnResult({ status: 2, stderr: "go: unknown flag --nope\n" }), + ); + const findings = runGoTest(runInput()); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("go test failed (exit 2)"); + }); + + it("a timeout produces an explicit no-verdict warning, never clean", () => { + spawnSyncMock.mockReturnValue( + spawnResult({ status: null, error: Object.assign(new Error("x"), { code: "ETIMEDOUT" }) }), + ); + const findings = runGoTest(runInput()); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("timed out"); + }); +}); + +describe("parseGoTestOutput", () => { + it("parses FAIL test headers and FAIL package trailers", () => { + const out = [ + "--- FAIL: TestA (0.01s)", + " a_test.go:12: got 1 want 2", + "--- FAIL: TestB (0.00s)", + "FAIL", + "FAIL\tgithub.com/x/pkg\t0.020s", + "FAIL", + ].join("\n"); + const findings = parseGoTestOutput(out, 1); + expect(findings.map((f) => f.message)).toEqual([ + "FAIL: TestA (0.01s)", + "FAIL: TestB (0.00s)", + "package github.com/x/pkg failed", + ]); + expect(nonNull(findings.find((f) => f.message.includes("github.com/x/pkg"))).file).toBe( + "github.com/x/pkg", + ); + }); + + it("captures panic lines", () => { + const findings = parseGoTestOutput("panic: runtime error: index out of range", 2); + expect(findings.some((f) => f.message.startsWith("panic:"))).toBe(true); + }); + + it("falls back to a generic whole-run finding with a stderr tail", () => { + const findings = parseGoTestOutput("fatal: no test files", 1); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("fatal: no test files"); + }); + + it("empty output with non-zero status still yields a finding (never clean)", () => { + expect(parseGoTestOutput("", 1)).toHaveLength(1); + }); +}); \ No newline at end of file diff --git a/src/harness/check-engine/tool-runners/go.ts b/src/harness/check-engine/tool-runners/go.ts index 0920c5e3..1bd60fc6 100644 --- a/src/harness/check-engine/tool-runners/go.ts +++ b/src/harness/check-engine/tool-runners/go.ts @@ -1,34 +1,74 @@ // =========================================== -// Tool Runners — Go (go build, golangci-lint) +// Tool Runners — Go (go build, golangci-lint, go test) // =========================================== import { spawnSync } from "node:child_process"; +import { runProcessAsync } from "../spawn-async.js"; +import { buildToolCommandArgv } from "../tool-commands.js"; import { filterResultsToFile, parseGoBuildOutput, + parseGoTestOutput, parseGolangciLintJson, } from "../output-parsers.js"; -import type { CheckResult, ToolRunnerInput } from "../types.js"; +import type { CheckResult, ToolRunnerInput, ResolvedToolCommand } from "../types.js"; + +// The FIXED prefixes below pair with tool_commands base_args: the project +// config replaces the default scope ("./...") with its own flags/scope, e.g. +// go build → `go build -tags 'dev devaccounts' ./...`. base_args REPLACE the +// scope rather than append after it so flag ordering stays correct (Go flags +// must precede the package pattern). +const GO_BUILD_PREFIX = ["go", "build"] as const; +const GOLANGCI_PREFIX = ["golangci-lint", "run", "--out-format=json"] as const; +const GO_TEST_PREFIX = ["go", "test"] as const; +const DOT_SLASH = ["./..."] as const; + +function effectiveTimeout(override: ResolvedToolCommand | undefined, base: number): number { + return override?.timeoutMs ?? base; +} + +/** timeout/killed/ENOENT all resolve to no-verdict, never clean. */ +function goTimeoutFinding( + tool: "go-build" | "go-test", + reason: string, +): CheckResult[] { + return [ + { + tool, + severity: "warning", + file: "", + line: 0, + message: `${tool} did not produce a verdict: ${reason}`, + }, + ]; +} // ------------------------------------------- // go build // ------------------------------------------- export function runGoBuild(input: ToolRunnerInput): CheckResult[] { - const { scope, timeoutMs } = input; + const { scope } = input; + const argv = buildToolCommandArgv(input.commandOverride, GO_BUILD_PREFIX, DOT_SLASH); + const bin = argv[0]; + if (bin === undefined) return []; + const args = argv.slice(1); try { - // go build is always project-wide (./...) - const result = spawnSync("go", ["build", "./..."], { + const result = spawnSync(bin, args, { cwd: scope.projectRoot, - timeout: timeoutMs, + timeout: effectiveTimeout(input.commandOverride, input.timeoutMs), encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], + ...(input.commandOverride?.env ? { env: { ...process.env, ...input.commandOverride.env } } : {}), }); if (result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT") { return []; } + if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") { + return goTimeoutFinding("go-build", "timed out"); + } if (result.status === 0) return []; // go build errors go to stderr @@ -44,20 +84,48 @@ export function runGoBuild(input: ToolRunnerInput): CheckResult[] { } } +export async function runGoBuildAsync(input: ToolRunnerInput): Promise { + const { scope } = input; + const argv = buildToolCommandArgv(input.commandOverride, GO_BUILD_PREFIX, DOT_SLASH); + const bin = argv[0]; + if (bin === undefined) return []; + const args = argv.slice(1); + + const result = await runProcessAsync(bin, args, { + cwd: scope.projectRoot, + timeout: effectiveTimeout(input.commandOverride, input.timeoutMs), + ...(input.commandOverride?.env ? { env: input.commandOverride.env } : {}), + }); + + if (result.code === null || result.timedOut || result.killed) return []; + if (result.code === 0) return []; + + const output = (result.stderr || "") + (result.stdout || ""); + const results = parseGoBuildOutput(output); + if (scope.mode === "file" && scope.targetFile && scope.filterToFile) { + return filterResultsToFile(results, scope.targetFile); + } + return results; +} + // ------------------------------------------- // golangci-lint // ------------------------------------------- export function runGolangciLint(input: ToolRunnerInput): CheckResult[] { - const { scope, timeoutMs } = input; + const { scope } = input; + const argv = buildToolCommandArgv(input.commandOverride, GOLANGCI_PREFIX, DOT_SLASH); + const bin = argv[0]; + if (bin === undefined) return []; + const args = argv.slice(1); try { - // golangci-lint always runs project-wide; filter for file mode - const result = spawnSync("golangci-lint", ["run", "--out-format=json", "./..."], { + const result = spawnSync(bin, args, { cwd: scope.projectRoot, - timeout: timeoutMs, + timeout: effectiveTimeout(input.commandOverride, input.timeoutMs), encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], + ...(input.commandOverride?.env ? { env: { ...process.env, ...input.commandOverride.env } } : {}), }); if (result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT") { @@ -79,3 +147,92 @@ export function runGolangciLint(input: ToolRunnerInput): CheckResult[] { return []; } } + +export async function runGolangciLintAsync(input: ToolRunnerInput): Promise { + const { scope } = input; + const argv = buildToolCommandArgv(input.commandOverride, GOLANGCI_PREFIX, DOT_SLASH); + const bin = argv[0]; + if (bin === undefined) return []; + const args = argv.slice(1); + + const result = await runProcessAsync(bin, args, { + cwd: scope.projectRoot, + timeout: effectiveTimeout(input.commandOverride, input.timeoutMs), + ...(input.commandOverride?.env ? { env: input.commandOverride.env } : {}), + }); + + if (result.code === null || result.timedOut || result.killed) return []; + if (result.code === 0 || result.code === 3 || result.code === 4) return []; + + const output = (result.stdout || "").trim(); + if (!output) return []; + const results = parseGolangciLintJson(output); + if (scope.mode === "file" && scope.targetFile && scope.filterToFile) { + return filterResultsToFile(results, scope.targetFile); + } + return results; +} + +// ------------------------------------------- +// go test (full suite) +// ------------------------------------------- + +export function runGoTest(input: ToolRunnerInput): CheckResult[] { + const { scope } = input; + const argv = buildToolCommandArgv(input.commandOverride, GO_TEST_PREFIX, DOT_SLASH); + const bin = argv[0]; + if (bin === undefined) return []; + const args = argv.slice(1); + + try { + const result = spawnSync(bin, args, { + cwd: scope.projectRoot, + timeout: effectiveTimeout(input.commandOverride, input.timeoutMs), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + ...(input.commandOverride?.env ? { env: { ...process.env, ...input.commandOverride.env } } : {}), + }); + + if (result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") { + return goTimeoutFinding("go-test", "timed out"); + } + if (result.status === 0) return []; + + const output = combinedGoOutput(result.stdout || "", result.stderr || ""); + return parseGoTestOutput(output, result.status ?? -1); + } catch { + return []; + } +} + +export async function runGoTestAsync(input: ToolRunnerInput): Promise { + const { scope } = input; + const argv = buildToolCommandArgv(input.commandOverride, GO_TEST_PREFIX, DOT_SLASH); + const bin = argv[0]; + if (bin === undefined) return []; + const args = argv.slice(1); + + const result = await runProcessAsync(bin, args, { + cwd: scope.projectRoot, + timeout: effectiveTimeout(input.commandOverride, input.timeoutMs), + ...(input.commandOverride?.env ? { env: input.commandOverride.env } : {}), + }); + + if (result.code === null || result.killed) return []; + if (result.timedOut) return goTimeoutFinding("go-test", "timed out"); + if (result.code === 0) return []; + + const output = combinedGoOutput(result.stdout, result.stderr); + return parseGoTestOutput(output, result.code ?? -1); +} + +// ------------------------------------------- +// Go test output helpers +// ------------------------------------------- + +function combinedGoOutput(stdout: string, stderr: string): string { + return stdout.trim() ? `${stdout}${stderr.trim() ? `\n${stderr}` : ""}` : stderr; +} \ No newline at end of file diff --git a/src/harness/check-engine/types.ts b/src/harness/check-engine/types.ts index c592ba06..5e65fa9a 100644 --- a/src/harness/check-engine/types.ts +++ b/src/harness/check-engine/types.ts @@ -18,6 +18,7 @@ export type ToolId = | "rustfmt" | "go-build" | "golangci-lint" + | "go-test" | "c-compile" | "clang-tidy" | "oxlint" @@ -109,6 +110,35 @@ export interface AuditResult { export interface ToolRunnerInput { scope: CheckScope; timeoutMs: number; + /** Resolved `.interlinked/tool-commands` override for this tool (see + * check-engine/tool-commands.ts). Runners merge it with their fixed + * prefix when present; when a full `argv` is supplied the runner uses it + * verbatim (the caller owns the command). */ + commandOverride?: ResolvedToolCommand | undefined; +} + +/** Resolved per-tool command override. Produced by `resolveToolCommand` in + * check-engine/tool-commands.ts from the two-tier tool-commands config. */ +export interface ResolvedToolCommand { + /** Full argv override — the runner performs no merge (project owns argv). */ + argv?: string[] | undefined; + /** base_args appended after the runner's fixed prefix, replacing its + * default scope. Empty when no base_args were configured. */ + baseArgs: string[]; + /** Extra/overriding env vars for the spawned process. */ + env?: Record | undefined; + /** Per-run cap in ms, already bounded by the hard CLI ceiling. */ + timeoutMs?: number | undefined; +} + +/** Raw per-tool entry from `.interlinked/tool-commands.json` / + * `.interlinked/tool-commands.local.json`. Field names stay snake_case to + * match the rest of the two-tier config vocabulary. */ +export interface ToolCommandConfig { + command?: string[] | undefined; + base_args?: string[] | undefined; + env?: Record | undefined; + timeout_ms?: number | undefined; } /** A tool runner function: spawn tool, parse output, return results. diff --git a/src/harness/quality-checks/test-dispatchers.ts b/src/harness/quality-checks/test-dispatchers.ts index bc471a25..d6b40d9b 100644 --- a/src/harness/quality-checks/test-dispatchers.ts +++ b/src/harness/quality-checks/test-dispatchers.ts @@ -12,6 +12,7 @@ import { existsSync } from "node:fs"; import { dirname, extname, relative, resolve, sep } from "node:path"; import { nonNull } from "../../lib/non-null.js"; import type { LanguageId, LanguageProfile } from "../types.js"; +import type { ResolvedToolCommand } from "../check-engine/types.js"; import { findDirectImporters } from "./direct-importers.js"; import { buildTestCandidates, classifyTestFailure } from "./test-classifier.js"; import { runBoundedTestProcess } from "./test-process-gate.js"; @@ -49,6 +50,11 @@ export interface TestDispatcherInput { /** `affected_tests` only: cap on direct-importer companion test files * (see {@link DEFAULT_MAX_DEPENDENT_TESTS}). Absent → the default. */ maxDependentTests?: number; + /** `affected_tests` only, Go: resolved `go_test` command override from + * `.interlinked/tool-commands*.json`. A full `command` argv is used + * verbatim; otherwise configured `base_args` carry the project's flags + * (e.g. build tags) into the touched-package run. */ + commandOverride?: ResolvedToolCommand | undefined; } export interface TestDispatcherResult { @@ -389,11 +395,22 @@ async function runGoTestDispatcher(input: TestDispatcherInput): Promise`. */ +function scopedGoTestArgs(baseArgs: string[], pkgArg: string): string[] { + const cleaned = baseArgs.filter((a) => a !== "./..."); + return [...cleaned, "-count=1", pkgArg]; +} + function findFirstExistingCandidate( absPath: string, profile: LanguageProfile, diff --git a/src/harness/quality-checks/tool-check-loop.ts b/src/harness/quality-checks/tool-check-loop.ts index dbd9a044..250bec7e 100644 --- a/src/harness/quality-checks/tool-check-loop.ts +++ b/src/harness/quality-checks/tool-check-loop.ts @@ -43,6 +43,7 @@ import { runSoftwareVersionChecks, } from "./tool-check-loop-manifest-checks.js"; import { deferredExternalCheck, runCommandCheck } from "./tool-command-check.js"; +import { resolveToolCommand } from "../check-engine/tool-commands.js"; /** * Yield the Node event loop so other socket connections in the daemon can @@ -313,6 +314,9 @@ async function runAffectedTests( if (!dispatcher) return null; const checkCwd = findProjectRoot(ctx.filePath, ctx.cwd) || ctx.cwd; + // Resolve the project's `go_test` command override so the touched-package + // run carries the configured tags/flags from .interlinked/tool-commands*.json. + const goTestOverride = resolveToolCommand(checkCwd, "go_test", ["go", "test"], ["./..."]); const dispatched = await dispatcher({ filePath: ctx.filePath, absPath, @@ -324,6 +328,7 @@ async function runAffectedTests( ...(check.max_dependent_tests !== undefined ? { maxDependentTests: check.max_dependent_tests } : {}), + ...(goTestOverride ? { commandOverride: goTestOverride } : {}), }); return dispatched.map((r) => ({ name: r.name, From 2d190e25b9c889367a7280dbb8a53c7d63fdf363 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:32:55 +0000 Subject: [PATCH 2/3] docs(skills): document tool_commands config and go-test in verify/setup/router Skill-impact review for the custom build/test command feature: the verify skill gains a 'Custom build/test command overrides' section (config shape, trust split, no-shell argv, opt-in go-test), setup documents the two new config files, and the router routes 'tool_commands' to interlinked-verify. --- skills/interlinked-setup/SKILL.md | 8 ++++++++ skills/interlinked-verify/SKILL.md | 32 ++++++++++++++++++++++++++++++ skills/interlinked/SKILL.md | 2 +- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/skills/interlinked-setup/SKILL.md b/skills/interlinked-setup/SKILL.md index 212c9ada..1f5cad87 100644 --- a/skills/interlinked-setup/SKILL.md +++ b/skills/interlinked-setup/SKILL.md @@ -399,6 +399,14 @@ hand. model reference, and source/test include policy. - **`.interlinked/semantic.local.json`** (gitignored): local-only CPU/runtime topology. Remote URLs, API tokens, and cloud fallbacks are rejected by the v1 schema. +- **`.interlinked/tool-commands.json`** (committed): per-tool argv overrides for build/lint/test + runners (`go_build`, `go_test`, `golangci_lint`, …). Team tier may set `base_args` + + `timeout_ms` only (flags for a fixed binary / a bounded cap); `command` (arbitrary executable) + and `env` are personal-tier only — a violation is a `doctor` error. +- **`.interlinked/tool-commands.local.json`** (gitignored): trusted personal tier; may add a full + `command` override or `env` wholesale, winning over the team entry for the same key. No shell + interpolation — argv must be written as the executed argument list (`-tags 'dev devaccounts'` is + one token `["-tags","dev devaccounts"]`). - **Env overrides** (win over both files): `INTERLINKED_SERVER_URL`, `INTERLINKED_ACCESS_TOKEN` (alias `INTERLINKED_TOKEN`), `INTERLINKED_AGENT_NAME`, `INTERLINKED_WORKSPACE_ID`, `INTERLINKED_SYNC_MODE`, `INTERLINKED_HOME` (relocates the whole diff --git a/skills/interlinked-verify/SKILL.md b/skills/interlinked-verify/SKILL.md index f53c6952..556b7165 100644 --- a/skills/interlinked-verify/SKILL.md +++ b/skills/interlinked-verify/SKILL.md @@ -49,6 +49,38 @@ dep-audit (+ language tools as available) **plus** the FP-safe inline checks. `- adds the advisory tier (complexity, taste/smell, DRY clones, most `ubs_*`, test heuristics) — a **review tool, expect noise, not a gate**. +## Custom build/test command overrides (`tool_commands`) + +A project can pin the **exact argv** Interlinked spawns for its build/lint/test tools in a +dedicated two-tier config — `.interlinked/tool-commands.json` (committed, team) + +`.interlinked/tool-commands.local.json` (gitignored, personal). This is how a Go project makes +`check --only go-build`/`--only go-test` and PostToolUse run the same flags as its dev server +(e.g. `-tags 'dev devaccounts'`, sharing air's Go build cache): + +```jsonc +// .interlinked/tool-commands.json +{ + "version": 1, + "tool_commands": { + "go_build": { "base_args": ["-tags", "dev devaccounts", "./..."], "timeout_ms": 300000 }, + "go_test": { "base_args": ["-tags", "dev devaccounts", "./..."], "timeout_ms": 300000 } + } +} +``` + +- Keyed by **check/config names** (`go_build`, `go_test`, `golangci_lint`, …); a full `command` + array wins over `base_args`, which REPLACE the runner's default scope (`./...`). + **No shell interpolation** — `-tags 'dev devaccounts'` is ONE argv token (`["-tags","dev devaccounts"]`), + not two (`["-tags","dev","devaccounts"]` treats `devaccounts` as a package pattern). +- **Trust split** mirrors `merge.ts`: the committed team tier may set `base_args`/`timeout_ms` + (flags for a fixed binary; bounded cap), while `command`/`env` are personal-tier only + (arbitrary executable / runtime rewiring) — `interlinked doctor` reports violations. +- `go-test` is a project-wide catalog tool that runs the full suite (`check --only go-test`, + `--tools go-test`, `verify --only go-test`). It is **opt-in**: unfiltered runs skip it until + `go_test` is configured (or it is explicitly requested); `check --report` lists it either way. +- `affected_tests` PostToolUse carries `go_test` tags into the touched-package run + (`go test -count=1 ./`, no full-suite `./...`). + > **`interlinked verify` exits 0 even with findings.** It is a *reporting* tool, not a > pass/fail gate — do not `&&`-chain on its exit status. To gate programmatically, parse > `--json`, or use `interlinked write` / `verify-changeset` (which **do** exit nonzero on diff --git a/skills/interlinked/SKILL.md b/skills/interlinked/SKILL.md index 5586f670..0bbe6250 100644 --- a/skills/interlinked/SKILL.md +++ b/skills/interlinked/SKILL.md @@ -77,7 +77,7 @@ A **block reason is always surfaced.** Allow-time warnings are surfaced but easy |---|---| | Installing / enabling Interlinked, connecting a coding client/hook, daemon down or **zombie**, `doctor` fails, config/mode | **interlinked-setup** | | A Bash command or edit was **BLOCKED**; a sandbox/effect-residue warning; a `[interlinked:*]` warning; suppressions | **interlinked-harness** | -| Running `interlinked verify`; a `pre_block` check blocked an edit; landing a cross-file refactor; scratch scripts | **interlinked-verify** | +| Running `interlinked verify`; a `pre_block` check blocked an edit; landing a cross-file refactor; scratch scripts; **configuring custom build/test command overrides (`tool_commands`)** | **interlinked-verify** | | Blocked by a **line-cap / function-token / coverage / complexity / CRAP / mutation** ratchet; configuring report, per-edit, or durable `mutation cloud` work; operating the mutation journal; "can't lower a baseline"; `adopt`; automatic obligation or manual marker debt; **dead code** (`deadcode` scan + `--categorize` deletion-safety buckets, per-edit `dead_code_action`) | **interlinked-quality-gates** | | Finding, reviewing, recording, or auditing opportunities to delete, replace, defer, or shrink code; `simplify …`; simplification coverage/evidence/deep handoff | **interlinked-simplification** | | Installing a local embedding model; building, inspecting, searching, or repairing the optional function-vector index | **interlinked-semantic-index** | From 7f10290351bf30a9f1648e62100f6fab8f8af8ce Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:49:06 +0000 Subject: [PATCH 3/3] test: pin go-test in the check-command available-checks list The __tests__/check.test.ts pin (sibling of commands/check.test.ts) was missed by the go-test tool-id wiring; update its exact unknown-check message so the drift guard covers both check test files. --- src/commands/__tests__/check.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/__tests__/check.test.ts b/src/commands/__tests__/check.test.ts index 9619ba3c..450267da 100644 --- a/src/commands/__tests__/check.test.ts +++ b/src/commands/__tests__/check.test.ts @@ -527,7 +527,7 @@ describe("checkCommand — --only rejection paths", () => { const stderr = captureStderr(); await checkCommand({ cwd: "/proj", only: "totally-fake-check" }); expect(stderr.get()).toBe( - 'Unknown check: "totally-fake-check". Available: broken-imports, cycles, duplicates, missing-tests, secrets, any-types, blast-radius, dead-imports, tsc, biome, eslint, oxlint, knip, semgrep, gitleaks, dep-audit, mypy, ruff, ruff-format, cargo-check, cargo-clippy, rustfmt, go-build, golangci-lint, c-compile, clang-tidy, shellcheck, actionlint, hadolint, taplo, swiftlint, swift-build, lizard, docs-check\n', + 'Unknown check: "totally-fake-check". Available: broken-imports, cycles, duplicates, missing-tests, secrets, any-types, blast-radius, dead-imports, tsc, biome, eslint, oxlint, knip, semgrep, gitleaks, dep-audit, mypy, ruff, ruff-format, cargo-check, cargo-clippy, rustfmt, go-build, golangci-lint, go-test, c-compile, clang-tidy, shellcheck, actionlint, hadolint, taplo, swiftlint, swift-build, lizard, docs-check\n', ); expect(process.exitCode).toBe(1); });