diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 617d471a1c..1b98555235 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -255,6 +255,14 @@ export { // Issue-centric RAG query composition (#2320, extracted in #4254): the pure query builder + the shared // minimum-query floor; the Vectorize/D1 retrieval backend intentionally stays in the backend. export { MIN_QUERY_CHARS, buildIssueRagQuery, type IssueRagQueryInput } from "./issue-rag-query.js"; +// #782 deterministic local scorer (extracted in #4253): pure token-scoring from changed-file metadata, +// shared by the published CLIs and the hosted Worker. The Node-coupled local-branch.ts stays in the backend. +export { + computeLocalScorerTokens, + type LocalScorerChangedFile, + type LocalScorerValidation, + type LocalScorerResult, +} from "./local-scorer.js"; export { buildPredictedGateVerdict, predictedGateNote, diff --git a/packages/gittensory-engine/src/local-scorer.ts b/packages/gittensory-engine/src/local-scorer.ts new file mode 100644 index 0000000000..daf4b9dcaf --- /dev/null +++ b/packages/gittensory-engine/src/local-scorer.ts @@ -0,0 +1,67 @@ +// #782 deterministic local scorer, extracted from src/signals/local-scorer.ts (#4253) so the published +// gittensory-mcp / gittensory-miner CLIs and the hosted Worker share one implementation. Replicates the +// gittensor-root token-scoring view from changed-file METADATA (paths + line counts) — never source content, +// so the no-upload boundary holds and it runs in every surface. It mirrors buildScorePreview's +// source/test/non-code classification, so feeding its output back in as `localScorer` (mode external_command) +// flips the preview off metadata-only with numbers it would otherwise have derived itself. +// +// The 3 dependent type shapes are narrowly duplicated here (the issue explicitly allows this) rather than +// moving the large, Node-coupled local-branch.ts; they are structurally identical to local-branch.ts's +// definitions. isCodeFile/isTestPath are the same portable classifiers local-branch.ts already delegates to. + +import { isCodeFile, isTestPath } from "./signals/test-evidence.js"; + +export type LocalScorerChangedFile = { + path: string; + previousPath?: string | undefined; + additions?: number | undefined; + deletions?: number | undefined; + status?: "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown" | undefined; + binary?: boolean | undefined; +}; + +export type LocalScorerValidation = { + command: string; + status: "passed" | "failed" | "not_run" | "skipped" | "focused" | "unknown"; + summary?: string | undefined; + durationMs?: number | undefined; + exitCode?: number | undefined; +}; + +export type LocalScorerResult = { + mode: "metadata_only" | "external_command" | "gittensor_root"; + activeModel?: string | undefined; + sourceTokenScore?: number | undefined; + totalTokenScore?: number | undefined; + sourceLines?: number | undefined; + testTokenScore?: number | undefined; + nonCodeTokenScore?: number | undefined; + warnings?: string[] | undefined; +}; + +const fileLines = (file: LocalScorerChangedFile): number => Math.max(0, file.additions ?? 0) + Math.max(0, file.deletions ?? 0); + +/** + * Compute token scores from changed-file metadata + the local validation results. `isCodeFile` already excludes + * tests, so source / test / non-code are disjoint. Binary files carry no token value and are dropped. A failed + * validation does not change the scores (they describe the diff) but is surfaced as a warning. Pure. + */ +export function computeLocalScorerTokens(input: { changedFiles: LocalScorerChangedFile[]; validation?: LocalScorerValidation[] | undefined }): LocalScorerResult { + const files = input.changedFiles.filter((file) => !file.binary); + const testTokenScore = files.filter((file) => isTestPath(file.path)).reduce((sum, file) => sum + fileLines(file), 0); + const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); + const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0); + const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore); + const failed = (input.validation ?? []).some((entry) => entry.status === "failed"); + const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : []; + return { + mode: "external_command", + activeModel: "gittensory-deterministic", + sourceTokenScore, + totalTokenScore, + sourceLines: Math.max(1, sourceTokenScore || totalTokenScore || 1), + testTokenScore, + nonCodeTokenScore, + ...(warnings.length > 0 ? { warnings } : {}), + }; +} diff --git a/packages/gittensory-engine/test/local-scorer.test.ts b/packages/gittensory-engine/test/local-scorer.test.ts new file mode 100644 index 0000000000..501a21c740 --- /dev/null +++ b/packages/gittensory-engine/test/local-scorer.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { computeLocalScorerTokens } from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports computeLocalScorerTokens", () => { + assert.equal(typeof computeLocalScorerTokens, "function"); +}); + +test("classifies source / test / non-code disjointly from changed-file metadata", () => { + const r = computeLocalScorerTokens({ + changedFiles: [ + { path: "src/a.ts", additions: 10, deletions: 2 }, // source: 12 + { path: "src/a.test.ts", additions: 5, deletions: 1 }, // test: 6 + { path: "README.md", additions: 4, deletions: 0 }, // non-code: 4 + ], + }); + assert.equal(r.mode, "external_command"); + assert.equal(r.activeModel, "gittensory-deterministic"); + assert.equal(r.sourceTokenScore, 12); + assert.equal(r.testTokenScore, 6); + assert.equal(r.nonCodeTokenScore, 4); + assert.equal(r.totalTokenScore, 22); + assert.equal(r.warnings, undefined); +}); + +test("drops binary files and floors sourceLines at 1", () => { + const r = computeLocalScorerTokens({ changedFiles: [{ path: "img.png", additions: 999, binary: true }] }); + assert.equal(r.totalTokenScore, 0); + assert.equal(r.sourceLines, 1); +}); + +test("surfaces a warning when a validation entry failed, without changing scores", () => { + const r = computeLocalScorerTokens({ + changedFiles: [{ path: "src/a.ts", additions: 3 }], + validation: [{ command: "npm test", status: "failed" }], + }); + assert.equal(r.sourceTokenScore, 3); + assert.ok(r.warnings && r.warnings.length === 1); +}); diff --git a/src/signals/local-scorer.ts b/src/signals/local-scorer.ts index ef4d706b70..9cbe70bf29 100644 --- a/src/signals/local-scorer.ts +++ b/src/signals/local-scorer.ts @@ -1,34 +1,6 @@ -import { isCodeFile, isTestFile, type LocalBranchChangedFile, type LocalBranchScorer, type LocalBranchValidation } from "./local-branch"; - -// #782 deterministic local scorer. Replicates the gittensor-root token-scoring view from changed-file METADATA -// (paths + line counts) — never source content, so the no-upload boundary holds and it runs in every surface -// (stdio package AND hosted Worker). It mirrors buildScorePreview's source/test/non-code classification, so -// feeding its output back in as `localScorer` (mode external_command) flips the preview off metadata-only with -// numbers it would otherwise have derived itself — closing the "miner runs the scorer manually" gap. - -const fileLines = (file: LocalBranchChangedFile): number => Math.max(0, file.additions ?? 0) + Math.max(0, file.deletions ?? 0); - -/** - * Compute token scores from changed-file metadata + the local validation results. `isCodeFile` already excludes - * tests, so source / test / non-code are disjoint. Binary files carry no token value and are dropped. A failed - * validation does not change the scores (they describe the diff) but is surfaced as a warning. Pure. - */ -export function computeLocalScorerTokens(input: { changedFiles: LocalBranchChangedFile[]; validation?: LocalBranchValidation[] | undefined }): LocalBranchScorer { - const files = input.changedFiles.filter((file) => !file.binary); - const testTokenScore = files.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); - const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); - const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0); - const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore); - const failed = (input.validation ?? []).some((entry) => entry.status === "failed"); - const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : []; - return { - mode: "external_command", - activeModel: "gittensory-deterministic", - sourceTokenScore, - totalTokenScore, - sourceLines: Math.max(1, sourceTokenScore || totalTokenScore || 1), - testTokenScore, - nonCodeTokenScore, - ...(warnings.length > 0 ? { warnings } : {}), - }; -} +// #782 deterministic local scorer — extracted to `@jsonbored/gittensory-engine` (#4253) so the published +// gittensory-mcp / gittensory-miner CLIs and the hosted Worker import the identical, versioned scoring logic +// instead of drifting. The Vectorize/Node-coupled local-branch.ts is intentionally NOT moved; this shim only +// re-exports the pure scorer. packages/gittensory-engine/src/local-scorer.ts (imported via relative source +// path, matching the #2278/#2282/#4254 extraction shims) is the source of truth. +export { computeLocalScorerTokens } from "../../packages/gittensory-engine/src/local-scorer";