From 2edcb6e9fa0bfa746446d2d165b03b928a4e6a5d Mon Sep 17 00:00:00 2001 From: nghetienhiep <13849419+nghetienhiep@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:43:19 +0000 Subject: [PATCH] feat(mcp): compute loopover_lint_pr_text in-process from the shared engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local MCP server's loopover_lint_pr_text proxied over HTTP (apiPost("/v1/lint/pr-text", …)) because buildPrTextLint lived in the host-bound signals/engine.ts, which is excluded from @loopover/engine's tsc emit and so never reached the published dist/. Extract buildPrTextLint and its types into a self-contained, buildable signals/pr-text-lint.ts that reuses the already-extracted shared rubric (GENERIC_COMMIT_PATTERN/hasClearNoIssueRationale from slop.ts, tokenize from predicted-gate-engine.ts) so there is one definition, not a mirror. signals/engine.ts re-exports it, keeping the API route and remote MCP server unchanged, and the engine barrel now exposes it. Bump loopover-mcp's @loopover/engine dependency from ^1.0.0 (which resolved the stale registry 1.0.0 predating this export) to ^3.0.0 so it links the workspace engine like loopover-miner already does, then call buildPrTextLint directly in the tool handler. A user running the local MCP server can now lint PR text fully offline, matching the remote server's in-process behavior. Closes #6268 --- package-lock.json | 17 +- packages/loopover-engine/src/index.ts | 8 + .../loopover-engine/src/signals/engine.ts | 168 +---------------- .../src/signals/pr-text-lint.ts | 175 ++++++++++++++++++ packages/loopover-mcp/bin/loopover-mcp.js | 8 +- packages/loopover-mcp/package.json | 2 +- test/unit/mcp-lint-pr-text.test.ts | 84 +++++++++ 7 files changed, 280 insertions(+), 182 deletions(-) create mode 100644 packages/loopover-engine/src/signals/pr-text-lint.ts create mode 100644 test/unit/mcp-lint-pr-text.test.ts diff --git a/package-lock.json b/package-lock.json index 3cbf32b4b6..c4c71a0291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20715,7 +20715,7 @@ "version": "3.0.0", "license": "AGPL-3.0-only", "dependencies": { - "@loopover/engine": "^1.0.0", + "@loopover/engine": "^3.0.0", "@modelcontextprotocol/sdk": "1.29.0", "zod": "^4.4.3" }, @@ -20726,21 +20726,6 @@ "node": ">=22.0.0" } }, - "packages/loopover-mcp/node_modules/@loopover/engine": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@loopover/engine/-/engine-1.0.0.tgz", - "integrity": "sha512-5OULTIoyl3ttEVZ0Sa9D8y89CbyM7n1CZFqhhx3EHP9YpYC+65xJz4u892d6a3U1f7lra9Bf2DrWwJr/GFHOIA==", - "license": "AGPL-3.0-only", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.205", - "tree-sitter-wasms": "^0.1.13", - "web-tree-sitter": "^0.20.8", - "yaml": "^2.9.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, "packages/loopover-miner": { "name": "@loopover/miner", "version": "3.0.0", diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 2360fdc261..e60e780f6d 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -420,6 +420,14 @@ export type { CollisionItem } from "./types/predicted-gate-types.js"; // `./signals/engine.js` — that file is excluded from this package's tsc emit (host-bound imports) and // pulling it into the public barrel breaks `npm run build` (closed #6139). export { buildIssueQualityReport } from "./signals/issue-quality-report.js"; +// Deterministic PR-text-lint rubric (#549), extracted from the host-bound `signals/engine.ts` (#6268) so the +// published loopover-mcp CLI can compute `loopover_lint_pr_text` in-process instead of proxying over HTTP. +export { + buildPrTextLint, + type PrTextLintComponent, + type PrTextLintInput, + type PrTextLintReport, +} from "./signals/pr-text-lint.js"; // Unlinked-issue candidate pre-filter (#4883), extracted out of src/signals/unlinked-issue-candidates.ts so the // miner's self-review can run the SAME deterministic recall pass the maintainer gate uses to flag a PR's // likely-but-unlinked issue, instead of a driftable copy. PURE — no IO, no AI call. diff --git a/packages/loopover-engine/src/signals/engine.ts b/packages/loopover-engine/src/signals/engine.ts index 6cbd08add8..e7c10e8683 100644 --- a/packages/loopover-engine/src/signals/engine.ts +++ b/packages/loopover-engine/src/signals/engine.ts @@ -4829,168 +4829,12 @@ function queuePressureOpenPullRequestScore(openPullRequests: number): number { return 3; } -export type PrTextLintInput = { - commitMessages?: string[] | undefined; - prBody?: string | undefined; - linkedIssue?: number | undefined; -}; - -export type PrTextLintComponent = { - key: "traceability" | "commit_message" | "pr_body" | "validation_evidence"; - label: string; - status: "ok" | "weak"; - evidence: string; - fix?: string | undefined; -}; - -export type PrTextLintReport = { - generatedAt: string; - verdict: "strong" | "adequate" | "weak"; - /** - * 0-100 PR-text quality score from the deterministic rubric (sum of per-component weights; weak - * components score 25% of their weight). Advisory sub-signal only — `verdict` is authoritative. - * Because traceability is a hard gate for the verdict but only one weighted component of the score, - * the two can rank-disagree (e.g. a strong commit + body with no linked issue scores ~81 yet the - * verdict is "weak"). Rank by `verdict`, not `score`. Not a Gittensor reward/trust score. - */ - score: number; - components: PrTextLintComponent[]; - fixes: string[]; - summary: string; -}; - -// Exported so the deterministic slop signal (#564) and the #549 lint tool share ONE definition of a -// "generic" commit subject — a single low-effort word (wip / fix / update / "." …) that is the whole subject. -export const GENERIC_COMMIT_PATTERN = /^(?:(?:wip|fix(?:es|ed|ing)?|updat(?:e|es|ed|ing)|change[sd]?|edit[sd]?|patch|minor|tweak[sd]?|misc|cleanup|chore|stuff|temp|tmp|test|final|done|commit|asdf+)\b|\.+)[\s.!]*$/i; -// Conventional Commit subject: one of CONTRIBUTING's allowed types, optional `(scope)`, optional `!`, -// then `: ` and a non-empty summary (e.g. `feat(api): add cursor pagination`). Single source of truth -// with CONTRIBUTING.md "Commit And PR Titles". -const CONVENTIONAL_COMMIT_PATTERN = /^(?:feat|fix|test|docs|refactor|build|ci|chore|revert)(?:\([^()\r\n]+\))?!?:\s+\S/i; -const PR_TEXT_LINT_WEIGHTS = { traceability: 25, commit_message: 30, pr_body: 30, validation_evidence: 15 } as const; - -function stripPrBodyScaffolding(body: string): string { - return body - .replace(//g, " ") - .replace(/^#{1,6}\s.*$/gm, " ") - .replace(/^\s*[-*]\s*\[[ xX]\]/gm, " ") - .replace(/[#>*_`[\]()]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -/** - * Deterministic commit-message + PR-body rubric linter. Catches generic/empty AI-slop text before - * submit and returns a quality verdict plus specific, public-safe fixes. Grades four dimensions: - * traceability (25 pts), commit message (30 pts), PR body (30 pts), validation evidence (15 pts). - * Reuses the gittensor traceability/no-issue-rationale rubric ({@link hasClearNoIssueRationale}, - * {@link tokenize}, {@link STOPWORDS}) shared with the public readiness score. All output is routed - * through {@link sanitizePublicComment}; no private scoring is exposed. - */ -export function buildPrTextLint(input: PrTextLintInput): PrTextLintReport { - const commitMessages = (input.commitMessages ?? []).map((message) => message.trim()).filter((message) => message.length > 0); - const prBody = (input.prBody ?? "").trim(); - const linkedIssue = typeof input.linkedIssue === "number" && input.linkedIssue > 0 ? input.linkedIssue : undefined; - - const hasRationale = hasClearNoIssueRationale({ title: "", body: prBody }); - const traceabilityOk = linkedIssue !== undefined || hasRationale; - const traceability: PrTextLintComponent = traceabilityOk - ? { - key: "traceability", - label: "Traceability", - status: "ok", - evidence: linkedIssue !== undefined ? `Linked issue #${linkedIssue}.` : "PR body includes a no-issue rationale.", - } - : { - key: "traceability", - label: "Traceability", - status: "weak", - evidence: "No linked issue and no no-issue rationale in the PR body.", - fix: 'Link the issue this PR resolves (e.g. "Fixes #123"), or explain in the body why no issue applies.', - }; - - const primaryCommit = commitMessages[0] ?? ""; - const commitTokens = tokenize(commitMessages.join(" ")); - const commitGeneric = primaryCommit.length > 0 && GENERIC_COMMIT_PATTERN.test(primaryCommit); - // The `^`-anchored pattern matches against the subject line at the start of the message. - const commitConventional = CONVENTIONAL_COMMIT_PATTERN.test(primaryCommit); - const commitOk = commitConventional && primaryCommit.length >= 15 && commitTokens.length >= 2 && !commitGeneric; - const commitMessage: PrTextLintComponent = commitOk - ? { key: "commit_message", label: "Commit message", status: "ok", evidence: "Commit message is specific and follows Conventional Commit format." } - : { - key: "commit_message", - label: "Commit message", - status: "weak", - evidence: - commitMessages.length === 0 - ? "No commit message was provided." - : commitGeneric - ? "Commit message is generic (e.g. update/fix/wip)." - : !commitConventional - ? "Commit message does not follow Conventional Commit format (type(scope): summary)." - : "Commit message is too short or lacks specific detail.", - fix: "Use a Conventional Commit subject (type(scope): summary, e.g. feat(api): add cursor pagination) that names what changed and why; avoid generic words like update, fix, or wip on their own.", - }; - - const strippedBody = stripPrBodyScaffolding(prBody); - const bodyTokens = tokenize(strippedBody); - const bodyLooksTemplated = prBody.length > 0 && /\[[ xX]\]|/g, " ") + .replace(/^#{1,6}\s.*$/gm, " ") + .replace(/^\s*[-*]\s*\[[ xX]\]/gm, " ") + .replace(/[#>*_`[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Deterministic commit-message + PR-body rubric linter. Catches generic/empty AI-slop text before + * submit and returns a quality verdict plus specific, public-safe fixes. Grades four dimensions: + * traceability (25 pts), commit message (30 pts), PR body (30 pts), validation evidence (15 pts). + * Reuses the gittensor traceability/no-issue-rationale rubric ({@link hasClearNoIssueRationale}, + * {@link tokenize}) shared with the public readiness score. All output is routed through + * {@link sanitizePublicComment}; no private scoring is exposed. + */ +export function buildPrTextLint(input: PrTextLintInput): PrTextLintReport { + const commitMessages = (input.commitMessages ?? []).map((message) => message.trim()).filter((message) => message.length > 0); + const prBody = (input.prBody ?? "").trim(); + const linkedIssue = typeof input.linkedIssue === "number" && input.linkedIssue > 0 ? input.linkedIssue : undefined; + + const hasRationale = hasClearNoIssueRationale({ title: "", body: prBody }); + const traceabilityOk = linkedIssue !== undefined || hasRationale; + const traceability: PrTextLintComponent = traceabilityOk + ? { + key: "traceability", + label: "Traceability", + status: "ok", + evidence: linkedIssue !== undefined ? `Linked issue #${linkedIssue}.` : "PR body includes a no-issue rationale.", + } + : { + key: "traceability", + label: "Traceability", + status: "weak", + evidence: "No linked issue and no no-issue rationale in the PR body.", + fix: 'Link the issue this PR resolves (e.g. "Fixes #123"), or explain in the body why no issue applies.', + }; + + const primaryCommit = commitMessages[0] ?? ""; + const commitTokens = tokenize(commitMessages.join(" ")); + const commitGeneric = primaryCommit.length > 0 && GENERIC_COMMIT_PATTERN.test(primaryCommit); + // The `^`-anchored pattern matches against the subject line at the start of the message. + const commitConventional = CONVENTIONAL_COMMIT_PATTERN.test(primaryCommit); + const commitOk = commitConventional && primaryCommit.length >= 15 && commitTokens.length >= 2 && !commitGeneric; + const commitMessage: PrTextLintComponent = commitOk + ? { key: "commit_message", label: "Commit message", status: "ok", evidence: "Commit message is specific and follows Conventional Commit format." } + : { + key: "commit_message", + label: "Commit message", + status: "weak", + evidence: + commitMessages.length === 0 + ? "No commit message was provided." + : commitGeneric + ? "Commit message is generic (e.g. update/fix/wip)." + : !commitConventional + ? "Commit message does not follow Conventional Commit format (type(scope): summary)." + : "Commit message is too short or lacks specific detail.", + fix: "Use a Conventional Commit subject (type(scope): summary, e.g. feat(api): add cursor pagination) that names what changed and why; avoid generic words like update, fix, or wip on their own.", + }; + + const strippedBody = stripPrBodyScaffolding(prBody); + const bodyTokens = tokenize(strippedBody); + const bodyLooksTemplated = prBody.length > 0 && /\[[ xX]\]|