@starci/eslint-canon-be 3.2.19 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +46 -4
  3. package/app-composition.mjs +176 -0
  4. package/config-owner.mjs +34 -19
  5. package/feature-thin.mjs +59 -17
  6. package/index.mjs +2 -0
  7. package/lib/app-roles.mjs +48 -0
  8. package/lib/webhook-ack.mjs +87 -0
  9. package/lib/webhook-handoff.mjs +138 -0
  10. package/lib/webhook-verifiers.mjs +147 -0
  11. package/module-layering.mjs +2 -2
  12. package/module-shape.mjs +6 -5
  13. package/package.json +1 -1
  14. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  15. package/runtime/knowledge/hfs/rules.yaml +41 -13
  16. package/runtime/knowledge/hfs/slots.yaml +99 -43
  17. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  18. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  19. package/runtime/scripts/api/git/diff.mjs +5 -0
  20. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  21. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  22. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  23. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  24. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  25. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  26. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  27. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  28. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  29. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  30. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  31. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  32. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  33. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  36. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  37. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  38. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  39. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  40. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  41. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  42. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  43. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  44. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  47. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  48. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  49. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  50. package/webhooks.mjs +141 -69
@@ -17,7 +17,7 @@
17
17
  # modules/kernel/allowlist.yaml, held by RT_ALLOWLIST_SPRAWL. Codes are quoted: the failure-code
18
18
  # scanner reads this file as an emitter of every code it names.
19
19
  schema: starci/hfs-rules@3
20
- version: 3.0.0
20
+ version: 3.1.0
21
21
  gates:
22
22
  pre-commit: the managed .husky/pre-commit at the app root - starci app hygiene (staged .starciwork and .starcistacks paths, secrets guard), typecheck, ESLint per side and stylelint on the staged files, prettier --check, the unit specs the staged files touch
23
23
  pre-push: typecheck, lint (starci app lint), format:check and the unit specs affected since origin/main; runs every pre-commit rule again
@@ -112,12 +112,16 @@ rules:
112
112
  - {kind: work-validate, id: identity-custody-role, at: scripts/work/validate/check-example-work.mjs}
113
113
  - id: R10
114
114
  code: "HFS_STACKS_SHAPE"
115
- law: "`.starcistacks` has the standard shape and the host Sonar owner."
115
+ law: "`.starcistacks` has the edition-aware standard shape and the host Sonar owner; Swarm stacks have digest-pinned images, healthchecks, rollout, rollback and restart policies, versioned external secrets, ingress-only published ports and declared schedulers for replicated jobs. Recurring work is app-owned; host crontab installation is forbidden. Lite declares Supabase as managed backing and runs no duplicate backing-service component."
116
116
  kinds: [check, codemod]
117
117
  gates: [settle, land, ci]
118
118
  failureCodes: ["HFS_STACKS_SHAPE"]
119
119
  enforcers:
120
120
  - {kind: hfs, id: stacks-shape, at: scripts/hfs/rules/stacks.mjs}
121
+ - {kind: hfs, id: stacks-runtime, at: scripts/hfs/rules/stacks-runtime.mjs}
122
+ - {kind: hfs, id: stacks-evidence-runtime, at: scripts/gates/stacks-gate.mjs}
123
+ - {kind: hfs, id: starcistacks-runtime, at: scripts/gates/starcistacks-runtime.mjs}
124
+ - {kind: hfs, id: starcistacks-runtime-gate, at: scripts/gates/starcistacks.mjs}
121
125
  - id: R11
122
126
  code: "HFS_SONAR_CONFIG"
123
127
  law: "Sonar config is generated, with no host URL, importing the be lcov with the logic of be/src/modules (the coverage scope derived from the slot manifest) as the only coverage scope."
@@ -258,7 +262,7 @@ rules:
258
262
  - {kind: eslint-fe, id: dead-exports}
259
263
  - id: R26
260
264
  code: "BE_TIER_DIRECTION"
261
- law: "The tier direction matrix; a shared package never imports an app."
265
+ law: "The tier direction matrix; a shared package never imports an app. Front-end page and layout owners may import overlay entries; every other cross-owner feature-to-feature edge is forbidden, including layout-to-page and overlay-to-overlay. Components never import overlays."
262
266
  kinds: [lint, design]
263
267
  gates: [pre-push, settle, land, ci]
264
268
  failureCodes: ["BE_TIER_DIRECTION", "FE_TIER_DIRECTION", "ARCH_PACKAGE_IMPORTS_APP"]
@@ -297,7 +301,7 @@ rules:
297
301
  - {kind: eslint-be, id: application-never-imports-transport}
298
302
  - id: R30
299
303
  code: "BE_PUBLIC_SURFACE"
300
- law: "Every owner has one `index.ts`; cross-owner imports use it, same-owner imports are relative and never go through it; it holds only named `export { }` lines, no `export *`, no alias re-export (`export { X as Y }`, `export const Y = X`, `export type Y = X`, `export interface Y extends X {}`: one declaration has one name), at most 60 exports; cross-package imports use the package name and a declared export."
304
+ law: "Every owner has one `index.ts`; cross-owner imports use it, same-owner imports are relative and never go through it; it holds only named `export { }` lines, no `export *`, no alias re-export (`export { X as Y }`, `export const Y = X`, `export type Y = X`, `export interface Y extends X {}`: one declaration has one name), within the owner slot's `indexExports` budget (60 for domain and feature owners, 80 for `be.integrations` provider entries); cross-package imports use the package name and a declared export."
301
305
  kinds: [lint, codemod]
302
306
  gates: [pre-push, settle, land, ci]
303
307
  failureCodes: ["BE_PUBLIC_SURFACE", "ARCH_OWNER_EXPORT_BYPASS", "ARCH_OWNER_EXPORT_STAR", "HFS_ALIAS_REEXPORT", "ARCH_PACKAGE_EXPORT_BYPASS"]
@@ -423,7 +427,7 @@ rules:
423
427
  - {kind: eslint-be, id: auth-door-strict-rate-tier}
424
428
  - id: R43
425
429
  code: "BE_CONFIG_OWNER"
426
- law: "Only `platform/config` reads `process.env`; config per capability."
430
+ law: "Only the `EnvSource` declaration of `platform/config`, app `main.ts`, the matching `<app>.env.ts` and the test world read `process.env`; app environment reads through `EnvSource` and capability config helpers live in `main.ts` or the matching `<app>.env.ts`; options files and composition groups receive typed options."
427
431
  kinds: [lint, design]
428
432
  gates: [pre-commit, pre-push, settle, land, ci]
429
433
  failureCodes: ["BE_CONFIG_OWNER"]
@@ -804,7 +808,7 @@ rules:
804
808
  - {kind: eslint-be, id: no-ambient-clock}
805
809
  - id: R80
806
810
  code: "BE_INBOX_DEDUPE_MISSING"
