---
name: audit-dev-frontend
description: Audit code generated by the Frontend phase against the PRD slice — page coverage, registry wiring, lazy-import integrity, i18n namespaces, drift detection
group: D
phase: devFrontend
kind: audit
audit_only: true
section_label: 'AUDIT-DEV-FRONTEND (rules to apply against generated React pages vs the PRD Frontend slice)'
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# audit-dev-frontend — Frontend Phase Code-vs-PRD Audit

## Context

You are auditing the output of the Frontend phase of `ba-develop`.
Compare the PRD slice (spec) against the generated `.tsx` pages, registries
and i18n files (reality), and emit findings for every drift.

You receive in the system prompt:

- `--- PRD SLICE: FRONTEND ---` — Markdown listing the screens / pages /
  components the user wants for this application.
- `--- PROJECT INVENTORY (frontend) ---` — deterministic scan of:
  - `pages[]` (file, entity, kind),
  - `registries[]` (which generated registry files exist),
  - `i18n.locales[]` and `i18n.namespaces[]`.
- `--- FILES TOUCHED THIS PHASE ---` — files modified during the current run.
- `--- APPLICATION CODE / MODULE CODE ---` — active scope.

You may use `Read` / `Glob` / `Grep` to verify rules. **No `Edit` /
`Write` / `Bash`** — you are read-only.

Apply every rule across the module scope. Emit `ok` findings for passing
rules and `err` / `warn` findings for drifts. Use `err` only when the spec
is unambiguous and the gap is clearly wrong (missing required page,
broken registry import). Use `warn` for conventions, drift, and missing
nice-to-haves.

## Rules

### DEV-UI-001 — Every screen in the PRD slice has a generated page file
- **Severity**: err (if any required page missing), ok (if all present)
- Check: for each screen declared in the PRD slice with a SmartComponent
  that maps to a page (`SmartListView`, `SmartForm`, `SmartCard`,
  `SmartDashboard`, `SmartKanban`, `SmartAppHome`, `SmartModuleHome`,
  `SmartSectionHome`), find a matching entry in `inventory.frontend.pages[*]`.
  Match key = `(entity.toLowerCase(), kind.toLowerCase())` against
  `(page.entity.toLowerCase(), page.kind.toLowerCase())`. Pages without
  an entity (Dashboard / *Home) match by their screen `code`.
