31907ba651
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
239 lines
9.8 KiB
TypeScript
239 lines
9.8 KiB
TypeScript
/**
|
|
* Duplicate detection for `i18nGuardFiles`, and the invariant it protects.
|
|
*
|
|
* The bug this pins down (#2214): the sidebar migration appended two paths that
|
|
* earlier migrations had already listed. A duplicate changes no behaviour — the
|
|
* ESLint config just passes the same pattern twice — so it cleared `pnpm lint`,
|
|
* `i18n:check`, the ratchet, `check-guard-allowlist.mjs` itself and 8,218 unit
|
|
* tests. It was caught by eye. What it costs is the record: the array is read as
|
|
* "which PR migrated what", and a second copy silently books someone else's work
|
|
* as the new PR's coverage.
|
|
*
|
|
* Following the pattern in `git-base.test.ts`, the "fixed" assertions are paired
|
|
* with one showing the pre-existing removal check still cannot see a duplicate —
|
|
* a test that only exercised the new helper would not explain why it has to be a
|
|
* separate check.
|
|
*/
|
|
import * as nodeFs from "node:fs";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { i18nGuardFiles } from "../../eslint.i18n.options.mjs";
|
|
import { duplicateEntries, filesForEntry, unmatchedEntries } from "./guard-allowlist.mjs";
|
|
|
|
/** What `check-guard-allowlist.mjs` did before this helper existed. */
|
|
function removedEntries(before: string[], after: string[]) {
|
|
const afterSet = new Set(after);
|
|
return before.filter((entry) => !afterSet.has(entry));
|
|
}
|
|
|
|
describe("duplicateEntries", () => {
|
|
it("reports nothing for a list with no repeats", () => {
|
|
expect(duplicateEntries(["a.tsx", "b.tsx", "components/c/**"])).toEqual([]);
|
|
});
|
|
|
|
it("names an entry listed twice", () => {
|
|
const entries = ["a.tsx", "b.tsx", "a.tsx"];
|
|
|
|
expect(duplicateEntries(entries)).toEqual(["a.tsx"]);
|
|
});
|
|
|
|
it("names an entry once however often it repeats", () => {
|
|
expect(duplicateEntries(["a.tsx", "a.tsx", "a.tsx", "a.tsx"])).toEqual(["a.tsx"]);
|
|
});
|
|
|
|
it("names every distinct repeat — #2214 added two, not one", () => {
|
|
const entries = [
|
|
"components/app-sidebar/sections/settings/general-group.tsx",
|
|
"components/app-sidebar/sections/settings/agents-group.tsx",
|
|
"components/app-status-bar/**/*.{ts,tsx}",
|
|
"components/app-sidebar/sections/settings/general-group.tsx",
|
|
"components/app-sidebar/sections/settings/agents-group.tsx",
|
|
];
|
|
|
|
expect(duplicateEntries(entries)).toEqual([
|
|
"components/app-sidebar/sections/settings/general-group.tsx",
|
|
"components/app-sidebar/sections/settings/agents-group.tsx",
|
|
]);
|
|
});
|
|
|
|
/**
|
|
* The check is exact-match ON PURPOSE. `main` carries entries that a broader
|
|
* glob already covers, and #2202's comment keeps them deliberately: they are
|
|
* the historical record of which PR migrated which half of the System group.
|
|
* Widening this into a subsumption check would fail the list as it stands.
|
|
*/
|
|
it("does not flag an entry a broader glob already covers", () => {
|
|
const entries = [
|
|
"app/settings/system/**/*.{ts,tsx}",
|
|
"app/settings/system/storage/**/*.{ts,tsx}",
|
|
"components/settings/system/*.{ts,tsx}",
|
|
"components/settings/system/system-page-shell.tsx",
|
|
];
|
|
|
|
expect(duplicateEntries(entries)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("the removal check cannot stand in for it", () => {
|
|
it("reproduces the gap: adding a duplicate removes nothing", () => {
|
|
const before = ["a.tsx", "b.tsx"];
|
|
const after = ["a.tsx", "b.tsx", "a.tsx"];
|
|
|
|
expect(removedEntries(before, after)).toEqual([]);
|
|
expect(duplicateEntries(after)).toEqual(["a.tsx"]);
|
|
});
|
|
});
|
|
|
|
describe("the live allowlist", () => {
|
|
it("lists no path twice", () => {
|
|
expect(duplicateEntries(i18nGuardFiles)).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* Guards the scenario above against drift: if these two ever stop coexisting,
|
|
* the "exact duplicates only" test is no longer pinning a real decision.
|
|
*/
|
|
it("still carries the deliberate storage redundancy the check must tolerate", () => {
|
|
expect(i18nGuardFiles).toContain("components/settings/system/**/*.{ts,tsx}");
|
|
expect(i18nGuardFiles).toContain("components/settings/system/storage/**/*.{ts,tsx}");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The born-dead entry (#2247). A glob for the `[id]` automations route was
|
|
* appended by a migration and matched NOTHING: glob brackets are a CHARACTER CLASS, so `[id]`
|
|
* means "one `i` or one `d`" and never the literal directory. The route was
|
|
* allowlisted and unguarded at the same time, and every check reported green —
|
|
* it never left the array so the removal check ignored it, it was not repeated
|
|
* so `duplicateEntries` ignored it, and `pnpm lint` passed because the rule had
|
|
* no files to apply. Confirmed then by putting a hardcoded literal in that
|
|
* route: 0 lint errors before escaping, 1 after.
|
|
*
|
|
* These run the REAL `fs.globSync` against a fixture tree rather than a
|
|
* re-implementation of glob semantics — the bracket behaviour is the whole point,
|
|
* so emulating it would only test the emulation. **Do not simplify this back to a
|
|
* hand-rolled matcher.** The first draft did exactly that, and a malformed
|
|
* character class in it (`/[*?[]]{}]/` — a lost backslash) made EVERY entry look
|
|
* unmatched, so the live-allowlist test below reported a problem that did not
|
|
* exist. A false positive that reads as a find is the failure mode this whole
|
|
* area keeps producing; a fixture plus the real matcher cannot have it.
|
|
*
|
|
* As with `duplicateEntries` above, the helper is paired with a test showing both
|
|
* existing checks are blind to it, so the reason it must be separate stays on the
|
|
* record rather than becoming folklore.
|
|
*/
|
|
describe("unmatchedEntries", () => {
|
|
let root: string;
|
|
|
|
/**
|
|
* The very resolver `check-guard-allowlist.mjs` uses, pointed at the fixture —
|
|
* not a re-implementation. Glob bracket semantics are the thing under test, so
|
|
* emulating them would only test the emulation.
|
|
*/
|
|
const resolve = (entry: string) => filesForEntry(entry, { cwd: root, fsImpl: nodeFs });
|
|
|
|
beforeAll(() => {
|
|
root = mkdtempSync(path.join(tmpdir(), "kandev-guard-allowlist-"));
|
|
for (const file of [
|
|
"app/settings/workspace/[id]/automations/page.tsx",
|
|
"app/settings/workspace/[id]/automations/new/page.tsx",
|
|
"components/automations/trigger-card.tsx",
|
|
]) {
|
|
const full = path.join(root, file);
|
|
mkdirSync(path.dirname(full), { recursive: true });
|
|
writeFileSync(full, "");
|
|
}
|
|
});
|
|
|
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
it("reports nothing when every entry matches", () => {
|
|
expect(unmatchedEntries(["components/automations/trigger-card.tsx"], resolve)).toEqual([]);
|
|
expect(unmatchedEntries(["components/automations/*.tsx"], resolve)).toEqual([]);
|
|
});
|
|
|
|
it("names an unescaped dynamic-route entry, which matches nothing", () => {
|
|
const entry = "app/settings/workspace/[id]/automations/**/*.tsx";
|
|
|
|
expect(resolve(entry)).toEqual([]);
|
|
expect(unmatchedEntries([entry], resolve)).toEqual([entry]);
|
|
});
|
|
|
|
it("accepts the escaped form of the very same route", () => {
|
|
const entry = "app/settings/workspace/[[]id[]]/automations/**/*.tsx";
|
|
|
|
expect(resolve(entry)).toHaveLength(2);
|
|
expect(unmatchedEntries([entry], resolve)).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A bare directory is the same bug wearing different clothes: `existsSync`
|
|
* confirms it, but ESLint flat config matches `files` against FILE paths, so
|
|
* `components/automations` selects nothing — it does not stand for
|
|
* `components/automations/**`. Caught in review on this very PR.
|
|
*/
|
|
it("does not count a bare directory as matched", () => {
|
|
expect(resolve("components/automations")).toEqual([]);
|
|
expect(unmatchedEntries(["components/automations"], resolve)).toEqual([
|
|
"components/automations",
|
|
]);
|
|
});
|
|
|
|
it("does not count directories a glob happens to match", () => {
|
|
// `*` matches the nested directory as well as the file; only the file counts.
|
|
expect(resolve("app/settings/workspace/[[]id[]]/automations/*")).toEqual([
|
|
"app/settings/workspace/[id]/automations/page.tsx",
|
|
]);
|
|
});
|
|
|
|
it("names an entry whose path is gone", () => {
|
|
expect(unmatchedEntries(["components/gone/page.tsx"], resolve)).toEqual([
|
|
"components/gone/page.tsx",
|
|
]);
|
|
});
|
|
|
|
it("names every dead entry, not only the first", () => {
|
|
const dead = ["components/gone/a.tsx", "components/gone/b.tsx"];
|
|
const entries = [...dead, "components/automations/trigger-card.tsx"];
|
|
|
|
expect(unmatchedEntries(entries, resolve)).toEqual(dead);
|
|
});
|
|
});
|
|
|
|
describe("neither existing check can stand in for it", () => {
|
|
it("reproduces the gap: a born-dead entry never leaves the array and is not a duplicate", () => {
|
|
const before = ["a.tsx"];
|
|
const after = ["a.tsx", "app/settings/workspace/[id]/automations/**/*.tsx"];
|
|
|
|
// A resolver that is realistic rather than blanket: "a.tsx" exists, the
|
|
// unescaped glob matches nothing. A `() => []` stub would report BOTH and
|
|
// read as if the check flags live entries too.
|
|
const resolve = (entry: string) => (entry.includes("[id]") ? [] : [entry]);
|
|
|
|
// Both existing checks are clean: nothing left, nothing repeated.
|
|
expect(removedEntries(before, after)).toEqual([]);
|
|
expect(duplicateEntries(after)).toEqual([]);
|
|
// Only the new check sees it, and it names just the dead entry.
|
|
expect(unmatchedEntries(after, resolve)).toEqual([
|
|
"app/settings/workspace/[id]/automations/**/*.tsx",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("the live allowlist", () => {
|
|
/**
|
|
* If this fails, some entry is listed but guards nothing. Run
|
|
* `node scripts/check-guard-allowlist.mjs` — it names the entry and the fix.
|
|
*/
|
|
it("has no entry matching zero files", () => {
|
|
const webDir = path.resolve(import.meta.dirname, "..", "..");
|
|
const live = (entry: string) => filesForEntry(entry, { cwd: webDir, fsImpl: nodeFs });
|
|
|
|
expect(unmatchedEntries(i18nGuardFiles, live)).toEqual([]);
|
|
});
|
|
});
|