@starci/hfs 4.7.23 → 4.7.24

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 (82) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/release/candidate-ci.mjs +20 -10
  5. package/release/gate.mjs +37 -19
  6. package/release/push.mjs +1 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  8. package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
  9. package/runtime/knowledge/hfs/rules.yaml +17 -17
  10. package/runtime/knowledge/hfs/slots.yaml +20 -1
  11. package/runtime/knowledge/patterns/be/saga.yaml +1 -1
  12. package/runtime/knowledge/sonar-gate.yaml +12 -12
  13. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
  15. package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
  16. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
  18. package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
  19. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
  20. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  21. package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
  22. package/runtime/scripts/hfs/slots.mjs +3 -2
  23. package/runtime/scripts/lib/gate-loosening.mjs +248 -0
  24. package/runtime/scripts/lib/managed-json.mjs +51 -0
  25. package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
  26. package/stack/prepare.mjs +10 -3
  27. package/stack/release-store.mjs +3 -3
  28. package/stack/remote.mjs +9 -0
  29. package/sync/ci-commands.mjs +38 -90
  30. package/sync/index.mjs +10 -5
  31. package/templates/app/browser-config/browser/tsconfig.json +11 -0
  32. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
  33. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
  34. package/templates/app/hooks/husky/pre-push +4 -2
  35. package/templates/app/package-scripts/package.json +0 -1
  36. package/templates/app/package-scripts-lite/package.json +0 -1
  37. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
  38. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
  39. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
  40. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
  41. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
  42. package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
  43. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
  44. package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
  45. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
  46. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
  47. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  48. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
  49. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
  50. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
  51. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
  52. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
  53. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
  54. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
  55. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
  56. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
  57. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
  58. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
  59. package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
  60. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
  61. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
  62. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
  63. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
  64. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
  65. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
  66. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  67. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
  68. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
  69. package/templates/fe/auth/supabase-client.ts.tpl +7 -7
  70. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
  71. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
  72. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
  73. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
  74. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
  75. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
  76. package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
  77. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
  78. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
  79. package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
  80. package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
  81. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
  82. package/templates/fe/transport/graphql/clients/response.ts +1 -1
@@ -10,91 +10,30 @@ const exampleCommand = (source, command) => source?.runtimeExample
10
10
 
11
11
  const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
12
12
  const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
13
- const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
13
+ const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}";
14
14
 
15
- /** Optional uploads require reachable upload and authentication endpoints without exposing identity tokens. */
16
- function codecovProbe(codecov, condition) {
17
- if (!codecov.optional) return [];
18
- return [
19
- ' - name: Codecov availability',
20
- ' id: codecov-availability',
21
- ` if: ${condition}`,
22
- ' env:',
23
- ` CODECOV_AUTH: ${codecov.auth}`,
24
- ' run: |',
25
- " node --input-type=module <<'NODE'",
26
- " import fs from 'node:fs';",
27
- ' let available = false;',
28
- ' try {',
29
- " const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
30
- ' available = upload.status < 500;',
31
- " if (available && process.env.CODECOV_AUTH === 'oidc') {",
32
- " const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
33
- " url.searchParams.set('audience', 'https://codecov.io');",
34
- " const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
35
- ' available = identity.ok && Boolean((await identity.json()).value);',
36
- ' }',
37
- ' } catch { available = false; }',
38
- " fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
39
- " if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
40
- ' NODE',
41
- ];
42
- }
43
-
44
- /** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
45
- function sonarProbe(sonar) {
46
- if (!sonar.optional) return [];
47
- return [
48
- ' - name: Sonar availability',
49
- ' id: sonar-availability',
50
- ` if: ${SONAR_CONDITION}`,
51
- ' run: |',
52
- ` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
53
- ' echo "up=true" >> "$GITHUB_OUTPUT"',
54
- ' else',
55
- ' echo "up=false" >> "$GITHUB_OUTPUT"',
56
- ' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
57
- ' fi',
58
- ];
59
- }
60
-
61
- /** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
15
+ /** Codecov uploads on release tags with its required authentication; a missing report, an unavailable endpoint or a rejected upload fails the job. */
62
16
  function codecovSteps(lcovReport, codecov) {
63
17
  if (codecov.enabled === false || codecov.wiring === 'not-used') return [
64
18
  ' - name: coverage upload',
65
19
  ' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
66
20
  ].join('\n');
67
- const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
68
- const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
69
21
  return [
70
- ' - name: coverage upload skipped',
71
- ` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
72
- ' run: |',
73
- ` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
74
- ' exit 0',
75
- ...codecovProbe(codecov, condition),
76
22
  ' - name: coverage upload',
77
- ...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
78
23
  ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
79
- ` if: ${uploadCondition}`,
24
+ " if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}",
80
25
  ' with:',
81
26
  codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
82
27
  ` files: ${lcovReport}`,
83
28
  ' disable_search: true',
84
- ` fail_ci_if_error: ${!codecov.optional}`,
85
- ...(codecov.optional ? [
86
- ' - name: Codecov upload unavailable',
87
- " if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
88
- ' run: |',
89
- ' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
90
- ] : []),
29
+ ' fail_ci_if_error: true',
91
30
  ].join('\n');
92
31
  }
