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
4 changes: 4 additions & 0 deletions .tech-debt/localstorage-allowlist-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"production": 19,
"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."
}
2 changes: 1 addition & 1 deletion docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
| 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) |
| 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) |
| 5 | `<DataState>` wrapper | 4 | 2 | 2.00 | [01 §3.2](./01-frontend-ergonomics.md) — done (component + 10 tests) |
| 6 | `localStorage` 17 → 0 codemod | 4 | 2 | 2.00 | [02 §2.2](./02-architecture-and-state.md) |
| 6 | `localStorage` allowlist burndown CI metric | 4 | 2 | 2.00 | [02 §2.2](./02-architecture-and-state.md) — done (`pnpm lint:localstorage-allowlist`) |
| 7 | Audit docs status-table + archive >6-mo | 2 | 1 | 2.00 | [04 §11](./04-security-observability-testing-devx.md) — done (Status / Implemented / Outstanding) |
| 8 | Form-engine unification | 5 | 3 | 1.67 | [01 §3.1](./01-frontend-ergonomics.md) |
| 9 | CloudSync split-brain integration tests | 5 | 3 | 1.67 | [02 §2.3](./02-architecture-and-state.md) |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Web deep-dive — Architecture & state

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

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

> **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).

**Що бачу.** `docs/tech-debt/frontend.md:89-100` — є TODO-список з 17 файлами, які усе ще читають `localStorage` напряму через `eslint.config.js` allowlist. Допустима тимчасова фаза, але burn-down треба **запланувати**, а не «коли руки дійдуть».

**Чому це дороге.** Кожен з цих файлів — потенційний краш у Safari Private Mode (де `localStorage.setItem` кидає `QuotaExceededError`) або у iOS WebKit з очищеним сховищем.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@
"build:analyze": "pnpm --filter @sergeant/web build:analyze",
"build:check-size": "node scripts/check-bundle-size.mjs",
"preview": "pnpm --filter @sergeant/web preview",
"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",
"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",
"lint:imports": "node scripts/check-imports.mjs",
"lint:migrations": "node scripts/lint-migrations.mjs",
"ops:n8n:validate": "node scripts/n8n/validate-n8n-workflows.mjs",
"n8n:import": "node scripts/n8n/n8n-workflows.mjs import",
"n8n:export": "node scripts/n8n/n8n-workflows.mjs export",
"lint:tech-debt-freshness": "node scripts/check-tech-debt-freshness.mjs",
"lint:localstorage-allowlist": "node scripts/check-localstorage-allowlist.mjs",
"lint:governance-sync": "node scripts/check-governance-sync.mjs",
"lint:hard-rules-registry": "node scripts/check-hard-rules-registry.mjs",
"api:generate-openapi": "node scripts/api/generate-openapi.mjs",
Expand Down
177 changes: 177 additions & 0 deletions scripts/__tests__/check-localstorage-allowlist.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// scripts/__tests__/check-localstorage-allowlist.test.mjs
//
// Unit tests for the localStorage allowlist budget guard.
// Run with:
// node --test scripts/__tests__/check-localstorage-allowlist.test.mjs
//
// We test the pure parsing helpers (no FS, no env). The CLI runner
// itself is exercised end-to-end via `pnpm lint:localstorage-allowlist`
// in CI.

import { describe, it } from "node:test";
import assert from "node:assert/strict";

import {
extractWebIgnoresBlock,
countProductionEntries,
parseBudgetFile,
} from "../check-localstorage-allowlist.mjs";

// ── extractWebIgnoresBlock ───────────────────────────────────────────────────

