@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
package/sync/index.mjs CHANGED
@@ -1,29 +1,32 @@
1
- // hfs sync: render, verify and write the files a product repository cannot `extends`.
1
+ // hfs sync: render, verify and write the files an app cannot `extends`.
2
2
  //
3
- // The managed set is read from the slot manifest (knowledge/hfs/slots.yaml): every slot that names `managedBy` lists,
4
- // in its `path`, the whole files sync renders, and `managedBy` names the template directory they are rendered from:
5
- // templates/<profile or common>/<managedBy>/<file path with each leading dot dropped>. Two targets are not whole files
6
- // of a managedBy slot and are listed in this module: the marked block of the shared .gitignore, and
7
- // .starciwork/.gitignore, which lives inside the .starciwork directory slot. Every file is rendered with the
8
- // repository's hfs.json (profile and apps) and, for the Sonar exclusions, the same jest preset a back end
9
- // repository installs. `--check` compares the sha256 of the rendered content with the file on disk and fails on any
10
- // drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked block is managed
11
- // and the repository's own lines around it are left alone. A back end's package.json is managed by its `scripts`
12
- // block only (mode scripts, compared as parsed JSON): the rest of the file is the repository's.
3
+ // An app is one repository: its root holds the one package.json (all dependencies, all scripts), the lockfile, the hfs.json of
4
+ // kind app, CI, the hooks, the formatter and Sonar configuration and .starciwork; its sides be/ and fe/ hold each side's tool
5
+ // configuration. The managed set is read from the slot manifest (knowledge/hfs/slots.yaml): every slot that names `managedBy`
6
+ // lists, in its `path`, the whole files sync renders, and `managedBy` names the template directory they are rendered from:
7
+ // templates/<scope or common>/<managedBy>/<file path with each leading dot dropped>, the scope being app for a root slot and the
8
+ // side (be, fe) for a side slot, whose files land under that side's folder. Two targets are not whole files of a managedBy slot
9
+ // and are listed in this module: the marked block of the root .gitignore, and .starciwork/.gitignore, which lives inside the
10
+ // .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
11
+ // exclusions, the jest preset the app installs for its be side. `--check` compares the sha256 of the rendered content with the
12
+ // file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked
13
+ // block is managed and the app's own lines around it are left alone. The root package.json is managed by its `scripts` block
14
+ // only (mode scripts, compared as parsed JSON): the rest of the file (dependencies, npm workspaces of fe/packages/*) is the app's.
13
15
  import { createHash } from 'node:crypto';
14
16
  import { createRequire } from 'node:module';
15
17
  import fs from 'node:fs';
16
18
  import path from 'node:path';
17
19
  import { braceVariants } from '../runtime/scripts/lib/glob.mjs';
