Skip to content

Commit 7796a79

Browse files
ci(ci): gate localStorage allowlist growth via burndown budget script
Adds scripts/check-localstorage-allowlist.mjs which parses the apps/web no-raw-local-storage ESLint allowlist, counts production entries (test fixtures excluded), and fails CI when the count exceeds .tech-debt/localstorage-allowlist-budget.json. Wired into pnpm lint via lint:localstorage-allowlist. Implements item 6 of diagnostic 2026-05-03-web-deep-dive section 2.2. 12 unit tests cover the parser plus budget validator.
1 parent f912ed6 commit 7796a79

6 files changed

Lines changed: 464 additions & 3 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"production": 19,
3+
"rationale": "Baseline 2026-05-04: 11 storage primitives + 4 cloud-sync internals + 4 module wrappers (see eslint.config.js). Burndown plan in docs/diagnostics/2026-05-03-web-deep-dive/02-architecture-and-state.md §2.2 + docs/tech-debt/frontend.md §2."
4+
}

docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
| 3 | CSP report-only on Vercel | 3 | 1 | 3.00 | [04 §6.4](./04-security-observability-testing-devx.md) — done [#1551](https://github.com/Skords-01/Sergeant/pull/1551) |
8383
| 4 | Module prefetch on hover + on-idle | 3 | 1 | 3.00 | [03 §5.2 + §10.4](./03-backend-and-performance.md) — done (idle + connection gate) |
8484
| 5 | `<DataState>` wrapper | 4 | 2 | 2.00 | [01 §3.2](./01-frontend-ergonomics.md) — done (component + 10 tests) |
85-
| 6 | `localStorage` 17 → 0 codemod | 4 | 2 | 2.00 | [02 §2.2](./02-architecture-and-state.md) |
85+
| 6 | `localStorage` allowlist burndown CI metric | 4 | 2 | 2.00 | [02 §2.2](./02-architecture-and-state.md) — done (`pnpm lint:localstorage-allowlist`) |
8686
| 7 | Audit docs status-table + archive >6-mo | 2 | 1 | 2.00 | [04 §11](./04-security-observability-testing-devx.md) — done (Status / Implemented / Outstanding) |
8787
| 8 | Form-engine unification | 5 | 3 | 1.67 | [01 §3.1](./01-frontend-ergonomics.md) |
8888
| 9 | CloudSync split-brain integration tests | 5 | 3 | 1.67 | [02 §2.3](./02-architecture-and-state.md) |

docs/diagnostics/2026-05-03-web-deep-dive/02-architecture-and-state.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Web deep-dive — Architecture & state
22

3-
> **Last validated:** 2026-05-03 by @Skords-01.
3+
> **Last validated:** 2026-05-04 by @Skords-01.
44
> **Status:** Active
55
> **Scope:** Provider tree, routing, sync v1↔v2, `index.css`, in-process workers, React Query patterns, `localStorage` migration, CloudSync split-brain risk, `useCloudSync` shape.
66
> **Related:** [`00-overview.md`](./00-overview.md), `docs/tech-debt/frontend.md`, `docs/audits/2026-04-28-sergeant-comprehensive-audit.md`.
@@ -203,6 +203,8 @@ ShortcutRegistryProvider
203203

204204
## 2.2 [Bad] `localStorage` allowlist у 17 файлах
205205

206+
> **2026-05-04 update.** Burn-down KPI запиновано у `pnpm lint:localstorage-allowlist` (`scripts/check-localstorage-allowlist.mjs`) — лічильник production-entries проти `.tech-debt/localstorage-allowlist-budget.json`. CI падає, якщо allowlist розросся понад бюджет; зменшення → треба бампнути бюджет вниз у тому ж PR + оновити `rationale`. Baseline 19 (11 storage primitives + 4 cloud-sync internals + 4 module wrappers).
207+
206208
**Що бачу.** `docs/tech-debt/frontend.md:89-100` — є TODO-список з 17 файлами, які усе ще читають `localStorage` напряму через `eslint.config.js` allowlist. Допустима тимчасова фаза, але burn-down треба **запланувати**, а не «коли руки дійдуть».
207209

208210
**Чому це дороге.** Кожен з цих файлів — потенційний краш у Safari Private Mode (де `localStorage.setItem` кидає `QuotaExceededError`) або у iOS WebKit з очищеним сховищем.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@
2323
"build:analyze": "pnpm --filter @sergeant/web build:analyze",
2424
"build:check-size": "node scripts/check-bundle-size.mjs",
2525
"preview": "pnpm --filter @sergeant/web preview",
26-
"lint": "turbo run lint && node scripts/check-imports.mjs && node tools/tsconfig-guard/check.mjs && pnpm lint:plugins && pnpm lint:tech-debt-freshness && pnpm api:check-openapi",
26+
"lint": "turbo run lint && node scripts/check-imports.mjs && node tools/tsconfig-guard/check.mjs && pnpm lint:plugins && pnpm lint:tech-debt-freshness && pnpm lint:localstorage-allowlist && pnpm api:check-openapi",
2727
"lint:imports": "node scripts/check-imports.mjs",
2828
"lint:migrations": "node scripts/lint-migrations.mjs",
2929
"ops:n8n:validate": "node scripts/n8n/validate-n8n-workflows.mjs",
3030
"n8n:import": "node scripts/n8n/n8n-workflows.mjs import",
3131
"n8n:export": "node scripts/n8n/n8n-workflows.mjs export",
3232
"lint:tech-debt-freshness": "node scripts/check-tech-debt-freshness.mjs",
33+
"lint:localstorage-allowlist": "node scripts/check-localstorage-allowlist.mjs",
3334
"lint:governance-sync": "node scripts/check-governance-sync.mjs",
3435
"lint:hard-rules-registry": "node scripts/check-hard-rules-registry.mjs",
3536
"api:generate-openapi": "node scripts/api/generate-openapi.mjs",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// scripts/__tests__/check-localstorage-allowlist.test.mjs
2+
//
3+
// Unit tests for the localStorage allowlist budget guard.
4+
// Run with:
5+
// node --test scripts/__tests__/check-localstorage-allowlist.test.mjs
6+
//
7+
// We test the pure parsing helpers (no FS, no env). The CLI runner
8+
// itself is exercised end-to-end via `pnpm lint:localstorage-allowlist`
9+
// in CI.
10+
11+
import { describe, it } from "node:test";
12+
import assert from "node:assert/strict";
13+
14+
import {
15+
extractWebIgnoresBlock,
16+
countProductionEntries,
17+
parseBudgetFile,
18+
} from "../check-localstorage-allowlist.mjs";
19+
20+
// ── extractWebIgnoresBlock ───────────────────────────────────────────────────
21+
22+
describe("extractWebIgnoresBlock", () => {
23+
it("returns null when the rule wiring is absent", () => {
24+
const source = `
25+
module.exports = [
26+
{ rules: { "no-console": "warn" } },
27+
];
28+
`;
29+
assert.equal(extractWebIgnoresBlock(source), null);
30+
});
31+
32+
it("locates the web app's ignores block adjacent to the rule wiring", () => {
33+
const source = [
34+
"export default [",
35+
" {",
36+
' files: ["apps/web/src/**/*.{js,jsx,ts,tsx}"],',
37+
" ignores: [",
38+
' "apps/web/src/**/*.test.{js,jsx,ts,tsx}",',
39+
' "apps/web/src/shared/lib/storage/storage.ts",',
40+
' "apps/web/src/shared/hooks/useDarkMode.ts",',
41+
" ],",
42+
" rules: {",
43+
' "sergeant-design/no-raw-local-storage": "error",',
44+
" },",
45+
" },",
46+
"];",
47+
"",
48+
].join("\n");
49+
50+
const block = extractWebIgnoresBlock(source);
51+
assert.ok(block, "block should be located");
52+
assert.match(block, /storage\.ts/);
53+
assert.match(block, /useDarkMode\.ts/);
54+
});
55+
56+
it("does NOT match the mobile rule wiring", () => {
57+
const source = [
58+
"export default [",
59+
" {",
60+
' files: ["apps/mobile/src/**/*.{js,jsx,ts,tsx}"],',
61+
" ignores: [",
62+
' "apps/mobile/src/**/*.test.{js,jsx,ts,tsx}",',
63+
" ],",
64+
" rules: {",
65+
' "sergeant-design/no-raw-local-storage": "error",',
66+
" },",
67+
" },",
68+
"];",
69+
"",
70+
].join("\n");
71+
72+
assert.equal(
73+
extractWebIgnoresBlock(source),
74+
null,
75+
"must not match the mobile glob",
76+
);
77+
});
78+
});
79+
80+
// ── countProductionEntries ───────────────────────────────────────────────────
81+
82+
describe("countProductionEntries", () => {
83+
it("returns 0 for an empty block", () => {
84+
assert.equal(countProductionEntries("[]"), 0);
85+
});
86+
87+
it("ignores the two test-fixture entries", () => {
88+
const block = `
89+
ignores: [
90+
"apps/web/src/**/*.test.{js,jsx,ts,tsx}",
91+
"apps/web/src/**/__tests__/**",
92+
"apps/web/src/shared/lib/storage/storage.ts",
93+
"apps/web/src/shared/hooks/useDarkMode.ts",
94+
],
95+
`;
96+
assert.equal(countProductionEntries(block), 2);
97+
});
98+
99+
it("ignores comments so reviewer notes don't shift the count", () => {
100+
const block = `
101+
ignores: [
102+
// Tests can use localStorage freely as fixtures.
103+
"apps/web/src/**/*.test.{js,jsx,ts,tsx}",
104+
// "apps/web/src/shared/hooks/oldHook.ts" — migrated PR #999
105+
"apps/web/src/shared/hooks/useDarkMode.ts",
106+
],
107+
`;
108+
assert.equal(countProductionEntries(block), 1);
109+
});
110+
111+
it("counts every non-test path exactly once", () => {
112+
const block = `
113+
ignores: [
114+
"apps/web/src/**/*.test.{js,jsx,ts,tsx}",
115+
"apps/web/src/**/__tests__/**",
116+
"apps/web/src/a.ts",
117+
"apps/web/src/b.ts",
118+
"apps/web/src/c.ts",
119+
],
120+
`;
121+
assert.equal(countProductionEntries(block), 3);
122+
});
123+
});
124+
125+
// ── parseBudgetFile ──────────────────────────────────────────────────────────
126+
127+
describe("parseBudgetFile", () => {
128+
it("accepts a well-formed budget", () => {
129+
const json = JSON.stringify({
130+
production: 17,
131+
rationale: "Baseline 2026-05-04: 11 primitives + 4 cloud-sync + 2 misc.",
132+
});
133+
const out = parseBudgetFile(json);
134+
assert.equal(out.production, 17);
135+
assert.match(out.rationale, /Baseline/);
136+
});
137+
138+
it("floors fractional production counts", () => {
139+
const json = JSON.stringify({
140+
production: 17.9,
141+
rationale: "Reasonable rationale text long enough.",
142+
});
143+
assert.equal(parseBudgetFile(json).production, 17);
144+
});
145+
146+
it("rejects negative production counts", () => {
147+
const json = JSON.stringify({
148+
production: -1,
149+
rationale: "Reasonable rationale text long enough.",
150+
});
151+
assert.throws(() => parseBudgetFile(json), / 0/);
152+
});
153+
154+
it("rejects non-numeric production counts", () => {
155+
const json = JSON.stringify({
156+
production: "17",
157+
rationale: "Reasonable rationale text long enough.",
158+
});
159+
assert.throws(() => parseBudgetFile(json), /finite number/);
160+
});
161+
162+
it("rejects a missing or too-short rationale", () => {
163+
assert.throws(
164+
() => parseBudgetFile(JSON.stringify({ production: 1, rationale: "" })),
165+
/ 8 chars/,
166+
);
167+
assert.throws(
168+
() =>
169+
parseBudgetFile(JSON.stringify({ production: 1, rationale: "short" })),
170+
/ 8 chars/,
171+
);
172+
assert.throws(
173+
() => parseBudgetFile(JSON.stringify({ production: 1 })),
174+
/rationale/,
175+
);
176+
});
177+
});

0 commit comments

Comments
 (0)