diff --git a/package.json b/package.json index 6fe3dedf83..5fee805ee0 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs", "docs:drift-check": "node scripts/check-docs-drift.mjs", "manifest:drift-check": "tsx scripts/check-manifest-drift.mjs", + "engine-parity:drift-check": "tsx scripts/check-engine-parity.ts", "ui:deploy": "npm run ui:build && npm run ui:deploy:built", "ui:deploy:built": "wrangler deploy --config apps/gittensory-ui/dist/server/wrangler.json", "ui:version:built": "wrangler versions upload --config apps/gittensory-ui/dist/server/wrangler.json", @@ -75,7 +76,7 @@ "test:smoke:observability": "node scripts/smoke-observability-traces.mjs", "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node scripts/smoke-ui-browser.mjs", - "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test --workspace @jsonbored/gittensory-engine && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run test:miner-pack && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run manifest:drift-check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test --workspace @jsonbored/gittensory-engine && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run test:miner-pack && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/packages/gittensory-engine/src/review/guardrail-config.ts b/packages/gittensory-engine/src/review/guardrail-config.ts index 521f2460c3..1900d8415b 100644 --- a/packages/gittensory-engine/src/review/guardrail-config.ts +++ b/packages/gittensory-engine/src/review/guardrail-config.ts @@ -30,6 +30,9 @@ export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ "src/github/pr-actions.ts", "src/github/app.ts", "src/github/backfill.ts", + // #4197: writes a real commit onto a CONTRIBUTOR's own PR branch (not a branch gittensory owns) — the same + // guardrail tier as pr-actions.ts/app.ts for the same reason, a new GitHub-write surface. + "src/github/e2e-test-commit.ts", "src/scoring/**", "src/auth/**", "src/review/safety.ts", diff --git a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts index d80c4bc284..1f2c820775 100644 --- a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts +++ b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts @@ -49,9 +49,9 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li return null; } // Unlike `removeOtherTypeLabels`, a malformed value here can only ever be warned-and-defaulted (never - // dropped) -- defaulting to `undefined`/strict is always the SAFE direction, so there is no silent-flip - // risk that would justify discarding an otherwise-valid mapping over it. Mirrors `src/review/linked-issue- - // label-propagation.ts`'s copy of this normalizer. + // dropped) -- defaulting to `undefined`/strict is always the SAFE direction (no mapping accidentally + // starts trusting maintainer-authored issues), so there is no silent-flip risk that would justify + // discarding an otherwise-valid mapping over it. let trustMaintainerAuthoredIssue: boolean | undefined; if (record.trustMaintainerAuthoredIssue !== undefined) { if (typeof record.trustMaintainerAuthoredIssue === "boolean") { diff --git a/packages/gittensory-engine/src/settings/pr-type-label.ts b/packages/gittensory-engine/src/settings/pr-type-label.ts index f834d543ac..3a80c7fe13 100644 --- a/packages/gittensory-engine/src/settings/pr-type-label.ts +++ b/packages/gittensory-engine/src/settings/pr-type-label.ts @@ -14,7 +14,7 @@ // moves away from it. Public + neutral categorization (NOT the reputation signal). Review-time + // independent of the gate / autonomy / dry-run (matches reviewbot, where auto-label runs at review // start). Fail-safe. -import type { LinkedIssueLabelPropagationConfig, PrTypeLabelSet } from "../types/manifest-deps-types.js"; +import type { LinkedIssueLabelPropagationConfig, LinkedIssueLabelPropagationMapping, PrTypeLabelSet } from "../types/manifest-deps-types.js"; export type { PrTypeLabelSet } from "../types/manifest-deps-types.js"; @@ -156,9 +156,25 @@ export function resolvePrTypeLabel(input: { if (input.propagation?.enabled) { const wanted = new Set((input.linkedIssueLabels ?? []).map((label) => label.toLowerCase())); + // Collect EVERY mapping the linked issue's labels satisfy, not just the first. An exclusive mapping + // (removeOtherTypeLabels: true -- e.g. bug/feature, genuinely mutually-exclusive categories) still only + // ever lets the FIRST-configured match win, same precedence as before. But an additive mapping (e.g. + // priority -- a maintainer-hand-picked reward tag that coexists WITH whichever type already applies, not a + // type of its own) must compose with that winner instead of being skipped just because an earlier mapping + // in the array already matched and returned. Before this, an additive match was unreachable whenever the + // SAME linked issue also carried a label an earlier (exclusive) mapping matched -- the overwhelmingly common + // case for gittensor:priority, which is applied ALONGSIDE gittensor:bug/gittensor:feature on the issue, never + // instead of it (#priority-linked-issue-gate). + let exclusiveMatch: LinkedIssueLabelPropagationMapping | undefined; + const additiveMatches: LinkedIssueLabelPropagationMapping[] = []; for (const mapping of input.propagation.mappings) { if (!wanted.has(mapping.issueLabel.toLowerCase())) continue; - return mapping.removeOtherTypeLabels ? decide([mapping.prLabel], "propagation_exclusive") : decide([titleLabel, mapping.prLabel], "propagation_additive"); + if (mapping.removeOtherTypeLabels) exclusiveMatch ??= mapping; + else additiveMatches.push(mapping); + } + if (exclusiveMatch || additiveMatches.length > 0) { + const applyLabels = [exclusiveMatch ? exclusiveMatch.prLabel : titleLabel, ...additiveMatches.map((mapping) => mapping.prLabel)]; + return decide(applyLabels, exclusiveMatch ? "propagation_exclusive" : "propagation_additive"); } } return decide([titleLabel], "title"); diff --git a/packages/gittensory-miner/expected-engine.version b/packages/gittensory-miner/expected-engine.version new file mode 100644 index 0000000000..0ea3a944b3 --- /dev/null +++ b/packages/gittensory-miner/expected-engine.version @@ -0,0 +1 @@ +0.2.0 diff --git a/packages/gittensory-miner/lib/status.d.ts b/packages/gittensory-miner/lib/status.d.ts index 62edb3e2b0..0cd0eb71ba 100644 --- a/packages/gittensory-miner/lib/status.d.ts +++ b/packages/gittensory-miner/lib/status.d.ts @@ -21,3 +21,26 @@ export function runStatus(args?: string[], env?: Record): DoctorCheck[]; export function runDoctor(args?: string[], env?: Record): number; + +export function readInstalledEnginePackageVersionFromPaths( + resolvedEntry: string, + workspacePkg: string, + deps?: { existsSync: (path: string) => boolean; readFileSync: (path: string, encoding: "utf8") => string }, +): string | null; + +export function readInstalledEnginePackageVersion(): string | null; + +export function readExpectedEnginePackageVersionFromPaths( + monorepoEnginePkg: string, + pinFile: string, + deps?: { existsSync: (path: string) => boolean; readFileSync: (path: string, encoding: "utf8") => string }, +): string | null; + +export function readExpectedEnginePackageVersion(): string | null; + +export function compareInstalledEngineVersion(installed: string, expected: string): -1 | 0 | 1; + +export function buildEngineVersionSkewCheck( + readInstalled?: () => string | null, + readExpected?: () => string | null, +): DoctorCheck; diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index fbe1f286cc..3883540497 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -45,6 +45,128 @@ function readEngineVersion() { } } +export function readInstalledEnginePackageVersionFromPaths( + resolvedEntry, + workspacePkg, + deps = { existsSync, readFileSync }, +) { + try { + for (const pkgJson of [join(resolvedEntry, "..", "package.json"), join(resolvedEntry, "..", "..", "package.json")]) { + if (deps.existsSync(pkgJson)) { + const version = JSON.parse(deps.readFileSync(pkgJson, "utf8")).version; + if (version) return version; + } + } + } catch { + // fall through to monorepo workspace fallback + } + if (deps.existsSync(workspacePkg)) { + try { + return JSON.parse(deps.readFileSync(workspacePkg, "utf8")).version ?? null; + } catch { + return null; + } + } + return null; +} + +/** Installed @jsonbored/gittensory-engine semver from node_modules (not the declared dependency range). */ +export function readInstalledEnginePackageVersion() { + try { + return readInstalledEnginePackageVersionFromPaths( + require.resolve(ENGINE_PACKAGE), + join(__dirname, "../../gittensory-engine/package.json"), + ); + } catch { + const workspacePkg = join(__dirname, "../../gittensory-engine/package.json"); + if (existsSync(workspacePkg)) { + try { + return JSON.parse(readFileSync(workspacePkg, "utf8")).version ?? null; + } catch { + return null; + } + } + return null; + } +} + +/** Expected minimum engine semver: monorepo engine package.json when present, else the shipped pin file. */ +export function readExpectedEnginePackageVersionFromPaths( + monorepoEnginePkg, + pinFile, + deps = { existsSync, readFileSync }, +) { + if (deps.existsSync(monorepoEnginePkg)) { + try { + return JSON.parse(deps.readFileSync(monorepoEnginePkg, "utf8")).version ?? null; + } catch { + return null; + } + } + try { + const pinned = deps.readFileSync(pinFile, "utf8").trim(); + return pinned || null; + } catch { + return null; + } +} + +export function readExpectedEnginePackageVersion() { + return readExpectedEnginePackageVersionFromPaths( + join(__dirname, "../../gittensory-engine/package.json"), + join(__dirname, "../expected-engine.version"), + ); +} + +function parseSemverCore(version) { + const match = String(version).trim().match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** Returns -1 when installed is behind expected, 0 when equal, 1 when ahead. */ +export function compareInstalledEngineVersion(installed, expected) { + const installedCore = parseSemverCore(installed); + const expectedCore = parseSemverCore(expected); + if (!installedCore || !expectedCore) return -1; + for (let index = 0; index < 3; index += 1) { + if (installedCore[index] < expectedCore[index]) return -1; + if (installedCore[index] > expectedCore[index]) return 1; + } + return 0; +} + +export function buildEngineVersionSkewCheck( + readInstalled = readInstalledEnginePackageVersion, + readExpected = readExpectedEnginePackageVersion, +) { + const installed = readInstalled(); + const expected = readExpected(); + if (!expected) { + return { name: "engine-version-skew", ok: true, detail: "expected engine version unavailable (skipped)" }; + } + if (!installed) { + return { + name: "engine-version-skew", + ok: false, + detail: `${ENGINE_PACKAGE} not installed (cannot verify version skew)`, + }; + } + const comparison = compareInstalledEngineVersion(installed, expected); + return { + name: "engine-version-skew", + ok: comparison >= 0, + detail: + comparison < 0 + ? `installed ${installed} is behind expected ${expected}` + : `installed ${installed} (${comparison === 0 ? "matches" : "ahead of"} expected ${expected})`, + }; +} + +function checkEngineVersionSkew() { + return buildEngineVersionSkewCheck(); +} + /** The minimum Node major version from the package's `engines.node` floor (e.g. ">=22.13.0" → 22). */ function requiredNodeMajor() { const engines = require("../package.json").engines; @@ -122,6 +244,7 @@ export function runDoctorChecks(env = process.env) { ok: engineVersion !== null, detail: engineVersion ? `${ENGINE_PACKAGE} ${engineVersion}` : `${ENGINE_PACKAGE} not resolvable`, }, + checkEngineVersionSkew(), checkStateDirWritable(resolveMinerStateDir(env)), checkLaptopStateSqlite(env), checkDockerPresent(), diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 7aba5d88d3..da88ef0502 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -28,7 +28,8 @@ }, "files": [ "bin", - "lib" + "lib", + "expected-engine.version" ], "scripts": { "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" diff --git a/scripts/check-engine-parity.ts b/scripts/check-engine-parity.ts new file mode 100644 index 0000000000..a9fbfa969a --- /dev/null +++ b/scripts/check-engine-parity.ts @@ -0,0 +1,305 @@ +#!/usr/bin/env node +// Mechanical drift tripwire for hand-duplicated src/ <-> gittensory-engine file pairs (#4260). Most src/{review, +// settings,signals} modules are thin re-export shims over the engine, but ~15 twin files are still maintained in +// parallel — this script discovers those pairs, normalizes known-harmless import-path aliases, and fails CI when +// the normalized bodies diverge. Also compares the workspace-installed @jsonbored/gittensory-engine semver against +// the monorepo engine package's declared version (version-skew tripwire; no live-gate round-trip). +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const ENGINE_PARITY_AREAS = Object.freeze(["review", "settings", "signals"] as const); +const ENGINE_SRC_ROOT = "packages/gittensory-engine/src"; +const HOST_SRC_ROOT = "src"; +const ENGINE_PACKAGE_JSON = "packages/gittensory-engine/package.json"; +const MINER_ENGINE_PIN_FILE = "packages/gittensory-miner/expected-engine.version"; +const ENGINE_PACKAGE_NAME = "@jsonbored/gittensory-engine"; + +export type EngineParityPair = { + area: string; + fileName: string; + hostRelative: string; + engineRelative: string; + hostText: string; + engineText: string; +}; + +export type EngineParityReadFile = (root: string, relativePath: string) => string; +export type EngineParityListDir = (root: string, relativePath: string) => string[]; + +function defaultReadFile(root: string, relativePath: string): string { + return readFileSync(join(root, relativePath), "utf8"); +} + +function defaultListDir(root: string, relativePath: string): string[] { + try { + return readdirSync(join(root, relativePath)); + } catch { + return []; + } +} + +/** Map equivalent relative import paths so import-only drift between host and engine copies does not false-fail. */ +export function normalizeImportSpec(spec: string): string { + let normalized = spec; + if (normalized.endsWith(".js")) normalized = normalized.slice(0, -3); + if (/^\.\.\/types\/[\w-]+$/.test(normalized)) normalized = "../types"; + if (normalized === "../focus-manifest/guidance") normalized = "../signals/focus-manifest"; + return normalized; +} + +/** Normalize line endings and canonicalize relative `from` specifiers before byte comparison. */ +export function normalizeEngineParityText(text: string): string { + return text + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => + line.replace(/from\s+['"](\.\.\/[^'"]+)['"]/g, (_match, spec: string) => `from "${normalizeImportSpec(spec)}"`), + ) + .join("\n"); +} + +/** True when the host copy is only a thin re-export of the engine module (not a hand-duplicated twin). */ +export function isThinEngineReExportShim(srcText: string): boolean { + const stripped = srcText + .replace(/\/\*[\s\S]*?\*\//g, "") + .split("\n") + .map((line) => line.replace(/\/\/.*$/, "").trim()) + .filter(Boolean) + .join("\n"); + return /^export\s+(\{[\s\S]*\}|\*)\s+from\s+['"][^'"]*gittensory-engine[^'"]*['"];?\s*$/.test(stripped); +} + +/** True when the engine twin is a placeholder stub (e.g. check-names) rather than a full parallel copy. + * Thresholds mirror the 2026-07-08 audit: engine stubs were <250 non-whitespace chars while host copies were + * 3×+ larger (check-names ~14 lines vs engine ~2; review-thread-findings ~98 vs ~2). */ +export function isEngineStubPair(srcText: string, engineText: string): boolean { + const compact = (text: string) => text.replace(/\s/g, "").length; + const engineCompact = compact(engineText); + const srcCompact = compact(srcText); + return engineCompact > 0 && srcCompact > engineCompact * 3 && engineCompact < 250; +} + +/** + * Discover in-scope hand-duplicated twins under src/{review,settings,signals} that also exist in the engine tree + * and are neither host shims nor engine stubs. + */ +export function discoverEngineParityPairs({ + root, + listDir = defaultListDir, + readFile = defaultReadFile, +}: { + root: string; + listDir?: EngineParityListDir; + readFile?: EngineParityReadFile; +}): EngineParityPair[] { + const pairs: EngineParityPair[] = []; + for (const area of ENGINE_PARITY_AREAS) { + const hostDir = join(HOST_SRC_ROOT, area); + const engineDir = join(ENGINE_SRC_ROOT, area); + const hostFiles = listDir(root, hostDir).filter((name) => name.endsWith(".ts")); + const engineFiles = new Set(listDir(root, engineDir).filter((name) => name.endsWith(".ts"))); + for (const fileName of hostFiles.sort()) { + if (!engineFiles.has(fileName)) continue; + const hostRelative = join(hostDir, fileName); + const engineRelative = join(engineDir, fileName); + const hostText = readFile(root, hostRelative); + const engineText = readFile(root, engineRelative); + if (isThinEngineReExportShim(hostText)) continue; + if (isEngineStubPair(hostText, engineText)) continue; + pairs.push({ area, fileName, hostRelative, engineRelative, hostText, engineText }); + } + } + return pairs; +} + +/** + * Compare normalized bodies of every discovered pair. Returns `{ failures, pairsChecked }` — pure given injectable IO. + */ +export function checkEngineParityDrift({ + root, + readFile = defaultReadFile, + listDir = defaultListDir, +}: { + root: string; + readFile?: EngineParityReadFile; + listDir?: EngineParityListDir; +}): { failures: string[]; pairsChecked: EngineParityPair[] } { + const pairs = discoverEngineParityPairs({ root, readFile, listDir }); + const failures: string[] = []; + for (const pair of pairs) { + const normalizedHost = normalizeEngineParityText(pair.hostText); + const normalizedEngine = normalizeEngineParityText(pair.engineText); + if (normalizedHost !== normalizedEngine) { + failures.push( + [ + `${pair.hostRelative} and ${pair.engineRelative} have drifted apart (normalized comparison).`, + `Edit both copies together or convert the host file to a thin engine re-export shim.`, + ].join("\n"), + ); + } + } + return { failures, pairsChecked: pairs }; +} + +/** Parse `major.minor.patch` prefix; non-numeric prerelease segments compare as equal at the patch level. */ +export function parseSemverCore(version: string): [number, number, number] | null { + const match = String(version).trim().match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** + * Compare two semver strings. Returns `-1` (installed behind expected), `0` (equal), or `1` (installed ahead). + * Unparseable versions are treated as behind so the skew check fails loudly. + */ +export function compareSemver(installed: string, expected: string): -1 | 0 | 1 { + const installedCore = parseSemverCore(installed); + const expectedCore = parseSemverCore(expected); + if (!installedCore || !expectedCore) return -1; + for (let index = 0; index < 3; index += 1) { + if (installedCore[index]! < expectedCore[index]!) return -1; + if (installedCore[index]! > expectedCore[index]!) return 1; + } + return 0; +} + +/** Human-readable skew label for doctor output and test assertions. */ +export function describeEngineVersionSkew(installed: string, expected: string): "behind" | "equal" | "ahead" { + const comparison = compareSemver(installed, expected); + if (comparison < 0) return "behind"; + if (comparison > 0) return "ahead"; + return "equal"; +} + +export function defaultResolveInstalledEngineVersion(root: string): string | null { + try { + const engineEntry = join(root, "node_modules", ENGINE_PACKAGE_NAME, "package.json"); + if (!existsSync(engineEntry)) return null; + return JSON.parse(readFileSync(engineEntry, "utf8")).version ?? null; + } catch { + return null; + } +} + +export function defaultReadExpectedEngineVersion(root: string, readFile: EngineParityReadFile = defaultReadFile): string | null { + try { + const text = readFile(root, ENGINE_PACKAGE_JSON); + return JSON.parse(text).version ?? null; + } catch { + return null; + } +} + +export type EngineVersionSkewResult = { + failures: string[]; + installed: string | null; + expected: string | null; + skew: string; +}; + +/** + * Version-skew tripwire: installed @jsonbored/gittensory-engine must be >= the monorepo engine package version. + * Returns `{ failures, installed, expected, skew }`. + */ +export function checkEngineVersionSkew({ + root, + readFile = defaultReadFile, + resolveInstalled = defaultResolveInstalledEngineVersion, + readExpected = (r) => defaultReadExpectedEngineVersion(r, readFile), +}: { + root: string; + readFile?: EngineParityReadFile; + resolveInstalled?: (root: string) => string | null; + readExpected?: (root: string) => string | null; +}): EngineVersionSkewResult { + const failures: string[] = []; + const installed = resolveInstalled(root); + const expected = readExpected(root); + const skew = installed && expected ? describeEngineVersionSkew(installed, expected) : "unknown"; + + if (!expected) { + failures.push(`Could not read expected engine version from ${ENGINE_PACKAGE_JSON}.`); + } else if (!installed) { + failures.push(`${ENGINE_PACKAGE_NAME} is not installed under node_modules (cannot verify version skew).`); + } else if (compareSemver(installed, expected) < 0) { + failures.push( + `${ENGINE_PACKAGE_NAME} version skew: installed ${installed} is behind expected minimum ${expected}.`, + ); + } + + return { failures, installed, expected, skew }; +} + +/** Fail when the published-miner pin drifts from the monorepo engine package version. */ +export function checkMinerEngineVersionPinSync({ + root, + readFile = defaultReadFile, + readExpected = (r) => defaultReadExpectedEngineVersion(r, readFile), +}: { + root: string; + readFile?: EngineParityReadFile; + readExpected?: (root: string) => string | null; +}): { failures: string[]; expected: string | null; pin: string | null } { + const failures: string[] = []; + const expected = readExpected(root); + let pin: string | null = null; + try { + pin = readFile(root, MINER_ENGINE_PIN_FILE).trim() || null; + } catch { + pin = null; + } + if (expected && pin && expected !== pin) { + failures.push( + `${MINER_ENGINE_PIN_FILE} (${pin}) is out of sync with ${ENGINE_PACKAGE_JSON} (${expected}).`, + ); + } else if (expected && !pin) { + failures.push(`Could not read miner engine version pin from ${MINER_ENGINE_PIN_FILE}.`); + } + return { failures, expected, pin }; +} + +/** Run both the file-pair drift check and the version-skew check. */ +export function runEngineParityChecks(options: { + root: string; + readFile?: EngineParityReadFile; + listDir?: EngineParityListDir; + resolveInstalled?: (root: string) => string | null; + readExpected?: (root: string) => string | null; +}): { + failures: string[]; + pairsChecked: EngineParityPair[]; + versionSkew: EngineVersionSkewResult; +} { + const drift = checkEngineParityDrift(options); + const skew = checkEngineVersionSkew(options); + const pinSync = checkMinerEngineVersionPinSync(options); + return { + failures: [...drift.failures, ...skew.failures, ...pinSync.failures], + pairsChecked: drift.pairsChecked, + versionSkew: skew, + }; +} + +/** @internal Exported for subprocess-free unit tests of the CLI success/failure paths. */ +export function runEngineParityMain(root: string = process.cwd()): number { + const { failures, pairsChecked, versionSkew } = runEngineParityChecks({ root }); + + if (failures.length > 0) { + console.error(`Engine-parity check found ${failures.length} issue(s):`); + for (const failure of failures) console.error(failure); + return 1; + } + + console.log( + `Engine-parity check ok: ${pairsChecked.length} hand-duplicated file pair(s) agree; ` + + `${ENGINE_PACKAGE_NAME} ${versionSkew.installed} is ${versionSkew.skew} vs expected ${versionSkew.expected}.`, + ); + return 0; +} + +function main(): void { + process.exit(runEngineParityMain(process.cwd())); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/check-miner-package.mjs b/scripts/check-miner-package.mjs index a7357fc3de..f88c823b38 100644 --- a/scripts/check-miner-package.mjs +++ b/scripts/check-miner-package.mjs @@ -9,6 +9,7 @@ const ALLOWED = [ /^lib\/[a-z0-9-]+\.(js|d\.ts)$/, /^package\.json$/, /^README\.md$/, + /^expected-engine\.version$/, ]; const REQUIRED = ["bin/gittensory-miner.js", "package.json"]; const FORBIDDEN_PATH = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; diff --git a/src/signals/change-guardrail.ts b/src/signals/change-guardrail.ts index d8a8744c2f..91142a2544 100644 --- a/src/signals/change-guardrail.ts +++ b/src/signals/change-guardrail.ts @@ -143,7 +143,11 @@ export function guardrailPathMatches(changedPaths: string[], hardGuardrailGlobs: if (path.length === 0) continue; const canonicalPath = canonicalize(path); for (const glob of hardGuardrailGlobs) { - if (hasUnsafeWildcardCount(glob) || globToRegExp(glob).test(canonicalPath)) { + if (hasUnsafeWildcardCount(glob)) { + matches.push({ path, glob }); + continue; + } + if (globToRegExp(glob).test(canonicalPath)) { matches.push({ path, glob }); } } diff --git a/test/unit/check-engine-parity-script.test.ts b/test/unit/check-engine-parity-script.test.ts new file mode 100644 index 0000000000..8173fbbe1c --- /dev/null +++ b/test/unit/check-engine-parity-script.test.ts @@ -0,0 +1,248 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + checkEngineParityDrift, + checkEngineVersionSkew, + checkMinerEngineVersionPinSync, + compareSemver, + defaultReadExpectedEngineVersion, + defaultResolveInstalledEngineVersion, + describeEngineVersionSkew, + discoverEngineParityPairs, + type EngineParityPair, + isEngineStubPair, + isThinEngineReExportShim, + normalizeEngineParityText, + normalizeImportSpec, + runEngineParityChecks, + runEngineParityMain, +} from "../../scripts/check-engine-parity"; + +const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx"); + +describe("check-engine-parity script", () => { + it("normalizes known-harmless import-path aliases", () => { + expect(normalizeImportSpec("../types/predicted-gate-types.js")).toBe("../types"); + expect(normalizeImportSpec("../focus-manifest/guidance.js")).toBe("../signals/focus-manifest"); + const host = 'import type { X } from "../types/predicted-gate-types";\n'; + const engine = 'import type { X } from "../types/manifest-deps-types.js";\n'; + expect(normalizeEngineParityText(host)).toBe(normalizeEngineParityText(engine)); + }); + + it("detects thin engine re-export shims and engine stub pairs", () => { + const shim = `// comment\nexport * from "../../packages/gittensory-engine/src/signals/test-evidence";\n`; + expect(isThinEngineReExportShim(shim)).toBe(true); + expect(isThinEngineReExportShim("export const MODE = 'strict';\n")).toBe(false); + expect(isEngineStubPair("export const A = 1;\n".repeat(30), "export {};\n")).toBe(true); + }); + + it("passes when normalized host and engine copies are identical", () => { + const body = "export const VALUE = 1;\nimport type { T } from \"../types\";\n"; + const readFile = (_root: string, relativePath: string) => { + if (relativePath === "src/settings/sample.ts") return body; + if (relativePath === "packages/gittensory-engine/src/settings/sample.ts") return body; + throw new Error(`unexpected read: ${relativePath}`); + }; + const listDir = (_root: string, relativePath: string) => { + if (relativePath === "src/settings") return ["sample.ts"]; + if (relativePath === "packages/gittensory-engine/src/settings") return ["sample.ts"]; + return []; + }; + const result = checkEngineParityDrift({ root: "/fake", readFile, listDir }); + expect(result.failures).toEqual([]); + expect(result.pairsChecked).toHaveLength(1); + }); + + it("fails with a clear message when a discovered pair diverges", () => { + const readFile = (_root: string, relativePath: string) => { + if (relativePath === "src/settings/autonomy.ts") return "export const MODE = 'strict';\n"; + if (relativePath === "packages/gittensory-engine/src/settings/autonomy.ts") return "export const MODE = 'relaxed';\n"; + throw new Error(`unexpected read: ${relativePath}`); + }; + const listDir = (_root: string, relativePath: string) => { + if (relativePath === "src/settings") return ["autonomy.ts"]; + if (relativePath === "packages/gittensory-engine/src/settings") return ["autonomy.ts"]; + return []; + }; + const result = checkEngineParityDrift({ root: "/fake", readFile, listDir }); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("src/settings/autonomy.ts"); + expect(result.failures[0]).toContain("packages/gittensory-engine/src/settings/autonomy.ts"); + expect(result.failures[0]).toContain("drifted apart"); + }); + + it("discovers real in-scope pairs in the repository (regression guard)", () => { + const pairs = discoverEngineParityPairs({ root: process.cwd() }); + expect(pairs.length).toBeGreaterThanOrEqual(14); + expect(pairs.some((pair: EngineParityPair) => pair.fileName === "guardrail-config.ts")).toBe(true); + expect(pairs.some((pair: EngineParityPair) => pair.fileName === "change-guardrail.ts")).toBe(true); + expect(pairs.some((pair: EngineParityPair) => pair.fileName === "duplicate-winner.ts")).toBe(false); + expect(pairs.some((pair: EngineParityPair) => pair.fileName === "check-names.ts")).toBe(false); + }); + + it("the real repo's hand-duplicated pairs agree after normalization (regression guard)", () => { + const result = checkEngineParityDrift({ root: process.cwd() }); + expect(result.failures).toEqual([]); + }); + + describe("engine version skew", () => { + it("classifies equal, behind, and ahead boundary cases", () => { + expect(compareSemver("0.2.0", "0.2.0")).toBe(0); + expect(describeEngineVersionSkew("0.2.0", "0.2.0")).toBe("equal"); + expect(compareSemver("0.1.9", "0.2.0")).toBe(-1); + expect(describeEngineVersionSkew("0.1.9", "0.2.0")).toBe("behind"); + expect(compareSemver("0.3.0", "0.2.0")).toBe(1); + expect(describeEngineVersionSkew("0.3.0", "0.2.0")).toBe("ahead"); + }); + + it("passes when installed engine matches or exceeds the expected version", () => { + const equal = checkEngineVersionSkew({ + root: "/fake", + readFile: () => JSON.stringify({ version: "0.2.0" }), + resolveInstalled: () => "0.2.0", + readExpected: () => "0.2.0", + }); + expect(equal.failures).toEqual([]); + expect(equal.skew).toBe("equal"); + + const ahead = checkEngineVersionSkew({ + root: "/fake", + readFile: () => JSON.stringify({ version: "0.2.0" }), + resolveInstalled: () => "0.2.1", + readExpected: () => "0.2.0", + }); + expect(ahead.failures).toEqual([]); + expect(ahead.skew).toBe("ahead"); + }); + + it("fails when installed engine is behind the monorepo expected version", () => { + const result = checkEngineVersionSkew({ + root: "/fake", + readFile: () => JSON.stringify({ version: "0.2.0" }), + resolveInstalled: () => "0.1.0", + readExpected: () => "0.2.0", + }); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("behind"); + expect(result.skew).toBe("behind"); + }); + + it("fails when expected or installed engine versions are unavailable", () => { + const missingExpected = checkEngineVersionSkew({ + root: "/fake", + readFile: () => { + throw new Error("missing"); + }, + resolveInstalled: () => "0.2.0", + readExpected: () => null, + }); + expect(missingExpected.failures[0]).toContain("Could not read expected"); + + const missingInstalled = checkEngineVersionSkew({ + root: "/fake", + readFile: () => JSON.stringify({ version: "0.2.0" }), + resolveInstalled: () => null, + readExpected: () => "0.2.0", + }); + expect(missingInstalled.failures[0]).toContain("not installed"); + }); + + it("treats unparseable semver as behind", () => { + expect(compareSemver("not-a-version", "0.2.0")).toBe(-1); + expect(describeEngineVersionSkew("not-a-version", "0.2.0")).toBe("behind"); + }); + it("default version readers handle missing or corrupt installs", () => { + const emptyRoot = mkdtempSync(join(tmpdir(), "engine-parity-missing-")); + try { + expect(defaultResolveInstalledEngineVersion(emptyRoot)).toBeNull(); + expect(defaultReadExpectedEngineVersion(emptyRoot)).toBeNull(); + expect(defaultReadExpectedEngineVersion("/fake", () => { + throw new Error("unreadable"); + })).toBeNull(); + + const engineDir = join(emptyRoot, "node_modules", "@jsonbored", "gittensory-engine"); + mkdirSync(engineDir, { recursive: true }); + writeFileSync(join(engineDir, "package.json"), "not-json"); + expect(defaultResolveInstalledEngineVersion(emptyRoot)).toBeNull(); + } finally { + rmSync(emptyRoot, { recursive: true, force: true }); + } + }); + + it("fails when the miner engine pin drifts from the monorepo engine package version", () => { + const result = checkMinerEngineVersionPinSync({ + root: "/fake", + readFile: (_root, relativePath) => { + if (relativePath === "packages/gittensory-miner/expected-engine.version") return "0.1.0\n"; + throw new Error(`unexpected read: ${relativePath}`); + }, + readExpected: () => "0.2.0", + }); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("out of sync"); + }); + + it("uses default version readers against the real monorepo workspace", () => { + expect(defaultResolveInstalledEngineVersion(process.cwd())).toMatch(/^\d+\.\d+\.\d+$/); + expect(defaultReadExpectedEngineVersion(process.cwd())).toBe("0.2.0"); + const result = runEngineParityChecks({ root: process.cwd() }); + expect(result.failures).toEqual([]); + }); + }); + + it("runEngineParityMain returns 1 and logs failures when checks fail", () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitCode = runEngineParityMain("/definitely-not-a-gittensory-root"); + expect(exitCode).toBe(1); + expect(errorLog).toHaveBeenCalled(); + }); + + it("runEngineParityMain returns 0 for the real monorepo workspace", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(runEngineParityMain(process.cwd())).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toMatch(/Engine-parity check ok:/); + }); + + it("prints a clean summary and exits 0 for the real repo state when run as a subprocess", () => { + const output = execFileSync(TSX_BIN, ["scripts/check-engine-parity.ts"], { encoding: "utf8" }); + expect(output).toMatch(/Engine-parity check ok:/); + expect(output).toMatch(/hand-duplicated file pair/); + }); + + it("exits non-zero when run outside the monorepo workspace", () => { + const emptyRoot = mkdtempSync(join(tmpdir(), "engine-parity-empty-")); + try { + expect(() => + execFileSync(TSX_BIN, [join(process.cwd(), "scripts/check-engine-parity.ts")], { + cwd: emptyRoot, + encoding: "utf8", + }), + ).toThrow(); + } finally { + rmSync(emptyRoot, { recursive: true, force: true }); + } + }); + + it("runEngineParityChecks aggregates drift and skew failures", () => { + const combined = runEngineParityChecks({ + root: "/fake", + readFile: (_root: string, relativePath: string) => { + if (relativePath === "packages/gittensory-engine/package.json") return JSON.stringify({ version: "0.2.0" }); + if (relativePath === "src/settings/autonomy.ts") return "export const MODE = 'strict';\n"; + if (relativePath === "packages/gittensory-engine/src/settings/autonomy.ts") return "export const MODE = 'relaxed';\n"; + throw new Error(`unexpected read: ${relativePath}`); + }, + listDir: (_root: string, relativePath: string) => { + if (relativePath === "src/settings") return ["autonomy.ts"]; + if (relativePath === "packages/gittensory-engine/src/settings") return ["autonomy.ts"]; + return []; + }, + resolveInstalled: () => "0.1.0", + readExpected: () => "0.2.0", + }); + expect(combined.failures.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 4ce4bb0ca7..b002f314bd 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -1,9 +1,15 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildEngineVersionSkewCheck, collectStatus, + compareInstalledEngineVersion, + readExpectedEnginePackageVersion, + readExpectedEnginePackageVersionFromPaths, + readInstalledEnginePackageVersion, + readInstalledEnginePackageVersionFromPaths, resolveMinerStateDir, runDoctor, runDoctorChecks, @@ -72,6 +78,7 @@ describe("gittensory-miner status/doctor (#2288)", () => { expect(checks.map((check) => check.name)).toEqual([ "node-version", "engine-resolves", + "engine-version-skew", "state-dir-writable", "laptop-state-sqlite", "docker-present", @@ -80,6 +87,87 @@ describe("gittensory-miner status/doctor (#2288)", () => { expect(log).toHaveBeenCalled(); }); + it("engine version skew helpers compare installed vs expected semver", () => { + expect(compareInstalledEngineVersion("0.2.0", "0.2.0")).toBe(0); + expect(compareInstalledEngineVersion("0.1.0", "0.2.0")).toBe(-1); + expect(compareInstalledEngineVersion("0.3.0", "0.2.0")).toBe(1); + expect(typeof readInstalledEnginePackageVersion()).toBe("string"); + expect(readExpectedEnginePackageVersion()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("buildEngineVersionSkewCheck skips when expected version is unavailable", () => { + const skewCheck = buildEngineVersionSkewCheck( + () => "0.2.0", + () => null, + ); + expect(skewCheck.ok).toBe(true); + expect(skewCheck.detail).toContain("skipped"); + }); + + it("buildEngineVersionSkewCheck fails when installed engine is missing", () => { + const skewCheck = buildEngineVersionSkewCheck( + () => null, + () => "0.2.0", + ); + expect(skewCheck.ok).toBe(false); + expect(skewCheck.detail).toContain("not installed"); + }); + + it("readExpectedEnginePackageVersionFromPaths prefers monorepo package.json then the pin file", () => { + const root = tempRoot(); + const monorepoPkg = join(root, "engine-package.json"); + const pinFile = join(root, "expected-engine.version"); + writeFileSync(monorepoPkg, JSON.stringify({ version: "0.2.0" })); + writeFileSync(pinFile, "0.1.0\n"); + expect(readExpectedEnginePackageVersionFromPaths(monorepoPkg, pinFile)).toBe("0.2.0"); + + expect(readExpectedEnginePackageVersionFromPaths(join(root, "missing.json"), pinFile)).toBe("0.1.0"); + expect(readExpectedEnginePackageVersionFromPaths(join(root, "missing.json"), join(root, "missing-pin"))).toBeNull(); + writeFileSync(join(root, "broken.json"), "not-json"); + expect(readExpectedEnginePackageVersionFromPaths(join(root, "broken.json"), pinFile)).toBeNull(); + }); + + it("readInstalledEnginePackageVersionFromPaths falls back to the workspace engine package", () => { + const root = tempRoot(); + const workspacePkg = join(root, "gittensory-engine-package.json"); + writeFileSync(workspacePkg, JSON.stringify({ version: "0.2.0" })); + expect(readInstalledEnginePackageVersionFromPaths("/missing/entry", workspacePkg)).toBe("0.2.0"); + writeFileSync(workspacePkg, "not-json"); + expect(readInstalledEnginePackageVersionFromPaths("/missing/entry", workspacePkg)).toBeNull(); + expect(readInstalledEnginePackageVersionFromPaths("/missing/entry", join(root, "missing.json"))).toBeNull(); + + const installedPkg = join(root, "installed", "package.json"); + mkdirSync(join(root, "installed"), { recursive: true }); + writeFileSync(installedPkg, JSON.stringify({ version: "0.2.1" })); + expect(readInstalledEnginePackageVersionFromPaths(join(root, "installed", "index.js"), workspacePkg)).toBe("0.2.1"); + }); + + it("runDoctor supports --json output", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + initLaptopState(env); + expect(runDoctor(["--json"], env)).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0])).checks).toBeDefined(); + }); + + it("buildEngineVersionSkewCheck reports behind when installed engine lags expected", () => { + const skewCheck = buildEngineVersionSkewCheck( + () => "0.1.0", + () => "0.2.0", + ); + expect(skewCheck.ok).toBe(false); + expect(skewCheck.detail).toContain("behind"); + }); + + it("buildEngineVersionSkewCheck reports ahead when installed engine exceeds expected", () => { + const skewCheck = buildEngineVersionSkewCheck( + () => "0.3.0", + () => "0.2.0", + ); + expect(skewCheck.ok).toBe(true); + expect(skewCheck.detail).toContain("ahead"); + }); + it("doctor fails (exit 1) when the state directory cannot be created", () => { vi.spyOn(console, "log").mockImplementation(() => {}); const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tsconfig.json b/tsconfig.json index ef257bc3e5..b992c0df48 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "forceConsistentCasingInFileNames": true, "outDir": "dist" }, - "include": ["src", "test", "worker-configuration.d.ts", "vitest.config.ts", "vitest.workers.config.ts", "drizzle.config.ts"] + "include": ["src", "test", "scripts/check-engine-parity.ts", "worker-configuration.d.ts", "vitest.config.ts", "vitest.workers.config.ts", "drizzle.config.ts"] }