Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/skills/contributing-to-loopover/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ path filter matched; on push to `main`, everything runs.
| lint → cf-typegen | worker types drift | `npm run cf-typegen:check` | committed `worker-configuration.d.ts` is stale (run `npm run cf-typegen`) |
| lint → schema-drift | `src/db/schema.ts` vs `migrations/` | `npm run db:schema-drift:check` | a Drizzle table's schema doesn't match the migration history |
| lint → selfhost-env-reference | self-host env-var doc drift | `npm run selfhost:env-reference:check` | committed `apps/loopover-ui/src/lib/selfhost-env-reference.ts` is stale (run `npm run selfhost:env-reference`) — triggers when `src/selfhost/**` (+ a few other scanned files) adds/removes an env var read; a pure line shift of an existing read does NOT trigger it, since the doc cites the file only, not `file:line` (#env-reference-churn) |
| lint → miner-env-reference | miner/AMS env-var doc drift | `npm run miner:env-reference:check` | committed `packages/loopover-miner/docs/env-reference.md` / `apps/loopover-ui/src/lib/ams-env-reference.ts` is stale (run `npm run miner:env-reference`) — miner/AMS twin of the selfhost check above |
| lint → observability | Grafana/Prometheus/alert config validation | `npm run selfhost:validate-observability` | a self-host observability config (dashboard/rule/datasource) is malformed |
| lint → typecheck | `tsc --noEmit` | `npm run typecheck` | any backend type error |
| test (1/2) | sharded vitest + coverage | `npm run test:coverage` (unsharded) | any failing `test/**/*.test.ts` (excl. `test/workers/**`) |
Expand All @@ -43,6 +44,10 @@ path filter matched; on push to `main`, everything runs.
| ui → version audit | MCP version copy | `npm run ui:version-audit` | stale MCP version strings / non-`@latest` install copy (hits npm registry) |
| docs → drift | doc/code claim checker | `npm run docs:drift-check` | a doc makes a claim the mechanical lint can verify is now false |
| docs → command-reference | generated CLI reference drift | `npm run command-reference:check` | committed command-reference doc is stale (run `npm run command-reference`) |
| manifest drift | `.loopover.yml` vs bundled fallback YAML | `npm run manifest:drift-check` | `src/config/loopover-repo-focus-manifest.ts`'s bundled YAML diverges from the real root `.loopover.yml` |
| engine-parity drift | `src/{review,settings,signals}` vs `loopover-engine` twins | `npm run engine-parity:drift-check` | a hand-duplicated twin file pair diverges, or the installed `@loopover/engine` semver skews from the monorepo package |
| branding drift | "gittensory" leaking into runtime source | `npm run branding-drift:check` | a file's gittensory-string hit count rises above the recorded baseline (`scripts/branding-drift-baseline.json`) |
| release-please manifest sync | `.release-please-manifest.json` vs each package's real `package.json` version | `npm run release-manifest:sync:check` | the manifest and a package's `package.json` version disagree — normally only possible after a manual out-of-band release (release-please's own PR-merge flow keeps them in sync automatically); fix with `npm run release-manifest:sync` (never hand-edit the manifest) |
| ui → lint | `eslint .` (UI) | `npm run ui:lint` | ESLint **incl. Prettier formatting** + design-token rules |
| ui → typecheck | `tsc --noEmit` (UI) | `npm run ui:typecheck` | UI type error |
| ui → tests | vitest jsdom (UI) | `npm run ui:test` | failing UI component test |
Expand All @@ -68,7 +73,6 @@ these for a normal PR:**
|---|---|
| `npm run test:engine-parity`, `npm run test:live-gate-parity`, `npm run test:driver-parity` | Plain `test/contract/*.test.ts` files — no dedicated CI job, but they DO run in CI as part of whichever `test (1/2)` shard happens to contain them (sharded `vitest run`). |
| `npm run test --workspace @loopover/engine` | The engine package's own `node --test` suite. **Not run by `ci.yml` on a PR at all** — only by `.github/workflows/publish-engine.yml` at release time. A regression here is invisible to Codecov and to every PR-gating CI check; `test:ci` locally is the only pre-merge signal. |
| `npm run manifest:drift-check`, `npm run engine-parity:drift-check` | Appear in `test:ci` only — not in `ci.yml` under any job. |

This is a real, previously-hit gap, not a hypothetical: a past PR shipped a genuine, undetected
`codecov/patch`-adjacent regression in the engine package specifically because `test --workspace
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ jobs:
- 'wrangler.jsonc'
- 'worker-configuration.d.ts'
- '.loopover.yml'
- '.release-please-manifest.json'
- 'release-please-config.json'
observability:
- 'grafana/dashboards/**'
- 'prometheus/rules/**'
Expand Down Expand Up @@ -314,6 +316,17 @@ jobs:
- name: Branding drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run branding-drift:check
# .release-please-manifest.json is release-please's own state file -- it normally keeps this in
# sync itself when its generated Release PR merges, but the documented human-override path for
# a stuck release tag (publish-mcp.yml's own header comment) bumps package.json directly with
# no way to touch release-please's state. That gap went uncaught with zero CI signal, then
# surfaced days later as release-please re-proposing an already-published version as a brand-new
# Release PR, which fails on publish (#7086/#7087). `.release-please-manifest.json` and
# `release-please-config.json` are now part of the `backend` path filter above so an edit to
# either alone still re-triggers this job.
- name: Release-please manifest sync check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run release-manifest:sync:check
- name: Validate observability configs
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.observability == 'true' }}
env:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"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",
"release-manifest:sync": "node scripts/sync-release-manifest.mjs",
"release-manifest:sync:check": "node scripts/sync-release-manifest.mjs --check",
"ui:deploy": "npm run ui:build && npm run ui:deploy:built",
"ui:deploy:built": "wrangler deploy --config apps/loopover-ui/dist/server/wrangler.json",
"ui:version:built": "wrangler versions upload --config apps/loopover-ui/dist/server/wrangler.json",
Expand Down Expand Up @@ -96,7 +98,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 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: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 release-manifest:sync: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",
Expand Down
28 changes: 28 additions & 0 deletions scripts/sync-release-manifest.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const MANIFEST_PATH: string;

export type SyncManifestStaleEntry = {
workspacePath: string;
from: string;
to: string;
};

export type SyncManifestResult = {
content: string;
changed: boolean;
stale: SyncManifestStaleEntry[];
};

export function syncManifestVersions(
manifestJson: string,
packageVersions: Record<string, string>,
): SyncManifestResult;

export type SyncManifestIo = {
readFileSync: (path: string, encoding: string) => string;
writeFileSync: (path: string, content: string) => void;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
exit: (code: number) => void;
};

export function main(argv?: string[], io?: SyncManifestIo): number;
78 changes: 78 additions & 0 deletions scripts/sync-release-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node
// Keeps .release-please-manifest.json in sync with each listed package's actual package.json
// version. release-please normally maintains this file itself (manifest mode) as part of merging
// its own generated Release PR -- but the documented human-override path for a stuck/broken release
// tag (publish-mcp.yml's own header comment: "A bare manual dispatch ... self-tags HEAD from
// packages/loopover-mcp/package.json's version") bumps package.json directly and has no way to touch
// release-please's own state file. When that happens the manifest goes stale, and release-please's
// NEXT run -- blind to the fact a version already shipped out-of-band -- recomputes and re-proposes
// that SAME already-published version as a brand-new Release PR, which then fails on publish (npm
// rejects republishing a version). Confirmed live: #7086/#7087 re-proposed mcp/miner v3.1.1 days
// after both had already shipped to npm via #7064's manual release.
//
// package.json is always the source of truth here, never a hand-typed version number: this script
// only ever reads it and writes the manifest to match, so fixing drift is one command, never a
// manual JSON edit (the exact mistake that produced the stale manifest in the first place).
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

export const MANIFEST_PATH = ".release-please-manifest.json";

/**
* Pure: diff a manifest's recorded versions against each workspace's real package.json version.
* @param {string} manifestJson
* @param {Record<string, string>} packageVersions workspacePath -> package.json's "version"
* @returns {{ content: string, changed: boolean, stale: Array<{ workspacePath: string, from: string, to: string }> }}
*/
export function syncManifestVersions(manifestJson, packageVersions) {
const manifest = JSON.parse(manifestJson);
const stale = [];
for (const [workspacePath, version] of Object.entries(packageVersions)) {
if (!(workspacePath in manifest)) continue; // not a manifest-tracked component -- nothing to sync
if (manifest[workspacePath] === version) continue;
stale.push({ workspacePath, from: manifest[workspacePath], to: version });
manifest[workspacePath] = version;
}
const changed = stale.length > 0;
return { content: changed ? `${JSON.stringify(manifest, null, 2)}\n` : manifestJson, changed, stale };
}

export function main(argv = process.argv.slice(2), io = {
readFileSync,
writeFileSync,
log: console.log.bind(console),
error: console.error.bind(console),
exit: (code) => process.exit(code),
}) {
const check = argv.includes("--check");
const manifestJson = io.readFileSync(MANIFEST_PATH, "utf8");
const manifestKeys = Object.keys(JSON.parse(manifestJson));
const packageVersions = Object.fromEntries(
manifestKeys.map((workspacePath) => [
workspacePath,
JSON.parse(io.readFileSync(`${workspacePath}/package.json`, "utf8")).version,
]),
);

const result = syncManifestVersions(manifestJson, packageVersions);
for (const { workspacePath, from, to } of result.stale) {
io.error(`${MANIFEST_PATH}: ${workspacePath} is ${from}, package.json says ${to}.`);
}
if (check) {
if (result.stale.length > 0) {
io.error(`${MANIFEST_PATH} is stale; run npm run release-manifest:sync.`);
io.exit(1);
return 1;
}
io.log(`sync-release-manifest: checked ${manifestKeys.length} package version(s), all in sync.`);
return 0;
}
if (result.changed) io.writeFileSync(MANIFEST_PATH, result.content);
io.log(
`sync-release-manifest: ${result.changed ? `synced ${result.stale.length}` : "checked"} of ${manifestKeys.length} package version(s).`,
);
return 0;
}

const invokedDirectly = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) main();
147 changes: 147 additions & 0 deletions test/unit/sync-release-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import { main, MANIFEST_PATH, syncManifestVersions } from "../../scripts/sync-release-manifest.mjs";

const SAMPLE_MANIFEST = JSON.stringify(
{
"packages/loopover-mcp": "3.1.0",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.0",
},
null,
2,
);

describe("syncManifestVersions (#release-manifest-drift)", () => {
it("rewrites a stale entry to match the real package.json version", () => {
const result = syncManifestVersions(SAMPLE_MANIFEST, { "packages/loopover-mcp": "3.1.1" });
expect(result.changed).toBe(true);
expect(result.stale).toEqual([{ workspacePath: "packages/loopover-mcp", from: "3.1.0", to: "3.1.1" }]);
expect(JSON.parse(result.content)["packages/loopover-mcp"]).toBe("3.1.1");
// Untouched entries survive the round-trip unchanged.
expect(JSON.parse(result.content)["packages/loopover-engine"]).toBe("3.2.0");
});

it("reports no drift and leaves content byte-identical when every version already matches", () => {
const result = syncManifestVersions(SAMPLE_MANIFEST, {
"packages/loopover-mcp": "3.1.0",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.0",
});
expect(result.changed).toBe(false);
expect(result.stale).toEqual([]);
expect(result.content).toBe(SAMPLE_MANIFEST);
});

it("ignores a package.json version for a workspace the manifest doesn't track", () => {
const result = syncManifestVersions(SAMPLE_MANIFEST, { "packages/loopover-ui-kit": "9.9.9" });
expect(result.changed).toBe(false);
expect(result.stale).toEqual([]);
expect(JSON.parse(result.content)).not.toHaveProperty("packages/loopover-ui-kit");
});

it("collects every stale entry, not just the first", () => {
const result = syncManifestVersions(SAMPLE_MANIFEST, {
"packages/loopover-mcp": "3.1.1",
"packages/loopover-miner": "3.1.1",
});
expect(result.stale.map((entry) => entry.workspacePath)).toEqual([
"packages/loopover-mcp",
"packages/loopover-miner",
]);
expect(JSON.parse(result.content)["packages/loopover-mcp"]).toBe("3.1.1");
expect(JSON.parse(result.content)["packages/loopover-miner"]).toBe("3.1.1");
});
});

function fakeIo(packageVersions: Record<string, string>) {
const written = new Map<string, string>();
const readFileSync = vi.fn((path: string) => {
if (path === MANIFEST_PATH) return SAMPLE_MANIFEST;
const match = /^(.+)\/package\.json$/.exec(path);
const key = match?.[1];
if (key !== undefined && key in packageVersions) {
return JSON.stringify({ version: packageVersions[key] });
}
throw new Error(`unexpected read: ${path}`);
});
const writeFileSync = vi.fn((path: string, content: string) => {
written.set(path, content);
});
const log = vi.fn();
const error = vi.fn();
const exit = vi.fn();
return { readFileSync, writeFileSync, log, error, exit, written };
}

describe("sync-release-manifest main (#release-manifest-drift)", () => {
it("--check exits non-zero and never writes when the manifest is stale", () => {
const io = fakeIo({
"packages/loopover-mcp": "3.1.1",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.1",
});

const code = main(["--check"], io);

expect(code).toBe(1);
expect(io.exit).toHaveBeenCalledWith(1);
expect(io.error).toHaveBeenCalledWith(
expect.stringContaining("packages/loopover-mcp is 3.1.0, package.json says 3.1.1"),
);
expect(io.error).toHaveBeenCalledWith(expect.stringContaining("run npm run release-manifest:sync"));
expect(io.writeFileSync).not.toHaveBeenCalled();
});

it("--check exits 0 and logs a clean summary when everything already matches", () => {
const io = fakeIo({
"packages/loopover-mcp": "3.1.0",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.0",
});

const code = main(["--check"], io);

expect(code).toBe(0);
expect(io.exit).not.toHaveBeenCalled();
expect(io.writeFileSync).not.toHaveBeenCalled();
expect(io.log).toHaveBeenCalledWith(
expect.stringContaining("checked 3 package version(s), all in sync"),
);
});

it("without --check, writes the synced manifest and reports how many entries changed", () => {
const io = fakeIo({
"packages/loopover-mcp": "3.1.1",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.1",
});

const code = main([], io);

expect(code).toBe(0);
expect(io.exit).not.toHaveBeenCalled();
expect(io.writeFileSync).toHaveBeenCalledOnce();
const [writtenPath, writtenContent] = io.writeFileSync.mock.calls[0]!;
expect(writtenPath).toBe(MANIFEST_PATH);
expect(JSON.parse(writtenContent as string)).toEqual({
"packages/loopover-mcp": "3.1.1",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.1",
});
expect(io.log).toHaveBeenCalledWith(expect.stringContaining("synced 2 of 3 package version(s)"));
});

it("without --check, never writes when nothing is stale", () => {
const io = fakeIo({
"packages/loopover-mcp": "3.1.0",
"packages/loopover-engine": "3.2.0",
"packages/loopover-miner": "3.1.0",
});

const code = main([], io);

expect(code).toBe(0);
expect(io.writeFileSync).not.toHaveBeenCalled();
expect(io.log).toHaveBeenCalledWith(expect.stringContaining("checked of 3 package version(s)"));
});
});
Loading