@starci/hfs 3.0.0 → 4.0.1

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 (199) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -21
  3. package/bin/hfs.mjs +59 -37
  4. package/lint/run.mjs +70 -39
  5. package/package.json +2 -2
  6. package/runtime/engine/admission.mjs +3 -3
  7. package/runtime/engine/ledger-db.mjs +2 -2
  8. package/runtime/engine/machine-db.mjs +90 -9
  9. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  10. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  12. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  13. package/runtime/knowledge/hfs/slots.yaml +193 -128
  14. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  15. package/runtime/modules/kernel/failure-codes.yaml +23 -32
  16. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  17. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  18. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  19. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  20. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  21. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  22. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  23. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  24. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  26. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  27. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  28. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  29. package/runtime/scripts/lib/hfs-check.mjs +156 -141
  30. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  31. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  32. package/runtime/scripts/lib/hfs-rules/deps.mjs +4 -2
  33. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  34. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  35. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  36. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  37. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  38. package/runtime/scripts/lib/language.mjs +11 -1
  39. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  40. package/scaffold/app.mjs +205 -0
  41. package/scaffold/service.mjs +26 -16
  42. package/sync/cli.mjs +1 -1
  43. package/sync/hygiene.mjs +11 -8
  44. package/sync/index.mjs +109 -111
  45. package/sync/managed.mjs +9 -8
  46. package/sync/sonar-key.mjs +20 -22
  47. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
  48. package/templates/app/gitignore +6 -0
  49. package/templates/app/hooks/husky/pre-commit +25 -0
  50. package/templates/app/hooks/husky/pre-push +7 -0
  51. package/templates/app/package-scripts/package.json +22 -0
  52. package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
  53. package/templates/app/skeleton/.editorconfig +15 -0
  54. package/templates/app/skeleton/.gitattributes +2 -0
  55. package/templates/app/skeleton/.nvmrc +1 -0
  56. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  57. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  58. package/templates/app/skeleton/README.md +36 -0
  59. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  60. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  61. package/templates/be/skeleton/.sops.yaml +2 -0
  62. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  63. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  64. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  65. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  66. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  67. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  68. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  69. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  70. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  71. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  72. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  73. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  76. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  77. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  78. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  79. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  80. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  81. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  82. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  83. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  84. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  85. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  86. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  87. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  88. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  89. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  90. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  91. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  95. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  96. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  97. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  98. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  99. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  100. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  101. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  102. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  103. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  107. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  108. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  109. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  110. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  111. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  112. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  113. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  114. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  118. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  119. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  120. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  121. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  122. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  123. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  124. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  125. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  126. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  127. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  128. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  129. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  130. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  131. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  132. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  133. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  134. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  135. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  136. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  137. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  138. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  139. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  140. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  141. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  142. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  143. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  147. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  148. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  149. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  150. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  151. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  152. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  153. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  154. package/sync/skeleton.mjs +0 -76
  155. package/templates/be/gitignore +0 -2
  156. package/templates/be/hooks/husky/pre-commit +0 -13
  157. package/templates/be/hooks/husky/pre-push +0 -6
  158. package/templates/be/package-scripts/package.json +0 -19
  159. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  160. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  161. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  162. package/templates/be/tool-config/prettierignore +0 -8
  163. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
  164. package/templates/fe/gitignore +0 -3
  165. package/templates/fe/hooks/husky/pre-commit +0 -16
  166. package/templates/fe/hooks/husky/pre-push +0 -5
  167. package/templates/fe/package-scripts/package.json +0 -13
  168. package/templates/fe/parts/api-client.ts +0 -44
  169. package/templates/fe/parts/api-outcome.ts +0 -7
  170. package/templates/fe/quality-config/sonar-project.properties +0 -8
  171. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  172. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  173. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  174. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  175. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  176. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  177. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  178. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  179. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  180. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  181. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  182. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  183. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  184. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  185. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  186. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  187. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  188. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  189. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  190. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  191. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  192. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  193. package/templates/fe/tool-config/prettierrc +0 -1
  194. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  195. /package/templates/{be → app}/starciwork.gitignore +0 -0
  196. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  197. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  198. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  199. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -1,4 +1,4 @@
1
- // hfs-slots.mjs - the HFS slot manifest and repository declaration, loaded once and asked four questions:
1
+ // hfs-slots.mjs - the HFS slot manifest and app declaration, loaded once and asked four questions:
2
2
  // which slot owns path P slotOf(P) / classifyPath(P) (an unknown path reports its nearest slot)
3
3
  // is import A -> B allowed importAllowed(A, B) (tier matrix, cross-owner entry, cross-app, layers)
4
4
  // which files are required requiredFiles(P) / requiredPaths()
@@ -8,6 +8,12 @@
8
8
  // list. The manifest shape is modules/schemas/hfs-slots.schema.yaml and hfs.json is modules/schemas/hfs-repo.schema.yaml;
9
9
  // the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv
10
10
  // (tests/hfs-slots.spec.mjs proves the two agree).
