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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"ui:openapi:settings-parity": "tsx scripts/check-openapi-settings-parity.mjs",
"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",
"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",
Expand All @@ -74,7 +75,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: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 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: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:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci && npm run changelog:check:mcp",
"test:watch": "vitest",
Expand Down
9 changes: 9 additions & 0 deletions scripts/check-manifest-drift.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function checkManifestDrift(options: {
root: string;
readFile?: (root: string, relativePath: string) => string;
bundledYaml?: string;
}): {
failures: string[];
rootManifest: unknown;
bundledManifest: unknown;
};
82 changes: 82 additions & 0 deletions scripts/check-manifest-drift.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// Cross-checks the bundled fallback YAML in src/config/gittensory-repo-focus-manifest.ts
// (GITTENSORY_REPO_FOCUS_MANIFEST_YAML) against the real root .gittensory.yml. The bundled string exists so
// the focus-manifest engine still has a sane default when the live repo file is unreachable (local dev,
// pre-merge branches) -- see that file's own header comment -- but nothing in CI previously caught the two
// silently diverging once someone edited one and forgot the other. This script parses both with the `yaml`
// package (already a project dependency) and deep-compares the resulting objects, not the raw text, so
// comment-only or whitespace-only edits in either file never false-fail the check.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { GITTENSORY_REPO_FOCUS_MANIFEST_YAML } from "../src/config/gittensory-repo-focus-manifest.ts";

const ROOT_MANIFEST_PATH = ".gittensory.yml";

function defaultReadFile(root, relativePath) {
return readFileSync(join(root, relativePath), "utf8");
}

/** Recursively sort object keys so deep-equal comparisons never depend on key insertion order (YAML key
* order is not semantically meaningful; a reordered-but-equivalent block must not be reported as drift). */
function sortKeysDeep(value) {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep(value[key])]),
);
}
return value;
}

/**
* Deep-compares the real root .gittensory.yml against the bundled fallback YAML constant. `readFile(root,
* relativePath)` and `bundledYaml` are both injectable so tests can simulate a diverged pair without
* touching the real filesystem or the real bundled constant. Returns `{ failures, rootManifest,
* bundledManifest }` -- pure given its inputs, no process.exit/console side effects of its own (those live
* in main()).
*/
export function checkManifestDrift({ root, readFile = defaultReadFile, bundledYaml = GITTENSORY_REPO_FOCUS_MANIFEST_YAML }) {
const failures = [];

const rootManifestText = readFile(root, ROOT_MANIFEST_PATH);
const rootManifest = parseYaml(rootManifestText);
const bundledManifest = parseYaml(bundledYaml);

const sortedRoot = sortKeysDeep(rootManifest);
const sortedBundled = sortKeysDeep(bundledManifest);
const rootJson = JSON.stringify(sortedRoot, null, 2);
const bundledJson = JSON.stringify(sortedBundled, null, 2);

if (rootJson !== bundledJson) {
failures.push(
[
`${ROOT_MANIFEST_PATH} and GITTENSORY_REPO_FOCUS_MANIFEST_YAML (src/config/gittensory-repo-focus-manifest.ts) have drifted apart.`,
`-- ${ROOT_MANIFEST_PATH} (parsed) --`,
rootJson,
`-- GITTENSORY_REPO_FOCUS_MANIFEST_YAML (parsed) --`,
bundledJson,
].join("\n"),
);
}

return { failures, rootManifest, bundledManifest };
}

function main() {
const { failures } = checkManifestDrift({ root: process.cwd() });

if (failures.length > 0) {
console.error(`Manifest-drift check found ${failures.length} issue(s):`);
for (const failure of failures) console.error(failure);
process.exit(1);
}

console.log(`Manifest-drift check ok: ${ROOT_MANIFEST_PATH} and GITTENSORY_REPO_FOCUS_MANIFEST_YAML agree.`);
}

// Guard so importing this module for its pure exports (tests) never triggers the file-read/exit side effects.
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
95 changes: 95 additions & 0 deletions test/unit/check-manifest-drift-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { checkManifestDrift } from "../../scripts/check-manifest-drift.mjs";
import { GITTENSORY_REPO_FOCUS_MANIFEST_YAML } from "../../src/config/gittensory-repo-focus-manifest";