describe("extractWebIgnoresBlock", () => {
it("returns null when the rule wiring is absent", () => {
const source = `
module.exports = [
{ rules: { "no-console": "warn" } },
];
`;
assert.equal(extractWebIgnoresBlock(source), null);
});

it("locates the web app's ignores block adjacent to the rule wiring", () => {
const source = [
"export default [",
" {",
' files: ["apps/web/src/**/*.{js,jsx,ts,tsx}"],',
" ignores: [",
' "apps/web/src/**/*.test.{js,jsx,ts,tsx}",',
' "apps/web/src/shared/lib/storage/storage.ts",',
' "apps/web/src/shared/hooks/useDarkMode.ts",',
" ],",
" rules: {",
' "sergeant-design/no-raw-local-storage": "error",',
" },",
" },",
"];",
"",
].join("\n");

const block = extractWebIgnoresBlock(source);
assert.ok(block, "block should be located");
assert.match(block, /storage\.ts/);
assert.match(block, /useDarkMode\.ts/);
});

it("does NOT match the mobile rule wiring", () => {
const source = [
"export default [",
" {",
' files: ["apps/mobile/src/**/*.{js,jsx,ts,tsx}"],',
" ignores: [",
' "apps/mobile/src/**/*.test.{js,jsx,ts,tsx}",',
" ],",
" rules: {",
' "sergeant-design/no-raw-local-storage": "error",',
" },",
" },",
"];",
"",
].join("\n");

assert.equal(
extractWebIgnoresBlock(source),
null,
"must not match the mobile glob",
);
});
});

// ── countProductionEntries ───────────────────────────────────────────────────

describe("countProductionEntries", () => {
it("returns 0 for an empty block", () => {
assert.equal(countProductionEntries("[]"), 0);
});

it("ignores the two test-fixture entries", () => {
const block = `
ignores: [
"apps/web/src/**/*.test.{js,jsx,ts,tsx}",
"apps/web/src/**/__tests__/**",
"apps/web/src/shared/lib/storage/storage.ts",
"apps/web/src/shared/hooks/useDarkMode.ts",
],
`;
assert.equal(countProductionEntries(block), 2);
});

it("ignores comments so reviewer notes don't shift the count", () => {
const block = `
ignores: [
// Tests can use localStorage freely as fixtures.
"apps/web/src/**/*.test.{js,jsx,ts,tsx}",
// "apps/web/src/shared/hooks/oldHook.ts" — migrated PR #999
"apps/web/src/shared/hooks/useDarkMode.ts",
],
`;
assert.equal(countProductionEntries(block), 1);
});

it("counts every non-test path exactly once", () => {
const block = `
ignores: [
"apps/web/src/**/*.test.{js,jsx,ts,tsx}",
"apps/web/src/**/__tests__/**",
"apps/web/src/a.ts",
"apps/web/src/b.ts",
"apps/web/src/c.ts",
],
`;
assert.equal(countProductionEntries(block), 3);
});
});

// ── parseBudgetFile ──────────────────────────────────────────────────────────

describe("parseBudgetFile", () => {
it("accepts a well-formed budget", () => {
const json = JSON.stringify({
production: 17,
rationale: "Baseline 2026-05-04: 11 primitives + 4 cloud-sync + 2 misc.",
});
const out = parseBudgetFile(json);
assert.equal(out.production, 17);
assert.match(out.rationale, /Baseline/);
});

it("floors fractional production counts", () => {
const json = JSON.stringify({
production: 17.9,
rationale: "Reasonable rationale text long enough.",
});
assert.equal(parseBudgetFile(json).production, 17);
});

it("rejects negative production counts", () => {
const json = JSON.stringify({
production: -1,
rationale: "Reasonable rationale text long enough.",
});
assert.throws(() => parseBudgetFile(json), /≥ 0/);
});

it("rejects non-numeric production counts", () => {
const json = JSON.stringify({
production: "17",
rationale: "Reasonable rationale text long enough.",
});
assert.throws(() => parseBudgetFile(json), /finite number/);
});

it("rejects a missing or too-short rationale", () => {
assert.throws(
() => parseBudgetFile(JSON.stringify({ production: 1, rationale: "" })),
/≥ 8 chars/,
);
assert.throws(
() =>
parseBudgetFile(JSON.stringify({ production: 1, rationale: "short" })),
/≥ 8 chars/,
);
assert.throws(
() => parseBudgetFile(JSON.stringify({ production: 1 })),
/rationale/,
);
});
});
Loading
Loading