@starci/hfs 4.7.12 → 4.7.14

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 (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/release/gate.mjs +388 -378
  5. package/release/lint-report.mjs +52 -0
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
  8. package/runtime/knowledge/hfs/rules.yaml +7 -6
  9. package/runtime/knowledge/patterns/be/cli.yaml +1 -0
  10. package/runtime/knowledge/patterns/be/realtime.yaml +1 -0
  11. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -0
  12. package/runtime/modules/kernel/failure-codes.yaml +24 -2
  13. package/runtime/packages/cli/src/catalog.generated.mjs +12 -6
  14. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  15. package/runtime/scripts/hfs/architecture/hfs.mjs +19 -4
  16. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
  17. package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
  18. package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
  19. package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
  20. package/runtime/scripts/hfs/doc-commands.mjs +2 -1
  21. package/runtime/scripts/hfs/rule-catalog.mjs +5 -2
  22. package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
  23. package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
  24. package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
  25. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
  27. package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
  28. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
  29. package/runtime/scripts/hfs/rules/doc-fresh.mjs +18 -3
  30. package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
  31. package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
  32. package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
  33. package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
  34. package/runtime/scripts/hfs/rules/release-provenance.mjs +8 -5
  35. package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
  36. package/runtime/scripts/hfs/rules/services.mjs +19 -5
  37. package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
  38. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
  39. package/runtime/scripts/hfs/rules/staged-specs.mjs +26 -0
  40. package/runtime/scripts/hfs/rules/supply-chain.mjs +3 -3
  41. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
  43. package/runtime/scripts/hfs/sealed-members.mjs +1 -1
  44. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
  45. package/runtime/scripts/hfs/secret-files.mjs +1 -1
  46. package/runtime/scripts/hfs/secret-sops.mjs +1 -1
  47. package/runtime/scripts/hfs/stack-deploy-config.mjs +19 -2
  48. package/runtime/scripts/lib/env.mjs +2 -1
  49. package/runtime/scripts/lib/git-hooks-dir.mjs +30 -0
  50. package/runtime/scripts/lib/path-key.mjs +1 -1
  51. package/runtime/scripts/lib/roots.mjs +3 -3
  52. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  53. package/runtime/scripts/lib/swarm-stack.mjs +4 -2
  54. package/src/main.mjs +2 -2
  55. package/sync/ci-commands.mjs +275 -275
  56. package/sync/hygiene.mjs +19 -4
  57. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
@@ -0,0 +1,52 @@
1
+ // lint-report.mjs - the external issues report the release gate's sonar row produces before it runs the scanner.
2
+ // The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `lint --side <side> --sonar reports/lint.<side>.sonar.json`
3
+ // per lint matrix side and a "merge Sonar lint reports" step. This module does the same locally, through the same process seam as every gate
4
+ // (an argument array through ctx.spawn, never a shell). The merge below is the one in packages/hfs/sync/ci-commands.mjs (sonarMergeSteps): keep them identical.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { firstLine, tool } from '../stack/context.mjs';
8
+
9
+ const LINT_SIDES = ['be', 'fe'];
10
+ const MERGED_REPORT = 'reports/lint.sonar.json';
11
+ const sideReport = (side) => `reports/lint.${side}.sonar.json`;
12
+
13
+ /** CI merge: rules unique by id (last one wins, first position kept), issues concatenated in side order. */
14
+ function mergeSonarReports(reports) {
15
+ const rules = [...new Map(reports.flatMap((report) => report.rules).map((rule) => [rule.id, rule])).values()];
16
+ const issues = reports.flatMap((report) => report.issues);
17
+ return { rules, issues };
18
+ }
19
+
20
+ /** The sides the app has: a be/ or fe/ folder beside the app root. */
21
+ const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
22
+
23
+ /**
24
+ * Runs `starci app lint --side <side> --sonar <file>` for every side the app has, then writes reports/lint.sonar.json.
25
+ * `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
26
+ * A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
27
+ */
28
+ export function produceLintReports(ctx, { launch, platform, env }) {
29
+ const sides = sidesOf(ctx.cwd);
30
+ const out = [];
31
+ if (sides.length === 0) return { pass: false, text: `no be/ or fe/ side in ${ctx.cwd}: cannot produce ${MERGED_REPORT}\n` };
32
+ fs.mkdirSync(path.join(ctx.cwd, 'reports'), { recursive: true });
33
+ for (const side of sides) {
34
+ const file = path.join(ctx.cwd, sideReport(side));
35
+ fs.rmSync(file, { force: true });
36
+ const argv = ['npx', 'starci', 'app', 'lint', '--side', side, '--sonar', sideReport(side)];
37
+ const [command, args] = launch(argv, platform);
38
+ out.push(`$ ${argv.join(' ')}\n`);
39
+ const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
40
+ out.push(`${run.stdout ?? ''}${run.stderr ?? ''}`);
41
+ if (run.error) return { pass: false, text: `${out.join('')}\nlint ${side} cannot run (${run.error.message})\n` };
42
+ if (run.status !== 0) return { pass: false, text: `${out.join('')}\nlint ${side} failed: exit ${run.status}; the scanner was not run\n` };
43
+ if (!fs.existsSync(file)) return { pass: false, text: `${out.join('')}\nlint ${side} wrote no ${sideReport(side)}; the scanner was not run\n` };
44
+ }
45
+ try {
46
+ const reports = sides.map((side) => JSON.parse(fs.readFileSync(path.join(ctx.cwd, sideReport(side)), 'utf8')));
47
+ fs.writeFileSync(path.join(ctx.cwd, MERGED_REPORT), `${JSON.stringify(mergeSonarReports(reports), null, 2)}\n`);
48
+ } catch (error) {
49
+ return { pass: false, text: `${out.join('')}\ncannot merge the side reports: ${firstLine(error.message)}\n` };
50
+ }
51
+ return { pass: true, text: `${out.join('')}\nwrote ${MERGED_REPORT} from ${sides.map(sideReport).join(', ')}\n\n` };
52
+ }
@@ -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.18
32
+ version: 3.4.22
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.18
39
+ version: 8.4.21
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
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.12
79
+ version: 1.7.14
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.12
86
+ version: 4.7.14
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -27,7 +27,7 @@ defaults:
27
27
  # The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
28
28
  heap:
29
29
  minMiB: 6144
30
- steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from']
30
+ steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from', 'npm run db:lint']
31
31
 
32
32
  items:
33
33
  - id: candidate-trigger
@@ -19,7 +19,7 @@
19
19
  schema: starci/hfs-rules@3
20
20
  version: 3.1.0
21
21
  gates:
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
22
+ pre-commit: the managed .husky/pre-commit at the app root - starci app hygiene (staged .starciwork and .starcistacks paths, secrets guard, a new back-end logic file staged without its unit spec), 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
24
24
  settle: the op gate `starci gate run` over the op's changed files (merge guard, starci app lint --changed, codegen, tsc, the slice's specs; only findings new against the workflow's previous checkpoint block), re-read at starci kernel settle, which refuses a red done; a green settle commits the op's checkpoint on wf-<workflowId>
25
25
  land: the workflow's finish, the only time main is touched - gate.mjs over the whole workflow branch against its merge-base with main, the merge guard, review.verify of the exact head that lands, the branch rebased onto main, then main fast-forwarded and pushed
@@ -205,10 +205,10 @@ rules:
205
205
  - {path: packages/hfs/templates/app/ci-workflows, edition: full}
206
206
  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"}
207
207
  code: "HFS_CI_MISSING_CANON"
208
- law: "CI runs the pinned `starci app lint` (`npm run lint`), and a clone never redirects `core.hooksPath` away from husky."
208
+ law: "CI runs the pinned `starci app lint` (`npm run lint`), a clone never redirects `core.hooksPath` away from husky, and the folder a set `core.hooksPath` names holds the installed hooks in every checkout (husky's dispatcher in `.husky/_`), so no commit lands without the pre-commit gate."
209
209
  kinds: [check, codemod]
210
210
  gates: [land, ci]
211
- failureCodes: ["HFS_CI_MISSING_CANON", "HFS_HOOKS_PATH_REDIRECTED"]
211
+ failureCodes: ["HFS_CI_MISSING_CANON", "HFS_HOOKS_PATH_REDIRECTED", "HFS_HOOKS_NOT_INSTALLED"]
212
212
  enforcers:
213
213
  - {kind: machine, id: hooks-path-not-redirected, at: scripts/hfs/architecture/hfs.mjs}
214
214
  - {kind: hfs, id: ci-missing-canon, at: scripts/hfs/rules/pipeline.mjs}
@@ -846,12 +846,13 @@ 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, 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 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`."
850
850
  kinds: [check, codemod, lint]
851
851
  gates: [land, ci]
852
- failureCodes: ["BE_TEST_TOPOLOGY", "BE_CONTRACT_UNGUARDED"]
852
+ failureCodes: ["BE_TEST_TOPOLOGY", "BE_CONTRACT_UNGUARDED", "BE_UNIT_SPEC_NOT_STAGED"]
853
853
  enforcers:
854
854
  - {kind: hfs, id: test-topology, at: scripts/hfs/rules/test-topology.mjs}
855
+ - {kind: hfs, id: staged-spec-presence, at: scripts/hfs/rules/staged-specs.mjs}
855
856
  - {kind: machine, id: test-kind-unsupported, at: scripts/hfs/architecture/hfs.mjs}
856
857
  - {kind: eslint-be, id: test-world-files}
857
858
  - {kind: machine, id: contract-fixture-guard, at: scripts/hfs/architecture/contract-fixture-guard.mjs}
@@ -2710,7 +2711,7 @@ rules:
2710
2711
  - {path: packages/hfs/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml, edition: lite}
2711
2712
  - {path: packages/hfs/templates/app/skeleton/.starcistacks/application-stacks.yaml, edition: full}
2712
2713
  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"}
2713
- law: "Every api or worker app `be/apps/<service>` of a product with more than one service is a component of the same name with `role: service` in `.starcistacks/application-stacks.yaml`."
2714
+ law: "Every api or worker app `be/apps/<service>` of a product with more than one service is a `role: service` component in `.starcistacks/application-stacks.yaml`: the component of the same name, or one whose `environments.<env>.deploy.images.<component>.app` names the app."
2714
2715
  kinds: [check]
2715
2716
  gates: [land, ci]
2716
2717
  failureCodes: ["HFS_SERVICE_STACK_DECLARATION"]
@@ -133,6 +133,7 @@ rules:
133
133
  - BE_CLI_COMMAND_SHAPE
134
134
  - BE_TEST_TOPOLOGY
135
135
  - BE_CONTRACT_UNGUARDED
136
+ - BE_UNIT_SPEC_NOT_STAGED
136
137
  - BE_SPEC_PLACEMENT
137
138
  - BE_FEATURE_THIN
138
139
  manual:
@@ -76,6 +76,7 @@ rules:
76
76
  - HFS_README_BADGE_NOT_LIVE
77
77
  - BE_TEST_TOPOLOGY
78
78
  - BE_CONTRACT_UNGUARDED
79
+ - BE_UNIT_SPEC_NOT_STAGED
79
80
  - BE_REALTIME_SHAPE
80
81
  manual:
81
82
  - Confirm no query or mutation lives in a subscription door; it lives in an api feature.
@@ -82,6 +82,7 @@ rules:
82
82
  - HFS_README_BADGE_NOT_LIVE
83
83
  - BE_TEST_TOPOLOGY
84
84
  - BE_CONTRACT_UNGUARDED
85
+ - BE_UNIT_SPEC_NOT_STAGED
85
86
  manual:
86
87
  - Confirm the provider endpoint is one door and that a second endpoint of the same provider is a second door only when it has a different proof.
87
88
  relatedExamples: [webhooks]
@@ -1605,6 +1605,17 @@ BE_TYPE_ESCAPE:
1605
1605
  owner: op-retry
1606
1606
  kind: check-finding
1607
1607
 
1608
+ BE_UNIT_SPEC_NOT_STAGED:
1609
+ title: "A new back-end logic file is committed without its unit spec"
1610
+ title_vi: "Tệp logic back-end mới được commit mà không có unit spec"
1611
+ meaning_vi: "Commit thêm `<file>`, một tệp `<name>.<role>.ts` có vai trò logic mà unit coverage đo ở mức 100 mỗi tệp, nhưng index không có `<name>.<role>.spec.ts` bên cạnh nó: mã được viết trước, spec để sau."
1612
+ causes_vi:
1613
+ - "Agent viết mã trước rồi định sửa lint và spec sau, thay vì chạy vòng op `starci gate read` → mã + spec → `starci gate run`"
1614
+ - "Spec đã viết nhưng chưa được stage cùng tệp"
1615
+ nextStep_vi: "Viết `<name>.<role>.spec.ts` bên cạnh tệp trong cùng thay đổi, chạy `starci gate run --root <app> --changed <files> --tests <pattern>`, rồi stage cả hai tệp và commit lại qua hook. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra; không thêm ngoại lệ."
1616
+ owner: op-retry
1617
+ kind: check-finding
1618
+
1608
1619
  BE_UNNAMED_DATA_ACCESS:
1609
1620
  title: "Database reached without the named shared EntityManager"
1610
1621
  title_vi: "Truy cập cơ sở dữ liệu không qua EntityManager dùng chung có tên"
@@ -3475,6 +3486,17 @@ HFS_GITIGNORE_BLOCK_DRIFT:
3475
3486
  owner: op-retry
3476
3487
  kind: check-finding
3477
3488
 
3489
+ HFS_HOOKS_NOT_INSTALLED:
3490
+ title: "The hooks folder core.hooksPath names is missing or empty in this checkout"
3491
+ title_vi: "Hook Git chưa được cài trong bản checkout này"
3492
+ meaning_vi: "`core.hooksPath` trỏ tới `.husky/_` (hoặc `.husky`) nhưng thư mục đó không tồn tại hoặc không có hook đã cài (không có tệp điều phối `h` của husky) trong bản checkout này, nên Git không chạy pre-commit và pre-push: mọi commit bỏ qua lint và spec. Một worktree dùng chung cấu hình Git của clone chính nhưng `.husky/_` chỉ được husky tạo ở nơi đã cài phụ thuộc có chạy lifecycle script."
3493
+ causes_vi:
3494
+ - "Cài phụ thuộc với `--ignore-scripts` nên script `prepare` (husky) không chạy"
3495
+ - "Worktree mới tạo chưa từng chạy husky; runtime chỉ ghi hook riêng của nó vào `.husky/_`"
3496
+ nextStep_vi: "Chạy `starci npm ci` ở gốc app (lệnh này chạy trình cài hook husky `prepare` của app kể cả khi lifecycle script bị bỏ qua), rồi commit lại qua hook; không dùng `--no-verify`. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra."
3497
+ owner: op-retry
3498
+ kind: check-finding
3499
+
3478
3500
  HFS_HOOKS_PATH_REDIRECTED:
3479
3501
  title: "A clone redirects core.hooksPath away from husky"
3480
3502
  title_vi: "core.hooksPath bị đổi hướng khỏi husky"
@@ -3962,10 +3984,10 @@ HFS_SERVICE_ROLE_SCOPE:
3962
3984
  HFS_SERVICE_STACK_DECLARATION:
3963
3985
  title: "Each service app is a service component of the stack"
3964
3986
  title_vi: "Ứng dụng dịch vụ chưa được khai báo trong stack"
3965
- meaning_vi: "Một ứng dụng api hoặc worker dưới `be/apps/<service>/` của sản phẩm nhiều dịch vụ không có thành phần `role: service` cùng tên trong `.starcistacks/application-stacks.yaml`."
3987
+ meaning_vi: "Một ứng dụng api hoặc worker dưới `be/apps/<service>/` của sản phẩm nhiều dịch vụ không có thành phần `role: service` trong `.starcistacks/application-stacks.yaml`: không có thành phần cùng tên, và không có thành phần nào được `environments.<env>.deploy.images.<component>.app` gắn với ứng dụng đó."
3966
3988
  causes_vi:
3967
3989
  - "Vi phạm luật R165: mọi dịch vụ của sản phẩm nhiều dịch vụ là một thành phần `role: service` của stack (image riêng `<repo>/<service>:<x.y.z>`, build từ Dockerfile của app)."
3968
- nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra: thêm thành phần cùng tên app (role service, image đã ghim, file compose và cổng của môi trường)."
3990
+ nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra: thêm thành phần cùng tên app (role service, image đã ghim, file compose và cổng của môi trường), hoặc gắn thành phần dịch vụ đang chạy với app qua `environments.<env>.deploy.images.<component>.app` thay vì đổi tên dịch vụ."
3969
3991
  owner: op-retry
3970
3992
  kind: check-finding
3971
3993
 
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "8913c2eedad972f96410ed412622ca511f32f9a4f2b5fb8104b745e310007ac5",
4
+ "hash": "1ac457c26ee05922e910a56fab64d9bdc9b0bc251466dd4a2f341bf698ff3e73",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -1039,7 +1039,7 @@ export const CATALOG = {
1039
1039
  "runtimeSide": "host"
1040
1040
  },
1041
1041
  "hygiene": {
1042
- "summary": "judge staged worktree metadata, stack files and plaintext secrets",
1042
+ "summary": "judge staged worktree metadata, stack files, plaintext secrets and new logic files without a spec",
1043
1043
  "owner": "@starci/hfs",
1044
1044
  "flags": [],
1045
1045
  "exit": {
@@ -7872,7 +7872,7 @@ export const CATALOG = {
7872
7872
  ],
7873
7873
  "exit": {
7874
7874
  "0": "the clean lockfile was installed",
7875
- "1": "npm or the host lock failed",
7875
+ "1": "npm, the host lock or the git hooks install failed",
7876
7876
  "2": "the worktree or install state was refused"
7877
7877
  },
7878
7878
  "json": "starci/npm-ci@1",
@@ -7898,7 +7898,10 @@ export const CATALOG = {
7898
7898
  "conventions": [
7899
7899
  "npm ci installs the lockfile into this worktree's real node_modules and never uses a junction or symbolic link",
7900
7900
  "package.json and package-lock.json must be clean before the run",
7901
- "dependency installs run one at a time under the host lock and never use npm install"
7901
+ "dependency installs run one at a time under the host lock and never use npm install",
7902
+ "after an install, a core.hooksPath naming a missing or empty folder runs the root husky prepare (npm run prepare), lifecycle scripts ignored or not",
7903
+ "the receipt's hooks field reports the git hooks as installed, repaired, not-set or not-declared",
7904
+ "a hooks path still naming a missing or empty folder after the installer ran fails the verb"
7902
7905
  ],
7903
7906
  "runtimeSide": "host"
7904
7907
  },
@@ -7926,7 +7929,7 @@ export const CATALOG = {
7926
7929
  ],
7927
7930
  "exit": {
7928
7931
  "0": "exact dependencies and any requested canon pins were installed",
7929
- "1": "npm, Git, target canon loading or the host lock failed",
7932
+ "1": "npm, Git, target canon loading, the host lock or the git hooks install failed",
7930
7933
  "2": "the package, target pin or worktree was refused"
7931
7934
  },
7932
7935
  "json": "starci/npm-install@1",
@@ -7958,7 +7961,10 @@ export const CATALOG = {
7958
7961
  "target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
7959
7962
  "each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
7960
7963
  "a failed canon phase exits nonzero even when the first CLI install succeeded",
7961
- "installs always use --save-exact and report the package.json and package-lock.json diff stat"
7964
+ "installs always use --save-exact and report the package.json and package-lock.json diff stat",
7965
+ "after an install, a core.hooksPath naming a missing or empty folder runs the root husky prepare (npm run prepare), lifecycle scripts ignored or not",
7966
+ "the receipt's hooks field reports the git hooks as installed, repaired, not-set or not-declared",
7967
+ "a hooks path still naming a missing or empty folder after the installer ran fails the verb"
7962
7968
  ],
7963
7969
  "runtimeSide": "host"
7964
7970
  }
@@ -106,7 +106,7 @@ function queueMethods(env, declaration, constructor, excluded) {
106
106
  && decoratorsHave(env, parameter, 'InjectQueueOutbox', '@modules/platform/queue'));
107
107
  if (!outbox || !env.ts.isIdentifier(outbox.name)) return;
108
108
  for (const method of declaration.members) {
109
- if (!env.ts.isMethodDeclaration(method) || !method.body || method.body.statements.length !== 1) continue;
109
+ if (!env.ts.isMethodDeclaration(method) || method.body?.statements.length !== 1) continue;
110
110
  const call = statementCall(env.ts, method.body.statements[0]);
111
111
  if (call && env.ts.isCallExpression(call) && receiverMember(env.ts, call) === outbox.name.text && call.expression.name.text === 'write'
112
112
  && call.arguments.length === 3 && call.arguments.every(argument => mapping(env, argument))) excluded.add(method);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { lsFiles } from '../../api/git/ls-files.mjs'; import { revParseQuery } from '../../api/git/rev-parse-query.mjs'; import { configGet } from '../../api/git/config-get.mjs'; import { gitOutputOf } from '../../lib/git.mjs';
4
4
  import { braceVariants } from '../../lib/glob.mjs';
5
+ import { hooksDirState } from '../../lib/git-hooks-dir.mjs';
5
6
  import { createSlotResolver, loadSlotManifest, openHfs } from '../slots.mjs';
6
7
  import { isFeTestPath } from '../rules/fe-no-tests.mjs';
7
8
  import { byCodeUnit } from '../../lib/list.mjs';
@@ -21,6 +22,7 @@ export const HFS_RULE_IDS = [
21
22
  'HFS_APPS_REQUIRED',
22
23
  'HFS_APP_LAYOUT_INVALID',
23
24
  'HFS_E2E_IN_AUTOMATIC_GATE',
25
+ 'HFS_HOOKS_NOT_INSTALLED',
24
26
  'HFS_HOOKS_PATH_REDIRECTED',
25
27
  'HFS_MODULE_TIER_INVALID',
26
28
  'HFS_PACKAGE_MANAGER_MIXED',
@@ -114,15 +116,28 @@ function ownsGitTopLevel(root) {
114
116
  }
115
117
  }
116
118
 
117
- /** A directory that is not the top level of its own work tree has no hooks of its own: the check is not applicable there. */
119
+ /**
120
+ * The hooks of the checked root: core.hooksPath stays husky's (HFS_HOOKS_PATH_REDIRECTED), and the folder it names holds
121
+ * installed hooks in this checkout (HFS_HOOKS_NOT_INSTALLED, scripts/lib/git-hooks-dir.mjs). An unset key judges nothing: a CI
122
+ * checkout installs without hooks. A directory that is not the top level of its own work tree has no hooks of its own: the
123
+ * check is not applicable there.
124
+ */
118
125
  function hooksPathNotRedirected({ root, finding }) {
119
126
  if (!ownsGitTopLevel(root)) return { status: 'not-applicable', reason: 'the checked root is not the top level of its own Git work tree, so it has no hooks of its own' };
120
127
  const set = configGet(root, 'core.hooksPath', { local: true });
121
128
  if (!set.ok) return { status: 'checked' }; // key not set: nothing is redirected
122
- const value = set.stdout;
123
- if (value && !HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/')))
129
+ const value = String(set.stdout ?? '').trim();
130
+ if (!value) return { status: 'checked' };
131
+ if (!HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/'))) {
124
132
  finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
125
- return { status: 'checked' };
133
+ return { status: 'checked' };
134
+ }
135
+ const hooks = hooksDirState(root, value);
136
+ if (hooks.state !== 'installed') {
137
+ const what = { missing: 'does not exist', empty: 'holds no installed hook (husky never ran here)', unreadable: `cannot be read (${hooks.error})` }[hooks.state];
138
+ finding('HFS_HOOKS_NOT_INSTALLED', value, `core.hooksPath is ${value}, but that folder ${what} in this checkout, so git runs no pre-commit or pre-push gate and a commit skips lint and specs. Install the hooks before committing: run \`starci npm ci\` at the app root (it runs the app's hook installer \`npm run prepare\` even when lifecycle scripts are ignored), or \`npm run prepare\`; an install with --ignore-scripts alone never installs them.`);
139
+ }
140
+ return { status: 'checked', hooks: hooks.state };
126
141
  }
