@starci/hfs 4.7.23 → 4.7.24

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 (82) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/release/candidate-ci.mjs +20 -10
  5. package/release/gate.mjs +37 -19
  6. package/release/push.mjs +1 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  8. package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
  9. package/runtime/knowledge/hfs/rules.yaml +17 -17
  10. package/runtime/knowledge/hfs/slots.yaml +20 -1
  11. package/runtime/knowledge/patterns/be/saga.yaml +1 -1
  12. package/runtime/knowledge/sonar-gate.yaml +12 -12
  13. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
  15. package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
  16. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
  18. package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
  19. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
  20. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  21. package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
  22. package/runtime/scripts/hfs/slots.mjs +3 -2
  23. package/runtime/scripts/lib/gate-loosening.mjs +248 -0
  24. package/runtime/scripts/lib/managed-json.mjs +51 -0
  25. package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
  26. package/stack/prepare.mjs +10 -3
  27. package/stack/release-store.mjs +3 -3
  28. package/stack/remote.mjs +9 -0
  29. package/sync/ci-commands.mjs +38 -90
  30. package/sync/index.mjs +10 -5
  31. package/templates/app/browser-config/browser/tsconfig.json +11 -0
  32. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
  33. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
  34. package/templates/app/hooks/husky/pre-push +4 -2
  35. package/templates/app/package-scripts/package.json +0 -1
  36. package/templates/app/package-scripts-lite/package.json +0 -1
  37. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
  38. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
  39. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
  40. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
  41. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
  42. package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
  43. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
  44. package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
  45. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
  46. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
  47. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  48. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
  49. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
  50. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
  51. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
  52. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
  53. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
  54. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
  55. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
  56. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
  57. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
  58. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
  59. package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
  60. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
  61. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
  62. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
  63. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
  64. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
  65. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
  66. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  67. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
  68. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
  69. package/templates/fe/auth/supabase-client.ts.tpl +7 -7
  70. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
  71. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
  72. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
  73. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
  74. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
  75. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
  76. package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
  77. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
  78. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
  79. package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
  80. package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
  81. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
  82. package/templates/fe/transport/graphql/clients/response.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.24 - 2026-10-23
4
+ - Fixed: `starci app release push` counts no skipped conclusion as passing (owner ruling 2026-10-22: a skip is a loosening): a green candidate run with a skipped job is red, and the failing-jobs report lists skipped jobs and steps. The candidate grace is 45 minutes and the bounded wait 150 minutes from the push; a queued run is listed, so GitHub's queue never counts against the grace. Specs: `src/candidate-ci.spec.mjs`.
5
+ - Fixed: the local release gate always runs `db-stop` once `db-start` ran (`teardownOf`): a red database row no longer leaves the lite app's local Supabase running. Specs: `src/no-loosening-ci.spec.mjs`.
6
+ - Changed: the bundled canon pins select @starci/cli 1.7.24, @starci/eslint-canon-be 3.4.31, @starci/eslint-canon-fe 8.4.30, @starci/jest-preset 2.3.5, @starci/prettier-config 1.0.4, @starci/test-world 1.2.3 and @starci/tsconfig 2.0.7.
7
+ - Changed: the skeletons, the GraphQL client templates and the lite auth partial compile under `exactOptionalPropertyTypes` (`@starci/tsconfig` 2.0.7): an option that may be absent is spread in only when present (credential guard, outcome mapper, GraphQL error mapper, the fetch and Apollo link options, the TypeORM schema), the Apollo client options and link parameters declare `signal` as `AbortSignal | undefined`, and `ApolloOutcome.data` is `TData | null | undefined`. Fixed: the lite `supabase-client.ts.tpl` partial calls `authOf()`, the function the skeleton defines, instead of the removed `supabase()`.
8
+ - Changed: the bundled R280 detector (`HFS_UNUSED_SYMBOL`) resolves more catalog keys so a product needs fewer `i18n-dynamic-keys.yaml` entries: the type checker reads the standard library (a key typed as a union of literals is read through `filter`/`map` callbacks), a `??` or `||` default holds both operands, a nullish branch of a property owner is skipped, and a table of constants is read through the `readonly` fields of the interface or alias that types it. Spec: `src/unused-symbol-flow.spec.mjs`.
9
+ - Changed: the bundled `knowledge/sonar-gate.yaml` holds every issue severity on changed lines and a change of any size to duplication (`ignoreBelowChangedLines: 0`).
10
+
3
11
  ## 4.7.23 - 2026-10-22
4
12
  - Changed: the rendered `sonar-project.properties` leaves the message catalogs (`**/messages/*.messages.ts`, `**/messages/*.json`) out of Sonar's copy-paste detection (`sonar.cpd.exclusions`, owner ruling 2026-10-22). A catalog repeats its keys once per language, which CPD read as duplicated code (35% new duplicated lines on NIVO v0.3.1 once prettier formatted the catalogs); they stay sources, so lint, analysis and every other rule still hold them. Spec: `src/sonar-cpd-messages.spec.mjs`.
5
13
 
@@ -7,6 +15,10 @@
7
15
  - Fixed: the bundled runtime carries `modules/schemas/provision-operation.schema.yaml`; 4.7.21 left it out, so `starci app check` and `starci app lint` failed with ENOENT in every app whose stack declaration has an `operations` block. The composition reads both schemas through `new URL(..., import.meta.url)`, the form the runtime copy follows. Spec: `src/provision-operation-schema-bundle.spec.mjs`.
8
16
 
9
17
  ## 4.7.21 - 2026-10-22
