From 0991c04cd723f6162d10c228cf96189f4fc355f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 05:09:36 +0000 Subject: [PATCH 1/2] fix(seed-posts): import dieWithLabel before calling it (Pass 4 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P3-H CLI consolidation left seed-posts calling dieWithLabel with no import — a ReferenceError on the empty-fetch abort path, invisible to lint/typecheck/knip. Recurrence guard in the same delivery: scriptCliConventions.unit.spec.ts fails the build when a script calls dieWithLabel/dieAgent without importing (or defining) it, and bans re-spelled 'const DAY_MS =' outside src/lib/text.ts (P3-K's fold leaked one private copy in engagementLevel.ts). --- scripts/seed-posts.mjs | 2 +- tests/unit/scriptCliConventions.unit.spec.ts | 63 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/unit/scriptCliConventions.unit.spec.ts diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs index 15c7040d..beb7d38e 100644 --- a/scripts/seed-posts.mjs +++ b/scripts/seed-posts.mjs @@ -27,7 +27,7 @@ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext- import { del as blobDel } from '@vercel/blob' import { JSDOM } from 'jsdom' import { getPayload } from 'payload' -import { loadCliEnv } from './lib/cli.mjs' +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' diff --git a/tests/unit/scriptCliConventions.unit.spec.ts b/tests/unit/scriptCliConventions.unit.spec.ts new file mode 100644 index 00000000..58b5e8b8 --- /dev/null +++ b/tests/unit/scriptCliConventions.unit.spec.ts @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Pass 4 (2026-07-31): `scripts/seed-posts.mjs` called `dieWithLabel(…)` +// without importing it — a ReferenceError on the empty-fetch abort path that +// lint, typecheck and knip all let through (P3-H half-adopted in the same pass +// that added the guard). The CLI skeleton is only a skeleton when the symbols +// it exports are actually imported. +// +// `DAY_MS` is single-sourced in `src/lib/text.ts` (P3-K); a private re-spell +// survived in `engagementLevel.ts` until this pass — ban the declaration form. + +const repoRoot = process.cwd() +const repoPath = (absolute: string) => relative(repoRoot, absolute) + +const walkFiles = (root: string, extensions: readonly string[]): string[] => + readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) + .map((entry) => resolve(entry.parentPath, entry.name)) + +describe('script CLI skeleton (Pass 4)', () => { + const scriptFiles = walkFiles(resolve(repoRoot, 'scripts'), ['.mjs']) + + it('imports dieWithLabel / dieAgent wherever they are called', () => { + const offenders: string[] = [] + + for (const file of scriptFiles) { + const source = readFileSync(file, 'utf8') + for (const symbol of ['dieWithLabel', 'dieAgent'] as const) { + const callsIt = new RegExp(`\\b${symbol}\\s*\\(`).test(source) + if (!callsIt) continue + const definesIt = new RegExp(`export const ${symbol}\\b`).test(source) + const importsIt = new RegExp(`^import\\s*\\{[^}]*\\b${symbol}\\b[^}]*\\}`, 'm').test(source) + if (!definesIt && !importsIt) offenders.push(`${repoPath(file)} (${symbol})`) + } + } + + expect( + offenders, + 'import the CLI die helper from scripts/lib instead of calling it bare', + ).toEqual([]) + }) + + it('keeps DAY_MS single-sourced in src/lib/text.ts', () => { + const offenders: string[] = [] + const declaration = /const DAY_MS\s*=/ // this spec quotes the shape itself + + for (const root of ['src', 'tests', 'scripts'] as const) { + for (const file of walkFiles(resolve(repoRoot, root), ['.ts', '.tsx', '.mjs'])) { + const path = repoPath(file) + if (path === 'src/lib/text.ts') continue + if (path === repoPath(import.meta.filename)) continue + const lines = readFileSync(file, 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + if (declaration.test(line)) offenders.push(`${path}:${index + 1}`) + } + } + } + + expect(offenders, 'import DAY_MS from @/lib/text instead of re-spelling it').toEqual([]) + }) +}) From 9ae062c77c903d794eca7faf471f8f2f640d5f4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 05:09:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor(lib):=20close=20the=20lib=E2=86=92?= =?UTF-8?q?utilities=20type-import=20hole=20+=20Local=20API=20omission=20g?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - suggestionCatalog type-imports MunicipalityTerritorialClass from lib/territorialClassAnchors (the single source) instead of the utilities re-export — direction inversion introduced in Pass 3. - ESLint src/lib zone: @/utilities|components|app restriction now rejects type imports too (allowTypeImports: false); shared types live in lib contract modules by policy. - engagementLevel imports DAY_MS from lib/text (fold missed in P3-K). - searchHomeAdvisors: delete stale 'Intentional admin bypass' comment — the call is overrideAccess: false under the staff gate. - New guard localApiOverrideAccessConventions.unit.spec.ts: payload calls with user must declare overrideAccess (baseline measured: 59 calls, 0 missing), and runtime dynamic import/require of payload/next server modules is banned in utilities (dodges the server-only sweep). --- eslint.config.mjs | 9 +- src/lib/engagementLevel.ts | 4 +- src/lib/suggestionCatalog.ts | 2 +- .../homeSearch/searchHomeAdvisors.ts | 3 +- ...lApiOverrideAccessConventions.unit.spec.ts | 86 +++++++++++++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 tests/unit/localApiOverrideAccessConventions.unit.spec.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9953f30e..953ca8d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,7 +119,10 @@ const eslintConfig = [ { // src/lib is the pure, client-safe layer (docs/ARCHITECTURE.md): it must // never depend on Payload/Next server code, on utilities/, or on higher - // layers. Sharing types across the boundary is always fine. + // layers. Types shared with utilities live in lib contract modules and + // utilities re-export FROM lib — a lib module type-importing utilities + // inverts the direction anyway (Pass 4: suggestionCatalog did exactly + // that for a type whose source already lived in lib). files: ['src/lib/**/*.{ts,tsx}'], rules: { '@typescript-eslint/no-restricted-imports': [ @@ -129,8 +132,8 @@ const eslintConfig = [ { group: ['@/utilities/**', '@/components/**', '@/app/**'], message: - 'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/.', - allowTypeImports: true, + 'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/. Shared types live in lib contract modules.', + allowTypeImports: false, }, { group: ['payload', 'payload/**', '@payload-config', 'next', 'next/**'], diff --git a/src/lib/engagementLevel.ts b/src/lib/engagementLevel.ts index 84fffbc1..cd93387c 100644 --- a/src/lib/engagementLevel.ts +++ b/src/lib/engagementLevel.ts @@ -9,6 +9,8 @@ * one object so recalibration is a one-line diff. */ +import { DAY_MS } from '@/lib/text' + export const engagementLevels = ['n0', 'n1', 'n2', 'n3', 'n4'] as const /** `allocationDecision.patternId` under which every level movement is filed. */ @@ -94,8 +96,6 @@ export type EngagementLevelMovementInput = { triangulatedShock: boolean } -const DAY_MS = 24 * 60 * 60 * 1000 - const daysBetween = (from: Date, to: Date): number => (to.getTime() - from.getTime()) / DAY_MS const isSameCalendarMonth = (left: Date, right: Date): boolean => diff --git a/src/lib/suggestionCatalog.ts b/src/lib/suggestionCatalog.ts index c55dcdc6..e13f87bc 100644 --- a/src/lib/suggestionCatalog.ts +++ b/src/lib/suggestionCatalog.ts @@ -10,9 +10,9 @@ import { } from '@/lib/electionYears' import type { EngagementLevel } from '@/lib/engagementLevel' import { engagementLevelRank } from '@/lib/engagementLevel' +import type { MunicipalityTerritorialClass } from '@/lib/territorialClassAnchors' import { DAY_MS } from '@/lib/text' import type { AllocationDecision } from '@/payload-types' -import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass' /** * E11 "motor de sugestões" — the versioned, curated catalog of data→decision diff --git a/src/utilities/homeSearch/searchHomeAdvisors.ts b/src/utilities/homeSearch/searchHomeAdvisors.ts index 6876bb99..c60f9fd5 100644 --- a/src/utilities/homeSearch/searchHomeAdvisors.ts +++ b/src/utilities/homeSearch/searchHomeAdvisors.ts @@ -29,7 +29,8 @@ export const searchHomeAdvisors = async ( const normalizedQuery = normalizeHomeSearchName(query) - // Intentional admin bypass — unrestricted staff gate already ran in the route. + // Under access (`overrideAccess: false`): the unrestricted-staff gate ran + // above, so the advisor directory is only queried for coordinator/candidate. const result = await payload.find({ collection: 'campaignUser', where: { role: { equals: 'advisor' } }, diff --git a/tests/unit/localApiOverrideAccessConventions.unit.spec.ts b/tests/unit/localApiOverrideAccessConventions.unit.spec.ts new file mode 100644 index 00000000..7adb5c08 --- /dev/null +++ b/tests/unit/localApiOverrideAccessConventions.unit.spec.ts @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Pass 4 (2026-07-31): the engineering standard is "Local API with `user` +// always with `overrideAccess: false`; bypass admin só com comentário +// justificando" — but until now nothing failed the build when a call passed +// `user` and simply omitted `overrideAccess` (Payload then bypasses access +// silently). Measured baseline at adoption: 59 calls with `user`, 0 missing +// (the undocumented-bypass ratchet in codebaseConventions covers the `true` +// side; this guard covers the omission side). +// +// Companion rule: the `server-only` sweep in codebaseConventions only sees +// static imports, so a runtime `await import('payload')` in utilities would +// dodge the mark. Banned outright — use a static import and the mark. + +const repoRoot = process.cwd() +const repoPath = (absolute: string) => relative(repoRoot, absolute) + +const srcFiles = readdirSync(resolve(repoRoot, 'src'), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) + .map((entry) => resolve(entry.parentPath, entry.name)) + +const CALL_PATTERN = + /payload\.(find|create|update|delete|count|findByID|findGlobal|updateGlobal)\s*\(\s*\{/g + +/** Brace-matches the first `{…}` object literal starting at `openIndex`. */ +const callObjectBody = (source: string, openIndex: number): string => { + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + if (source[index] === '{') depth++ + else if (source[index] === '}') { + depth-- + if (depth === 0) return source.slice(openIndex, index + 1) + } + } + return source.slice(openIndex) +} + +describe('Local API overrideAccess discipline (Pass 4)', () => { + it('declares overrideAccess on every payload call that passes user', () => { + const offenders: string[] = [] + + for (const file of srcFiles) { + const source = readFileSync(file, 'utf8') + CALL_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = CALL_PATTERN.exec(source)) !== null) { + const openIndex = source.indexOf('{', match.index) + const body = callObjectBody(source, openIndex) + if (!/\buser\s*:/.test(body)) continue + if (/overrideAccess\s*:/.test(body)) continue + const line = source.slice(0, match.index).split('\n').length + offenders.push(`${repoPath(file)}:${line} (payload.${match[1]})`) + } + } + + expect( + offenders, + 'Local API with `user` must declare overrideAccess (false under access; true only with the justifying comment)', + ).toEqual([]) + }) + + it('bans runtime dynamic import/require of server-bound modules in utilities', () => { + const offenders: string[] = [] + const dynamicServerImport = + /\bawait\s+import\(\s*['"](?:payload|@payload-config|next\/(?:cache|server|headers))['"]\s*\)/ + const requireServerModule = /\brequire\(\s*['"](?:payload|@payload-config)['"]\s*\)/ + + for (const file of srcFiles) { + const path = repoPath(file) + if (!path.startsWith('src/utilities/')) continue + const lines = readFileSync(file, 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + if (dynamicServerImport.test(line) || requireServerModule.test(line)) { + offenders.push(`${path}:${index + 1}`) + } + } + } + + expect( + offenders, + "use a static import plus `import 'server-only'` — dynamic imports dodge the boundary sweep", + ).toEqual([]) + }) +})