diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 8a6679b774..e44c9e4fbb 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -3377,6 +3377,272 @@ "summary" ] }, + "LocalWorkspaceIntelligence": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 2 + ] + }, + "sourceUpload": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "enum": [ + false + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "enabled", + "detail" + ] + }, + "branch": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "baseRef": { + "type": "string" + }, + "headSha": { + "type": "string" + }, + "pendingCommitCount": { + "type": "number" + } + }, + "required": [ + "pendingCommitCount" + ] + }, + "changedFiles": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "added": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "deleted": { + "type": "number" + }, + "renamed": { + "type": "number" + }, + "binary": { + "type": "number" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "total", + "added", + "modified", + "deleted", + "renamed", + "binary", + "paths" + ] + }, + "testEvidence": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "test_files", + "validation_commands", + "both", + "none" + ] + }, + "testFileCount": { + "type": "number" + }, + "passedValidationCount": { + "type": "number" + }, + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "passed", + "failed", + "not_run" + ] + }, + "summary": { + "type": "string" + } + }, + "required": [ + "command", + "status" + ] + } + } + }, + "required": [ + "level", + "testFileCount", + "passedValidationCount", + "commands" + ] + }, + "linkedIssues": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseFreshness": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "fresh", + "stale", + "possibly_stale", + "unknown" + ] + }, + "baseRef": { + "type": "string" + }, + "baseSha": { + "type": "string" + }, + "headSha": { + "type": "string" + }, + "mergeBaseSha": { + "type": "string" + }, + "remoteTrackingSha": { + "type": "string" + }, + "changedFileCount": { + "type": "number" + }, + "testFileCount": { + "type": "number" + }, + "passedValidationCount": { + "type": "number" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "recommendation": { + "type": "string" + } + }, + "required": [ + "status", + "changedFileCount", + "testFileCount", + "passedValidationCount", + "warnings" + ] + }, + "ciStatusHints": { + "type": "array", + "items": { + "type": "string" + } + }, + "localScorerDiagnostics": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "activeModel": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadataOnly": { + "type": "boolean" + } + }, + "required": [ + "mode", + "warnings", + "metadataOnly" + ] + }, + "blockers": { + "type": "object", + "properties": { + "branchQuality": { + "type": "array", + "items": { + "type": "string" + } + }, + "accountState": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "branchQuality", + "accountState" + ] + }, + "rerunWhen": { + "type": "string" + } + }, + "required": [ + "version", + "sourceUpload", + "branch", + "changedFiles", + "testEvidence", + "linkedIssues", + "baseFreshness", + "ciStatusHints", + "blockers", + "rerunWhen" + ] + }, "LocalBranchAnalysis": { "type": "object", "properties": { @@ -4658,6 +4924,9 @@ "$ref": "#/components/schemas/RewardRiskAction" } }, + "workspaceIntelligence": { + "$ref": "#/components/schemas/LocalWorkspaceIntelligence" + }, "summary": { "type": "string" } @@ -4683,6 +4952,7 @@ "maintainerFit", "prPacket", "nextActions", + "workspaceIntelligence", "summary" ] }, diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 238b20df50..774b77be28 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -291,7 +291,12 @@ server.registerTool( }, async (input) => { const result = await analyzeCurrentBranch(input); - return toolResult("Gittensory current-branch preflight.", { local: result.local, preflight: result.analysis.preflight, prPacket: result.analysis.prPacket }); + return toolResult("Gittensory current-branch preflight.", { + local: result.local, + preflight: result.analysis.preflight, + prPacket: result.analysis.prPacket, + workspaceIntelligence: result.analysis.workspaceIntelligence, + }); }, ); @@ -482,29 +487,15 @@ async function runCli(args) { validation: validationFromOptions(options), scorePreviewCommand: options.scorePreviewCommand, }); - const payload = command === "preflight" ? { local: result.local, preflight: result.analysis.preflight, prPacket: result.analysis.prPacket } : result; + const payload = + command === "preflight" + ? { local: result.local, preflight: result.analysis.preflight, prPacket: result.analysis.prPacket, workspaceIntelligence: result.analysis.workspaceIntelligence } + : result; if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); return; } - process.stdout.write(`${result.analysis.summary}\n`); - process.stdout.write(`Top action: ${result.analysis.nextActions?.[0]?.actionKind ?? "none"}\n`); - if (result.analysis.nextActions?.[0]?.whyThisHelps?.length) { - process.stdout.write("Why this helps:\n"); - for (const line of result.analysis.nextActions[0].whyThisHelps.slice(0, 3)) process.stdout.write(`- ${line}\n`); - } - if (result.analysis.scoreBlockers?.length) { - process.stdout.write("Score blockers:\n"); - for (const blocker of result.analysis.scoreBlockers.slice(0, 5)) process.stdout.write(`- ${blocker}\n`); - } - process.stdout.write(`Preflight: ${result.analysis.preflight.status}\n`); - process.stdout.write(`Source upload: disabled\n`); - if (result.local?.localScorerStatus?.ok === false) { - process.stdout.write(`Local scorer: ${result.local.localScorerStatus.code ?? "metadata_only"}\n`); - for (const line of result.local.setupGuidance ?? setupGuidanceForLocalScorer(result.local.localScorerStatus)) { - process.stdout.write(`- ${line}\n`); - } - } + writeBranchAnalysisCli(result, command); } async function runAgentCli(args) { @@ -567,13 +558,68 @@ function outputAgentPayload(payload, options, summary) { return process.stdout.write(safeMarkdown.endsWith("\n") ? safeMarkdown : `${safeMarkdown}\n`); } process.stdout.write(`${summary}\n`); - const actions = payload.actions ?? []; + if (payload.summary && payload.summary !== summary) process.stdout.write(`${payload.summary}\n`); + if (payload.recommendedRerunCondition) process.stdout.write(`Rerun when: ${payload.recommendedRerunCondition}\n`); + const actions = payload.actions ?? payload.nextActions ?? []; for (const action of actions.slice(0, 3)) { - process.stdout.write(`- ${action.actionType}: ${action.recommendation}\n`); + const label = action.actionType ?? action.actionKind ?? action.recommendation ?? "action"; + const detail = action.recommendation ?? action.actionKind ?? action.summary ?? label; + process.stdout.write(`- ${label}: ${detail}\n`); if (action.rerunWhen) process.stdout.write(` rerun: ${action.rerunWhen}\n`); } } +function writeBranchAnalysisCli(result, command) { + const analysis = result.analysis; + const intelligence = analysis.workspaceIntelligence; + process.stdout.write(`${analysis.summary}\n`); + process.stdout.write(`Top action: ${analysis.nextActions?.[0]?.actionKind ?? "none"}\n`); + if (analysis.nextActions?.[0]?.whyThisHelps?.length) { + process.stdout.write("Why this helps:\n"); + for (const line of analysis.nextActions[0].whyThisHelps.slice(0, 3)) process.stdout.write(`- ${line}\n`); + } + if (intelligence) writeWorkspaceIntelligenceCli(intelligence); + if (command === "analyze-branch" && analysis.scoreBlockers?.length) { + process.stdout.write("Score blockers:\n"); + for (const blocker of analysis.scoreBlockers.slice(0, 5)) process.stdout.write(`- ${blocker}\n`); + } + process.stdout.write(`Preflight: ${analysis.preflight.status}\n`); + process.stdout.write(`Source upload: disabled\n`); + if (result.local?.localScorerStatus?.ok === false) { + process.stdout.write(`Local scorer: ${result.local.localScorerStatus.code ?? "metadata_only"}\n`); + for (const line of result.local.setupGuidance ?? setupGuidanceForLocalScorer(result.local.localScorerStatus)) { + process.stdout.write(`- ${line}\n`); + } + } +} + +function writeWorkspaceIntelligenceCli(intelligence) { + process.stdout.write(`Workspace intelligence v${intelligence.version}:\n`); + const files = intelligence.changedFiles; + process.stdout.write(`- Changed files: ${files.total} (${files.binary} binary, ${files.deleted} deleted, ${files.renamed} renamed)\n`); + process.stdout.write(`- Test evidence: ${intelligence.testEvidence.level}\n`); + if (intelligence.branch.pendingCommitCount > 0) { + process.stdout.write(`- Pending commits ahead of base: ${intelligence.branch.pendingCommitCount}\n`); + } + if (intelligence.baseFreshness.status !== "fresh") { + process.stdout.write(`- Base freshness: ${intelligence.baseFreshness.status}\n`); + for (const warning of intelligence.baseFreshness.warnings.slice(0, 2)) process.stdout.write(` ${warning}\n`); + } + if (intelligence.blockers.branchQuality.length) { + process.stdout.write("- Branch-quality blockers:\n"); + for (const blocker of intelligence.blockers.branchQuality.slice(0, 4)) process.stdout.write(` - ${blocker}\n`); + } + if (intelligence.blockers.accountState.length) { + process.stdout.write("- Account/queue blockers:\n"); + for (const blocker of intelligence.blockers.accountState.slice(0, 4)) process.stdout.write(` - ${blocker}\n`); + } + if (intelligence.ciStatusHints.length) { + process.stdout.write("- CI hints:\n"); + for (const hint of intelligence.ciStatusHints.slice(0, 3)) process.stdout.write(` - ${hint}\n`); + } + process.stdout.write(`- Rerun when: ${intelligence.rerunWhen}\n`); +} + function requirePublicSafePacketMarkdown(markdown) { const unsafeLine = markdown.split(/\r?\n/).find((line) => isUnsafePublicPacketText(line)); if (unsafeLine) throw new Error("Refusing to print unsafe public packet markdown from the server."); diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index 4fbae5e7f7..24378ff3b6 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -44,6 +44,8 @@ export function collectLocalBranchMetadata(input) { const mergeBaseSha = gitLines(cwd, ["merge-base", baseRef, "HEAD"])[0]; const remoteTrackingSha = collectRemoteTrackingSha(cwd, baseRef); const changedFiles = collectChangedFiles(cwd, baseRef); + const pendingCommitCount = input.pendingCommitCount ?? collectPendingCommitCount(cwd, baseRef); + const ciStatusHints = input.ciStatusHints ?? collectCiStatusHints(cwd, baseRef, changedFiles); const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"))])].sort( @@ -72,10 +74,34 @@ export function collectLocalBranchMetadata(input) { expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, projectedCredibility: input.projectedCredibility, scenarioNotes: input.scenarioNotes, + pendingCommitCount, + ciStatusHints, }; return stripUndefined(payload); } +export function collectPendingCommitCount(cwd, baseRef) { + const count = gitLines(cwd, ["rev-list", "--count", `${baseRef}..HEAD`])[0]; + const parsed = Number(count); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0; +} + +export function collectCiStatusHints(cwd, baseRef, changedFiles = []) { + const hints = []; + const paths = changedFiles.map((file) => file.path).filter(Boolean); + if (paths.some((path) => /^\.github\/workflows\//i.test(path))) { + hints.push("Workflow files changed; CI required-check behavior may change after merge."); + } + if (paths.some((path) => /(^|\/)(Makefile|Dockerfile|package\.json|pyproject\.toml|go\.mod|Cargo\.toml)$/i.test(path))) { + hints.push("Build or dependency manifests changed; rerun the repo's standard validation commands."); + } + const pendingCommits = collectPendingCommitCount(cwd, baseRef); + if (pendingCommits > 0) { + hints.push(`${pendingCommits} local commit(s) ahead of ${baseRef}; push or rebase before reviewers rely on the latest diff.`); + } + return hints; +} + export function buildBranchAnalysisPayload(input) { const metadata = collectLocalBranchMetadata(input); const scorerMetadata = { ...metadata, repoRoot: input.cwd ?? process.cwd() }; diff --git a/src/api/routes.ts b/src/api/routes.ts index fbb12f7f32..4be28802c3 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -222,6 +222,8 @@ const localBranchAnalysisSchema = z expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), + pendingCommitCount: z.number().int().min(0).optional(), + ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), }) .strict(); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 0ca1dcc1b0..7aef464e07 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1359,6 +1359,71 @@ export const RepoRewardRiskSchema = z }) .openapi("RepoRewardRisk"); +export const LocalWorkspaceIntelligenceSchema = z + .object({ + version: z.literal(2), + sourceUpload: z.object({ + enabled: z.literal(false), + detail: z.string(), + }), + branch: z.object({ + name: z.string().optional(), + baseRef: z.string().optional(), + headSha: z.string().optional(), + pendingCommitCount: z.number(), + }), + changedFiles: z.object({ + total: z.number(), + added: z.number(), + modified: z.number(), + deleted: z.number(), + renamed: z.number(), + binary: z.number(), + paths: z.array(z.string()), + }), + testEvidence: z.object({ + level: z.enum(["test_files", "validation_commands", "both", "none"]), + testFileCount: z.number(), + passedValidationCount: z.number(), + commands: z.array( + z.object({ + command: z.string(), + status: z.enum(["passed", "failed", "not_run"]), + summary: z.string().optional(), + }), + ), + }), + linkedIssues: z.array(z.number()), + baseFreshness: z.object({ + status: z.enum(["fresh", "stale", "possibly_stale", "unknown"]), + baseRef: z.string().optional(), + baseSha: z.string().optional(), + headSha: z.string().optional(), + mergeBaseSha: z.string().optional(), + remoteTrackingSha: z.string().optional(), + changedFileCount: z.number(), + testFileCount: z.number(), + passedValidationCount: z.number(), + warnings: z.array(z.string()), + recommendation: z.string().optional(), + }), + ciStatusHints: z.array(z.string()), + localScorerDiagnostics: z + .object({ + mode: z.string(), + activeModel: z.string().optional(), + warnings: z.array(z.string()), + metadataOnly: z.boolean(), + }) + .optional(), + blockers: z.object({ + branchQuality: z.array(z.string()), + accountState: z.array(z.string()), + }), + rerunWhen: z.string(), + }) + .openapi("LocalWorkspaceIntelligence"); + export const LocalBranchAnalysisSchema = z .object({ login: z.string(), @@ -1447,6 +1512,7 @@ export const LocalBranchAnalysisSchema = z publicSafeWarnings: z.array(z.string()), }), nextActions: z.array(RewardRiskActionSchema), + workspaceIntelligence: LocalWorkspaceIntelligenceSchema, summary: z.string(), }) .openapi("LocalBranchAnalysis"); diff --git a/src/signals/engine.ts b/src/signals/engine.ts index b49605013a..ff0e45eef8 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -19,6 +19,7 @@ import type { import type { PublicContributorProfile } from "../github/public"; import type { GittensorContributorSnapshot } from "../gittensor/api"; import { nowIso } from "../utils/json"; +import { hasLocalTestEvidence } from "./test-evidence"; export type ParticipationLane = "direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown"; export type SignalFinding = AdvisoryFinding; @@ -1995,7 +1996,7 @@ export function buildLocalDiffPreflightResult( action: "Split unrelated work or clearly explain why the scope needs to stay together.", }); } - if (codeFileCount > 0 && testFileCount === 0 && (input.tests ?? []).length === 0) { + if (codeFileCount > 0 && testFileCount === 0 && !hasLocalTestEvidence({ tests: input.tests, testFiles: input.testFiles })) { findings.push({ code: "local_diff_missing_tests", severity: "warning", diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 94a513ff2f..97e7c5a86b 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -15,6 +15,7 @@ import { type RoleContext, } from "./engine"; import { buildRepoRewardRisk, type RepoRewardRisk, type RewardRiskAction } from "./reward-risk"; +import { buildLocalWorkspaceIntelligence, type LocalWorkspaceIntelligence } from "./local-workspace-intelligence"; export type LocalBranchChangedFile = { path: string; @@ -68,6 +69,8 @@ export type LocalBranchAnalysisInput = { expectedOpenPrCountAfterMerge?: number | undefined; projectedCredibility?: number | undefined; scenarioNotes?: string[] | undefined; + pendingCommitCount?: number | undefined; + ciStatusHints?: string[] | undefined; }; type ObservedPullRequestScenarios = { @@ -159,6 +162,7 @@ export type LocalBranchAnalysis = { publicSafeWarnings: string[]; }; nextActions: RewardRiskAction[]; + workspaceIntelligence: LocalWorkspaceIntelligence; summary: string; }; @@ -325,6 +329,17 @@ export function buildLocalBranchAnalysis(args: { }, prPacket, nextActions: withSituationalAction(rewardRisk.actions, branchQualityBlockers, accountStateBlockers, scorePreview).slice(0, 6), + workspaceIntelligence: buildLocalWorkspaceIntelligence({ + input: args.input, + analysis: { + baseFreshness, + branchQualityBlockers, + accountStateBlockers, + recommendedRerunCondition, + prPacket, + }, + changedFiles, + }), summary: `${args.input.repoFullName}: local branch analysis is ${preflight.status}; ${rewardRisk.actions[0]?.actionKind ?? "no ranked action"} is the top private next action.`, }; } @@ -596,6 +611,46 @@ function buildLocalFindings( }, ] : []), + ...(changedFiles.some((file) => file.status === "deleted") + ? [ + { + code: "deleted_paths_present", + severity: "warning" as const, + title: "Deleted paths detected", + detail: "Deleted files are included in local metadata only; confirm the removal is intentional before submitting the change.", + }, + ] + : []), + ...(changedFiles.some((file) => file.status === "renamed" || file.previousPath) + ? [ + { + code: "renamed_paths_present", + severity: "info" as const, + title: "Renamed paths detected", + detail: "Renamed files are summarized from git metadata; reviewers should confirm history and import paths.", + }, + ] + : []), + ...((input.validation ?? []).some((entry) => entry.status === "passed") && !changedFiles.some((file) => isTestFile(file.path)) + ? [ + { + code: "validation_as_test_evidence", + severity: "info" as const, + title: "Validation commands supplied as test evidence", + detail: "Passed local validation commands are treated as test evidence even when no test files changed.", + }, + ] + : []), + ...(input.localScorer?.warnings?.length + ? [ + { + code: "local_scorer_warning", + severity: "info" as const, + title: "Local scorer diagnostics", + detail: input.localScorer.warnings.join(" "), + }, + ] + : []), ...(baseFreshness.status === "stale" || baseFreshness.status === "possibly_stale" ? [ { diff --git a/src/signals/local-workspace-intelligence.ts b/src/signals/local-workspace-intelligence.ts new file mode 100644 index 0000000000..ba7e1b5b27 --- /dev/null +++ b/src/signals/local-workspace-intelligence.ts @@ -0,0 +1,127 @@ +import type { LocalBranchAnalysis, LocalBranchAnalysisInput, LocalBranchChangedFile, LocalBranchValidation } from "./local-branch"; +import { isTestPath } from "./test-evidence"; + +export type LocalWorkspaceIntelligence = { + version: 2; + sourceUpload: { + enabled: false; + detail: string; + }; + branch: { + name?: string | undefined; + baseRef?: string | undefined; + headSha?: string | undefined; + pendingCommitCount: number; + }; + changedFiles: { + total: number; + added: number; + modified: number; + deleted: number; + renamed: number; + binary: number; + paths: string[]; + }; + testEvidence: { + level: "test_files" | "validation_commands" | "both" | "none"; + testFileCount: number; + passedValidationCount: number; + commands: LocalBranchValidation[]; + }; + linkedIssues: number[]; + baseFreshness: LocalBranchAnalysis["baseFreshness"]; + ciStatusHints: string[]; + localScorerDiagnostics?: { + mode: string; + activeModel?: string | undefined; + warnings: string[]; + metadataOnly: boolean; + }; + blockers: { + branchQuality: string[]; + accountState: string[]; + }; + rerunWhen: string; +}; + +export function buildLocalWorkspaceIntelligence(args: { + input: LocalBranchAnalysisInput; + analysis: Pick< + LocalBranchAnalysis, + "baseFreshness" | "branchQualityBlockers" | "accountStateBlockers" | "recommendedRerunCondition" | "prPacket" + >; + changedFiles: LocalBranchChangedFile[]; +}): LocalWorkspaceIntelligence { + const validation = args.input.validation ?? []; + const testFileCount = args.changedFiles.filter((file) => isTestPath(file.path)).length; + const passedValidationCount = validation.filter((entry) => entry.status === "passed").length; + const hasTestFiles = testFileCount > 0; + const hasValidation = passedValidationCount > 0; + const testEvidenceLevel = hasTestFiles && hasValidation ? "both" : hasTestFiles ? "test_files" : hasValidation ? "validation_commands" : "none"; + const scorer = args.input.localScorer; + + return { + version: 2, + sourceUpload: { + enabled: false, + detail: "Local workspace intelligence uses git metadata and optional local scorer output only; source contents are not uploaded.", + }, + branch: { + ...(args.input.branchName ? { name: args.input.branchName } : {}), + ...(args.input.baseRef ? { baseRef: args.input.baseRef } : {}), + ...(args.input.headSha ? { headSha: args.input.headSha } : {}), + pendingCommitCount: nonNegative(args.input.pendingCommitCount), + }, + changedFiles: summarizeChangedFiles(args.changedFiles), + testEvidence: { + level: testEvidenceLevel, + testFileCount, + passedValidationCount, + commands: validation, + }, + linkedIssues: [...(args.input.linkedIssues ?? [])].sort((left, right) => left - right), + baseFreshness: args.analysis.baseFreshness, + ciStatusHints: [...(args.input.ciStatusHints ?? [])], + ...(scorer + ? { + localScorerDiagnostics: { + mode: scorer.mode, + ...(scorer.activeModel ? { activeModel: scorer.activeModel } : {}), + warnings: scorer.warnings ?? [], + metadataOnly: scorer.mode === "metadata_only", + }, + } + : {}), + blockers: { + branchQuality: args.analysis.branchQualityBlockers, + accountState: args.analysis.accountStateBlockers, + }, + rerunWhen: args.analysis.recommendedRerunCondition, + }; +} + +function summarizeChangedFiles(files: LocalBranchChangedFile[]): LocalWorkspaceIntelligence["changedFiles"] { + const counts = { added: 0, modified: 0, deleted: 0, renamed: 0, binary: 0 }; + for (const file of files) { + if (file.binary) counts.binary += 1; + if (file.status === "added") counts.added += 1; + else if (file.status === "deleted") counts.deleted += 1; + else if (file.status === "renamed" || file.status === "copied" || file.previousPath) counts.renamed += 1; + else counts.modified += 1; + } + return { + total: files.length, + ...counts, + paths: files.slice(0, 12).map((file) => formatChangedPath(file)), + }; +} + +function formatChangedPath(file: LocalBranchChangedFile): string { + const path = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path; + return `${path} (${file.status ?? "modified"}${file.binary ? ", binary" : ""})`; +} + +function nonNegative(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} diff --git a/src/signals/test-evidence.ts b/src/signals/test-evidence.ts new file mode 100644 index 0000000000..20fa1db283 --- /dev/null +++ b/src/signals/test-evidence.ts @@ -0,0 +1,13 @@ +export function isTestPath(file: string): boolean { + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb)$/i.test(file) || + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file) + ); +} + +export function hasLocalTestEvidence(input: { tests?: string[] | undefined; testFiles?: string[] | undefined }): boolean { + return (input.tests ?? []).length > 0 || (input.testFiles ?? []).some((file) => isTestPath(file)); +} diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 90fd0ac0ff..5aba88eb8e 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -47,6 +47,11 @@ describe("local branch analysis", () => { expect(analysis.rewardRisk.rewardUpside.relevantLane).toBe("direct_pr"); expect(analysis.nextActions.map((action) => action.actionKind)).toContain("open_new_direct_pr"); expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "source_upload_disabled" })])); + expect(analysis.workspaceIntelligence.version).toBe(2); + expect(analysis.workspaceIntelligence.sourceUpload.enabled).toBe(false); + expect(analysis.workspaceIntelligence.testEvidence.level).toBe("both"); + expect(analysis.workspaceIntelligence.blockers.branchQuality).toEqual(analysis.branchQualityBlockers); + expect(analysis.workspaceIntelligence.blockers.accountState).toEqual(analysis.accountStateBlockers); expect(analysis.prPacket.markdown).toContain("## Branch Freshness"); expect(analysis.prPacket.markdown).toContain("## Overlap/WIP Check"); expect(analysis.prPacket.markdown).toContain("- Closes #7"); @@ -773,6 +778,8 @@ describe("local branch analysis", () => { expect(analysis.prPacket.markdown).toMatch(/Base freshness: stale|git fetch origin/i); expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("missing_test_evidence"); expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("local_diff_missing_tests"); + expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "validation_as_test_evidence" })])); + expect(analysis.workspaceIntelligence.testEvidence.level).toBe("validation_commands"); expect(analysis.recommendedRerunCondition).toMatch(/git fetch origin/i); }); @@ -985,6 +992,8 @@ describe("local branch analysis", () => { expect(analysis.branchQualityBlockers).toEqual([]); expect(analysis.accountStateBlockers.join(" ")).toMatch(/Open PR count|Credibility/i); + expect(analysis.workspaceIntelligence.blockers.branchQuality).toEqual([]); + expect(analysis.workspaceIntelligence.blockers.accountState.length).toBeGreaterThan(0); expect(analysis.recommendedRerunCondition).toBe("Rerun after account/queue maturity blockers clear."); expect(analysis.nextActions[0]?.actionKind).not.toBe("land_existing_prs"); }); @@ -1014,6 +1023,7 @@ describe("local branch analysis", () => { expect.objectContaining({ code: "binary_diff_present" }), ]), ); + expect(analysis.workspaceIntelligence.changedFiles.binary).toBe(1); expect(analysis.prPacket.bodySections).toEqual( expect.arrayContaining([ expect.objectContaining({ heading: "Linked Context", lines: ["- No linked issue detected; explain why this is a no-issue PR."] }), @@ -1060,6 +1070,42 @@ describe("local MCP git metadata collection", () => { delete process.env.GITTENSORY_UPLOAD_SOURCE; }); + it("counts pending commits, emits CI hints, and tracks deleted or renamed paths", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { collectCiStatusHints, collectLocalBranchMetadata, collectPendingCommitCount } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + tempDir = mkdtempSync(join(tmpdir(), "gittensory-local-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:entrius/allways-ui.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + writeFileSync(join(tempDir, "keep.ts"), "keep\n"); + git(tempDir, "add", "keep.ts"); + git(tempDir, "commit", "-m", "add keep file"); + git(tempDir, "checkout", "-b", "rename-delete"); + writeFileSync(join(tempDir, "old.ts"), "old\n"); + git(tempDir, "add", "old.ts"); + git(tempDir, "commit", "-m", "add old file"); + git(tempDir, "mv", "old.ts", "new.ts"); + git(tempDir, "add", "new.ts"); + git(tempDir, "commit", "-m", "rename old to new"); + const renameMetadata = collectLocalBranchMetadata({ cwd: tempDir, baseRef: "HEAD~1", login: "oktofeesh1" }); + expect(renameMetadata.changedFiles).toEqual( + expect.arrayContaining([expect.objectContaining({ path: "new.ts", previousPath: "old.ts", status: "renamed" })]), + ); + git(tempDir, "rm", "keep.ts"); + git(tempDir, "commit", "-m", "delete keep file"); + + expect(collectPendingCommitCount(tempDir, "HEAD~2")).toBe(2); + const deleteMetadata = collectLocalBranchMetadata({ cwd: tempDir, baseRef: "HEAD~1", login: "oktofeesh1" }); + expect(deleteMetadata.changedFiles).toEqual(expect.arrayContaining([expect.objectContaining({ path: "keep.ts", status: "deleted" })])); + expect(collectCiStatusHints(tempDir, "HEAD~2", deleteMetadata.changedFiles).join(" ")).toMatch(/local commit/i); + expect(JSON.stringify(renameMetadata)).not.toMatch(/export const old/); + }); + it("parses remotes, changed-file stats, linked issues, and refuses source upload mode", async () => { // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata, parseGitRemote } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); diff --git a/test/unit/local-workspace-intelligence.test.ts b/test/unit/local-workspace-intelligence.test.ts new file mode 100644 index 0000000000..3f4bab1912 --- /dev/null +++ b/test/unit/local-workspace-intelligence.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { buildLocalWorkspaceIntelligence } from "../../src/signals/local-workspace-intelligence"; +import { hasLocalTestEvidence, isTestPath } from "../../src/signals/test-evidence"; +import { buildLocalDiffPreflightResult } from "../../src/signals/engine"; +import type { RepositoryRecord } from "../../src/types"; + +const repo: RepositoryRecord = { + fullName: "entrius/allways-ui", + owner: "entrius", + name: "allways-ui", + isInstalled: true, + isRegistered: true, + isPrivate: false, + defaultBranch: "main", + registryConfig: { + repo: "entrius/allways-ui", + emissionShare: 0.01, + issueDiscoveryShare: 0, + labelMultipliers: {}, + trustedLabelPipeline: true, + maintainerCut: 0, + raw: {}, + }, +}; + +describe("local workspace intelligence v2", () => { + it("summarizes renamed, binary, and deleted files without uploading source", () => { + const intelligence = buildLocalWorkspaceIntelligence({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + branchName: "asset-cleanup", + baseRef: "origin/main", + pendingCommitCount: 2, + ciStatusHints: ["Workflow files changed; CI required-check behavior may change after merge."], + changedFiles: [ + { path: "assets/logo.bin", status: "modified", binary: true }, + { path: "src/old.ts", status: "deleted" }, + { path: "src/new.ts", previousPath: "src/old.ts", status: "renamed" }, + ], + }, + analysis: { + baseFreshness: { + status: "fresh", + changedFileCount: 3, + testFileCount: 0, + passedValidationCount: 0, + warnings: [], + }, + branchQualityBlockers: [], + accountStateBlockers: ["Open PR count is high."], + recommendedRerunCondition: "Rerun after account/queue maturity blockers clear.", + prPacket: { + titleSuggestion: "Asset cleanup", + markdown: "# Asset cleanup\n", + bodySections: [], + reviewerNotes: [], + validationSummary: { passed: 0, failed: 0, notRun: 0, commands: [] }, + publicSafeWarnings: [], + }, + }, + changedFiles: [ + { path: "assets/logo.bin", status: "modified", binary: true }, + { path: "src/old.ts", status: "deleted" }, + { path: "src/new.ts", previousPath: "src/old.ts", status: "renamed" }, + ], + }); + + expect(intelligence.sourceUpload.enabled).toBe(false); + expect(intelligence.changedFiles).toMatchObject({ total: 3, binary: 1, deleted: 1, renamed: 1 }); + expect(intelligence.changedFiles.paths.join(" ")).toMatch(/logo\.bin.*binary/); + expect(intelligence.branch.pendingCommitCount).toBe(2); + expect(intelligence.ciStatusHints[0]).toMatch(/Workflow files changed/i); + expect(intelligence.blockers.accountState).toEqual(["Open PR count is high."]); + expect(intelligence.blockers.branchQuality).toEqual([]); + expect(JSON.stringify(intelligence)).not.toMatch(/export const|wallet|hotkey|farming|payout|trust score/i); + }); + + it("treats passed validation as test evidence when no test files changed", () => { + const intelligence = buildLocalWorkspaceIntelligence({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + changedFiles: [{ path: "internal/entity/model.go", status: "modified" }], + validation: [{ command: "go test ./internal/entity", status: "passed", summary: "focused regression passed" }], + }, + analysis: { + baseFreshness: { + status: "stale", + baseRef: "origin/main", + warnings: ["Local branch is behind remote tracking SHA; run git fetch origin && git rebase origin/main."], + changedFileCount: 1, + testFileCount: 0, + passedValidationCount: 1, + recommendation: "Rebase onto the latest remote base before opening.", + }, + branchQualityBlockers: ["Local branch base is stale."], + accountStateBlockers: [], + recommendedRerunCondition: "Rerun after rebasing onto the latest remote base.", + prPacket: { + titleSuggestion: "Entity model fix", + markdown: "# Entity model fix\n", + bodySections: [], + reviewerNotes: [], + validationSummary: { + passed: 1, + failed: 0, + notRun: 0, + commands: [{ command: "go test ./internal/entity", status: "passed", summary: "focused regression passed" }], + }, + publicSafeWarnings: [], + }, + }, + changedFiles: [{ path: "internal/entity/model.go", status: "modified" }], + }); + + expect(intelligence.testEvidence.level).toBe("validation_commands"); + expect(intelligence.baseFreshness.status).toBe("stale"); + expect(hasLocalTestEvidence({ tests: ["go test ./internal/entity"] })).toBe(true); + expect(isTestPath("internal/entity/model_test.go")).toBe(true); + expect(isTestPath("internal/entity/model.go")).toBe(false); + + const preflight = buildLocalDiffPreflightResult( + { + repoFullName: repo.fullName, + title: "Entity model fix", + changedFiles: ["internal/entity/model.go"], + tests: ["go test ./internal/entity"], + changedLineCount: 12, + }, + repo, + [], + [], + ); + expect(preflight.findings.map((finding) => finding.code)).not.toContain("local_diff_missing_tests"); + }); + + it("records metadata-only scorer diagnostics when no external scorer is configured", () => { + const intelligence = buildLocalWorkspaceIntelligence({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + localScorer: { mode: "metadata_only", warnings: ["GITTENSOR_SCORE_PREVIEW_CMD is not configured."] }, + }, + analysis: { + baseFreshness: { status: "unknown", changedFileCount: 0, testFileCount: 0, passedValidationCount: 0, warnings: [] }, + branchQualityBlockers: [], + accountStateBlockers: [], + recommendedRerunCondition: "Rerun after any branch, base, or PR state changes before opening/submitting.", + prPacket: { + titleSuggestion: "Local branch preflight", + markdown: "# Local branch preflight\n", + bodySections: [], + reviewerNotes: [], + validationSummary: { passed: 0, failed: 0, notRun: 0, commands: [] }, + publicSafeWarnings: [], + }, + }, + changedFiles: [], + }); + + expect(intelligence.localScorerDiagnostics).toMatchObject({ + mode: "metadata_only", + metadataOnly: true, + warnings: expect.arrayContaining([expect.stringMatching(/not configured/i)]), + }); + expect(intelligence.testEvidence.level).toBe("none"); + }); + + it("records test_files evidence when only test paths changed", () => { + const intelligence = buildLocalWorkspaceIntelligence({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + headSha: "abc123", + localScorer: { mode: "external_command", activeModel: "fixture-model", warnings: [] }, + changedFiles: [{ path: "src/cache.test.ts", status: "added" }], + }, + analysis: { + baseFreshness: { status: "fresh", changedFileCount: 1, testFileCount: 1, passedValidationCount: 0, warnings: [] }, + branchQualityBlockers: [], + accountStateBlockers: [], + recommendedRerunCondition: "Rerun after any branch, base, or PR state changes before opening/submitting.", + prPacket: { + titleSuggestion: "Cache tests", + markdown: "# Cache tests\n", + bodySections: [], + reviewerNotes: [], + validationSummary: { passed: 0, failed: 0, notRun: 0, commands: [] }, + publicSafeWarnings: [], + }, + }, + changedFiles: [{ path: "src/cache.test.ts", status: "added" }], + }); + + expect(intelligence.testEvidence.level).toBe("test_files"); + expect(intelligence.branch.headSha).toBe("abc123"); + expect(intelligence.localScorerDiagnostics).toMatchObject({ mode: "external_command", activeModel: "fixture-model", metadataOnly: false }); + }); + + it("counts copied file changes as renames in summaries", () => { + const intelligence = buildLocalWorkspaceIntelligence({ + input: { login: "oktofeesh1", repoFullName: repo.fullName }, + analysis: { + baseFreshness: { status: "fresh", changedFileCount: 1, testFileCount: 0, passedValidationCount: 0, warnings: [] }, + branchQualityBlockers: [], + accountStateBlockers: [], + recommendedRerunCondition: "Rerun after any branch, base, or PR state changes before opening/submitting.", + prPacket: { + titleSuggestion: "Copy path", + markdown: "# Copy path\n", + bodySections: [], + reviewerNotes: [], + validationSummary: { passed: 0, failed: 0, notRun: 0, commands: [] }, + publicSafeWarnings: [], + }, + }, + changedFiles: [{ path: "src/new.ts", previousPath: "src/old.ts", status: "copied" }], + }); + + expect(intelligence.changedFiles.renamed).toBe(1); + }); + + it("keeps linked issues sorted for stable public output", () => { + const intelligence = buildLocalWorkspaceIntelligence({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + linkedIssues: [42, 7, 19], + }, + analysis: { + baseFreshness: { status: "fresh", changedFileCount: 0, testFileCount: 0, passedValidationCount: 0, warnings: [] }, + branchQualityBlockers: [], + accountStateBlockers: [], + recommendedRerunCondition: "Rerun after any branch, base, or PR state changes before opening/submitting.", + prPacket: { + titleSuggestion: "Local branch preflight", + markdown: "# Local branch preflight\n", + bodySections: [], + reviewerNotes: [], + validationSummary: { passed: 0, failed: 0, notRun: 0, commands: [] }, + publicSafeWarnings: [], + }, + }, + changedFiles: [], + }); + + expect(intelligence.linkedIssues).toEqual([7, 19, 42]); + }); +}); diff --git a/test/unit/test-evidence.test.ts b/test/unit/test-evidence.test.ts new file mode 100644 index 0000000000..0e251dab42 --- /dev/null +++ b/test/unit/test-evidence.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { hasLocalTestEvidence, isTestPath } from "../../src/signals/test-evidence"; + +describe("test evidence helpers", () => { + it("detects common test path conventions", () => { + expect(isTestPath("pkg/foo_test.go")).toBe(true); + expect(isTestPath("spec/models/widget_spec.rb")).toBe(true); + expect(isTestPath("src/test/helpers.ts")).toBe(true); + expect(isTestPath("tests/integration/api.test.ts")).toBe(true); + expect(isTestPath("__tests__/widget.spec.tsx")).toBe(true); + expect(isTestPath("src/widget.rs")).toBe(false); + }); + + it("treats explicit test file lists as evidence", () => { + expect(hasLocalTestEvidence({ testFiles: ["internal/cache_test.go"] })).toBe(true); + expect(hasLocalTestEvidence({ tests: [] })).toBe(false); + expect(hasLocalTestEvidence({})).toBe(false); + }); +});