18
+ - Changed: the bundled `scripts/lib/gate-loosening.mjs` has no approval route (owner ruling loosening-always-red, 2026-10-22): `approved` and `approvalIdOf` are gone, `looseningStep` is the land gate's always-red step whose only way out is restoring the gate, and the pure released-state judgement `loosensReleased` moved here from the runtime's check library. Spec: `src/gate-loosening.spec.mjs`.
19
+ - Added: the bundled runtime copy of `scripts/lib/gate-loosening.mjs`, which judges whether one change loosens a gate. It is rename-aware: an allowlist or rule entry that only moves with a git rename of the paths it names (the removed line, with every renamed prefix rewritten, equals an added line) is no loosening, while an entry that also widens, or one added beside the move, still is. Spec: `src/gate-loosening.spec.mjs`.
20
+ - Added: the opt-in managed slot `app.browser-config` (slot manifest 3.5.0): an app that declares `browser: true` gets `browser/tsconfig.json`, rendered by `starci app sync` from `templates/app/browser-config` (extending `@starci/tsconfig/e2e.json`), the program that type-checks the browser config and journeys Playwright only transpiles. Sync renders an opt-in managed slot only for an app that enables it. The bundled `scripts/lib/managed-json.mjs` names a slot-declared template and an app's byte-equal render of it as managed canon, not authored JSON. Specs: `src/browser-config.spec.mjs`, `src/managed-json.spec.mjs`.
21
+ - Added: the bundled runtime copy of `scripts/lib/uat-run-folder.mjs`, the pure half of `starci uat record`: a UAT run folder holds exactly manifest.yaml, result.md, screens/*.png and videos/*.webm (each of its type by its bytes), an example's fixture store receives each verified blob once, and the work/evidence@1 document cites every file by sha256 with an outcome that is pass only when the walk exited 0 and its manifest says pass. Spec: `src/uat-run-folder.spec.mjs`.
10
22
  - Fixed: the release gate's Sonar read retries a network error or HTTP 5xx up to 5 times with backoff inside its 600 s wait; a 4xx and every gate status keep their verdict. Spec: `src/sonar-quality-gate.spec.mjs`.
11
23
  - Fixed: `starci app db dump` archives its parts with tar run inside the scratch directory and relative names only (no `-C`, no absolute path): GNU tar, first on PATH under Git Bash on Windows, read the absolute drive-letter archive path as a remote host and every backup failed with `DB_BACKUP_TOOL_FAILED`. Spec: `src/db-dump-archive.spec.mjs`.
12
24
  - Fixed: the bundled stacks gate validates a declaration's `operations` block with the composed provision-operation schema instead of crashing on its cross-file `$ref`; the bundled schema lets a SonarCloud admin credential name a runtime secret.env variable (`hostConnection: sonar-runtime-secret-env`).
package/README.md CHANGED
@@ -169,7 +169,7 @@ The managed `sonar-project.properties` carries `sonar.externalIssuesReportPaths`
169
169
  reaches Sonar while the job stays failed. There is no `continue-on-error`. CI runs `npm run lint -- --sonar reports/lint.sonar.json` once at the app root, before the scan. The duplicate-block threshold (`ruleParams.<side>.duplicateBlock`) has no Sonar property for TypeScript (SonarJS detects
170
170
  clones with its own token rule), so the machine enforces it (R21) and its findings are imported like every other.
171
171
 
172
- The gate is `knowledge/sonar-gate.yaml`, the one declaration: the new-code conditions (coverage 100, duplication, blocker and critical issues, hotspots) and an `overall` part (coverage 100, 0 open issues on the whole code,
172
+ The gate is `knowledge/sonar-gate.yaml`, the one declaration: the new-code conditions (coverage 100, duplication, issues of every severity, hotspots) and an `overall` part (coverage 100, 0 open issues on the whole code,
173
173
  every hotspot reviewed, duplicated lines density, cognitive complexity through the S3776 rule). A SonarQube gate condition cannot filter by engine, so the condition counts every
174
174
  open issue, imported or native; that is stricter than the three imports alone and is intended. `starci app check` reports `HFS_SONAR_CONFIG` (R11) when the
175
175
  properties file is not its render or the stack declaration names another gate. R20 and R21 have their Sonar enforcers as conditions of that file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.23",
3
+ "version": "4.7.24",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +1,9 @@
1
1
  // candidate-ci.mjs - the candidate CI of `starci app release push` (OWNER RULING candidate-ci, 2026-10-18: "never again have main receive a release whose CI is
2
2
  // red, with the owner confirming afterwards"). The push sends the exact release sha to refs/heads/release/candidate-<version> first (the managed ci.yml triggers on
3
3
  // release/candidate-*), waits for the ci.yml run of THAT sha, and moves the default branch and the tag only when it is green:
4
- // green the newest non-cancelled run of the sha concluded success
5
- // red RELEASE_CANDIDATE_CI_RED with the run URL and the failing jobs and steps; nothing reaches the default branch. The candidate ref stays for diagnosis
6
- // timeout RELEASE_CANDIDATE_CI_TIMEOUT: no run started within the grace, or it did not end within the wait
4
+ // green the newest non-cancelled run of the sha concluded success and none of its jobs was skipped
5
+ // red RELEASE_CANDIDATE_CI_RED (a run or a job that concluded skipped is red, never a pass: owner ruling 2026-10-22, a skip is a loosening) with the run URL and the failing jobs and steps; nothing reaches the default branch. The candidate ref stays for diagnosis
6
+ // timeout RELEASE_CANDIDATE_CI_TIMEOUT: no run listed within the grace (a queued run is listed), or it did not end within the wait
7
7
  // unknown RELEASE_CI_UNKNOWN: GitHub could not be read to the end; a release never proceeds blind
8
8
  // Emergency path (documented, owner-only): the owner's `starci release ci-override --sha <sha>` record for this exact sha
9
9
  // (<git common dir>/starci-release/<sha>.ci-override.json, schema starci/ci-override@1) lets a red candidate through, and the push says so.
@@ -13,10 +13,11 @@ import path from 'node:path';
13
13
 
14
14
  const CANDIDATE_PREFIX = 'refs/heads/release/candidate-';
15
15
  /** The wait of the candidate run: poll interval, the grace for a run to appear, the wait for it to end (an app's ci.yml jobs time out within 45 minutes). */
16
- const CANDIDATE_WAIT = Object.freeze({ pollSeconds: 30, graceMinutes: 10, waitMinutes: 90 });
16
+ // A queued run is listed (running): the grace only covers a run GitHub has not created yet, and the wait, counted from the push, bounds grace, queue and run.
17
+ const CANDIDATE_WAIT = Object.freeze({ pollSeconds: 30, graceMinutes: 45, waitMinutes: 150 });
17
18
  const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
18
19
  const SHA = /^[a-f0-9]{40}$/u;
19
- const PASSING = new Set(['success', 'neutral', 'skipped']);
20
+ const PASSING = new Set(['success', 'neutral']);
20
21
 
21
22
  /** The candidate ref of the release tag `tag` with the app's tag prefix: refs/heads/release/candidate-<version>. */
22
23
  export const candidateRef = (tag, prefix = 'v') => `${CANDIDATE_PREFIX}${String(tag).startsWith(prefix) ? String(tag).slice(prefix.length) : String(tag)}`;
@@ -40,18 +41,27 @@ export function candidateVerdict(rows) {
40
41
  return { state: newest.conclusion === 'success' ? 'green' : 'red', run: newest };
41
42
  }
42
43
 
43
- /** One read of the ci.yml runs of `sha`: {ok, state, run} or {ok: false}. */
44
+ /** The jobs of a run ([{name, conclusion, steps}]), or null when unreadable. */
45
+ function runJobsOf(ctx, runRow) {
46
+ if (!/^\d+$/u.test(String(runRow?.databaseId ?? ''))) return null;
47
+ const reply = jsonOf(run(ctx, 'gh', ['run', 'view', String(runRow.databaseId), '--json', 'jobs']));
48
+ return Array.isArray(reply?.jobs) ? reply.jobs : null;
49
+ }
50
+
51
+ /** One read of the ci.yml runs of `sha`: {ok, state, run} or {ok: false}. A green run is read job by job: a skipped job makes it red. */
44
52
  function readCandidateRun(ctx, sha) {
45
53
  const rows = jsonOf(run(ctx, 'gh', ['run', 'list', '--workflow', 'ci.yml', '--commit', sha, '--limit', '20', '--json', FIELDS]));
46
54
  if (!Array.isArray(rows)) return { ok: false };
47
- return { ok: true, ...candidateVerdict(rows.filter((row) => !row.headSha || row.headSha === sha)) };
55
+ const seen = candidateVerdict(rows.filter((row) => !row.headSha || row.headSha === sha));
56
+ if (seen.state !== 'green') return { ok: true, ...seen };
57
+ const jobs = runJobsOf(ctx, seen.run);
58
+ if (!jobs) return { ok: false };
59
+ return { ok: true, ...seen, state: jobs.some((job) => job.conclusion === 'skipped') ? 'red' : 'green' };
48
60
  }
