@starci/hfs 2.0.2 → 4.0.0

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 (207) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +33 -33
  3. package/bin/hfs.mjs +94 -68
  4. package/lint/run.mjs +161 -0
  5. package/package.json +3 -2
  6. package/report/sonar.mjs +14 -29
  7. package/runtime/engine/admission.mjs +3 -3
  8. package/runtime/engine/ledger-db.mjs +2 -2
  9. package/runtime/engine/machine-db.mjs +90 -9
  10. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  11. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  12. package/runtime/knowledge/hfs/canon-pins.yaml +32 -13
  13. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  14. package/runtime/knowledge/hfs/slots.yaml +193 -128
  15. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  16. package/runtime/knowledge/sonar-gate.yaml +8 -7
  17. package/runtime/modules/kernel/failure-codes.yaml +31 -52
  18. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  19. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  20. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  21. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  22. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  23. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  24. package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/hfs.mjs +105 -67
  26. package/runtime/scripts/checks/architecture/index.mjs +18 -13
  27. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  28. package/runtime/scripts/checks/architecture/owners.mjs +12 -6
  29. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  30. package/runtime/scripts/checks/architecture/surface.mjs +91 -0
  31. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  32. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  33. package/runtime/scripts/checks/architecture/typescript.mjs +127 -29
  34. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  35. package/runtime/scripts/lib/hfs-check.mjs +160 -209
  36. package/runtime/scripts/lib/hfs-path-findings.mjs +95 -0
  37. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  38. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  39. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  40. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
  41. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  42. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
  43. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  44. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  45. package/runtime/scripts/lib/language.mjs +11 -1
  46. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  47. package/scaffold/app.mjs +179 -0
  48. package/scaffold/service.mjs +26 -16
  49. package/sync/cli.mjs +1 -1
  50. package/sync/hygiene.mjs +11 -8
  51. package/sync/index.mjs +109 -111
  52. package/sync/managed.mjs +9 -8
  53. package/sync/sonar-key.mjs +20 -22
  54. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +5 -11
  55. package/templates/app/gitignore +6 -0
  56. package/templates/app/hooks/husky/pre-commit +25 -0
  57. package/templates/app/hooks/husky/pre-push +7 -0
  58. package/templates/app/package-scripts/package.json +22 -0
  59. package/templates/{be → app}/quality-config/sonar-project.properties +4 -3
  60. package/templates/app/skeleton/.editorconfig +15 -0
  61. package/templates/app/skeleton/.gitattributes +2 -0
  62. package/templates/app/skeleton/.nvmrc +1 -0
  63. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  64. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  65. package/templates/app/skeleton/README.md +36 -0
  66. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  67. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  68. package/templates/be/skeleton/.sops.yaml +2 -0
  69. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  70. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  71. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  72. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  73. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  76. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  77. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  78. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  79. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  80. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  81. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  82. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  83. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  84. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  85. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  86. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  87. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  88. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  89. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  90. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  91. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  95. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  96. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  97. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  98. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  99. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  100. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  101. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  102. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  103. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  107. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  108. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  109. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  110. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  111. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  112. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  113. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  114. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  118. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  119. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  120. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  121. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  122. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  123. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  124. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  125. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  126. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  127. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  128. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  129. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  130. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  131. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  132. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  133. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  134. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  135. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  136. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  137. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  138. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  139. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  140. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  141. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  142. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  143. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  147. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  148. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  149. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  150. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  151. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  152. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  153. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  154. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  155. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  156. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  157. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  158. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  159. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  160. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  161. package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
  162. package/sync/skeleton.mjs +0 -76
  163. package/templates/be/gitignore +0 -2
  164. package/templates/be/hooks/husky/pre-commit +0 -13
  165. package/templates/be/hooks/husky/pre-push +0 -7
  166. package/templates/be/package-scripts/package.json +0 -22
  167. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  168. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  169. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  170. package/templates/be/tool-config/prettierignore +0 -8
  171. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -54
  172. package/templates/fe/gitignore +0 -3
  173. package/templates/fe/hooks/husky/pre-commit +0 -16
  174. package/templates/fe/hooks/husky/pre-push +0 -6
  175. package/templates/fe/package-scripts/package.json +0 -17
  176. package/templates/fe/parts/api-client.ts +0 -44
  177. package/templates/fe/parts/api-outcome.ts +0 -7
  178. package/templates/fe/quality-config/sonar-project.properties +0 -8
  179. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  180. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  181. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  182. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  183. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  184. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  185. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  186. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  187. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  188. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  189. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  190. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  191. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  192. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  193. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  194. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  195. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  196. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  197. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  198. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  199. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  200. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  201. package/templates/fe/tool-config/prettierrc +0 -1
  202. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  203. /package/templates/{be → app}/starciwork.gitignore +0 -0
  204. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  205. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  206. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  207. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -13,11 +13,12 @@
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's directory with safeRemoveTree and then prunes the registration,
17
- // so `git worktree remove --force` never walks the tree.
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.
18
18
  import fs from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  import { sleepSync } from './sleep-sync.mjs';