807
- law: "Every service method that takes a delivery (a public method of a `*.service.ts` whose first parameter has an `eventId`) makes `claim(source, eventId)` on the `Inbox` port its first awaited expression and returns early when the claim answers `false`; consumers and `SignedWebhook` controllers stay thin doors (R88) and dispatch to the handler whose service claims."
811
+ law: "Every service method that takes a delivery (a public method of a `*.service.ts` whose first parameter has an `eventId`) makes `claim(source, eventId)` on the `Inbox` port its first awaited expression and returns early when the claim answers `false`; consumers stay thin doors (R88) and dispatch one command to the handler whose service claims. SignedWebhook controllers verify first and call one domain recording intake directly (R182, R183); a reactor or job dispatches the processing command after the inbox receipt commits."
808
812
  kinds: [lint, design]
809
813
  gates: [pre-commit, pre-push, settle, land, ci]
810
814
  failureCodes: ["BE_INBOX_DEDUPE_MISSING"]
@@ -961,7 +965,7 @@ rules:
961
965
  - {kind: eslint-fe, id: frontend-source-layout}
962
966
  - id: R92
963
967
  code: "FE_COMPONENT_API"
964
- law: "A component exposes the canon surface: typed named props and slots, a presentational twin for every connected block, status through the slot view, no className or CSS doors, no resting twin or placeholder prop, class names in the colocated file; a pure component reaches no hook, router, locale or API."
968
+ law: "A component exposes the canon surface: typed named props and slots, an exact sibling XBase presentational twin for every connected block, status through the slot view, no className or CSS doors, no resting twin or placeholder prop, class names in the colocated file; a pure component reaches no hook, router, locale or API, but may render a list of blocks supplied through render props without importing blocks or owning their data."
965
969
  kinds: [lint, design]
966
970
  gates: [pre-commit, pre-push, settle, land, ci]
967
971
  failureCodes: ["FE_COMPONENT_API", "FE_COMPONENT_WORLD_OWNERSHIP", "FE_WORLD_OWNER_RENDER_BOUNDARY", "FE_CONNECTED_BLOCK_RENDER_PAIR", "FE_PURE_REACHES_DATA", "FE_PURE_WORLD_HOOK", "FE_PURE_WORLD_IMPORT"]
@@ -1606,7 +1610,7 @@ rules:
1606
1610
  - {kind: eslint-be, id: sql-returning}
1607
1611
  - id: R182
1608
1612
  code: "BE_WEBHOOK_SHAPE"
1609
- law: "A provider webhook door (`src/features/webhooks/<provider>/transport/http/<provider>.webhook.ts`) is one `@Controller` with one `@Post` handler declared `@Public({ reason: PublicReason.SignedWebhook })`; it injects only the `WebhookSignatureService` of `platform/http-security` and services of `modules/domain`, and its handler holds no branch, loop or `try`, calls only `verify` on the signature service and exactly one method of a domain service (the intake that records the delivery and publishes the event in its own transaction), and returns nothing."
1613
+ law: "A provider webhook door (`src/features/webhooks/<provider>/transport/http/<provider>.webhook.ts`) is one `@Controller` with exactly one `@Post` handler and optionally one `@Get` subscription challenge handler, each declared `@Public({ reason: PublicReason.SignedWebhook })`. It injects only the `WebhookSignatureService` of `platform/http-security` and services of `modules/domain`, holds no branch, loop or `try`, and its POST calls proof then exactly one domain intake that durably records the inbox receipt and outbox event in its transaction. A combined domain proof and intake call is allowed. POST returns nothing or exactly the own-feature exported PROVIDER_ACK, a literal object or string declared with `as const` or a readonly literal type; computed values, inline return objects and cross-feature constants are refused. GET starts with a declared domain verifier call and returns exactly that result, with no intake. The door and its HTTP module import or inject no CommandBus, QueryBus of @nestjs/cqrs or feature command handler; the resolved called intake dispatches no command/query bus or feature handler. A reactor or job consumes the durable receipt and dispatches after commit; unresolved helpers and dynamic receivers require semantic review."
1610
1614
  kinds: [lint]
1611
1615
  gates: [pre-commit, pre-push, settle, land, ci]
1612
1616
  failureCodes: ["BE_WEBHOOK_SHAPE"]
@@ -1614,7 +1618,7 @@ rules:
1614
1618
  - {kind: eslint-be, id: webhook-shape}
1615
1619
  - id: R183
1616
1620
  code: "BE_WEBHOOK_UNVERIFIED"
1617
- law: "The first statement of a webhook handler is the unconditional `verify` call of the `WebhookSignatureService` (signature and replay window): a delivery that fails the proof throws before any other code runs, so no path reaches the domain intake unproven."
1621
+ law: "The first statement of a webhook POST is an unconditional verification call to `WebhookSignatureService.verify` or a domain verifier, awaited when async, with no data access or other call before proof. A domain verifier is a method on a domain service whose declared class name ends in `Verifier` or `verify`, or a domain service method declared by symbol in the adjacent HTTP module's exported `webhookVerifiers` array. A domain method may verify with per-connection secrets before intake in the same call; invalid proof throws before intake. A fixed PROVIDER_ACK never replaces proof. The optional GET challenge handler starts with a declared domain verifier call (directly returned or bound to a const and then returned), awaited when async, and echoes only its exact result."
1618
1622
  kinds: [lint]
1619
1623
  gates: [pre-commit, pre-push, settle, land, ci]
1620
1624
  failureCodes: ["BE_WEBHOOK_UNVERIFIED"]
@@ -1776,7 +1780,7 @@ rules:
1776
1780
  - {kind: runtime, id: ci-upload, at: scripts/hfs/runtime-rules/ci-upload.mjs}
1777
1781
  - id: R203
1778
1782
  code: "BE_FEATURE_THIN"
1779
- law: "A feature file (a role of ruleParams.be.thinRoles inside a slot of tier feature: handler, resolver, controller, consumer, processor, step, saga, saga-step, compensation, webhook, gateway, subscription, cli, mapper, the saga orchestrator service) maps its parameters and makes ONE delegating call into modules/ (a service, the command or query bus, the event bus, a queue producer): no branch, loop, ternary, try or throw and no computation. Business logic lives in modules/**, where every logic file owes 100 per file; orchestration (step order, compensation, claim, fencing, retry) lives in platform/*; the feature layer is proven by integration and e2e. The gate calls a kind requires are not delegation: a webhook's `verify` and a job step's `JobClaims` fence (`advance`, `runKey`); a logger call, a pure mapper function and a modules function in argument position are free too; a mapper may map a collection item by item (`map`) and write a date as text (`toISOString`), and a cli group command shows its help."
1783
+ law: "A feature file (a role of ruleParams.be.thinRoles inside a slot of tier feature: handler, resolver, controller, consumer, processor, step, saga, saga-step, compensation, webhook, gateway, subscription, cli, mapper, the saga orchestrator service) maps its parameters and makes ONE delegating call into modules/ (a service, the command or query bus, the event bus, a queue producer): no branch, loop, ternary, try or throw and no computation. Business logic lives in modules/**, where every logic file owes 100 per file; orchestration (step order, compensation, claim, fencing, retry) lives in platform/*; the feature layer is proven by integration and e2e. The gate calls a kind requires are not delegation: a webhook's first verification call of R183 when followed by one separate intake, and a job step's `JobClaims` fence (`advance`, `runKey`); a combined domain verification and intake call counts as the delegation. A logger call, a pure mapper function and a modules function in argument position are free too; a mapper may map a collection item by item (`map`) and write a date as text (`toISOString`), and a cli group command shows its help. A webhook POST may return the declared literal PROVIDER_ACK and its optional GET may echo only its first domain verifier result; neither is an extra delegation. A webhook door and its HTTP module import or inject no CommandBus, QueryBus of @nestjs/cqrs or feature command handler: the inbox recording intake is the handoff, and a reactor or job dispatches after commit."
1780
1784
  kinds: [lint]