11
+ //
12
+ // A product is ONE app repository: hfs.json at the app root has kind `app` and declares its two sides, `be` and `fe`, each in
13
+ // the folder of that name. The resolver of the app answers for the whole tree: a root path with the slots of profile `app`,
14
+ // a path below a side folder with that side's resolver (profile be or fe, the side folder as its root: the side view), so
15
+ // every check and lint rule runs unchanged with a side folder as its repository root. Nothing crosses sides except the
16
+ // paths the declaration lists in `sides.<side>.reads` (a subset of the manifest's `sides.<side>.reads`).
11
17
  import fs from 'node:fs';
12
18
  import path from 'node:path';
13
19
  import { skillRoot } from '../../engine/runtime-root.mjs';
@@ -32,11 +38,36 @@ export class HfsSlotsError extends Error {
32
38
  const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
33
39
  const NAME = /^[a-z][a-z0-9-]*$/;
34
40
  const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
35
- const SLOT_ID = /^(repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
41
+ const SLOT_ID = /^(app|repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
36
42
  const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
37
43
  const TRACKED = ['tracked', 'ignored', 'external'];
38
44
  const TESTS = ['unit-beside', 'e2e', 'none'];
45
+ /** The sides of an app; each lives in the folder of its name and is the profile of its slots. */
39
46
  const PROFILES = ['be', 'fe'];
47
+ export const SIDES = Object.freeze([...PROFILES]);
48
+ /** The profile of the app root's own slots. */
49
+ export const APP_SCOPE = 'app';
50
+
51
+ /**
52
+ * The rewriter of a side's finding messages: `(message) => message` with its paths made app-relative like the finding's own path.
53
+ * Every check and rule of a side judges the side folder as its root, so the paths it names (`apps/web/src/...`, `src/modules/...`,
54
+ * `tsconfig.json`) are side-relative. A path here is a token that starts at a word boundary with a top-level entry of the side folder
55
+ * (`sideRoot`, read once) and goes on with `/`, or is that entry when its name holds a dot (a file). An import specifier (`@/x`,
56
+ * `../x`) or a path already app-relative is left alone.
57
+ */
58
+ export function appRelativeMessages(side, sideRoot) {
59
+ let entries = [];
60
+ try { entries = fs.readdirSync(sideRoot).filter((name) => name !== 'node_modules' && !name.startsWith('.git')); } catch { /* no side folder: nothing to rewrite */ }
61
+ if (!entries.length) return (message) => message;
62
+ const escaped = entries.sort((a, b) => b.length - a.length).map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
63
+ const token = new RegExp(`(^|[\\s'"\`(\\[{,;=<>])((?:${escaped.join('|')})(?=/|[\\s'"\`)\\]},;:!?<>]|\\.(?:\\s|$)|$))`, 'g');
64
+ return (message) => (typeof message === 'string' && message
65
+ ? message.replace(token, (whole, before, entry, offset) => (message.startsWith('/', offset + whole.length) || entry.includes('.') ? `${before}${side}/${entry}` : whole))
66
+ : message);
67
+ }
68
+
69
+ const SCOPES = [APP_SCOPE, ...PROFILES];
70
+ const APP_KIND = 'app';
40
71
  const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
41
72
 
42
73
  // ------------------------------------------------------------------------------------------------ patterns
@@ -120,7 +151,7 @@ const fail = (code, message, details) => { throw new HfsSlotsError(code, message
120
151
  function manifestShapeProblems(m) {
121
152
  const bad = [];
122
153
  if (!isPlainObject(m)) return ['the manifest is not a map'];
123
- const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
154
+ const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'sides', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
124
155
  for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
125
156
  if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
126
157
  if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
@@ -128,6 +159,11 @@ function manifestShapeProblems(m) {
128
159
  if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
129
160
  if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED)) bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
130
161
  if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
162
+ if (!isPlainObject(m.sides) || Object.keys(m.sides).sort().join() !== PROFILES.join()) bad.push('sides must be a map with exactly be and fe');
163
+ else for (const side of PROFILES) {
164
+ const def = m.sides[side];
165
+ if (!isPlainObject(def) || Object.keys(def).join() !== 'reads' || !Array.isArray(def.reads) || !def.reads.every((r) => typeof r === 'string' && /^(be|fe)\/([^/]+\/)+$/.test(r)) || new Set(def.reads).size !== def.reads.length) bad.push(`sides.${side} must be {reads: [unique <side>/<dir>/ paths]}`);
166
+ }
131
167
  for (const key of ['appKinds', 'tiers']) {
132
168
  if (!isPlainObject(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
133
169
  for (const extra of Object.keys(m[key])) if (!PROFILES.includes(extra)) bad.push(`${key}.${extra} is not a profile`);
@@ -174,7 +210,8 @@ function manifestShapeProblems(m) {
174
210
  if (!isPlainObject(slot)) { bad.push(`${at} is not a map`); return; }
175
211
  for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
176
212
  if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
177
- if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) => PROFILES.includes(p)) || new Set(slot.profiles).size !== slot.profiles.length) bad.push(`${at}: profiles must be a unique non-empty subset of be, fe`);
213
+ if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) => SCOPES.includes(p)) || new Set(slot.profiles).size !== slot.profiles.length || (slot.profiles.includes(APP_SCOPE) && slot.profiles.length !== 1)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
214
+ else if ((slot.profiles[0] === APP_SCOPE) !== String(slot.id).startsWith(`${APP_SCOPE}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
178
215
  if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
179
216
  if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
180
217
  if (!TRACKED.includes(slot.tracked)) bad.push(`${at}: tracked must be one of ${TRACKED.join(', ')}`);
@@ -211,9 +248,15 @@ function manifestSemanticProblems(m) {
211
248
  ids.add(slot.id);
212
249
  const pathVars = new Set(varsOf(slot.path));
213
250
  for (const profile of slot.profiles) {
214
- const tiers = m.tiers[profile];
215
- if (slot.tier !== 'none' && slot.tier !== 'inherit' && !(slot.tier in tiers)) bad.push(`slot ${slot.id}: tier ${slot.tier} is not a ${profile} tier`);
216
- if (slot.appKind !== undefined && !m.appKinds[profile].includes(slot.appKind)) bad.push(`slot ${slot.id}: app kind ${slot.appKind} is not a ${profile} kind`);
251
+ if (profile === APP_SCOPE) {
252
+ // The app root holds no source: its slots take no part in import checks and describe no app.
253
+ for (const key of ['appKind', 'owner', 'layers', 'kinds', 'composedBy', 'requiredWhen', 'requiredInstances']) if (slot[key] !== undefined) bad.push(`slot ${slot.id}: an app-root slot has no ${key}`);
254
+ if (slot.tier !== 'none') bad.push(`slot ${slot.id}: an app-root slot has tier none`);
255
+ } else {
256
+ const tiers = m.tiers[profile];
257
+ if (slot.tier !== 'none' && slot.tier !== 'inherit' && !(slot.tier in tiers)) bad.push(`slot ${slot.id}: tier ${slot.tier} is not a ${profile} tier`);
258
+ if (slot.appKind !== undefined && !m.appKinds[profile].includes(slot.appKind)) bad.push(`slot ${slot.id}: app kind ${slot.appKind} is not a ${profile} kind`);
259
+ }
217
260
  if (slot.appKind === undefined) {
218
261
  for (const variant of braceVariants(slot.path)) {
219
262
  const key = `${profile}:${variant}`;
@@ -234,6 +277,12 @@ function manifestSemanticProblems(m) {
234
277
  for (const kind of slot.composedBy ?? []) if (!slot.profiles.every((p) => m.appKinds[p].includes(kind))) bad.push(`slot ${slot.id}: composedBy names ${kind}, which is not an app kind of every profile of the slot`);
235
278
  if (slot.layers !== undefined && slot.tier !== 'none' && !slot.profiles.every((p) => m.tiers[p][slot.tier]?.lowerLayerOnly)) bad.push(`slot ${slot.id}: layers need a lowerLayerOnly tier`);
236
279
  }
280
+ for (const side of PROFILES) for (const read of m.sides[side].reads) {
281
+ const [owner, ...rest] = read.split('/');
282
+ const below = rest.join('/');
283
+ if (owner === side) bad.push(`sides.${side}.reads names ${read}, which is its own side`);
284
+ else if (!m.slots.some((s) => s.profiles.includes(owner) && s.presence !== 'forbidden' && braceVariants(s.path).some((v) => v.startsWith(below)))) bad.push(`sides.${side}.reads names ${read}, which no ${owner} slot owns`);
285
+ }
237
286
  for (const profile of PROFILES) {
238
287
  for (const [tier, def] of Object.entries(m.tiers[profile])) {
239
288
  for (const target of def.mayImport) if (!(target in m.tiers[profile])) bad.push(`tiers.${profile}.${tier}.mayImport names ${target}, which is not a ${profile} tier`);
@@ -268,79 +317,131 @@ export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_
268
317
  function declarationShapeProblems(d) {
269
318
  const bad = [];
270
319
  if (!isPlainObject(d)) return ['hfs.json is not an object'];
271
- for (const key of Object.keys(d)) if (!['hfs', 'profile', 'project', 'apps', 'optionalSlots', 'connections', 'stacks'].includes(key)) bad.push(`unknown key ${key}`);
320
+ for (const key of Object.keys(d)) if (!['hfs', 'kind', 'project', 'sides'].includes(key)) bad.push(`unknown key ${key}`);
272
321
  if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
273
- if (!PROFILES.includes(d.profile)) bad.push('profile must be be or fe');
322
+ if (d.kind !== APP_KIND) bad.push(`kind must be ${APP_KIND}: a product is one app repository with a be and an fe side`);
274
323
  if (!NAME.test(String(d.project))) bad.push('project must be a project name');
275
- if (!Array.isArray(d.apps) || !d.apps.length) bad.push('apps must list every apps/<name> with its kind');
276
- else d.apps.forEach((app, i) => {
277
- if (!isPlainObject(app) || !NAME.test(String(app.name)) || !NAME.test(String(app.kind)) || Object.keys(app).some((k) => k !== 'name' && k !== 'kind')) bad.push(`apps[${i}] must be {name, kind}`);
278
- });
279
- if (d.stacks !== undefined && (d.profile !== 'fe' || typeof d.stacks !== 'string' || !d.stacks || /^([a-zA-Z]:)?[\/]/.test(d.stacks))) bad.push('stacks is front end only and must be a relative path to the sibling back-end repository');
280
- if (d.optionalSlots !== undefined && (!Array.isArray(d.optionalSlots) || !d.optionalSlots.every((v) => SLOT_ID.test(String(v))) || new Set(d.optionalSlots).size !== d.optionalSlots.length)) bad.push('optionalSlots must be a unique list of slot ids');
281
- if (d.connections !== undefined) {
282
- // One physical database = one entry (R84): {name, envPrefix}; names and env prefixes unique, no prefix inside another's keys.
283
- const list = Array.isArray(d.connections) ? d.connections : null;
284
- if (!list || !list.every((c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && Object.keys(c).length === 2)) bad.push('connections must be a list of {name: kebab-case database name, envPrefix: UPPER_SNAKE prefix of its env keys}');
285
- else {
286
- if (new Set(list.map((c) => c.name)).size !== list.length) bad.push('connections names must be unique');
287
- for (const a of list) for (const b of list) if (a !== b && `${b.envPrefix}_`.startsWith(`${a.envPrefix}_`)) bad.push(`connections ${a.name} and ${b.name} share env keys (${a.envPrefix}_ covers ${b.envPrefix}_)`);
324
+ if (!isPlainObject(d.sides) || Object.keys(d.sides).sort().join() !== PROFILES.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
325
+ const names = new Map();
326
+ for (const side of PROFILES) {
327
+ const s = d.sides[side];
328
+ const at = `sides.${side}`;
329
+ if (!isPlainObject(s)) { bad.push(`${at} must be an object`); continue; }
330
+ for (const key of Object.keys(s)) if (!['apps', 'optionalSlots', 'connections', 'reads'].includes(key)) bad.push(`${at} has unknown key ${key}`);
331
+ if (!Array.isArray(s.apps) || !s.apps.length) bad.push(`${at}.apps must list every ${side}/apps/<name> with its kind`);
332
+ else s.apps.forEach((app, i) => {
333
+ if (!isPlainObject(app) || !NAME.test(String(app.name)) || !NAME.test(String(app.kind)) || Object.keys(app).some((k) => k !== 'name' && k !== 'kind')) bad.push(`${at}.apps[${i}] must be {name, kind}`);
334
+ // The root scripts name every app (start:<app>), so an app name is unique across both sides.
335
+ else if (names.has(app.name)) bad.push(`app ${app.name} is declared twice (${names.get(app.name)} and ${side})`);
336
+ else names.set(app.name, side);
337
+ });
338
+ if (s.optionalSlots !== undefined && (!Array.isArray(s.optionalSlots) || !s.optionalSlots.every((v) => SLOT_ID.test(String(v))) || new Set(s.optionalSlots).size !== s.optionalSlots.length)) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
339
+ if (s.reads !== undefined && (!Array.isArray(s.reads) || !s.reads.every((r) => typeof r === 'string' && r.length > 0) || new Set(s.reads).size !== s.reads.length)) bad.push(`${at}.reads must be a unique list of paths`);
340
+ if (s.connections !== undefined) {
341
+ if (side !== 'be') { bad.push('connections belong to the be side'); continue; }
342
+ // One physical database = one entry (R84): {name, envPrefix}; names and env prefixes unique, no prefix inside another's keys.
343
+ const list = Array.isArray(s.connections) ? s.connections : null;
344
+ if (!list || !list.every((c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && Object.keys(c).length === 2)) bad.push('connections must be a list of {name: kebab-case database name, envPrefix: UPPER_SNAKE prefix of its env keys}');
345
+ else {
346
+ if (new Set(list.map((c) => c.name)).size !== list.length) bad.push('connections names must be unique');
347
+ for (const a of list) for (const b of list) if (a !== b && `${b.envPrefix}_`.startsWith(`${a.envPrefix}_`)) bad.push(`connections ${a.name} and ${b.name} share env keys (${a.envPrefix}_ covers ${b.envPrefix}_)`);
348
+ }
288
349
  }
289
350
  }
290
- if (d.profile === 'fe' && d.connections !== undefined) bad.push('connections belong to a backend repository');
291
351
  return bad;
292
352
  }
293
353
 
294
354
  const declarationInvalid = (problems, file) => fail('HFS_DECLARATION_INVALID', `hfs.json is refused: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? `; and ${problems.length - 5} more` : ''}`, { file, problems });
295
355
 
356
+ /** One side of a declaration checked against the manifest: app kinds of the profile, opt-in slots, required app kinds, reads. */
357
+ function sideProblems(manifest, side, s) {
358
+ const bad = [];
359
+ for (const app of s.apps) if (!manifest.appKinds[side].includes(app.kind)) bad.push(`${side} app ${app.name} has kind ${app.kind}, which is not a ${side} kind (${manifest.appKinds[side].join(', ')})`);
360
+ for (const id of s.optionalSlots ?? []) {
361
+ const slot = manifest.slots.find((candidate) => candidate.id === id);
362
+ if (!slot || !slot.profiles.includes(side)) bad.push(`sides.${side}.optionalSlots names ${id}, which is not a ${side} slot`);
363
+ else if (slot.presence !== 'opt-in') bad.push(`sides.${side}.optionalSlots names ${id}, which is ${slot.presence}, not opt-in`);
364
+ else if (slot.appKind !== undefined) bad.push(`sides.${side}.optionalSlots names ${id}; an app of kind ${slot.appKind} enables it`);
365
+ }
366
+ for (const read of s.reads ?? []) if (!manifest.sides[side].reads.includes(read)) bad.push(`sides.${side}.reads names ${read}; ${side} may read only ${manifest.sides[side].reads.join(', ') || 'nothing of the other side'}`);
367
+ const connections = s.connections ?? [];
368
+ for (const slot of manifest.slots) {
369
+ if (slot.appKind === undefined || !slot.profiles.includes(side) || slot.presence !== 'required') continue;
370
+ if (slot.requiredWhen === 'connections' && !connections.length) continue;
371
+ if (!s.apps.some((a) => a.kind === slot.appKind)) bad.push(`no ${side} app of kind ${slot.appKind} is declared (${slot.id} is required${slot.requiredWhen ? ' once a connection is declared' : ''})`);
372
+ }
373
+ return bad;
374
+ }
375
+
296
376
  /**
297
- * A declaration checked against the manifest: the pinned major must be the manifest's (HFS_MANIFEST_MAJOR_MISMATCH
298
- * otherwise, and there is no compatibility window), app kinds must exist for the profile, optionalSlots may name only
299
- * opt-in slots that no app kind implies, and every required app kind must be declared.
377
+ * A declaration checked against the manifest: kind app, the pinned major the manifest's (HFS_MANIFEST_MAJOR_MISMATCH otherwise,
378
+ * and there is no compatibility window), and per side: app kinds of that profile, optionalSlots naming only opt-in slots of the
379
+ * side that no app kind implies, every required app kind declared, reads within the manifest's. Returns the app (profile app,
380
+ * its two sides under `sides`), or with `side` that side's view: the declaration a check of the side folder runs under.
300
381
  */
301
- export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLARATION_FILE } = {}) {
382
+ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLARATION_FILE, side = null } = {}) {
302
383
  const problems = declarationShapeProblems(declaration);
303
384
  if (problems.length) declarationInvalid(problems, file);
304
385
  if (declaration.hfs !== manifest.major)
305
386
  fail('HFS_MANIFEST_MAJOR_MISMATCH', `hfs.json pins manifest major ${declaration.hfs} but the manifest is ${manifest.version}`, { pinned: declaration.hfs, manifest: manifest.version, manifestMajor: manifest.major, file });
306
- const { profile } = declaration;
307
- const bad = [];
308
- const names = new Set();
309
- for (const app of declaration.apps) {
310
- if (names.has(app.name)) bad.push(`app ${app.name} is declared twice`);
311
- names.add(app.name);
312
- if (!manifest.appKinds[profile].includes(app.kind)) bad.push(`app ${app.name} has kind ${app.kind}, which is not a ${profile} kind (${manifest.appKinds[profile].join(', ')})`);
313
- }
314
- for (const id of declaration.optionalSlots ?? []) {
315
- const slot = manifest.slots.find((s) => s.id === id);
316
- if (!slot || !slot.profiles.includes(profile)) bad.push(`optionalSlots names ${id}, which is not a ${profile} slot`);
317
- else if (slot.presence !== 'opt-in') bad.push(`optionalSlots names ${id}, which is ${slot.presence}, not opt-in`);
318
- else if (slot.appKind !== undefined) bad.push(`optionalSlots names ${id}; an app of kind ${slot.appKind} enables it`);
319
- }
320
- const connections = declaration.connections ?? [];
321
- for (const slot of manifest.slots) {
322
- if (slot.appKind === undefined || !slot.profiles.includes(profile) || slot.presence !== 'required') continue;
323
- if (slot.requiredWhen === 'connections' && !connections.length) continue;
324
- if (!declaration.apps.some((a) => a.kind === slot.appKind)) bad.push(`no app of kind ${slot.appKind} is declared (${slot.id} is required${slot.requiredWhen ? ' once a connection is declared' : ''})`);
325
- }
387
+ const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name]));
326
388
  if (bad.length) declarationInvalid(bad, file);
389
+ if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
390
+ const sides = Object.fromEntries(PROFILES.map((name) => {
391
+ const s = declaration.sides[name];
392
+ return [name, Object.freeze({
393
+ hfs: declaration.hfs,
394
+ kind: APP_KIND,
395
+ project: declaration.project,
396
+ side: name,
397
+ profile: name,
398
+ apps: Object.freeze(s.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
399
+ optionalSlots: Object.freeze([...(s.optionalSlots ?? [])]),
400
+ connections: Object.freeze((s.connections ?? []).map((c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix }))),
401
+ reads: Object.freeze([...(s.reads ?? [])]),
402
+ manifestVersion: manifest.version,
403
+ })];
404
+ }));
405
+ if (side !== null) return sides[side];
327
406
  return Object.freeze({
328
407
  hfs: declaration.hfs,
329
- profile,
408
+ kind: APP_KIND,
330
409
  project: declaration.project,
331
- apps: Object.freeze(declaration.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
332
- optionalSlots: Object.freeze([...(declaration.optionalSlots ?? [])]),
333
- connections: Object.freeze(connections.map((c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix }))),
410
+ side: null,
411
+ profile: APP_SCOPE,
412
+ // The root declares no app, opt-in slot or connection of its own: each side does.
413
+ apps: Object.freeze([]),
414
+ optionalSlots: Object.freeze([]),
415
+ connections: Object.freeze([]),
416
+ reads: Object.freeze([]),
417
+ sides: Object.freeze(sides),
334
418
  manifestVersion: manifest.version,
335
419
  });
336
420
  }
337
421
 
338
- /** hfs.json of the repository at `repoRoot`, parsed and resolved; a missing or unreadable file is a refusal, never "unavailable". */
422
+ /**
423
+ * Where the declaration of `dir` lives: `dir` itself when it holds hfs.json (the app root), else its parent when `dir` is a side
424
+ * folder (be/ or fe/) of an app (the side view). `{ file, appRoot, side }`; side is null at the app root.
425
+ */
426
+ export function locateDeclaration(dir) {
427
+ const own = path.join(dir, HFS_DECLARATION_FILE);
428
+ if (fs.existsSync(own)) return { file: own, appRoot: dir, side: null };
429
+ const parent = path.dirname(dir);
430
+ const side = path.basename(dir);
431
+ const up = path.join(parent, HFS_DECLARATION_FILE);
432
+ if (PROFILES.includes(side) && fs.existsSync(up)) return { file: up, appRoot: parent, side };
433
+ return { file: own, appRoot: dir, side: null };
434
+ }
435
+
436
+ /**
437
+ * hfs.json of the app at `repoRoot` (the app itself), or of the app whose side folder `repoRoot` is (that side's view), parsed and
438
+ * resolved; a missing or unreadable file is a refusal, never "unavailable".
439
+ */
339
440
  export function readRepoDeclaration(manifest, repoRoot) {
340
- const file = path.join(repoRoot, HFS_DECLARATION_FILE);
441
+ const { file, side } = locateDeclaration(repoRoot);
341
442
  let declaration;
342
443
  try { declaration = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { declarationInvalid([`hfs.json cannot be read (${String(error?.code ?? error?.message ?? error).split('\n')[0]})`], file); }
343
- return resolveRepoDeclaration(manifest, declaration, { file });
444
+ return resolveRepoDeclaration(manifest, declaration, { file, side });
344
445
  }
345
446
 
346
447
  // ------------------------------------------------------------------------------------------- resolver
@@ -348,10 +449,10 @@ export function readRepoDeclaration(manifest, repoRoot) {
348
449
  const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
349
450
 
350
451
  /**
351
- * The four questions for one repository. `repo` comes from resolveRepoDeclaration / readRepoDeclaration.
352
- * Paths are repository-relative (backslashes and a leading ./ are folded); a trailing / or a directory path is fine.
452
+ * The four questions for one scope: the app root (profile app, root paths) or one side (profile be or fe, paths relative to the
453
+ * side folder). createSlotResolver composes them; nothing else calls this.
353
454
  */
354
- export function createSlotResolver(manifest, repo) {
455
+ function createScopeResolver(manifest, repo) {
355
456
  const profile = repo.profile;
356
457
  const slots = manifest.slots.filter((s) => s.profiles.includes(profile));
357
458
  const byId = new Map(slots.map((s) => [s.id, s]));
@@ -579,7 +680,86 @@ export function createSlotResolver(manifest, repo) {
579
680
  trackingOf,
580
681
  isTracked,
581
682
  ruleParams: () => ruleParams(manifest, profile),
582
- allowedImports: (tier) => manifest.tiers[profile][tier]?.mayImport ?? null,
683
+ allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
684
+ });
685
+ }
686
+
687
+ /**
688
+ * The four questions for one app, or for one side of it. `repo` comes from resolveRepoDeclaration / readRepoDeclaration.
689
+ * Paths are relative to the root the declaration was resolved for (backslashes and a leading ./ are folded; a trailing / or a
690
+ * directory path is fine): a side view (repo.side set) answers for the side folder as its root, exactly as a check of that side
691
+ * runs; the app (profile app) answers a root path with the app-root slots and a path below be/ or fe/ with that side's view,
692
+ * the side prefixed back onto every path and root it returns (and `side` added). Only the declared `reads` cross sides.
693
+ */
694
+ export function createSlotResolver(manifest, repo) {
695
+ if (repo.profile !== APP_SCOPE) return createScopeResolver(manifest, repo);
696
+ const root = createScopeResolver(manifest, repo);
697
+ const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, repo.sides[side])]));
698
+ const clean = (p) => posixPath(p).replace(/\/+$/, '');
699
+ /** { side, rest } when `p` lies below a side folder, else null. */
700
+ const split = (input) => {
701
+ const p = clean(input);
702
+ const slash = p.indexOf('/');
703
+ const head = slash < 0 ? p : p.slice(0, slash);
704
+ return PROFILES.includes(head) && slash > 0 ? { side: head, rest: p.slice(slash + 1) } : null;
705
+ };
706
+ const under = (side, rel) => (rel ? `${side}/${rel}` : side);
707
+ const prefixed = (side, c) => ({
708
+ ...c,
709
+ path: under(side, c.path),
710
+ side,
711
+ ...(c.root !== undefined ? { root: under(side, c.root) } : {}),
712
+ ...(c.nearest ? { nearest: { ...c.nearest, matchedPrefix: under(side, c.nearest.matchedPrefix), matchedDepth: c.nearest.matchedDepth + 1 } } : {}),
713
+ });
714
+ const classifyPath = (input) => { const at = split(input); return at ? prefixed(at.side, sides[at.side].classifyPath(at.rest)) : root.classifyPath(input); };
715
+ const ownerOf = (input) => {
716
+ const at = split(input);
717
+ if (!at) return root.ownerOf(input);
718
+ const owner = sides[at.side].ownerOf(at.rest);
719
+ return owner ? { ...owner, root: under(at.side, owner.root), side: at.side } : null;
720
+ };
721
+ const allSlots = [...new Map([...root.slots(), ...PROFILES.flatMap((side) => sides[side].slots())].map((s) => [s.id, s])).values()];
722
+ const byId = new Map(allSlots.map((s) => [s.id, s]));
723
+ /** Whether `toPath`, of the other side, lies below a path `fromSide` declares it reads. */
724
+ const reads = (fromSide, toPath) => repo.sides[fromSide].reads.some((read) => `${clean(toPath)}/`.startsWith(read));
725
+ return Object.freeze({
726
+ repo,
727
+ sides: Object.freeze(sides),
728
+ /** The side of a path (be, fe) or null for a root path. */
729
+ sideOf: (input) => split(input)?.side ?? null,
730
+ slot: (id) => byId.get(id) ?? null,
731
+ slots: () => allSlots,
732
+ slotEnabled: (slot) => (slot.profiles.includes(APP_SCOPE) ? root.slotEnabled(slot) : PROFILES.some((side) => slot.profiles.includes(side) && sides[side].slotEnabled(slot))),
733
+ classifyPath,
734
+ slotOf: (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; },
735
+ ownerOf,
736
+ tierOf: (input) => { const at = split(input); return at ? sides[at.side].tierOf(at.rest) : root.tierOf(input); },
737
+ importAllowed(fromPath, toPath) {
738
+ const from = split(fromPath);
739
+ const to = split(toPath);
740
+ if (from && to && from.side !== to.side) {
741
+ return reads(from.side, toPath) ? { allowed: true, reason: 'sideRead', fromSide: from.side, toSide: to.side } : { allowed: false, reason: 'crossSide', fromSide: from.side, toSide: to.side, reads: repo.sides[from.side].reads };
742
+ }
743
+ if (from && to) return sides[from.side].importAllowed(from.rest, to.rest);
744
+ return root.importAllowed(fromPath, toPath);
745
+ },
746
+ requiredFiles: (input) => { const at = split(input); return at ? sides[at.side].requiredFiles(at.rest).map((p) => under(at.side, p)) : root.requiredFiles(input); },
747
+ requiredPaths() {
748
+ const own = root.requiredPaths();
749
+ const paths = [...own.paths];
750
+ const minimums = [...own.minimums];
751
+ for (const side of PROFILES) {
752
+ const required = sides[side].requiredPaths();
753
+ paths.push(...required.paths.map((entry) => ({ ...entry, path: under(side, entry.path), side })));
754
+ minimums.push(...required.minimums.map((entry) => ({ ...entry, side })));
755
+ }
756
+ return { paths, minimums };
757
+ },
758
+ trackingOf: (p) => classifyPath(p).tracking ?? null,
759
+ isTracked: (p) => classifyPath(p).tracking === 'tracked',
760
+ // The root has no tier and no rule parameters of its own; each side has them (sides.<side>.ruleParams()).
761
+ ruleParams: () => null,
762
+ allowedImports: () => null,
583
763
  });
584
764
  }
585
765
 
@@ -590,9 +770,12 @@ export function ruleParams(manifest, profile) {
590
770
  return deepFreeze(structuredClone(manifest.ruleParams[profile]));
591
771
  }
592
772
 
593
- /** The manifest of this runtime plus the resolver for the repository at `repoRoot` (or for an already-parsed declaration). */
594
- export function openHfs({ root = skillRoot, repoRoot, declaration, manifest = loadSlotManifest({ root }) } = {}) {
595
- const repo = declaration !== undefined ? resolveRepoDeclaration(manifest, declaration) : readRepoDeclaration(manifest, repoRoot);
773
+ /**
774
+ * The manifest of this runtime plus the resolver for the app or side folder at `repoRoot` (a side folder gets that side's view), or
775
+ * for an already-parsed declaration (the app, or with `side` that side's view).
776
+ */
777
+ export function openHfs({ root = skillRoot, repoRoot, declaration, side = null, manifest = loadSlotManifest({ root }) } = {}) {
778
+ const repo = declaration !== undefined ? resolveRepoDeclaration(manifest, declaration, { side }) : readRepoDeclaration(manifest, repoRoot);
596
779
  return { manifest, repo, ...createSlotResolver(manifest, repo), rules: () => loadRuleCatalog({ root, manifest }) };
597
780
  }
598
781
 
@@ -1,5 +1,5 @@
1
- // hfs-view.mjs - the frozen view of one repository's HFS slots that the lint factories hand their rules
2
- // (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
1
+ // hfs-view.mjs - the frozen view of one side of an app (be/ or fe/, the side folder as its root) that the lint factories hand
2
+ // their rules (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
3
3
  // runtime/ bundle (packages/hfs/scripts/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
4
4
  // architecture machine and `hfs check` use, and never tests a path with a regular expression of its own.
5
5
  import path from 'node:path';
@@ -31,6 +31,7 @@ export function hfsView(opened, repoRoot) {
31
31
  return Object.freeze({
32
32
  repoRoot,
33
33
  profile: opened.repo.profile,
34
+ side: opened.repo.side ?? null,
34
35
  apps: opened.repo.apps,
35
36
  connections: opened.repo.connections,
36
37
  ruleParams: opened.ruleParams(),
@@ -52,17 +53,18 @@ export function hfsView(opened, repoRoot) {
52
53
  }
53
54
 
54
55
  /**
55
- * The view of the repository on disk whose root is `repoRoot`, against the manifest under `runtimeRoot`.
56
+ * The view of the app folder on disk at `repoRoot`, against the manifest under `runtimeRoot`: a side folder (be/, fe/) finds the
57
+ * app-root hfs.json one level up and gets that side's view, so every linted file below it is judged by its own side's slots.
56
58
  *
57
- * @param {{ runtimeRoot: string, repoRoot: string }} input - Where the manifest copy lives and the repository root.
59
+ * @param {{ runtimeRoot: string, repoRoot: string }} input - Where the manifest copy lives and the side folder (or app root).
58
60
  * @returns {object} The frozen view.
59
61
  */
60
62
  export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot);
61
63
 
62
64
  /**
63
- * The view of an in-memory declaration (rule tests): no file is read except the manifest.
65
+ * The view of one side of an in-memory app declaration (rule tests): no file is read except the manifest.
64
66
  *
65
- * @param {{ runtimeRoot: string, declaration: object, repoRoot: string }} input - Manifest root, hfs.json object, linted root.
67
+ * @param {{ runtimeRoot: string, declaration: object, repoRoot: string, side: string }} input - Manifest root, hfs.json object, the linted side folder, its side.
66
68
  * @returns {object} The frozen view.
67
69
  */
68
- export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, declaration }), repoRoot);
70
+ export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => hfsView(openHfs({ root: runtimeRoot, declaration, side }), repoRoot);
@@ -39,6 +39,9 @@ export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meanin
39
39
  * - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
40
40
  * - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
41
41
  * phrase lists of the archetype recognisers).
42
+ * An op manifest (modules/ops/ops/<id>.yaml, the starci/op@1 documents check-op-manifest types) is declared by
43
+ * OP_MANIFEST_VIETNAMESE_FIELDS: its text objects are {en, vi} (modules/schemas/op.schema.yaml $defs text), and `vi` is the
44
+ * owner's reading of the same rule.
42
45
  */
43
46
  export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
44
47
  'modules/kernel/failure-codes.yaml': Object.freeze({ fields: FAILURE_CODE_VIETNAMESE_FIELDS }),
@@ -51,6 +54,13 @@ export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
51
54
  }),
52
55
  });
53
56
 
57
+ /** The `vi` field of an op manifest's text objects (modules/schemas/op.schema.yaml $defs text). */
58
+ export const OP_MANIFEST_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']) });
59
+ const OP_MANIFEST_DIR = 'modules/ops/ops/';
60
+ /** The declared Vietnamese fields of `rel`: its own entry, or the op-manifest text field for a manifest of modules/ops/ops. */
61
+ export const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[rel]
62
+ ?? (rel.startsWith(OP_MANIFEST_DIR) && !rel.slice(OP_MANIFEST_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? OP_MANIFEST_VIETNAMESE_FIELDS : null);
63
+
54
64
  /**
55
65
  * The slots whose files may carry another language, and why: a catalog is product copy in two languages, and an i18n
56
66
  * fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
@@ -86,7 +96,7 @@ export function yamlKeyOfEachLine(text) {
86
96
 
87
97
  /** The Vietnamese hits of a document, minus the declared field-level exceptions of `rel` (a repository-relative POSIX path). */
88
98
  export function documentLanguageHits(rel, text) {
89
- const declared = DECLARED_VIETNAMESE_FIELDS[rel];
99
+ const declared = declaredVietnameseFieldsOf(rel);
90
100
  const hits = secondLanguageHits(text);
91
101
  if (!declared) return hits;
92
102
  const keys = yamlKeyOfEachLine(text);