127
142
 
128
143
  /** The tracked paths under the given root, relative to it: the pathspec keeps a nested directory from listing the paths of its enclosing clone. */
@@ -62,11 +62,13 @@ function catalogsPerApp(config, graph) {
62
62
 
63
63
  /** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
64
64
  function templatePatternOf(node) {
65
- return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
66
- if (!segment.includes('\u0000')) return segment;
65
+ const pattern = [];
66
+ for (const segment of [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.')) {
67
+ if (!segment.includes('\u0000')) { pattern.push(segment); continue; }
67
68
  const parts = segment.split('\u0000');
68
- return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su');
69
- });
69
+ pattern.push(parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su'));
70
+ }
71
+ return pattern;
70
72
  }
71
73
 
72
74
  function patternsOf(ts, files) {
@@ -94,7 +94,7 @@ function moduleSpecifierOf(ts, node) {
94
94
  function isApolloClientConstruction(ts, kit, checker, node) {
95
95
  if (!ts.isNewExpression(node)) return false;
96
96
  const binding = kit.importBinding(checker, node.expression);
97
- return Boolean(binding && binding.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
97
+ return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
98
98
  }
99
99
 
100
100
  function inspectLibraryImport({ ts, file, node, transport, report }) {
@@ -60,7 +60,10 @@ export function qualityServices(doc, { example = false } = {}) {
60
60
  /** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
61
61
  function entries(dir, fs) {
62
62
  try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
63
- catch (error) { if (error.code === 'ENOENT') return []; throw error; }
63
+ catch (error) {
64
+ if (error.code === 'ENOENT') return [];
65
+ throw error;
66
+ }
64
67
  }
65
68
 
66
69
  /** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
@@ -70,7 +73,7 @@ export function sealedKeyNames(root, { fs }) {
70
73
  for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
71
74
  const file = `.starcistacks/${env.name}/secrets/${member.name}`;
72
75
  const source = fs.readFileSync(path.join(root, file), 'utf8');
73
- const dotenv = [...source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
76
+ const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
74
77
  let keys = dotenv;
75
78
  if (!dotenv.length) {
76
79
  const doc = parseYaml(source);
@@ -87,7 +87,7 @@ function baselinePlan(root, opts, now) {
87
87
  if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
88
88
  const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
89
89
  const oldStamps = [...active, ...archived].map((entry) => entry.stamp);
90
- const last = oldStamps.sort((left, right) => left.localeCompare(right)).at(-1);
90
+ const last = oldStamps.toSorted((left, right) => left.localeCompare(right)).at(-1);
91
91
  const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
92
92
  if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= last) throw new DbVerbError('--stamp must be a valid UTC version after the archive boundary');
93
93
  const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
@@ -225,7 +225,10 @@ function extensionDeclaration(stmt, sql, extensions) {
225
225
 
226
226
  function baselineStatement(state, stmt, sql) {
227
227
  if (roleDeclaration(stmt, sql, state.roles) || extensionDeclaration(stmt, sql, state.extensions)) return;
228
- if (stmt.DropRoleStmt) { for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename); return; }
228
+ if (stmt.DropRoleStmt) {
229
+ for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename);
230
+ return;
231
+ }
229
232
  if (stmt.GrantRoleStmt || stmt.AlterExtensionStmt || stmt.AlterExtensionContentsStmt || /^OBJECT_ROLE$/.test(stmt.RenameStmt?.renameType ?? '')) throw new DbVerbError('baseline cannot reconstruct role membership, role renames or extension alterations');
230
233
  if (dataMutation(stmt) || sourceExecution(stmt)) throw new DbVerbError('baseline cannot reconstruct migration-owned data changes or custom execution from a schema-only dump');
231
234
  const node = Object.values(stmt)[0];
@@ -29,7 +29,8 @@ const inner = (t) => t.replace(/^<|>$/g, '');
29
29
 
30
30
  // ---- extraction -----------------------------------------------------------------------------------------------
31
31
 
32
- const COMMAND_AT = /(?<![\w@./:-])(?:npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci)(?![\w@./-])(?=\s|$)/g;
32
+ const COMMAND_FORM = /npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci/;
33
+ const COMMAND_AT = new RegExp(String.raw`(?<![\w@./:-])(?:${COMMAND_FORM.source})(?![\w@./-])(?=\s|$)`, 'g');
33
34
  const FENCE = /^\s*(?:[-*>|]\s*)?```/;
34
35
  const SCRIPT_COMMENT = /^\s*(?:\/\/|\*|\/\*)/;
35
36
  const CONTINUED = /\\\s*$/;
@@ -21,7 +21,10 @@ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'fail
21
21
  const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
22
22
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
23
23
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
24
- const CASE_REFERENCE = /^(?:case-[1-9][0-9]*|(?:ruling:)?R[0-9]{2,3}(?:\/case-[1-9][0-9]*)?|owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/;
24
+ const CASE_ID = /case-[1-9]\d*/;
25
+ const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
26
+ const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
27
+ const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
25
28
  const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
26
29
  const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
27
30
  const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
@@ -191,7 +194,7 @@ function checkRuleCases(rule, label, bad) {
191
194
  const at = `${label}.cases[${index}]`;
192
195
  if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
193
196
  for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
194
- if (typeof item.id !== 'string' || !/^case-[1-9][0-9]*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
197
+ if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
195
198
  ids.add(item.id);
196
199
  if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
197
200
  if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
@@ -12,7 +12,6 @@ import { found } from './read.mjs';
12
12
  export const HFS_COVERAGE_BELOW = 'HFS_COVERAGE_BELOW';
13
13
  const METRICS = ['lines', 'functions', 'branches'];
14
14
  const SUMMARY = { LF: ['lines', 'found'], LH: ['lines', 'hit'], FNF: ['functions', 'found'], FNH: ['functions', 'hit'], BRF: ['branches', 'found'], BRH: ['branches', 'hit'] };
15
- const SOURCE = /\.ts$/;
16
15
  const SPEC = /(?:\.(?:spec|test)|-spec)\.ts$/;
17
16
  const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
18
17
  const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
@@ -66,7 +65,10 @@ function parseLcov(text) {
66
65
  const records = [];
67
66
  const problems = [];
68
67
  let current = null;
69
- const flush = () => { if (current) records.push(current); current = null; };
68
+ const flush = () => {
69
+ if (current) records.push(current);
70
+ current = null;
71
+ };
70
72
  for (const [index, raw] of String(text).replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
71
73
  const line = raw.trim();
72
74
  if (!line) continue;
@@ -102,7 +104,7 @@ function inventory({ files, all, repo, manifest }) {
102
104
  const scope = coverageScope(effective, repo.providers ?? repo.sides?.be?.providers ?? []);
103
105
  const roots = scope.roots.map((dir) => globExpression(`${dir}**`));
104
106
  const excludes = scope.excludes.map((dir) => globExpression(`${dir}**`));
105
- const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && SOURCE.test(file) && !SPEC.test(file)).sort(byCodeUnit);
107
+ const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
106
108
  const measured = [];
107
109
  const unmeasured = [];
108
110
  for (const file of candidates) {
@@ -74,7 +74,7 @@ async function principalHelpers(analyses) {
74
74
  try { bodies.push({ name: fn.name, ast: await parseSql(body) }); } catch { /* PL/pgSQL safety is checked separately. */ }
75
75
  });
76
76
  const helpers = new Set();
77
- for (let pass = 0; pass < bodies.length; pass += 1) {
77
+ for (let remaining = bodies.length; remaining > 0; remaining -= 1) {
78
78
  for (const body of bodies) if (hasPrincipal(body.ast, helpers)) helpers.add(body.name);
79
79
  }
80
80
  return helpers;
@@ -13,7 +13,10 @@ import { fileURLToPath } from 'node:url';
13
13
  import { loadTypescript } from '../../lib/package-at.mjs';
14
14
 
15
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
16
- const MIGRATION_NAME = /^(\d{14})_([a-z0-9]+(?:-[a-z0-9]+)*|contract_[a-z0-9]+(?:[-_][a-z0-9]+)*|[a-z0-9]+(?:-[a-z0-9]+)*_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*)\.sql$/;
16
+ const MIGRATION_PLAIN = /[a-z0-9]+(?:-[a-z0-9]+)*/;
17
+ const MIGRATION_CONTRACT_TAIL = /contract_[a-z0-9]+(?:[-_][a-z0-9]+)*/;
18
+ const MIGRATION_SLUG = [MIGRATION_PLAIN.source, MIGRATION_CONTRACT_TAIL.source, `${MIGRATION_PLAIN.source}_${MIGRATION_CONTRACT_TAIL.source}`].join('|');
19
+ const MIGRATION_NAME = new RegExp(String.raw`^(\d{14})_(${MIGRATION_SLUG})\.sql$`);
17
20
  export const isMigrationName = (name) => MIGRATION_NAME.test(name);
18
21
  const RESTAMP_HEADER = /^-- starci:restamp (\d{14}) (\d{14})(?:\r?\n|$)/;
19
22
  const withoutRestampHeader = (text) => text?.replace(RESTAMP_HEADER, '') ?? null;
@@ -227,7 +230,11 @@ export function databaseSqlTokens(text) {
227
230
  let index = 0;
228
231
  let line = 1;
229
232
  while (index < text.length) {
230
- if (/\s/.test(text[index])) { if (text[index] === '\n') line += 1; index += 1; continue; }
233
+ if (/\s/.test(text[index])) {
234
+ if (text[index] === '\n') line += 1;
235
+ index += 1;
236
+ continue;
237
+ }
231
238
  const end = commentEnd(text, index);
232
239
  if (end !== null) { line += text.slice(index, end).split('\n').length - 1; index = end; continue; }
233
240
  const token = sqlTokenAt(text, index);
@@ -243,7 +250,10 @@ export function databaseSqlStatements(tokens) {
243
250
  const statements = [];
244
251
  let current = [];
245
252
  for (const token of tokens) {
246
- if (token.t === 'punct' && token.v === ';') { if (current.length) statements.push(current); current = []; }
253
+ if (token.t === 'punct' && token.v === ';') {
254
+ if (current.length) statements.push(current);
255
+ current = [];
256
+ }
247
257
  else current.push(token);
248
258
  }
249
259
  if (current.length) statements.push(current);
@@ -290,7 +290,7 @@ async function functionFindings(file, fn, facts, exposed) {
290
290
 
291
291
  function alteredDefinerFindings(file, alter, facts, exposed) {
292
292
  if (!alter.securityDefiner) return [];
293
- const created = facts.functions.filter((fn) => fn.index < alter.index && matchesAlter(fn, alter)).at(-1);
293
+ const created = facts.functions.findLast((fn) => fn.index < alter.index && matchesAlter(fn, alter));
294
294
  const node = created?.node ?? { func: alter.node.func };
295
295
  const fn = { ...alter, node, altered: true, originIndex: created?.index ?? -1 };
296
296
  return definerDeclarationFindings(file, fn, facts, exposed, true);
@@ -104,7 +104,7 @@ function inspectDo(state, node, file) {
104
104
  const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
105
105
  const tree = plpgsqlTree(option('as') ?? '');
106
106
  walk(tree, (item) => {
107
- if (item.PLpgSQL_expr && item.PLpgSQL_expr.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
107
+ if (item.PLpgSQL_expr?.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
108
108
  if (item.PLpgSQL_stmt_dynexecute) {
109
109
  const expression = sqlTree(`select ${item.PLpgSQL_stmt_dynexecute.query?.PLpgSQL_expr?.query ?? ''}`);
110
110
  dynamicOwnership(state, expression, file, true);
@@ -128,7 +128,7 @@ function grantCheck(state, node, file) {
128
128
  function recordGrant(state, node, file, executed) {
129
129
  state.grants.push({ node, file, created: new Map(state.created), executed });
130
130
  const grant = node.GrantStmt;
131
- if (!executed || !grant || grant.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
131
+ if (!executed || grant?.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
132
132
  if (!(grant.grantees ?? []).some((item) => item.RoleSpec?.rolename === state.appRole)) return;
133
133
  const privileges = grant.privileges?.map((item) => item.AccessPriv?.priv_name).filter(Boolean) ?? TABLE_PRIVILEGES;
134
134
  for (const object of grant.objects ?? []) {