- **ok**: label=`DEV_UI_001_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_UI_001_err`, params=`{ missing: "<comma-separated entity/kind or screen-code>" }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Re-run the Frontend phase. The
  subagent must call `scaffold-component` for each missing page,
  using the matching SmartComponent pattern from the PRD slice."

### DEV-UI-002 — Every generated page is referenced in at least one registry
- **Severity**: err (if any orphan page), ok (if all wired)
- Check: for each page in `inventory.frontend.pages[*].file`, search the
  registry files listed in `inventory.frontend.registries[]` (typically
  `componentRegistry.generated.ts` and friends) for a `lazy-import`
  pointing at that file. Use `Grep` with the relative file path or the
  page module's expected key.
- A page that exists on disk but isn't registered will not render at
  runtime — DynamicRouter cannot resolve it.
- **ok**: label=`DEV_UI_002_ok`
- **err**: label=`DEV_UI_002_err`, params=`{ pages: "<comma-separated orphan page paths>" }`
- **solution** (mandatory on err): "Either re-run the routes scaffolder
  to regenerate the registry, or add a manual `lazy-import` entry for
  each orphan page. The DB-driven router fails to resolve unregistered
  pages."

### DEV-UI-003 — Registry lazy-imports resolve to existing files
- **Severity**: err (if any broken import), ok (if all valid)
- Check: for each registry file, `Read` it and extract every
  `() => import('<path>')`. Resolve `<path>` relative to the registry
  file. The target file MUST exist on disk (`Glob`). Broken imports
  fail the Vite build silently for lazy chunks until the route is
  visited at runtime.
- **ok**: label=`DEV_UI_003_ok`
- **err**: label=`DEV_UI_003_err`, params=`{ broken: "<comma-separated registry→missing-import>" }`
- **solution** (mandatory on err): "Either restore the missing files or
  remove the orphan import entries from the registry. Lazy-imports that
  resolve to non-existent paths produce a runtime error on first
  navigation, not at build time."

### DEV-UI-004 — i18n namespace exists for the module across every locale
- **Severity**: warn (if any locale missing the namespace), ok (if all present)
- Check: derive the expected namespace from the module code in the PRD
  (typically `<applicationCode>.<moduleCode>` or `<moduleCode>` alone).
  For every locale in `inventory.frontend.i18n.locales`, the namespace
  MUST appear in `inventory.frontend.i18n.namespaces`. The frontend
  falls back to the key string when a translation is missing — the user
  sees raw camelCase in the UI.
- Skip when `inventory.frontend.i18n.locales` is empty.
- **ok**: label=`DEV_UI_004_ok`
- **warn**: label=`DEV_UI_004_warn`, params=`{ missing: "<comma-separated locale.namespace pairs>" }`
- **solution** (mandatory on warn): "Add the missing namespace JSON
  files for each locale (typically `src/locales/<locale>/<namespace>.json`).
  Even an empty `{}` is preferable to a missing file — the i18next
  loader logs a warning but does not crash."

### DEV-UI-005 — Pages wrap their content in `<PageTemplate>`
- **Severity**: warn (if any page raw), ok (if all wrap)
- Check: for each page in `inventory.frontend.pages[*].file`, `Grep`
  for `<PageTemplate` in the file content. Pages that skip the wrapper
  break the Studio layout (sidebar, breadcrumbs, header).
- Skip pages whose `kind` is `dashboard` or `home` if the project's
  customisation-ui doesn't expose a PageTemplate for them (verify by
  reading at most one example dashboard / home page from the
  customisation-ui templates).
- **ok**: label=`DEV_UI_005_ok`
- **warn**: label=`DEV_UI_005_warn`, params=`{ pages: "<comma-separated page paths missing PageTemplate>" }`
- **solution** (mandatory on warn): "Wrap each listed page with
  `<PageTemplate title=\"...\">{...}</PageTemplate>`. The template is
  the single source of truth for layout (header / sidebar / breadcrumbs)."

### DEV-UI-006 — Sensitive actions are gated by `<PermissionGuard>` / `useHasPermission`
- **Severity**: warn (if any unguarded action button), ok (if all guarded)
- Check: for each page that exposes a write action (button, menu item)
  matching the keywords `Create`, `Edit`, `Delete`, `Update`, `New`,
  `Add`, `Remove`, the file must reference either `PermissionGuard` or
  `useHasPermission` at least once. `Grep` the page contents for both
  identifiers.
- Skip pages that obviously have no write actions (read-only Dashboard,
  Home pages without quick-actions).
- **ok**: label=`DEV_UI_006_ok`
- **warn**: label=`DEV_UI_006_warn`, params=`{ pages: "<comma-separated page paths missing permission gate>" }`
- **solution** (mandatory on warn): "Wrap each write button with
  `<PermissionGuard permission=\"<key>\">...</PermissionGuard>` or
  short-circuit the handler with `useHasPermission`. Without the gate,
  unauthorized users see a button that throws on click instead of
  being hidden."

### DEV-UI-007 — Each page has at least one `*.test.tsx` test file
- **Severity**: warn (if any page has no test), ok (if all covered)
- Calque the existing frontend gate (`runGate frontend` already counts
  test files). Check: for each page in `inventory.frontend.pages[*].file`,
  there must be a sibling test file matching `<base>.test.tsx` OR a
  test file under `__tests__/` with a matching name. Use `Glob` to
  enumerate.