// The script imports src/config/gittensory-repo-focus-manifest.ts (a .ts module), so -- like
// check-schema-drift.mjs, check-migrations.mjs, and check-openapi-settings-parity.mjs -- it must run via
// `tsx`, the same binary package.json's manifest:drift-check uses, rather than plain `node`.
const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx");

describe("check-manifest-drift script", () => {
function makeReadFile(rootManifestYaml: string) {
return (_root: string, relativePath: string): string => {
if (relativePath !== ".gittensory.yml") throw new Error(`unexpected read: ${relativePath}`);
return rootManifestYaml;
};
}

it("passes cleanly when the root manifest and the bundled fallback parse to the same object", () => {
const rootManifestYaml = "source: repo_file\nwantedPaths:\n - src/\n - test/\n";
const result = checkManifestDrift({
root: "/fake",
readFile: makeReadFile(rootManifestYaml),
bundledYaml: rootManifestYaml,
});

expect(result.failures).toEqual([]);
});

it("does not flag purely cosmetic differences: comments, key order, and whitespace", () => {
const rootManifestYaml = "# a comment\nsource: repo_file\nwantedPaths:\n - src/\n - test/\n";
const bundledYaml = "wantedPaths:\n - src/\n - test/\nsource: repo_file\n# a different comment\n";
const result = checkManifestDrift({
root: "/fake",
readFile: makeReadFile(rootManifestYaml),
bundledYaml,
});

expect(result.failures).toEqual([]);
});

it("catches a field present in the root manifest but missing from the bundled fallback", () => {
const rootManifestYaml = "source: repo_file\nlinkedIssuePolicy: preferred\n";
const bundledYaml = "source: repo_file\n";
const result = checkManifestDrift({
root: "/fake",
readFile: makeReadFile(rootManifestYaml),
bundledYaml,
});

expect(result.failures).toHaveLength(1);
expect(result.failures[0]).toContain(".gittensory.yml");
expect(result.failures[0]).toContain("GITTENSORY_REPO_FOCUS_MANIFEST_YAML");
expect(result.failures[0]).toContain("linkedIssuePolicy");
});

it("catches a value that differs between the two files for the same key", () => {
const rootManifestYaml = "gate:\n duplicates: block\n";
const bundledYaml = "gate:\n duplicates: advisory\n";
const result = checkManifestDrift({
root: "/fake",
readFile: makeReadFile(rootManifestYaml),
bundledYaml,
});

expect(result.failures).toHaveLength(1);
expect(result.failures[0]).toContain("block");
expect(result.failures[0]).toContain("advisory");
});

// Most important regression test in this file: proves the REAL current repo state (root .gittensory.yml
// vs. the real bundled GITTENSORY_REPO_FOCUS_MANIFEST_YAML constant) agrees, using the real filesystem
// reader against the real repo root. If this fails, the two have genuinely drifted apart -- either way,
// the check must not be weakened to make this test pass.
it("the real repo's root .gittensory.yml and the bundled fallback agree (regression guard)", () => {
const result = checkManifestDrift({ root: process.cwd() });

expect(result.failures).toEqual([]);
});

it("the bundled fallback constant is non-empty and includes the source: repo_file marker", () => {
// A structural guard on the constant itself: an accidental empty-string or truncated bundle would
// otherwise parse to `undefined`/{} and could pass a deep-equal check against an equally-broken root
// read, so assert the constant looks like real manifest YAML independent of the comparison above.
expect(GITTENSORY_REPO_FOCUS_MANIFEST_YAML.length).toBeGreaterThan(100);
expect(GITTENSORY_REPO_FOCUS_MANIFEST_YAML).toContain("source: repo_file");
});

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-manifest-drift.mjs"], { encoding: "utf8" });

expect(output).toMatch(/Manifest-drift check ok: \.gittensory\.yml and GITTENSORY_REPO_FOCUS_MANIFEST_YAML agree\./);
});
});
Loading