@starci/hfs 1.0.0 → 2.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 (246) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +116 -12
  3. package/bin/hfs.mjs +119 -15
  4. package/emit/compiler.mjs +35 -0
  5. package/emit/contracts.mjs +97 -0
  6. package/emit/operations-worker.mjs +24 -0
  7. package/emit/operations.mjs +126 -0
  8. package/emit/schema-worker.mjs +117 -0
  9. package/emit/static-graph.mjs +670 -0
  10. package/emit/type-schema.mjs +145 -0
  11. package/package.json +10 -2
  12. package/report/sonar.mjs +180 -0
  13. package/runtime/engine/admission.mjs +284 -0
  14. package/runtime/engine/digest.mjs +10 -0
  15. package/runtime/engine/ledger-db.mjs +1245 -0
  16. package/runtime/engine/machine-db.mjs +1484 -0
  17. package/runtime/engine/migrations/machine/0001-init.sql +887 -0
  18. package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
  19. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
  20. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
  21. package/runtime/engine/plain-object.mjs +5 -0
  22. package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
  23. package/runtime/knowledge/hfs/slots.yaml +409 -140
  24. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  25. package/runtime/knowledge/sonar-gate.yaml +85 -0
  26. package/runtime/modules/kernel/failure-codes.yaml +1480 -16
  27. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  28. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  29. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  30. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  31. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  32. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  33. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  34. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  35. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  36. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  37. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  38. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  39. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  40. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  41. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  42. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  43. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  44. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  45. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  46. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  47. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  48. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  49. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  50. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  51. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  52. package/runtime/scripts/checks/architecture/index.mjs +316 -0
  53. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  54. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  55. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  56. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  57. package/runtime/scripts/checks/architecture/owners.mjs +89 -0
  58. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  59. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  60. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  61. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  62. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  63. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  64. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  65. package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
  66. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  67. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  68. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  69. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  70. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  71. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  72. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  73. package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
  74. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  75. package/runtime/scripts/checks/architecture.mjs +41 -0
  76. package/runtime/scripts/checks/common.mjs +37 -0
  77. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  78. package/runtime/scripts/lib/artifact-hold.mjs +89 -0
  79. package/runtime/scripts/lib/artifact-store.mjs +103 -0
  80. package/runtime/scripts/lib/fs-kind.mjs +10 -0
  81. package/runtime/scripts/lib/git.mjs +53 -0
  82. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  83. package/runtime/scripts/lib/hfs-check.mjs +254 -28
  84. package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
  85. package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
  86. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  87. package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
  88. package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
  89. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
  90. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
  91. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  92. package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
  93. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  94. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
  95. package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +95 -41
  98. package/runtime/scripts/lib/hfs-tree.mjs +80 -0
  99. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  100. package/runtime/scripts/lib/json.mjs +22 -0
  101. package/runtime/scripts/lib/language.mjs +107 -0
  102. package/runtime/scripts/lib/path-key.mjs +2 -0
  103. package/runtime/scripts/lib/redact.mjs +148 -0
  104. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  105. package/runtime/scripts/lib/safe-remove.mjs +179 -0
  106. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  107. package/runtime/scripts/lib/sleep-sync.mjs +17 -0
  108. package/runtime/scripts/lib/stack-declaration.mjs +52 -0
  109. package/runtime/scripts/lib/stack-services.mjs +217 -0
  110. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  111. package/scaffold/service.mjs +333 -0
  112. package/sync/format.mjs +46 -0
  113. package/sync/hygiene.mjs +56 -24
  114. package/sync/index.mjs +126 -41
  115. package/sync/managed.mjs +170 -0
  116. package/sync/skeleton.mjs +32 -10
  117. package/sync/sonar-key.mjs +13 -0
  118. package/sync/ts-strict.mjs +48 -0
  119. package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
  120. package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
  121. package/templates/be/hooks/husky/pre-commit +13 -0
  122. package/templates/be/hooks/husky/pre-push +7 -0
  123. package/templates/be/package-scripts/package.json +21 -0
  124. package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
  125. package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
  126. package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
  127. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  128. package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
  129. package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
  130. package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
  131. package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
  132. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
  133. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
  134. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
  135. package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
  136. package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
  137. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
  138. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
  139. package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
  140. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
  141. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
  142. package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
  143. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
  144. package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
  145. package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
  146. package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
  147. package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
  148. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
  149. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
  150. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
  151. package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
  152. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
  153. package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
  154. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
  155. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
  156. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
  157. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
  158. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
  159. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
  162. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
  163. package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
  164. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
  165. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
  166. package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
  167. package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
  168. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
  169. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
  170. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
  171. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
  172. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
  173. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
  174. package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
  175. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
  176. package/templates/be/tool-config/eslint.config.mjs +3 -0
  177. package/templates/be/tool-config/jest.config.js +1 -0
  178. package/templates/be/tool-config/prettierignore +8 -0
  179. package/templates/be/tool-config/prettierrc +1 -0
  180. package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
  181. package/templates/be/tool-config/tsconfig.build.json +5 -0
  182. package/templates/be/tool-config/tsconfig.json +11 -0
  183. package/templates/common/gitignore.base +1 -1
  184. package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
  185. package/templates/fe/hooks/husky/pre-commit +16 -0
  186. package/templates/fe/hooks/husky/pre-push +6 -0
  187. package/templates/fe/package-scripts/package.json +17 -0
  188. package/templates/fe/parts/api-client.ts +44 -0
  189. package/templates/fe/parts/api-outcome.ts +7 -0
  190. package/templates/fe/quality-config/sonar-project.properties +8 -0
  191. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
  192. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
  193. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
  194. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  195. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
  196. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
  197. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
  198. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
  199. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
  200. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
  201. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
  202. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
  203. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
  204. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
  205. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
  206. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
  207. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
  208. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
  209. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
  210. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
  211. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
  212. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
  213. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
  214. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
  215. package/templates/fe/tool-config/eslint.config.mjs +3 -0
  216. package/templates/fe/tool-config/prettierignore +10 -0
  217. package/templates/fe/tool-config/prettierrc +1 -0
  218. package/templates/fe/tool-config/stylelint.config.mjs +3 -0
  219. package/templates/fe/tool-config/tsconfig.json +4 -0
  220. package/templates/be/pre-commit +0 -8
  221. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
  222. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
  223. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
  224. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
  225. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
  226. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
  227. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
  228. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
  229. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
  230. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
  231. package/templates/common/codecov.yml +0 -13
  232. package/templates/common/pre-push +0 -5
  233. package/templates/fe/e2e.yml +0 -22
  234. package/templates/fe/pre-commit +0 -7
  235. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
  236. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
  237. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
  238. package/templates/fe/sonar-project.properties +0 -11
  239. /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
  240. /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
  241. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
  242. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
  243. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
  244. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
  245. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
  246. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
