Skip to content

Commit afe6969

Browse files
authored
fix(engine): reject over-complex screenshotTableGate whenPaths globs at normalization (#10115)
whenPaths back matchesAnyWithExclusions, whose include half (matchesAny) FAILS TOWARD MATCHING for a glob whose wildcard-group count exceeds the compiler cap -- correct for hardGuardrailGlobs (an over-complex guardrail glob still forces a human hold) but wrong for whenPaths, where matching everything means every PR in the repo is in scope for a close-tier visual gate. normalizeStringList validated type/emptiness/count/length but never wildcard count, so an ordinary monorepo scoping glob like apps/**/src/**/*.tsx (3 groups) silently scoped every PR, and a 3-group exclusion like !**/*.generated.* compiled to NEVER_MATCHES and excluded nothing -- both with no warning. Validate whenPaths at the normalizer with the same hasUnsafeWildcardCount predicate every other manifest glob surface already uses (mirrors focus-manifest's normalizeOptionalGlob): an over-complex entry is dropped with a warning naming the field and index, measured on the glob BODY so an exclusion is judged by the pattern matchesAnyWithExclusions actually compiles, and a bare '!' (which would mis-route into the include list) is dropped too. whenLabels / requireViewports / requireThemes normalization, matchesAny's fail-toward-matching semantics, MAX_GLOB_WILDCARD_GROUPS and hardGuardrailGlobs are all unchanged. Closes #9993
1 parent 3d7fdbf commit afe6969

3 files changed

Lines changed: 95 additions & 5 deletions

File tree

packages/loopover-engine/src/review/screenshot-table-gate.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { matchesAnyWithExclusions } from "../signals/change-guardrail.js";
1+
import { hasUnsafeWildcardCount, matchesAnyWithExclusions } from "../signals/change-guardrail.js";
22
import type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js";
33

44
export type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js";
@@ -39,7 +39,7 @@ export function isScreenshotTableGateAction(value: unknown): value is Screenshot
3939
return typeof value === "string" && (VALID_ACTIONS as readonly string[]).includes(value);
4040
}
4141

42-
function normalizeStringList(value: unknown, field: string, max: number, maxChars: number, warnings: string[]): string[] {
42+
function normalizeStringList(value: unknown, field: string, max: number, maxChars: number, warnings: string[], glob = false): string[] {
4343
if (value === undefined) return [];
4444
if (!Array.isArray(value)) {
4545
warnings.push(`settings.requireScreenshotTable.${field} must be an array; ignoring it.`);
@@ -55,7 +55,21 @@ function normalizeStringList(value: unknown, field: string, max: number, maxChar
5555
warnings.push(`settings.requireScreenshotTable.${field}[${index}] must be a non-empty string; ignoring it.`);
5656
continue;
5757
}
58-
out.push(item.trim().slice(0, maxChars));
58+
const trimmed = item.trim().slice(0, maxChars);
59+
if (glob) {
60+
// #9993: whenPaths back matchesAnyWithExclusions, whose include half FAILS TOWARD MATCHING for an
61+
// over-complex glob (matchesAny returns true for every path). An unvalidated `apps/**/src/**/*.tsx`
62+
// (3 groups) would silently put every PR in scope for a close-tier gate. Reject the same shape the
63+
// other manifest glob surfaces reject (mirrors focus-manifest's normalizeOptionalGlob), measured on
64+
// the glob BODY: an exclusion `!<body>` is compiled as `<body>`, so a bare `!` has no body to compile
65+
// and matchesAnyWithExclusions would mis-route it into the include list -- drop it too.
66+
const globBody = trimmed.startsWith("!") ? trimmed.slice(1) : trimmed;
67+
if (globBody === "" || hasUnsafeWildcardCount(globBody)) {
68+
warnings.push(`settings.requireScreenshotTable.${field}[${index}] has too many wildcards to compile safely; ignoring it.`);
69+
continue;
70+
}
71+
}
72+
out.push(trimmed);
5973
}
6074
return out;
6175
}
@@ -93,7 +107,7 @@ export function normalizeScreenshotTableGateConfig(input: unknown, warnings: str
93107
return {
94108
enabled,
95109
whenLabels: normalizeStringList(record.whenLabels, "whenLabels", MAX_LABELS, MAX_LABEL_CHARS, warnings),
96-
whenPaths: normalizeStringList(record.whenPaths, "whenPaths", MAX_PATHS, MAX_PATH_CHARS, warnings),
110+
whenPaths: normalizeStringList(record.whenPaths, "whenPaths", MAX_PATHS, MAX_PATH_CHARS, warnings, true),
97111
action,
98112
requireViewports: normalizeStringList(record.requireViewports, "requireViewports", MAX_MATRIX_DIMENSION, MAX_MATRIX_TOKEN_CHARS, warnings),
99113
requireThemes: normalizeStringList(record.requireThemes, "requireThemes", MAX_MATRIX_DIMENSION, MAX_MATRIX_TOKEN_CHARS, warnings),

packages/loopover-engine/test/screenshot-table-gate.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33

4-
import { normalizeScreenshotTableGateConfig } from "../dist/review/screenshot-table-gate.js";
4+
import { isScreenshotTableGateInScope, normalizeScreenshotTableGateConfig } from "../dist/review/screenshot-table-gate.js";
55

66
// #9996: the invalid-action warning was left naming only "close" or "advisory" after #9964 added the
77
// non-destructive "block" tier -- an operator who mistyped the value was never told "block" exists.
@@ -27,3 +27,48 @@ test("normalizeScreenshotTableGateConfig resolves a valid block action with no w
2727
assert.equal(warnings.length, 0);
2828
assert.equal(config.action, "block");
2929
});
30+
31+
// #9993: whenPaths back matchesAnyWithExclusions, whose include half FAILS TOWARD MATCHING for an
32+
// over-complex glob (matchesAny returns true for every path). An unvalidated 3-group glob such as
33+
// `apps/**/src/**/*.tsx` therefore silently put EVERY changed file in scope for a close-tier visual gate.
34+
// whenPaths is now validated with the same hasUnsafeWildcardCount predicate every other manifest glob uses.
35+
test("#9993: a 3-wildcard-group whenPaths glob is dropped, so an unrelated file is no longer in scope", () => {
36+
const warnings: string[] = [];
37+
// The over-complex glob alongside a valid one: today the over-complex glob matches README.md (fail-open),
38+
// putting it in scope. After the fix only the valid glob survives and README.md is out of scope.
39+
const config = normalizeScreenshotTableGateConfig(
40+
{ enabled: true, whenPaths: ["apps/**/src/**/*.tsx", "apps/ui/src/**"] },
41+
warnings,
42+
);
43+
assert.deepEqual(config.whenPaths, ["apps/ui/src/**"]);
44+
assert.ok(warnings.some((w) => w.includes("whenPaths[0]")));
45+
assert.equal(isScreenshotTableGateInScope(config, [], ["README.md"]), false);
46+
assert.equal(isScreenshotTableGateInScope(config, [], ["apps/ui/src/App.tsx"]), true);
47+
});
48+
49+
test("#9993: an exclusion is judged by its glob BODY, and a bare `!` is dropped", () => {
50+
const warnings: string[] = [];
51+
// `!**/*.generated.*` has a 3-group body, so it is dropped (an unvalidated over-complex exclusion compiles
52+
// to NEVER_MATCHES and excludes nothing). The valid include survives.
53+
const config = normalizeScreenshotTableGateConfig(
54+
{ enabled: true, whenPaths: ["!**/*.generated.*", "apps/ui/src/**"] },
55+
warnings,
56+
);
57+
assert.deepEqual(config.whenPaths, ["apps/ui/src/**"]);
58+
assert.ok(warnings.some((w) => w.includes("whenPaths[0]")));
59+
60+
const bareBang: string[] = [];
61+
const config2 = normalizeScreenshotTableGateConfig({ enabled: true, whenPaths: ["!"] }, bareBang);
62+
assert.deepEqual(config2.whenPaths, []);
63+
assert.ok(bareBang.some((w) => w.includes("whenPaths")));
64+
});
65+
66+
test("#9993: a 2-group whenPaths glob (and a valid exclusion) is preserved unchanged", () => {
67+
const warnings: string[] = [];
68+
const config = normalizeScreenshotTableGateConfig(
69+
{ enabled: true, whenPaths: ["apps/ui/public/**/*.json", "!node_modules/**"] },
70+
warnings,
71+
);
72+
assert.deepEqual(config.whenPaths, ["apps/ui/public/**/*.json", "!node_modules/**"]);
73+
assert.equal(warnings.filter((w) => w.includes("whenPaths")).length, 0);
74+
});

test/unit/screenshot-table-gate.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,37 @@ describe("normalizeScreenshotTableGateConfig", () => {
319319
expect(normalizeScreenshotTableGateConfig({ skillFileUrl: "x".repeat(301) }, []).skillFileUrl).toBeUndefined();
320320
expect(warnings.some((w) => w.includes("skillFileUrl"))).toBe(true);
321321
});
322+
323+
it("#9993: drops an over-complex whenPaths glob with a warning, so it can no longer scope every PR", () => {
324+
// whenPaths back matchesAnyWithExclusions, whose include half FAILS TOWARD MATCHING for an over-complex
325+
// glob — so an unvalidated `apps/**/src/**/*.tsx` (3 groups) silently put every PR in scope. It is now
326+
// validated with the same hasUnsafeWildcardCount predicate the other manifest glob surfaces use.
327+
const warnings: string[] = [];
328+
const config = normalizeScreenshotTableGateConfig({ enabled: true, whenPaths: ["apps/**/src/**/*.tsx", "apps/ui/src/**"] }, warnings);
329+
expect(config.whenPaths).toEqual(["apps/ui/src/**"]);
330+
expect(warnings.some((w) => w.includes("whenPaths[0]"))).toBe(true);
331+
// The behaviour change the drop produces: an unrelated file is no longer in scope (it was, today).
332+
expect(isScreenshotTableGateInScope(config, [], ["README.md"])).toBe(false);
333+
expect(isScreenshotTableGateInScope(config, [], ["apps/ui/src/App.tsx"])).toBe(true);
334+
});
335+
336+
it("#9993: measures an exclusion by its glob BODY and drops a bare `!`, keeping valid entries", () => {
337+
const warnings: string[] = [];
338+
// `!**/*.generated.*` has a 3-group body → dropped; the valid include survives.
339+
expect(normalizeScreenshotTableGateConfig({ enabled: true, whenPaths: ["!**/*.generated.*", "apps/ui/src/**"] }, warnings).whenPaths).toEqual(["apps/ui/src/**"]);
340+
expect(warnings.some((w) => w.includes("whenPaths[0]"))).toBe(true);
341+
// A bare `!` has no body to compile and would mis-route into the include list — dropped.
342+
const bare: string[] = [];
343+
expect(normalizeScreenshotTableGateConfig({ enabled: true, whenPaths: ["!"] }, bare).whenPaths).toEqual([]);
344+
expect(bare.some((w) => w.includes("whenPaths"))).toBe(true);
345+
});
346+
347+
it("#9993: preserves a 2-group whenPaths glob and a valid exclusion unchanged", () => {
348+
const warnings: string[] = [];
349+
const config = normalizeScreenshotTableGateConfig({ enabled: true, whenPaths: ["apps/ui/public/**/*.json", "!node_modules/**"] }, warnings);
350+
expect(config.whenPaths).toEqual(["apps/ui/public/**/*.json", "!node_modules/**"]);
351+
expect(warnings.filter((w) => w.includes("whenPaths"))).toHaveLength(0);
352+
});
322353
});
323354

324355
describe("requiredScreenshotMatrixPairs (#4535)", () => {

0 commit comments

Comments
 (0)