@starci/hfs 4.7.13 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.14 - 2026-10-20
4
+ - Added: `starci app hygiene` (the managed pre-commit hook) refuses a back-end logic file the index adds without its `<name>.<role>.spec.ts` beside it, naming the file (`BE_UNIT_SPEC_NOT_STAGED`, R47); the measured roles and roots come from the coverage scope.
5
+ - Added: `starci app check` reports `HFS_HOOKS_NOT_INSTALLED` (R13, error) when a set `core.hooksPath` names a hooks folder that is missing or holds no husky dispatcher in the checkout, with the install remedy.
6
+ - Fixed: R165 `HFS_SERVICE_STACK_DECLARATION` accepts, for an api or worker app, a `role: service` component named after it or mapped to it by `environments.<env>.deploy.images.<component>.app` in any environment, so a deployed service named after its stack (`shop-api: {app: api}`) needs no rename onto a shared network alias.
7
+ - Changed: the scaffold's next step names `starci npm ci`, which installs the lockfile and then the app's git hooks; canon pins follow the matching package cohort.
8
+
3
9
  ## 4.7.13 - 2026-10-20
4
10
  - Fixed: the lite CI template sets NODE_OPTIONS on the database migration check step; ci-standard heap.steps lists `npm run db:lint`.
5
11
  - Fixed: bundled runtime source clears the SonarCloud code smells of alpha.23 (regex complexity, concise classes, string prefix and suffix checks, block braces, optional chaining, export-from, default parameters, synchronous awaits, dead stores) without behavior change; canon pins follow the matching package cohort.
package/README.md CHANGED
@@ -109,7 +109,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
109
109
  | `FE_GRAPHQL_CONTRACT` | error | a front-end `.graphql` document that the back end's contract snapshot (`be/contracts/<service>/schema.graphql`) does not serve: an unknown field, argument or input field, a missing required argument, a variable of another type, or a selection that does not fit (R113) |
110
110
  | `HFS_SERVICE_PLACEMENT` | error | a folder outside `be/apps/<service>/` with a `Dockerfile` or a `package.json` of its own (R163): a back-end service is a Nest app of the one repository |
111
111
  | `HFS_IMAGE_UNPINNED` | error | in a product with more than one service, a stack component whose `image` has no tag, `latest`, a branch word or a major or minor-only tag instead of a digest or an exact version (R164) |
112
- | `HFS_SERVICE_STACK_DECLARATION` | error | an api or worker app of a multi-service product that is not a `role: service` component of `application-stacks.yaml` (R165) |
112
+ | `HFS_SERVICE_STACK_DECLARATION` | error | an api or worker app of a multi-service product with no `role: service` component of `application-stacks.yaml`, named after it or mapped to it by `deploy.images.<component>.app` (R165) |
113
113
  | `HFS_EVENT_CONTRACT` | error | the vendored async contract: a missing or stale `be/contracts/<service>/events.json`, a `consumes.ts` entry the provider's snapshot does not declare at that version, a `compensates` naming no declared event, or a consumer queue no consumes table lists (R166) |
114
114
  | `BE_ASYNC_SPEC_MISSING` | error | a consumed event no e2e spec through `useTestWorld` names, or a saga step whose spec does not also name the compensated event (R167) |
115
115
  | `BE_INTEGRATION_SPEC_MISSING` | error | an integration (`src/modules/integrations/<provider>/` with `<provider>.config.ts`) without a `src/tests/integration/<provider>/*.integration-spec.ts` that registers its module through `useTestWorld({ modules })`, references its ErrorCode enum and drives an outage through the world (R112) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.13",
3
+ "version": "4.7.14",
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",
@@ -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.20
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.19
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.13
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.13
86
+ version: 4.7.14
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -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
  }
@@ -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. */
@@ -5,7 +5,8 @@
5
5
  // exists only at be/apps/<service>/ (fe/apps/<app>/Dockerfile is a front end's);
6
6
  // R164 HFS_IMAGE_UNPINNED in a multi-service product every component image of application-stacks.yaml is a digest or an exact
7
7
  // version (x.y.z), never a missing tag, `latest`, a branch word or a major/minor-only tag;