- **ok**: label=`DEV_UI_007_ok`
- **warn**: label=`DEV_UI_007_warn`, params=`{ pages: "<comma-separated page paths missing tests>" }`
- **solution** (mandatory on warn): "Re-run `scaffold-tests --layer frontend`
  for each listed page, or add a minimal smoke test that mounts the page
  with a Test wrapper providing QueryClient + i18n."

### DEV-UI-008 — No "ghost" pages (drift detector)
- **Severity**: warn (if any ghost found), ok (if none)
- Reciprocal of DEV-UI-001: every page in
  `inventory.frontend.pages[*]` must trace back to a screen in the PRD
  slice. Match key = `(entity, kind)` for entity-bound pages, screen
  `code` for hub pages. Pages with no upstream spec are stale (PRD
  edited after a previous run) or hallucinated.
- **ok**: label=`DEV_UI_008_ok`
- **warn**: label=`DEV_UI_008_warn`, params=`{ pages: "<comma-separated ghost page paths>" }`
- **solution** (mandatory on warn): "Either re-add the corresponding
  screen to the PRD slice if it was removed by mistake, or delete the
  page file AND remove its registry entry. Leaving ghost pages in the
  registry leaves dead routes in the Studio menu."

### DEV-UI-009 — Every PRD use case has at least one page targeting its primary entity
- **Severity**: err (if any use case uncovered), ok (if all covered)
- Check: for each entry in `prdSlice.useCases[*]` with an `entity` field,
  there must be at least one page in `inventory.frontend.pages[*]` with a
  matching entity (case-insensitive). Use cases without an entity are
  skipped (process-only flows). A use case with no UI entry-point cannot
  be exercised by the user — the PRD scenario is unreachable in practice.
- **ok**: label=`DEV_UI_009_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_UI_009_err`, params=`{ useCases: "<comma-separated UC.code(entity)>" }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Each PRD use case must have at least
  one page targeting its primary entity. Re-run scaffold-component to
  generate the missing pages. If the use case is intentionally process-only
  (e.g. background jobs), remove the `entity` field from the PRD slice."

### DEV-UI-010 — Business rules with `enforcedAt: ui|both` are wired into form pages
- **Severity**: warn (if any rule unwired), ok (if all wired or no UI rules)
- Check: for each rule in `prdSlice.businessRules[*]` whose `enforcedAt`
  is `'ui'` or `'both'`, find the form/create/edit page for the rule's
  `entity`. The page source MUST either reference the rule code (e.g.
  in a comment or test) OR contain a Zod schema with `.refine(...)`. A
  rule with `enforcedAt: ui` that produces no Zod refinement leaves the
  form unconstrained — the user submits invalid data and discovers the
  rule only via a backend 422 response, breaking the inline-feedback loop.
- **ok**: label=`DEV_UI_010_ok`, params=`{ count: <number of rules verified> }`
- **warn**: label=`DEV_UI_010_warn`, params=`{ rule, entity, page?, missingPage? }`
- **solution** (mandatory on warn): "Inject a Zod refine in the page's
  formSchema using the rule's `validExamples` / `invalidExamples`. The
  refine message should match the rule code so users can correlate UI
  errors back to the PRD."

### DEV-UI-011 — Pages mapped to a `[RequirePermission(X)]` controller wrap their body in `<PermissionGuard permission="X">`
- **Severity**: err (if any page unguarded), ok (if all guarded)
- Check: for each PRD permission code (and, if `--backend-path` is
  supplied to the CLI, every `[RequirePermission("X")]` attribute scanned
  from `*Controller.cs` files), find the corresponding page (by entity
  match) and verify its source contains
  `<PermissionGuard permission="X">…</PermissionGuard>` wrapping the body.
  This is a defence-in-depth gate: the backend always enforces the
  permission, but without the frontend wrap the user sees a route that
  loads, fails its API call with 403, and renders a broken empty state
  instead of "you do not have access".
- DEV-UI-011 covers **page-level** wrapping. The action-level rule
  (DEV-UI-006) remains separate — both must pass for full coverage.
- **ok**: label=`DEV_UI_011_ok`
- **err**: label=`DEV_UI_011_err`, params=`{ page, permission, entity }`
- **fixSkill**: `audit-dev-frontend`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Wrap the page body inside
  `<PermissionGuard permission=\"<key>\">…</PermissionGuard>`. The CLI's
  --mode apply pass autofixes this rule by injecting the wrap and importing
  PermissionGuard from `@/components/PermissionGuard`."