1781
1785
  gates: [pre-commit, pre-push, settle, land, ci]
1782
1786
  failureCodes: ["BE_FEATURE_THIN"]
@@ -1846,12 +1850,14 @@ rules:
1846
1850
  - {kind: runtime, id: slot-id-shape, at: scripts/checks/check-slot-id-shape.mjs}
1847
1851
  - id: R212
1848
1852
  code: "HFS_EDITION_INVALID"
1849
- law: "hfs.json names an edition the manifest knows (full, or lite; absent means full). Lite = full minus self-hosted infrastructure: the same monorepo, code structure, slots, patterns and back-end unit specs, every pattern port implemented on Supabase (Postgres, Auth, Storage, Supabase Queues/pgmq for queue and event bus, Postgres locks, pg_cron), the realtime pattern on the same socket.io gateway run inside the be api process (one instance, no Redis adapter), and one front-end standard for both editions (knowledge/hfs/fe-stack.yaml); a lite app holds none of the infrastructure lite does not run: no e2e or contract spec, no `test:e2e`, `test:contract`, `test:stack` or `test:browser` script, no e2e workflow or browser journey, no UAT record, no test container, toxiproxy or browser-runner dependency, no BullMQ, Kafka, Redis or graphql-ws dependency (the libraries lite infraOwners own nowhere), no .starcistacks infra/runtime/seeds tree and no supabase/functions."
1853
+ law: "hfs.json names an edition the manifest knows (full, or lite; absent means full). Lite follows the 2026-10-13 ruling: the same monorepo, code structure, slots, patterns and back-end unit specs, with only backing infrastructure swapped for managed Supabase (Postgres, Auth, Storage, Supabase Queues/pgmq for queue and event bus, Postgres locks, pg_cron); the api and sidecars run on docker-swarm on a VPS. The realtime pattern uses the same socket.io gateway inside the be api process (one instance, no Redis adapter), and both editions share knowledge/hfs/fe-stack.yaml. Lite requires application-stacks.yaml and permits environment metadata, runbooks, infra/swarm and encrypted-secret custody, while refusing infra/compose, infra/k8s, infra/terraform, runtime, seeds and components duplicating Supabase backing services. It has no e2e or contract spec, `test:e2e`, `test:contract`, `test:stack` or `test:browser` script, e2e workflow, browser journey, Work catalog, shell, features, resources, UAT or br records, test container, toxiproxy, browser-runner, BullMQ, Kafka, Redis or graphql-ws dependency, or supabase/functions. Its .starciwork is optional and admits only .gitignore, workspace.yaml and brand/**; workspace.yaml is optional and carries project identity and repo roles for the Work tools."
1850
1854
  kinds: [check]
1851
1855
  gates: [pre-push, settle, land, ci]
1852
- failureCodes: ["HFS_EDITION_INVALID", "HFS_EDITION_FORBIDDEN_PRESENT"]
1856
+ failureCodes: ["HFS_EDITION_INVALID", "HFS_EDITION_FORBIDDEN_PRESENT", "HFS_STARCIWORK_LITE_FORBIDDEN"]
1853
1857
  enforcers:
1854
1858
  - {kind: hfs, id: edition, at: scripts/hfs/rules/edition.mjs}
1859
+ - {kind: hfs, id: lite-brand-work, at: scripts/hfs/path-findings.mjs}
1860
+ - {kind: work-validate, id: lite-brand-work, at: scripts/work/validate/work-boundary-check.mjs}
1855
1861
  cases:
1856
1862
  - id: case-1
1857
1863
  date: "2026-10-12"
@@ -1869,9 +1875,15 @@ rules:
1869
1875
  quote: "As the student proposes. (translated from Vietnamese; the owner adopted the proposed HFS standard for front end and back end, lite = full)"
1870
1876
  ruling: "One HFS tree for both editions: the repository root, be/ (apps, contracts, features, modules, tests) and fe/apps/<app>/src have the same shape in lite and full, and code above the platform/integrations seam is the same. The editions differ only behind that seam: the database and its migration authority (lite supabase/migrations; full TypeORM entities and migrations in be.persistence), the auth provider (lite Supabase Auth through fe.modules.auth and a JWKS-verified back end; full the app's identity provider), object storage (lite Supabase Storage; full S3-compatible storage), queue and jobs (lite pgmq and pg_cron; full BullMQ) behind platform/queue, realtime (lite the socket.io gateway in the api process, one instance; full socket.io scaled with a Redis adapter), the event-bus adapter (lite outbox and pgmq; full outbox and Kafka) and hosting; lite has no e2e. Migration baseline at the cutover of an existing app: the live schema is dumped into one baseline migration written to the current standard and marked applied on production without a re-run, the older migrations move to app.supabase.migrations.archive (supabase/migrations/_archive/, never applied and never judged), and new migrations append after the baseline. Assets: static templates, copy and presets are TS constants in the owning domain capability (<x>.templates.ts) and user-editable ones are seed rows; small build-time binaries such as fonts live in be.assets (be/src/assets/<kind>/); large and user media live in object storage and are fetched at runtime; there is no be/resources/."
1871
1877
 
1878
+ - id: case-4
1879
+ date: "2026-10-14"
1880
+ source: owner
1881
+ quote: "In a lite app, .starciwork keeps only the brand records."
1882
+ ruling: "Amended by the owner on 2026-10-14: under lite, .starciwork is optional when the app has no brand records and admits only .gitignore, workspace.yaml and brand/** in the existing brand tooling layout, including drawing records, references and assets. workspace.yaml is optional and keeps the project id and repo roles so Work root detection and render-proof role mapping can tie brand records to the front end. index.yaml, shell/, features/, _resources/, uat and br remain forbidden. Lite readers read workspace.yaml normally and skip the full-edition catalog, shell and feature metadata; lite scaffolding creates .gitignore and workspace.yaml, with brand/ only when present. Full edition is unchanged."
1883
+
1872
1884
  - id: R213
