@starci/hfs 4.0.6 → 4.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -9
  3. package/bin/hfs.mjs +4 -4
  4. package/lint/run.mjs +1 -1
  5. package/package.json +2 -2
  6. package/runtime/engine/runtime-root.mjs +5 -0
  7. package/runtime/engine/yaml.mjs +3 -3
  8. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  9. package/runtime/knowledge/hfs/slots.yaml +5 -5
  10. package/runtime/knowledge/patterns/fe/folder.yaml +2 -2
  11. package/runtime/knowledge/sonar-gate.yaml +2 -2
  12. package/runtime/modules/kernel/failure-codes.yaml +12 -2
  13. package/runtime/scripts/api/fs/lib.mjs +6 -0
  14. package/runtime/scripts/api/fs/rmdir-link.mjs +1 -1
  15. package/runtime/scripts/{lib → api/fs}/safe-remove.mjs +31 -82
  16. package/runtime/scripts/api/git/lib.mjs +1 -1
  17. package/runtime/scripts/api/sops/decrypt.mjs +16 -0
  18. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  19. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +1 -1
  20. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +2 -2
  21. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +1 -1
  22. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +1 -1
  23. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +3 -3
  24. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +1 -1
  25. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  26. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +3 -3
  27. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  28. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +18 -105
  29. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +1 -1
  30. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +1 -1
  31. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +2 -2
  32. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +1 -1
  33. package/runtime/scripts/{checks → hfs}/architecture.mjs +2 -2
  34. package/runtime/scripts/{lib/hfs-check.mjs → hfs/check.mjs} +33 -25
  35. package/runtime/scripts/hfs/manifest-shape.mjs +170 -0
  36. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +6 -6
  37. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/contract.mjs +3 -2
  38. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +66 -0
  39. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/integration-specs.mjs +1 -1
  40. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +2 -2
  41. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/stacks.mjs +2 -2
  42. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +52 -58
  43. package/runtime/scripts/{lib/hfs-tree.mjs → hfs/tree.mjs} +1 -1
  44. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +3 -3
  45. package/runtime/scripts/lib/graphql-contract.mjs +429 -0
  46. package/runtime/scripts/lib/is-main.mjs +13 -0
  47. package/runtime/scripts/lib/language.mjs +2 -2
  48. package/runtime/scripts/lib/path-key.mjs +2 -0
  49. package/runtime/scripts/lib/stack-declaration.mjs +2 -2
  50. package/runtime/scripts/lib/test-secrets.mjs +2 -6
  51. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +1 -12
  52. package/scaffold/app.mjs +1 -1
  53. package/scaffold/service.mjs +2 -2
  54. package/sync/hygiene.mjs +8 -8
  55. package/sync/index.mjs +48 -11
  56. package/templates/app/quality-config/codecov.yml +1 -1
  57. package/templates/app/quality-config/sonar-project.properties +1 -1
  58. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +2 -2
  59. package/templates/fe/skeleton/apps/__app__/next.config.ts +10 -2
  60. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +2 -2
  61. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +0 -2
  62. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +1 -7
  63. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +0 -2
  64. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +0 -1
  65. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +0 -4
  66. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +1 -1
  67. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +0 -4
  68. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +1 -1
  69. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +0 -4
  70. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +1 -1
  71. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +8 -3
  72. package/templates/fe/skeleton/apps/__app__/src/proxy.ts +1 -1
  73. package/runtime/engine/admission.mjs +0 -284
  74. package/runtime/engine/digest.mjs +0 -10
  75. package/runtime/engine/ledger-db.mjs +0 -1245
  76. package/runtime/engine/machine-db.mjs +0 -1565
  77. package/runtime/engine/migrations/machine/0001-init.sql +0 -887
  78. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +0 -13
  79. package/runtime/engine/migrations/machine/0003-worktrees-workflow-orca.sql +0 -21
  80. package/runtime/engine/migrations/runtime/0001-init.sql +0 -1072
  81. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +0 -13
  82. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +0 -43
  83. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +0 -93
  84. package/runtime/scripts/lib/artifact-hold.mjs +0 -89
  85. package/runtime/scripts/lib/artifact-store.mjs +0 -103
  86. package/runtime/scripts/lib/redact.mjs +0 -148
  87. /package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +0 -0
  88. /package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +0 -0
  89. /package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +0 -0
  90. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  91. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  92. /package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +0 -0
  93. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  94. /package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +0 -0
  95. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  96. /package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +0 -0
  97. /package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +0 -0
  98. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  99. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  100. /package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +0 -0
  101. /package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +0 -0
  102. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  103. /package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +0 -0
  104. /package/runtime/scripts/{checks → hfs}/architecture/index.mjs +0 -0
  105. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  106. /package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +0 -0
  107. /package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +0 -0
  108. /package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +0 -0
  109. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  110. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  111. /package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +0 -0
  112. /package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +0 -0
  113. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  114. /package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +0 -0
  115. /package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +0 -0
  116. /package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +0 -0
  117. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  118. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  119. /package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +0 -0
  120. /package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +0 -0
  121. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
  122. /package/runtime/scripts/{lib → hfs}/repo-identity.mjs +0 -0
  123. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/deps.mjs +0 -0
  124. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +0 -0
  125. /package/runtime/scripts/{lib/hfs-rules/frontend.mjs → hfs/rules/frontend-tree.mjs} +0 -0
  126. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/lint-suppression.mjs +0 -0
  127. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/peer-integrations.mjs +0 -0
  128. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/pipeline.mjs +0 -0
  129. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/proof-commands.mjs +0 -0
  130. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +0 -0
  131. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/repo-local-checks.mjs +0 -0
  132. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +0 -0
  133. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/test-topology.mjs +0 -0
  134. /package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Changed: the managed fe scripts (`dev:fe`, `start:<app>`, `build:fe`) run `next` from the app directory (`cd fe/apps/<app> && next ...`) so next-intl finds its request config; the fe skeleton reads the request locale through `next/root-params` (`experimental.rootParams`), pins the workspace root three levels above the app and uses the `.*[.].*` proxy matcher; the bundled runtime carries the `&&`/`||`-aware FE_SWR_KEY_IDENTITY check.