### DEV-UI-012 — RBAC nav entries declare `requiredPermissions:` matching the PRD RBAC matrix
- **Severity**: warn (if any actor's permission missing from the seed), ok otherwise
- Check (CLI-only, requires `--core-navigation-seed-path`): for each
  `prdSlice.rbac[*]` entry granting permissions to an actor, the
  generated `Persistence/Seeding/Core/CoreNavigationSeedDataProvider.cs`
  must reference that permission (string-literal match). If the navigation
  seed omits a permission an actor was supposed to have, the menu item is
  hidden for that actor — they discover the missing route only by direct
  URL access.
- This rule crosses the Phase 0 backend boundary: the fix is to update
  the BA RBAC tables and re-run `scaffold-core-seed`, NOT to hand-edit
  the seed file.
- **ok**: label=`DEV_UI_012_ok`
- **warn**: label=`DEV_UI_012_warn`, params=`{ actor, permission, file }`
- **fixSkill**: `backend-core-seed`, **fixPhaseKey**: `core`
- **solution** (mandatory on warn): "Re-run scaffold-core-seed after
  updating the BA RBAC tables to include this permission in the
  navigation entry's requiredPermissions list."

### DEV-UI-013 — `navigate()` URLs come from the per-module routes helper, not hardcoded literals
- **Severity**: err (if any hardcoded URL), ok otherwise
- Check: for each `*Page.tsx` page, grep for absolute string literals inside
  `navigate(...)` (regex `navigate\s*\(\s*(['"\`])(\/[^'"\`]+)\1`). Pages
  must instead `import { routes } from '@/extensions/<module>Routes'` and
  call `routes.<section>.create()` / `.detail(id)` / `.edit(id)`.
- The bug it catches : when a page navigates to `/budgets/budgets/new` while
  the registry registered `'budgets.budgets.create'`, DynamicRouter cannot
  resolve the URL → blank page or perpetual spinner. The routes helper is
  the single source of truth shared with `{module}Registry.ts`.
- **ok**: label=`DEV_UI_013_ok`
- **err**: label=`DEV_UI_013_err`, params=`{ file, url }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on err): "Replace the hardcoded URL with a call
  to the per-module routes helper. Re-run `scaffold-component --overwrite`
  for the affected page — the regenerated template imports `routes` from
  `@/extensions/<module>Routes` and uses it consistently."

### DEV-UI-014 — List pages do not emit stub helpers for missing DTO fields
- **Severity**: err (if any stub helper), ok otherwise
- Check: in List pages only, regex-detect named arrow/function helpers whose
  body returns a literal stub (`return 0`, `return ''`, `return null`,
  `return false`). Pattern names ending in `For` (`consumptionFor`,
  `priceFor`) are the typical "give-up fallback" the sub-agent emits when
  it can't find the field in the list DTO.
- This rule does **not** auto-fix : the structural fix is to declare the
  missing field as a computed attribute with a `formula` in the BA data
  model (audit DM-016). `scaffold-business` then projects it via LINQ and
  the column reads `item.<name>` directly — no helper, no stub.
- **ok**: label=`DEV_UI_014_ok`
- **err**: label=`DEV_UI_014_err`, params=`{ file, helper }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `data` (for the
  scaffolders to re-emit the corrected DTO)
- **solution** (mandatory on err): "Don't paper over the gap with a stub.
  Either (a) declare the missing field as a computed attribute (`formula`)
  on the BA data model so scaffold-business projects it via LINQ, or
  (b) extend the list DTO to include the value computed by the backend
  service. The List column should read `item.<name>` directly."

### DEV-UI-015 — API client signatures match controller routes
- **Severity**: err (if any mismatch), ok otherwise
- **Requires**: `--backend-path` flag pointing to the .NET project root.
  Skipped (`ok` with `note`) when not supplied — equivalent to the
  best-effort behaviour of system-prompt mode.
- **Check**: parse every `*Controller.cs` to extract endpoints (verb +
  full route from `[Route("api/…")]` + method-level `[HttpGet/Post/Put/Delete]`,
  plus `[FromBody]` / mutation-parameter detection). Parse every
  `services/*Service.ts` (and `src/services/**/*.ts`) for axios calls
  (`apiClient.get('/...')`, `axios.post('/...', body)`). Match TS calls
  against controller endpoints by **(verb, normalised URL)**. URLs are
  normalised : lower-case, leading `/api/` stripped, template-literal
  `${id}` → `{id}` (matches the ASP.NET route token).
- **Detected drifts** :
  - No matching endpoint → `no controller route matches '{verb} /{url}'`
    (typo or missing endpoint).
  - Same URL but different verb → `wrong HTTP verb — service uses 'X' but
    controller exposes 'Y'`.
  - Body presence mismatch → `controller expects a body ([FromBody]) but
    service does not send one` (or the reverse).
- This rule catches the original Budget Dashboard 400 pattern : a service
  call hitting an endpoint that doesn't exist, or sending the wrong shape.
  Mismatches at this layer surface as runtime 400/404 errors with no
  console diagnostic — exactly the kind of bug `npm run build` cannot detect.
- **ok**: label=`DEV_UI_015_ok`, params=`{ count }`
- **err**: label=`DEV_UI_015_err`, params=`{ file, verb, url, reason }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `api`, audit-only
  (the fix is a re-scaffold with the corrected spec — apply.ts does NOT
  rerun scaffold-api-client because the spec must come from a fresh PRD
  slice read).
- **solution** (mandatory on err): "Re-run scaffold-api-client with the
  corrected spec, or fix the controller route attribute. Mismatches here
  produce silent runtime 400/404 — `npm run build` won't catch them."

### DEV-UI-016 — List pages must use `<DataTable>` from customisation-ui
- **Severity**: warn (if raw `<table>` found), ok otherwise
- Check: in List pages only, fail if the source contains `<table` AND does
  not import / render `<DataTable` from `@/components/ui/DataTable`. The
  customisation-ui baseline mandates DataTable for built-in search /
  pagination / sortable columns + theme cohesion.
- **ok**: label=`DEV_UI_016_ok`
- **warn**: label=`DEV_UI_016_warn`, params=`{ pages }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on warn): "Re-run scaffold-component with
  `--overwrite` for each listed page — the canonical template emits
  `<DataTable>` with searchable + paginated columns wired via the
  `DataTableColumn<T>[]` contract."

