diff --git a/package.json b/package.json index 18dc8dba4f..0637f8f320 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs", "ui:version-audit:sync": "node scripts/check-ui-mcp-version-copy.mjs --write", "docs:drift-check": "node scripts/check-docs-drift.mjs", + "branding-drift:check": "node scripts/check-branding-drift.mjs", + "branding-drift:update": "node scripts/check-branding-drift.mjs --update", "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", @@ -94,7 +96,7 @@ "test:smoke:observability:metrics": "node scripts/smoke-observability-metrics.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 miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/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 test:miner-deployment-docs-audit && 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: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 miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/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 test:miner-deployment-docs-audit && 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 branding-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/scripts/branding-drift-baseline.json b/scripts/branding-drift-baseline.json new file mode 100644 index 0000000000..545dc4c1de --- /dev/null +++ b/scripts/branding-drift-baseline.json @@ -0,0 +1,38 @@ +{ + "packages/loopover-engine/src/signals/engine.ts": 2, + "packages/loopover-mcp/bin/loopover-mcp.js": 5, + "src/api/routes.ts": 3, + "src/db/repositories.ts": 1, + "src/github/app.ts": 11, + "src/github/backfill.ts": 7, + "src/github/commands.ts": 2, + "src/github/comments.ts": 3, + "src/integrations/project-tracker-adapter.ts": 1, + "src/orb/federated-bundle.ts": 1, + "src/queue/processors.ts": 6, + "src/review/check-names.ts": 8, + "src/review/contributor-calibration.ts": 2, + "src/review/enrichment-analyzers-taxonomy.ts": 1, + "src/review/enrichment-wire.ts": 1, + "src/review/ops-wire.ts": 3, + "src/review/outcomes-wire.ts": 2, + "src/review/parity-wire.ts": 9, + "src/review/planner.ts": 1, + "src/review/rag.ts": 3, + "src/review/repo-doc-render.ts": 2, + "src/review/repo-skill-render.ts": 2, + "src/review/selftune-wire.ts": 1, + "src/selfhost/ai.ts": 6, + "src/selfhost/health.ts": 3, + "src/selfhost/monitored-work.ts": 1, + "src/selfhost/orb-collector.ts": 1, + "src/selfhost/sentry.ts": 2, + "src/services/ai-review.ts": 4, + "src/services/ai-slop.ts": 1, + "src/services/ai-summaries.ts": 1, + "src/services/contributor-issue-draft.ts": 2, + "src/services/merge-failure.ts": 1, + "src/services/notify-pagerduty.ts": 2, + "src/upstream/ruleset.ts": 3, + "src/utils/crypto.ts": 1 +} diff --git a/scripts/check-branding-drift.d.mts b/scripts/check-branding-drift.d.mts new file mode 100644 index 0000000000..a91efa047f --- /dev/null +++ b/scripts/check-branding-drift.d.mts @@ -0,0 +1,9 @@ +export const BASELINE_RELATIVE_PATH: string; +export const BRANDING_DRIFT_PATHSPECS: string[]; + +export function scanBrandingHits(options: { + root: string; + exec?: (root: string, args: string[]) => string; +}): Record; + +export function diffBrandingBaseline(baseline: Record, current: Record): string[]; diff --git a/scripts/check-branding-drift.mjs b/scripts/check-branding-drift.mjs new file mode 100644 index 0000000000..d31ff96dd9 --- /dev/null +++ b/scripts/check-branding-drift.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Guards against the "gittensory" branding silently creeping back into runtime source after the LoopOver +// rebrand -- distinct from docs/prose residue (already owned by the ongoing rebrand-sweep PR series), this +// targets the class of bug that actually broke things: a hardcoded metric name, MCP resource URI, or Qdrant +// collection default left on the pre-rebrand string. That exact pattern caused live drift more than once +// (e.g. #6786 -- ENRICHMENT_ANALYZERS_URI silently left as "gittensory://enrichment-analyzers" while its +// sibling FINDING_TAXONOMY_URI was correctly renamed in the same PR). Scoped to executable code in `src/**` +// and each workspace package's `bin/`, `lib/`, `src/`, `scripts/` dirs -- NOT `test/**` or `*.md`, where a +// literal "gittensory" is usually an intentional, permanent historical reference (a Sentry ticket ID like +// GITTENSORY-K/8, a stable comment-marker the bot must keep matching in already-posted PR bodies, a DB +// `source` column value joined against historical rows) rather than drift; those files churn constantly and +// would make this check pure noise if included. +// +// Baseline-diff, not a hard "zero gittensory" ban: scripts/branding-drift-baseline.json snapshots today's +// known-legitimate per-file hit count (grandfathered, same shape as KNOWN_MIGRATION_DUPLICATES in +// src/db/migration-collisions.ts). A file's count rising means new drift; falling means a cleanup landed +// without updating the baseline. Either way the fix is the same: run `npm run branding-drift:update` and +// commit the regenerated baseline -- mirrors this repo's existing generated-artifact convention (openapi.json, +// cf-typegen, migrations) rather than inventing a new one. +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const BASELINE_RELATIVE_PATH = "scripts/branding-drift-baseline.json"; + +// git pathspecs: executable code only. Each workspace package's bin/lib/src/scripts dirs mirror the +// top-level src/** scope; docs/README/CHANGELOG/schema/terraform/css and every test dir are deliberately +// excluded (see header comment). +export const BRANDING_DRIFT_PATHSPECS = [ + "src/**/*.ts", + "src/**/*.tsx", + "packages/*/bin/**", + "packages/*/lib/**/*.js", + "packages/*/lib/**/*.ts", + "packages/*/src/**/*.ts", + "packages/*/scripts/**/*.mjs", + ":(exclude)**/*.test.ts", + ":(exclude)**/*.test.tsx", + ":(exclude)packages/*/test/**", +]; + +function defaultExec(root, args) { + try { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }); + } catch (error) { + // git grep exits 1 for "zero matches" -- not a real failure, just an empty result. + if (error.status === 1) return ""; + throw error; + } +} + +/** Every tracked, non-excluded file with >=1 case-insensitive "gittensory" MATCHING LINE, and that line + * count (line-granularity, not raw occurrence count -- sufficient to detect "something new appeared" without + * the fragility of an exact substring-occurrence count). Uses `git grep`, so it only ever sees tracked files + * exactly as CI would check them out -- no node_modules/dist/build noise to exclude by hand. */ +export function scanBrandingHits({ root, exec = defaultExec }) { + const output = exec(root, ["grep", "-ciI", "gittensory", "--", ...BRANDING_DRIFT_PATHSPECS]); + const counts = {}; + for (const line of output.split("\n")) { + if (!line) continue; + const separatorIndex = line.lastIndexOf(":"); + const file = line.slice(0, separatorIndex); + counts[file] = Number(line.slice(separatorIndex + 1)); + } + return counts; +} + +/** Pure comparison: every failure is one of "increased" (new drift -- or a file that didn't exist in the + * baseline at all, same failure shape) or "decreased" (a cleanup landed; still a failure so the baseline + * never silently drifts stale, but a one-line fix -- regenerate). Sorted for stable, reviewable CI output. */ +export function diffBrandingBaseline(baseline, current) { + const failures = []; + const files = new Set([...Object.keys(baseline), ...Object.keys(current)]); + for (const file of [...files].sort()) { + const before = baseline[file] ?? 0; + const after = current[file] ?? 0; + if (after > before) { + failures.push( + `${file}: "gittensory" mentions increased from ${before} to ${after} -- looks like new branding drift, not an intentional historical reference. If it genuinely belongs (e.g. a permanent Sentry ticket ID or a stable comment-marker already posted to live PRs), run \`npm run branding-drift:update\` and commit the regenerated baseline.`, + ); + } else if (after < before) { + failures.push( + `${file}: "gittensory" mentions decreased from ${before} to ${after} -- looks like a cleanup landed without regenerating the baseline. Run \`npm run branding-drift:update\` and commit the result.`, + ); + } + } + return failures; +} + +function readBaseline(root) { + return JSON.parse(readFileSync(join(root, BASELINE_RELATIVE_PATH), "utf8")); +} + +function writeBaseline(root, counts) { + const sorted = Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))); + writeFileSync(join(root, BASELINE_RELATIVE_PATH), `${JSON.stringify(sorted, null, 2)}\n`); +} + +function main() { + const root = process.cwd(); + const update = process.argv.includes("--update"); + const current = scanBrandingHits({ root }); + + if (update) { + writeBaseline(root, current); + console.log(`Branding-drift baseline regenerated: ${Object.keys(current).length} file(s) with a "gittensory" reference.`); + return; + } + + const baseline = readBaseline(root); + const failures = diffBrandingBaseline(baseline, current); + + if (failures.length > 0) { + console.error(`Branding-drift check found ${failures.length} issue(s):`); + for (const failure of failures) console.error(failure); + process.exit(1); + } + + console.log(`Branding-drift check ok: ${Object.keys(current).length} file(s) match the recorded baseline.`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/test/unit/check-branding-drift-script.test.ts b/test/unit/check-branding-drift-script.test.ts new file mode 100644 index 0000000000..b0173c1d8b --- /dev/null +++ b/test/unit/check-branding-drift-script.test.ts @@ -0,0 +1,108 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { diffBrandingBaseline, scanBrandingHits } from "../../scripts/check-branding-drift.mjs"; + +describe("scanBrandingHits", () => { + it("parses git grep -c output into a { file: count } map", () => { + const exec = () => "src/a.ts:2\nsrc/b.ts:1\n"; + const result = scanBrandingHits({ root: "/fake", exec }); + + expect(result).toEqual({ "src/a.ts": 2, "src/b.ts": 1 }); + }); + + it("returns an empty map when there is no output (git grep found nothing)", () => { + const exec = () => ""; + const result = scanBrandingHits({ root: "/fake", exec }); + + expect(result).toEqual({}); + }); + + it("uses the LAST colon as the file/count separator, so a path containing a colon still parses", () => { + const exec = () => "src/weird:name.ts:3\n"; + const result = scanBrandingHits({ root: "/fake", exec }); + + expect(result).toEqual({ "src/weird:name.ts": 3 }); + }); + + it("passes the branding-drift pathspecs through to the injected exec", () => { + let capturedArgs: string[] = []; + const exec = (_root: string, args: string[]) => { + capturedArgs = args; + return ""; + }; + scanBrandingHits({ root: "/fake", exec }); + + expect(capturedArgs[0]).toBe("grep"); + expect(capturedArgs).toContain("src/**/*.ts"); + expect(capturedArgs).toContain(":(exclude)**/*.test.ts"); + }); + + // Real regression guard, mirroring check-manifest-drift-script.test.ts's own real-repo-state test: proves + // the actual defaultExec (real `git grep` subprocess, real exit-1-means-empty handling) works against this + // repo's real tracked files, not just the injected fake above. + it("runs the real git grep against this repo without throwing", () => { + const result = scanBrandingHits({ root: process.cwd() }); + + expect(typeof result).toBe("object"); + for (const count of Object.values(result)) { + expect(count).toBeGreaterThan(0); + } + }); +}); + +describe("diffBrandingBaseline", () => { + it("reports no failures when baseline and current match exactly", () => { + const failures = diffBrandingBaseline({ "src/a.ts": 2 }, { "src/a.ts": 2 }); + + expect(failures).toEqual([]); + }); + + it("flags a file whose count increased (new drift)", () => { + const failures = diffBrandingBaseline({ "src/a.ts": 1 }, { "src/a.ts": 2 }); + + expect(failures).toHaveLength(1); + expect(failures[0]).toContain("src/a.ts"); + expect(failures[0]).toContain("increased from 1 to 2"); + expect(failures[0]).toContain("branding-drift:update"); + }); + + it("flags a brand-new file not present in the baseline at all (increased from 0)", () => { + const failures = diffBrandingBaseline({}, { "src/new.ts": 1 }); + + expect(failures).toHaveLength(1); + expect(failures[0]).toContain("increased from 0 to 1"); + }); + + it("flags a file whose count decreased (stale baseline after a cleanup)", () => { + const failures = diffBrandingBaseline({ "src/a.ts": 3 }, { "src/a.ts": 1 }); + + expect(failures).toHaveLength(1); + expect(failures[0]).toContain("decreased from 3 to 1"); + }); + + it("flags a file removed entirely from current (decreased to 0)", () => { + const failures = diffBrandingBaseline({ "src/gone.ts": 2 }, {}); + + expect(failures).toHaveLength(1); + expect(failures[0]).toContain("decreased from 2 to 0"); + }); + + it("reports one failure per affected file, sorted, when several files differ", () => { + const failures = diffBrandingBaseline({ "src/b.ts": 1, "src/a.ts": 1 }, { "src/b.ts": 2, "src/a.ts": 2 }); + + expect(failures).toHaveLength(2); + expect(failures[0]).toContain("src/a.ts"); + expect(failures[1]).toContain("src/b.ts"); + }); +}); + +describe("check-branding-drift script (real repo state)", () => { + // Most important test in this file: proves the checked-in baseline actually matches the real repo right + // now. If this fails, real drift landed (or a cleanup did) without regenerating the baseline -- either way, + // fix it with `npm run branding-drift:update`, don't weaken this test. + it("the committed baseline matches the real current repo state (regression guard)", () => { + const output = execFileSync("node", ["scripts/check-branding-drift.mjs"], { encoding: "utf8" }); + + expect(output).toMatch(/Branding-drift check ok: \d+ file\(s\) match the recorded baseline\./); + }); +});