23
24
  import { samePath } from './path-key.mjs';
@@ -46,6 +47,22 @@ export function isLinkLike(p, { parentReal = null, stat = null } = {}) {
46
47
  }
47
48
 
48
49
  /** Remove a link itself, never its target. True when nothing is left at `p`. */
50
+ /** Every link (symlink, junction, other reparse point) under root, found with lstat; the walk never enters one. */
51
+ export function linksUnder(root) {
52
+ const found = [];
53
+ const visit = (p, parentReal) => {
54
+ let stat;
55
+ try { stat = fs.lstatSync(p); } catch { return; }
56
+ if (isLinkLike(p, { parentReal, stat })) { found.push(p); return; }
57
+ if (!stat.isDirectory()) return;
58
+ let real, names;
59
+ try { real = fs.realpathSync.native(p); names = fs.readdirSync(p); } catch { return; }
60
+ for (const name of names) visit(path.join(p, name), real);
61
+ };
62
+ visit(path.resolve(root), null);
63
+ return found;
64
+ }
65
+
49
66
  export function unlinkOnly(p) {
50
67
  try { fs.lstatSync(p); } catch (error) { return error?.code === 'ENOENT'; }
51
68
  try { fs.unlinkSync(p); } catch { try { fs.rmdirSync(p); } catch { /* verified below */ } }
@@ -164,16 +181,84 @@ export function safeRemoveTree(root, { retries = 5, checkoutsUnder = null } = {}
164
181
  return out;
165
182
  }
166
183
 
184
+ /** Remove one link as a link, never its target: `cmd /c rmdir <link>` on Windows (no /s), then unlinkOnly. */
185
+ export function removeLink(p) {
186
+ if (WIN) {
187
+ let st = null;
188
+ try { st = fs.lstatSync(p); } catch { return true; }
189
+ if (st.isDirectory() || st.isSymbolicLink()) spawnSync('cmd', ['/d', '/c', 'rmdir', p], { windowsHide: true, encoding: 'utf8' });
190
+ }
191
+ return unlinkOnly(p);
192
+ }
193
+
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
+
167
215
  /**
168
- * Remove a git worktree without letting git walk it: its directory goes through safeRemoveTree, then
169
- * `git worktree prune` (run in `repo`) drops the registration. `git(args, {cwd})` is the caller's git runner;
170
- * omitted, git is spawned directly.
216
+ * Remove a git worktree (the one algorithm; the 490-file .claude incident and nivo-fe inc-c8fbf76aa499):
217
+ * 1. enumerate every link in it WITHOUT following one (linksUnder);
218
+ * 2. remove each as a link (removeLink: `cmd /c rmdir <link>`, never /s), outermost first;
219
+ * 3. re-scan the same way and refuse (link-stuck, nothing deleted) unless ZERO links remain;
220
+ * 4. only then `git worktree remove --force` (a link-free tree: git cannot walk out of it); a directory git does not know
221
+ * goes through safeRemoveTree (never follows a link); `git worktree prune`;
222
+ * 5. assert the main checkout is untouched: no new tracked deletion, node_modules and packages/node_modules entry counts
223
+ * unchanged - a violation is {ok:false, fatal:true, reason:'main-checkout-damaged'}: the caller (the GC) stops.
224
+ * Never robocopy, rm -rf or rmdir /s. `git(args, {cwd})` is the caller's git runner; `repo` any checkout of the repository.
225
+ * {ok, root, links, removed, errors, damage?}
171
226
  */
172
227
  export function safeRemoveWorktree(worktree, { repo, git = null, retries = 5 } = {}) {
173
- const removed = safeRemoveTree(worktree, { retries });
174
- if (repo) {
175
- const run = git ?? ((args, opts) => gitSpawn('git', args, { cwd: opts.cwd }));
176
- try { run(['worktree', 'prune'], { cwd: repo }); } catch { /* the registration is pruned on the next prune */ }
228
+ const target = path.resolve(String(worktree ?? ''));
229
+ const run = git ?? ((args, opts) => gitSpawn('git', args, { cwd: opts.cwd, maxBuffer: 64 * 1024 * 1024 }));
230
+ const out = { ok: false, root: target, links: 0, removed: { files: 0, dirs: 0, links: 0 }, errors: [] };
231
+ const list = repo ? String((run(['worktree', 'list', '--porcelain'], { cwd: repo }) ?? {}).stdout ?? '') : '';
232
+ const trees = list.split(/\r?\n/).filter((l) => l.startsWith('worktree ')).map((l) => path.resolve(l.slice(9).trim()));
233
+ const mainRoot = trees[0] ?? null;
234
+ if (mainRoot && same(mainRoot, target)) { out.errors.push({ path: target, code: 'REFUSED', message: 'refusing to remove the main checkout' }); return out; }
235
+ const refused = forbiddenRoot(target);
236
+ if (refused) { out.errors.push({ path: target, code: 'REFUSED', message: `refusing to remove ${refused}` }); return out; }
237
+ const before = mainRoot ? mainCheckoutGuard(mainRoot, { git: run }) : null;
238
+ if (fs.existsSync(target)) {
239
+ for (const link of linksUnder(target)) { if (removeLink(link)) out.links += 1; else out.errors.push({ path: link, code: 'LINK_STUCK', message: 'a link could not be removed' }); }
240
+ const left = linksUnder(target);
241
+ if (left.length) {
242
+ for (const l of left) if (!out.errors.some((e) => e.path === l)) out.errors.push({ path: l, code: 'LINK_STUCK', message: 'a link is still there after removal' });
243
+ out.reason = 'link-stuck';
244
+ return out;
245
+ }
246
+ out.removed.links = out.links;
247
+ const registered = trees.some((t) => same(t, target));
248
+ if (registered && repo) run(['worktree', 'remove', '--force', target], { cwd: repo });
249
+ if (fs.existsSync(target)) {
250
+ if (linksUnder(target).length) { out.errors.push({ path: target, code: 'LINK_STUCK', message: 'a link appeared during removal' }); out.reason = 'link-stuck'; return out; }
251
+ const rm = safeRemoveTree(target, { retries });
252
+ out.removed.files += rm.removed.files; out.removed.dirs += rm.removed.dirs;
253
+ out.errors.push(...rm.errors);
254
+ }
177
255
  }
178
- return removed;
256
+ if (repo) { try { run(['worktree', 'prune'], { cwd: repo }); } catch { /* the registration is pruned on the next prune */ } }
257
+ if (before) {
258
+ const damage = mainCheckoutDamage(before, mainCheckoutGuard(mainRoot, { git: run }));
259
+ 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; }
260
+ }
261
+ out.ok = !fs.existsSync(target) && (() => { try { fs.lstatSync(target); return false; } catch { return true; } })();
262
+ if (!out.ok && !out.reason) out.reason = 'remove-failed';
263
+ return out;
179
264
  }
@@ -0,0 +1,179 @@
1
+ // hfs scaffold app <name> - the first tree of a new app, the one shape every StarCi product has:
2
+ //
3
+ // <name>/ hfs.json (kind app), package.json (every dependency of both sides at its canon pin, the managed scripts),
4
+ // package-lock.json, README.md, the managed root files (CI, husky, .gitignore block, Sonar, prettier) and
5
+ // .starciwork; scripts/codegen.mjs, the app's own step of `npm run codegen`
6
+ // <name>/be/ the back-end side: the managed tool configuration and the templates/be/skeleton tree (the api app's
7
+ // entrypoint, platform config/logging/errors/clock/cqrs, the liveness capability and the health feature)
8
+ // <name>/fe/ the front-end side: the managed tool configuration and the templates/fe/skeleton tree (the next-intl
9
+ // [locale] shell with vi default, as-needed prefix and proxy.ts; each route slot mounts one pages feature
10
+ // drawn with @starci/grammar)
11
+ //
12
+ // The skeleton files are written once from templates/<app|be|fe>/skeleton ({{project}}, {{app}} and {{appPascal}} filled, a
13
+ // `__app__` folder named after the side's app); the managed files are the render of `hfs sync` (sync/index.mjs), so a fresh app
14
+ // is in sync by construction. Nothing is installed: `npm install` completes the lockfile, which is written as the root entry only.
15
+ // An existing directory is refused, never merged into.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
19
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
20
+ import { TEMPLATES_DIR, renderTargets, writeTargets } from '../sync/index.mjs';
21
+ import { ScaffoldError } from './service.mjs';
22
+
23
+ const NAME = /^[a-z][a-z0-9-]*$/;
24
+ const APP_DIR = '__app__';
25
+ const PINS_FILE = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'hfs', 'canon-pins.yaml');
26
+ const SONAR_GATE_FILE = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'sonar-gate.yaml');
27
+ const pascal = name => name.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('');
28
+
29
+ /**
30
+ * A JSON file as prettier prints it (the app's format check judges every file the scaffold writes): an object one key per line, an
31
+ * array of plain values on one line, any other array one item per line.
32
+ */
33
+ function jsonText(value) {
34
+ const print = (item, indent) => {
35
+ const inner = `${indent} `;
36
+ if (Array.isArray(item)) {
37
+ if (item.every(entry => entry === null || typeof entry !== 'object')) return `[${item.map(entry => JSON.stringify(entry)).join(', ')}]`;
38
+ return `[\n${item.map(entry => `${inner}${print(entry, inner)}`).join(',\n')}\n${indent}]`;
39
+ }
40
+ if (item !== null && typeof item === 'object') {
41
+ const keys = Object.keys(item);
42
+ if (!keys.length) return '{}';
43
+ return `{\n${keys.map(key => `${inner}${JSON.stringify(key)}: ${print(item[key], inner)}`).join(',\n')}\n${indent}}`;
44
+ }
45
+ return JSON.stringify(item);
46
+ };
47
+ return `${print(value, '')}\n`;
48
+ }
49
+
50
+ /** The apps a new app starts with: one api app on the be side, one Next app on the fe side, which reads the be contracts for its codegen. */
51
+ export const STARTER_SIDES = Object.freeze({
52
+ be: Object.freeze({ apps: [{ name: 'api', kind: 'api' }] }),
53
+ fe: Object.freeze({ apps: [{ name: 'web', kind: 'next' }], reads: ['be/contracts/'] }),
54
+ });
55
+
56
+ /**
57
+ * The dependencies of the starter: what the skeleton imports and the tools the managed scripts and configs run. A name with a
58
+ * canon pin (knowledge/hfs/canon-pins.yaml) takes the pin; the others take the range the reference apps (examples/todo-app)
59
+ * declare.
60
+ */
61
+ const STARTER_DEPENDENCIES = Object.freeze({
62
+ dependencies: {
63
+ '@heroui/react': null, '@heroui/styles': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': '^11.0.3',
64
+ '@nestjs/platform-express': null, '@starci/grammar': null, next: null, 'next-intl': null, react: null, 'react-dom': null,
65
+ 'reflect-metadata': '^0.2.2', rxjs: '^7.8.1', 'server-only': '^0.0.1', tslib: '^2.8.1',
66
+ },
67
+ devDependencies: {
68
+ '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/hfs': null, '@starci/jest-preset': null,
69
+ '@starci/prettier-config': null, '@starci/stylelint-canon': null, '@starci/test-world': null, '@starci/tsconfig': null, '@tailwindcss/postcss': '^4', '@types/express': '^4.17.21',
70
+ '@types/jest': null, '@types/node': null, '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0', eslint: null, 'eslint-plugin-react-hooks': null,
71
+ husky: '^9.1.7', jest: null, 'postcss-value-parser': null, prettier: null, stylelint: null, tailwindcss: '^4', 'ts-jest': null,
72
+ 'ts-node-dev': '^2.0.0', 'tsc-alias': '^1.8.10', 'tsconfig-paths': '^4.2.0', typescript: null,
73
+ },
74
+ });
75
+
76
+ /** The app hfs.json of a new app called `name`. */
77
+ export const starterDeclaration = (name, manifest = loadSlotManifest()) => ({ hfs: manifest.major, kind: 'app', project: name, sides: structuredClone(STARTER_SIDES) });
78
+
79
+ /** The root package.json of a new app: name, the dependencies at their pins; the managed scripts are sync's. */
80
+ function packageManifest(name, pins) {
81
+ const pinned = (dependency, range) => {
82
+ if (range !== null) return range;
83
+ if (!pins[dependency]) throw new ScaffoldError('HFS_SCAFFOLD_PIN_MISSING', `${dependency} has no canon pin in knowledge/hfs/canon-pins.yaml`);
84
+ return pins[dependency].version;
85
+ };
86
+ const section = entries => Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)).map(([dependency, range]) => [dependency, pinned(dependency, range)]));
87
+ return { name, version: '0.0.0', private: true, dependencies: section(STARTER_DEPENDENCIES.dependencies), devDependencies: section(STARTER_DEPENDENCIES.devDependencies) };
88
+ }
89
+
90
+ function listFiles(dir, base = dir) {
91
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
92
+ const full = path.join(dir, entry.name);
93
+ return entry.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
94
+ });
95
+ }
96
+
97
+ /** The be side's nest-cli.json: one Nest project per be app, the first api app the default. */
98
+ function nestCli(app) {
99
+ const projects = Object.fromEntries(app.sides.be.apps.map(entry => [entry.name, { type: 'application', root: `apps/${entry.name}`, entryFile: 'main', sourceRoot: `apps/${entry.name}/src` }]));
100
+ const first = app.sides.be.apps.find(entry => entry.kind === 'api');
101
+ return { $schema: 'https://json.schemastore.org/nest-cli', collection: '@nestjs/schematics', monorepo: true, root: `apps/${first.name}`, sourceRoot: `apps/${first.name}/src`, projects };
102
+ }
103
+
104
+ /**
105
+ * The tsconfig.json of one Next app of the fe side: the side's managed tsconfig plus the app's `@/*` alias and the Next plugin. It is
106
+ * written from code, not kept as a template file: a tsconfig.json inside templates/ would make every TypeScript tool of this
107
+ * repository treat the skeleton as a project of its own.
108
+ */
109
+ function nextAppTsconfig() {
110
+ return {
111
+ extends: '../../tsconfig.json',
112
+ compilerOptions: { plugins: [{ name: 'next' }], incremental: true, paths: { '@/*': ['./src/*'] } },
113
+ include: ['next-env.d.ts', 'src/**/*.ts', 'src/**/*.tsx', '.next/types/**/*.ts'],
114
+ exclude: ['node_modules'],
115
+ };
116
+ }
117
+
118
+ /** The names a skeleton file may fill: {{project}}, {{app}}, {{appPascal}} (the side's app) and {{sonarGate}}. */
119
+ const SKELETON_VARIABLES = Object.freeze(['project', 'app', 'appPascal', 'sonarGate']);
120
+
121
+ /**
122
+ * A skeleton file with its variables filled. Only the skeleton names are variables: every other `{{name}}` is source the app keeps
123
+ * (a message placeholder of the i18n canon, `{{title}}` in a catalog), and a skeleton name with no value for this file is an error.
124
+ */
125
+ function fill(source, vars, file) {
126
+ return source.replace(new RegExp(`\\{\\{(${SKELETON_VARIABLES.join('|')})\\}\\}`, 'g'), (_, key) => {
127
+ if (!Object.hasOwn(vars, key)) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `${file} names {{${key}}}, which has no value outside a __app__ folder`);
128
+ return vars[key];
129
+ });
130
+ }
131
+
132
+ /** The skeleton files of one scope (app root, be, fe): [{ path, content }], app-relative; a `__app__` file once per app of the side. */
133
+ function skeletonOf(scope, app, vars) {
134
+ const dir = path.join(TEMPLATES_DIR, scope, 'skeleton');
135
+ if (!fs.existsSync(dir)) throw new ScaffoldError('HFS_SCAFFOLD_SKELETON_MISSING', `no skeleton templates in templates/${scope}/skeleton`);
136
+ const apps = scope === 'app' ? [] : app.sides[scope].apps.filter(entry => scope === 'fe' || entry.kind === 'api');
137
+ const prefix = scope === 'app' ? '' : `${scope}/`;
138
+ const files = [];
139
+ for (const rel of listFiles(dir)) {
140
+ const source = fs.readFileSync(path.join(dir, rel), 'utf8').replace(/\r\n/g, '\n');
141
+ const once = { project: app.project, ...vars };
142
+ if (!rel.includes(APP_DIR)) {
143
+ files.push({ path: `${prefix}${rel}`, content: fill(source, once, rel) });
144
+ continue;
145
+ }
146
+ for (const entry of apps) files.push({ path: `${prefix}${rel.split(APP_DIR).join(entry.name)}`, content: fill(source, { ...once, app: entry.name, appPascal: pascal(entry.name) }, rel) });
147
+ }
148
+ return files;
149
+ }
150
+
151
+ /**
152
+ * `hfs scaffold app <name>`: writes the new app under `into` and returns `{ root, files }` (app-relative paths, sorted). `presets`
153
+ * is what sync loads from the installed @starci/jest-preset (the Sonar exclusions); the CLI passes the one it resolves.
154
+ */
155
+ export function scaffoldApp({ name, into, presets, manifest = loadSlotManifest() }) {
156
+ if (!NAME.test(String(name))) throw new ScaffoldError('HFS_SCAFFOLD_NAME_INVALID', `the app name ${name} must be kebab-case (a project name: ${NAME})`);
157
+ const root = path.join(into, name);
158
+ if (fs.existsSync(root)) throw new ScaffoldError('HFS_SCAFFOLD_EXISTS', `${root} already exists; hfs scaffold app never writes into an existing directory`);
159
+ const declaration = starterDeclaration(name, manifest);
160
+ const app = resolveRepoDeclaration(manifest, declaration);
161
+ const pins = parseYaml(fs.readFileSync(PINS_FILE, 'utf8')).pins;
162
+ const pkg = packageManifest(name, pins);
163
+ const files = [
164
+ { path: 'hfs.json', content: jsonText(declaration) },
165
+ { path: 'package.json', content: jsonText(pkg) },
166
+ { path: 'package-lock.json', content: jsonText({ name, version: pkg.version, lockfileVersion: 3, requires: true, packages: { '': { name, version: pkg.version, dependencies: pkg.dependencies, devDependencies: pkg.devDependencies } } }) },
167
+ { path: 'be/nest-cli.json', content: jsonText(nestCli(app)) },
168
+ ...app.sides.fe.apps.map(entry => ({ path: `fe/apps/${entry.name}/tsconfig.json`, content: jsonText(nextAppTsconfig()) })),
169
+ ...['app', 'be', 'fe'].flatMap(scope => skeletonOf(scope, app, { sonarGate: parseYaml(fs.readFileSync(SONAR_GATE_FILE, 'utf8')).gate.name })),
170
+ ];
171
+ for (const file of files) {
172
+ const target = path.join(root, ...file.path.split('/'));
173
+ fs.mkdirSync(path.dirname(target), { recursive: true });
174
+ fs.writeFileSync(target, file.content);
175
+ }
176
+ const targets = renderTargets(declaration, presets, { manifest });
177
+ writeTargets(root, targets);
178
+ return { root, files: [...new Set([...files.map(file => file.path), ...targets.map(target => target.path)])].sort() };
179
+ }
@@ -15,7 +15,7 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { createRequire } from 'node:module';
17
17
  import { explainPath } from '../runtime/scripts/lib/hfs-check.mjs';