@@ -12,8 +12,9 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { skillRoot } from '../../engine/runtime-root.mjs';
14
14
  import { parseYaml } from '../../engine/yaml.mjs';
15
- import { braceVariants } from './glob.mjs';
15
+ import { braceVariants, globExpression } from './glob.mjs';
16
16
  import { posixPath } from './path-key.mjs';
17
+ import { isPlainObject } from '../../engine/plain-object.mjs';
17
18
 
18
19
  export const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
19
20
  export const HFS_DECLARATION_FILE = 'hfs.json';
@@ -30,12 +31,12 @@ export class HfsSlotsError extends Error {
30
31
 
31
32
  const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
32
33
  const NAME = /^[a-z][a-z0-9-]*$/;
34
+ const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
33
35
  const SLOT_ID = /^(repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
34
36
  const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
35
37
  const TRACKED = ['tracked', 'ignored', 'external'];
36
38
  const TESTS = ['unit-beside', 'e2e', 'none'];
37
39
  const PROFILES = ['be', 'fe'];
38
- const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
39
40
  const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
40
41
 
41
42
  // ------------------------------------------------------------------------------------------------ patterns
@@ -118,17 +119,17 @@ const fail = (code, message, details) => { throw new HfsSlotsError(code, message
118
119
  /** Shape problems of a parsed manifest, in the words of modules/schemas/hfs-slots.schema.yaml. */
119
120
  function manifestShapeProblems(m) {
120
121
  const bad = [];
121
- if (!isMap(m)) return ['the manifest is not a map'];
122
+ if (!isPlainObject(m)) return ['the manifest is not a map'];
122
123
  const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
123
124
  for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
124
125
  if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
125
126
  if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
126
- if (!isMap(m.versioning) || !['patch', 'minor', 'major', 'retire', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major, retire and pins text');
127
+ if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'retire', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major, retire and pins text');
127
128
  if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
128
129
  if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED)) bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
129
130
  if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
130
131
  for (const key of ['appKinds', 'tiers']) {
131
- if (!isMap(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
132
+ if (!isPlainObject(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
132
133
  for (const extra of Object.keys(m[key])) if (!PROFILES.includes(extra)) bad.push(`${key}.${extra} is not a profile`);
133
134
  for (const profile of PROFILES) if (!(profile in m[key])) bad.push(`${key}.${profile} is missing`);
134
135
  }
@@ -137,26 +138,40 @@ function manifestShapeProblems(m) {
137
138
  if (kinds !== undefined && (!Array.isArray(kinds) || !kinds.length || !kinds.every((k) => NAME.test(String(k))) || new Set(kinds).size !== kinds.length)) bad.push(`appKinds.${profile} must be a non-empty list of unique names`);
138
139
  const tiers = m.tiers?.[profile];
139
140
  if (tiers === undefined) continue;
140
- if (!isMap(tiers) || !Object.keys(tiers).length) { bad.push(`tiers.${profile} must be a non-empty map`); continue; }
141
+ if (!isPlainObject(tiers) || !Object.keys(tiers).length) { bad.push(`tiers.${profile} must be a non-empty map`); continue; }
141
142
  for (const [tier, def] of Object.entries(tiers)) {
142
143
  if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
143
- if (!isMap(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
144
+ if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
144
145
  for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
145
146
  }
146
147
  }
147
- const blockLinesOk = (v) => Number.isInteger(v) && v >= 2;
148
- const fileLinesOk = (v) => isMap(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
148
+ const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
149
+ const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
149
150
  const rp = m.ruleParams;
150
- if (!isMap(rp) || Object.keys(rp).some((k) => !PROFILES.includes(k)) || !PROFILES.every((p) => isMap(rp[p]))) bad.push('ruleParams must be a map with be and fe');
151
+ if (!isPlainObject(rp) || Object.keys(rp).some((k) => !PROFILES.includes(k)) || !PROFILES.every((p) => isPlainObject(rp[p]))) bad.push('ruleParams must be a map with be and fe');
151
152
  else {
152
- if (!(strList(rp.be.globalModules) && new Set(rp.be.globalModules).size === rp.be.globalModules.length) || !fileLinesOk(rp.be.fileLines) || !blockLinesOk(rp.be.duplicateBlockLines) || Object.keys(rp.be).length !== 3) bad.push('ruleParams.be needs globalModules (unique paths), fileLines {soft, hardGrowth} and duplicateBlockLines (integer >= 2)');
153
- if (!fileLinesOk(rp.fe.fileLines) || typeof rp.fe.clientModule !== 'string' || !rp.fe.clientModule || !blockLinesOk(rp.fe.duplicateBlockLines) || Object.keys(rp.fe).length !== 3) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth}, clientModule and duplicateBlockLines (integer >= 2)');
153
+ if (!fileLinesOk(rp.be.fileLines) || !blockOk(rp.be.duplicateBlock) || Object.keys(rp.be).length !== 7) bad.push('ruleParams.be needs fileLines {soft, hardGrowth}, duplicateBlock {lines >= 2, tokens >= 1}, infraOwners, specDoubles, suffixes, bannedSuffixes and contractShape {helper}');
154
+ if (!isPlainObject(rp.be.contractShape) || Object.keys(rp.be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(rp.be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
155
+ const owners = rp.be.infraOwners;
156
+ const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
157
+ if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
158
+ const roleList = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => /^[a-z][a-z0-9-]*$/.test(String(x))) && new Set(v).size === v.length;
159
+ if (!roleList(rp.be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
160
+ if (!roleList(rp.be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
161
+ else if (roleList(rp.be.suffixes) && rp.be.suffixes.some((x) => rp.be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
162
+ const formNames = ['call', 'new', 'curried', 'object', 'primitive', 'array'];
163
+ const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => formNames.includes(x)) && new Set(v).size === v.length;
164
+ const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
165
+ const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
166
+ const sd = rp.be.specDoubles;
167
+ if (!isPlainObject(sd) || Object.keys(sd).sort().join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
168
+ if (!fileLinesOk(rp.fe.fileLines) || !blockOk(rp.fe.duplicateBlock) || Object.keys(rp.fe).length !== 2) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
154
169
  }
155
170
  if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
156
- const slotKeys = new Set(['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'allows', 'forbids', 'layers', 'budget', 'managedBy', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor']);
171
+ const slotKeys = new Set(['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor']);
157
172
  m.slots.forEach((slot, index) => {
158
- const at = isMap(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
159
- if (!isMap(slot)) { bad.push(`${at} is not a map`); return; }
173
+ const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
174
+ if (!isPlainObject(slot)) { bad.push(`${at} is not a map`); return; }
160
175
  for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
161
176
  if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
162
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`);
@@ -169,9 +184,12 @@ function manifestShapeProblems(m) {
169
184
  if (slot.appKind !== undefined && !NAME.test(String(slot.appKind))) bad.push(`${at}: appKind must be a name`);
170
185
  if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
171
186
  if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
172
- if (slot.requiredInstances !== undefined && !(isMap(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
173
- for (const key of ['requires', 'allows', 'forbids', 'layers']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
174
- if (slot.budget !== undefined && !(isMap(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
187
+ if (slot.requiredInstances !== undefined && !(isPlainObject(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
188
+ for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
189
+ if (slot.kinds !== undefined && strList(slot.kinds) && (!slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k)))) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
190
+ if (slot.roles !== undefined && !(isPlainObject(slot.roles) && Object.keys(slot.roles).length && Object.entries(slot.roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/')))) bad.push(`${at}: roles must map a role name to a file name`);
191
+ if (slot.composedBy !== undefined && !(strList(slot.composedBy) && slot.composedBy.length && new Set(slot.composedBy).size === slot.composedBy.length)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
192
+ if (slot.budget !== undefined && !(isPlainObject(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
175
193
  if (slot.managedBy !== undefined && !NAME.test(String(slot.managedBy))) bad.push(`${at}: managedBy must be a template id`);
176
194
  if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
177
195
  if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
@@ -207,11 +225,13 @@ function manifestSemanticProblems(m) {
207
225
  if (slot.appKind !== undefined && !pathVars.has('app')) bad.push(`slot ${slot.id}: an app-kind slot binds <app> in its path`);
208
226
  if (slot.requiredWhen !== undefined && slot.presence !== 'required') bad.push(`slot ${slot.id}: requiredWhen belongs to a required slot`);
209
227
  for (const name of Object.keys(slot.requiredInstances ?? {})) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requiredInstances names <${name}>, which the path does not bind`);
228
+ for (const file of Object.values(slot.roles ?? {})) for (const name of varsOf(file)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: roles names <${name}> in ${file}, which the path does not bind`);
210
229
  for (const entry of slot.requires ?? []) for (const name of varsOf(entry)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requires ${entry} uses <${name}>, which the path does not bind`);
211
230
  for (const variant of braceVariants(slot.path)) {
212
231
  try { compileVariant(slot, variant); } catch (error) { bad.push(`slot ${slot.id}: pattern ${variant} does not compile (${error.message})`); }
213
232
  }
214
233
  if (slot.successor !== undefined && !m.slots.some((s) => s.id === slot.successor)) bad.push(`slot ${slot.id}: successor ${slot.successor} is not a slot`);
234
+ 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`);
215
235
  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`);
216
236
  }
217
237
  for (const profile of PROFILES) {
@@ -247,20 +267,25 @@ export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_
247
267
  /** Shape problems of a parsed hfs.json, in the words of modules/schemas/hfs-repo.schema.yaml. */
248
268
  function declarationShapeProblems(d) {
249
269
  const bad = [];
250
- if (!isMap(d)) return ['hfs.json is not an object'];
270
+ if (!isPlainObject(d)) return ['hfs.json is not an object'];
251
271
  for (const key of Object.keys(d)) if (!['hfs', 'profile', 'project', 'apps', 'optionalSlots', 'connections', 'stacks'].includes(key)) bad.push(`unknown key ${key}`);
252
272
  if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
253
273
  if (!PROFILES.includes(d.profile)) bad.push('profile must be be or fe');
254
274
  if (!NAME.test(String(d.project))) bad.push('project must be a project name');
255
275
  if (!Array.isArray(d.apps) || !d.apps.length) bad.push('apps must list every apps/<name> with its kind');
256
276
  else d.apps.forEach((app, i) => {
257
- if (!isMap(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}`);
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}`);
258
278
  });
259
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');
260
- for (const key of ['optionalSlots', 'connections']) {
261
- if (d[key] === undefined) continue;
262
- const pattern = key === 'optionalSlots' ? SLOT_ID : NAME;
263
- if (!Array.isArray(d[key]) || !d[key].every((v) => pattern.test(String(v))) || new Set(d[key]).size !== d[key].length) bad.push(`${key} must be a unique list of ${key === 'optionalSlots' ? 'slot ids' : 'connection names'}`);
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}_)`);
288
+ }
264
289
  }
265
290
  if (d.profile === 'fe' && d.connections !== undefined) bad.push('connections belong to a backend repository');
266
291
  return bad;
@@ -305,7 +330,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
305
330
  project: declaration.project,
306
331
  apps: Object.freeze(declaration.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
307
332
  optionalSlots: Object.freeze([...(declaration.optionalSlots ?? [])]),
308
- connections: Object.freeze([...connections]),
333
+ connections: Object.freeze(connections.map((c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix }))),
309
334
  manifestVersion: manifest.version,
310
335
  });
311
336
  }
@@ -389,18 +414,41 @@ export function createSlotResolver(manifest, repo) {
389
414
  };
390
415
  }
391
416
 
417
+ /**
418
+ * The folder kind of a file: the layer or kind folder its slot names (`layers`, `kinds`) that the file sits in. A slot whose
419
+ * path spells the choice (`components/{blocks,leaves}/<name>/`) answers from the matched pattern; a slot that owns a whole
420
+ * directory (`packages/<family>-ui/`) answers from the first folder below its root that the list names.
421
+ */
422
+ function kindOf({ variant, slot, root }, p) {
423
+ const names = [...(slot.layers ?? []), ...(slot.kinds ?? [])];
424
+ if (!names.length) return null;
425
+ const literal = variant.segments.find((segment) => names.includes(segment));
426
+ if (literal) return literal;
427
+ const below = root ? p.slice(root.length + 1) : p;
428
+ return below.split('/').slice(0, -1).find((segment) => names.includes(segment)) ?? null;
429
+ }
430
+
431
+ /** The role of a file in its slot: the entry of `roles` whose file name (variables filled from the path, `*` a wildcard inside the name) matches the file's name. */
432
+ function roleOf(slot, bindings, p) {
433
+ const name = p.split('/').pop();
434
+ for (const [role, file] of Object.entries(slot.roles ?? {})) if (globExpression(fillVars(file, bindings)).test(name)) return role;
435
+ return null;
436
+ }
437
+
392
438
  /**
393
439
  * status: owned | forbidden (external slot) | not-enabled (opt-in slot the repository did not declare) | ambiguous
394
- * (two slots of equal specificity; a manifest gap) | no-slot (code HFS_PATH_NO_SLOT, with the nearest slot).
440
+ * (two slots of equal specificity; a manifest gap) | no-slot (code HFS_SLOT_UNDECLARED, with the nearest slot).
395
441
  */
396
442
  function classifyPath(input) {
397
443
  const p = clean(input);
398
444
  const { hit, ambiguous } = best(matches(p));
399
445
  if (ambiguous.length) return { path: p, status: 'ambiguous', candidates: ambiguous };
400
- if (!hit) return { path: p, status: 'no-slot', code: 'HFS_PATH_NO_SLOT', nearest: nearest(p) };
446
+ if (!hit) return { path: p, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: nearest(p) };
401
447
  const { slot, root, bindings } = hit;
402
448
  const status = slot.presence === 'forbidden' ? 'forbidden' : (slotEnabled(slot) ? 'owned' : 'not-enabled');
403
- return { path: p, status, slot: slot.id, root, bindings, presence: slot.presence, tracking: slot.tracked, ...(status === 'forbidden' ? { goesTo: slot.goesTo } : {}) };
449
+ const kind = kindOf(hit, p);
450
+ const role = roleOf(slot, bindings, p);
451
+ return { path: p, status, slot: slot.id, root, bindings, ...(kind ? { kind } : {}), ...(role ? { role } : {}), presence: slot.presence, tracking: slot.tracked, ...(status === 'forbidden' ? { goesTo: slot.goesTo } : {}) };
404
452
  }
405
453
 
406
454
  const slotOf = (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; };
@@ -426,7 +474,7 @@ export function createSlotResolver(manifest, repo) {
426
474
 
427
475
  /**
428
476
  * Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
429
- * tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_PATH_NO_SLOT for the path no slot owns).
477
+ * tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_SLOT_UNDECLARED for the path no slot owns).
430
478
  * Cycles are a graph property and belong to the architecture check, not to one edge.
431
479
  */
432
480
  function importAllowed(fromPath, toPath) {
@@ -535,7 +583,7 @@ export function createSlotResolver(manifest, repo) {
535
583
  });
536
584
  }
537
585
 
538
- /** The rule parameters of one profile (be: globalModules, fileLines, duplicateBlockLines; fe: fileLines, clientModule, duplicateBlockLines), as a frozen deep copy. */
586
+ /** The rule parameters of one profile (be: fileLines, duplicateBlock, infraOwners, suffixes, bannedSuffixes; fe: fileLines, duplicateBlock), as a frozen deep copy. */
539
587
  export function ruleParams(manifest, profile) {
540
588
  if (!PROFILES.includes(profile)) fail('HFS_MANIFEST_INVALID', `ruleParams has no profile ${profile}`, { profile });
541
589
  const deepFreeze = (v) => { if (v && typeof v === 'object') Object.values(v).forEach(deepFreeze); return Object.freeze(v); };
@@ -561,14 +609,14 @@ const FILE_ENFORCERS = ['machine', 'hfs', 'work-validate', 'sonar'];
561
609
  /** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
562
610
  function ruleCatalogProblems(d) {
563
611
  const bad = [];
564
- if (!isMap(d)) return ['the rule catalog is not a map'];
612
+ if (!isPlainObject(d)) return ['the rule catalog is not a map'];
565
613
  for (const key of Object.keys(d)) if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules'].includes(key)) bad.push(`unknown top-level key ${key}`);
566
614
  const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(d.schema));
567
615
  if (!schemaOk) bad.push('schema must be starci/hfs-rules@<major>');
568
616
  if (!SEMVER.test(String(d.version))) bad.push('version must be MAJOR.MINOR.PATCH');
569
617
  else if (schemaOk && d.schema.split('@')[1] !== d.version.split('.')[0]) bad.push('the major of version must equal the number after @ in schema');
570
618
  const vocabulary = (key, names) => {
571
- if (!isMap(d[key])) { bad.push(`${key} must be a map`); return; }
619
+ if (!isPlainObject(d[key])) { bad.push(`${key} must be a map`); return; }
572
620
  if (JSON.stringify(Object.keys(d[key])) !== JSON.stringify(names)) bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
573
621
  for (const [name, text] of Object.entries(d[key])) if (typeof text !== 'string' || !text.trim()) bad.push(`${key}.${name} needs a description`);
574
622
  };
@@ -578,14 +626,14 @@ function ruleCatalogProblems(d) {
578
626
  const codeOwner = new Map();
579
627
  d.rules.forEach((r, index) => {
580
628
  const at = `rules[${index}]`;
581
- if (!isMap(r)) { bad.push(`${at} is not a map`); return; }
629
+ if (!isPlainObject(r)) { bad.push(`${at} is not a map`); return; }
582
630
  const label = typeof r.id === 'string' ? r.id : at;
583
- for (const key of Object.keys(r)) if (!['id', 'code', 'title_vi', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
584
- const expectedId = `R${String(index + 1).padStart(2, '0')}`;
585
- if (!/^R\d{2}$/.test(String(r.id))) bad.push(`${at}.id must be R<two digits>`);
586
- else if (r.id !== expectedId) bad.push(`${label} is out of order: ${at} must be ${expectedId}`);
631
+ for (const key of Object.keys(r)) if (!['id', 'code', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
632
+ // A retired rule leaves its id unused for good (never reused), so ids only have to increase.
633
+ if (!/^R\d{2,3}$/.test(String(r.id))) bad.push(`${at}.id must be R<two or three digits>`);
634
+ else if (index > 0 && typeof d.rules[index - 1]?.id === 'string' && Number(r.id.slice(1)) <= Number(d.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${d.rules[index - 1].id} comes before it`);
587
635
  if (!FINDING_CODE.test(String(r.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
588
- for (const key of ['title_vi', 'law']) if (typeof r[key] !== 'string' || !r[key].trim()) bad.push(`${label}.${key} is missing`);
636
+ if (typeof r.law !== 'string' || !r.law.trim()) bad.push(`${label}.law is missing`);
589
637
  if (typeof r.law === 'string' && r.law.includes('\n')) bad.push(`${label}.law must be one line`);
590
638
  const enumList = (key, allowed) => {
591
639
  if (!Array.isArray(r[key]) || !r[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
@@ -612,7 +660,7 @@ function ruleCatalogProblems(d) {
612
660
  const seen = new Set();
613
661
  r.enforcers.forEach((e, n) => {
614
662
  const eat = `${label}.enforcers[${n}]`;
615
- if (!isMap(e)) { bad.push(`${eat} is not a map`); return; }
663
+ if (!isPlainObject(e)) { bad.push(`${eat} is not a map`); return; }
616
664
  for (const key of Object.keys(e)) if (!['kind', 'id', 'status', 'at'].includes(key)) bad.push(`${eat} has unknown key ${key}`);
617
665
  if (!ENFORCER_FAMILIES.includes(e.kind)) bad.push(`${eat}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
618
666
  if (!ENFORCER_ID.test(String(e.id))) bad.push(`${eat}.id must be kebab-case`);
@@ -626,7 +674,7 @@ function ruleCatalogProblems(d) {
626
674
  } else if (FILE_ENFORCERS.includes(e.kind) && e.status !== 'planned') bad.push(`${eat} exists, so it names the file (at) that emits its code`);
627
675
  });
628
676
  if (Array.isArray(r.gates)) {
629
- const hasSonar = r.enforcers.some((e) => isMap(e) && e.kind === 'sonar');
677
+ const hasSonar = r.enforcers.some((e) => isPlainObject(e) && e.kind === 'sonar');
630
678
  if (r.gates.includes('sonar') !== hasSonar) bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
631
679
  }
632
680
  });
@@ -662,6 +710,12 @@ export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_R
662
710
  byCode: (code) => byCode.get(code) ?? null,
663
711
  /** The rules that run at a gate. */
664
712
  forGate: (gate) => list.filter((r) => r.gates.includes(gate)),
713
+ /** The catalogued why code of a lint finding's rule id (`starci-be/<id>`, `starci-fe/<id>`), or undefined. */
714
+ lintCode: (ruleId) => {
715
+ const [plugin, id] = String(ruleId ?? '').split('/');
716
+ const kind = plugin === 'starci-be' ? 'eslint-be' : plugin === 'starci-fe' ? 'eslint-fe' : null;
717
+ return kind ? list.find((r) => r.enforcers.some((e) => e.kind === kind && e.id === id))?.code : undefined;
718
+ },
665
719
  /** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
666
720
  forEnforcer: (kind, id) => list.filter((r) => r.enforcers.some((e) => e.kind === kind && e.id === id)),
667
721
  /** Every enforcer still owed, as {rule, kind, id}. */
@@ -671,5 +725,5 @@ export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_R
671
725
  });
672
726
  }
673
727
 
674
- /** The 77 rules of this runtime's catalog, frozen, in id order. */
728
+ /** The rules of this runtime's catalog, frozen, in id order. */
675
729
  export const rules = (options) => loadRuleCatalog(options).rules;
@@ -0,0 +1,80 @@
1
+ // hfs-tree.mjs - the facts about the work tree that `git ls-files` cannot show: directories with no file below them, sibling
2
+ // directories one typo apart (a renamed structure whose old copy was never removed), and entries git neither tracks nor
3
+ // ignores. hfs-check.mjs turns these facts into HFS_EMPTY_DIR, HFS_GHOST_TREE and HFS_UNTRACKED_ROOT_ENTRY findings (R03).
4
+ // Read-only: it walks the file system and asks git, nothing else.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { gitOutput } from './git.mjs';
8
+ import { posixPath } from './path-key.mjs';
9
+
10
+ /** Directory names no tree check enters: git's own store and installed packages. */
11
+ const NEVER_WALKED = new Set(['.git', 'node_modules']);
12
+ /** Two sibling names this close (Levenshtein) are the same name misspelt. */
13
+ export const GHOST_DISTANCE = 2;
14
+
15
+ /** Levenshtein distance of two names. */
16
+ export function editDistance(a, b) {
17
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
18
+ for (let i = 1; i <= a.length; i += 1) {
19
+ const row = [i];
20
+ for (let j = 1; j <= b.length; j += 1) row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
21
+ previous = row;
22
+ }
23
+ return previous[b.length];
24
+ }
25
+
26
+ /**
27
+ * The directory tree of `repoRoot` below any `ignored` slot: {name, rel, files, dirs: [node]}. A directory in an
28
+ * ignored slot is not entered and counts as holding a file (its content is build output the repository does not own).
29
+ * Links are not followed.
30
+ */
31
+ export function readTree(repoRoot, { isIgnored }) {
32
+ const walk = (abs, rel) => {
33
+ const node = { name: path.basename(abs), rel, files: 0, dirs: [] };
34
+ let entries;
35
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return node; }
36
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
37
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
38
+ if (!entry.isDirectory()) { node.files += 1; continue; }
39
+ if (NEVER_WALKED.has(entry.name)) continue;
40
+ if (isIgnored(childRel)) { node.files += 1; continue; }
41
+ node.dirs.push(walk(path.join(abs, entry.name), childRel));
42
+ }
43
+ return node;
44
+ };
45
+ return walk(repoRoot, '');
46
+ }
47
+
48
+ const holdsNoFile = (node) => node.files === 0 && node.dirs.every(holdsNoFile);
49
+ const countDirs = (node) => node.dirs.reduce((sum, child) => sum + 1 + countDirs(child), 0);
50
+
51
+ /**
52
+ * The tree's own findings: `empty` are the topmost directories with no file anywhere below them (each with the number of
53
+ * directories it holds); `ghosts` are pairs of sibling directories within GHOST_DISTANCE edits where `ghost` has no file
54
+ * below it and `of` is its lookalike.
55
+ */
56
+ export function treeFacts(tree) {
57
+ const empty = [];
58
+ const ghosts = [];
59
+ const visit = (node) => {
60
+ for (const child of node.dirs) {
61
+ if (holdsNoFile(child)) empty.push({ path: child.rel, below: countDirs(child) });
62
+ else visit(child);
63
+ }
64
+ for (const [i, a] of node.dirs.entries()) {
65
+ for (const b of node.dirs.slice(i + 1)) {
66
+ if (editDistance(a.name, b.name) > GHOST_DISTANCE) continue;
67
+ if (holdsNoFile(a)) ghosts.push({ path: a.rel, of: b.rel, distance: editDistance(a.name, b.name) });
68
+ if (holdsNoFile(b)) ghosts.push({ path: b.rel, of: a.rel, distance: editDistance(a.name, b.name) });
69
+ }
70
+ }
71
+ };
72
+ visit(tree);
73
+ return { empty, ghosts };
74
+ }
75
+
76
+ /** Entries git neither tracks nor ignores, as it prints them (a wholly untracked directory is one entry ending in '/'). */
77
+ export function untrackedEntries(repoRoot) {
78
+ const out = gitOutput(['ls-files', '-z', '--others', '--exclude-standard', '--directory', '--no-empty-directory'], { dir: repoRoot, maxBuffer: 256 * 1024 * 1024 });
79
+ return out.split('\0').filter(Boolean).map(posixPath);
80
+ }
@@ -0,0 +1,68 @@
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
3
+ // runtime/ bundle (packages/hfs/scripts/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
4
+ // architecture machine and `hfs check` use, and never tests a path with a regular expression of its own.
5
+ import path from 'node:path';
6
+ import { allowsFile } from './hfs-allows.mjs';
7
+ import { openHfs } from './hfs-slots.mjs';
8
+
9
+ const posix = (p) => String(p).replace(/\\/g, '/');
10
+ const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
11
+
12
+ /**
13
+ * The view rules read, over an opened resolver.
14
+ *
15
+ * @param {object} opened - The result of `openHfs` (manifest, repo and resolver methods).
16
+ * @param {string} repoRoot - The absolute repository root.
17
+ * @returns {object} The frozen view: profile, apps, connections, ruleParams, relative, classify, slotOf, tierOf, ownerOf, slot.
18
+ */
19
+ export function hfsView(opened, repoRoot) {
20
+ const rel = (file) => {
21
+ const text = posix(file);
22
+ if (!isAbsoluteAny(text)) return text.replace(/^\.\//, '');
23
+ return posix(path.relative(repoRoot, file));
24
+ };
25
+ const slotCache = new Map();
26
+ const classify = (file) => {
27
+ const key = rel(file);
28
+ if (!slotCache.has(key)) slotCache.set(key, opened.classifyPath(key));
29
+ return slotCache.get(key);
30
+ };
31
+ return Object.freeze({
32
+ repoRoot,
33
+ profile: opened.repo.profile,
34
+ apps: opened.repo.apps,
35
+ connections: opened.repo.connections,
36
+ ruleParams: opened.ruleParams(),
37
+ /** The repository-relative, forward-slash form of a linted filename. */
38
+ relative: rel,
39
+ /** The classification of a file: `{ slot, tier, owner?, bindings? ... }` or a no-slot status. */
40
+ classify,
41
+ /** The slot id that owns a file, or null when no slot does. */
42
+ slotOf: (file) => classify(file).slot ?? null,
43
+ /** The tier of a file, or null. */
44
+ tierOf: (file) => opened.tierOf(rel(file)) ?? null,
45
+ /** The owner root of a file, or null. */
46
+ ownerOf: (file) => opened.ownerOf(rel(file))?.root ?? null,
47
+ /** What the slot that owns a file says about it: `{ slot, root, relative, allowed, entry?, forbiddenBy?, allows }`, or null when the slot names no `allows`. */
48
+ allows: (file) => allowsFile(opened, rel(file)),
49
+ /** A slot definition by id. */
50
+ slot: (id) => opened.slot(id),
51
+ });
52
+ }
53
+
54
+ /**
55
+ * The view of the repository on disk whose root is `repoRoot`, against the manifest under `runtimeRoot`.
56
+ *
57
+ * @param {{ runtimeRoot: string, repoRoot: string }} input - Where the manifest copy lives and the repository root.
58
+ * @returns {object} The frozen view.
59
+ */
60
+ export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot);
61
+
62
+ /**
63
+ * The view of an in-memory declaration (rule tests): no file is read except the manifest.
64
+ *
65
+ * @param {{ runtimeRoot: string, declaration: object, repoRoot: string }} input - Manifest root, hfs.json object, linted root.
66
+ * @returns {object} The frozen view.
67
+ */
68
+ export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, declaration }), repoRoot);
@@ -0,0 +1,22 @@
1
+ // json.mjs — the ledger's forgiving JSON read. Every one of these rows may hold text a peer wrote or a
2
+ // truncated payload, so a malformed column is the caller's fallback, never a throw. The parse of the
3
+ // literal 'null' is kept as null (callers that want {} instead write `parseJsonOr`).
4
+ import fs from 'node:fs';
5
+
6
+ /** `text` as JSON, or `fallback` when it does not parse. */
7
+ export const parseJson = (text, fallback = null) => {
8
+ try { return JSON.parse(text); } catch { return fallback; }
9
+ };
10
+
11
+ /** `text` as JSON, or `fallback` when it does not parse OR parses to null/undefined. Rows written
12
+ * as the literal 'null' read back as the fallback too, so a caller that wants an object gets one. */
13
+ export const parseJsonOr = (text, fallback = {}) => parseJson(text) ?? fallback;
14
+
15
+ /** A jobs/events row spread with `payload`: the row's payload_json parsed via parseJsonOr
16
+ * (`fallback` defaults to {}). Pass fallback null to keep a raw parseJson result, nulls included. */
17
+ export const withPayload = (row, fallback = {}) => (row ? { ...row, payload: parseJsonOr(row.payload_json, fallback) } : row);
18
+
19
+ /** `file` read and parsed as JSON, or `fallback` when the file is missing, unreadable or malformed. */
20
+ export const readJsonFile = (file, fallback = null) => {
21
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
22
+ };
@@ -0,0 +1,107 @@
1
+ // language.mjs - the one home of "is this text English?" for source, comments, tests and docs (HFS_LANGUAGE_NOT_ENGLISH,
2
+ // BE_SOURCE_FORM, FE_SOURCE_FORM). Shared by the architecture machine (docs) and by both lint canons (each ships a byte
3
+ // copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
4
+ //
5
+ // Detection is structural on characters, never a word list: the letters Vietnamese adds to the Latin alphabet (a-breve, a-circumflex,
6
+ // d-stroke, e-circumflex, o-circumflex, o-horn, u-horn) and every vowel carrying a tone mark. Text is folded to NFC first, so a
7
+ // decomposed spelling (base letter + combining mark) is caught exactly like the precomposed one, and a loanword such as
8
+ // `naive`, `facade` or `Muller` (no tone mark, no Vietnamese letter) is never a hit.
9
+ import path from 'node:path';
10
+
11
+ /** One Vietnamese letter, precomposed (NFC): the Vietnamese-specific base letters, tone-marked vowels and the Latin Extended Additional block. */
12
+ export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/;
13
+
14
+ /** Whether `text` holds a Vietnamese letter, in NFC or NFD spelling. */
15
+ export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
16
+
17
+ /** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
18
+ export function secondLanguageHits(text) {
19
+ const hits = [];
20
+ String(text).split(/\r?\n/).forEach((raw, index) => {
21
+ const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
22
+ if (match) hits.push({ line: index + 1, column: match.index + 1 });
23
+ });
24
+ return hits;
25
+ }
26
+
27
+ /**
28
+ * The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
29
+ * (scripts/checks/failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
30
+ */
31
+ export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meaning_vi', 'nextStep_vi', 'causes_vi']);
32
+
33
+ /**
34
+ * The fields of a document that carry deliberate Vietnamese text, keyed by file: a declared field-level exception of that file's
35
+ * typed content, never a path pattern over content. `fields` are block keys (the key's own value and its list items are exempt);
36
+ * `flowFields` are keys inside an inline flow map (`{ vi: ..., en: ... }`), where only that key's value is exempt. A Vietnamese
37
+ * value under any other key of the same file is still a finding.
38
+ * - modules/kernel/failure-codes.yaml: the operator text the owner mandated for the failure-code catalog.
39
+ * - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
40
+ * - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
41
+ * phrase lists of the archetype recognisers).
42
+ */
43
+ export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
44
+ 'modules/kernel/failure-codes.yaml': Object.freeze({ fields: FAILURE_CODE_VIETNAMESE_FIELDS }),
45
+ 'modules/ops/_labels.yaml': Object.freeze({ flowFields: Object.freeze(['vi']) }),
46
+ 'modules/goal/archetypes.yaml': Object.freeze({
47
+ fields: Object.freeze([
48
+ 'buildIntent', 'canonIntent', 'e2eIntent', 'uatIntent', 'proofNegation', 'integrationIntent', 'integrationNegation', 'brandIntent',
49
+ 'phrases', 'requires', 'excludes', 'backend', 'frontend', 'package',
50
+ ]),
51
+ }),
52
+ });
53
+
54
+ /**
55
+ * The slots whose files may carry another language, and why: a catalog is product copy in two languages, and an i18n
56
+ * fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
57
+ * comment pragma and no path pattern. The slots are declared in knowledge/hfs/slots.yaml.
58
+ */
59
+ export const LOCALIZED_TEXT_SLOTS = Object.freeze([
60
+ 'be.domain.messages', 'be.feature.messages', 'be.tests.fixtures.i18n',
61
+ 'fe.modules.i18n', 'fe.package.i18n',
62
+ ]);
63
+
64
+ /** Whether a document (Markdown or YAML) of `slot` is exempt: only catalogue and fixture DATA (YAML) is; Markdown is prose and never is. */
65
+ export const isLocalizedDataFile = (rel, slot) => LOCALIZED_TEXT_SLOTS.includes(slot) && /\.ya?ml$/.test(rel);
66
+
67
+ /**
68
+ * The key each line of a YAML text belongs to: a `key:` line owns itself, and every deeper-indented line after it (a list item,
69
+ * a folded block, a wrapped scalar) belongs to the same key. A line at or above the key's indent that is not a key belongs to none.
70
+ */
71
+ export function yamlKeyOfEachLine(text) {
72
+ let key = null;
73
+ let keyIndent = -1;
74
+ return String(text).split(/\r?\n/).map((raw) => {
75
+ if (raw.trim() === '') return key;
76
+ const indent = raw.length - raw.trimStart().length;
77
+ const own = /^(\s*(?:-\s+)?)([A-Za-z_][\w-]*)\s*:(?:\s|$)/.exec(raw);
78
+ if (own) { key = own[2]; keyIndent = own[1].length; return key; }
79
+ // a list item may sit at the key's own indent (`key:` then `- item` at the same column)
80
+ if (indent > keyIndent || (indent === keyIndent && /^\s*-\s/.test(raw))) return key;
81
+ key = null;
82
+ keyIndent = -1;
83
+ return null;
84
+ });
85
+ }
86
+
87
+ /** The Vietnamese hits of a document, minus the declared field-level exceptions of `rel` (a repository-relative POSIX path). */
88
+ export function documentLanguageHits(rel, text) {
89
+ const declared = DECLARED_VIETNAMESE_FIELDS[rel];
90
+ const hits = secondLanguageHits(text);
91
+ if (!declared) return hits;
92
+ const keys = yamlKeyOfEachLine(text);
93
+ const lines = String(text).split(/\r?\n/);
94
+ return hits.filter((hit) => {
95
+ if (declared.fields?.includes(keys[hit.line - 1])) return false;
96
+ if (!declared.flowFields) return true;
97
+ // an inline flow map: drop the declared fields' values and judge what is left of the line
98
+ const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(`\\b${field}\\s*:\\s*[^,}]*`, 'g'), ''), lines[hit.line - 1]);
99
+ return hasSecondLanguage(rest);
100
+ });
101
+ }
102
+
103
+ /** The file extensions of a prose document (Markdown and YAML). */
104
+ export const DOCUMENT_EXTENSIONS = Object.freeze(['.md', '.yaml', '.yml']);
105
+
106
+ /** Whether `rel` is a document a language check reads. */
107
+ export const isDocument = (rel) => DOCUMENT_EXTENSIONS.includes(path.posix.extname(rel));
@@ -7,6 +7,8 @@ const WIN = process.platform === 'win32';
7
7
  export const slash = (p) => String(p ?? '').replaceAll('\\', '/');
8
8
  /** A relative path in git's spelling: forward slashes, no leading './'. */
9
9
  export const posixPath = (p) => slash(p).replace(/^\.\//, '');
10
+ /** Whether posix `file` is `prefix` itself or under it (`file === prefix || file` starts with `prefix/`). */
11
+ export const sameOrUnder = (file, prefix) => file === prefix || file.startsWith(`${prefix}/`);
10
12
  /** `p` case-folded where the filesystem ignores case (Windows). */
11
13
  export const foldCase = (p) => (WIN ? p.toLowerCase() : p);
12
14
  /** Whether two spellings name the same path on this host's filesystem. */