6
+ ## Unreleased (C0 batch)
7
+
8
+ - Fixed (contract change `sonar-coverage-exclusions`): SonarQube has no `sonar.coverage.inclusions` property, so Sonar ignored it and counted every executable be/ file at 0% (overall coverage 65.8% and 45.2% on the examples, although every service was at 100). The managed `sonar-project.properties` now renders `sonar.coverage.exclusions` as the complement of the services. `coverageExclusions(presets, manifest)` in `sync/index.mjs` derives it from the preset's `COVERAGE_SOURCES` and the slot manifest, and lists:
9
+ - every be role of the R89 suffix vocabulary other than `service`;
10
+ - the be file names a slot declares outside it (`main.ts`, `index.ts`, `connection.ts`, the migrations, the test world's files);
11
+ - `fe/**`.
12
+
13
+ A coverage source whose complement Sonar's globs cannot write is refused (`HFS_SYNC_COVERAGE_SCOPE`). `sonar.coverage.inclusions` is deleted.
14
+ - Fixed: the fe skeleton's `[locale]/error.tsx` exports `ErrorBoundary` (Sonar S2137: `Error` shadowed the global).
15
+
16
+ ## 4.0.7 - 2026-10-01
17
+
18
+ - Changed: the runtime layer check (scripts/api/<system>, a pure scripts/lib) in its bundled runtime.
19
+
3
20
  ## 4.0.6 - 2026-10-01
4
21
 
5
22
  - Changed: R112 integration specs, the runtime api layer, the examples-root CI scope and the new pins.
package/README.md CHANGED
@@ -5,7 +5,7 @@ and it is self-contained: `runtime/` carries the slot manifest, the canon pins,
5
5
  catalog slice, the loader and the architecture machine with every file it imports, so it runs where there is no runtime
6
6
  checkout. The machine loads `typescript` from the app it checks (never its own copy), so run `npm ci` first.
7
7
 
8
- Every command runs at the app root. A side is judged with its folder as the root it was when products were split in two repositories (the "side view" of `scripts/lib/hfs-slots.mjs`): the root slots (`app.*`) are judged once, the `be.*` slots under `be/` and the `fe.*` slots under `fe/`, and nothing crosses sides except `sides.fe.reads` (`be/contracts/`, the input of the front end's codegen). Every finding path is app-relative (`be/src/...`, `fe/apps/...`).
8
+ Every command runs at the app root. A side is judged with its folder as the root it was when products were split in two repositories (the "side view" of `scripts/hfs/slots.mjs`): the root slots (`app.*`) are judged once, the `be.*` slots under `be/` and the `fe.*` slots under `fe/`, and nothing crosses sides except `sides.fe.reads` (`be/contracts/`, the input of the front end's codegen). Every finding path is app-relative (`be/src/...`, `fe/apps/...`).
9
9
 
10
10
  ```sh
11
11
  npx hfs lint [--repo <dir>] [--changed <file>...] [--fix] [--format text|json] [--sonar <file>] [--stylelint <glob>] # THE lint entry (`npm run lint`): ESLint over be/ with the BE canon and over fe/ with the FE canon, stylelint over fe/, the hfs checks; exit 0 clean, 1 findings, 2 a tool could not run
@@ -32,7 +32,7 @@ The spec skeleton is `Test.createTestingModule({ providers: [Service, { provide:
32
32
  whole architecture machine over each side folder. Every finding carries a why code and its Vietnamese text
33
33
  (`modules/kernel/failure-codes.yaml`).
34
34
 
35
- Its own checks (`scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-rules/`; the rendered-file checks (`sync/managed.mjs`) and the prettier check (`sync/format.mjs`) live in `sync/` because they read the templates and the repository's own install, and reach `hfs check` as `extraFindings`; the jest / vitest preset the coverage exclusions come from and prettier are read from the repository's `node_modules`, so run `npm ci` first):
35
+ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file checks (`sync/managed.mjs`) and the prettier check (`sync/format.mjs`) live in `sync/` because they read the templates and the repository's own install, and reach `hfs check` as `extraFindings`; the jest / vitest preset the coverage exclusions come from and prettier are read from the repository's `node_modules`, so run `npm ci` first):
36
36
 
37
37
  | Code | Level | Meaning |
38
38
  |---|---|---|
@@ -63,13 +63,14 @@ Its own checks (`scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-rules/`; the rende
63
63
  | `HFS_REPO_LOCAL_CHECK` | error | a `check-*` file in `scripts/` or `tools/`, an `eslint-local-rules*` file or local eslint plugin, or a script that runs a local check (R103) |
64
64
  | `HFS_LINT_SUPPRESSION_FILE` | error | an `eslint.suppressions*` file, a `lint:suppressions` script, an eslint suppress flag or a suppressions config (R104) |
65
65
  | `HFS_PROOF_COMMAND_FILE_MISSING` | error | a `.starciwork` `requiresProof.<kind>.command` that runs a file the repository does not hold (R105) |
66
+ | `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) |
66
67
  | `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) |
67
68
  | `HFS_PEER_INTEGRATION_MISSING` | error | the app root `package.json` depends on a driver integration (a pair of `knowledge/hfs/peer-integrations.yaml`) without its runtime peer, e.g. `@nestjs/apollo` on `@nestjs/platform-express` 11 without `@as-integrations/express5` (R111) |
68
69
  | `FE_WIRE_GENERATED` | error | a contract copy with no `codegen` script wired before `build` and `typecheck`, or generated types older than the copy (R52) |
69
70
  | `FE_I18N_PLACEMENT` | error | no `next-intl`, no `src/proxy.ts`, a `middleware.ts`, a route file outside `[locale]`, no `vi.json` catalog (R59) |
70
71
  | `FE_I18N_CATALOG` | error | a locale catalog lacking a key another locale has (R60) |
71
72
  | `FE_I18N_KEYS` | error | a literal key read through `next-intl` that a locale lacks, or a catalog key no source reads (R106; the architecture machine) |
72
- | `FE_NO_TESTS` | error | a front end holds a `*.spec.*`, `*.test.*` or `*-spec.*` file, an `e2e/`, `__tests__/`, `__mocks__/` or `test-support/` directory, a vitest, Playwright, jest or Cypress file, a test script, or a test dependency in a `package.json`; no exception (R97; `scripts/lib/hfs-rules/fe-no-tests.mjs`) |
73
+ | `FE_NO_TESTS` | error | a front end holds a `*.spec.*`, `*.test.*` or `*-spec.*` file, an `e2e/`, `__tests__/`, `__mocks__/` or `test-support/` directory, a vitest, Playwright, jest or Cypress file, a test script, or a test dependency in a `package.json`; no exception (R97; `scripts/hfs/rules/fe-no-tests.mjs`) |
73
74
  | `HFS_GITIGNORE_BLOCK_DRIFT` | error | the managed `.gitignore` block differs from its render (R04; `sync/managed.mjs`) |
74
75
  | `HFS_SONAR_CONFIG` | error | `sonar-project.properties` differs from its render: no `sonar.host.url`, the `sonar.exclusions` of the installed jest preset, the be lcov import with the services as the only coverage scope (R11; `sync/managed.mjs`) |
75
76
  | `HFS_FORMAT` | error | a tracked file the repository's own prettier would change (R19; `sync/format.mjs`, not under `--fast`) |
@@ -81,7 +82,7 @@ Each finding is reported once: the eslint and stylelint one-liners under R17, `t
81
82
  The root and both sides are rendered by the one mechanism. The root: `package.json` `scripts` (`dev:be`, `dev:fe`, `build:be`, `build:fe`, `start:<app>`, `lint`, `lint:fix`, `test`, `test:integration`, `test:e2e`, `test:contract`, `test:stack`, `codegen`, `contract:emit`, `migrate`, `typecheck`, ...; a be script runs from `be/`, where its tsconfig and jest configuration are), `.prettierrc`, `.prettierignore`, the `.husky` hooks, the CI workflows, the Sonar file and the `.gitignore` block. A side: its `tsconfig.json` (resolving `@starci/tsconfig` from the root `node_modules`), its `eslint.config.mjs` one-liner, and for be `tsconfig.build.json`, `src/tests/tsconfig.json` and `jest.config.js`, for fe `stylelint.config.mjs`. `lint` is the one lint gate, `hfs lint`: ESLint over each side with its canon, the app check and stylelint over fe; `lint:fix` is the same with `--fix`. The front end has no test script, no test configuration and no e2e or coverage file: it has no tests, and `FE_NO_TESTS` (R97) refuses any spec, e2e file, test tool or test script under `fe/`. `turbo.json` stays the app's own
82
83
  (`fe.tool-config-repo`): it carries the task graph, which no preset can render.
83
84
 
84
- The architecture machine (`scripts/checks/architecture.mjs` of the runtime, the same code bundled here): tiers and import
85
+ The architecture machine (`scripts/hfs/architecture.mjs` of the runtime, the same code bundled here): tiers and import
85
86
  direction, owner public API, cycles, module registration and composition, clones, dead exports, required files,
86
87
  the source-shape, contract-form and front-end rules, and the repository-tree rules. Each violation and each error is one
87
88
  finding under the machine's own rule id (`BE_TIER_DIRECTION`, `HFS_UNUSED_EXPORT`, `ARCH_OWNER_EXPORT_BYPASS`, ...). A
@@ -120,18 +121,18 @@ every hotspot reviewed, duplicated lines density, cognitive complexity through t
120
121
  open issue, imported or native; that is stricter than the three imports alone and is intended. `hfs check` reports `HFS_SONAR_CONFIG` (R11) when the
121
122
  properties file is not its render or the stack declaration names another gate. R20 and R21 have their Sonar enforcers as conditions of that file.
122
123
 
123
- Coverage is the services' alone, from one scope. The managed `test` script is `jest --selectProjects unit --coverage`: it fails below the per-file 100 threshold on `src/**/*.service.ts` and writes `be/coverage/lcov.info` (the jest preset's lcov reporter). The managed `sonar-project.properties` imports that report (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) with `sonar.coverage.inclusions=be/src/**/*.service.ts` and no other coverage key, so a handler, resolver, controller, module, config file or test is not a coverage target and `fe/` is outside coverage. The managed `codecov.yml` holds the same paths at 100 on the project and the patch and ignores `fe/**`; the managed CI workflow uploads the lcov with `codecov/codecov-action` after the unit run. `hfs sync` renders the scope into both files from the installed jest preset's `COVERAGE_SOURCES` (`coverageScope` in `sync/index.mjs`), so they can never drift. The runtime judges the coverage per file: `sonar-local.mjs scan` holds every service a slice touched at 100, and `sonar-local.mjs dashboard` fails a project unless every service is at 100.
124
+ Coverage is the services' alone, from one scope. The managed `test` script is `jest --selectProjects unit --coverage`: it fails below the per-file 100 threshold on `src/**/*.service.ts` and writes `be/coverage/lcov.info` (the jest preset's lcov reporter). The managed `sonar-project.properties` imports that report (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) with `sonar.coverage.exclusions` set to the complement of the services (SonarQube has no coverage inclusions: `coverageExclusions` in `sync/index.mjs` lists every be source role of the slot manifest's suffix vocabulary other than `service`, the be file names a slot declares outside it such as `main.ts`, `index.ts` and the migrations, and `fe/**`) and no other coverage key, so a handler, resolver, controller, module, config file or test is not a coverage target and `fe/` is outside coverage. The managed `codecov.yml` holds the same paths at 100 on the project and the patch and ignores `fe/**`; the managed CI workflow uploads the lcov with `codecov/codecov-action` after the unit run. `hfs sync` renders the scope into both files from the installed jest preset's `COVERAGE_SOURCES` (`coverageScope` in `sync/index.mjs`), so they can never drift. The runtime judges the coverage per file: `sonar-local.mjs scan` holds every service a slice touched at 100, and `sonar-local.mjs dashboard` fails a project unless every service is at 100.
124
125
 
125
126
  The upload needs the repository secret `CODECOV_TOKEN` (each product monorepo gets its own; the runtime repository's root `.github/workflows/examples.yml` uses the runtime repository's one for the example apps; the owner adds it once per repository: Codecov, the repository's settings, then GitHub Settings > Secrets and variables > Actions > New repository secret `CODECOV_TOKEN`). Without it the step is skipped like the Sonar steps without `SONAR_TOKEN`.
126
127
 
127
128
  ## Maintaining the bundle
128
129
 
129
- `runtime/` is a byte copy of the slot loader files, the pins, and the import closure of `scripts/lib/hfs-check.mjs` and
130
- `scripts/checks/architecture.mjs` (computed by `scripts/sync-runtime.mjs`, so a new import of the machine is bundled without
130
+ `runtime/` is a byte copy of the slot loader files, the pins, and the import closure of `scripts/hfs/check.mjs` and
131
+ `scripts/hfs/architecture.mjs` (computed by `scripts/sync-runtime.mjs`, so a new import of the machine is bundled without
131
132
  editing a list), plus the catalog slice of every code `hfs check` can emit: its own and the machine's (`ARCHITECTURE_RULE_IDS`,
132
133
  derived from the machine's rule id lists). After changing any of those files, `knowledge/hfs/slots.yaml`,
133
- `knowledge/hfs/canon-pins.yaml`, `knowledge/patterns/fe/folder.yaml` or the catalog entries of those codes, run `node packages/hfs/scripts/sync-runtime.mjs`;
134
- `tests/hfs-cli.spec.mjs` fails on a stale copy. Bump `version` here and in the pin when the behaviour changes.
134
+ `knowledge/hfs/canon-pins.yaml`, `knowledge/patterns/fe/folder.yaml` or the catalog entries of those codes, run `node scripts/hfs/sync-runtime.mjs`;
135
+ `tests/packages-hfs/hfs-cli.spec.mjs` fails on a stale copy. Bump `version` here and in the pin when the behaviour changes.
135
136
 
136
137
  The examples gate `node scripts/checks/check-example-architecture.mjs` runs `hfs lint` of this CLI at the root of every `examples/*`
137
138
  app with an `hfs.json` (`examples/todo-app`, `examples/ecommerce-app`) and fails on any finding or any tool that could not run;
package/bin/hfs.mjs CHANGED
@@ -38,9 +38,9 @@
38
38
  // 0 clean, 1 error findings, 2 a refusal or bad usage.
39
39
  import path from 'node:path';
40
40
  import { fileURLToPath } from 'node:url';
41
- import { isMain } from '../runtime/scripts/checks/common.mjs';
42
- import { checkRepository, explainPath, trackedFiles } from '../runtime/scripts/lib/hfs-check.mjs';
43
- import { HfsSlotsError, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
41
+ import { isMain } from '../runtime/scripts/lib/is-main.mjs';
42
+ import { checkRepository, explainPath, trackedFiles } from '../runtime/scripts/hfs/check.mjs';
43
+ import { HfsSlotsError, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
44
44
  import { formatFindings } from '../sync/format.mjs';
45
45
  import { main as syncMain } from '../sync/cli.mjs';
46
46
  import { SyncError, loadPresets } from '../sync/index.mjs';
@@ -48,7 +48,7 @@ import { managedFindings } from '../sync/managed.mjs';
48
48
  import { emitContracts } from '../emit/contracts.mjs';
49
49
  import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
50
50
  import { scaffoldApp } from '../scaffold/app.mjs';
51
- import { contractEmitFindings } from '../runtime/scripts/lib/hfs-rules/contract.mjs';
51
+ import { contractEmitFindings } from '../runtime/scripts/hfs/rules/contract.mjs';
52
52
  import { lintRepository, parseLintArgs, printLintText } from '../lint/run.mjs';
53
53
  import { writeReport } from '../report/sonar.mjs';
54
54
 
package/lint/run.mjs CHANGED
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import { createRequire } from 'node:module';
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
20
- import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
20
+ import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
21
21
  import { STYLE_GLOB } from '../sync/index.mjs';
22
22
 
23
23
  export const LINT_SCHEMA = 'starci/lint@1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.0.6",
3
+ "version": "4.0.8",
4
4
  "description": "The HFS command line of a StarCi app (one repository: the root, be/ and fe/): hfs lint, check, scaffold app, explain, sync and work-hygiene. Self-contained: it carries the runtime files it reads.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -33,6 +33,6 @@
33
33
  "scripts": {
34
34
  "sync": "node scripts/sync-runtime.mjs",
35
35
  "sync:check": "node scripts/sync-runtime.mjs --check",
36
- "test": "node --test \"bin/*.test.mjs\""
36
+ "test": "node --test \"bin/*.spec.mjs\""
37
37
  }
38
38
  }
@@ -16,6 +16,11 @@ const moduleRoot = path.dirname(fileURLToPath(new URL('../package.json', import.
16
16
 
17
17
  /** The runtime root: this source tree (or an immutable sealed payload of it). */
18
18
  export const skillRoot = moduleRoot;
19
+ /**
20
+ * The StarCi Source root (the host containing .claude and .workspaces): STARCI_SOURCE_ROOT overrides it, else the directory
21
+ * holding this runtime checkout. Parameterized over `env`, so a spec can inject a fixture Source.
22
+ */
23
+ export const starciSourceRoot = (env = process.env) => (env.STARCI_SOURCE_ROOT ? path.resolve(env.STARCI_SOURCE_ROOT) : path.dirname(skillRoot));
19
24
 
20
25
  /**
21
26
  * Read a runtime contract document. `parts` are path segments under `skillRoot`
@@ -1,8 +1,8 @@
1
1
  // engine/yaml.mjs — GENERATED, frozen. This is the yaml@2.9.0 package bundled by esbuild, with the
2
2
  // runtime's parseYaml/stringifyYaml wrapper as the entry module (source shown below). The installed
3
- // runtime carries zero npm dependencies (tests/npm-package.spec.mjs), so it vendors this parser
3
+ // runtime carries zero npm dependencies (tests/repo/npm-package.spec.mjs), so it vendors this parser
4
4
  // rather than importing node_modules; yaml is a devDependency and a direct import breaks that
5
- // install. tests/yaml-vendored-parity.spec.mjs proves the vendored parseYaml/stringifyYaml behave
5
+ // install. tests/engine/yaml-vendored-parity.spec.mjs proves the vendored parseYaml/stringifyYaml behave
6
6
  // exactly like yaml@2.9.0 with these options for every tracked *.yaml/*.yml file (parsed values
7
7
  // deep-equal, stringify output byte-identical). License: engine/yaml-license.json.
8
8
  // Rebuild only when deliberately upgrading yaml. The entry source is:
@@ -133,7 +133,7 @@ ${t.comment}`:t.comment}this.doc.range[2]=t.offset;break}default:this.errors.pus
133
133
  `),i=e.substring(0,t),n=e.substring(t+1)+`
134
134
  `;if(s.type==="block-scalar"){let r=s.props[0];if(r.type!=="block-scalar-header")throw new Error("Invalid block scalar header");r.source=i,s.source=n}else{let{offset:r}=s,a="indent"in s?s.indent:-1,l=[{type:"block-scalar-header",offset:r,indent:a,source:i}];Xr(l,"end"in s?s.end:void 0)||l.push({type:"newline",offset:-1,indent:a,source:`
135
135
  `});for(let c of Object.keys(s))c!=="type"&&c!=="offset"&&delete s[c];Object.assign(s,{type:"block-scalar",indent:a,props:l,source:n})}}o(Af,"setBlockScalarValue");function Xr(s,e){if(e)for(let t of e)switch(t.type){case"space":case"comment":s.push(t);break;case"newline":return s.push(t),!0}return!1}o(Xr,"addEndtoBlockProps");function pi(s,e,t){switch(s.type){case"scalar":case"double-quoted-scalar":case"single-quoted-scalar":s.type=t,s.source=e;break;case"block-scalar":{let i=s.props.slice(1),n=e.length;s.props[0].type==="block-scalar-header"&&(n-=s.props[0].source.length);for(let r of i)r.offset+=n;delete s.props,Object.assign(s,{type:t,source:e,end:i});break}case"block-map":case"block-seq":{let n={type:"newline",offset:s.offset+e.length,indent:s.indent,source:`
136
- `};delete s.items,Object.assign(s,{type:t,source:e,end:[n]});break}default:{let i="indent"in s?s.indent:-1,n="end"in s&&Array.isArray(s.end)?s.end.filter(r=>r.type==="space"||r.type==="comment"||r.type==="newline"):[];for(let r of Object.keys(s))r!=="type"&&r!=="offset"&&delete s[r];Object.assign(s,{type:t,indent:i,source:e,end:n})}}}o(pi,"setFlowScalarValue");jt.createScalarToken=kf;jt.resolveAsScalar=vf;jt.setScalarValue=Nf});var ea=y(Zr=>{"use strict";var qf=o(s=>"type"in s?Rt(s):Ft(s),"stringify");function Rt(s){switch(s.type){case"block-scalar":{let e="";for(let t of s.props)e+=Rt(t);return e+s.source}case"block-map":case"block-seq":{let e="";for(let t of s.items)e+=Ft(t);return e}case"flow-collection":{let e=s.start.source;for(let t of s.items)e+=Ft(t);for(let t of s.end)e+=t.source;return e}case"document":{let e=Ft(s);if(s.end)for(let t of s.end)e+=t.source;return e}default:{let e=s.source;if("end"in s&&s.end)for(let t of s.end)e+=t.source;return e}}}o(Rt,"stringifyToken");function Ft({start:s,key:e,sep:t,value:i}){let n="";for(let r of s)n+=r.source;if(e&&(n+=Rt(e)),t)for(let r of t)n+=r.source;return i&&(n+=Rt(i)),n}o(Ft,"stringifyItem");Zr.stringify=qf});var na=y(ia=>{"use strict";var mi=Symbol("break visit"),Of=Symbol("skip children"),ta=Symbol("remove item");function re(s,e){"type"in s&&s.type==="document"&&(s={start:s.start,value:s.value}),sa(Object.freeze([]),s,e)}o(re,"visit");re.BREAK=mi;re.SKIP=Of;re.REMOVE=ta;re.itemAtPath=(s,e)=>{let t=s;for(let[i,n]of e){let r=t?.[i];if(r&&"items"in r)t=r.items[n];else return}return t};re.parentCollection=(s,e)=>{let t=re.itemAtPath(s,e.slice(0,-1)),i=e[e.length-1][0],n=t?.[i];if(n&&"items"in n)return n;throw new Error("Parent collection not found")};function sa(s,e,t){let i=t(e,s);if(typeof i=="symbol")return i;for(let n of["key","value"]){let r=e[n];if(r&&"items"in r){for(let a=0;a<r.items.length;++a){let l=sa(Object.freeze(s.concat([[n,a]])),r.items[a],t);if(typeof l=="number")a=l-1;else{if(l===mi)return mi;l===ta&&(r.items.splice(a,1),a-=1)}}typeof i=="function"&&n==="key"&&(i=i(e,s))}}return typeof i=="function"?i(e,s):i}o(sa,"_visit");ia.visit=re});var Yt=y($=>{"use strict";var gi=zr(),Lf=ea(),Ef=na(),yi="\uFEFF",bi="",Si="",wi="",Tf=o(s=>!!s&&"items"in s,"isCollection"),Cf=o(s=>!!s&&(s.type==="scalar"||s.type==="single-quoted-scalar"||s.type==="double-quoted-scalar"||s.type==="block-scalar"),"isScalar");function If(s){switch(s){case yi:return"<BOM>";case bi:return"<DOC>";case Si:return"<FLOW_END>";case wi:return"<SCALAR>";default:return JSON.stringify(s)}}o(If,"prettyToken");function Mf(s){switch(s){case yi:return"byte-order-mark";case bi:return"doc-mode";case Si:return"flow-error-end";case wi:return"scalar";case"---":return"doc-start";case"...":return"doc-end";case"":case`
136
+ `};delete s.items,Object.assign(s,{type:t,source:e,end:[n]});break}default:{let i="indent"in s?s.indent:-1,n="end"in s&&Array.isArray(s.end)?s.end.filter(r=>r.type==="space"||r.type==="comment"||r.type==="newline"):[];for(let r of Object.keys(s))r!=="type"&&r!=="offset"&&delete s[r];Object.assign(s,{type:t,indent:i,source:e,end:n})}}}o(pi,"setFlowScalarValue");jt.createScalarToken=kf;jt.resolveAsScalar=vf;jt.setScalarValue=Nf});var ea=y(Zr=>{"use strict";var qf=o(s=>"type"in s?Rt(s):Ft(s),"stringify");function Rt(s){switch(s.type){case"block-scalar":{let e="";for(let t of s.props)e+=Rt(t);return e+s.source}case"block-map":case"block-seq":{let e="";for(let t of s.items)e+=Ft(t);return e}case"flow-collection":{let e=s.start.source;for(let t of s.items)e+=Ft(t);for(let t of s.end)e+=t.source;return e}case"document":{let e=Ft(s);if(s.end)for(let t of s.end)e+=t.source;return e}default:{let e=s.source;if("end"in s&&s.end)for(let t of s.end)e+=t.source;return e}}}o(Rt,"stringifyToken");function Ft({start:s,key:e,sep:t,value:i}){let n="";for(let r of s)n+=r.source;if(e&&(n+=Rt(e)),t)for(let r of t)n+=r.source;return i&&(n+=Rt(i)),n}o(Ft,"stringifyItem");Zr.stringify=qf});var na=y(ia=>{"use strict";var mi=Symbol("break visit"),Of=Symbol("skip children"),ta=Symbol("remove item");function re(s,e){"type"in s&&s.type==="document"&&(s={start:s.start,value:s.value}),sa(Object.freeze([]),s,e)}o(re,"visit");re.BREAK=mi;re.SKIP=Of;re.REMOVE=ta;re.itemAtPath=(s,e)=>{let t=s;for(let[i,n]of e){let r=t?.[i];if(r&&"items"in r)t=r.items[n];else return}return t};re.parentCollection=(s,e)=>{let t=re.itemAtPath(s,e.slice(0,-1)),i=e[e.length-1][0],n=t?.[i];if(n&&"items"in n)return n;throw new Error("Parent collection not found")};function sa(s,e,t){let i=t(e,s);if(typeof i=="symbol")return i;for(let n of["key","value"]){let r=e[n];if(r&&"items"in r){for(let a=0;a<r.items.length;++a){let l=sa(Object.freeze(s.concat([[n,a]])),r.items[a],t);if(typeof l=="number")a=l-1;else{if(l===mi)return mi;l===ta&&(r.items.splice(a,1),a-=1)}}typeof i=="function"&&n==="key"&&(i=i(e,s))}}return typeof i=="function"?i(e,s):i}o(sa,"_visit");ia.visit=re});var Yt=y($=>{"use strict";var gi=zr(),Lf=ea(),Ef=na(),yi="\uFEFF",bi="\u0002",Si="\u0018",wi="\u001f",Tf=o(s=>!!s&&"items"in s,"isCollection"),Cf=o(s=>!!s&&(s.type==="scalar"||s.type==="single-quoted-scalar"||s.type==="double-quoted-scalar"||s.type==="block-scalar"),"isScalar");function If(s){switch(s){case yi:return"<BOM>";case bi:return"<DOC>";case Si:return"<FLOW_END>";case wi:return"<SCALAR>";default:return JSON.stringify(s)}}o(If,"prettyToken");function Mf(s){switch(s){case yi:return"byte-order-mark";case bi:return"doc-mode";case Si:return"flow-error-end";case wi:return"scalar";case"---":return"doc-start";case"...":return"doc-end";case"":case`
137
137
  `:case`\r
138
138
  `:return"newline";case"-":return"seq-item-ind";case"?":return"explicit-key-ind";case":":return"map-value-ind";case"{":return"flow-map-start";case"}":return"flow-map-end";case"[":return"flow-seq-start";case"]":return"flow-seq-end";case",":return"comma"}switch(s[0]){case" ":case" ":return"space";case"#":return"comment";case"%":return"directive-line";case"*":return"alias";case"&":return"anchor";case"!":return"tag";case"'":return"single-quoted-scalar";case'"':return"double-quoted-scalar";case"|":case">":return"block-scalar-header"}return null}o(Mf,"tokenType");$.createScalarToken=gi.createScalarToken;$.resolveAsScalar=gi.resolveAsScalar;$.setScalarValue=gi.setScalarValue;$.stringify=Lf.stringify;$.visit=Ef.visit;$.BOM=yi;$.DOCUMENT=bi;$.FLOW_END=Si;$.SCALAR=wi;$.isCollection=Tf;$.isScalar=Cf;$.prettyToken=If;$.tokenType=Mf});var Ni=y(aa=>{"use strict";var Je=Yt();function K(s){switch(s){case void 0:case" ":case`
139
139
  `:case"\r":case" ":return!0;default:return!1}}o(K,"isEmpty");var ra=new Set("0123456789ABCDEFabcdef"),Pf=new Set("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-#;/?:@&=+$_.!~*'()"),Ut=new Set(",[]{}"),_f=new Set(` ,[]{}
@@ -17,68 +17,68 @@ pins:
17
17
  # --- @starci packages. Every @starci package is a PUBLISHED npm package (install: registry): a product repository
18
18
  # installs the exact pinned version from the npm registry in the root and every workspace package.json, never `file:`.
19
19
  '@starci/heroicons':
20
- version: 0.3.1
20
+ version: 0.3.2
21
21
  group: starci
22
22
  install: registry
23
23
  side: fe
24
24
  source: packages/heroicons/package.json
25
25
  why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
26
26
  '@starci/grammar':
27
- version: 0.8.1
27
+ version: 0.8.2
28
28
  group: starci
29
29
  install: registry
30
30
  side: fe
31
31
  source: packages/grammar/package.json
32
32
  why: nivo-fe pins 0.4.11 and 0.5.0 in one workspace, starci-next-fe 0.5.1, miamia-fe 0.5.0; the runtime source is 0.8.0 (the brand layer sets `--font-sans` and `--font-mono`; the grammar reads them; 0.7.2 added the Input tel kind and IconButton disclosure props).
33
33
  '@starci/eslint-canon-be':
34
- version: 3.0.6
34
+ version: 3.0.8
35
35
  group: starci
36
36
  install: registry
37
37
  side: be
38
38
  source: packages/eslint/be/package.json
39
39
  why: '3.0.3: its bundled canon-pins copy pins stylelint-canon 2.0.2 and hfs 4.0.3; no rule changed. 3.0.2: its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root (app.starcistacks, app.sops) and pin hfs 4.0.2. 3.0.1: its bundled canon-pins copy pins hfs 4.0.1. 3.0.0: `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; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
40
40
  '@starci/eslint-canon-fe':
41
- version: 8.0.6
41
+ version: 8.0.8
42
42
  group: starci
43
43
  install: registry
44
44
  side: fe
45
45
  source: packages/eslint/fe/package.json
46
46
  why: '8.0.3: its bundled canon-pins copy pins stylelint-canon 2.0.2 and hfs 4.0.3; no rule changed. 8.0.2: its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root (app.starcistacks, app.sops) and pin hfs 4.0.2. 8.0.1: its bundled canon-pins copy pins hfs 4.0.1. 8.0.0: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side; the project graph is built per side. 7.0.0: the front end has no tests (FE_NO_TESTS R97 in hfs); no-vietnamese-in-source (R91).'
47
47
  '@starci/stylelint-canon':
48
- version: 2.0.2
48
+ version: 2.0.3
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/stylelint/package.json
53
53
  why: '2.0.2: no-class-selector refuses a compound class selector (`div.card`), which the 2.0.1 pattern let through. 2.0.1 accepts `--font-sans` and `--font-mono` in the brand layer (the vocabulary follows the grammar 0.8.0). 2.0.0 adds status-contrast (HeroUI soft pairs measured per theme), fixes brand-layer-shape on the shared :root,.light,.dark block and the info soft pair, and derives appTokens with loadAppTokens(import.meta.url) for the managed one-line stylelint.config.mjs.'
54
54
  '@starci/tsconfig':
55
- version: 2.0.1
55
+ version: 2.0.2
56
56
  group: starci
57
57
  install: registry
58
58
  side: both
59
59
  source: packages/tsconfig/package.json
60
60
  '@starci/prettier-config':
61
- version: 1.0.0
61
+ version: 1.0.1
62
62
  group: starci
63
63
  install: registry
64
64
  side: both
65
65
  source: packages/prettier-config/package.json
66
66
  '@starci/jest-preset':
67
- version: 2.2.2
67
+ version: 2.2.3
68
68
  group: starci
69
69
  install: registry
70
70
  side: be
71
71
  source: packages/jest-preset/package.json
72
72
  why: '2.2.2: the unit run writes the lcov Sonar and Codecov import (services only); 2.2.0: the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; 2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
73
73
  '@starci/test-world':
74
- version: 1.0.5
74
+ version: 1.0.6
75
75
  group: starci
76
76
  install: registry
77
77
  side: be
78
78
  source: packages/test-world/package.json
79
79
  why: '1.0.5: a modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, and w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client. 1.0.4: world.resolve and scope.resolve take any Nest token (class, string or symbol). 1.0.3: the globalSetup registers the path aliases as TypeScript resolves them (the extends chain, paths from the config that declares them, the effective baseUrl), so a tests tsconfig that only extends the side config loads the declaration. 1.0.2: every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json), where .starcistacks lives, never from the be side. The shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end. 1.0.1: world.keycloak.events/sessions (the user events of the realm and live sessions through the admin API) and world.infra.postgresql.connection(name) (the database of one connection down while the others serve, under the outage lock).'
80
80
  '@starci/hfs':
81
- version: 4.0.6
81
+ version: 4.0.8
82
82
  group: starci
83
83
  install: registry
84
84
  side: both
@@ -4,7 +4,7 @@
4
4
  # the CI, the hooks and .starciwork (slots of profile `app`); `be/` and `fe/` are its two sides, each laid out as the old
5
5
  # standalone repository root (slots of profile be or fe, paths relative to the side folder). Every tracked path of every
6
6
  # repository must match exactly one slot. Checks, lint factories, the architecture machine, templates and the why
7
- # catalog READ this file through scripts/lib/hfs-slots.mjs; none of them hardcodes a path.
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@2
10
10
  version: 2.0.0
@@ -97,7 +97,7 @@ tiers:
97
97
  crossOwner: every import across owners targets the owner's public entry (index.ts or index.tsx); never export *.
98
98
  crossApp: apps never import each other; shared code is a packages/<pkg> slot.
99
99
 
100
- # Parameters the lint factories and checks read per profile (through ruleParams(profile) in scripts/lib/hfs-slots.mjs).
100
+ # Parameters the lint factories and checks read per profile (through ruleParams(profile) in scripts/hfs/slots.mjs).
101
101
  ruleParams:
102
102
  be:
103
103
  # HFS_SIZE_GROWTH: a file above soft may not grow against its parent commit; a new file stays within soft.
@@ -1166,9 +1166,9 @@ slots:
1166
1166
 
1167
1167
  # Checks that read this manifest (all ship from .claude; none keeps its own path list)
1168
1168
  consumers:
1169
- - scripts/lib/hfs-slots.mjs # the loader every consumer below goes through
1170
- - scripts/lib/hfs-check.mjs # hfs check: HFS_SLOT_* / tracked / external, the side view per side
1171
- - scripts/checks/architecture.mjs # tiers, direction matrix, cycles, reachability
1169
+ - scripts/hfs/slots.mjs # the loader every consumer below goes through
1170
+ - scripts/hfs/check.mjs # hfs check: HFS_SLOT_* / tracked / external, the side view per side
1171
+ - scripts/hfs/architecture.mjs # tiers, direction matrix, cycles, reachability
1172
1172
  - packages/eslint/be starciBeConfig({hfs}) # file globs for rules come from slots
1173
1173
  - packages/eslint/fe starciFeConfig({hfs})
1174
1174
  - packages/stylelint # @starci/stylelint-canon: colour and brand allowances from fe.modules.brand
@@ -40,11 +40,11 @@ rules:
40
40
  fe.source-root-pinned, even though the machine list names it as a file Next loads. Any other file at the source
41
41
  root, including a helper folder beside a pinned file, has no owner and is refused.
42
42
  frameworkPinnedRootFiles:
43
- # Machine list read by scripts/checks/architecture/framework-pinned.mjs (FE_SOURCE_LAYOUT_INVALID accepts exactly
43
+ # Machine list read by scripts/hfs/architecture/framework-pinned.mjs (FE_SOURCE_LAYOUT_INVALID accepts exactly
44
44
  # these basenames directly in a Next source root; FE_TIER_DIRECTION keeps them thin). Exact names,
45
45
  # case-sensitive, no globs. It lists what the framework loads from the source root. Which of them a repository may
46
46
  # keep is decided by the slot fe.source-root-pinned and FE_NEXT_CONVENTIONS, not by this list.
47
- # Machine list read by scripts/checks/architecture/framework-pinned.mjs (FE_SOURCE_LAYOUT_INVALID
47
+ # Machine list read by scripts/hfs/architecture/framework-pinned.mjs (FE_SOURCE_LAYOUT_INVALID
48
48
  # accepts exactly these basenames directly in a Next source root;
49
49
  # their imports follow the FE_TIER_DIRECTION matrix). Exact names, case-sensitive, no globs. Supervisor rulings for nivo
50
50
  # wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4 and inc-846867b9a34e (proxy is the Next 16 name of
@@ -4,7 +4,7 @@ title: The Sonar quality gate every product is held to
4
4
  purpose: |
5
5
  The one place the Sonar thresholds live. A product repository never restates them: its
6
6
  .starcistacks/application-stacks.yaml `services.sonar.qualityGate` names the gate below, and
7
- scripts/checks/sonar-local.mjs (1) makes the local SonarQube's gate of that name carry exactly these
7
+ scripts/gates/sonar-local.mjs (1) makes the local SonarQube's gate of that name carry exactly these
8
8
  conditions and selects it for the project, and (2) judges an op's slice - the lines it changed - against
9
9
  the same numbers. Code-writing ops cannot settle done while the slice is red
10
10
  (scripts/kernel/sonar-settle.mjs). Changing a number here changes every repository on the next scan.
@@ -20,7 +20,7 @@ gate:
20
20
  newCode:
21
21
  # Coverage of the services only. The be unit run writes coverage/lcov.info (@starci/jest-preset coverageReporters lcov) and the
22
22
  # managed sonar-project.properties imports it (sonar.javascript.lcov.reportPaths=be/coverage/lcov.info) with
23
- # sonar.coverage.inclusions=be/src/**/*.service.ts, the one scope rendered by hfs sync from the preset's COVERAGE_SOURCES (codecov.yml
23
+ # sonar.coverage.exclusions = the complement of be/src/**/*.service.ts (SonarQube has no coverage inclusions), rendered by hfs sync from the preset's COVERAGE_SOURCES and the slot manifest (codecov.yml
24
24
  # is rendered from the same constant). A handler, resolver, controller, module, config file or test is not a coverage target and
25
25
  # fe/ is outside coverage entirely, so coverage on these conditions is the services' coverage alone.
26
26
  coverage:
@@ -86,7 +86,7 @@ ARCH_KNOWLEDGE_UNAVAILABLE:
86
86
  meaning_vi: "Bộ kiểm kiến trúc cần một tệp tri thức đi kèm (danh sách tệp gốc do framework ghim) nhưng không đọc được hoặc sai khuôn, nên không phán xét bằng hợp đồng khác."
87
87
  causes_vi:
88
88
  - "Bản đóng gói của `hfs` thiếu hoặc hỏng tệp tri thức, hoặc tệp tri thức trong runtime bị sửa sai khuôn."
89
- nextStep_vi: "Cài lại `@starci/hfs` đúng phiên bản đã ghim; nếu là runtime thì sửa tệp tri thức rồi chạy `node packages/hfs/scripts/sync-runtime.mjs`."
89
+ nextStep_vi: "Cài lại `@starci/hfs` đúng phiên bản đã ghim; nếu là runtime thì sửa tệp tri thức rồi chạy `node scripts/hfs/sync-runtime.mjs`."
90
90
  owner: runtime-core
91
91
  kind: check-finding
92
92
 
@@ -667,6 +667,16 @@ FE_ERROR_BOUNDARY_MISSING:
667
667
  owner: op-retry
668
668
  kind: check-finding
669
669
 
670
+ FE_GRAPHQL_CONTRACT:
671
+ title: "A front-end GraphQL document is one its back end serves"
672
+ title_vi: "Tài liệu GraphQL của front end không khớp hợp đồng của back end"
673
+ meaning_vi: "Một thao tác trong tệp `.graphql` dưới `fe/` không khớp ảnh chụp hợp đồng `be/contracts/<service>/schema.graphql` của dịch vụ phục vụ trường gốc đầu tiên của nó: trường, đối số hoặc trường input không được khai báo, thiếu đối số bắt buộc, biến khác kiểu, chọn trường trên giá trị lá hoặc thiếu lựa chọn trên đối tượng, biến khai báo mà không dùng, hoặc không hợp đồng nào phục vụ trường gốc đó."
674
+ causes_vi:
675
+ - "Vi phạm luật R113: front end gửi một tài liệu viết theo hợp đồng cũ (ví dụ `(request: ...)` trong khi back end nhận `input`), nên lời gọi hỏng khi chạy mà type-check của front end không thấy."
676
+ 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: sửa tài liệu `.graphql` cho khớp hợp đồng (hoặc chạy `npm run contract:emit` nếu ảnh chụp đã cũ); không thêm ngoại lệ."
677
+ owner: op-retry
678
+ kind: check-finding
679
+
670
680
  FE_HOOKS_ARE_HOOKS:
671
681
  title: "`hooks/` hold hooks and one shared file per domain"
672
682
  title_vi: "hooks/ chứa thứ không phải hook"
@@ -1496,7 +1506,7 @@ HFS_SONAR_CONFIG:
1496
1506
  title_vi: "Cấu hình Sonar lệch"
1497
1507
  meaning_vi: "Cấu hình Sonar lệch: `<detail>`. Sonar phải đọc đúng cấu hình sinh ra và nhập báo cáo độ phủ lcov của be, chỉ tính các service."
1498
1508
  causes_vi:
1499
- - "Vi phạm luật R11: `sonar-project.properties` do template sinh: không `sonar.host.url`, `sources`/`tests` không chồng, `sonar.exclusions` = đúng danh sách loại trừ của jest, nhập lcov của be (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) với `sonar.coverage.inclusions=be/src/**/*.service.ts` và không khóa độ phủ nào khác, không nhắc kiểu spec đã bỏ, và nhận báo cáo ESLint cùng tệp nhập lỗi HFS (`sonar.eslint.reportPaths`, `sonar.externalIssuesReportPaths`) để mọi lỗi canon hiện trong Sonar."
1509
+ - "Vi phạm luật R11: `sonar-project.properties` do template sinh: không `sonar.host.url`, `sources`/`tests` không chồng, `sonar.exclusions` = đúng danh sách loại trừ của jest, nhập lcov của be (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) với `sonar.coverage.exclusions` loại mọi tệp không phải service (SonarQube không có coverage inclusions; hfs sync sinh phần bù từ COVERAGE_SOURCES và bộ hậu tố của slot manifest, gồm cả fe/**) và không khóa độ phủ nào khác, không nhắc kiểu spec đã bỏ, và nhận báo cáo ESLint cùng tệp nhập lỗi HFS (`sonar.eslint.reportPaths`, `sonar.externalIssuesReportPaths`) để mọi lỗi canon hiện trong Sonar."
1500
1510
  - "Khối `services.sonar.qualityGate` của khai báo stack phải gọi đúng cổng chất lượng duy nhất của `knowledge/sonar-gate.yaml`; repo không tự ghi ngưỡng."
1501
1511
  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 (nợ hàng loạt có codemod của HFS); không cần ai can thiệp thêm."
1502
1512
  owner: op-retry
@@ -0,0 +1,6 @@
1
+ // scripts/api/fs/lib.mjs — what the fs call files beside it share: the error codes Windows returns while another
2
+ // process holds a path open. The call files (safe-remove.mjs, rename-over.mjs, rmdir-link.mjs, zip-write.mjs) each
3
+ // name one filesystem use.
4
+
5
+ /** The rename and unlink errors Windows returns while another process holds the target open: retried, never final. */
6
+ export const FS_BUSY = Object.freeze(['EPERM', 'EBUSY', 'EACCES']);
@@ -1,5 +1,5 @@
1
1
  // rmdir-link.mjs — the link-safe removal primitive: `cmd /d /c rmdir <link>` (no /s) removes a Windows junction or
2
- // directory symlink as a link and never touches its target. scripts/lib/safe-remove.mjs removeLink is its one caller and
2
+ // directory symlink as a link and never touches its target. scripts/api/fs/safe-remove.mjs removeLink is its one caller and
3
3
  // checks afterwards that the link is gone (unlinkOnly); this file only issues the call.
4
4
 
5
5
  import { spawnSync } from 'node:child_process';
@@ -13,18 +13,19 @@
13
13
  // its own path under its parent's real path (any name-surrogate reparse point). A link that cannot be
14
14
  // unlinked stops the removal of everything above it; nothing is ever deleted through it.
15
15
  //
16
- // safeRemoveWorktree removes a git worktree: every link removed as a link first (found without following one), zero
17
- // links asserted, only then `git worktree remove --force`, and the main checkout asserted untouched afterwards.
16
+ // A worktree's removal composes this with git (scripts/machine/worktree-git.mjs safeRemoveWorktree, the Orca home in
17
+ // scripts/machine/worktree-orca.mjs): every link removed as a link first (removeLinksUnder), zero asserted, only then git
18
+ // or Orca. Whether a tree holds an indexed job artifact is the caller's decision (`hold`: scripts/machine/artifact-hold.mjs
19
+ // artifactHoldReason), taken before anything is deleted.
18
20
  import fs from 'node:fs';
19
21
  import os from 'node:os';
20
22
  import path from 'node:path';
21
23
  import { fileURLToPath } from 'node:url';
22
- import { sleepSync } from './sleep-sync.mjs';
23
- import { samePath } from './path-key.mjs';
24
- import { gitSpawn } from '../api/git/lib.mjs';
25
- import { rmdirLink } from '../api/fs/rmdir-link.mjs';
26
- import { artifactHoldReason } from './artifact-hold.mjs';
27
- import { realpathOr } from './fs-kind.mjs';
24
+ import { sleepSync } from '../../lib/sleep-sync.mjs';
25
+ import { samePath } from '../../lib/path-key.mjs';
26
+ import { rmdirLink } from './rmdir-link.mjs';
27
+ import { FS_BUSY } from './lib.mjs';
28
+ import { realpathOr } from '../../lib/fs-kind.mjs';
28
29
 
29
30
  const WIN = process.platform === 'win32';
30
31
  const same = samePath;
@@ -73,7 +74,7 @@ const retrying = (fn, retries) => {
73
74
  for (let attempt = 0; ; attempt += 1) {
74
75
  try { fn(); return null; } catch (error) {
75
76
  if (error?.code === 'ENOENT') return null;
76
- if (attempt >= retries || !['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(error?.code)) return error;
77
+ if (attempt >= retries || ![...FS_BUSY, 'ENOTEMPTY'].includes(error?.code)) return error;
77
78
  sleepSync(25 * (attempt + 1));
78
79
  }
79
80
  }
@@ -84,7 +85,7 @@ const removeFile = (p, retries) => retrying(() => {
84
85
  }
85
86
  }, retries);
86
87
 
87
- const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
88
+ const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
88
89
  /**
89
90
  * True when `p` sits strictly below `root` by real path: no link on the way, not `root` itself. The one place a
90
91
  * primary checkout may be removed is a disposable fixture a caller names by its temp root (hk-tmp).
@@ -105,17 +106,20 @@ export function strictlyInsideReal(p, root) {
105
106
  * scratch trees the runtime removes are temp directories and linked worktrees, whose .git is a file).
106
107
  * `checkoutsUnder` names a disposable root (hk-tmp's temp root): a checkout strictly inside it by real path
107
108
  * is a spec fixture, not a live repository, and is not refused for its .git. Every other refusal stands.
108
- * A tree holding an indexed job artifact, or inside an evidence directory holding one, is refused whatever the
109
- * workflow's phase (artifact-hold.mjs).
109
+ * `hold(path)` is the caller's artifact-hold check (scripts/machine/artifact-hold.mjs artifactHoldReason: a tree holding
110
+ * an indexed job artifact, or inside an evidence directory holding one, whatever the workflow's phase), its refusal line
111
+ * or null. It is required: without one every path is refused (fail closed); a caller that removes only a scratch tree
112
+ * it made itself, outside every ledger's repository, says so with `hold: () => null`.
110
113
  */
111
- export function forbiddenRoot(p, { checkoutsUnder = null } = {}) {
114
+ export function forbiddenRoot(p, { checkoutsUnder = null, hold } = {}) {
112
115
  const resolved = path.resolve(p);
113
116
  if (path.parse(resolved).root === resolved || same(path.dirname(resolved), resolved)) return 'a filesystem root';
114
117
  for (const [name, dir] of [['the home directory', os.homedir()], ['the temp directory', os.tmpdir()], ['the runtime', SKILL_ROOT],
115
118
  ['the repository hosting the runtime', path.dirname(SKILL_ROOT)], ['the repositories root', path.dirname(path.dirname(SKILL_ROOT))]]) {
116
119
  if (dir && same(path.resolve(dir), resolved)) return name;
117
120
  }
118
- const held = artifactHoldReason(resolved);
121
+ if (typeof hold !== 'function') return 'a tree no artifact-hold check cleared (pass hold)';
122
+ const held = hold(resolved);
119
123
  if (held) return held;
120
124
  let checkout = false;
121
125
  try { checkout = fs.lstatSync(path.join(resolved, '.git')).isDirectory(); } catch { /* no .git directory */ }
@@ -125,14 +129,14 @@ export function forbiddenRoot(p, { checkoutsUnder = null } = {}) {
125
129
 
126
130
  /**
127
131
  * Remove `root` and everything under it without ever following a link. Links are unlinked (the link only);
128
- * plain files and directories are deleted bottom-up. `checkoutsUnder` is forbiddenRoot's disposable root. Returns {ok, root, removed: {files, dirs, links},
132
+ * plain files and directories are deleted bottom-up. `checkoutsUnder` and `hold` (required) are forbiddenRoot's. Returns {ok, root, removed: {files, dirs, links},
129
133
  * errors: [{path, code, message}]}; ok is true only when `root` is gone. A missing root is ok.
130
134
  */
131
- export function safeRemoveTree(root, { retries = 5, checkoutsUnder = null } = {}) {
135
+ export function safeRemoveTree(root, { retries = 5, checkoutsUnder = null, hold } = {}) {
132
136
  const target = path.resolve(String(root ?? ''));
133
137
  const out = { ok: false, root: target, removed: { files: 0, dirs: 0, links: 0 }, errors: [] };
134
138
  const fail = (p, error) => { out.errors.push({ path: p, code: error?.code ?? 'ERROR', message: String(error?.message ?? error) }); };
135
- const refused = root ? forbiddenRoot(target, { checkoutsUnder }) : 'no path';
139
+ const refused = root ? forbiddenRoot(target, { checkoutsUnder, hold }) : 'no path';
136
140
  if (refused) { fail(target, { code: 'REFUSED', message: `refusing to remove ${refused}` }); return out; }
137
141
  let st;
138
142
  try { st = fs.lstatSync(target); } catch (error) {
@@ -191,29 +195,8 @@ export function removeLink(p) {
191
195
  return unlinkOnly(p);
192
196
  }
193
197
 
194
- /** The main checkout's state a removal must never change: its tracked deletions and its node_modules entry counts. */
195
- export function mainCheckoutGuard(mainRoot, { git = null } = {}) {
196
- const run = git ?? ((args, opts) => gitSpawn('git', args, { cwd: opts.cwd, maxBuffer: 64 * 1024 * 1024 }));
197
- const count = (rel) => { try { return fs.readdirSync(path.join(mainRoot, rel)).length; } catch { return null; } };
198
- // porcelain v2 ("1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>"): no leading blank a runner's trim could eat.
199
- const st = run(['status', '--porcelain=v2', '--untracked-files=no'], { cwd: mainRoot });
200
- const text = String(st?.stdout ?? st?.out ?? '');
201
- const ok = st?.ok ?? (!st?.error && st?.status === 0);
202
- const deleted = text.split(/\r?\n/).map((l) => l.trim().split(' ')).filter((f) => f[0] === '1' && f.length >= 9 && f[1].includes('D')).map((f) => f.slice(8).join(' '));
203
- return { ok: Boolean(ok), deleted: new Set(deleted),
204
- nodeModules: count('node_modules'), packagesNodeModules: count(path.join('packages', 'node_modules')) };
205
- }
206
- /** What changed in the main checkout between two guards: [] when nothing. */
207
- export function mainCheckoutDamage(before, after) {
208
- const out = [];
209
- if (before.ok && after.ok) for (const f of after.deleted) if (!before.deleted.has(f)) out.push(`tracked file deleted: ${f}`);
210
- if (before.nodeModules !== after.nodeModules) out.push(`node_modules entries ${before.nodeModules} -> ${after.nodeModules}`);
211
- if (before.packagesNodeModules !== after.packagesNodeModules) out.push(`packages/node_modules entries ${before.packagesNodeModules} -> ${after.packagesNodeModules}`);
212
- return out;
213
- }
214
-
215
198
  /**
216
- * The link step of every worktree removal (git's here, Orca's in scripts/api/orca/worktree-remove.mjs removeOrcaWorktree): every link
199
+ * The link step of every worktree removal (scripts/machine/worktree-git.mjs safeRemoveWorktree, worktree-orca.mjs removeOrcaWorktree): every link
217
200
  * under `target` found WITHOUT following one (linksUnder), each removed as a link (removeLink: `cmd /c rmdir <link>`, never
218
201
  * /s), outermost first, then a re-scan that must find ZERO. {ok, links, errors: [{path, code, message}]}; ok false: a link
219
202
  * is stuck and the caller removes nothing.
@@ -228,48 +211,14 @@ export function removeLinksUnder(target) {
228
211
  }
229
212
 
230
213
  /**
231
- * Remove a git worktree (the one algorithm; the 490-file .claude incident and nivo-fe inc-c8fbf76aa499):
232
- * 1. enumerate every link in it WITHOUT following one (linksUnder);
233
- * 2. remove each as a link (removeLink: `cmd /c rmdir <link>`, never /s), outermost first;
234
- * 3. re-scan the same way and refuse (link-stuck, nothing deleted) unless ZERO links remain;
235
- * 4. only then `git worktree remove --force` (a link-free tree: git cannot walk out of it); a directory git does not know
236
- * goes through safeRemoveTree (never follows a link); `git worktree prune`;
237
- * 5. assert the main checkout is untouched: no new tracked deletion, node_modules and packages/node_modules entry counts
238
- * unchanged - a violation is {ok:false, fatal:true, reason:'main-checkout-damaged'}: the caller (the GC) stops.
239
- * Never robocopy, rm -rf or rmdir /s. `git(args, {cwd})` is the caller's git runner; `repo` any checkout of the repository.
240
- * {ok, root, links, removed, errors, damage?}
214
+ * Unlink `<dir>/node_modules` when it is a link (one an older runtime made; no runtime code makes one, RT_NODE_MODULES_LINK).
215
+ * true when no link is left there; a real directory is left alone (true: it is the checkout's own).
241
216
  */
242
- export function safeRemoveWorktree(worktree, { repo, git = null, retries = 5 } = {}) {
243
- const target = path.resolve(String(worktree ?? ''));
244
- const run = git ?? ((args, opts) => gitSpawn('git', args, { cwd: opts.cwd, maxBuffer: 64 * 1024 * 1024 }));
245
- const out = { ok: false, root: target, links: 0, removed: { files: 0, dirs: 0, links: 0 }, errors: [] };
246
- const list = repo ? String((run(['worktree', 'list', '--porcelain'], { cwd: repo }) ?? {}).stdout ?? '') : '';
247
- const trees = list.split(/\r?\n/).filter((l) => l.startsWith('worktree ')).map((l) => path.resolve(l.slice(9).trim()));
248
- const mainRoot = trees[0] ?? null;
249
- if (mainRoot && same(mainRoot, target)) { out.errors.push({ path: target, code: 'REFUSED', message: 'refusing to remove the main checkout' }); return out; }
250
- const refused = forbiddenRoot(target);
251
- if (refused) { out.errors.push({ path: target, code: 'REFUSED', message: `refusing to remove ${refused}` }); return out; }
252
- const before = mainRoot ? mainCheckoutGuard(mainRoot, { git: run }) : null;
253
- if (fs.existsSync(target)) {
254
- const unlinked = removeLinksUnder(target);
255
- out.links = unlinked.links;
256
- if (!unlinked.ok) { out.errors.push(...unlinked.errors); out.reason = 'link-stuck'; return out; }
257
- out.removed.links = out.links;
258
- const registered = trees.some((t) => same(t, target));
259
- if (registered && repo) run(['worktree', 'remove', '--force', target], { cwd: repo });
260
- if (fs.existsSync(target)) {
261
- if (linksUnder(target).length) { out.errors.push({ path: target, code: 'LINK_STUCK', message: 'a link appeared during removal' }); out.reason = 'link-stuck'; return out; }
262
- const rm = safeRemoveTree(target, { retries });
263
- out.removed.files += rm.removed.files; out.removed.dirs += rm.removed.dirs;
264
- out.errors.push(...rm.errors);
265
- }
266
- }
267
- if (repo) { try { run(['worktree', 'prune'], { cwd: repo }); } catch { /* the registration is pruned on the next prune */ } }
268
- if (before) {
269
- const damage = mainCheckoutDamage(before, mainCheckoutGuard(mainRoot, { git: run }));
270
- if (damage.length) { out.damage = damage; out.fatal = true; out.reason = 'main-checkout-damaged'; out.errors.push({ path: mainRoot, code: 'main-checkout-damaged', message: damage.join('; ') }); return out; }
271
- }
272
- out.ok = !fs.existsSync(target) && (() => { try { fs.lstatSync(target); return false; } catch { return true; } })();
273
- if (!out.ok && !out.reason) out.reason = 'remove-failed';
274
- return out;
217
+ export function unlinkNodeModulesLink(dir) {
218
+ const nm = path.join(dir, 'node_modules');
219
+ let st;
220
+ try { st = fs.lstatSync(nm); } catch { return true; }
221
+ if (!st.isSymbolicLink()) return true;
222
+ try { fs.unlinkSync(nm); } catch { try { fs.rmdirSync(nm); } catch { /* checked below */ } }
223
+ try { fs.lstatSync(nm); return false; } catch { return true; }
275
224
  }
@@ -1,4 +1,4 @@
1
- // scripts/api/git/lib.mjs — the one place the runtime spawns git (scripts/checks/check-layers.mjs enforces it).
1
+ // scripts/api/git/lib.mjs — the one place the runtime spawns git (RT_EXTERNAL_OWNER of scripts/hfs/runtime-rules/external-owner.mjs enforces it).
2
2
  //
3
3
  // Every caller spelt the same options by hand - encoding:'utf8', windowsHide:true, sometimes a timeout - with two shapes:
4
4
  // `git args` in a cwd and `git -C dir args`. gitOutput is the throwing shape (stdout text, or an Error on a non-zero exit).