49
61
 
50
62
  /** The failing jobs and steps of a run: ['job (conclusion): step, step']; [] when unreadable. */
51
63
  export function failingJobs(ctx, runRow) {
52
- if (!/^\d+$/u.test(String(runRow?.databaseId ?? ''))) return [];
53
- const reply = jsonOf(run(ctx, 'gh', ['run', 'view', String(runRow.databaseId), '--json', 'jobs']));
54
- return (reply?.jobs ?? []).filter((job) => job.conclusion && !PASSING.has(job.conclusion)).map((job) => {
64
+ return (runJobsOf(ctx, runRow) ?? []).filter((job) => job.conclusion && !PASSING.has(job.conclusion)).map((job) => {
55
65
  const steps = (job.steps ?? []).filter((step) => step.conclusion && !PASSING.has(step.conclusion)).map((step) => step.name);
56
66
  return `${job.name} (${job.conclusion})${steps.length ? `: ${steps.join(', ')}` : ''}`;
57
67
  });
package/release/gate.mjs CHANGED
@@ -28,7 +28,14 @@ const PREFIX = /^[A-Za-z][A-Za-z0-9._-]{0,19}$/u;
28
28
  export const VERSION = /^(\d+)\.(\d+)\.(\d+)$/u;
29
29
  const THRESHOLD_MISS = /coverage threshold .* not met/gu;
30
30
  const WRAPPED = new Set(['npm', 'npx']);
31
- const SCRIPT_GATES = ['typecheck', 'build:be', 'lint', 'build:fe', 'test'];
31
+ // Every row the managed CI runs, so a release that does not wait for its candidate CI (release.requireCi false) still ran them all locally:
32
+ // fast (audit, format, typecheck), lint, test + coverage (the managed `test` script is the per-file 100 threshold), build, the lite database lint and types, and Sonar.
33
+ const SCRIPT_GATES = ['format:check', 'typecheck', 'build:be', 'lint', 'db:lint', 'build:fe', 'test'];
34
+ // A full app has a test world (integration, e2e and contract projects, always present: the managed `test:e2e` script marks it), so those layers are rows of its gate;
35
+ // a lite app's integration project exists only where it has integration specs, so it has no world rows.
36
+ const WORLD_SCRIPTS = ['test:integration', 'test:e2e', 'test:contract'];
37
+ const DB_TYPES_SCRIPTS = ['db:start', 'db:reset', 'db:types', 'db:stop'];
38
+ const DB_TYPES_COMMITTED = { name: 'db-types-committed', command: ['git', 'diff', '--exit-code', '--', 'supabase/types/database.types.ts'], summary: null, env: {} };
32
39
  const MAX_PATCH = 100;
33
40
 
34
41
  /** True for a branch or remote name git can take as one argument (no leading dash, only path-like characters). */
@@ -41,10 +48,21 @@ const scriptGate = (script) => ({
41
48
  env: {},
42
49
  });
43
50
 
44
- /** Default gates run app-check, the enabled audit, then every managed script present in the app. */
51
+ /**
52
+ * The lite database-types rows of the managed CI, in its order: start Supabase, apply the migrations, regenerate the types, require them committed, stop.
53
+ * The stop is the teardown of the start (`teardownOf`): it runs whenever the start ran, also after a red row, so a failed database row never leaves Supabase running.
54
+ */
55
+ function dbTypesGates(scripts) {
56
+ if (!DB_TYPES_SCRIPTS.every((script) => typeof scripts?.[script] === 'string')) return [];
57
+ const [start, reset, types, stop] = DB_TYPES_SCRIPTS.map(scriptGate);
58
+ return [start, reset, types, DB_TYPES_COMMITTED, { ...stop, teardownOf: start.name }];
59
+ }
60
+
61
+ /** Default gates run app-check, the enabled audit, then every managed script present in the app and the database-types rows, Sonar following when it is declared. */
45
62
  function defaultGates(scripts, audit) {
46
63
  const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
47
- return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate)];
64
+ const world = typeof scripts?.['test:e2e'] === 'string' ? WORLD_SCRIPTS.filter((script) => typeof scripts?.[script] === 'string') : [];
65
+ return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate), ...world.map(scriptGate), ...dbTypesGates(scripts)];
48
66
  }
49
67
 
50
68
  /** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
@@ -218,7 +236,7 @@ export function launch(argv, platform) {
218
236
  return [argv[0], argv.slice(1)];
219
237
  }
220
238
 
221
- /** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. */
239
+ /** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. An unreachable host is a red row: Sonar is required. */
222
240
  async function sonarAvailability(ctx, gate) {
223
241
  const sonar = gate.provider;
224
242
  if (!sonar) return null;
@@ -228,13 +246,10 @@ async function sonarAvailability(ctx, gate) {
228
246
  up = response.ok && (await response.json()).status === 'UP';
229
247
  } catch { up = false; }
230
248
  if (up) return null;
231
- const detail = sonar.optional
232
- ? `notice: sonar skipped: ${sonar.host} not reachable (services.sonar.optional)`
233
- : `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds`;
234
- return { skipped: sonar.optional === true, pass: false, detail };
249
+ return { pass: false, detail: `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds` };
235
250
  }
236
251
 