### DEV-UI-017 — Generated CSS utility classes are declared in `index.css`
- **Severity**: warn (if any class is used but not declared), ok otherwise
- Check: scan every page `className="..."` for tokens in the tracked set
  (`input`, `btn-primary`, `btn-secondary` — the helpers the scaffold
  templates rely on). Each used token must appear as a `.<token> { ... }`
  declaration in `src/index.css`. Without the declaration, the form
  inputs / buttons render unstyled because Tailwind's `@apply` chain
  misses the helper layer.
- **ok**: label=`DEV_UI_017_ok`
- **warn**: label=`DEV_UI_017_warn`, params=`{ file, classes }`
- **fixSkill**: `frontend-theme`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on warn): "Re-run scaffold-theme to inject the
  missing helper classes into `src/index.css`. The theme scaffolder is
  idempotent and respects the `/* @customised */` marker for hand-edited
  blocks."

### DEV-UI-018 — Barrel `index.ts` re-exports resolve to existing files
- **Severity**: err (if any broken re-export), ok (if all valid)
- **Scope**: barrels under `src/components/**/index.ts`, `src/pages/**/index.ts`,
  `src/hooks/**/index.ts`, `src/utils/**/index.ts`, `src/services/**/index.ts`,
  `src/features/**/index.ts`. Excludes `src/extensions/componentRegistry.generated.ts`
  (already covered by DEV-UI-003).
- **Check**: for each barrel, regex-extract every `export { ... } from '<rel>'`
  / `export * from '<rel>'` with a relative path. Resolve `<rel>` against the
  barrel's directory, trying `.tsx`, `.ts`, `/index.tsx`, `/index.ts`. Fail if
  none exists on disk.
- **The bug it catches**: `src/components/email/index.ts` exports
  `EmailTemplateEditor3PaneLayout` from a file that was renamed to
  `2PaneLayout.tsx`. Vite returns **404** on the missing file when transforming
  the barrel → barrel can't be served → every dynamic `import()` traversing it
  (e.g. `EmailTemplateEditPage` consuming `@/components/email`) **cascades** into
  `Failed to fetch dynamically imported module` on the *parent* bundle — and
  `lazyWithRetry` cannot rescue a true 404. The user sees a `RouteErrorBoundary`
  screen on a route that has nothing to do with the broken file.
- **ok**: label=`DEV_UI_018_ok`
- **err**: label=`DEV_UI_018_err`, params=`{ broken: "<comma-separated barrel→missing-import>" }`
- **solution** (mandatory on err): "Either restore the missing file or delete
  the dead re-export line. A barrel `index.ts` that references a non-existent
  sibling is a tripwire — it breaks every consumer of the barrel even though
  the consumer never touched the dead export."

### DEV-UI-019 — Pagespec `kind:api` action produces service method + hook + button
- **Severity**: err (any pagespec api-action missing on the frontend), ok (if all wired)
- Check: load every `<MODULE>/pagespecs/*.md` of the audited module and
  parse the fenced ```json blocks. For each `pagespec.actions[]` entry
  with `kind: "api"` AND `code ∉ STANDARD_CRUD_CODES`:
  1. `<entityLower>Service.ts` MUST declare a member named
     `toCamel(endpoint)` (e.g. `endpoint: "sync-from-proconcept"` →
     `syncFromProconcept`).
  2. `use<Entity>.ts` MUST declare a hook named
     `use<PascalCase(endpoint)><Entity>` (e.g.
     `useSyncFromProconceptTypeAffaire`).
  3. The page matching the action's `scope` (`row` → list/detail page,
     `header` → list page) MUST import that hook AND wire a button whose
     `onClick` calls the resulting mutation.
- De-duplicate by `(scope, endpoint, httpMethod)` across pagespecs — the
  same action MAY appear on several views and only needs ONE wiring.
- **ok**: label=`DEV_UI_019_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_UI_019_err`, params=`{ actions: "<comma-separated entity:code:missingArtifact>" }`
- **fixSkill**: `frontend-api-client` + `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Re-run Phase 3a of `/ba-develop`. The
  orchestrator derives `customActions[]` from `pagespec.actions[]` and
  forwards them to `scaffold-api-client` AND `scaffold-component`. A
  missing artefact means either the regen was skipped or the pagespec
  was edited after the last run — sync the contracts via
  `/audit-dev-actions-alignment` before re-running."

