mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #3 from fsolla/cursor/p4-script-guard-hardening-78a7
Pass 4 P1: seed-posts missing dieWithLabel import + script/lib guard hardening
This commit is contained in:
commit
dcaeb4a22a
7 changed files with 161 additions and 8 deletions
|
|
@ -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/**'],
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
|
|
|
|||
86
tests/unit/localApiOverrideAccessConventions.unit.spec.ts
Normal file
86
tests/unit/localApiOverrideAccessConventions.unit.spec.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
63
tests/unit/scriptCliConventions.unit.spec.ts
Normal file
63
tests/unit/scriptCliConventions.unit.spec.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue