@starci/hfs 4.7.13 → 4.7.15

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.15 - 2026-10-21
4
+ - Added: slot `be.contract.mcp-tools` (opt-in, slot manifest 3.4.0) for the committed MCP tool catalog `be/contracts/<app>/<contract>.tools.json`, and R302 `HFS_MCP_TOOLS_SNAPSHOT`: the catalog names a declared be app, its contract is `<server>@<major>` equal to the file name and the server name, and every tool has a unique name, a description and an object `inputSchema`; canon pins follow the matching package cohort.
5
+
6
+ ## 4.7.14 - 2026-10-20
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+
3
12
  ## 4.7.13 - 2026-10-20
4
13
  - Fixed: the lite CI template sets NODE_OPTIONS on the database migration check step; ci-standard heap.steps lists `npm run db:lint`.
5
14
  - 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.15",
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.23
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.22
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.15
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.15
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"]
@@ -4984,3 +4985,18 @@ rules:
4984
4985
  - {path: packages/hfs/templates/app/ci-workflows/github/workflows/ci.yml, edition: full}
4985
4986
  specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; the scaffold e2e (e2e/hfs-scaffold-app.spec.mjs: a fresh scaffold has no finding) and the example apps prove it"
4986
4987
  witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the runtime has no spec fixtures", date: "2026-10-18"}
4988
+ - id: R302
4989
+ code: "HFS_MCP_TOOLS_SNAPSHOT"
4990
+ law: "An app that commits the MCP tool catalog of its server keeps it at `be/contracts/<app>/<contract>.tools.json` (slot be.contract.mcp-tools, opt-in), `<contract>` being `<server>@<major>`, as the server's `tools/list` result: `{ contract, server: { name, version }, tools }` where the contract equals the file name and the server name, and every tool has a unique name, a description and an object `inputSchema`."
4991
+ kinds: [check]
4992
+ gates: [land, ci]
4993
+ failureCodes: ["HFS_MCP_TOOLS_SNAPSHOT"]
4994
+ enforcers:
4995
+ - {kind: hfs, id: mcp-tools-snapshot, at: scripts/hfs/rules/mcp-tools.mjs}
4996
+ editions: [lite, full]
4997
+ docs:
4998
+ - {path: docs/hfs-contract-snapshots.md#r302, editions: [lite, full]}
4999
+ templateNa: "The canon emits no MCP catalog: the app commits its server's tools/list result, so there is no scaffolded snapshot."
5000
+ exampleNa: "owner:mcp-tools-snapshot-no-example (2026-10-21): no example app serves an MCP server; the package spec judges a passing and a violating catalog"
5001
+ specNa: "owner:no-runtime-specs (2026-10-17): the enforcer is runtime code with no spec of its own; the @starci/hfs package spec packages/hfs/src/mcp-tools-snapshot.spec.mjs runs it from the bundled copy"
5002
+ witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the runtime has no spec fixtures; the package spec proves the rule", date: "2026-10-21"}
@@ -7,7 +7,7 @@
7
7
  # catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
8
8
  # Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
9
9
  schema: starci/hfs-slots@3
10
- version: 3.3.0
10
+ version: 3.4.0
11
11
 
12
12
  versioning:
13
13
  # MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
@@ -38,6 +38,7 @@ versioning:
38
38
  # or environment reads (BE_APP_OPTIONS_COMPOSITION). Secrets use EnvSource.secret(): <NAME>_FILE first, plain <NAME> only
39
39
  # when the file key is absent; .starcistacks owns custody. Capability <capability>.config.ts roles stay unchanged.
40
40
  # 3.3.0 is a minor (alpha.16): it ADDS the optional app.ci-stack-deploy slot, the managed workflow of the starci app stack verbs.
41
+ # 3.4.0 is a minor (alpha.24): it ADDS the opt-in be.contract.mcp-tools slot, the committed MCP tool-catalog snapshot of an app.
41
42
 
42
43
  # Field meaning, per slot.
43
44
  # id stable name, never changes meaning inside a major.
@@ -1153,6 +1154,19 @@ slots:
1153
1154
  why: the previously published event contract of a service, the same format as its events.json (copied over when the contract is published, never edited); the current events.json may only extend it additively, a breaking change is a new `<event>.v2` key (R177 BE_CONTRACT_BREAKING)
1154
1155
  rules: [BE_CONTRACT_BREAKING]
1155
1156
 
1157
+ - id: be.contract.mcp-tools
1158
+ since: 3.4.0
1159
+ profiles: [be]
1160
+ path: "contracts/<app>/<contract>.tools.json"
1161
+ presence: opt-in
1162
+ tracked: tracked
1163
+ tier: none
1164
+ tests: none
1165
+ coverage: none
1166
+ why: the MCP tool catalog an app's MCP server publishes at one contract major (`<server>@<major>`), the `tools/list` result its clients rely on, committed as a cross-repo interface; the canon emits no MCP catalog, so its shape is judged (R302 HFS_MCP_TOOLS_SNAPSHOT)
1167
+ exampleNa: "owner:mcp-tools-snapshot-no-example (2026-10-21): no example app serves an MCP server; the package spec judges the shape"
1168
+ rules: [HFS_MCP_TOOLS_SNAPSHOT]
1169
+
1156
1170
  # ----- BE: apps (one slot per app kind; a new kind = a new slot, minor bump) ---------------------------
1157
1171
  # The files of every app (owner ruling 2026-10-14, R245 and R246, since 3.1.0), each with a line budget that
1158
1172
  # BE_APP_COMPOSITION_BUDGET holds absolutely (not a ratchet):
@@ -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"
@@ -3580,6 +3602,17 @@ HFS_MANIFEST_MAJOR_MISMATCH:
3580
3602
  owner: owner
3581
3603
  kind: check-finding
3582
3604
 
3605
+ HFS_MCP_TOOLS_SNAPSHOT:
3606
+ title: "A committed MCP tool catalog has the canonical tools/list shape"
3607
+ title_vi: "Snapshot danh mục tool MCP sai hình dạng chuẩn"
3608
+ meaning_vi: "Tệp `<file>` dưới `be/contracts/<app>/<contract>.tools.json` không phải danh mục tool MCP chuẩn: thiếu app khai báo, `contract` không phải `<server>@<major>` khớp tên tệp và `server.name`, hoặc có tool thiếu tên, mô tả hay `inputSchema` kiểu object, hoặc trùng tên."
3609
+ causes_vi:
3610
+ - "Vi phạm luật R302: snapshot là kết quả `tools/list` của server MCP ở một major hợp đồng, được commit như giao diện giữa các repo."
3611
+ - "Snapshot được chép tay hoặc đặt dưới thư mục của một app không khai báo trong hfs.json"
3612
+ nextStep_vi: "Lấy lại kết quả `tools/list` của server, ghi `{ contract, server: { name, version }, tools }` vào đúng tệp của app phục vụ server đó và khai báo slot `be.contract.mcp-tools` trong `sides.be.optionalSlots`. 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."
3613
+ owner: op-retry
3614
+ kind: check-finding
3615
+
3583
3616
  HFS_MIN_INSTANCES:
3584
3617
  title: "Fewer instances of a slot than required"
3585
3618
  title_vi: "Số thực thể của ô ít hơn mức tối thiểu"
@@ -3962,10 +3995,10 @@ HFS_SERVICE_ROLE_SCOPE:
3962
3995
  HFS_SERVICE_STACK_DECLARATION:
3963
3996
  title: "Each service app is a service component of the stack"
3964
3997
  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`."
3998
+ 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
3999
  causes_vi:
3967
4000
  - "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)."
4001
+ 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
4002
  owner: op-retry
3970
4003
  kind: check-finding
3971
4004
 
@@ -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": "c7ef5f72727b2b906b541669c413050b7abd34206c123a87fe90502330271856",
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": {
@@ -2729,7 +2729,7 @@ export const CATALOG = {
2729
2729
  {
2730
2730
  "name": "changed",
2731
2731
  "type": "list",
2732
- "summary": "explicit files in the operation slice"
2732
+ "summary": "files added to the slice beside the delta from the base"
2733
2733
  },
2734
2734
  {
2735
2735
  "name": "tests",
@@ -2773,6 +2773,12 @@ export const CATALOG = {
2773
2773
  "impl": {
2774
2774
  "script": "scripts/cli/gate-run.mjs"
2775
2775
  },
2776
+ "conventions": [
2777
+ "the slice is every file that differs from the base (committed, staged, dirty and untracked) plus the --changed files",
2778
+ "--changed extends the slice and never narrows it, so a report cannot hide an edit; narrow it with --base",
2779
+ "the base is --base, else the workflow's previous checkpoint, else the merge-base of HEAD with its upstream or main",
2780
+ "a TypeScript file with no tsconfig.json of its own under a hfs.json sides.<side>.reads tree is checked by that side's programs"
2781
+ ],
2776
2782
  "runtimeSide": "tree"
2777
2783
  },
2778
2784
  "runtime-artifact": {
@@ -7872,7 +7878,7 @@ export const CATALOG = {
7872
7878
  ],
7873
7879
  "exit": {
7874
7880
  "0": "the clean lockfile was installed",
7875
- "1": "npm or the host lock failed",
7881
+ "1": "npm, the host lock or the git hooks install failed",
7876
7882
  "2": "the worktree or install state was refused"
7877
7883
  },
7878
7884
  "json": "starci/npm-ci@1",
@@ -7898,7 +7904,10 @@ export const CATALOG = {
7898
7904
  "conventions": [
7899
7905
  "npm ci installs the lockfile into this worktree's real node_modules and never uses a junction or symbolic link",
7900
7906
  "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"
7907
+ "dependency installs run one at a time under the host lock and never use npm install",
7908
+ "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",
7909
+ "the receipt's hooks field reports the git hooks as installed, repaired, not-set or not-declared",
7910
+ "a hooks path still naming a missing or empty folder after the installer ran fails the verb"
7902
7911
  ],
7903
7912
  "runtimeSide": "host"
7904
7913
  },
@@ -7926,7 +7935,7 @@ export const CATALOG = {
7926
7935
  ],
7927
7936
  "exit": {
7928
7937
  "0": "exact dependencies and any requested canon pins were installed",
7929
- "1": "npm, Git, target canon loading or the host lock failed",
7938
+ "1": "npm, Git, target canon loading, the host lock or the git hooks install failed",
7930
7939
  "2": "the package, target pin or worktree was refused"
7931
7940
  },
7932
7941
  "json": "starci/npm-install@1",
@@ -7958,7 +7967,10 @@ export const CATALOG = {
7958
7967
  "target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
7959
7968
  "each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
7960
7969
  "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"
7970
+ "installs always use --save-exact and report the package.json and package-lock.json diff stat",
7971
+ "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",
7972
+ "the receipt's hooks field reports the git hooks as installed, repaired, not-set or not-declared",
7973
+ "a hooks path still naming a missing or empty folder after the installer ran fails the verb"
7962
7974
  ],
7963
7975
  "runtimeSide": "host"
7964
7976
  }
@@ -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. */
@@ -20,6 +20,7 @@
20
20
  // HFS_CI_MISSING_CANON (R13, rules/pipeline.mjs) CI without the pinned `starci app check`, pre-push without typecheck or lint
21
21
  // HFS_DEP_VERSION_SKEW (R14, rules/deps.mjs) a dependency at two versions in the workspace, or a nested copy in the lockfile
22
22
  // HFS_CONTRACT_SNAPSHOT_DRIFT (R23, rules/contract.mjs) an uncommitted back-end snapshot, or a front-end copy that differs from it
23
+ // HFS_MCP_TOOLS_SNAPSHOT (R302, rules/mcp-tools.mjs) a committed MCP tool catalog that is not the canonical `tools/list` snapshot
23
24
  // BE_TEST_TOPOLOGY (R47, rules/test-topology.mjs) a `.test` file, a testing/ folder, a second jest configuration
24
25
  // FE_NO_TESTS (R97, rules/fe-no-tests.mjs) a front end holds a spec, e2e or test-tool file, a test script or a test dependency; no exception
25
26
  // FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
@@ -72,6 +73,7 @@ import { gitOutputOf } from '../lib/git.mjs';
72
73
  import { posixPath } from '../lib/path-key.mjs';
73
74
  import { treeFindings } from './tree-findings.mjs';
74
75
  import { contractFindings } from './rules/contract.mjs';
76
+ import { mcpToolsSnapshotFindings } from './rules/mcp-tools.mjs';
75
77
  import { appRootFindings } from './rules/app-root.mjs';
76
78
  import { frontendFindings } from './rules/frontend-tree.mjs';
77
79
  import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
@@ -102,7 +104,7 @@ export const CHECK_CODES = Object.freeze([
102
104
  'HFS_SLOT_UNDECLARED', 'HFS_SLOT_NOT_ENABLED', 'HFS_SLOT_AMBIGUOUS', 'HFS_TRACKED_MUST_BE_IGNORED', 'HFS_FORBIDDEN_PRESENT',
103
105
  'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
104
106
  'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
105
- 'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
107
+ 'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT', 'HFS_MCP_TOOLS_SNAPSHOT',
106
108
  'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
107
109
  'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY', 'HFS_STARCIWORK_LITE_FORBIDDEN',
108
110
  'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
@@ -220,7 +222,7 @@ function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pin
220
222
  ...checkAppRoot({ root: repoRoot, resolver, tree: trackedTreeView(all) }).violations.map((item) => ({ code: item.ruleId, level: 'error', path: item.path, line: item.line, column: item.column, source: 'machine', message: `${item.path}: ${item.message}` })),
221
223
  );
222
224
  } else if (repo.profile === 'be') {
223
- findings.push(...contractFindings({ repoRoot, files, repo, resolver }), ...testTopologyFindings({ repoRoot, files }));
225
+ findings.push(...contractFindings({ repoRoot, files, repo, resolver }), ...mcpToolsSnapshotFindings({ repoRoot, files, repo }), ...testTopologyFindings({ repoRoot, files }));
224
226
  } else {
225
227
  findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter((file) => inScope(file)) }));
226
228
  }
@@ -0,0 +1,63 @@
1
+ // mcp-tools.mjs - HFS_MCP_TOOLS_SNAPSHOT (R302): the committed MCP tool-catalog snapshot of an app has the canonical shape.
2
+ // An app that serves an MCP server commits the catalog its clients rely on at `contracts/<app>/<contract>.tools.json` (slot
3
+ // be.contract.mcp-tools): `{ contract: "<server>@<major>", server: { name, version }, tools: [{ name, description, inputSchema, ... }] }`,
4
+ // the `tools/list` result of the server at that contract major. The canon emits no MCP catalog, so the check is the shape: the
5
+ // file names a declared be app and its own contract, the contract is the server name at an integer major, every tool has a unique
6
+ // name, a description and an object input schema. A file that is not JSON is a finding too.
7
+ import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
8
+ import { found, readText } from './read.mjs';
9
+
10
+ const MCP_TOOLS_SNAPSHOT = 'HFS_MCP_TOOLS_SNAPSHOT';
11
+ const SNAPSHOT = /^contracts\/([^/]+)\/([^/]+)\.tools\.json$/u;
12
+ const CONTRACT = /^([a-z][a-z0-9-]*)@([1-9]\d*)$/u;
13
+
14
+ const text = (value) => typeof value === 'string' && value.trim() !== '';
15
+
16
+ /** The shape problems of one tool entry at index `index`, given the names already seen. */
17
+ function toolProblems(tool, index, seen) {
18
+ if (!plain(tool)) return [`tools[${index}] is not an object`];
19
+ const at = text(tool.name) ? `tool ${tool.name}` : `tools[${index}]`;
20
+ const problems = [];
21
+ if (!text(tool.name)) problems.push(`${at} has no name`);
22
+ else if (seen.has(tool.name)) problems.push(`${at} is listed twice`);
23
+ else seen.add(tool.name);
24
+ if (!text(tool.description)) problems.push(`${at} has no description`);
25
+ if (!plain(tool.inputSchema) || tool.inputSchema.type !== 'object') problems.push(`${at} has no inputSchema of type object`);
26
+ return problems;
27
+ }
28
+
29
+ /** The shape problems of a parsed snapshot whose file stem is `stem`. */
30
+ function snapshotProblems(doc, stem) {
31
+ if (!plain(doc)) return ['is not a JSON object'];
32
+ const problems = [];
33
+ const contract = CONTRACT.exec(String(doc.contract ?? ''));
34
+ if (!contract) problems.push('contract is not `<server>@<major>` (a kebab-case server name and an integer major from 1)');
35
+ else if (doc.contract !== stem) problems.push(`contract ${doc.contract} differs from the file name ${stem}.tools.json`);
36
+ if (!plain(doc.server) || !text(doc.server.name) || !text(doc.server.version)) problems.push('server has no name and version');
37
+ else if (contract && doc.server.name !== contract[1]) problems.push(`server.name ${doc.server.name} differs from the contract server ${contract[1]}`);
38
+ if (!Array.isArray(doc.tools) || doc.tools.length === 0) return [...problems, 'tools is not a non-empty list'];
39
+ const seen = new Set();
40
+ return [...problems, ...doc.tools.flatMap((tool, index) => toolProblems(tool, index, seen))];
41
+ }
42
+
43
+ /** The findings of R302 for the be side at `repoRoot` (be-relative `files`, the side view `repo`). */
44
+ export function mcpToolsSnapshotFindings({ repoRoot, files, repo }) {
45
+ if (repo.profile !== 'be') return [];
46
+ const apps = new Set((repo.apps ?? []).map((app) => app.name));
47
+ const findings = [];
48
+ for (const file of files) {
49
+ const match = SNAPSHOT.exec(file);
50
+ if (!match) continue;
51
+ const [, app, stem] = match;
52
+ const fix = 'commit the server\'s `tools/list` result as `{ contract: "<server>@<major>", server: { name, version }, tools: [...] }`';
53
+ if (!apps.has(app)) {
54
+ findings.push(found(MCP_TOOLS_SNAPSHOT, file, `${file} belongs to no be app: ${app} is not declared in hfs.json sides.be.apps; a tool catalog lives under contracts/<app>/ of the app that serves it`, { app }));
55
+ continue;
56
+ }
57
+ let doc;
58
+ try { doc = JSON.parse(readText(repoRoot, file) ?? ''); } catch { doc = undefined; }
59
+ const problems = doc === undefined ? ['is not readable JSON'] : snapshotProblems(doc, stem);
60
+ if (problems.length) findings.push(found(MCP_TOOLS_SNAPSHOT, file, `${file} is not a canonical MCP tool catalog: ${problems.join('; ')}; ${fix}`, { app, problems }));
61
+ }
62
+ return findings;
63
+ }
@@ -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}`);