### DEV-UI-020 — Pagespec `kind:navigate` actions emit `navigate()`, NOT axios
- **Severity**: err (any navigate action wired as an api call), ok otherwise
- Check: for each `pagespec.actions[]` entry with `kind: "navigate"` of
  entity `<E>`, the page MUST contain a button whose handler calls
  `navigate(<targetRoute>)` AND MUST NOT import a hook
  `use<PascalCase(endpoint)><E>` named after this action's code. The bug
  this catches is the legacy pattern `POST /{id}/open` → 405 — a legacy
  generator that ignored `kind` and emitted an axios call for what should
  have been a router transition.
- **ok**: label=`DEV_UI_020_ok`
- **err**: label=`DEV_UI_020_err`, params=`{ actions: "<comma-separated entity:code pairs wired as api>" }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on err): "The component template wired a
  navigate-kind action as an api call. Re-run `scaffold-component` after
  upgrading to a template that branches on `kind` (it now imports NO hook
  for navigate actions, just `useNavigate()`)."

### DEV-UI-021 — Hardcoded `apiClient.<verb>(...)` URLs match a pagespec endpoint
- **Severity**: err (any axios URL outside the contract), ok otherwise
- Check: same parser as DEV-UI-015 (axios call detector) BUT cross-references
  the discovered URLs against the union of `pagespec.actions[].endpoint`
  values (per entity). Any URL hitting `/api/<module>/<plural>/<non-crud-segment>`
  that is NOT in this union AND NOT one of the canonical CRUD shapes
  (`/api/<module>/<plural>` GET/POST, `/api/<module>/<plural>/:id`
  GET/PUT/DELETE) is a hardcoded URL bypassing the per-page contract.
  Catches the legacy pattern: service.ts calls `/sync-from-pce` while the
  pagespec declares `endpoint: "sync-from-proconcept"` → instant 405.
- **ok**: label=`DEV_UI_021_ok`
- **err**: label=`DEV_UI_021_err`, params=`{ calls: "<comma-separated file:line:URL>" }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Either declare the missing custom
  action in the pagespec (then re-run Phase 3a) or fix the URL to match
  the pagespec endpoint. Hardcoded URLs outside the contract drift the
  moment the BA reshapes the screen — service.ts must always be the
  reflection of the pagespec, never a parallel source of truth. See
  `/audit-dev-actions-alignment` for the alignment report."

