Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions packages/gittensory-miner/lib/stack-detection.d.ts
Original file line number Diff line number Diff line change
@@ -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;
248 changes: 248 additions & 0 deletions packages/gittensory-miner/lib/stack-detection.js
Original file line number Diff line number Diff line change
@@ -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,
// `<pm> 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}`;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
Loading