1873
1885
  code: "DB_MIGRATION_SHAPE"
1874
- law: "A migration is `supabase/migrations/<ts14>_<kebab>.sql`; the stamp is a valid UTC instant not in the future, new migrations sort strictly after every migration on the base branch, and a migration already on the base is immutable (a diff or a removal is a finding); a file that does not parse as PostgreSQL is a shape failure."
1886
+ law: "A migration is `supabase/migrations/<ts14>_<kebab>.sql`; the stamp is a valid UTC instant, active stamps are unique, and a new migration sorts strictly after the committed active migrations in HEAD. Repository history includes the archive: moving an active migration to the archive retires it from ordering, so a replacement baseline may predate archived stamps and later migrations append after the baseline. Wall-clock time, commit time and remote-branch stamps do not set the ordering boundary. A migration already on the base is immutable (a diff or an unarchived removal is a finding); a file that does not parse as PostgreSQL is a shape failure."
1875
1887
  kinds: [check]
1876
1888
  gates: [pre-push, settle, land, ci]
1877
1889
  failureCodes: ["DB_MIGRATION_SHAPE"]
@@ -2156,3 +2168,19 @@ rules:
2156
2168
  enforcers:
2157
2169
  - {kind: hfs, id: fe-stack, at: scripts/hfs/rules/fe-stack.mjs}
2158
2170
  - {kind: eslint-fe, id: fe-stack-import}
2171
+ - id: R245
2172
+ code: "BE_APP_OPTIONS_COMPOSITION"
2173
+ law: "An app's `src/` holds five app roles (owner ruling 2026-10-14): `main.ts` builds `EnvSource` once, parses inline or imports `<app>.env.ts`, builds options and boots; `<app>.env.ts` holds typed, fail-fast environment parsing only, via `EnvSource` and capability parse helpers, with no logic, module composition, business code or feature imports; only `main.ts` and `<app>.env.ts` may read the app's environment; `<app>.options.ts` holds env-derived options plus port bindings (provider maps such as `{ approvalGate: X }`) only, with no parsing; neither the env nor options file holds an array listing `*Module` classes, module or queue registrations (`register`, `forRoot`, `registerQueue`) or scheduler entries, or an `imports`, `modules`, `schedulers` or `processors` list; the env file also refuses standalone module or queue registration calls; `compose/<area>.ts` exports the module list of one area (and the providers of its global ports when it has any), composition only, with no business logic and no environment read; `app.module.ts` imports the groups and spreads them and binds the app-wide guards and filter."
2174
+ kinds: [lint, codemod]
2175
+ gates: [pre-commit, pre-push, settle, land, ci]
2176
+ failureCodes: ["BE_APP_OPTIONS_COMPOSITION"]
2177
+ enforcers:
2178
+ - {kind: eslint-be, id: app-options-composition-free}
2179
+ - id: R246
2180
+ code: "BE_APP_COMPOSITION_BUDGET"
2181
+ law: "Each app file stays within the line budget its `be.app.*` slot names (`budget`, keyed by the slot entry the file matches): `main.ts` 80, `<app>.env.ts` 150, `<app>.options.ts` 200, `compose/<area>.ts` 120, `app.module.ts` 250. The budget is absolute, not a ratchet: an entrypoint that outgrows it moves typed, fail-fast parsing into `<app>.env.ts`; an app root that outgrows it moves an area into a new `compose/<area>.ts` group, never into the env or options file."
2182
+ kinds: [lint, codemod]
2183
+ gates: [pre-commit, pre-push, settle, land, ci]
2184
+ failureCodes: ["BE_APP_COMPOSITION_BUDGET"]
2185
+ enforcers:
2186
+ - {kind: eslint-be, id: app-file-budget}
@@ -7,7 +7,7 @@
7
7
  # catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
8
8
  # Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
9
9
  schema: starci/hfs-slots@3
10
- version: 3.0.0
10
+ version: 3.1.0
11
11
 
12
12
  versioning:
13
13
  # MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
@@ -31,6 +31,12 @@ versioning:
31
31
  # is the socket.io gateway in both editions), fe.route holds pages, layouts and the one health/live route handler, and the slots
32
32
  # marked `since: 3.0.0` are added: the Apollo, SWR, RHF, zustand and socket.io hooks, redux, modules/auth, modules/toast, the
33
33
  # providers, be.assets and the migration archive.
34
+ # 3.1.0 is a minor (owner ruling 2026-10-14, R245 and R246): every be.app.* slot allows `<app>.env.ts` and the composition groups
35
+ # `compose/<area>.ts` and budgets its app files (`app.module.ts` 250, `<app>.options.ts` 200, `<app>.env.ts` 150,
36
+ # `compose/<area>.ts` 120, `main.ts` 80); `<app>.env.ts` exports interface <App>Env and read<App>Env(env: EnvSource): <App>Env
37
+ # for fail-fast env parsing only, and `<app>.options.ts` holds env-derived options and port bindings only, with no parsing
38
+ # or environment reads (BE_APP_OPTIONS_COMPOSITION). Secrets use EnvSource.secret(): <NAME>_FILE first, plain <NAME> only
39
+ # when the file key is absent; .starcistacks owns custody. Capability <capability>.config.ts roles stay unchanged.
34
40
 
35
41
  # Field meaning, per slot.
36
42
  # id stable name, never changes meaning inside a major.
@@ -139,6 +145,7 @@ naming:
139
145
  # ---------------------------------------------------------------------------------------------------------
140
146
  # Tiers and the dependency direction matrix (owner granularity; type-only imports count; no cycles anywhere)
141
147
  # mayImport lists the tiers a file of that tier may import. Importing inside the same owner is always allowed.
148
+ # mayImportKinds narrows a permitted tier edge to its named from/to folder kinds; other kind pairs remain forbidden.
142
149
  # ---------------------------------------------------------------------------------------------------------
143
150
  tiers:
144
151
  be:
@@ -155,7 +162,7 @@ tiers:
155
162
  package: {mayImport: [package]}
156
163
  fe:
157
164
  route: {mayImport: [feature, foundation, modules, transport, package]} # app/** adapters
158
- feature: {mayImport: [components, hooks, foundation, modules, transport, package]}
165
+ feature: {mayImport: [feature, components, hooks, foundation, modules, transport, package], mayImportKinds: {feature: {from: [pages, layouts], to: [overlays]}}} # pages and layouts compose overlays and blocks; other feature-to-feature edges stay forbidden
159
166
  # A component never reaches data: config, routes, i18n and types are foundation; every other module and the api
160
167
  # transport are reached through a hook (owner decision 2026-09-29).
161
168
  components: {mayImport: [components, hooks, foundation, package], lowerLayerOnly: true} # blocks, composites, branches, leaves
@@ -501,6 +508,33 @@ slots:
501
508
  tests: none
502
509
  managedBy: docker-ignore