### DEV-UI-022 — FK fields render as `<EntityLookup>`, never as `<input>` / `<select>`
- **Severity**: err (any FK rendered as a text input or static dropdown), ok (all FK fields use the combobox)
- Scope: Form / Create / Edit pages only — List pages can show FK columns as text (they're not editable).
- Check: scan each form page for `<input ... (name|id)="xxxId">` and
  `<select ... value={...xxxId}>`. The convention `xxxId` mirrors the BA
  `entité.md` Rel: line FK naming (`Employee *→1 Department (FK DepartmentId)`
  → camelCase `departmentId` in the React state). Hidden inputs
  (`type="hidden"`) and a small whitelist (`externalId`, `parentId`,
  `guidId` — identifiers that happen to end in "Id" but are not FKs) are
  excluded.
- Symmetric to backend DEV-DAT-008 (same-module FKs are real constraints,
  not bare `Guid`). On the frontend, the symmetric guarantee is that the
  user interacts with the canonical combobox over
  `/api/{module}/{plural}/lookup` — not a free-text Guid input that breaks
  on first paste.
- **ok**: label=`DEV_UI_022_ok`
- **err**: label=`DEV_UI_022_err`, params=`{ file, field, entity }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable: false (orchestrator must enrich the field with `fkTo` from `entité.md` Rel: lines before re-scaffolding — see ba-develop Phase 3a)
- **solution** (mandatory on err): "Re-run scaffold-component for this page so the orchestrator (ba-develop Phase 3a) enriches the field with `fkTo: { entity, module }` from entité.md Rel: lines. The generator then emits `<EntityLookup apiEndpoint=\"/api/{module}/{plural}/lookup\" />` (scaffolded into `src/components/ui/EntityLookup.tsx` by `scaffold-ui-primitives` at Phase 3.0). If the FK has no declared relation in entité.md, add a `Relations:` line to the entity definition first."

## CLI mode

A deterministic CLI sibling lives at `cli/audit-dev-frontend/index.ts`.
Invoke it via Bash to run all 22 rules without LLM variance, including
auto-apply fixes for autoFixable rules:

```
npx tsx skills/development/audit-dev-frontend/cli/audit-dev-frontend/index.ts \
  --project-path "<web-root>" --mode apply \
  --module-code "<modCode>" --app-code "<appCode>" \
  --prd-slice "<slice.json>" \
  --backend-path "<dotnet-root>" \
  --core-navigation-seed-path "<path/to/CoreNavigationSeedDataProvider.cs>"
```

The CLI emits the same `auditReport` envelope as the system-prompt skill
plus a `report.findings[]` field for the orchestrator to consume. Both
audit modes are equivalent for rules 001-008. Rules 009-022 require the
PRD slice (and backend path / nav seed for 011-012) — the system-prompt
mode does best-effort and can emit `ok` with a `note: "skipping — context unavailable"`.

### CLI mode — actions-alignment audit (legacy / drift detection)

When debugging an unexpected 4xx/405 on a custom action button (e.g. a project's
synchronization action → 405), or when onboarding a legacy project that
pre-dates the per-page contract, run the dedicated alignment CLI:

```
npx tsx skills/development/audit-dev-frontend/cli/audit-dev-actions-alignment/index.ts \
  --project-path "<web-root>" \
  --backend-path "<dotnet-root>" \
  --module-path  "<absolute path to .smartstack/ba/<APP>/<MODULE>>" \
  --mode report-only
```

The CLI cross-references the three sources of truth for every custom action:

1. **`pagespecs/*.md`** (BA contract — the `endpoint` field is the URL segment)
2. **`*Controller.cs`** (backend reality — the `[HttpVerb("…")]` attribute)
3. **`*Service.ts`** (frontend reality — the `apiClient.<verb>('/…')` call)

Findings are typed `ACTION-DRIFT-001..005`:

| Code | Severity | Meaning |
|---|---|---|
| ACTION-DRIFT-001 | err | Pagespec endpoint ≠ controller route (same scope, similar suffix) |
| ACTION-DRIFT-002 | err | Pagespec endpoint ≠ service URL (the canonical 405 pattern) |
| ACTION-DRIFT-003 | warn | Service calls a URL with no pagespec OR controller backing (dead call) |
| ACTION-DRIFT-004 | warn | Controller exposes a route nobody references (dead endpoint) |
| ACTION-DRIFT-005 | err | Service verb ≠ controller verb on the same URL (e.g. GET vs POST on same endpoint) |

The report is written to `<projectPath>/_audit/actions-alignment.md` and
emitted on stdout as a JSON envelope. The `--mode apply` variant ships as a
stub (iteration 1 is report-only) — once the per-side rewriter lands, it will
accept `--source-of-truth pagespec|controller` to pick which side wins.

## Output

Emit EXACTLY ONE JSON code block matching the standard `auditReport`
envelope (same shape as `audit-screens`). Set `dimension` to `devFrontend`
on every finding. Include one `ok` finding per passing rule (so the UI
shows green checks) and one finding per failing rule.

```json
{
  "auditReport": {
    "scope": "devFrontend",
    "applicationCode": "<from PRD>",
    "moduleCode": "<from PRD>",
    "findings": [
      {
        "dimension": "devFrontend",
        "code": "DEV-UI-001",
        "severity": "err",
        "label": "DEV_UI_001_err",
        "params": { "missing": "Contract/list,Contract/detail" },
        "solution": "Re-run the Frontend phase. The subagent must call scaffold-component for each missing page.",
        "fixSkill": "frontend-component",
        "fixPhaseKey": "frontend"
      }
    ]
  }
}
```

Stop immediately after the JSON block. Do not narrate.