8
- // R165 HFS_SERVICE_STACK_DECLARATION each api or worker app of a multi-service product is a `role: service` component of the stack;
8
+ // R165 HFS_SERVICE_STACK_DECLARATION each api or worker app of a multi-service product is a `role: service` component of the stack,
9
+ // named after the app or mapped to it by `environments.<env>.deploy.images.<component>.app`;
9
10
  // R166 HFS_EVENT_CONTRACT the async contract is vendored: `be/contracts/<service>/events.json` equals the provider's
10
11
  // `apps/<service>/src/events.ts` table (an event may declare `compensates: "<event>"`), every event a
11
12
  // consumer's `apps/<app>/src/consumes.ts` names exists in that snapshot at the same version;
@@ -16,6 +17,7 @@
16
17
  import path from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { findStackDeclaration } from '../../lib/stack-declaration.mjs';
20
+ import { deployImageApps } from '../stack-deploy-config.mjs';
19
21
  import { loadTypescript } from '../../lib/package-at.mjs';
20
22
  import { propertyText } from '../../lib/ts-ast.mjs';
21
23
  import { folded, readEventClasses, snapshotText } from '../../lib/event-contract.mjs';
@@ -105,7 +107,17 @@ function imagePinFindings({ repoRoot, repo }) {
105
107
 
106
108
  // ------------------------------------------------------------------------------------------------ R165 stack declaration
107
109
 
108
- /** Findings of R165: each service app of a multi-service product is a `role: service` component. */
110
+ /**
111
+ * The component names that stand for `app`: the app's own name, then every `environments.<env>.deploy.images` key whose `app` is
112
+ * the app (the one tie between component and app the declaration states, read by scripts/hfs/stack-deploy-config.mjs). A product
113
+ * whose deployed service is named after its stack (`shop-api: {app: api}`) keeps that name; renaming it would rename the live
114
+ * service and put the short app alias on a shared network (R294).
115
+ */
116
+ function componentNamesOf(doc, app) {
117
+ return [...new Set([app, ...deployImageApps(doc).filter((tie) => tie.app === app).map((tie) => tie.service)])];
118
+ }
119
+
120
+ /** Findings of R165: each service app of a multi-service product is a `role: service` component, named after it or mapped to it by deploy.images. */
109
121
  function serviceStackFindings({ repoRoot, repo }) {
110
122
  const services = servicesOf(repo);
111
123
  if (services.length < 2) return [];
@@ -115,9 +127,11 @@ function serviceStackFindings({ repoRoot, repo }) {
115
127
  const file = '.starcistacks/application-stacks.yaml';
116
128
  const findings = [];
117
129
  for (const app of services) {
118
- const component = components[app.name];
119
- if (component === null || typeof component !== 'object') findings.push(found(SERVICE_STACK_DECLARATION, file, `${file} declares no component \`${app.name}\` for the ${app.kind} app be/apps/${app.name}; every service of a multi-service product is a \`role: service\` component (own image \`<repo>/${app.name}:<exact version>\`, built from be/apps/${app.name}/Dockerfile).`, { app: app.name }));
120
- else if (component.role !== 'service') findings.push(found(SERVICE_STACK_DECLARATION, file, `${file} components.${app.name} is the stack's face of be/apps/${app.name} and must be \`role: service\`, not \`${String(component.role)}\`.`, { app: app.name }));
130
+ const names = componentNamesOf(declaration.doc, app.name);
131
+ const declared = names.filter((name) => components[name] !== null && typeof components[name] === 'object');
132
+ if (declared.some((name) => components[name].role === 'service')) continue;
133
+ if (!declared.length) findings.push(found(SERVICE_STACK_DECLARATION, file, `${file} declares no component for the ${app.kind} app be/apps/${app.name}: none named \`${app.name}\` and none mapped to it by \`environments.<env>.deploy.images.<component>.app: ${app.name}\`; every service of a multi-service product is a \`role: service\` component (own image at an exact version, built from be/apps/${app.name}/Dockerfile).`, { app: app.name }));
134
+ else findings.push(found(SERVICE_STACK_DECLARATION, file, `${file} ${declared.map((name) => 'components.' + name).join(', ')} is the stack's face of be/apps/${app.name} and must be \`role: service\`, not \`${String(components[declared[0]].role)}\`.`, { app: app.name, components: declared }));
121
135
  }
122
136
  return findings;
123
137
  }
@@ -0,0 +1,26 @@
1
+ // staged-specs.mjs - BE_UNIT_SPEC_NOT_STAGED (R47): the commit that adds a back-end logic file adds its unit spec beside it. A new
2
+ // file `be/<path>/<name>.<role>.ts` that the coverage scope measures (scripts/hfs/coverage-scope.mjs isMeasured: a role of
3
+ // ruleParams.be.logicRoles below a measured root, outside its none folders) needs `<name>.<role>.spec.ts` in the same index.
4
+ // `starci app hygiene` judges the files the index adds (packages/hfs/sync/hygiene.mjs, run by the managed pre-commit hook), so a
5
+ // coding agent cannot commit the code first and its spec afterwards. A file that already exists is the coverage gate's.
6
+ import { isMeasured } from '../coverage-scope.mjs';
7
+
8
+ const SPEC_NOT_STAGED = 'BE_UNIT_SPEC_NOT_STAGED';
9
+ const BE = 'be/';
10
+
11
+ /** The unit spec beside an app-relative `<name>.<role>.ts`: `<name>.<role>.spec.ts`. */
12
+ const siblingSpec = (file) => file.replace(/\.ts$/u, '.spec.ts');
13
+
14
+ /**
15
+ * The findings `{ file, code, message }` of the app-relative files the index adds (`added`) whose sibling spec is not among the
16
+ * paths the index holds (`indexed`, a Set). `manifest` is the slot manifest the coverage scope reads.
17
+ */
18
+ export function stagedSpecFindings(manifest, added, indexed) {
19
+ return added
20
+ .filter((file) => file.startsWith(BE) && !file.endsWith('.spec.ts') && isMeasured(manifest, file.slice(BE.length)) && !indexed.has(siblingSpec(file)))
21
+ .map((file) => ({
22
+ file,
23
+ code: SPEC_NOT_STAGED,
24
+ message: `is a new back-end logic file the unit coverage measures at 100 per file, staged without ${siblingSpec(file).slice(file.lastIndexOf('/') + 1)} beside it; write the spec in the same change (starci gate read, code and spec, starci gate run --tests) and stage both`,
25
+ }));
26
+ }
@@ -314,3 +314,17 @@ export function normalizeDeploy(block, { project, probe }) {
314
314
  if (block.stack === undefined && !NAME.test(String(project))) c.problem('deploy.stack', `the stack name ${project} must match ${NAME}`);
315
315
  return { config: c.problems.length === 0 ? config : null, problems: c.problems };
316
316
  }
317
+
318
+ /**
319
+ * The component-to-app ties a stack declaration states: every `environments.<env>.deploy.images.<service>` entry with a string
320
+ * `app`, as `[{ environment, service, app }]`. Read without validating the rest of the block (R294 judges that), so a defect
321
+ * elsewhere in a deploy block never hides the tie R165 reads.
322
+ */
323
+ export function deployImageApps(doc) {
324
+ const ties = [];
325
+ for (const [environment, spec] of Object.entries(plain(doc?.environments) ? doc.environments : {})) {
326
+ const block = plain(spec) && plain(spec.deploy) && plain(spec.deploy.images) ? spec.deploy.images : {};
327
+ for (const [service, entry] of Object.entries(block)) if (plain(entry) && typeof entry.app === 'string') ties.push({ environment, service, app: entry.app });
328
+ }
329
+ return ties;
330
+ }
@@ -0,0 +1,30 @@
1
+ // git-hooks-dir.mjs - whether a checkout's hooks folder holds installed hooks. The one judgement read by `starci npm ci` and
2
+ // `starci npm install` (scripts/machine/npm-hooks.mjs, which runs the app's hook installer when it fails) and by the HFS check
3
+ // HFS_HOOKS_NOT_INSTALLED (scripts/hfs/architecture/hfs.mjs). husky's `.husky/_` is generated per checkout: a linked worktree or
4
+ // an install whose lifecycle scripts were ignored has none, or only the runtime's own wrapped hooks, so git runs no pre-commit gate.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ /** husky's generated hooks folder; every hook file in it sources the dispatcher `h`, which runs the tracked `.husky/<hook>`. */
9
+ const HUSKY_GENERATED = /^\.husky\/_\/?$/u;
10
+ const HUSKY_DISPATCHER = 'h';
11
+
12
+ /**
13
+ * The state of the hooks folder `hooksPath` (a core.hooksPath value, relative to the checkout `root` or absolute):
14
+ * `{ state, dir }` with state `installed`, `missing` (no such folder), `empty` (no hook file; for husky's `.husky/_`, no
15
+ * dispatcher `h`) or `unreadable` (with `error`).
16
+ */
17
+ export function hooksDirState(root, hooksPath) {
18
+ const value = String(hooksPath ?? '').trim().replaceAll('\\', '/');
19
+ const dir = path.resolve(root, value);
20
+ let entries;
21
+ try {
22
+ entries = fs.readdirSync(dir, { withFileTypes: true });
23
+ } catch (error) {
24
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return { state: 'missing', dir };
25
+ return { state: 'unreadable', dir, error: String(error?.message ?? error) };
26
+ }
27
+ const files = entries.filter((entry) => entry.isFile() && entry.name !== '.gitignore').map((entry) => entry.name);
28
+ const installed = HUSKY_GENERATED.test(value) ? files.includes(HUSKY_DISPATCHER) : files.length > 0;
29
+ return { state: installed ? 'installed' : 'empty', dir };
30
+ }
package/src/main.mjs CHANGED
@@ -28,7 +28,7 @@
28
28
  // printSchema(lexicographicSortSchema) of the resolvers the app root composes; no env, no database, no network.
29
29
  // starci app sync (--check | --write) [--cwd <dir>] the managed files of the root (scripts, husky, CI, .gitignore block, sonar, prettier) and
30
30
  // of each side (tsconfig, eslint, jest, stylelint); sync/cli.mjs
31
- // starci app hygiene the pre-commit guard: staged .starciwork and .starcistacks paths, and the secrets guard over every staged file (read from the index); sync/cli.mjs
31
+ // starci app hygiene the pre-commit guard: staged .starciwork and .starcistacks paths, the secrets guard over every staged file (read from the index) and a new back-end logic file staged without its unit spec; sync/cli.mjs
32
32
  // starci app coverage merge --out <lcov> <lcov-or-dir>... the lcov reports of the sharded unit run merged into one (coverage/cli.mjs; the managed CI coverage job)
33
33
  // starci app new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--cwd <dir>]
34
34
  // a back-end `<name>.service.ts` and its `<name>.service.spec.ts` skeleton (scaffold/service.mjs; <dir> is
@@ -390,7 +390,7 @@ async function scaffoldMain(opts, { cwd, edition = 'full', presets, lock, stdout
390
390
  const [name, ...extra] = opts.positional;
391
391
  if (!name || extra.length) throw new Error('starci app scaffold takes `<name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private]`');
392
392
  const { root: created, files } = scaffoldApp({ name, into: path.resolve(cwd, opts.into ?? '.'), edition, presets: presets ?? await scaffoldPresets(), ...(opts['registry-visibility'] === undefined ? {} : { registryVisibility: opts['registry-visibility'] }), ...(lock ? { lock } : {}) });
393
- out(`starci app scaffold: created ${created} (${files.length} files); next: npm ci, then starci app lint\n`);
393
+ out(`starci app scaffold: created ${created} (${files.length} files); next: starci npm ci (the lockfile, then the git hooks through npm run prepare; after a raw npm ci --ignore-scripts run npm run prepare), then starci app lint\n`);
394
394
  return 0;
395
395
  }
396
396
 
package/sync/hygiene.mjs CHANGED
@@ -3,8 +3,10 @@
3
3
  // .starcistacks must not be a plaintext secret (only *.enc is sealed). It is also the secrets guard of the commit: every staged file, in
4
4
  // any tree, is read from the index and judged with the one secret judgement of `starci app check` (scripts/hfs/rules/secrets.mjs: a secret by
5
5
  // being, an .enc that is no sops envelope, a line that matches a secret pattern), so no plaintext secret reaches the history whatever
6
- // .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. The pre-commit hook judges the staged
7
- // files; scripts/gates/hfs-sync.mjs judges every tracked file.
6
+ // .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. At an HFS app root (hfs.json) it also
7
+ // refuses a back-end logic file the index adds without its unit spec beside it (BE_UNIT_SPEC_NOT_STAGED,
8
+ // scripts/hfs/rules/staged-specs.mjs over the coverage scope), naming the file. The pre-commit hook judges the staged files;
9
+ // scripts/gates/hfs-sync.mjs judges every tracked file.
8
10
  //
9
11
  // Run inside a full runtime checkout — the repository under judgment is the checkout — it also reports the
10
12
  // state-root ledger finding of that checkout's scripts/housekeeping/hk-orphan-ledgers.mjs: LEDGER_ORPHAN_STATE_ROOT. Any other repository — a product repo,
@@ -14,6 +16,8 @@ import fs from 'node:fs';
14
16
  import path from 'node:path';
15
17
  import { pathToFileURL } from 'node:url';
16
18
  import { secretFileFindings } from '../runtime/scripts/hfs/rules/secrets.mjs';
19
+ import { stagedSpecFindings } from '../runtime/scripts/hfs/rules/staged-specs.mjs';
20
+ import { loadSlotManifest } from '../runtime/scripts/hfs/slots.mjs';
17
21
  import { runGit } from '../runtime/scripts/api/git/lib.mjs';
18
22
 
19
23
  // One git call through the runtime's git owner (scripts/api/git/lib.mjs): stdout, or the failure thrown the way execFileSync threw it (error.status carries the exit code). The caller's own env is passed on unchanged: a hook runs with GIT_INDEX_FILE set, which the staged reads must keep seeing.
@@ -65,6 +69,15 @@ export const stagedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only
65
69
  /** Every tracked file. */
66
70
  export const trackedFiles = cwd => gitList(cwd, ['ls-files', '-z']);
67
71
 
72
+ /** Files the index adds for the next commit. */
73
+ const addedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only', '--diff-filter=A', '-z']);
74
+
75
+ /** The new back-end logic files staged without their unit spec, at an HFS app root only (a repository without hfs.json has no slots). */
76
+ function missingSpecFindings(cwd, { added = addedFiles(cwd), indexed, manifest } = {}) {
77
+ if (!fs.existsSync(path.join(cwd, 'hfs.json')) || !added.some(file => file.startsWith('be/'))) return [];
78
+ return stagedSpecFindings(manifest ?? loadSlotManifest(), added, indexed ?? new Set(trackedFiles(cwd)));
79
+ }
80
+
68
81
  const MAX_STAGED_BYTES = 1024 * 1024;
69
82
 
70
83
  /** The text of `file` as the index holds it (what the commit would record), or null when it is absent, binary or over 1 MB. */
@@ -125,9 +138,11 @@ async function ledgerHygieneFindings(cwd = process.cwd()) {
125
138
  }
126
139
  }
127
140
 
128
- /** `starci app hygiene`: checks staged files plus state-root ledger findings when reachable; returns the exit code. */
141
+ /** `starci app hygiene`: checks staged files (for the staged run also the new logic files without a spec) plus state-root ledger findings when reachable; returns the exit code. */
129
142
  export async function runWorkHygiene({ cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), files } = {}) {
130
- const { checked, findings } = judge(cwd, files ?? stagedFiles(cwd));
143
+ const judged = judge(cwd, files ?? stagedFiles(cwd));
144
+ const { checked } = judged;
145
+ const findings = [...judged.findings, ...(files ? [] : missingSpecFindings(cwd))];
131
146
  const ledgerFindings = await ledgerHygieneFindings(cwd);
132
147
  const all = [...findings, ...ledgerFindings];
133
148
  for (const finding of all) out(`${finding.code} ${finding.file} ${finding.message}`);