503
510
  rules: [HFS_MANAGED_FILE_DRIFT]
511
+ - id: app.deploy
512
+ profiles: [app]
513
+ # Production delivery tooling for the app's own processes: compose files, Caddyfile, *.env.example and ops shell scripts.
514
+ # Lite still keeps its backing services on Supabase and its encrypted custody in app.starcistacks; R06 scans every file here.
515
+ path: "deploy/"
516
+ presence: optional
517
+ tracked: tracked
518
+ tier: none
519
+ tests: none
520
+ rules: [HFS_PLAINTEXT_SECRET]
521
+ - id: app.ci-deploy
522
+ profiles: [app]
523
+ # Product-owned delivery workflows; the literal app.ci-db path remains the managed database workflow's more specific slot.
524
+ path: ".github/workflows/<app>-deploy.yml"
525
+ presence: optional
526
+ tracked: tracked
527
+ tier: none
528
+ tests: none
529
+ rules: [HFS_PLAINTEXT_SECRET]
530
+ - id: app.ci-authorize-deploy-key
531
+ profiles: [app]
532
+ path: ".github/workflows/authorize-deploy-key.yml"
533
+ presence: optional
534
+ tracked: tracked
535
+ tier: none
536
+ tests: none
537
+ rules: [HFS_PLAINTEXT_SECRET]
504
538
  - id: app.browser
505
539
  profiles: [app]
506
540
  # The browser journey of the product (Playwright, owner ruling: one minimal journey per example app, run manually): the config, its
@@ -536,6 +570,17 @@ slots:
536
570
  path: scripts/codegen.mjs
537
571
  liteManagedBy: codegen-lite
538
572
  rules: [HFS_SCRIPT_ONE_OFF, BE_SPEC_PLACEMENT, HFS_REPO_LOCAL_CHECK] # one-off codemods and fix-* scripts are agent output, not tooling
573
+ - id: app.scripts.delivery
574
+ profiles: [app]
575
+ editions: [lite]
576
+ # Product-owned environment, secret and build tooling, kept apart from lite's managed codegen script.
577
+ # Full already owns these files through app.scripts; its generic *.mjs pattern must not compete with build-*.mjs.
578
+ path: "scripts/{env-sync.mjs,with-secrets.mjs,set-secrets.ps1,build-*.mjs}"
579
+ presence: optional
580
+ tracked: tracked
581
+ tier: none
582
+ tests: none
583
+ rules: [HFS_PLAINTEXT_SECRET, HFS_SCRIPT_ONE_OFF, BE_SPEC_PLACEMENT, HFS_REPO_LOCAL_CHECK]
539
584
  - id: app.sides
540
585
  profiles: [app]
541
586
  # The two sides. Everything below be/ and fe/ is judged by the side's own slots (profiles be and fe) with the side folder as its root;
@@ -558,23 +603,13 @@ slots:
558
603
  "features/<feature>/ui/<name>/assets/<approved-file>", "features/<feature>/uat/<name>/{index,accounts,fixtures}.yaml",
559
604
  "features/<feature>/uat/<name>/{seed,cleanup}.sql", "_resources/{identities,environments,fixtures}/<slug>/resource.yaml"]
560
605
  forbids: [evidence/, logs/, reports/, captures/, runs/, "kernel-*/", "*.sqlite*", _derived/]
561
- # Lite keeps the product catalog, feature overviews, grammar and brand drawing records; UAT records belong to the forbidden slot below.
606
+ # Owner amendment 2026-10-14: lite keeps workspace.yaml for project identity and repo roles, plus brand drawing records,
607
+ # references and assets. No brand means no Work tree is needed; workspace.yaml is legal but optional.
608
+ litePresence: optional
562
609
  lite:
563
- requires: [.gitignore, workspace.yaml, index.yaml]
564
- allows: [workspace.yaml, index.yaml, "brand/**", shell/index.yaml, "features/<feature>/index.yaml", "features/<feature>/ui/<name>/index.yaml", "features/<feature>/ui/<name>/assets/<approved-file>"]
610
+ requires: [.gitignore]
611
+ allows: [workspace.yaml, "brand/**"]
565
612
  rules: [HFS_AGENT_DATA_TRACKED, HFS_IDENTITY_CUSTODY]
566
- - id: app.starciwork.uat
567
- profiles: [app]
568
- # The UAT records of the full edition: they are proven on the e2e world (identities, fixtures, br/ac evidence) that lite does
569
- # not have, so the trees are forbidden paths under lite only. In full this slot does not exist and .starciwork answers for them.
570
- editions: [lite]
571
- path: ".starciwork/{_resources/identities,_resources/fixtures,features/<feature>/uat,features/<feature>/br}/"
572
- presence: forbidden
573
- tracked: external
574
- tier: none
575
- tests: none
576
- goesTo: "the full edition: a lite app has no e2e world, so no UAT records (no identities or fixtures resources, no br/ac evidence, no uat directories)"
577
- since: 2.1.0
578
613
  - id: app.starcistacks
579
614
  profiles: [app]
580
615
  # The stack declarations and the sealed custody of the app, at the app root beside be/, fe/ and .starciwork (never under a side).
@@ -584,16 +619,16 @@ slots:
584
619
  tier: none
585
620
  tests: none
586
621
  requires: [application-stacks.yaml]