93
32
 
94
- /** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
33
+ /** Sonar is required: on release tags it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
95
34
  function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
96
35
  if (!sonar) return '';
97
- const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
36
+ const condition = SONAR_CONDITION;
98
37
  const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
99
38
  const header = [
100
39
  ' sonar:',
@@ -107,7 +46,10 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
107
46
  ' steps:',
108
47
  ].join('\n');
109
48
  const steps = [
110
- ...sonarProbe(sonar),
49
+ ' - name: Sonar token',
50
+ ` if: ${condition}`,
51
+ ' run: |',
52
+ ` if [ -z "$SONAR_TOKEN" ]; then echo "::error::SONAR_TOKEN is not set: Sonar is required, so a missing token fails this job (secret ${sonar.token})"; exit 1; fi`,
111
53
  ` - uses: ${DOWNLOAD_ARTIFACT}`,
112
54
  ` if: ${condition}`,
113
55
  ' with:',
@@ -208,12 +150,22 @@ function sonarMergeSteps(condition) {
208
150
  }
209
151
 
210
152
  const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
211
- /** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard. */
212
- function ciJobSettings(declaration) {
153
+ /** jest --shard=i/N gives each shard ceil(specs / N) spec files in order, so shard N is empty unless (N - 1) * ceil(specs / N) < specs. */
154
+ export function filledShards(specs, wanted) {
155
+ for (let shards = wanted; shards > 1; shards -= 1) if ((shards - 1) * Math.ceil(specs / shards) < specs) return shards;
156
+ return 1;
157
+ }
158
+
159
+ /** The unit specs of the be source: every *.spec.ts outside the test kind folders (src/tests/<kind>), the files the unit project runs. */
160
+ const unitSpecCount = (source) => (source?.files ?? []).filter((file) => file.endsWith('.spec.ts') && !file.includes('/tests/')).length;
161
+
162
+ /** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard, the shards capped at the count the unit specs fill (an empty shard would fail, never pass empty). */
163
+ function ciJobSettings(declaration, source) {
213
164
  const { defaults, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
214
165
  const jobs = declaration?.ci?.jobs ?? {};
215
166
  const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
216
- const shards = Number(jobs.test?.shards ?? defaults.shards);
167
+ const wanted = Number(jobs.test?.shards ?? defaults.shards);
168
+ const shards = source?.files?.length ? filledShards(unitSpecCount(source), wanted) : wanted;
217
169
  return {
218
170
  nodeOptions: `--max-old-space-size=${heap.minMiB}`,
219
171
  timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
@@ -230,7 +182,7 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
230
182
  const audit = quality?.audit ?? { level: 'high', omitDev: true };
231
183
  const lcovArtifact = 'coverage-lcov';
232
184
  const build = frontendBuild(source, app);
233
- const settings = ciJobSettings(buildDeclaration(source));
185
+ const settings = ciJobSettings(buildDeclaration(source), source);
234
186
  const side = '${{ matrix.side }}';
235
187
  return {
236
188
  ...settings,
@@ -247,28 +199,24 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
247
199
  auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
248
200
  sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
249
201
  sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
250
- sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
251
- // One lint job per side (the lint matrix): with a Sonar token each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
252
- ciLintSteps: [
202
+ // One lint job per side (the lint matrix): with Sonar each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
203
+ ciLintSteps: (sonar ? [
204
+ ' - name: lint with Sonar import',
205
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
206
+ ' env:',
207
+ ` NODE_OPTIONS: ${settings.nodeOptions}`,
208
+ ` - uses: ${UPLOAD_ARTIFACT}`,
209
+ // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
210
+ " if: always() && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
211
+ ' with:',
212
+ ` name: sonar-lint-${side}`,
213
+ ` path: reports/lint.${side}.sonar.json`,
214
+ ] : [
253
215
  ' - name: lint',
254
- ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
255
216
  ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
256
217
  ' env:',
257
218
  ` NODE_OPTIONS: ${settings.nodeOptions}`,
258
- ...(sonar ? [
259
- ' - name: lint with Sonar import',
260
- " if: env.SONAR_TOKEN != ''",
261
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
262
- ' env:',
263
- ` NODE_OPTIONS: ${settings.nodeOptions}`,
264
- ` - uses: ${UPLOAD_ARTIFACT}`,
265
- // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
266
- " if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
267
- ' with:',
268
- ` name: sonar-lint-${side}`,
269
- ` path: reports/lint.${side}.sonar.json`,
270
- ] : []),
271
- ].join('\n'),
219
+ ]).join('\n'),
272
220
  codecovSteps: codecovSteps(lcovReport, codecov),
273
221
  sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
274
222
  lcovArtifact,
package/sync/index.mjs CHANGED
@@ -24,6 +24,7 @@ import { braceVariants } from '../runtime/scripts/lib/glob.mjs';
24
24
  import { codecovPaths, coverageComponents, jestCoverage, sonarCoverageExclusions } from '../runtime/scripts/hfs/coverage-scope.mjs';
25
25
  import { APP_SCOPE, SIDES, litePresenceOf, loadSlotManifest, resolveRepoDeclaration, slotInEdition } from '../runtime/scripts/hfs/slots.mjs';
26
26
  import { managedGroupOf } from '../runtime/scripts/hfs/edition-slots.mjs';
27
+ import { declaredSlotEnabled } from '../runtime/scripts/hfs/declaration-slots.mjs';
27
28
  import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
28
29
  import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
29
30
  import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
@@ -74,8 +75,10 @@ function managedTemplate(scope, group, file, hasTemplate) {
74
75
  return directories.map(dir => `${dir}/${group}/${file}`).find(name => hasTemplate(name));
75
76
  }
76
77
 
77
- function slotTargets(slot, scope, edition, manifest, hasTemplate) {
78
+ function slotTargets(slot, scope, edition, manifest, hasTemplate, enabled) {
78
79
  if (!slot.profiles.includes(scope) || !slotInEdition(manifest, slot, edition)) return [];
80
+ // An opt-in slot is rendered only for a repository that enables it (app.browser-config with `browser: true`).
81
+ if (slot.presence === 'opt-in' && !enabled(slot)) return [];
79
82
  if (edition === 'lite' && litePresenceOf(slot, scope) === 'forbidden') return [];
80
83
  const group = managedGroupOf(slot, edition);
81
84
  if (group === undefined) return [];
@@ -100,10 +103,11 @@ function unlistedTargets(scope, edition) {
100
103
  /**
101
104
  * Every target of `scope` (app, be or fe), its path relative to that scope's folder: the files the manifest's managedBy slots
102
105
  * list, then (app root) the unlisted ones. A slot that names managedBy lists literal files only, and each has a template under
103
- * its scope or under common.
106
+ * its scope or under common. `enabled(slot)` decides an opt-in slot (renderTargets passes the declaration's); without it every slot
107
+ * counts, which is what an inventory of the templates needs.
104
108
  */
105
- export function targetsOf(scope, { edition = 'full', manifest = loadSlotManifest(), hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)) } = {}) {
106
- const listed = manifest.slots.flatMap(slot => slotTargets(slot, scope, edition, manifest, hasTemplate));
109
+ export function targetsOf(scope, { edition = 'full', manifest = loadSlotManifest(), hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)), enabled = () => true } = {}) {
110
+ const listed = manifest.slots.flatMap(slot => slotTargets(slot, scope, edition, manifest, hasTemplate, enabled));
107
111
  return [...listed, ...unlistedTargets(scope, edition)];
108
112
  }
109
113
 
@@ -389,7 +393,8 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
389
393
  const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
390
394
  return SCOPES.flatMap(scope => {
391
395
  const vars = variables(app, scope, presets, sonarKey, manifest, source);
392
- const declaredTargets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate });
396
+ const repo = scope === APP_SCOPE ? app : app.sides[scope];
397
+ const declaredTargets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate, enabled: slot => declaredSlotEnabled(slot, repo) });
393
398
  if (scope === APP_SCOPE && source?.badges) declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
394
399
  const targets = declaredTargets
395
400
  .filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@starci/tsconfig/e2e.json",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "lib": ["ES2022", "DOM"],
6
+ "module": "commonjs",
7
+ "moduleResolution": "node",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["playwright.config.ts", "journeys/*.ts"]
11
+ }
@@ -36,7 +36,6 @@ jobs:
36
36
  lint:
37
37
  timeout-minutes: {{timeoutLint}}
38
38
  runs-on: ubuntu-latest
39
- {{sonarEnv}}
40
39
  strategy:
41
40
  fail-fast: false
42
41
  matrix:
@@ -57,9 +56,9 @@ jobs:
57
56
  {{> app/ci-workflows/install-steps.yml}}
58
57
  {{> app/ci-workflows/jest-cache.yml}}
59
58
  # A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
60
- # JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. A small suite may leave a shard empty.
59
+ # JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. The shard count never exceeds what the unit specs fill.
61
60
  - name: unit
62
- run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
61
+ run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}}
63
62
  env:
64
63
  STARCI_COVERAGE_SHARD: "1"
65
64
  {{> app/ci-workflows/jest-cache-save.yml}}
@@ -36,7 +36,6 @@ jobs:
36
36
  lint:
37
37
  timeout-minutes: {{timeoutLint}}
38
38
  runs-on: ubuntu-latest
39
- {{sonarEnv}}
40
39
  strategy:
41
40
  fail-fast: false
42
41
  matrix:
@@ -57,9 +56,9 @@ jobs:
57
56
  {{> app/ci-workflows/install-steps.yml}}
58
57
  {{> app/ci-workflows/jest-cache.yml}}
59
58
  # A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
60
- # JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. A small suite may leave a shard empty.
59
+ # JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. The shard count never exceeds what the unit specs fill.
61
60
  - name: unit
62
- run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
61
+ run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}}
63
62
  env:
64
63
  STARCI_COVERAGE_SHARD: "1"
65
64
  {{> app/ci-workflows/jest-cache-save.yml}}
@@ -21,8 +21,10 @@ refuse() {
21
21
  release_gate() {
22
22
  if [ -n "$gate" ]; then return 0; fi
23
23
  gate="no annotated tag v[0-9]* on HEAD $head"
24
- for tag in $(git tag --points-at HEAD --list 'v[0-9]*' 2>/dev/null || true); do
25
- if [ "$(git cat-file -t "refs/tags/$tag" 2>/dev/null || true)" != "tag" ]; then continue; fi
24
+ tags=$(git tag --points-at HEAD --list 'v[0-9]*' 2>/dev/null) || { gate="cannot list the tags of HEAD $head"; return 0; }
25
+ for tag in $tags; do
26
+ kind=$(git cat-file -t "refs/tags/$tag" 2>/dev/null) || continue
27
+ if [ "$kind" != "tag" ]; then continue; fi
26
28
  if [ ! -f "$record" ]; then gate="no L4 record $record"; continue; fi
27
29
  flat=$(tr -d ' \t\r\n' < "$record")
28
30
  case "$flat" in
@@ -5,7 +5,6 @@
5
5
  {{> app/package-scripts/core.json}}
6
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
7
  "test": "cd be && jest --selectProjects unit --coverage",
8
- "test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
9
8
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
10
9
  "test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
11
10
  "test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
@@ -5,7 +5,6 @@
5
5
  {{> app/package-scripts/core.json}}
6
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
7
  "test": "cd be && jest --selectProjects unit --coverage",
8
- "test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
9
8
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
10
9
  {{> app/package-scripts/supabase.json}}
11
10
  "contract:emit": "{{appBinary}} emit"
@@ -137,18 +137,17 @@ k8s:
137
137
  reason: This starter declares Docker Swarm production; no Kubernetes environment is provisioned.
138
138
  ci:
139
139
  secrets:
140
- - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app, optional: true}
140
+ - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
141
141
  - {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
142
142
  - {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
143
143
  - {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
144
144
  vars:
145
- - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host, optional: true}
145
+ - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
146
146
  - {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
147
147
  - {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
148
148
  services:
149
149
  sonar:
150
150
  provider: sonarqube
151
- optional: true
152
151
  mode: local
153
152
  qualityGate: {{sonarGate}}
154
153
  host: {local: http://127.0.0.1:9000, public: "https://sonar.{{project}}.example.invalid"}
@@ -160,7 +159,7 @@ services:
160
159
  auth: token
161
160
  credentials: []
162
161
  projects: [{repository: "{{project}}", key: "{{project}}"}]
163
- ci: {wiring: optional-follow-up, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
162
+ ci: {wiring: required, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
164
163
  ownerAction:
165
164
  needed: Provision this app's Sonar host and project, then set SONAR_TOKEN with starci app ci secret set and sync SONAR_HOST_URL.
166
165
  reason: A fresh app declares its quality service without fabricating credentials or a successful analysis.
@@ -172,7 +171,7 @@ services:
172
171
  target: 100
173
172
  components: auto
174
173
  required: true
175
- ci: {wiring: optional-follow-up, workflow: .github/workflows/ci.yml}
174
+ ci: {wiring: required, workflow: .github/workflows/ci.yml}
176
175
  ownerAction: none
177
176
  dependabot:
178
177
  provider: github
@@ -199,5 +198,5 @@ services:
199
198
  host: {public: https://ghcr.io}
200
199
  auth: github-token
201
200
  projects: [{repository: "{{project}}", key: "{{project}}"}]
202
- ci: {wiring: optional-follow-up}
201
+ ci: {wiring: required}
203
202
  ownerAction: none
@@ -182,18 +182,17 @@ k8s:
182
182
  reason: Lite production uses Docker Swarm with managed Supabase backing; Kubernetes belongs to a future full-edition deployment.
183
183
  ci:
184
184
  secrets:
185
- - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app, optional: true}
185
+ - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
186
186
  - {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
187
187
  - {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
188
188
  - {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
189
189
  vars:
190
- - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host, optional: true}
190
+ - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
191
191
  - {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
192
192
  - {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
193
193
  services:
194
194
  sonar:
195
195
  provider: sonarqube
196
- optional: true
197
196
  mode: local
198
197
  qualityGate: {{sonarGate}}
199
198
  host: {local: http://127.0.0.1:9000, public: "https://sonar.{{project}}.example.invalid"}
@@ -205,7 +204,7 @@ services:
205
204
  auth: token
206
205
  credentials: []
207
206
  projects: [{repository: "{{project}}", key: "{{project}}"}]
208
- ci: {wiring: optional-follow-up, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
207
+ ci: {wiring: required, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
209
208
  ownerAction:
210
209
  needed: Provision this app's Sonar host and project, then set SONAR_TOKEN with starci app ci secret set and sync SONAR_HOST_URL.
211
210
  reason: A fresh app declares its quality service without fabricating credentials or a successful analysis.
@@ -217,7 +216,7 @@ services:
217
216
  target: 100
218
217
  components: auto
219
218
  required: true
220
- ci: {wiring: optional-follow-up, workflow: .github/workflows/ci.yml}
219
+ ci: {wiring: required, workflow: .github/workflows/ci.yml}
221
220
  ownerAction: none
222
221
  dependabot:
223
222
  provider: github
@@ -244,5 +243,5 @@ services:
244
243
  host: {public: https://ghcr.io}
245
244
  auth: github-token
246
245
  projects: [{repository: "{{project}}", key: "{{project}}"}]
247
- ci: {wiring: optional-follow-up}
246
+ ci: {wiring: required}
248
247
  ownerAction: none
@@ -32,24 +32,23 @@ k8s:
32
32
  reason: The upgrade establishes the full local stack first; deployment topology is selected separately.
33
33
  ci:
34
34
  secrets:
35
- - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app, optional: true}
35
+ - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
36
36
  - {name: SOPS_AGE_KEY, scope: repository, purpose: Decrypt app members during deployment, optional: true}
37
37
  - {name: VPS_SSH_KEY, scope: repository, purpose: Deployment SSH key, optional: true}
38
38
  - {name: VPS_KNOWN_HOSTS, scope: repository, purpose: Pinned deployment SSH host keys, optional: true}
39
39
  vars:
40
- - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host, optional: true}
40
+ - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
41
41
  - {name: VPS_HOST, scope: repository, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
42
42
  - {name: VPS_USER, scope: repository, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
43
43
  services:
44
44
  sonar:
45
45
  provider: sonarqube
46
- optional: true
47
46
  mode: disabled
48
47
  reason: Enable the repository's Sonar service when a host and project token are provisioned.
49
48
  qualityGate: starci-quality
50
49
  credentials: []
51
50
  projects: [{repository: "{{project}}", key: "{{project}}"}]
52
- ci: {wiring: optional-follow-up, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
51
+ ci: {wiring: required, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
53
52
  ownerAction: none
54
53
  codecov:
55
54
  provider: codecov
@@ -59,7 +58,7 @@ services:
59
58
  target: 100
60
59
  components: auto
61
60
  required: true
62
- ci: {wiring: optional-follow-up, workflow: .github/workflows/ci.yml}
61
+ ci: {wiring: required, workflow: .github/workflows/ci.yml}
63
62
  ownerAction: none
64
63
  dependabot:
65
64
  provider: github
@@ -86,5 +85,5 @@ services:
86
85
  host: {public: https://ghcr.io}
87
86
  auth: github-token
88
87
  projects: [{repository: "{{project}}", key: "{{project}}"}]
89
- ci: {wiring: optional-follow-up}
88
+ ci: {wiring: required}
90
89
  ownerAction: none
@@ -13,6 +13,10 @@ import { readEnvelope } from "./event-envelope.policy"
13
13
  import { EventRunnerService } from "./event-runner.service"
14
14
  import type { EventSubscription, EventTransport, InboundMessage } from "./event-transport.port"
15
15
 
16
+ /** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
17
+ const anyOf = (type: unknown): unknown => expect.any(type)
18
+ const objectLike = (shape: Record<string, unknown>): unknown => expect.objectContaining(shape)
19
+
16
20
  const AT = "2026-02-03T04:05:06.000Z"
17
21
  const NOW = new Date(AT).getTime()
18
22
 
@@ -169,7 +173,7 @@ describe("EventRunnerService", () => {
169
173
  await runner.receive(message({ value: JSON.stringify([1]) }))
170
174
 
171
175
  expect(handle).not.toHaveBeenCalled()
172
- expect(logger.error).toHaveBeenCalledWith(EventBusLogEvent.MessageSkipped, expect.any(SyntaxError), {
176
+ expect(logger.error).toHaveBeenCalledWith(EventBusLogEvent.MessageSkipped, anyOf(SyntaxError), {
173
177
  topic: "run-1.events.probe",
174
178
  })
175
179
  expect(logger.warn).toHaveBeenCalledTimes(1)
@@ -269,7 +273,7 @@ describe("EventRunnerService", () => {
269
273
  )
270
274
 
271
275
  expect(transport.wait).toHaveBeenCalledWith(800)
272
- expect(handle).toHaveBeenCalledWith(expect.objectContaining({ attempt: 2 }))
276
+ expect(handle).toHaveBeenCalledWith(objectLike({ attempt: 2 }))
273
277
  })
274
278
 
275
279
  it("does not wait for a retry message whose backoff has passed", async () => {
@@ -9,6 +9,9 @@ import type { Logger } from "@modules/platform/logging"
9
9
  import { OutboxLogEvent } from "./outbox.log-events"
10
10
  import { OutboxRelayPolicy } from "./outbox-relay.policy"
11
11
 
12
+ /** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
13
+ const anyOf = (type: unknown): unknown => expect.any(type)
14
+
12
15
  interface Row {
13
16
  readonly id: string
14
17
  }
@@ -60,7 +63,7 @@ describe("OutboxRelayPolicy", () => {
60
63
  await expect(relay.relay()).resolves.toBe(2)
61
64
 
62
65
  expect(relay.deliver).toHaveBeenCalledWith(rows)
63
- expect(relay.mark).toHaveBeenCalledWith(expect.any(Object), rows)
66
+ expect(relay.mark).toHaveBeenCalledWith(anyOf(Object), rows)
64
67
  expect(relay.mark).toHaveBeenCalledTimes(1)
65
68
  expect(first.commits).toBe(1)
66
69
  expect(second.commits).toBe(1)
@@ -7,6 +7,9 @@ import type { QueueOptions } from "./queue.options"
7
7
  import { ATTEMPTS, BACKOFF_MS, KEEP_COMPLETED_SECONDS } from "./queue.policy"
8
8
  import type { BullmqJob, BullmqQueue, BullmqWorker, QueueFactory } from "./queue-transport.port"
9
9
 
10
+ /** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
11
+ const anyOf = (type: unknown): unknown => expect.any(type)
12
+
10
13
  const options: QueueOptions = {
11
14
  redisHost: "redis.test",
12
15
  redisPort: 6380,
@@ -83,14 +86,14 @@ describe("BullmqQueueTransportClient", () => {
83
86
 
84
87
  await client.work(handler, 3)
85
88
 
86
- expect(factory.worker).toHaveBeenCalledWith("orders", expect.any(Function), {
89
+ expect(factory.worker).toHaveBeenCalledWith("orders", anyOf(Function), {
87
90
  prefix: "spec.",
88
91
  connection: { host: "redis.test", port: 6380, db: 7, maxRetriesPerRequest: null },
89
92
  concurrency: 3,
90
93
  })
91
94
  const processor = factory.worker.mock.calls[0]?.[1]
92
95
  await processor?.(mock<BullmqJob>({ id: "job-1", data: { orderId: "order-1" }, attemptsMade: 2 }))
93
- await processor?.(mock<BullmqJob>({ id: undefined, data: { orderId: "order-2" }, attemptsMade: 0 }))
96
+ await processor?.({ data: { orderId: "order-2" }, attemptsMade: 0 } satisfies BullmqJob)
94
97
 
95
98
  expect(handler.handle).toHaveBeenNthCalledWith(1, {
96
99
  id: "job-1",
@@ -70,7 +70,7 @@ export class @@Channel@@WebsocketModule extends ConfigurableModuleClass implemen
70
70
  }
71
71
 
72
72
  private async authenticate(client: @@Channel@@Socket): Promise<void> {
73
- client.data.principal = undefined
73
+ delete client.data.principal
74
74
  const token = client.handshake.auth.accessToken
75
75
  if (typeof token !== "string" || token.length === 0) throw new Error("Unauthorized")
76
76
  const verified = await this.verifyAccessToken(token)
@@ -7,6 +7,9 @@ import { @@Step@@Compensation } from "./compensations/@@step@@.compensation"
7
7
  import { @@Saga@@SagaService } from "./@@saga@@.saga.service"
8
8
  import { @@Step@@Step } from "./steps/@@step@@.saga-step"
9
9
 
10
+ /** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
11
+ const anyOf = (type: unknown): unknown => expect.any(type)
12
+
10
13
  const build = async () => {
11
14
  const sagas = mock<SagaService>()
12
15
  const compensation = mock<@@Step@@Compensation>()
@@ -33,7 +36,7 @@ describe("@@Saga@@SagaService", () => {
33
36
  saga: @@sagaUpper@@_SAGA,
34
37
  correlationId: "x-1",
35
38
  eventId: "e-1",
36
- step: expect.any(@@Step@@Step),
39
+ step: anyOf(@@Step@@Step),
37
40
  compensation,
38
41
  })
39
42
  })
@@ -16,16 +16,16 @@ export const formatGraphqlError = (
16
16
  if (error instanceof GraphQLError && error.originalError === undefined) {
17
17
  return {
18
18
  message: formatted.message,
19
- locations: formatted.locations,
20
- path: formatted.path,
19
+ ...(formatted.locations === undefined ? {} : { locations: formatted.locations }),
20
+ ...(formatted.path === undefined ? {} : { path: formatted.path }),
21
21
  extensions: { kind: "invalid" },
22
22
  }
23
23
  }
24
24
  const description = errors.describe(error instanceof GraphQLError ? error.originalError : error)
25
25
  return {
26
26
  message: description.code,
27
- locations: formatted.locations,
28
- path: formatted.path,
27
+ ...(formatted.locations === undefined ? {} : { locations: formatted.locations }),
28
+ ...(formatted.path === undefined ? {} : { path: formatted.path }),
29
29
  extensions: { code: description.code, kind: description.kind, params: description.params },
30
30
  }
31
31
  }
@@ -8,7 +8,7 @@ import type { GraphqlContext } from "./graphql.contracts"
8
8
  import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
9
9
 
10
10
  const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
11
- mock<GraphQLResponse>({ body: { kind: "single", singleResult: { errors } } })
11
+ mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
12
12
 
13
13
  const requestContext = (
14
14
  response: GraphQLResponse,
@@ -6,10 +6,14 @@ import { PublicReason } from "./identity.contracts"
6
6
 
7
7
  const ADMISSION: unique symbol = Symbol("identity.admission")
8
8
 
9
+ /** A record is read as metadata whatever its fields: the admission under test is what judges the reason. */
10
+ const isMetadata = (value: unknown): value is PublicMetadata => typeof value === "object" && value !== null
11
+
9
12
  const runtimeMetadata = (source: string): PublicMetadata => {
10
13
  try {
11
- const metadata: PublicMetadata = JSON.parse(source)
12
- return metadata
14
+ const parsed: unknown = JSON.parse(source)
15
+ if (!isMetadata(parsed)) throw new IdentityError({ code: IdentityErrorCode.Unauthenticated })
16
+ return parsed
13
17
  } catch (cause) {
14
18
  throw new IdentityError({ code: IdentityErrorCode.Unauthenticated, cause })
15
19
  }