18
- import { loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
20
+ import { APP_SCOPE, SIDES, loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
19
21
  import { readDeclaredSonarKey } from './sonar-key.mjs';
20
22
 
21
23
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
22
24
  export const NODE_MAJOR = 22;
23
- export const PROFILES = Object.freeze(['be', 'fe']);
25
+ /** The scopes a target is rendered for: the app root, then each side. */
26
+ export const SCOPES = Object.freeze([APP_SCOPE, ...SIDES]);
24
27
  export const BLOCK_BEGIN = '# >>> hfs sync (managed block; do not edit) >>>';
25
28
  export const BLOCK_END = '# <<< hfs sync <<<';
26
- const HEADER = profile => `# Generated by hfs sync (profile ${profile}). Do not edit: run "npx hfs sync --write".`;
29
+ const HEADER = scope => `# Generated by hfs sync (${scope === APP_SCOPE ? 'app root' : `${scope} side`}). Do not edit: run "npx hfs sync --write".`;
27
30
 
28
31
  export class SyncError extends Error {
29
32
  constructor(code, message) {
@@ -32,69 +35,54 @@ export class SyncError extends Error {
32
35
  }
33
36
  }
34
37
 
35
- // mode: file = whole file, block = the marked block inside a file the repository also writes to, scripts = the `scripts`
36
- // block of package.json. A template that can hold a comment opens with {{header}}; a file whose exact content is
37
- // specified (JSON, the eslint one-liner) has no header.
38
+ // mode: file = whole file, block = the marked block inside a file the app also writes to, scripts = the `scripts` block of
39
+ // package.json. A template that can hold a comment opens with {{header}}; a file whose exact content is specified (JSON, the
40
+ // eslint one-liner) has no header.
38
41
  const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts' });
39
42
  const LITERAL_FILE = /^[^*?<>{}[\]]+[^/*?<>{}[\]]$/;
40
43
 
41
- /** The targets no managedBy slot lists as a whole file (see the header of this module). */
44
+ /** The targets no managedBy slot lists as a whole file (see the header of this module); both belong to the app root. */
42
45
  export const UNLISTED_TARGETS = Object.freeze([
43
- { path: '.gitignore', template: { be: 'be/gitignore', fe: 'fe/gitignore' }, mode: 'block' },
44
- { path: '.starciwork/.gitignore', template: { be: 'be/starciwork.gitignore' }, mode: 'file' },
46
+ { path: '.gitignore', template: 'app/gitignore', mode: 'block' },
47
+ { path: '.starciwork/.gitignore', template: 'app/starciwork.gitignore', mode: 'file' },
45
48
  ].map(Object.freeze));
46
49
 
47
- const templateName = (profile, group, file) => `${profile}/${group}/${file.split('/').map(segment => segment.replace(/^\./, '')).join('/')}`;
50
+ const templateName = (scope, group, file) => `${scope}/${group}/${file.split('/').map(segment => segment.replace(/^\./, '')).join('/')}`;
51
+ const onSide = (scope, file) => (scope === APP_SCOPE ? file : `${scope}/${file}`);
48
52
 
49
53
  /**
50
- * Every target of `profile`: the files the manifest's managedBy slots list, then the unlisted ones. A slot that names
51
- * managedBy lists literal files only, and each has a template under its profile or under common.
54
+ * Every target of `scope` (app, be or fe), its path relative to that scope's folder: the files the manifest's managedBy slots
55
+ * list, then (app root) the unlisted ones. A slot that names managedBy lists literal files only, and each has a template under
56
+ * its scope or under common.
52
57
  */
53
- export function targetsOf(profile, { manifest = loadSlotManifest(), readTemplate = readBundled, hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)) } = {}) {
58
+ export function targetsOf(scope, { manifest = loadSlotManifest(), hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)) } = {}) {
54
59
  const listed = [];
55
60
  for (const slot of manifest.slots) {
56
- if (slot.managedBy === undefined || !slot.profiles.includes(profile)) continue;
61
+ if (slot.managedBy === undefined || !slot.profiles.includes(scope)) continue;
57
62
  for (const file of braceVariants(slot.path)) {
58
63
  if (!LITERAL_FILE.test(file)) throw new SyncError('HFS_SYNC_MANIFEST_MANAGED', `slot ${slot.id} names managedBy but its path ${slot.path} is not a list of literal files (${file})`);
59
- const template = [profile, 'common'].map(dir => templateName(dir, slot.managedBy, file)).find(hasTemplate);
60
- if (!template) throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${profile}, but templates/{${profile},common}/${slot.managedBy} has no template for it`);
64
+ const template = [scope, 'common'].map(dir => templateName(dir, slot.managedBy, file)).find(hasTemplate);
65
+ if (!template) throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${scope}, but templates/{${scope},common}/${slot.managedBy} has no template for it`);
61
66
  listed.push({ path: file, template, mode: MODE_OF_GROUP[slot.managedBy] ?? 'file', slot: slot.id });
62
67
  }
63
68
  }
64
- return [...listed, ...UNLISTED_TARGETS.filter(target => target.template[profile]).map(target => ({ path: target.path, template: target.template[profile], mode: target.mode }))];
69
+ return [...listed, ...(scope === APP_SCOPE ? UNLISTED_TARGETS.map(target => ({ ...target })) : [])];
65
70
  }
66
71
 
67
- const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
68
72
  const lf = text => text.replace(/\r\n/g, '\n');
69
73
  export const hashOf = text => createHash('sha256').update(lf(text)).digest('hex');
70
74
 
71
- /** The hfs.json shape sync reads: the manifest major, profile, project and a non-empty apps list (a back end names each app's kind). */
75
+ /**
76
+ * The hfs.json sync reads: an app declaration valid for the manifest it pins (both sides, their apps, connections, optional slots,
77
+ * reads). Sync never renders configs for a declaration the eslint canons and hfs check would refuse, so a pin bump cannot leave
78
+ * lint unable to start. Answers the resolved app.
79
+ */
72
80
  export function validateHfs(hfs, manifest = loadSlotManifest()) {
73
- const bad = message => { throw new SyncError('HFS_SYNC_HFS_INVALID', message); };
74
- if (!hfs || typeof hfs !== 'object') bad('hfs.json is not an object');
75
- if (hfs.hfs !== manifest.major) bad(`hfs must be ${manifest.major}, the major of the slot manifest`);
76
- if (!PROFILES.includes(hfs.profile)) bad(`profile must be one of ${PROFILES.join(', ')}`);
77
- if (typeof hfs.project !== 'string' || !KEBAB.test(hfs.project)) bad('project must be a kebab-case name');
78
- if (!Array.isArray(hfs.apps) || hfs.apps.length === 0) bad('apps must list at least one app');
79
- if (hfs.stacks !== undefined) {
80
- if (hfs.profile !== 'fe') bad('stacks is a front-end field: a back-end repository owns its own .starcistacks declaration');
81
- if (typeof hfs.stacks !== 'string' || !hfs.stacks || path.isAbsolute(hfs.stacks)) bad('stacks must be a relative path to the sibling back-end repository that owns .starcistacks');
82
- }
83
- const seen = new Set();
84
- for (const app of hfs.apps) {
85
- if (!app || typeof app.name !== 'string' || !KEBAB.test(app.name)) bad('every app needs a kebab-case name');
86
- if (seen.has(app.name)) bad(`app ${app.name} is listed twice`);
87
- if (hfs.profile === 'be' && !manifest.appKinds.be.includes(app.kind)) bad(`app ${app.name} needs a kind, one of ${manifest.appKinds.be.join(', ')}`);
88
- seen.add(app.name);
89
- }
90
- // The whole declaration must be valid for the manifest it pins (connections, optional slots, ...): sync never renders
91
- // configs for a declaration the eslint canons and hfs check would refuse, so a pin bump cannot leave lint unable to start.
92
81
  try {
93
- resolveRepoDeclaration(manifest, hfs);
82
+ return resolveRepoDeclaration(manifest, hfs);
94
83
  } catch (error) {
95
- bad(`hfs.json is not a valid declaration for manifest ${manifest.version}: ${error.message}`);
84
+ throw new SyncError('HFS_SYNC_HFS_INVALID', `hfs.json is not a valid app declaration for manifest ${manifest.version}: ${error.message}`);
96
85
  }
97
- return hfs;
98
86
  }