237
- /** Unreachable optional providers remain explicit skipped rows, with a real log and no scan invocation. */
252
+ /** An unreachable Sonar host stays a red row with a real log and no scan invocation. */
238
253
  async function runAvailableGate(ctx, gate, options) {
239
254
  const availability = await sonarAvailability(ctx, gate);
240
255
  const log = path.join(options.dir, `${gate.name}.log`);
@@ -334,10 +349,11 @@ function inspect(ctx, config, options) {
334
349
  }
335
350
 
336
351
  function planLines(config, facts, dir, recordPath) {
337
- const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (required gates must pass, optional provider outages are skipped, in this order):'];
352
+ const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (every gate must pass, in this order):'];
338
353
  for (const gate of config.gates) {
339
354
  const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
340
- out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
355
+ const teardown = gate.teardownOf ? ` (teardown of ${gate.teardownOf}: runs whenever it ran)` : '';
356
+ out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}${teardown}`);
341
357
  }
342
358
  out.push(`then: write ${path.basename(recordPath)} and \`git tag -a ${facts.chosen.tag}\` on ${facts.head.slice(0, 10)}; push it with \`starci app release push\`.`);
343
359
  return out;
@@ -353,7 +369,7 @@ function assertUnmoved(ctx, head) {
353
369
  function writeRecord(ctx, { head, tag, results, recordPath, requireCi }) {
354
370
  const record = {
355
371
  schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
356
- note: 'Required gates passed on a clean HEAD; optional provider skips are explicit and every entry links its log and sha256.',
372
+ note: 'Every gate passed on a clean HEAD; every entry links its log and sha256.',
357
373
  ci: requireCi === true ? { required: true, note: 'starci app release push waits for the candidate CI of this sha (release.requireCi=true)' } : { required: false, note: CI_NOT_REQUIRED },
358
374
  gates: results,
359
375
  };
@@ -361,20 +377,21 @@ function writeRecord(ctx, { head, tag, results, recordPath, requireCi }) {
361
377
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
362
378
  }
363
379
 
364
- /** Gates run sequentially because each failure prevents subsequent commands and record creation. */
380
+ /**
381
+ * Gates run sequentially because each failure prevents subsequent commands and record creation. A teardown row (`teardownOf`) is the exception: it runs
382
+ * whenever the row it tears down ran, red or green, so a red row never leaves a started service behind; its own verdict counts like any row's.
383
+ */
365
384
  async function runAll(ctx, config, options) {
366
385
  fs.mkdirSync(options.dir, { recursive: true });
367
386
  return config.gates.reduce(async (pending, gate) => {
368
387
  const results = await pending;
369
- if (results.some((result) => !result.pass && !result.skipped)) return results;
388
+ if (results.some((result) => !result.pass) && !(gate.teardownOf && results.some((result) => result.name === gate.teardownOf))) return results;
370
389
  return [...results, await runAvailableGate(ctx, gate, options)];
371
390
  }, Promise.resolve([]));
372
391
  }
373
392
 
374
393
  function summaryOf(result) {
375
- let status = result.pass ? 'PASS' : 'FAIL';
376
- if (result.skipped) status = 'SKIPPED';
377
- return `${status} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
394
+ return `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
378
395
  }
379
396
 
380
397
  /**
@@ -382,7 +399,8 @@ function summaryOf(result) {
382
399
  * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
383
400
  */
384
401
  export async function releaseGate(ctx, config, options = {}) {
385
- if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
402
+ // Sonar is a row of the managed CI, so it is a row of the local gate whenever the app declares it (and when --sonar asks for it); a missing token is a refusal, never a skip.
403
+ if (options.sonar || config.services?.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
386
404
  const credentialNames = config.gates.find(gate => gate.provider)?.credentialNames ?? ['SONAR_TOKEN', config.services?.sonar?.token].filter(Boolean);
387
405
  ctx = Object.create(ctx, {env: {value: withoutCredentials(ctx.env, credentialNames)}});
388
406
  // The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
@@ -397,7 +415,7 @@ export async function releaseGate(ctx, config, options = {}) {
397
415
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
398
416
  const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform, credentialNames });
399
417
  const report = results.map(summaryOf);
400
- const failed = results.filter((result) => !result.pass && !result.skipped);
418
+ const failed = results.filter((result) => !result.pass);
401
419
  if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
402
420
  assertUnmoved(ctx, facts.head);
403
421
  writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath, requireCi: config.requireCi });
package/release/push.mjs CHANGED
@@ -19,7 +19,7 @@ function readRecord(file) {
19
19
  }
20
20
 
21
21
  const greenRecord = (record, head, tag) => record?.head === head && record?.tag === tag
22
- && Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true || (gate?.name === 'sonar' && gate?.skipped === true));
22
+ && Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true);
23
23
 
24
24
  /** The release gate as the hook reads it: the annotated tag on HEAD whose L4 record names head and tag. Returns { tag } or { reason }. */
