From bb8758127a53a31608a5de9bdd4577d4b48fe829 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Sun, 12 Jul 2026 22:28:31 +0000 Subject: [PATCH] feat(miner): add repo stack auto-detection (#4785) --- packages/gittensory-miner/README.md | 8 + .../gittensory-miner/lib/stack-detection.d.ts | 41 +++ .../gittensory-miner/lib/stack-detection.js | 248 +++++++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-stack-detection.test.ts | 249 ++++++++++++++++++ 5 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/stack-detection.d.ts create mode 100644 packages/gittensory-miner/lib/stack-detection.js create mode 100644 test/unit/miner-stack-detection.test.ts diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index e144139d98..7ccd90c770 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -35,6 +35,14 @@ tenant goal spec to the ranker, printing `usedDefaultGoalSpec` so a fall-back to rather than silent. See [`docs/repo-agnostic-capability-audit.md`](docs/repo-agnostic-capability-audit.md) for the #4780 audit this executes. +The package also includes repo stack auto-detection: `detectRepoStack` (`lib/stack-detection.js`) inspects an +already-cloned target repo's manifest / lockfile / config files and returns a structured description — language, +package manager, and the build / test / lint / format commands — for Node (npm/yarn/pnpm/bun), Python +(pip/poetry/pipenv/uv), Rust, Go, Maven, and Gradle. It is pure (injectable `existsSync` / `readFileSync`), never +throws, and per its acceptance criteria **fails closed** — a repo with no recognized manifest returns +`{ detected: false, reason }` and a command that can't be inferred without guessing stays `null`, rather than being +assumed. Detection only; wiring the description into the attempt prompt is the follow-up ([#4786](https://github.com/JSONbored/gittensory/issues/4786)). (#4785) + The package also includes an append-only governor decision ledger: `initGovernorLedger` / `appendGovernorEvent` persist structured allow/deny/throttle/kill-switch outcomes in local SQLite for contributor audit. Insert-only — no enforcement wiring yet. (#2328) diff --git a/packages/gittensory-miner/lib/stack-detection.d.ts b/packages/gittensory-miner/lib/stack-detection.d.ts new file mode 100644 index 0000000000..e69b216fae --- /dev/null +++ b/packages/gittensory-miner/lib/stack-detection.d.ts @@ -0,0 +1,41 @@ +/** Stack auto-detection (#4785). `detectRepoStack` inspects an already-cloned repo's manifest / lockfile / config + * files and returns a structured stack description, or an explicit fail-closed result when the stack can't be + * confidently identified (no guessing). */ + +/** Which manifest (and lockfile, when present) drove the detection. */ +export type StackEvidence = { + manifest: string; + lockfile: string | null; +}; + +/** A confidently-detected stack. Command fields are `null` when the command can't be inferred without guessing. */ +export type DetectedRepoStack = { + detected: true; + language: string; + packageManager: string | null; + buildCommand: string | null; + testCommand: string | null; + lintCommand: string | null; + formatCommand: string | null; + evidence: StackEvidence; +}; + +/** A repo whose stack could not be confidently identified. */ +export type UndetectedRepoStack = { + detected: false; + reason: string; +}; + +export type RepoStackResult = DetectedRepoStack | UndetectedRepoStack; + +export type DetectRepoStackOptions = { + existsSync?: (path: string) => boolean; + readFileSync?: (path: string, encoding: "utf8") => string; +}; + +/** Manifests, in the precedence order detection tries them (first match wins). */ +export const RECOGNIZED_MANIFESTS: readonly string[]; + +export function detectRepoStack(repoPath: string, options?: DetectRepoStackOptions): RepoStackResult; + +export function renderStackSummary(stack: RepoStackResult): string; diff --git a/packages/gittensory-miner/lib/stack-detection.js b/packages/gittensory-miner/lib/stack-detection.js new file mode 100644 index 0000000000..ab2830afad --- /dev/null +++ b/packages/gittensory-miner/lib/stack-detection.js @@ -0,0 +1,248 @@ +/** Stack auto-detection (#4785): inspect an already-cloned target repo's manifest / lockfile / config files and + * infer a structured description of its stack — language, package manager, and the build / test / lint / format + * commands — before any code-generation step runs. Like `miner-goal-spec.js` this reads the ALREADY-CLONED repo on + * disk (attempt-worktree.js's prepareAttemptWorktree runs first), so the injected `existsSync` / `readFileSync` + * always receive the FULL joined path, mirroring node:fs. It is pure and NEVER throws: an unreadable/unparseable + * file degrades to "no evidence" rather than crashing, and — per the acceptance criteria — a repo whose stack + * can't be confidently identified returns an explicit `{ detected: false, reason }` instead of guessing. */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with + * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ +export const RECOGNIZED_MANIFESTS = Object.freeze([ + "package.json", + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle", + "build.gradle.kts", +]); + +const NO_MANIFEST_REASON = + "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; + +const NODE_PACKAGE_MANAGERS = Object.freeze(["npm", "yarn", "pnpm", "bun"]); +const NODE_LOCKFILES = Object.freeze([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], +]); + +/** Build a never-throwing accessor over the cloned repo. `exists` and `read` both swallow fs errors so the detector + * treats an EACCES/ENOENT/binary file as simply "absent" instead of crashing the attempt. */ +function makeAccess(repoPath, options) { + const existsImpl = options.existsSync ?? existsSync; + const readImpl = options.readFileSync ?? readFileSync; + const exists = (relativePath) => { + try { + return existsImpl(join(repoPath, relativePath)) === true; + } catch { + return false; + } + }; + const read = (relativePath) => { + try { + if (!exists(relativePath)) return null; + const content = readImpl(join(repoPath, relativePath), "utf8"); + return typeof content === "string" ? content : null; + } catch { + return null; + } + }; + return { exists, read }; +} + +function parseJson(text) { + if (typeof text !== "string") return null; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */ +function pickScript(scripts, exactName, pattern) { + const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); + if (names.includes(exactName)) return exactName; + return names.find((name) => pattern.test(name)) ?? null; +} + +function nodeLockfile(exists) { + const match = NODE_LOCKFILES.find(([file]) => exists(file)); + return match ? match[0] : null; +} + +function nodePackageManager(pkg, lockfile) { + const corepack = + typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : ""; + if (NODE_PACKAGE_MANAGERS.includes(corepack)) return corepack; + const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); + // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). + return byLock ? byLock[1] : "npm"; +} + +function hasTypescriptDependency(pkg) { + const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }; + return typeof deps.typescript === "string"; +} + +function detectNode({ exists, read }) { + if (!exists("package.json")) return null; + const pkg = parseJson(read("package.json")); + const scripts = + pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {}; + const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; + const lockfile = nodeLockfile(exists); + const packageManager = nodePackageManager(pkg, lockfile); + + const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); + const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); + const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); + const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); + + return { + language, + packageManager, + buildCommand: buildName ? `${packageManager} run ${buildName}` : null, + // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. + testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, + lintCommand: lintName ? `${packageManager} run ${lintName}` : null, + formatCommand: formatName ? `${packageManager} run ${formatName}` : null, + evidence: { manifest: "package.json", lockfile }, + }; +} + +function detectPython({ exists, read }) { + const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); + if (manifest === undefined) return null; + const pyproject = read("pyproject.toml") ?? ""; + + let packageManager; + let lockfile = null; + if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { + packageManager = "poetry"; + lockfile = exists("poetry.lock") ? "poetry.lock" : null; + } else if (exists("uv.lock")) { + packageManager = "uv"; + lockfile = "uv.lock"; + } else if (exists("Pipfile") || exists("Pipfile.lock")) { + packageManager = "pipenv"; + lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; + } else { + packageManager = "pip"; + } + + // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). + const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); + const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); + + return { + language: "python", + packageManager, + buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, + testCommand: hasPytest ? "pytest" : null, + lintCommand: hasRuff ? "ruff check ." : null, + formatCommand: hasRuff ? "ruff format ." : null, + evidence: { manifest, lockfile }, + }; +} + +function detectRust({ exists }) { + if (!exists("Cargo.toml")) return null; + return { + language: "rust", + packageManager: "cargo", + buildCommand: "cargo build", + testCommand: "cargo test", + lintCommand: "cargo clippy", + formatCommand: "cargo fmt", + evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, + }; +} + +function detectGo({ exists }) { + if (!exists("go.mod")) return null; + const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); + return { + language: "go", + packageManager: "go", + buildCommand: "go build ./...", + testCommand: "go test ./...", + lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", + formatCommand: "gofmt -l .", + evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, + }; +} + +function detectMaven({ exists }) { + if (!exists("pom.xml")) return null; + return { + language: "java", + packageManager: "maven", + buildCommand: "mvn -B package", + testCommand: "mvn -B test", + lintCommand: null, + formatCommand: null, + evidence: { manifest: "pom.xml", lockfile: null }, + }; +} + +function detectGradle({ exists }) { + const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; + if (manifest === null) return null; + const runner = exists("gradlew") ? "./gradlew" : "gradle"; + return { + language: "java", + packageManager: "gradle", + buildCommand: `${runner} build`, + testCommand: `${runner} test`, + lintCommand: null, + formatCommand: null, + evidence: { manifest, lockfile: null }, + }; +} + +const DETECTORS = Object.freeze([detectNode, detectPython, detectRust, detectGo, detectMaven, detectGradle]); + +/** + * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the + * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no + * recognized manifest is present. Never throws. + */ +export function detectRepoStack(repoPath, options = {}) { + if (typeof repoPath !== "string" || !repoPath.trim()) { + return { detected: false, reason: "A repository path is required to detect the stack." }; + } + const access = makeAccess(repoPath, options); + for (const detector of DETECTORS) { + const detected = detector(access); + if (detected !== null) { + return { detected: true, ...detected }; + } + } + return { detected: false, reason: NO_MANIFEST_REASON }; +} + +/** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ +export function renderStackSummary(stack) { + if (!stack || stack.detected !== true) { + return `stack not detected: ${stack?.reason ?? "unknown reason"}`; + } + const commands = [ + stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, + stack.testCommand ? `test=\`${stack.testCommand}\`` : null, + stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, + stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; + return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index b868ac8a6a..c22d5b2c49 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-stack-detection.test.ts b/test/unit/miner-stack-detection.test.ts new file mode 100644 index 0000000000..354d17a37c --- /dev/null +++ b/test/unit/miner-stack-detection.test.ts @@ -0,0 +1,249 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectRepoStack, + RECOGNIZED_MANIFESTS, + renderStackSummary, +} from "../../packages/gittensory-miner/lib/stack-detection.js"; + +const ROOT = "/repo"; + +/** Build injected `existsSync` / `readFileSync` doubles over a relative-path -> content map. A `null` value models a + * file that is listed (exists) but throws on read (e.g. EACCES / a binary). */ +function fakeFs(files: Record) { + const rels = Object.keys(files); + const full = (rel: string) => join(ROOT, rel); + const present = new Set(rels.map(full)); + return { + existsSync: (path: string) => present.has(path), + readFileSync: (path: string) => { + const rel = rels.find((candidate) => full(candidate) === path); + if (rel === undefined || files[rel] === null) throw new Error(`ENOENT: ${path}`); + return files[rel] as string; + }, + }; +} + +function detect(files: Record) { + return detectRepoStack(ROOT, fakeFs(files)); +} + +const pkg = (value: Record) => JSON.stringify(value); + +describe("detectRepoStack — fail-closed (#4785)", () => { + it("returns detected:false with a clear reason when no manifest is present", () => { + const result = detect({ "README.md": "# hi", "src/index.txt": "x" }); + expect(result).toEqual({ detected: false, reason: expect.stringContaining("No recognized") }); + }); + + it("requires a repository path", () => { + expect(detectRepoStack("")).toEqual({ + detected: false, + reason: "A repository path is required to detect the stack.", + }); + expect(detectRepoStack(123 as never).detected).toBe(false); + expect(detectRepoStack(" ").detected).toBe(false); + }); + + it("treats an fs that throws on exists as 'file absent' (never crashes)", () => { + const result = detectRepoStack(ROOT, { + existsSync: () => { + throw new Error("EACCES"); + }, + readFileSync: () => "", + }); + expect(result.detected).toBe(false); + }); + + it("exposes the recognized-manifest precedence list", () => { + expect(RECOGNIZED_MANIFESTS).toContain("package.json"); + expect(RECOGNIZED_MANIFESTS).toContain("Cargo.toml"); + expect(Object.isFrozen(RECOGNIZED_MANIFESTS)).toBe(true); + }); +}); + +describe("detectRepoStack — Node (#4785)", () => { + it("detects a plain JavaScript repo defaulting to npm with no commands", () => { + expect(detect({ "package.json": pkg({}) })).toEqual({ + detected: true, + language: "javascript", + packageManager: "npm", + buildCommand: null, + testCommand: null, + lintCommand: null, + formatCommand: null, + evidence: { manifest: "package.json", lockfile: null }, + }); + }); + + it("classifies TypeScript via tsconfig.json or a typescript dependency", () => { + expect(detect({ "package.json": pkg({}), "tsconfig.json": "{}" }).detected && "typescript").toBe("typescript"); + const viaDep = detect({ "package.json": pkg({ devDependencies: { typescript: "^5.4.0" } }) }); + expect(viaDep).toMatchObject({ detected: true, language: "typescript" }); + }); + + it("derives full build/test/lint/format commands from package.json scripts", () => { + const result = detect({ + "package.json": pkg({ + scripts: { build: "tsc", test: "vitest", lint: "eslint .", format: "prettier -w ." }, + }), + }); + expect(result).toMatchObject({ + buildCommand: "npm run build", + testCommand: "npm test", + lintCommand: "npm run lint", + formatCommand: "npm run format", + }); + }); + + it("matches script name variants and ignores non-string script values", () => { + const result = detect({ + "package.json": pkg({ + scripts: { build: 123, "compile:prod": "tsc -p .", "test:ci": "vitest run", "lint:fix": "eslint --fix", fmt: "biome format" }, + }), + }); + expect(result).toMatchObject({ + buildCommand: "npm run compile:prod", + testCommand: "npm run test:ci", + lintCommand: "npm run lint:fix", + formatCommand: "npm run fmt", + }); + }); + + it("resolves the package manager from the corepack field over any lockfile", () => { + const result = detect({ "package.json": pkg({ packageManager: "pnpm@8.15.0" }), "yarn.lock": "" }); + expect(result).toMatchObject({ packageManager: "pnpm" }); + }); + + it("ignores an unknown corepack value and falls back to the lockfile / npm", () => { + expect(detect({ "package.json": pkg({ packageManager: "deno@1" }) })).toMatchObject({ packageManager: "npm" }); + }); + + it("resolves the package manager from each supported lockfile", () => { + expect(detect({ "package.json": pkg({}), "pnpm-lock.yaml": "" })).toMatchObject({ packageManager: "pnpm", evidence: { lockfile: "pnpm-lock.yaml" } }); + expect(detect({ "package.json": pkg({}), "yarn.lock": "" })).toMatchObject({ packageManager: "yarn" }); + expect(detect({ "package.json": pkg({}), "bun.lockb": "" })).toMatchObject({ packageManager: "bun" }); + expect(detect({ "package.json": pkg({}), "package-lock.json": "" })).toMatchObject({ packageManager: "npm", evidence: { lockfile: "package-lock.json" } }); + }); + + it("degrades safely on an unparseable or unreadable package.json (still Node, no commands)", () => { + expect(detect({ "package.json": "{ not json" })).toMatchObject({ detected: true, language: "javascript", buildCommand: null }); + // Present but unreadable (read throws -> null). + expect(detect({ "package.json": null })).toMatchObject({ detected: true, language: "javascript" }); + }); + + it("treats a non-string readFileSync result as no content", () => { + const result = detectRepoStack(ROOT, { + existsSync: (path: string) => path === join(ROOT, "package.json"), + readFileSync: () => 123 as never, + }); + expect(result).toMatchObject({ detected: true, language: "javascript" }); + }); + + it("ignores a non-object scripts field", () => { + expect(detect({ "package.json": pkg({ scripts: ["build"] }) })).toMatchObject({ buildCommand: null, testCommand: null }); + }); +}); + +describe("detectRepoStack — Python (#4785)", () => { + it("detects a pip repo from requirements.txt with no guessed commands", () => { + expect(detect({ "requirements.txt": "requests\n" })).toEqual({ + detected: true, + language: "python", + packageManager: "pip", + buildCommand: null, + testCommand: null, + lintCommand: null, + formatCommand: null, + evidence: { manifest: "requirements.txt", lockfile: null }, + }); + }); + + it("detects poetry via [tool.poetry] and builds with poetry when a build-system is declared", () => { + expect(detect({ "pyproject.toml": "[tool.poetry]\n[build-system]\nrequires = []\n" })).toMatchObject({ + packageManager: "poetry", + buildCommand: "poetry build", + evidence: { manifest: "pyproject.toml", lockfile: null }, + }); + expect(detect({ "pyproject.toml": "", "poetry.lock": "" })).toMatchObject({ packageManager: "poetry", evidence: { lockfile: "poetry.lock" }, buildCommand: null }); + }); + + it("detects uv and pipenv and pip build-system", () => { + expect(detect({ "pyproject.toml": "[build-system]\n", "uv.lock": "" })).toMatchObject({ packageManager: "uv", buildCommand: "python -m build", evidence: { lockfile: "uv.lock" } }); + expect(detect({ "Pipfile": "" })).toMatchObject({ packageManager: "pipenv", evidence: { lockfile: null } }); + expect(detect({ "requirements.txt": "", "Pipfile.lock": "" })).toMatchObject({ packageManager: "pipenv", evidence: { lockfile: "Pipfile.lock" } }); + expect(detect({ "pyproject.toml": "[build-system]\n" })).toMatchObject({ packageManager: "pip", buildCommand: "python -m build" }); + }); + + it("infers ruff lint/format and pytest only from real config", () => { + expect(detect({ "pyproject.toml": "[tool.ruff]\n" })).toMatchObject({ lintCommand: "ruff check .", formatCommand: "ruff format ." }); + expect(detect({ "requirements.txt": "", "ruff.toml": "" })).toMatchObject({ lintCommand: "ruff check ." }); + expect(detect({ "requirements.txt": "", ".ruff.toml": "" })).toMatchObject({ lintCommand: "ruff check ." }); + expect(detect({ "pyproject.toml": "[tool.pytest.ini_options]\n" })).toMatchObject({ testCommand: "pytest" }); + expect(detect({ "setup.py": "", "pytest.ini": "" })).toMatchObject({ testCommand: "pytest", evidence: { manifest: "setup.py" } }); + expect(detect({ "setup.cfg": "", "tox.ini": "" })).toMatchObject({ testCommand: "pytest", evidence: { manifest: "setup.cfg" } }); + }); +}); + +describe("detectRepoStack — Rust / Go / JVM (#4785)", () => { + it("detects Rust with the canonical cargo toolchain", () => { + expect(detect({ "Cargo.toml": "" })).toEqual({ + detected: true, + language: "rust", + packageManager: "cargo", + buildCommand: "cargo build", + testCommand: "cargo test", + lintCommand: "cargo clippy", + formatCommand: "cargo fmt", + evidence: { manifest: "Cargo.toml", lockfile: null }, + }); + expect(detect({ "Cargo.toml": "", "Cargo.lock": "" })).toMatchObject({ evidence: { lockfile: "Cargo.lock" } }); + }); + + it("detects Go, using go vet by default and golangci-lint when configured", () => { + expect(detect({ "go.mod": "" })).toMatchObject({ language: "go", lintCommand: "go vet ./...", evidence: { lockfile: null } }); + for (const config of [".golangci.yml", ".golangci.yaml", ".golangci.toml"]) { + expect(detect({ "go.mod": "", "go.sum": "", [config]: "" })).toMatchObject({ lintCommand: "golangci-lint run", evidence: { lockfile: "go.sum" } }); + } + }); + + it("detects Maven and Gradle (wrapper-aware)", () => { + expect(detect({ "pom.xml": "" })).toMatchObject({ language: "java", packageManager: "maven", buildCommand: "mvn -B package", lintCommand: null }); + expect(detect({ "build.gradle": "" })).toMatchObject({ packageManager: "gradle", buildCommand: "gradle build" }); + expect(detect({ "build.gradle.kts": "", "gradlew": "" })).toMatchObject({ buildCommand: "./gradlew build", evidence: { manifest: "build.gradle.kts" } }); + }); +}); + +describe("detectRepoStack — precedence + summary (#4785)", () => { + it("resolves the first matching manifest when several are present", () => { + expect(detect({ "package.json": pkg({}), "pyproject.toml": "" })).toMatchObject({ language: "javascript" }); + }); + + it("renders a one-line summary for detected and undetected results", () => { + const detected = detect({ + "package.json": pkg({ scripts: { build: "tsc" } }), + "tsconfig.json": "{}", + }); + expect(renderStackSummary(detected)).toBe("typescript via npm (build=`npm run build`)"); + + expect(renderStackSummary(detect({ "Cargo.toml": "" }))).toContain("rust via cargo"); + expect(renderStackSummary(detect({}))).toContain("stack not detected:"); + expect(renderStackSummary(undefined as never)).toBe("stack not detected: unknown reason"); + // No commands detected. + expect(renderStackSummary(detect({ "pom.xml": "" }))).toContain("java via maven"); + expect(renderStackSummary(detect({ "requirements.txt": "" }))).toContain("no validation commands detected"); + // packageManager null arm. + expect( + renderStackSummary({ + detected: true, + language: "elixir", + packageManager: null, + buildCommand: null, + testCommand: null, + lintCommand: null, + formatCommand: null, + evidence: { manifest: "mix.exs", lockfile: null }, + }), + ).toContain("elixir via unknown"); + }); +});