99
87
 
100
88
  function readBundled(name) {
@@ -110,9 +98,8 @@ export function render(text, vars, readTemplate = readBundled) {
110
98
  });
111
99
  }
112
100
 
113
- /** The Sonar exclusions from the jest preset a back end installs: { sonarExclusions }; a front end has no test runner and no preset: null. */
114
- export async function loadPresets(root, profile) {
115
- if (profile === 'fe') return null;
101
+ /** The Sonar exclusions from the jest preset the app installs for its be side: { sonarExclusions }. */
102
+ export async function loadPresets(root) {
116
103
  const name = '@starci/jest-preset';
117
104
  const require = createRequire(path.join(root, 'package.json'));
118
105
  let resolved;
@@ -126,38 +113,50 @@ export async function loadPresets(root, profile) {
126
113
  }
127
114
 
128
115
  /**
129
- * The package.json script lines the apps add, each ending with a comma (the template puts them mid-object). A back end:
130
- * `start:<app>` runs a built api, worker or cli app, `migrate` the migrate app. A front end: `dev:<app>` and `start:<app>`
131
- * run the app's own script through its workspace path (the npm workspace of apps/<app>).
116
+ * The script lines the apps add to the root package.json, each ending with a comma (the template puts them mid-object). Every
117
+ * script runs a side from the app root: a be script from be/ (`cd be && ...`, the folder its tsconfig and jest configuration
118
+ * are relative to), an fe script from fe/. be: `dev:be` runs the one api app from source and restarts it on change (`dev:be:<app>`
119
+ * each, with several), `start:<app>` runs a built api, worker or cli app, `migrate` the migrate app (`migrate:<app>` with several).
120
+ * fe: `dev:fe` runs the one Next app in development (`dev:fe:<app>` each, with several), `start:<app>` serves a built one.
132
121
  */
133
- export function appScripts(profile, apps) {
122
+ export function appScripts(app) {
134
123
  const line = (name, command) => `${JSON.stringify(name)}: ${JSON.stringify(command)},`;
135
- if (profile === 'fe') return apps.flatMap(app => [line(`dev:${app.name}`, `npm run dev --workspace apps/${app.name}`), line(`start:${app.name}`, `npm run start --workspace apps/${app.name}`)]).join('\n ');
136
- const migrates = apps.filter(app => app.kind === 'migrate');
137
- return apps.map(app => {
138
- const name = app.kind === 'migrate' ? (migrates.length === 1 ? 'migrate' : `migrate:${app.name}`) : `start:${app.name}`;
139
- return line(name, `node dist/apps/${app.name}/src/main.js`);
140
- }).join('\n ');
124
+ const be = app.sides.be.apps;
125
+ const fe = app.sides.fe.apps;
126
+ const apis = be.filter(entry => entry.kind === 'api');
127
+ const migrates = be.filter(entry => entry.kind === 'migrate');
128
+ const watch = entry => `cd be && ts-node-dev --respawn -r tsconfig-paths/register apps/${entry.name}/src/main.ts`;
129
+ return [
130
+ ...(apis.length === 1 ? [line('dev:be', watch(apis[0]))] : apis.map(entry => line(`dev:be:${entry.name}`, watch(entry)))),
131
+ ...(fe.length === 1 ? [line('dev:fe', `npm run codegen --silent && cd fe && next dev apps/${fe[0].name}`)] : fe.map(entry => line(`dev:fe:${entry.name}`, `npm run codegen --silent && cd fe && next dev apps/${entry.name}`))),
132
+ ...be.map(entry => line(entry.kind === 'migrate' ? (migrates.length === 1 ? 'migrate' : `migrate:${entry.name}`) : `start:${entry.name}`, `node be/dist/apps/${entry.name}/src/main.js`)),
133
+ ...fe.map(entry => line(`start:${entry.name}`, `cd fe && next start apps/${entry.name}`)),
134
+ ].join('\n ');
141
135
  }
142
136
 
143
- /** True when hfs.json opts into a workspace package (repo.packages or any fe.package.* slot): the sources then include packages/. */
144
- export const opensPackages = hfs => (hfs.optionalSlots ?? []).some(id => id === 'repo.packages' || String(id).startsWith('fe.package.'));
137
+ /** True when the fe side opts into a workspace package (repo.packages or any fe.package.* slot): the sources then include fe/packages/. */
138
+ export const opensPackages = side => (side.optionalSlots ?? []).some(id => id === 'repo.packages' || String(id).startsWith('fe.package.'));
145
139
 
146
- /** The stylesheets of a front end the CSS canon judges: every app and every workspace package (slot fe.route, fe.package.*). */
140
+ /** The stylesheets of the fe side the CSS canon judges, relative to fe/: every app and every workspace package (slot fe.route, fe.package.*). */
147
141
  export const STYLE_GLOB = '{apps,packages}/*/src/**/*.css';
148
142
 
149
- /** Every value a template can name, derived from hfs.json and the presets. */
150
- export function variables(hfs, presets, sonarKey) {
151
- const packages = hfs.profile === 'fe' && opensPackages(hfs);
143
+ /** Every value a template of `scope` can name, derived from hfs.json and the presets. */
144
+ export function variables(app, scope, presets, sonarKey) {
145
+ const fe = app.sides.fe;
146
+ const packages = opensPackages(fe);
147
+ const feApps = fe.apps.map(entry => `fe/apps/${entry.name}`);
148
+ const feTsconfigs = [...feApps.map(dir => `${dir}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])];
152
149
  return {
153
- header: HEADER(hfs.profile),
154
- appScripts: appScripts(hfs.profile, hfs.apps),
155
- profile: hfs.profile,
150
+ header: HEADER(scope),
151
+ appScripts: appScripts(app),
152
+ buildFe: ['npm run codegen --silent', ...(packages ? ['npm run build --workspaces --if-present'] : []), `cd fe && ${fe.apps.map(entry => `next build apps/${entry.name}`).join(' && ')}`].join(' && '),
153
+ // The fe apps import the workspace packages from their dist/, so the packages are built before the apps are type-checked.
154
+ typecheck: ['npm run codegen --silent', 'tsc -p be/tsconfig.json', ...(packages ? ['npm run build --workspaces --if-present'] : []), ...feTsconfigs.filter(file => !file.includes('*')).map(file => `tsc -p ${file} --noEmit`), ...(packages ? ['npm run typecheck --workspaces --if-present'] : [])].join(' && '),
156
155
  nodeMajor: String(NODE_MAJOR),
157
- sonarKey: sonarKey ?? `${hfs.project}-${hfs.profile === 'be' ? 'backend' : 'fe'}`,
158
- sonarExclusions: presets?.sonarExclusions ?? '',
159
- tsconfigPaths: [...hfs.apps.map(app => `apps/${app.name}/tsconfig.json`), ...(packages ? ['packages/*/tsconfig.json'] : [])].join(','),
160
- sonarRoots: packages ? 'apps,packages' : 'apps',
156
+ sonarKey: sonarKey ?? app.project,
157
+ sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**'].filter(Boolean).join(','),
158
+ sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
159
+ tsconfigPaths: ['be/tsconfig.json', ...feTsconfigs].join(','),
161
160
  styleGlob: STYLE_GLOB,
162
161
  };
163
162
  }
@@ -175,23 +174,27 @@ function parseScripts(text) {
175
174
  }
176
175
 
177
176
  /**
178
- * The managed content of every target this profile owns: [{ path, mode, content, hash }] (a scripts target also carries
179
- * `scripts`, the parsed block). `sonarKey` is the key the repository's stack declaration names; without one the key is
180
- * derived from hfs.json.
177
+ * The managed content of every target of the app: [{ path, mode, content, hash, scope }], paths app-relative (a side's targets under
178
+ * its folder); a scripts target also carries `scripts`, the parsed block. `sonarKey` is the key the be side's stack declaration
179
+ * names; without one the key is the project.
181
180
  */
182
181
  export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundled, manifest = loadSlotManifest() } = {}) {
183
- validateHfs(hfs, manifest);
184
- const vars = variables(hfs, presets, sonarKey);
185
- return targetsOf(hfs.profile, { manifest, readTemplate, hasTemplate: name => { try { return readTemplate(name) !== undefined; } catch { return false; } } }).map(target => {
186
- const body = render(readTemplate(target.template), vars, readTemplate);
187
- if (target.mode === 'scripts') {
188
- const scripts = parseScripts(body);
189
- if (!scripts) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `the rendered ${target.template} has no scripts object`);
190
- const content = scriptsText(scripts);
191
- return { path: target.path, mode: 'scripts', content, hash: hashOf(content), scripts };
192
- }
193
- const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${body.replace(/\n*$/, '\n')}${BLOCK_END}\n` : body;
194
- return { path: target.path, mode: target.mode, content: managed, hash: hashOf(managed) };
182
+ const app = validateHfs(hfs, manifest);
183
+ const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
184
+ return SCOPES.flatMap(scope => {
185
+ const vars = variables(app, scope, presets, sonarKey);
186
+ return targetsOf(scope, { manifest, hasTemplate }).map(target => {
187
+ const body = render(readTemplate(target.template), vars, readTemplate);
188
+ const where = onSide(scope, target.path);
189
+ if (target.mode === 'scripts') {
190
+ const scripts = parseScripts(body);
191
+ if (!scripts) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `the rendered ${target.template} has no scripts object`);
192
+ const content = scriptsText(scripts);
193
+ return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
194
+ }
195
+ const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${body.replace(/\n*$/, '\n')}${BLOCK_END}\n` : body;
196
+ return { path: where, scope, mode: target.mode, content: managed, hash: hashOf(managed) };
197
+ });
195
198
  });
196
199
  }
197
200
 
@@ -256,42 +259,37 @@ export function writeTargets(root, targets) {
256
259
  return written;
257
260
  }
258
261
 
262
+ /** The hfs.json object of the app at `root` (validated), or a SyncError. */
259
263
  export function loadHfs(root) {
260
264
  const file = path.join(root, 'hfs.json');
261
- if (!fs.existsSync(file)) throw new SyncError('HFS_SYNC_HFS_INVALID', `${file} does not exist`);
265
+ if (!fs.existsSync(file)) throw new SyncError('HFS_SYNC_HFS_INVALID', `${file} does not exist; hfs sync runs at the app root`);
266
+ let hfs;
262
267
  try {
263
- return validateHfs(JSON.parse(fs.readFileSync(file, 'utf8')));
268
+ hfs = JSON.parse(fs.readFileSync(file, 'utf8'));
264
269
  } catch (error) {
265
- if (error instanceof SyncError) throw error;
266
270
  throw new SyncError('HFS_SYNC_HFS_INVALID', `${file} is not valid JSON: ${error.message}`);
267
271
  }
272
+ validateHfs(hfs);
273
+ return hfs;
268
274
  }
269
275
 
270
- /** The rendered targets of the repository at `root`: its hfs.json, the Sonar key its stack declaration names, the presets it installs. */
276
+ /** The rendered targets of the app at `root`: its hfs.json, the Sonar key the be side's stack declaration names, the presets it installs. */
271
277
  export async function renderRepo(root, { presets, parseYaml } = {}) {
272
278
  const hfs = loadHfs(root);
273
- const sonarKey = await readDeclaredSonarKey(root, { parseYaml, stacks: hfs.stacks, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
274
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root, hfs.profile), { sonarKey }) };
279
+ const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
280
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey }) };
275
281
  }
276
282
 
277
- /** `hfs sync --check | --write [--root <dir>]`; returns the exit code (0 clean, 1 drift or error, 2 usage). */
283
+ /** `hfs sync --check | --write [--root <dir>]` at the app root; returns the exit code (0 clean, 1 drift or error, 2 usage). */
278
284
  export async function runSync(argv, { cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
279
- const check = argv.includes('--check'), write = argv.includes('--write'), init = argv.includes('--init');
285
+ const check = argv.includes('--check'), write = argv.includes('--write');
280
286
  const rootAt = argv.indexOf('--root');
281
- if ([check, write, init].filter(Boolean).length !== 1) {
282
- out('usage: hfs sync (--check | --write | --init) [--root <dir>]');
287
+ if ([check, write].filter(Boolean).length !== 1) {
288
+ out('usage: hfs sync (--check | --write) [--root <dir>]');
283
289
  return 2;
284
290
  }
285
291
  const root = rootAt >= 0 ? path.resolve(cwd, argv[rootAt + 1] ?? '') : cwd;
286
292
  try {
287
- const hfs = loadHfs(root);
288
- if (init) {
289
- const { initSkeleton } = await import('./skeleton.mjs');
290
- const { created, skipped } = initSkeleton(root, hfs);
291
- for (const file of created) out(`created ${file}`);
292
- out(`hfs sync --init: ${created.length} created, ${skipped.length} already exist and were left alone`);
293
- return 0;
294
- }
295
293
  const { targets } = await renderRepo(root, { presets, parseYaml });
296
294
  if (write) {
297
295
  const written = writeTargets(root, targets);
package/sync/managed.mjs CHANGED
@@ -72,14 +72,15 @@ function driftFindings(repoRoot, targets) {
72
72
  for (const result of checkTargets(repoRoot, targets)) {
73
73
  const target = targets.find(candidate => candidate.path === result.path);
74
74
  if (!fs.existsSync(path.join(repoRoot, result.path)) || result.status === 'ok') continue;
75
- if (result.path === TS_STRICT_FILE) {
76
- const flags = tsStrictFindings(fs.readFileSync(path.join(repoRoot, result.path), 'utf8'), target.content);
75
+ // A side's tsconfig.json (be/tsconfig.json, fe/tsconfig.json: the side's own root) is judged by flag (R22).
76
+ if (target.scope !== 'app' && result.path === `${target.scope}/${TS_STRICT_FILE}`) {
77
+ const flags = tsStrictFindings(fs.readFileSync(path.join(repoRoot, result.path), 'utf8'), target.content, result.path);
77
78
  if (flags.length) {
78
79
  findings.push(...flags);
79
80
  continue;
80
81
  }
81
82
  }
82
- const code = target.mode === 'block' ? 'HFS_GITIGNORE_BLOCK_DRIFT' : ONE_LINER_FILES.has(result.path) ? 'HFS_RULE_OFF_WITHOUT_REPLACEMENT' : result.path === SONAR_PROPERTIES_FILE ? 'HFS_SONAR_CONFIG' : 'HFS_MANAGED_FILE_DRIFT';
83
+ const code = target.mode === 'block' ? 'HFS_GITIGNORE_BLOCK_DRIFT' : ONE_LINER_FILES.has(path.posix.basename(result.path)) ? 'HFS_RULE_OFF_WITHOUT_REPLACEMENT' : result.path === SONAR_PROPERTIES_FILE ? 'HFS_SONAR_CONFIG' : 'HFS_MANAGED_FILE_DRIFT';
83
84
  const where = result.difference ? `; line ${result.difference.line} expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)}` : '';
84
85
  const what = target.mode === 'block' ? `the managed block of ${result.path} is ${result.status === 'missing' ? 'missing' : 'not its render'}` : target.mode === 'scripts' ? 'the scripts block of package.json is not the rendered one' : `${result.path} is not its render${code === 'HFS_RULE_OFF_WITHOUT_REPLACEMENT' ? ', so a rule can be off or redefined in it' : ''}`;
85
86
  findings.push({ code, level: 'error', path: result.path, mode: target.mode, expectedHash: result.expectedHash, ...(result.actualHash ? { actualHash: result.actualHash } : {}), message: `${what} (expected sha256 ${result.expectedHash.slice(0, 12)}${result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : ''}${where}); run "npx hfs sync --write"` });
@@ -87,9 +88,9 @@ function driftFindings(repoRoot, targets) {
87
88
  return findings;
88
89
  }
89
90
 
90
- /** R11: the quality gate the stack declaration names is the one gate of the bundled knowledge/sonar-gate.yaml. */
91
- function sonarGateFindings(repoRoot, hfs, parseYaml) {
92
- const declared = readDeclaredSonarGate(repoRoot, { parseYaml, stacks: hfs.stacks });
91
+ /** R11: the quality gate the be side's stack declaration names is the one gate of the bundled knowledge/sonar-gate.yaml. */
92
+ function sonarGateFindings(repoRoot, parseYaml) {
93
+ const declared = readDeclaredSonarGate(repoRoot, { parseYaml });
93
94
  if (declared === null) return [];
94
95
  const gate = (parseYaml ?? bundledParseYaml)(fs.readFileSync(SONAR_GATE_FILE, 'utf8'))?.gate?.name;
95
96
  if (declared.qualityGate === gate) return [];
@@ -160,9 +161,9 @@ export async function managedFindings({ repoRoot, tracked, presets, parseYaml })
160
161
  if (error instanceof SyncError && error.code === 'HFS_SYNC_HFS_INVALID') return [];
161
162
  throw error;
162
163
  }
163
- const { hfs, targets } = rendered;
164
+ const { targets } = rendered;
164
165
  const findings = driftFindings(repoRoot, targets);
165
- findings.push(...sonarGateFindings(repoRoot, hfs, parseYaml));
166
+ findings.push(...sonarGateFindings(repoRoot, parseYaml));
166
167
  findings.push(...ruleFileFindings(repoRoot, tracked));
167
168
  findings.push(...toolKeyFindings(repoRoot, tracked));
168
169
  findings.push(...flagFindings(repoRoot, tracked, new Set(targets.map(target => target.path))));
@@ -1,13 +1,16 @@
1
- // The Sonar project key of a repository: read from its stack declaration (.starcistacks/application-stacks.yaml,
2
- // services.sonar.projects[]) when one exists, so there is one source of the key. Only when no declaration names this
3
- // repository does sync derive `<project>-backend` / `<project>-fe`.
1
+ // The Sonar project key of an app: read from its stack declaration (be/.starcistacks/application-stacks.yaml,
2
+ // services.sonar.projects[]) when one names the app, so there is one source of the key. Only when no declaration names the app
3
+ // does sync derive the key from the project name.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
7
7
 
8
- export const DECLARATION = path.join('.starcistacks', 'application-stacks.yaml');
8
+ /** The side folder that holds an app's stack declaration. */
9
+ export const STACKS_SIDE = 'be';
10
+ /** The stack declaration, app-relative. */
11
+ export const DECLARATION = path.join(STACKS_SIDE, '.starcistacks', 'application-stacks.yaml');
9
12
 
10
- /** The repository name the declaration lists its projects under: package.json name, else the folder name. */
13
+ /** The repository name the declaration lists its projects under: the app package.json name, else the folder name. */
11
14
  export function repositoryName(root) {
12
15
  try {
13
16
  const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name;
@@ -26,31 +29,26 @@ export function declaredSonarKeys(declaration, repository) {
26
29
  }
27
30
 
28
31
  /**
29
- * The declared key, or null when the repository has no declaration or the declaration names none for it.
30
- * `stacks` is hfs.json's optional path to the sibling repository that owns the declaration (a front-end repository has
31
- * none of its own); the key is still looked up under THIS repository's name. A `stacks` path with no declaration behind it
32
- * is refused, because the key would silently fall back to a derived one.
33
- * `parseYaml(text)` is injected; the default is the YAML parser bundled in this package (the package installs with no node_modules).
32
+ * The declared key of the app at `root`, or null when the app has no stack declaration or the declaration names none for it.
33
+ * Two keys for one app are refused through `fail`. `parseYaml(text)` is injected; the default is the YAML parser bundled in this
34
+ * package (the package installs with no node_modules).
34
35
  */
35
- export async function readDeclaredSonarKey(root, { parseYaml, fail, stacks }) {
36
- const file = path.join(stacks === undefined ? root : path.resolve(root, stacks), DECLARATION);
37
- if (!fs.existsSync(file)) {
38
- if (stacks !== undefined) fail(`hfs.json stacks points at ${stacks}, which has no ${DECLARATION}`);
39
- return null;
40
- }
36
+ export async function readDeclaredSonarKey(root, { parseYaml, fail }) {
37
+ const file = path.join(root, DECLARATION);
38
+ if (!fs.existsSync(file)) return null;
41
39
  const parse = parseYaml ?? bundledParseYaml;
42
40
  const keys = declaredSonarKeys(parse(fs.readFileSync(file, 'utf8')), repositoryName(root));
43
- if (keys.length > 1) fail(`${DECLARATION} declares ${keys.length} Sonar projects for ${repositoryName(root)} (${keys.join(', ')}); one repository has one key`);
41
+ if (keys.length > 1) fail(`${DECLARATION.split(path.sep).join('/')} declares ${keys.length} Sonar projects for ${repositoryName(root)} (${keys.join(', ')}); one app has one key`);
44
42
  return keys[0] ?? null;
45
43
  }
46
44
 
47
45
  /**
48
- * The quality gate the declaration names for Sonar: `{ file, qualityGate }`, or null when there is no declaration, Sonar is
49
- * disabled in it, or it declares no Sonar service. The one gate is knowledge/sonar-gate.yaml; a repository names it and
50
- * never states thresholds of its own.
46
+ * The quality gate the declaration names for Sonar: `{ file, qualityGate }` (file app-relative), or null when there is no
47
+ * declaration, Sonar is disabled in it, or it declares no Sonar service. The one gate is knowledge/sonar-gate.yaml; an app names it
48
+ * and never states thresholds of its own.
51
49
  */
52
- export function readDeclaredSonarGate(root, { parseYaml, stacks } = {}) {
53
- const file = path.join(stacks === undefined ? root : path.resolve(root, stacks), DECLARATION);
50
+ export function readDeclaredSonarGate(root, { parseYaml } = {}) {
51
+ const file = path.join(root, DECLARATION);
54
52
  if (!fs.existsSync(file)) return null;
55
53
  const sonar = (parseYaml ?? bundledParseYaml)(fs.readFileSync(file, 'utf8'))?.services?.sonar;
56
54
  if (!sonar || sonar.mode === 'disabled') return null;
@@ -29,8 +29,10 @@ jobs:
29
29
  run: npm run typecheck
30
30
  - name: unit
31
31
  run: npm test -- --ci
32
- - name: build
33
- run: npm run build
32
+ - name: build be
33
+ run: npm run build:be
34
+ - name: build fe
35
+ run: npm run build:fe
34
36
  - uses: SonarSource/sonarqube-scan-action@v7
35
37
  if: ${{ !cancelled() && env.SONAR_TOKEN != '' }}
36
38
  env:
@@ -0,0 +1,6 @@
1
+ {{> common/gitignore.base}}
2
+ # be: the schema Nest writes when it boots GraphQL
3
+ schema.gql
4
+ # fe: Next build output and its generated type file
5
+ .next/
6
+ next-env.d.ts
@@ -0,0 +1,25 @@
1
+ {{header}}
2
+ # Commit gate: the secrets guard and work hygiene of the staged files, types, lint and format of the staged files of each side,
3
+ # stylelint over the staged CSS of fe, the unit specs of be the staged files touch. Never integration, e2e or contract.
4
+ npx hfs work-hygiene
5
+ npm run typecheck
6
+ be=$(git diff --cached --name-only --diff-filter=ACMR --relative=be -- '*.ts' '*.mts' '*.cts' '*.js' '*.mjs' '*.cjs' || true)
7
+ if [ -n "$be" ]; then
8
+ (cd be && npx eslint $be)
9
+ fi
10
+ fe=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' || true)
11
+ if [ -n "$fe" ]; then
12
+ (cd fe && npx eslint --max-warnings=0 --no-warn-ignored $fe)
13
+ fi
14
+ styles=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.css' || true)
15
+ if [ -n "$styles" ]; then
16
+ (cd fe && npx stylelint $styles)
17
+ fi
18
+ formatted=$(git diff --cached --name-only --diff-filter=ACMR || true)
19
+ if [ -n "$formatted" ]; then
20
+ npx prettier --check --ignore-unknown $formatted
21
+ fi
22
+ specs=$(git diff --cached --name-only --diff-filter=ACMR --relative=be -- '*.ts' | grep -v -E '(^|/)src/tests/(world|integration|e2e|contract)/|\.(integration|e2e|contract)-spec\.ts$' || true)
23
+ if [ -n "$specs" ]; then
24
+ npm run test:affected -- --findRelatedTests $specs
25
+ fi
@@ -0,0 +1,7 @@
1
+ {{header}}
2
+ # Push gate: types, lint (one `hfs lint`: eslint per side, the app checks, stylelint over fe), format, the unit specs of be affected
3
+ # since main. Never integration, e2e or contract.
4
+ npm run typecheck
5
+ npm run lint
6
+ npm run format:check
7
+ npm run test:affected -- --changedSince=origin/main
@@ -0,0 +1,22 @@
1
+ {
2
+ "scripts": {
3
+ "prepare": "husky",
4
+ {{appScripts}}
5
+ "build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
6
+ "build:fe": "{{buildFe}}",
7
+ "codegen": "node scripts/codegen.mjs",
8
+ "typecheck": "{{typecheck}}",
9
+ "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
10
+ "lint": "npm run codegen --silent && hfs lint",
11
+ "lint:fix": "npm run codegen --silent && hfs lint --fix",
12
+ "format": "prettier --write .",
13
+ "format:check": "prettier --check .",
14
+ "test": "cd be && jest --selectProjects unit --coverage",
15
+ "test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
16
+ "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
17
+ "test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
18
+ "test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
19
+ "test:stack": "cd be && starci-test-stack",
20
+ "contract:emit": "hfs emit-contracts"
21
+ }
22
+ }
@@ -1,9 +1,10 @@
1
1
  {{header}}
2
2
  sonar.projectKey={{sonarKey}}
3
3
  sonar.sourceEncoding=UTF-8
4
- sonar.sources=apps,src
5
- sonar.tests=apps,src
4
+ sonar.sources={{sonarSources}}
5
+ sonar.tests=be/apps,be/src
6
6
  sonar.exclusions={{sonarExclusions}}
7
7
  sonar.test.inclusions=**/*.spec.ts
8
+ sonar.typescript.tsconfigPaths={{tsconfigPaths}}
8
9
  sonar.externalIssuesReportPaths=reports/lint.sonar.json
9
10
  sonar.nodejs.maxspace=8192
@@ -0,0 +1,15 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_style = space
7
+ indent_size = 4
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.{json,yml,yaml}]
12
+ indent_size = 2
13
+
14
+ [*.md]
15
+ trim_trailing_whitespace = false
@@ -0,0 +1,2 @@
1
+ * text=auto
2
+ *.enc binary
@@ -0,0 +1 @@
1
+ 22
@@ -0,0 +1,7 @@
1
+ schema: work/catalog@1
2
+ id: {{project}}
3
+ description: Product catalog of the features directory. One entry per feature; every record of a feature lives under its directory.
4
+ features:
5
+ - id: system-health
6
+ directory: features/system-health
7
+ description: The liveness probe the platform asks before it routes traffic to the app.
@@ -0,0 +1,9 @@
1
+ schema: work/workspace@1
2
+ id: {{project}}
3
+ project: {{project}}
4
+ description: The Work records of {{project}}, one StarCi app with its back end in be/ and its front end in fe/.
5
+ repositories:
6
+ - role: be
7
+ name: be
8
+ - role: fe
9
+ name: fe