587
- allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/infra/{compose,k8s,terraform}/**", "<env>/infra/metadata.json",
622
+ allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/infra/{compose,k8s,terraform,swarm}/**", "<env>/infra/metadata.json",
588
623
  "<env>/runtime/{config,env}/**", "<env>/runtime/env/KEYS.md", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep", "<env>/seeds/**"]
589
624
  forbids: ["<env>/runtime/files/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
590
- # Lite has no self-hosted infrastructure (owner ruling 2026-10-12): .starcistacks holds env and secret custody only - the stack
591
- # declaration, the environment descriptor and the sealed secrets; the stack's one backing service is the Supabase project (the
592
- # local CLI stack or the cloud project). infra/ (compose, k8s, terraform), runtime/ and seeds/ are the full edition's.
625
+ # Lite keeps full's code structure with backing infrastructure replaced by Supabase (owner ruling 2026-10-13). The api and
626
+ # sidecars run on docker-swarm; its value-free stack belongs in <env>/infra/swarm/. The checker also refuses components whose
627
+ # images duplicate Supabase services: postgres, redis, kafka, keycloak, minio, rabbitmq, mysql and mongo.
593
628
  lite:
594
- requires: []
595
- allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep"]
596
- forbids: ["<env>/runtime/files/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
629
+ requires: [application-stacks.yaml]
630
+ allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep"]
631
+ forbids: ["<env>/infra/{compose,k8s,terraform}/**", "<env>/runtime/**", "<env>/seeds/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
597
632
  rules: [HFS_STACKS_SHAPE, HFS_STACKS_IN_SIDE, HFS_PLAINTEXT_SECRET, HFS_IDENTITY_CUSTODY]
598
633
  - id: app.sops
599
634
  profiles: [app]
@@ -637,7 +672,7 @@ slots:
637
672
  goesTo: "<lanes root>/<project>/<lane>/ (outside every repository) for a lane; a Kernel workflow's worktree is created and owned by Orca outside the app checkout, never tracked, and removed by the runtime's host-side controller once the workflow's finish marked it release-pending"
638
673
  - id: app.plaintext-env
639
674
  profiles: [app]
640
- path: "{.env,.env.*,.secrets/,**/*.pem,**/*.key}"
675
+ path: "{.env,.env.*,secrets.env,.secrets/,**/*.pem,**/*.key}"
641
676
  presence: forbidden
642
677
  tracked: external
643
678
  tier: none
@@ -959,6 +994,23 @@ slots:
959
994
  rules: [BE_CONTRACT_BREAKING]
960
995
 
961
996
  # ----- BE: apps (one slot per app kind; a new kind = a new slot, minor bump) ---------------------------
997
+ # The files of every app (owner ruling 2026-10-14, R245 and R246, since 3.1.0), each with a line budget that
998
+ # BE_APP_COMPOSITION_BUDGET holds absolutely (not a ratchet):
999
+ # main.ts the entry: builds EnvSource once, imports and calls read<App>Env from <app>.env.ts, builds options and boots.
1000
+ # <app>.env.ts exports interface <App>Env and read<App>Env(env: EnvSource): <App>Env (for example ApiEnv/readApiEnv);
1001
+ # fail-fast environment parsing only, via EnvSource and unchanged capability parse helpers; no localhost
1002
+ # or default host fallback, logic, module composition, business code or feature imports. Only main.ts
1003
+ # and this app file may read process.env or EnvSource. The interface declares its own readonly parsed
1004
+ # members with parser-derived types, structurally compatible with the app's options;
1005
+ # the role name stays <app>.env.ts, never env.ts. Secrets use EnvSource.secret(): <NAME>_FILE first
1006
+ # for .starcistacks custody, plain <NAME> only when the file key is absent. A declared file supplies
1007
+ # exact contents; an empty or unreadable path stops boot without fallback.
1008
+ # <app>.options.ts the options: env-derived options plus port bindings (provider maps such as { approvalGate: X }) only;
1009
+ # no environment parsing or process.env/EnvSource reads; module, queue or scheduler composition here or in <app>.env.ts is
1010
+ # BE_APP_OPTIONS_COMPOSITION.
1011
+ # compose/<area>.ts a composition group: exports the module list of one area (an imports array, and the providers of the
1012
+ # area's global ports when it has any); composition only, no business logic or process.env/EnvSource read.
1013
+ # app.module.ts the composition root: imports the groups and spreads them, binds the app-wide guards and filter.
962
1014
  - id: be.app.api
963
1015
  profiles: [be]
964
1016
  path: "apps/<app>/src/"
@@ -969,11 +1021,11 @@ slots:
969
1021
  owner: true
970
1022
  minInstances: 1
971
1023
  requires: [main.ts, app.module.ts]
972
- allows: [main.ts, app.module.ts, "<app>.options.ts", operations.ts]
1024
+ allows: [main.ts, app.module.ts, "<app>.env.ts", "<app>.options.ts", "compose/<area>.ts", operations.ts]
973
1025
  tests: none # an app is proven by the e2e world (useTestWorld({ apps })), never by a unit spec
974
1026
  coverage: none
975
- budget: {app.module.ts: 250, main.ts: 80}
976
- rules: [BE_APP_COMPOSITION_ONLY, BE_APP_BUSINESS_ROLE, BE_DEFAULT_DENY, BE_ERROR_MASKED, BE_FEATURE_NOT_COMPOSED, BE_CONTEXT_OWNER]
1027
+ budget: {app.module.ts: 250, main.ts: 80, "<app>.env.ts": 150, "<app>.options.ts": 200, "compose/<area>.ts": 120}
1028
+ rules: [BE_APP_COMPOSITION_ONLY, BE_APP_BUSINESS_ROLE, BE_APP_OPTIONS_COMPOSITION, BE_APP_COMPOSITION_BUDGET, BE_DEFAULT_DENY, BE_ERROR_MASKED, BE_FEATURE_NOT_COMPOSED, BE_CONTEXT_OWNER]
977
1029
  - id: be.app.worker
978
1030
  profiles: [be]
979
1031
  path: "apps/<app>/src/"
@@ -983,17 +1035,19 @@ slots:
983
1035
  tier: app
984
1036
  owner: true
985
1037
  requires: [main.ts, app.module.ts]
986
- allows: [main.ts, app.module.ts, "<app>.options.ts"]
1038
+ allows: [main.ts, app.module.ts, "<app>.env.ts", "<app>.options.ts", "compose/<area>.ts"]
987
1039
  tests: none # an app is proven by the e2e world (useTestWorld({ apps })), never by a unit spec
988
1040
  coverage: none
989
- rules: [BE_APP_COMPOSITION_ONLY, BE_BACKGROUND_UNOWNED, BE_CONTEXT_OWNER]
1041
+ budget: {app.module.ts: 250, main.ts: 80, "<app>.env.ts": 150, "<app>.options.ts": 200, "compose/<area>.ts": 120}
1042
+ rules: [BE_APP_COMPOSITION_ONLY, BE_APP_OPTIONS_COMPOSITION, BE_APP_COMPOSITION_BUDGET, BE_BACKGROUND_UNOWNED, BE_CONTEXT_OWNER]
990
1043
  - id: be.app.cli
991
1044
  profiles: [be]
992
1045
  # The ONE app of every one-off action (migrate, seed, sync, backup, any operator command): be/apps/cli, on nest-commander.
993
1046
  # main.ts boots with CommandFactory.run (BE_CLI_BOOTSTRAP); app.module.ts composes the platform and the cli feature root
994
- # (src/features/cli/cli.module.ts); cli.options.ts carries what it needs from the environment, the connections it migrates
995
- # among them (the test world migrates through the same list). It is required as soon as the back end declares a connection
996
- # or tracks a command, with its image apps/cli/Dockerfile (BE_CLI_REQUIRED): one image, run as `cli <group> <command>`.
1047
+ # (src/features/cli/cli.module.ts); cli.env.ts exports CliEnv and readCliEnv(env: EnvSource): CliEnv, and cli.options.ts carries the env-derived values,
1048
+ # the connections it migrates among them (the test world migrates through the same list). It is required as soon as the
1049
+ # back end declares a connection or tracks a command, with its image apps/cli/Dockerfile (BE_CLI_REQUIRED): one image,
1050
+ # run as `cli <group> <command>`.
997
1051
  path: "apps/<app>/src/"
998
1052
  appKind: cli
999
1053
  presence: opt-in
@@ -1002,10 +1056,11 @@ slots:
1002
1056
  tier: app
1003
1057
  owner: true
1004
1058
  requires: [main.ts, app.module.ts]
1005
- allows: [main.ts, app.module.ts, "<app>.options.ts"]
1059
+ allows: [main.ts, app.module.ts, "<app>.env.ts", "<app>.options.ts", "compose/<area>.ts"]
1006
1060
  tests: none # an app is proven by the e2e world (useTestWorld({ apps })), never by a unit spec
1007
1061
  coverage: none
1008
- rules: [BE_CLI_REQUIRED, BE_CLI_BOOTSTRAP, BE_CLI_OWNER, BE_SCHEMA_AUTHORITY]
1062
+ budget: {app.module.ts: 250, main.ts: 80, "<app>.env.ts": 150, "<app>.options.ts": 200, "compose/<area>.ts": 120}
1063
+ rules: [BE_CLI_REQUIRED, BE_CLI_BOOTSTRAP, BE_CLI_OWNER, BE_SCHEMA_AUTHORITY, BE_APP_OPTIONS_COMPOSITION, BE_APP_COMPOSITION_BUDGET]
1009
1064
 
1010
1065
  # ----- BE: features ------------------------------------------------------------------------------------
1011
1066
  # src/features/ is grouped by TRIGGER KIND (owner decision 2026-10-02): features/api/<feature>/ (a request: application/ and
@@ -1515,7 +1570,7 @@ slots:
1515
1570
  requires: [index.ts, "<provider>.config.ts"]
1516
1571
  tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
1517
1572
  coverage: required
1518
- budget: {indexExports: 60}
1573
+ budget: {indexExports: 80} # a provider's port surface includes clients, config, options, tokens and errors
1519
1574
  rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING]
1520
1575
  - id: be.integrations.model
1521
1576
  profiles: [be]
@@ -1789,7 +1844,7 @@ slots:
1789
1844
  allows: [index.ts, "use<name>.ts", "<domain>.shared.ts"] # React hooks only; <domain>.shared.ts = sanctioned non-hook helpers
1790
1845
  roles: {entry: index.ts, shared: "<domain>.shared.ts"}
1791
1846
  tests: none
1792
- budget: {file: 200, dataHooks: 6, useState: 6}
1847
+ budget: {file: 200, barrel: 400, dataHooks: 6, useState: 6} # barrel: index.ts containing only export ... from statements
1793
1848
  rules: [FE_HOOKS_ARE_HOOKS, FE_DATA_FRESHNESS]
1794
1849
  - id: fe.hooks.swr
1795
1850
  profiles: [fe]
@@ -1806,7 +1861,7 @@ slots:
1806
1861
  "api/rest/queries/useGet*Swr.ts", "api/rest/mutations/use{Post,Put,Patch,Delete}*Swr.ts"]
1807
1862
  roles: {entry: index.ts, effects: SwrSideEffects.tsx}
1808
1863
  tests: none
1809
- budget: {file: 200}
1864
+ budget: {file: 200, barrel: 400}
1810
1865
  since: 3.0.0
1811
1866
  rules: [FE_HOOKS_ARE_HOOKS, FE_DATA_FRESHNESS]
1812
1867
  - id: fe.hooks.rhf
@@ -1822,7 +1877,7 @@ slots:
1822
1877
  allows: [index.ts, "use*Form.ts"]
1823
1878
  roles: {entry: index.ts}
1824
1879
  tests: none
1825
- budget: {file: 200}
1880
+ budget: {file: 200, barrel: 400}
1826
1881
  since: 3.0.0
1827
1882
  rules: [FE_HOOKS_ARE_HOOKS]
1828
1883
  - id: fe.hooks.zustand
@@ -1837,7 +1892,7 @@ slots:
1837
1892
  allows: [index.ts, store.ts, "use*.ts"]
1838
1893
  roles: {entry: index.ts, store: store.ts}
1839
1894
  tests: none
1840
- budget: {file: 200}
1895
+ budget: {file: 200, barrel: 400}
1841
1896
  since: 3.0.0
1842
1897
  rules: [FE_HOOKS_ARE_HOOKS]
1843
1898
  - id: fe.hooks.socketio
@@ -1853,7 +1908,7 @@ slots:
1853
1908
  allows: [index.ts, sockets.ts, SocketIoSideEffects.tsx, connectionStore.ts, "enums/*.ts", "types/*.ts", "use*.ts"]
1854
1909
  roles: {entry: index.ts, effects: SocketIoSideEffects.tsx}
1855
1910
  tests: none
1856
- budget: {file: 200}
1911
+ budget: {file: 200, barrel: 400}
1857
1912
  since: 3.0.0
1858
1913
  rules: [FE_HOOKS_ARE_HOOKS]
1859
1914
  - id: fe.hooks.effects
@@ -1869,7 +1924,7 @@ slots:
1869
1924
  allows: [index.ts, "use*.ts", "*SideEffect.tsx", "*SideEffects.tsx"]
1870
1925
  roles: {entry: index.ts, effects: "*SideEffect.tsx"}
1871
1926
  tests: none
1872
- budget: {file: 200}
1927
+ budget: {file: 200, barrel: 400}
1873
1928
  since: 3.0.0
1874
1929
  rules: [FE_HOOKS_ARE_HOOKS]
1875
1930
  - id: fe.hooks.reuseables
@@ -1884,7 +1939,7 @@ slots:
1884
1939
  allows: [index.ts, "use*.ts"]
1885
1940
  roles: {entry: index.ts}
1886
1941
  tests: none
1887
- budget: {file: 200}
1942
+ budget: {file: 200, barrel: 400}
1888
1943
  since: 3.0.0
1889
1944
  rules: [FE_HOOKS_ARE_HOOKS]
1890
1945
  - id: fe.redux
@@ -1909,6 +1964,7 @@ slots:
1909
1964
  tier: modules
1910
1965
  owner: true
1911
1966
  requiredInstances: {capability: [config, i18n, routes]}
1967
+ # A populated fe.modules instance with no .ts or .tsx files (CSS/assets only) needs no index.ts.
1912
1968
  requires: [index.ts]
1913
1969
  tests: none
1914
1970
  budget: {file: 400}
@@ -174,6 +174,9 @@ rules:
174
174
  component.tsx. Pure blocks, composites, branches and leaves use index.tsx only and never gain an
175
175
  empty twin. classNames.ts is present only when the unit owns class strings. Budgets: component.tsx 300 lines,
176
176
  connected index.tsx 200 lines, at most 6 data hooks and 6 useState per connected unit.
177
+ A composition-only feature may render an existing public pure component owner directly instead of creating a
178
+ forwarding component.tsx. The route-state overlay host of FE-BOUNDARY-5 renders components/branches/OverlayHost;
179
+ a connected block still requires its exact private Base twin.
177
180
  rationale: |
178
181
  Stable basenames make class names and the connected and presentational split discoverable without forwarding
179
182
  twins, and budgets keep a unit reviewable.
@@ -0,0 +1,5 @@
1
+ // cat-file.mjs — `git cat-file <args>`: the type, size or content of repository objects (-e, -p, --batch).
2
+ import { runGit } from './lib.mjs';
3
+
4
+ /** The spawn result {status, stdout, stderr, error} of `git cat-file ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
+ export const catFile = (args = [], options = {}) => runGit(['cat-file', ...args], options);
@@ -0,0 +1,5 @@
1
+ // diff.mjs — `git diff <args>`: the changes between commits, the index and the working tree.
2
+ import { runGit } from './lib.mjs';
3
+
4
+ /** The spawn result {status, stdout, stderr, error} of `git diff ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
+ export const diff = (args = [], options = {}) => runGit(['diff', ...args], options);
@@ -0,0 +1,110 @@
1
+ import path from 'node:path';
2
+ import { allowsFile } from '../allows.mjs';
3
+ import { relativePath, unwrapExpression } from './typescript.mjs';
4
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
5
+
6
+ const PROCESS_MODULES = new Set(['process', 'node:process']);
7
+ const PROCESS_GLOBAL = new Set(['process']);
8
+ const GLOBAL_HOLDERS = new Set(['globalThis', 'global']);
9
+ const REFLECT_GLOBAL = new Set(['Reflect']);
10
+
11
+ /** A static property name, including `object['env']` and `object[`env`]`. */
12
+ export function accessName(ts, node) {
13
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
14
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
15
+ return null;
16
+ }
17
+
18
+ function bindingName(ts, element) {
19
+ const name = element.propertyName ?? element.name;
20
+ if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text;
21
+ return ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression) ? name.expression.text : null;
22
+ }
23
+
24
+ /** A global name is not a same-named local variable or parameter. */
25
+ function isGlobalName(ts, checker, node, names) {
26
+ if (!ts.isIdentifier(node) || !names.has(node.text)) return false;
27
+ const declarations = checker?.getSymbolAtLocation(node)?.getDeclarations() ?? [];
28
+ return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
29
+ }
30
+
31
+ function recordProcessBindings(ts, clause, add, report) {
32
+ if (clause?.name) add(clause.name);
33
+ const bindings = clause?.namedBindings;
34
+ if (bindings && ts.isNamespaceImport(bindings)) add(bindings.name);
35
+ if (!bindings || !ts.isNamedImports(bindings)) return;
36
+ for (const element of bindings.elements) {
37
+ const name = (element.propertyName ?? element.name).text;
38
+ if (name === 'env') report(element);
39
+ else if (name === 'default') add(element.name);
40
+ }
41
+ }
42
+
43
+ function processImportSymbols(ts, checker, sourceFile, report) {
44
+ const imports = new Set();
45
+ const add = name => {
46
+ const symbol = checker.getSymbolAtLocation(name);
47
+ if (symbol) imports.add(symbol);
48
+ };
49
+ for (const statement of sourceFile.statements) {
50
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)
51
+ || !PROCESS_MODULES.has(statement.moduleSpecifier.text)) continue;
52
+ recordProcessBindings(ts, statement.importClause, add, report);
53
+ }
54
+ return imports;
55
+ }
56
+
57
+ /** Process bindings are resolved by symbol, so a nested parameter cannot masquerade as an imported process. */
58
+ function processAccess(ts, checker, sourceFile, report) {
59
+ const imports = processImportSymbols(ts, checker, sourceFile, report);
60
+ const isProcess = expression => {
61
+ const node = unwrapExpression(ts, expression);
62
+ if (!node) return false;
63
+ if (ts.isIdentifier(node)) return imports.has(checker.getSymbolAtLocation(node))
64
+ || isGlobalName(ts, checker, node, PROCESS_GLOBAL);
65
+ return accessName(ts, node) === 'process'
66
+ && isGlobalName(ts, checker, node.expression, GLOBAL_HOLDERS);
67
+ };
68
+ return node => {
69
+ if (accessName(ts, node) === 'env' && isProcess(node.expression)) report(node);
70
+ if (ts.isVariableDeclaration(node) && node.initializer && isProcess(node.initializer) && ts.isObjectBindingPattern(node.name)
71
+ && node.name.elements.some(element => bindingName(ts, element) === 'env')) report(node);
72
+ if (ts.isCallExpression(node) && accessName(ts, node.expression) === 'get'
73
+ && isGlobalName(ts, checker, node.expression.expression, REFLECT_GLOBAL)
74
+ && isProcess(node.arguments[0]) && node.arguments[1] && ts.isStringLiteralLike(node.arguments[1]) && node.arguments[1].text === 'env') report(node);
75
+ };
76
+ }
77
+
78
+ /** The declaration, rather than the caller's name, identifies EnvSource readers and configuration helpers. */
79
+ function appEnvironmentCall(config, ts, checker, call) {
80
+ const declaration = checker.getResolvedSignature(call)?.declaration;
81
+ if (!declaration) return false;
82
+ const relative = relativePath(config.root, declaration.getSourceFile().fileName);
83
+ if (ts.isMethodDeclaration(declaration) && ts.isClassDeclaration(declaration.parent) && declaration.parent.name?.text === 'EnvSource') {
84
+ return config.hfs.tierOf(relative) === 'platform' && path.posix.basename(config.hfs.ownerOf(relative)?.root ?? '') === 'config';
85
+ }
86
+ if (!ts.isFunctionDeclaration(declaration) && !ts.isArrowFunction(declaration) && !ts.isFunctionExpression(declaration)) return false;
87
+ return allowsFile(config.hfs, relative)?.entry === '<app>.env.ts'
88
+ || (path.posix.basename(relative).endsWith('.config.ts') && config.hfs.slotOf(relative) !== null);
89
+ }
90
+
91
+ /** Composition groups and options consume parsed options; only the app's matching env role and main read them. */
92
+ export function checkApplicationEnvironment(config, context, sourceFile, fileName, violations) {
93
+ const role = allowsFile(config.hfs, relativePath(config.root, fileName));
94
+ if (role?.allowed && (role.entry === 'main.ts' || role.entry === '<app>.env.ts')) return;
95
+ const { ts } = context;
96
+ const checker = context.checkerFor(fileName);
97
+ const report = node => violations.push({
98
+ ruleId: 'BE_APP_COMPOSITION_ONLY',
99
+ path: relativePath(config.root, fileName),
100
+ ...sourceLocation(sourceFile, node),
101
+ message: "Application environment reads live in main.ts or the matching <app>.env.ts. Options files and compose/<area>.ts receive the typed result of read<App>Env(env: EnvSource).",
102
+ });
103
+ const checkProcess = processAccess(ts, checker, sourceFile, report);
104
+ const visit = node => {
105
+ checkProcess(node);
106
+ if (ts.isCallExpression(node) && appEnvironmentCall(config, ts, checker, node)) report(node);
107
+ ts.forEachChild(node, visit);
108
+ };
109
+ visit(sourceFile);
110
+ }