25
25
  function gateState(ctx, head, prefix) {
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.30
32
+ version: 3.4.31
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.29
39
+ version: 8.4.30
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -50,40 +50,40 @@ pins:
50
50
  source: packages/stylelint/package.json
51
51
  why: 'the front-end stylelint canon: no-class-selector refuses a compound class selector (`div.card`); the brand layer accepts `--font-sans` and `--font-mono`; status-contrast measures the HeroUI soft pairs per theme; brand-layer-shape reads the shared :root,.light,.dark block and the info soft pair; appTokens come from loadAppTokens(import.meta.url) for the managed one-line stylelint.config.mjs.'
52
52
  '@starci/tsconfig':
53
- version: 2.0.5
53
+ version: 2.0.7
54
54
  group: starci
55
55
  install: registry
56
56
  side: both
57
57
  source: packages/tsconfig/package.json
58
58
  '@starci/prettier-config':
59
- version: 1.0.3
59
+ version: 1.0.4
60
60
  group: starci
61
61
  install: registry
62
62
  side: both
63
63
  source: packages/prettier-config/package.json
64
64
  '@starci/jest-preset':
65
- version: 2.3.4
65
+ version: 2.3.5
66
66
  group: starci
67
67
  install: registry
68
68
  side: be
69
69
  source: packages/jest-preset/package.json
70
70
  why: 'the Jest preset of a back end: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: it pairs exactly with @starci/test-world, and any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. The unit run writes the lcov Sonar and Codecov import (services only); the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; it ships the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
71
71
  '@starci/test-world':
72
- version: 1.2.2
72
+ version: 1.2.3
73
73
  group: starci
74
74
  install: registry
75
75
  side: be
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.23
79
+ version: 1.7.24
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.23
86
+ version: 4.7.24
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -465,7 +465,7 @@ pins:
465
465
  version: 13.4.5
466
466
  group: framework
467
467
  side: be
468
- why: 'the GraphQL transport of every back end (be.transport.graphql) that the front end calls; the ecommerce-app resolution on 2026-10-13. Stays on 13.x for alpha.18: the npm-audit advisory fixed by 14.0.3 (semver major) is a documented accepted exception, upgrade deferred to alpha.19 (owner ruling graphql-13-advisory-accepted-until-a19, 2026-10-16).'
468
+ why: 'the GraphQL transport of every back end (be.transport.graphql) that the front end calls; the full-app resolution on 2026-10-13. Stays on 13.x for alpha.18: the npm-audit advisory fixed by 14.0.3 (semver major) is a documented accepted exception, upgrade deferred to alpha.19 (owner ruling graphql-13-advisory-accepted-until-a19, 2026-10-16).'
469
469
  '@graphql-tools/utils':
470
470
  version: 12.1.0
471
471
  group: framework
@@ -475,12 +475,12 @@ pins:
475
475
  version: 13.4.5
476
476
  group: framework
477
477
  side: be
478
- why: 'the Apollo driver of @nestjs/graphql; the ecommerce-app resolution on 2026-10-13. Stays on 13.x with @nestjs/graphql (accepted advisory, major deferred to alpha.19).'
478
+ why: 'the Apollo driver of @nestjs/graphql; the full-app resolution on 2026-10-13. Stays on 13.x with @nestjs/graphql (accepted advisory, major deferred to alpha.19).'
479
479
  '@apollo/server':
480
480
  version: 5.5.1
481
481
  group: framework
482
482
  side: be
483
- why: 'the Apollo server behind @nestjs/apollo; the ecommerce-app resolution on 2026-10-13.'
483
+ why: 'the Apollo server behind @nestjs/apollo; the full-app resolution on 2026-10-13.'
484
484
  '@nestjs/websockets':
485
485
  version: 11.2.5
486
486
  group: framework
@@ -59,7 +59,7 @@ items:
59
59
  check: ci-standard
60
60
  - id: test-sharding
61
61
  title: the unit run in shards, merged once
62
- requires: "the test job is a matrix over shard (ci.jobs.test.shards, default defaults.shards); each shard runs npm test with --shard=<i>/<N>, STARCI_COVERAGE_SHARD=1 (the preset measures only the files the shard loads, no all-files instrumentation, no per-file threshold) and uploads its istanbul JSON; the coverage job merges them by source location (starci app coverage merge, @starci/jest-preset/merge) and the coverage proof then requires every measured file to be present in the merge at 100 per file"
62
+ requires: "the test job is a matrix over shard (ci.jobs.test.shards, default defaults.shards); each shard runs npm test with --shard=<i>/<N> and STARCI_COVERAGE_SHARD=1 (the preset owns shard mode: it measures only the files the shard loads, no all-files instrumentation, no per-file threshold) and uploads its istanbul JSON, with no --passWithNoTests and no --coverageThreshold flag; the shard count never exceeds what the unit specs fill (jest gives shard i the slice of ceil(specs / N) specs, so an empty shard is impossible) ; the coverage job merges them by source location (starci app coverage merge, @starci/jest-preset/merge) and the coverage proof then requires every measured file to be present in the merge at 100 per file"
63
63
  covers: [app-ci]
64
64
  check: ci-standard
65
65
  - id: lint-parallel
@@ -77,10 +77,10 @@ items:
77
77
  requires: "the build job builds every fe app with that app's own public build values (static NEXT_PUBLIC_* from the declaration), never a shared env"
78
78
  covers: [app-ci]
79
79
  by: [render packages/hfs/sync/ci-commands.mjs, R300 FE_PUBLIC_BUILD_ARG_MISSING]
80
- - id: optional-providers
81
- title: optional providers probe, then skip with a notice
82
- requires: "an optional Codecov or Sonar step (continue-on-error) is preceded in its job by its availability probe (id codecov-availability / sonar-availability); a strict provider fails the job"
83
- covers: [app-ci]
80
+ - id: required-providers
81
+ title: Codecov and Sonar are required, nothing continues on error
82
+ requires: "no step or job sets continue-on-error; a Codecov upload sets fail_ci_if_error: true; no Codecov or Sonar step has an availability probe or an `if` that reads a secret, CODECOV_TOKEN or SONAR_TOKEN (the sonar job refuses a missing token with its own failing step); a red step is red"
83
+ covers: [app-ci, runtime]
84
84
  check: ci-standard
85
85
  - id: timeouts
86
86
  title: a timeout on every job, configurable per job
@@ -56,7 +56,7 @@ rules:
56
56
  - {path: packages/eslint/fe/slot-ownership.spec.mjs, editions: [lite, full], codes: [HFS_SLOT_UNDECLARED, HFS_SLOT_AMBIGUOUS, HFS_SLOT_NOT_ENABLED]}
57
57
  exemplifiedBy:
58
58
  - {path: examples/lite-app/be/src/features/webhooks/calendar/transport/http/calendar.webhook.ts, edition: lite}
59
- - {path: examples/ecommerce-app/be/src/features/webhooks/sepay/transport/http/sepay.webhook.ts, edition: full}
59
+ - {path: examples/full-app/be/src/features/webhooks/sepay/transport/http/sepay.webhook.ts, edition: full}
60
60
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
61
61
  specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; the package specs named in proves keep proving the lint enforcers, the runtime-code enforcers lost their deleted runtime spec"
62
62
  - id: R02
@@ -846,7 +846,7 @@ rules:
846
846
  exemplifiedBy:
847
847
  - {path: packages/hfs/templates/be/inbox/service.spec.ts.tpl, editions: [lite, full]}
848
848
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
849
- law: "One jest config, projects `unit`, `integration`, `e2e` and `contract`, live by folder, `diagnostics: false`; unit specs are `<name>.<role>.spec.ts` beside the file they test: required for the roles of `ruleParams.be.unitRoles` (`<name>.service.spec.ts` beside a `<name>.service.ts`, `<name>.cli.spec.ts` beside a cli command `<name>.cli.ts` of `src/features/cli`), optional for any other logic role (`ruleParams.be.logicRoles`) inside a slot whose `coverage` is required, except that the commit adding a measured logic file stages its `<name>.<role>.spec.ts` beside it (owner 2026-10-20, judged by `starci app hygiene` in the pre-commit hook), and nowhere else but beside a webhook or realtime door that has no service of its own (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<x>.subscription.spec.ts`) (every unit-tested subject has exactly one; any other `*.spec.ts` outside `be/src/tests/{world,integration,e2e,contract}` is a finding, including specs in apps), and the unit project collects coverage from the logic roles of the measured roots only (the scope derived from the slot manifest, R204) with a per-file threshold of 100 on lines, branches, functions and statements (`test` runs `--coverage`; `importHelpers` and `tslib`); `be/src/tests/world/` is the ONLY test infrastructure: `global-setup.ts` starts the app's own stack (`.starcistacks/<env>`, named by `stack` in `test-world.config.ts`, the declaration of the test-world library in its one named form `export const { useTestWorld, useSandbox } = defineTestWorld({...})` (a default export is not read): every service it declares runs REAL, behind toxiproxy, images and versions read from the stack at run time, never spelled in test source) or attaches to a warm one that answers, and runs the migrations once over the cli app's connections (the runner of the cli migrate command); `global-teardown.ts` stops only what its setup started; `use-test-world.ts` exports `useTestWorld({ apps } | { modules })` (`world.apps.<name>.api`, `world.db.<connection>`, `world.infra.<service>` with `latency(ms)`, `cut()` and `restore()` on the real service, `world.fake.<provider>`, `world.waitFor`) and `fakes/<provider>/` holds the network-edge fakes (`failNext`, `replayWebhook`, `delay`, payload fixtures) of external SaaS the team does not operate ONLY: a fake of a service the stack declares is refused, except stateless compute that needs special hardware or an external model (GPU inference, a self-hosted embedding model), whose `stacks` entry in `test-world.config.ts` declares `{ fakedBy: <fake>, reason }` with a non-empty `reason` (a stateful service, or one with a persistent volume, is never accepted); it alone owns containers, DataSource, migrations and `process.env`; nothing under `be/src/tests` overrides a provider or DI token; `be/src/tests/integration/<capability>/*.integration-spec.ts` (`{ modules }`, with the real peer apps its client calls beside them as `apps`; real infrastructure, no HTTP door of ours), `be/src/tests/e2e/<area>/*.e2e-spec.ts` (`{ apps }`) and `be/src/tests/contract/<provider>/*.contract-spec.ts` (provider sandboxes, skipped without sandbox config, run only by `test:contract`, never by `test` or `test:e2e`) agree folder with suffix; an integration or e2e spec passes `useTestWorld` one object literal with plain keys (no variable, spread or computed key) and calls it; the contract layer skips only through the test-world library's `useSandbox(...)` when the sandbox config is absent (`sandbox.describe(...)`), and every `fakes/<provider>/` that serves payload fixtures (`payloads/*.json`) has a `contract/<provider>/*.contract-spec.ts` that references those fixtures and asserts `shapeOf(real)` equals `shapeOf(fixture)` (the helper `shapeOf` is named by `ruleParams.be.contractShape` of the slot manifest; fakes the test-world library provides are guarded by the library); no `.test.ts`, `int-spec` or `harness-spec`, no `live/` and no `be/src/tests/e2e/world`."
849
+ law: "One jest config, projects `unit`, `integration`, `e2e` and `contract`, live by folder, `diagnostics: false`; unit specs are `<name>.<role>.spec.ts` beside the file they test: required for the roles of `ruleParams.be.unitRoles` (`<name>.service.spec.ts` beside a `<name>.service.ts`, `<name>.cli.spec.ts` beside a cli command `<name>.cli.ts` of `src/features/cli`), optional for any other logic role (`ruleParams.be.logicRoles`) inside a slot whose `coverage` is required, except that the commit adding a measured logic file stages its `<name>.<role>.spec.ts` beside it (owner 2026-10-20, judged by `starci app hygiene` in the pre-commit hook), and nowhere else but beside a webhook or realtime door that has no service of its own (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<x>.subscription.spec.ts`) (every unit-tested subject has exactly one; any other `*.spec.ts` outside `be/src/tests/{world,integration,e2e,contract}` is a finding, including specs in apps), and the unit project collects coverage from the logic roles of the measured roots only (the scope derived from the slot manifest, R204) with a per-file threshold of 100 on lines, branches, functions and statements (`test` runs `--coverage`; `importHelpers` and `tslib`); `be/src/tests/world/` is the ONLY test infrastructure: `global-setup.ts` starts the app's own stack (`.starcistacks/<env>`, named by `stack` in `test-world.config.ts`, the declaration of the test-world library in its one named form `export const { useTestWorld, useSandbox } = defineTestWorld({...})` (a default export is not read): every service it declares runs REAL, behind toxiproxy, images and versions read from the stack at run time, never spelled in test source) or attaches to a warm one that answers, and runs the migrations once over the cli app's connections (the runner of the cli migrate command); `global-teardown.ts` stops only what its setup started; `use-test-world.ts` exports `useTestWorld({ apps } | { modules })` (`world.apps.<name>.api`, `world.db.<connection>`, `world.infra.<service>` with `latency(ms)`, `cut()` and `restore()` on the real service, `world.fake.<provider>`, `world.waitFor`) and `fakes/<provider>/` holds the network-edge fakes (`failNext`, `replayWebhook`, `delay`, payload fixtures) of external SaaS the team does not operate ONLY: a fake of a service the stack declares is refused, except stateless compute that needs special hardware or an external model (GPU inference, a self-hosted embedding model), whose `stacks` entry in `test-world.config.ts` declares `{ fakedBy: <fake>, reason }` with a non-empty `reason` (a stateful service, or one with a persistent volume, is never accepted); it alone owns containers, DataSource, migrations and `process.env`; nothing under `be/src/tests` overrides a provider or DI token; `be/src/tests/integration/<capability>/*.integration-spec.ts` (`{ modules }`, with the real peer apps its client calls beside them as `apps`; real infrastructure, no HTTP door of ours), `be/src/tests/e2e/<area>/*.e2e-spec.ts` (`{ apps }`) and `be/src/tests/contract/<provider>/*.contract-spec.ts` (provider sandboxes, skipped without sandbox config, run only by `test:contract`, never by `test` or `test:e2e`) agree folder with suffix; an integration or e2e spec passes `useTestWorld` one object literal with plain keys (no variable, spread or computed key) and calls it; the contract layer has no skip either: a contract spec writes `sandbox.describe(...)` from the test-world library's `useSandbox(...)`, and when the sandbox config is absent that describe registers one failing test naming the missing keys (the body never runs), and every `fakes/<provider>/` that serves payload fixtures (`payloads/*.json`) has a `contract/<provider>/*.contract-spec.ts` that references those fixtures and asserts `shapeOf(real)` equals `shapeOf(fixture)` (the helper `shapeOf` is named by `ruleParams.be.contractShape` of the slot manifest; fakes the test-world library provides are guarded by the library); no `.test.ts`, `int-spec` or `harness-spec`, no `live/` and no `be/src/tests/e2e/world`."
850
850
  kinds: [check, codemod, lint]
851
851
  gates: [land, ci]
852
852
  failureCodes: ["BE_TEST_TOPOLOGY", "BE_CONTRACT_UNGUARDED", "BE_UNIT_SPEC_NOT_STAGED"]
@@ -2540,7 +2540,7 @@ rules:
2540
2540
  enforcers:
2541
2541
  - {kind: hfs, id: pattern-spec-missing, at: scripts/hfs/rules/event-bus.mjs, editions: [full]}
2542
2542
  - {kind: hfs, id: lite-proof-lane, at: scripts/hfs/rules/lite-proof-lane.mjs, editions: [lite]}
2543
- editionWhy: "owner:gate-loosening-e77ca91cded7 approves the per-edition enforcer split; ruling:R212/case-2 (with the Work boundary amended by ruling:R212/case-4) gives lite its integration proof lane while full retains e2e or integration proofs."
2543
+ editionWhy: "each enforcer judges the edition it emits for (pattern-spec-missing returns no findings for lite, lite-proof-lane is lite's emitter), so the split adds lite's emitter and drops no finding; ruling:R212/case-2 (with the Work boundary amended by ruling:R212/case-4) gives lite its integration proof lane while full retains e2e or integration proofs."
2544
2544
  docs: [docs/quality-bar.md#r155]
2545
2545
  templateNa: "Pattern scenarios depend on application behavior and providers; no universal runnable scenario proof is scaffolded."
2546
2546
  specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; this code is enforced by runtime code and its spec proof was a deleted runtime spec"
@@ -3282,7 +3282,7 @@ rules:
3282
3282
  - {path: packages/hfs/templates/app/ci-workflows-lite/github/workflows/ci.yml, edition: lite}
3283
3283
  - {path: packages/hfs/templates/app/ci-workflows/github/workflows/ci.yml, edition: full}
3284
3284
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
3285
- law: "A coverage upload of a CI workflow never skips or fails silently: every step that uses codecov/codecov-action, in a root workflow, an example workflow or a template workflow of packages/hfs, authenticates by OIDC (`use_oidc: true`, with `permissions: id-token: write` on the job or the workflow), sets `fail_ci_if_error: true`, has no `continue-on-error`, and has no `if` that reads a secret or CODECOV_TOKEN, so a missing token can never turn the upload into a skipped step."
3285
+ law: "A coverage upload of a CI workflow never skips or fails silently: every step that uses codecov/codecov-action, in a root workflow, an example workflow or a template workflow of packages/hfs, authenticates by OIDC (`use_oidc: true`, with `permissions: id-token: write` on the job or the workflow), sets `fail_ci_if_error: true`, has no `continue-on-error`, and has no `if` that reads a secret or CODECOV_TOKEN, so a missing token can never turn the upload into a skipped step. Every step that uses SonarSource/* has no `if` that reads a secret or SONAR_TOKEN (a missing token fails the job), and no step or job of a workflow sets `continue-on-error`."
3286
3286
  scope: runtime
3287
3287
  kinds: [check]
3288
3288
  gates: [runtime]
@@ -3754,7 +3754,7 @@ rules:
3754
3754
  - {path: packages/eslint/be/sonar-parity-editions.spec.mjs, edition: full}
3755
3755
  - {path: packages/eslint/fe/sonar-parity-editions.spec.mjs, edition: lite}
3756
3756
  - {path: packages/eslint/fe/sonar-parity-editions.spec.mjs, edition: full}
3757
- law: "Code carries none of the smells SonarCloud measures that a lint rule can decide: no `await` inside a loop (S9382; an endless loop is spared), no `charCodeAt`/`fromCharCode` (S7758), no string that only escapes backslashes (S7780; a Next `matcher` is spared, it is read statically), no nested conditional expression (S3358), no `void` on anything but a call (S3735), no unused import (S1128), no import that is only handed on instead of `export ... from` (S7763), no prop of a component's props type the component never reads (S6767, front end), no `<video>` or `<audio>` without a captions track unless muted (S4084, front end), and, through typescript-eslint with type information, no deprecated API (S1874), no object turned into text (S6551) and no `async` function that never awaits and returns no promise (S7503). No function takes more than seven parameters (S107), except the constructor of an injectable class, whose parameters are its injected dependencies. Every other rule of the Sonar way TypeScript profile with an ESLint equivalent that reports what the server reports on the same source (scripts/lib/sonar-way.mjs: eslint-plugin-sonarjs' Sonar way set and the core and unicorn rules it builds on) is on at error in both canons; a port that does not match the server is left to the server. The files a unit or an end-to-end spec names are outside the scan, so outside these rules."
3757
+ law: "Code carries none of the smells SonarCloud measures that a lint rule can decide: no `await` inside a loop (S9382; an endless loop is spared), no `charCodeAt`/`fromCharCode` (S7758), no string that only escapes backslashes (S7780; a Next `matcher` is spared, it is read statically), no nested conditional expression (S3358), no `void` on anything but a call (S3735), no unused import (S1128), no import that is only handed on instead of `export ... from` (S7763), no prop of a component's props type the component never reads (S6767, front end), no `<video>` or `<audio>` without a captions track unless muted (S4084, front end), and, through typescript-eslint with type information, no deprecated API (S1874), no object turned into text (S6551) and no `async` function that never awaits and returns no promise (S7503). No function takes more than seven parameters (S107), except the constructor of an injectable class, whose parameters are its injected dependencies. Every other rule of the Sonar way TypeScript profile with an ESLint equivalent that reports what the server reports on the same source (scripts/lib/sonar-way.mjs: eslint-plugin-sonarjs' Sonar way set and the core and unicorn rules it builds on) is on at error in both canons (S7785, top-level await, runs in the front end only: a back end compiles to CommonJS); a port that disagrees with the server is fixed in the source it reports, never left out. The files a unit or an end-to-end spec names are outside the scan, so outside these rules."
3758
3758
  kinds: [lint, codemod]
3759
3759
  gates: [pre-commit, settle, land, ci]
3760
3760
  failureCodes: ["SCAN_SMELL"]
@@ -3781,10 +3781,10 @@ rules:
3781
3781
  code: "RT_GATE_LOOSENING"
3782
3782
  editions: [lite, full]
3783
3783
  docs: [docs/debugging.md#r236]
3784
- templateNa: "Runtime release-history approval is checked against committed gates and owner rulings; product scaffolding has no runtime release history to generate."
3784
+ templateNa: "Runtime release history is judged against the committed gates of the last release; product scaffolding has no runtime release history to generate."
3785
3785
  specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; this code is enforced by runtime code and its spec proof was a deleted runtime spec"
3786
3786
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
3787
- law: "A change that loosens a gate or check is owner-class: a check removed from a gate file's list, a floor lowered or a ceiling raised there, an allowlist entry added, a spec deleted or weakened or skipped together with product code (modules/kernel/gate-loosening.yaml). It lands, and passes the check, only when the tree it is judged against already holds the owner's approval of exactly that loosening, an owner-rulings entry gate-loosening-<fingerprint>."
3787
+ law: "A change that loosens a gate or check is always red: a check removed from a gate file's list, a floor lowered or a ceiling raised there, an allowlist entry added, a spec deleted or weakened or skipped together with product code (modules/kernel/gate-loosening.yaml). It never lands and never passes the check; there is no approval route, the owner's included (owner:loosening-always-red), and the fix is to restore the gate. An entry that only moves with a git rename of the paths it names is no loosening."
3788
3788
  scope: runtime
3789
3789
  kinds: [check]
3790
3790
  gates: [runtime]
@@ -4254,7 +4254,7 @@ rules:
4254
4254
  - {"path":"packages/hfs/templates/be/inbox/webhook-door.ts.tpl","editions":["lite","full"]}
4255
4255
  - {"path":"packages/hfs/templates/be/inbox/service.ts.tpl","editions":["lite","full"]}
4256
4256
  - {"path":"examples/lite-app/be/src/features/webhooks/calendar/transport/http/calendar.webhook.ts","edition":"lite"}
4257
- - {"path":"examples/ecommerce-app/be/src/features/webhooks/sepay/transport/http/sepay.webhook.ts","edition":"full"}
4257
+ - {"path":"examples/full-app/be/src/features/webhooks/sepay/transport/http/sepay.webhook.ts","edition":"full"}
4258
4258
  - {"path":"packages/eslint/be/webhook-handoff-inbox.spec.mjs","editions":["lite","full"],"codes":["BE_WEBHOOK_INBOX_KEY"]}
4259
4259
  proves:
4260
4260
  - {"path":"packages/eslint/be/webhook-handoff-scope.spec.mjs","editions":["lite","full"],"codes":["BE_WEBHOOK_SCOPE"]}
@@ -4279,7 +4279,7 @@ rules:
4279
4279
  exemplifiedBy:
4280
4280
  - {"path":"packages/hfs/templates/be/skeleton/src/features/api/system-health/application/check-liveness.contracts.ts","editions":["lite","full"]}
4281
4281
  - {"path":"examples/lite-app/be/src/features/api/bookings/application/create-booking.contracts.ts","edition":"lite"}
4282
- - {"path":"examples/ecommerce-app/be/src/features/api/health/application/check-health.contracts.ts","edition":"full"}
4282
+ - {"path":"examples/full-app/be/src/features/api/health/application/check-health.contracts.ts","edition":"full"}
4283
4283
  proves:
4284
4284
  - {"path":"packages/eslint/be/feature-thin.spec.mjs","editions":["lite","full"],"codes":["HFS_FEATURE_LOGIC_ROLE"]}
4285
4285
  witness:
@@ -4301,7 +4301,7 @@ rules:
4301
4301
  - {"path":"packages/hfs/templates/be/skeleton-lite/apps/api/src/compose/platform.ts","edition":"lite"}
4302
4302
  - {"path":"packages/hfs/templates/be/skeleton/apps/__app__/src/compose/platform.ts","edition":"full"}
4303
4303
  - {"path":"examples/lite-app/be/apps/api/src/compose/platform.ts","edition":"lite"}
4304
- - {"path":"examples/ecommerce-app/be/apps/order/src/compose/platform.ts","edition":"full"}
4304
+ - {"path":"examples/full-app/be/apps/order/src/compose/platform.ts","edition":"full"}
4305
4305
  proves:
4306
4306
  - {"path":"packages/eslint/be/app-composition.spec.mjs","editions":["lite","full"],"codes":["BE_COMPOSITION_UNTYPED"]}
4307
4307
  witness:
@@ -4453,7 +4453,7 @@ rules:
4453
4453
  exemplifiedBy:
4454
4454
  - {"path":"packages/hfs/templates/be/tool-config/jest.config.js","editions":["lite","full"]}
4455
4455
  - {"path":"examples/lite-app/be/jest.config.js","edition":"lite"}
4456
- - {"path":"examples/ecommerce-app/be/jest.config.js","edition":"full"}
4456
+ - {"path":"examples/full-app/be/jest.config.js","edition":"full"}
4457
4457
  specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; this code is enforced by runtime code and its spec proof was a deleted runtime spec"
4458
4458
 
4459
4459
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
@@ -4794,7 +4794,7 @@ rules:
4794
4794
  - {path: packages/eslint/fe/function-budget.spec.mjs, editions: [lite, full], codes: [HFS_FUNCTION_BUDGET]}
4795
4795
  - {path: packages/hfs/templates/be/patterns/api/graphql.resolver.ts.tpl, editions: [lite, full]}
4796
4796
  - {path: examples/lite-app/be/src/features/api/bookings/application/create-booking.handler.ts, editions: [lite]}
4797
- - {path: examples/ecommerce-app/be/src/features/api/checkout/application/add-cart-item.handler.ts, editions: [full]}
4797
+ - {path: examples/full-app/be/src/features/api/checkout/application/add-cart-item.handler.ts, editions: [full]}
4798
4798
  proves:
4799
4799
  - {path: packages/eslint/be/function-budget.spec.mjs, editions: [lite, full], codes: [HFS_FUNCTION_BUDGET]}
4800
4800
  - {path: packages/eslint/fe/function-budget.spec.mjs, editions: [lite, full], codes: [HFS_FUNCTION_BUDGET]}
@@ -4815,7 +4815,7 @@ rules:
4815
4815
  exemplifiedBy:
4816
4816
  - {path: packages/hfs/templates/be/patterns/api, editions: [lite, full]}
4817
4817
  - {path: examples/lite-app/be/src/features/api/bookings, editions: [lite]}
4818
- - {path: examples/ecommerce-app/be/src/features/api/checkout, editions: [full]}
4818
+ - {path: examples/full-app/be/src/features/api/checkout, editions: [full]}
4819
4819
  specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; this code is enforced by runtime code and its spec proof was a deleted runtime spec"
4820
4820
 
4821
4821
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
@@ -4876,7 +4876,7 @@ rules:
4876
4876
  - {path: packages/hfs/templates/be/patterns/api/feature.module-definition.ts.tpl, edition: lite}
4877
4877
  - {path: packages/hfs/templates/be/patterns/api/graphql.module-definition.ts.tpl, edition: lite}
4878
4878
  - {path: examples/lite-app/be/src/features/api/bookings/transport/graphql/bookings-graphql.module-definition.ts, edition: lite}
4879
- - {path: examples/ecommerce-app/be/src/features/api/checkout/checkout.module-definition.ts, edition: full}
4879
+ - {path: examples/full-app/be/src/features/api/checkout/checkout.module-definition.ts, edition: full}
4880
4880
  - {path: packages/eslint/be/module-shape.spec.mjs, editions: [lite, full]}
4881
4881
  proves:
4882
4882
  - {path: packages/eslint/be/module-shape.spec.mjs, editions: [lite, full], codes: [BE_MODULE_DEFINITION_MISSING]}
@@ -4895,7 +4895,7 @@ rules:
4895
4895
  - {path: docs/application-stacks.md#r296, editions: [lite, full]}
4896
4896
  exemplifiedBy:
4897
4897
  - {path: examples/lite-app/.starcistacks/application-stacks.yaml, edition: lite}
4898
- - {path: examples/ecommerce-app/.starcistacks/application-stacks.yaml, edition: full}
4898
+ - {path: examples/full-app/.starcistacks/application-stacks.yaml, edition: full}
4899
4899
  - {path: examples/shape-slot/.starcistacks/application-stacks.yaml, edition: full}
4900
4900
  - {path: packages/hfs/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml, edition: lite}
4901
4901
  - {path: packages/hfs/templates/app/skeleton/.starcistacks/application-stacks.yaml, edition: full}
@@ -4914,7 +4914,7 @@ rules:
4914
4914
  - {path: docs/application-stacks.md#r297, editions: [lite, full]}
4915
4915
  exemplifiedBy:
4916
4916
  - {path: examples/lite-app/.starcistacks/application-stacks.yaml, edition: lite}
4917
- - {path: examples/ecommerce-app/.starcistacks/application-stacks.yaml, edition: full}
4917
+ - {path: examples/full-app/.starcistacks/application-stacks.yaml, edition: full}
4918
4918
  - {path: examples/shape-slot/.starcistacks/application-stacks.yaml, edition: full}
4919
4919
  - {path: packages/hfs/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml, edition: lite}
4920
4920
  - {path: packages/hfs/templates/app/skeleton/.starcistacks/application-stacks.yaml, edition: full}
@@ -4933,7 +4933,7 @@ rules:
4933
4933
  - {path: docs/application-stacks.md#r298, editions: [lite, full]}
4934
4934
  exemplifiedBy:
4935
4935
  - {path: examples/lite-app/.starcistacks/application-stacks.yaml, edition: lite}
4936
- - {path: examples/ecommerce-app/.starcistacks/application-stacks.yaml, edition: full}
4936
+ - {path: examples/full-app/.starcistacks/application-stacks.yaml, edition: full}
4937
4937
  - {path: examples/shape-slot/.starcistacks/application-stacks.yaml, edition: full}
4938
4938
  - {path: packages/hfs/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml, edition: lite}
4939
4939
  - {path: packages/hfs/templates/app/skeleton/.starcistacks/application-stacks.yaml, edition: full}
@@ -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.4.0
10
+ version: 3.5.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).
@@ -39,6 +39,8 @@ versioning:
39
39
  # when the file key is absent; .starcistacks owns custody. Capability <capability>.config.ts roles stay unchanged.
40
40
  # 3.3.0 is a minor (alpha.16): it ADDS the optional app.ci-stack-deploy slot, the managed workflow of the starci app stack verbs.
41
41
  # 3.4.0 is a minor (alpha.25): it ADDS the opt-in be.contract.mcp-tools slot, the committed MCP tool-catalog snapshot of an app.
42
+ # 3.5.0 is a minor (alpha.27): it ADDS the opt-in app.browser-config slot, the managed browser/tsconfig.json that type-checks the browser
43
+ # journey (enabled with app.browser by `browser: true`).
42
44
 
43
45
  # Field meaning, per slot.
44
46
  # id stable name, never changes meaning inside a major.
@@ -617,6 +619,23 @@ slots:
617
619
  tests: none
618
620
  requires: [playwright.config.ts, journeys/]
619
621
  allows: [playwright.config.ts, stack.mjs, "journeys/*.ts"]
622
+ - id: app.browser-config
623
+ since: 3.5.0
624
+ profiles: [app]
625
+ editionWhy: "ruling:R212/case-2 (with the Work boundary amended by ruling:R212/case-4): browser journeys belong to full; lite proves app boot through R272 wiring."
626
+ # The program that type-checks the browser journey: Playwright only transpiles the config and the journeys, so without it nothing
627
+ # type-checks them and the op gate finds no tsconfig.json owning a changed journey (GATE_TSC_CONFIG_MISSING). It is managed (rendered
628
+ # by starci app sync from templates/app/browser-config, extending @starci/tsconfig/e2e.json), never authored, and enabled with
629
+ # app.browser by `browser: true` in hfs.json.
630
+ path: browser/tsconfig.json
631
+ presence: opt-in
632
+ litePresence: forbidden
633
+ goesTo: "the full edition: a lite app has no browser journey to type-check"
634
+ tracked: tracked
635
+ tier: none
636
+ tests: none
637
+ managedBy: browser-config
638
+ rules: [HFS_MANAGED_FILE_DRIFT]
620
639
  - id: app.github-meta
621
640
  since: 3.1.0
622
641
  profiles: [app]