18
- import { loadSlotManifest } from '../runtime/scripts/lib/hfs-slots.mjs';
18
+ import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
19
19
 
20
20
  export class ScaffoldError extends Error {
21
21
  constructor(code, message) {
@@ -284,11 +284,21 @@ const loadTypeScript = repoRoot => {
284
284
  }
285
285
  };
286
286
 
287
- const profileOf = repoRoot => {
288
- let declaration;
289
- try { declaration = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8')); } catch { throw new ScaffoldError('HFS_NEW_NO_HFS', `${repoRoot} has no readable hfs.json`); }
290
- if (declaration.profile !== 'be') throw new ScaffoldError('HFS_NEW_BACKEND_ONLY', 'hfs new service | spec writes back-end services; this repository is a front end');
291
- return declaration;
287
+ const BE = 'be';
288
+
289
+ /** The be side folder of the app at `appRoot`: hfs new runs at the app root and writes into be/ only. */
290
+ const backEndOf = appRoot => {
291
+ let repo;
292
+ try { repo = readRepoDeclaration(loadSlotManifest(), appRoot); } catch (error) { throw new ScaffoldError('HFS_NEW_NO_HFS', `${appRoot} has no valid app hfs.json (${error.message})`); }
293
+ if (!repo.sides) throw new ScaffoldError('HFS_NEW_NO_HFS', `${appRoot} is not the app root; run hfs new at the folder of hfs.json`);
294
+ return path.join(appRoot, BE);
295
+ };
296
+
297
+ /** An app-relative path below be/ as a be-relative one; a path of the root or of fe/ is refused. */
298
+ const belowBackEnd = input => {
299
+ const relative = input.split(path.sep).join('/').replace(/^\.\//, '').replace(/\/$/, '');
300
+ if (!relative.startsWith(`${BE}/`)) throw new ScaffoldError('HFS_NEW_BACKEND_ONLY', `hfs new service | spec writes back-end services under be/; ${relative} is not below be/`);
301
+ return relative.slice(BE.length + 1);
292
302
  };
293
303
 
294
304
  /** The slot check of a target: the manifest must own the path, else nothing is written. */
@@ -305,29 +315,29 @@ const writeNew = (repoRoot, relative, text) => {
305
315
  return relative;
306
316
  };
307
317
 
308
- /** `hfs new spec <file>.service.ts`: writes the spec skeleton of an existing service; returns the created paths. */
309
- export function newSpec({ repoRoot, file, ts = loadTypeScript(repoRoot), manifest = loadSlotManifest() }) {
310
- profileOf(repoRoot);
311
- const relative = file.split(path.sep).join('/').replace(/^\.\//, '');
318
+ /** `hfs new spec be/<file>.service.ts` at the app root: writes the spec skeleton of an existing service; returns the created app-relative paths. */
319
+ export function newSpec({ repoRoot: appRoot, file, ts = loadTypeScript(appRoot), manifest = loadSlotManifest() }) {
320
+ const repoRoot = backEndOf(appRoot);
321
+ const relative = belowBackEnd(file);
312
322
  if (!relative.endsWith('.service.ts')) throw new ScaffoldError('HFS_NEW_NOT_A_SERVICE', `${relative} is not a *.service.ts file: only services have a unit spec`);
313
323
  const absolute = path.join(repoRoot, relative);
314
324
  if (!fs.existsSync(absolute)) throw new ScaffoldError('HFS_NEW_NO_SERVICE', `${relative} does not exist`);
315
325
  const specPath = relative.replace(/\.ts$/, '.spec.ts');
316
326
  requireSlot(repoRoot, specPath);
317
327
  const shape = readServiceShape({ ts, fileName: absolute, text: fs.readFileSync(absolute, 'utf8') });
318
- return [writeNew(repoRoot, specPath, specSkeleton({ shape, serviceFile: relative, manifest }))];
328
+ return [writeNew(repoRoot, specPath, specSkeleton({ shape, serviceFile: relative, manifest }))].map(created => `${BE}/${created}`);
319
329
  }
320
330
 
321
- /** `hfs new service <dir> <name> [--inject ...]`: writes the service and its spec skeleton; returns the created paths. */
322
- export function newService({ repoRoot, dir, name, inject = [], ts = loadTypeScript(repoRoot), manifest = loadSlotManifest() }) {
323
- profileOf(repoRoot);
331
+ /** `hfs new service be/<dir> <name> [--inject ...]` at the app root: writes the service and its spec skeleton; returns the created app-relative paths. */
332
+ export function newService({ repoRoot: appRoot, dir, name, inject = [], ts = loadTypeScript(appRoot), manifest = loadSlotManifest() }) {
333
+ const repoRoot = backEndOf(appRoot);
324
334
  if (!SERVICE_NAME.test(name)) throw new ScaffoldError('HFS_NEW_NAME_INVALID', `the service name ${name} must be kebab-case (member-profile), without the .service suffix`);
325
- const relative = path.posix.join(dir.split(path.sep).join('/').replace(/^\.\//, '').replace(/\/$/, ''), `${name}.service.ts`);
335
+ const relative = path.posix.join(belowBackEnd(dir), `${name}.service.ts`);
326
336
  requireSlot(repoRoot, relative);
327
337
  requireSlot(repoRoot, relative.replace(/\.ts$/, '.spec.ts'));
328
338
  for (const target of [relative, relative.replace(/\.ts$/, '.spec.ts')]) if (fs.existsSync(path.join(repoRoot, target))) throw new ScaffoldError('HFS_NEW_EXISTS', `${target} already exists; hfs new never overwrites`);
329
339
  const text = serviceSource({ name, dependencies: inject.map(parseInject) });
330
340
  const shape = readServiceShape({ ts, fileName: path.join(repoRoot, relative), text });
331
341
  const spec = specSkeleton({ shape, serviceFile: relative, manifest });
332
- return [writeNew(repoRoot, relative, text), writeNew(repoRoot, relative.replace(/\.ts$/, '.spec.ts'), spec)];
342
+ return [writeNew(repoRoot, relative, text), writeNew(repoRoot, relative.replace(/\.ts$/, '.spec.ts'), spec)].map(created => `${BE}/${created}`);
333
343
  }
package/sync/cli.mjs CHANGED
@@ -8,7 +8,7 @@ export async function main(argv) {
8
8
  const [command, ...rest] = argv;
9
9
  if (command === 'sync') return runSync(rest);
10
10
  if (command === 'work-hygiene') return runWorkHygiene();
11
- process.stdout.write('usage: hfs sync (--check | --write | --init) [--root <dir>] | hfs work-hygiene\n');
11
+ process.stdout.write('usage: hfs sync (--check | --write) [--root <dir>] | hfs work-hygiene\n');
12
12
  return 2;
13
13
  }
14
14
 
package/sync/hygiene.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // hfs work-hygiene: the guard for the two trees a back end tracks besides source. A file under .starciwork must be
2
- // product content (the .starciwork/.gitignore allowlist admits it, so agent output is refused), and a file under
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
1
+ // hfs work-hygiene: the guard for the two trees an app tracks besides source. A file under the app root's .starciwork must be
2
+ // product content (the .starciwork/.gitignore allowlist admits it, so agent output is refused), and a file under the be side's
3
+ // be/.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 `hfs check` (scripts/lib/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
6
  // .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. The pre-commit hook judges the staged
@@ -18,7 +18,10 @@ import { pathToFileURL } from 'node:url';
18
18
  import { secretFileFindings } from '../runtime/scripts/lib/hfs-rules/secrets.mjs';
19
19
 
20
20
  const PLAINTEXT_NAME = /(^|\/)(\.env(\..*)?|[^/]*\.(pem|key|identity|age))$/;
21
- const GUARDED = file => file.startsWith('.starciwork/') || file.startsWith('.starcistacks/');
21
+ /** The app root's work tree and the be side's stack tree, app-relative (hfs work-hygiene runs at the app root). */
22
+ const WORK = '.starciwork/';
23
+ const STACKS = 'be/.starcistacks/';
24
+ const GUARDED = file => file.startsWith(WORK) || file.startsWith(STACKS);
22
25
 
23
26
  /** The subset of `files` git ignores (as if untracked), asked in one call: a Set of paths. */
24
27
  export function ignoredAmong(cwd, files) {
@@ -36,12 +39,12 @@ export function ignoredAmong(cwd, files) {
36
39
  export function hygieneFindings(files, ignored) {
37
40
  const findings = [];
38
41
  for (const file of files) {
39
- if (file.startsWith('.starciwork/') && ignored.has(file)) {
42
+ if (file.startsWith(WORK) && ignored.has(file)) {
40
43
  findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
41
44
  }
42
- if (file.startsWith('.starcistacks/') && !file.endsWith('.enc') && !file.endsWith('.env.example')) {
45
+ if (file.startsWith(STACKS) && !file.endsWith('.enc') && !file.endsWith('.env.example')) {
43
46
  if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
44
- else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
47
+ else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to be/.starcistacks/<env>/secrets/<slug>.enc' });
45
48
  }
46
49
  }
47
50
  return findings;
@@ -75,7 +78,7 @@ export function secretGuardFindings(cwd, files, alreadyRefused = new Set()) {
75
78
  /** Findings for `files`: the guarded trees, then the secret guard over every file. */
76
79
  export function judge(cwd, files) {
77
80
  const guarded = files.filter(GUARDED);
78
- const findings = hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith('.starciwork/'))));
81
+ const findings = hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith(WORK))));
79
82
  const refused = new Set(findings.filter(finding => finding.code === 'HFS_PLAINTEXT_SECRET').map(finding => finding.file));
80
83
  return { checked: files.length, findings: [...findings, ...secretGuardFindings(cwd, files, refused)] };
81
84
  }