import { type RunTrackedResult } from "../tooling/exec.js"; import type { AnalyzerSetting } from "../analyzerPolicy.js"; import { type ExternalAnalyzerParseOutcome, type ExternalAnalyzerResults, type ExternalAnalyzerToolStatus } from "./types.js"; import { type BinarySpec, type BinaryResolveOptions } from "./binaryAcquisition.js"; /** * External analyzer acquisition engine. * * On-demand acquisition + ephemeral execution of mature, ecosystem-native * analyzers (eslint, ruff, cargo-clippy, …). Distinct from the in-tree * `LanguageAnalyzer` registry (which enriches the regex floor from a resolved * npm package directory): this engine *runs an external tool as a subprocess* and * normalizes its native output through the EXISTING adapter seam * (`normalizeExternal.ts`) into {@link ExternalAnalyzerResults}, which then * re-enters the graph/risk artifacts through the shared * CCU-analyzer-merge-helper-seam pair. * * Design invariants (all enforced here, never by host discretion): * - **No baked-in language→tool / OS→runner table.** Ecosystem is detected * deterministically from repo marker files; the runner (npx/pipx/cargo/bundle) * is capability-probed at RUNTIME (`--version`) before any tool spawn. * - **Single subprocess-SPAWN admission chokepoint** (`admitSpawn`): EVERY * non-DEFAULT candidate is gated on the per-run consent token regardless of * its `AnalyzerSetting` (auto|ephemeral|permanent) — even a `permanent`, * pre-installed tool cannot spawn without consent (CE-005). The small * value-curated DEFAULT set runs without prompting. * - **Own-vs-acquire boundary.** git-history is OWNED in-house and is rejected at * registration — never acquired here. Secret scanning is ACQUIRED (gitleaks). * - **Run-safety gate written once** (`runSafetyGate`): capability-probe, pin * version, read-only/sandboxed argv, degrade-to-empty. * - **Degrade-to-empty + report-skipped-never-silently.** Every candidate * yields exactly one {@link ExternalAnalyzerToolStatus}; a failure never * throws and never silently drops the candidate. */ /** * Tool ids OWNED in-house and never acquired. Only git-history mining is OWNED — it * is a truly-agnostic signal with no ecosystem tool. Secret scanning is ACQUIRED * (gitleaks), not owned, so it is deliberately NOT listed here. */ export declare const OWNED_TOOL_IDS: Set; /** * Ecosystem runners this engine knows how to drive. `npx`/`pipx`/`cargo`/`bundle` acquire * + run a pinned package ephemerally; `binary` runs a standalone release binary * resolved (and downloaded-if-absent) by the binary-acquisition seam. */ export type EcosystemRunner = "npx" | "pipx" | "cargo" | "bundle" | "binary"; /** * Safety properties of an external analyzer. `defaultRun` eligibility derives * from these: `config_execution !== "executable" && !network_egress && version_pinning === "pinned"`. */ export interface AnalyzerSafetyProfile { /** Whether the tool might execute repo code during config loading. */ config_execution: "none" | "inert-data" | "executable"; /** Whether the tool might make network requests outside the local repo. */ network_egress: boolean; /** How strictly the tool version is pinned. */ version_pinning: "pinned" | "toolchain-resolved" | "unpinned"; } /** * One acquirable external analyzer. `defaultRun: true` marks a member of the * small value-curated set that may run without a consent token; everything else * is gated on the per-run consent token at the spawn chokepoint. */ export interface ExternalAnalyzerCandidate { /** Stable id; also the `analyzers.` session-config key. */ id: string; /** Runner that acquires + executes the tool ephemerally. */ runner: EcosystemRunner; /** Pinned tool spec, e.g. "eslint@9" / "ruff==0.5.0" (pinned for reproducibility). */ spec: string; /** Safety profile indicating whether this tool is safe to run by default. */ safetyProfile: AnalyzerSafetyProfile; /** * One human line: what this analyzer detects. Rendered into the Item B * consent offer so the operator decides from the tool's own words, never a * host improvisation. Optional only for test fixtures — every registered * candidate carries one (pinned by the candidates safety contract test). */ purpose?: string; /** * Build the read-only argv for the tool given the resolved runner argv prefix * and repo root. MUST NOT request fixes/writes — acquisition is observe-only. */ buildArgv(runnerPrefix: string[], root: string): string[]; /** * Parse the tool's stdout into the generic item shape consumed by * `normalizeGenericExternalResults`. NEVER throws — but a degradation must be * REPORTED, not swallowed: return the {@link ExternalAnalyzerParseReport} form * carrying `parse_failed` / `dropped_rows` so the engine can classify the run, * because a bare `[]` is byte-identical to a genuinely clean scan. */ parse(stdout: string): ExternalAnalyzerParseOutcome; /** * For tools that ONLY report to a file (e.g. gitleaks) rather than stdout: the * report path the tool was told to write (must match `buildArgv`). When set, the * engine reads this file (degrade-to-"" if absent) and passes its contents to * `parse` instead of stdout, then best-effort removes it. Omit for stdout tools. */ reportFile?(root: string): string; /** Whether this ecosystem is present (marker file detection). */ detect(root: string): boolean; /** Member of the value-curated DEFAULT set (runs without prompting). */ defaultRun: boolean; /** * Acquisition spec for a `runner: "binary"` candidate — how to probe PATH and, * if absent, download + SHA256-verify the pinned release binary. Resolved ahead * of the (synchronous) engine by `resolveBinaryCandidates`; ignored for the * package-manager runners. */ binary?: BinarySpec; } export declare function detectNodeEcosystem(root: string): boolean; export declare function detectPythonEcosystem(root: string): boolean; export declare function detectRustEcosystem(root: string): boolean; export declare function detectRubyEcosystem(root: string): boolean; export declare function detectDockerEcosystem(root: string): boolean; /** * GitHub Actions workflows live under `.github/workflows/`. actionlint only makes * sense when that directory exists, so its presence is the ecosystem marker. */ export declare function detectGithubActionsEcosystem(root: string): boolean; /** * The async command-runner seam. Analyzer spawns run on * {@link runTrackedAsync} (the shared exec boundary's async twin) so a stalled * child cannot block the event loop and starve the liveness/file-lock * heartbeats; tests inject an async fake at this seam. */ export interface AcquisitionRunner { (argv: string[], cwd: string): Promise; } /** A recorded, durable operator decision for one analyzer. */ export type AnalyzerConsentDecision = "granted" | "declined"; /** The recorded decisions, keyed by candidate id, as loaded from the durable policy. */ export type AnalyzerConsentDecisions = Record; /** * A consent token SCOPED to the analyzers the operator was actually offered. This is * THE form a caller issues: the grant names its tools, so a grant obtained by * offering one analyzer cannot silently admit an analyzer the operator never saw. * The former unscoped bare-string form is retired — a run-wide token is expressed * as a grant naming every candidate the run offered. */ export interface AnalyzerConsentTokenGrant { /** Opaque per-run value; never persisted (pinned by the strict AnalyzerPolicy schema). */ readonly value: string; /** The candidate ids this grant authorizes, and only those. */ readonly tools: readonly string[]; } export interface AcquisitionEngineOptions { /** * Per-run, tool-SCOPED consent grant. REQUIRED to spawn any non-DEFAULT candidate * (and any candidate whose setting is ephemeral/permanent) that has no recorded * "granted" decision. Absent ⇒ only the DEFAULT set runs; everything else is * reported `skipped` with a consent note. A grant admits ONLY the candidates it * names — and NEVER overrides a recorded "declined". */ consentToken?: AnalyzerConsentTokenGrant; /** Per-analyzer settings (auto|ephemeral|permanent|skip|repo). */ analyzers?: Record; /** * CALLER OBLIGATION (declared here, not left to prose): the durable analyzer * policy is loaded and BOTH `analyzers` and `analyzerConsent` are passed on EVERY * acquisition call; an unreadable policy blocks the step rather than degrading to * an empty one; and a consent token is never synthesized on the operator's behalf. * Omitting this map leaves a recorded decline UNREPRESENTABLE at the chokepoint — * the decision cannot be enforced because it never arrives. */ analyzerConsent?: AnalyzerConsentDecisions; /** Injectable command runner; defaults to the shared runTracked. */ run?: AcquisitionRunner; /** Injectable logger; defaults to a no-op (degrade quietly to status records). */ log?: (...args: unknown[]) => void; /** * Resolved executable path per `runner: "binary"` candidate id, produced by * `resolveBinaryCandidates`. A binary candidate with no entry here is reported * `not_resolved` (acquisition failed / was skipped) and never spawned. */ resolvedBinaries?: Record; } /** * The denial vocabulary. An operator refusal and a tool nobody has decided on are * DIFFERENT causes and must be distinguishable in the persisted status record — a * single conflated string cannot tell an operator that their own decline was the * reason. Every CONSENT-channel reason names "consent"; `setting_skip` deliberately * does not, because it comes from the settings channel (`analyzers. = "skip"`) * and is not a consent decision at all. */ export declare const ANALYZER_DENIAL_REASONS: { /** `analyzers. = "skip"` — the settings channel, not the consent record. */ readonly setting_skip: "setting=skip"; /** A recorded, durable operator refusal. Terminal: no token overrides it. */ readonly consent_declined: "consent declined by the operator for this analyzer (recorded decision)"; /** No decision recorded and no token presented — nobody has said yes OR no. */ readonly consent_not_decided: "consent not recorded for this analyzer (not yet decided; no consent token for this run)"; /** A token was presented, but it was issued for a different analyzer. */ readonly consent_token_scope: "consent token was not issued for this analyzer (scoped consent covers other tools only)"; }; /** * Single subprocess-SPAWN admission chokepoint. Returns the reason a spawn is * NOT admitted, or `undefined` when admitted. * * ORDER IS THE CONTRACT. A recorded `"declined"` is consulted FIRST — before the * settings channel, before the DEFAULT-set short-circuit, and before any consent * token — so an operator refusal is a veto for EVERY candidate, including a * default-set member, and no token can override it. It also outranks * `setting=skip`: both refuse, but only one of them is the operator's own decision, * and the REASON is what tells them so. Only then: `skip`, then the DEFAULT set * runs unprompted, a recorded `"granted"` admits, and finally a consent token * admits the candidate it was issued for. */ export declare function admitSpawn(candidate: ExternalAnalyzerCandidate, setting: AnalyzerSetting, consentToken: AnalyzerConsentTokenGrant | undefined, recordedDecision?: AnalyzerConsentDecision): string | undefined; /** * Local (non-analyzer) spawn admission for the deterministic executors that run * repo tooling in place — formatters, syntax resolvers. The decline-first rule * is ONE rule regardless of WHAT spawns: a recorded operator `declined` for a * tool id vetoes every spawn of that id, exactly as {@link admitSpawn} does for * acquired analyzers, and nothing overrides it. Everything else about a local * command (resolution order, fallbacks) stays its executor's own business; this * gate adds only the veto, so "the operator said no to this tool" can never * resolve into a spawn. * * Deliberately narrower than {@link admitSpawn}: a formatter is not an acquired * analyzer — there is no per-run consent offer and no DEFAULT-set notion, only * the operator's own refusal. The FULL argv is passed, not just `command`, so * the reduction sees a `process.execPath` invocation's script * (`node …/prettier.cjs` is `prettier`, never `node` — keying the executable * alone would let a recorded decline of `prettier` sail past the repo-local * `node_modules` arm). An arm whose real tool sits deeper in the argv than the * script (`python -m black`, `uvx black`, `pipx run black`) cannot be derived * — pass its id as `declaredToolId`; a declared id always wins over derivation. */ export declare function admitLocalSpawn(argv: readonly string[], recordedDecisions: AnalyzerConsentDecisions | undefined, declaredToolId?: string): string | undefined; /** * Reduce a local spawn's ARGV to its registry key: the executable's basename * with any launcher suffix stripped. `C:\repo\node_modules\.bin\prettier.cmd`, * `/usr/bin/prettier`, and `prettier` all reduce to `prettier`. For a * `process.execPath` invocation (`node