@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
@@ -20,7 +20,7 @@ provenance:
20
20
  - knowledge/hfs/slots.yaml
21
21
  - knowledge/hfs/rules.yaml
22
22
  - knowledge/patterns/be/services.yaml
23
- - examples/ecommerce-app
23
+ - examples/full-app
24
24
  limitations: |-
25
25
  The checks read the folder tree, the step and compensation event names, the contracts, the consumers' use of the delivery id
26
26
  and the e2e specs. They cannot see that a compensation really undoes its step, that a step is idempotent, or that the
@@ -8,7 +8,7 @@ purpose: |
8
8
  conditions and selects it for the project, and (2) judges an op's slice - the lines it changed - against
9
9
  the same numbers. Code-writing ops cannot settle done while the slice is red
10
10
  (scripts/kernel/sonar-settle.mjs). Changing a number here changes every repository on the next scan.
11
- The gate has two parts: `newCode` (the recent work: coverage, duplication, blocker/critical issues, hotspots) and
11
+ The gate has two parts: `newCode` (the recent work: coverage, duplication, issues of every severity, hotspots) and
12
12
  `overall` (the whole code: coverage, no imported HFS, ESLint or stylelint finding, no bug, code smell or
13
13
  vulnerability, every hotspot reviewed, no duplicated-lines excess, no cognitive-complexity issue). The one Sonar
14
14
  mechanism of the HFS canon is packages/hfs/README.md section "Sonar".
@@ -26,15 +26,15 @@ newCode:
26
26
  coverage:
27
27
  metric: new_coverage
28
28
  minPercent: 100
29
- # SonarQube's ignoreSmallChanges: fewer changed lines than this are not held to duplication.
30
- ignoreBelowChangedLines: 20
29
+ # SonarQube's ignoreSmallChanges floor: 0, so a change of any size is held to duplication.
30
+ ignoreBelowChangedLines: 0
31
31
  duplication:
32
32
  metric: new_duplicated_lines_density
33
33
  maxPercent: 3
34
34
  issues:
35
- # An open issue of these severities on a changed line fails the slice; lesser ones are listed, never blocking.
36
- blockingSeverities: [BLOCKER, CRITICAL]
37
- metrics: {BLOCKER: new_blocker_violations, CRITICAL: new_critical_violations}
35
+ # An open issue of any severity on a changed line fails the slice: there is no lesser severity that is only listed.
36
+ blockingSeverities: [BLOCKER, CRITICAL, MAJOR, MINOR, INFO]
37
+ metrics: {BLOCKER: new_blocker_violations, CRITICAL: new_critical_violations, MAJOR: new_major_violations, MINOR: new_minor_violations, INFO: new_info_violations}
38
38
  max: 0
39
39
  hotspots:
40
40
  metric: new_security_hotspots_reviewed
@@ -92,13 +92,13 @@ lite:
92
92
  coverage:
93
93
  metric: new_coverage
94
94
  minPercent: 100
95
- ignoreBelowChangedLines: 20
95
+ ignoreBelowChangedLines: 0
96
96
  duplication:
97
97
  metric: new_duplicated_lines_density
98
98
  maxPercent: 3
99
99
  issues:
100
- blockingSeverities: [BLOCKER, CRITICAL]
101
- metrics: {BLOCKER: new_blocker_violations, CRITICAL: new_critical_violations}
100
+ blockingSeverities: [BLOCKER, CRITICAL, MAJOR, MINOR, INFO]
101
+ metrics: {BLOCKER: new_blocker_violations, CRITICAL: new_critical_violations, MAJOR: new_major_violations, MINOR: new_minor_violations, INFO: new_info_violations}
102
102
  max: 0
103
103
  hotspots:
104
104
  metric: new_security_hotspots_reviewed
@@ -139,9 +139,9 @@ reasoning: |
139
139
  lives there, the feature tier is thin (R203) and proven by integration and e2e, and the front end has no unit tests.
140
140
  duplication 3: the Sonar way value; the worst project today is 2.1, so 3 rejects a real copy-paste block and
141
141
  passes ordinary work.
142
- blocker/critical 0: the two severities that are defects, not style. main carries old debt of both kinds
143
- (one product repository carries 3 blocker and 137 critical), so the SLICE verdict reads only changed lines and never main's backlog;
144
- major and lower are reported on the summary and never fail a slice.
142
+ issues 0 at every severity (blocker, critical, major, minor, info), owner ruling 2026-10-22 (no loosening): a lesser severity is not a
143
+ lesser finding. The SLICE verdict reads only changed lines and never main's backlog; the overall condition holds the whole project.
144
+ A change of any size is held to duplication (ignoreBelowChangedLines 0).
145
145
  overall issues 0 and overall duplication 3: the server gate is also the owner's definition of done for the HFS canon:
146
146
  a repository that carries any open ESLint, stylelint or HFS finding is not green, whatever the age of the line. The
147
147
  op slice keeps judging changed lines (an op cannot fix a repository's whole backlog); the dashboard and CI show the
@@ -4712,13 +4712,13 @@ RT_FACT_FALSE:
4712
4712
  kind: check-finding
4713
4713
 
4714
4714
  RT_GATE_LOOSENING:
4715
- title: "A change loosens a gate without the owner's approval"
4716
- title_vi: "Một thay đổi nới lỏng cổng kiểm mà chưa có chủ phê duyệt"
4717
- meaning_vi: "Bỏ một kiểm tra khỏi danh sách của cổng, hạ ngưỡng, thêm một mục vào allowlist, xoá hoặc làm yếu một spec cùng lúc với mã sản phẩm là việc của chủ dự án; commit hoặc lần land đó chỉ qua khi cây gốc đã có mục owner-rulings gate-loosening-<dấu vân> của đúng thay đổi này."
4715
+ title: "A change loosens a gate"
4716
+ title_vi: "Một thay đổi nới lỏng cổng kiểm"
4717
+ meaning_vi: "Bỏ một kiểm tra khỏi danh sách của cổng, hạ ngưỡng, thêm một mục vào allowlist, xoá hoặc làm yếu một spec cùng lúc với mã sản phẩm luôn bị chặn; không có phê duyệt nào cho nới lỏng đi qua, kể cả của chủ (owner ruling loosening-always-red, 2026-10-22)."
4718
4718
  causes_vi:
4719
4719
  - "Một lane gỡ kiểm tra hoặc hạ ngưỡng để cho workflow chạy qua"
4720
4720
  - "Một spec bị xoá hoặc bớt khẳng định trong cùng thay đổi với mã sản phẩm"
4721
- nextStep_vi: "Khôi phục cổng; nếu chủ thật sự duyệt, chủ thêm mục owner-rulings gate-loosening-<dấu vân> trước, rồi mới land thay đổi."
4721
+ nextStep_vi: "Khôi phục mọi cổng được liệt kê rồi land lại; không có đường duyệt cho nới lỏng."
4722
4722
  owner: owner
4723
4723
  kind: check-finding
4724
4724
 
@@ -151,9 +151,6 @@ $defs:
151
151
  target: {type: number, minimum: 0, maximum: 100}
152
152
  components: {enum: [auto]}
153
153
  required: {type: boolean}
154
- optional:
155
- description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
156
- type: boolean
157
154
  badgeToken:
158
155
  description: >-
159
156
  codecov and sonar only: a read-only, public-safe badge token (Codecov graphing token, Sonar project badge token)
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "4018e220cb9abde27d6d8cd79cd502c4d0c64a5312d7549c8e631553a22cb878",
4
+ "hash": "5bec1a5a568a8294dd40e3e09b7b97693660b8a7c273fcd35594aea87196257a",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -1232,18 +1232,18 @@ export const CATALOG = {
1232
1232
  ],
1233
1233
  "impl": null,
1234
1234
  "conventions": [
1235
- "the gate list is release.gates of application-stacks.yaml (argument arrays), else app-check, audit, then the managed package scripts",
1235
+ "the gate list is release.gates of application-stacks.yaml (argument arrays), else app-check, audit, then every row the managed CI runs",
1236
1236
  "audit runs npm audit --omit=dev --audit-level=high by default; services.audit.level and omitDev configure it,",
1237
1237
  "and an explicit audit.in without release-gate omits it;",
1238
1238
  "declared release.gates gets audit appended if missing",
1239
- "--sonar appends @sonar/scan@4.3.0 against services.sonar.host (host.public for a host mapping), plus organization for sonarcloud;",
1239
+ "a declared services.sonar is a row (as --sonar): @sonar/scan@4.3.0 against its host (host.public for a mapping), plus organization for sonarcloud;",
1240
1240
  "disabled or unavailable Sonar or an unset token refuses RELEASE_SONAR_UNAVAILABLE",
1241
1241
  "a scanner exit 0 is not a pass: the row polls api/ce/task until the analysis ends, then reads api/qualitygates/project_status with the same token",
1242
1242
  "OK passes; ERROR is red as RELEASE_SONAR_GATE_FAILED naming each failed condition, as is a FAILED or CANCELED task",
1243
1243
  "a task still running after 600 s is RELEASE_SONAR_GATE_TIMEOUT; no report-task, an HTTP 4xx or no gate is RELEASE_SONAR_GATE_UNREADABLE",
1244
1244
  "a network error or HTTP 5xx is retried up to 5 times with backoff inside the 600 s; still failing, it is RELEASE_SONAR_GATE_UNREADABLE",
1245
1245
  "OWNER RULING candidate-ci (2026-10-18): the gate no longer reads the default-branch CI; the verdict is the candidate run of the sha",
1246
- "release.requireCi (default false, owner ruling release-require-ci-per-project) decides whether push waits for CI; the local gate is never skipped",
1246
+ "release.requireCi (default false, owner ruling release-require-ci-per-project) decides whether push waits for CI; every local gate row runs either way",
1247
1247
  "requireCi false: push needs the tag and the green L4 record, runs the dry run, pushes atomically and states CI was not required",
1248
1248
  "requireCi true: push --execute sends HEAD to refs/heads/release/candidate-<version> first and waits for its ci.yml run",
1249
1249
  "green moves the branch and the tag in one atomic push; red refuses RELEASE_CANDIDATE_CI_RED naming the failing jobs",
@@ -12764,6 +12764,69 @@ export const CATALOG = {
12764
12764
  },
12765
12765
  "runtimeSide": "host"
12766
12766
  },
12767
+ "record": {
12768
+ "summary": "walk one UAT flow in a recorded browser outside a Kernel and file the run as its evidence",
12769
+ "owner": "runtime",
12770
+ "positional": [
12771
+ {
12772
+ "name": "command",
12773
+ "required": true,
12774
+ "variadic": true
12775
+ }
12776
+ ],
12777
+ "flags": [
12778
+ {
12779
+ "name": "work",
12780
+ "type": "string",
12781
+ "required": true,
12782
+ "summary": "the .starciwork root holding the flow"
12783
+ },
12784
+ {
12785
+ "name": "flow",
12786
+ "type": "string",
12787
+ "required": true,
12788
+ "summary": "the work/uat-flow@1 record id to walk"
12789
+ },
12790
+ {
12791
+ "name": "cwd",
12792
+ "type": "string",
12793
+ "summary": "directory the Playwright command runs in (default: the Work root's parent)"
12794
+ },
12795
+ {
12796
+ "name": "environment",
12797
+ "type": "string",
12798
+ "summary": "environment label of the evidence (default: local)"
12799
+ },
12800
+ {
12801
+ "name": "base-url",
12802
+ "type": "string",
12803
+ "summary": "the origin the walk drove, named in the evidence provenance"
12804
+ }
12805
+ ],
12806
+ "exit": {
12807
+ "0": "the walk passed and its evidence is filed",
12808
+ "1": "the walk was recorded and filed but did not pass",
12809
+ "2": "refused: the flow, the run folder or a file is invalid, and nothing is written"
12810
+ },
12811
+ "json": "always",
12812
+ "examples": [
12813
+ "starci uat record --work <app>/.starciwork --flow uat.checkout.place-order --cwd <app> -- npx playwright test -c browser/playwright.config.ts uat-place-order.ts"
12814
+ ],
12815
+ "editions": [
12816
+ "full"
12817
+ ],
12818
+ "impl": {
12819
+ "script": "scripts/uat/uat-record.mjs"
12820
+ },
12821
+ "conventions": [
12822
+ "the command holds one UAT slot and runs through the browser recorder, as a uat.verify step does (video, trace, screenshots)",
12823
+ "it writes manifest.yaml, result.md and screens/*.png into UAT_RUN_DIR; the recorder adds videos/<flow>.webm",
12824
+ "every run file becomes a blob; an example's Work also gets the same verified bytes in examples/.runtimes/<app>/artifacts",
12825
+ "evidence.yaml is written with actor uat-record; the outcome is pass only when the command exits 0 and its manifest says pass",
12826
+ "the flow record's own index.yaml is never edited: state, proves and blockedBy are a person's or a Kernel operation's act"
12827
+ ],
12828
+ "runtimeSide": "host"
12829
+ },
12767
12830
  "slots": {
12768
12831
  "summary": "inspect UAT slots, end the ones whose lessee is gone, or run a command while holding one",
12769
12832
  "owner": "runtime",
@@ -10,7 +10,7 @@ const E2E_COMMAND = new RegExp([
10
10
  String.raw`src\/tests\/(?:world|integration|e2e|contract)\b|`,
11
11
  String.raw`jest[^\n|&;]*(?:e2e|integration|contract)`,
12
12
  ].join(''), 'u');
13
- const UNIT_RUN_SCRIPTS = ['test', 'test:unit', 'test:ci', 'test:affected', 'test:coverage', 'test:cov'];
13
+ const UNIT_RUN_SCRIPTS = ['test', 'test:unit', 'test:ci', 'test:coverage', 'test:cov'];
14
14
 
15
15
  const runsE2e = text => E2E_COMMAND.test(String(text).replace(/--ignore-pattern[= ]+(?:"[^"]*"|'[^']*'|\S+)/gu, ''));
16
16
  const withoutComments = text => text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n');
@@ -42,13 +42,13 @@ export function qualityServices(doc, { example = false } = {}) {
42
42
  const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
43
43
  return {
44
44
  sonar: !sonar || sonar.mode === 'disabled' ? null : {
45
- provider: sonar.provider, mode: sonar.mode, optional: sonar.optional ?? sonar.provider === 'sonarqube', host: sonar.host?.public || sonar.host?.local || null,
45
+ provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
46
46
  organization: sonar.organization ?? null,
47
47
  organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null), projectKey: sonar.projects?.[0]?.key ?? null,
48
48
  qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
49
49
  token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
50
50
  },
51
- codecov: { optional: codecov.optional ?? false, enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
51
+ codecov: { enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
52
52
  flag: codecov.flag ?? null, target: codecov.target ?? 100, components: codecov.components ?? 'auto', required: codecov.required ?? true, wiring: codecov.ci?.wiring ?? 'required' },
53
53
  dependabot: { ecosystems: [...(dependabot.ecosystems ?? ['npm', 'github-actions', 'docker'])],
54
54
  schedule: { interval: 'weekly', ...dependabot.schedule }, groups: { minorPatch: 'grouped', major: 'per-package', ...dependabot.groups },
@@ -4,8 +4,8 @@
4
4
  // full-history-base-ref every actions/checkout fetches the full history; a job that lints writes STARCI_BASE_REF before it lints
5
5
  // heap-per-step every heavy step (heap.steps) declares NODE_OPTIONS --max-old-space-size >= heap.minMiB on the step
6
6
  // sonar-lint-per-side a lint with --sonar names --side
7
- // optional-providers an optional (continue-on-error) Codecov or Sonar step follows its availability probe in the same job
8
- // test-sharding the test job is a shard matrix; each shard runs --shard=i/N without the per-file threshold; the coverage job merges, then proves
7
+ // required-providers no continue-on-error; Codecov fail_ci_if_error true; no availability probe, no secret-gated Codecov or Sonar step
8
+ // test-sharding the test job is a shard matrix; each shard runs --shard=i/N with STARCI_COVERAGE_SHARD and no threshold or pass-empty flag; the coverage job merges, then proves
9
9
  // lint-parallel the lint job is a matrix over side [be, fe]
10
10
  // cache-saved-always no cache keyed by the commit sha; actions/cache/save runs under if: always()
11
11
  // timeouts every job declares timeout-minutes
@@ -79,7 +79,7 @@ function testSharding(doc, say) {
79
79
  if (unit && !total) out.push(say('test-sharding', 'the unit run must pass --shard=${{ matrix.shard }}/<N>'));
80
80
  if (total && Array.isArray(shards) && Number(total[1]) !== shards.length) out.push(say('test-sharding', `the unit run's shard total ${total[1]} must equal the ${shards.length} matrix shards`));
81
81
  if (unit && String(unit.env?.STARCI_COVERAGE_SHARD ?? '') !== '1') out.push(say('test-sharding', 'a shard runs the unit step with STARCI_COVERAGE_SHARD: "1": it measures only the files it loads and writes the istanbul JSON the coverage job merges'));
82
- if (unit && !runOf(unit).includes('--coverageThreshold={}')) out.push(say('test-sharding', 'a shard measures without the per-file threshold (--coverageThreshold={}); the coverage job proves it on the merged report'));
82
+ if (unit && /--passWithNoTests|--coverageThreshold/.test(runOf(unit))) out.push(say('test-sharding', 'a shard passes neither --passWithNoTests nor --coverageThreshold: the preset owns shard mode (no per-file threshold; the coverage job proves it on the merged report) and the shard count never exceeds what the unit specs fill'));
83
83
  const coverage = record(doc.jobs?.coverage) ? stepsOf(doc.jobs.coverage) : [];
84
84
  const merge = coverage.findIndex((step) => /app coverage merge\b/.test(runOf(step)));
85
85
  const proof = coverage.findIndex((step) => COVERAGE_PROOF.test(runOf(step)));
@@ -146,20 +146,30 @@ function sonarLintPerSide(doc, say) {
146
146
  return out;
147
147
  }
148
148
 
149
- function optionalProviders(doc, say) {
149
+ const PROVIDER_STEP = /^(?:codecov\/codecov-action|SonarSource\/)/;
150
+ const PROVIDER_SECRET_GATE = /\bsecrets\.|\bCODECOV_TOKEN\b|\bSONAR_TOKEN\b/;
151
+ const PROBE_IDS =new Set(['codecov-availability', 'sonar-availability']);
152
+ const truthy = (value) => value === true || String(value).toLowerCase() === 'true';
153
+
154
+ /** The findings of one step: it continues on error, it is a probe, or it is a Codecov or Sonar step that a secret gates or that tolerates a failed upload. */
155
+ function providerStepFindings(id, step, say) {
156
+ const name = step.name ?? usesOf(step) ?? step.id;
150
157
  const out = [];
151
- const probes = [['codecov/codecov-action@', 'codecov-availability'], ['SonarSource/sonarqube-scan-action@', 'sonar-availability']];
152
- for (const [id, job] of jobsOf(doc)) {
153
- const steps = stepsOf(job);
154
- steps.forEach((step, index) => {
155
- const probe = probes.find(([prefix]) => usesOf(step).startsWith(prefix));
156
- if (!probe || step['continue-on-error'] !== true) return;
157
- if (!steps.slice(0, index).some((earlier) => earlier.id === probe[1])) out.push(say('optional-providers', `job ${id}: the optional ${usesOf(step).split('@')[0]} step must follow its availability probe (id ${probe[1]})`));
158
- });
159
- }
158
+ if (truthy(step['continue-on-error'])) out.push(say('required-providers', `job ${id}, step ${name}: continue-on-error lets a red step pass`));
159
+ if (PROBE_IDS.has(step.id)) out.push(say('required-providers', `job ${id}: ${step.id} probes a required provider to skip it; an unreachable provider is red`));
160
+ if (!PROVIDER_STEP.test(usesOf(step))) return out;
161
+ if (PROVIDER_SECRET_GATE.test(String(step.if ?? ''))) out.push(say('required-providers', `job ${id}, step ${name}: gated on a secret, so a missing token skips it instead of failing the job`));
162
+ if (usesOf(step).startsWith('codecov/codecov-action@') && !truthy(step.with?.fail_ci_if_error)) out.push(say('required-providers', `job ${id}, step ${name}: the upload must set fail_ci_if_error: true`));
160
163
  return out;
161
164
  }
162
165
 
166
+ function requiredProviders(doc, say) {
167
+ return jobsOf(doc).flatMap(([id, job]) => [
168
+ ...(truthy(job['continue-on-error']) ? [say('required-providers', `job ${id}: continue-on-error lets a red job pass`)] : []),
169
+ ...stepsOf(job).flatMap((step) => providerStepFindings(id, step, say)),
170
+ ]);
171
+ }
172
+
163
173
  function timeouts(doc, say) {
164
174
  return jobsOf(doc).filter(([, job]) => !Number.isFinite(Number(job['timeout-minutes'])) || job['timeout-minutes'] === null || job['timeout-minutes'] === '')
165
175
  .filter(([, job]) => job.uses === undefined).map(([id]) => say('timeouts', `job ${id} must declare timeout-minutes`));
@@ -188,7 +198,7 @@ export function ciStandardFindings({ doc, file, scope, edition = 'full', standar
188
198
  'full-history-base-ref': () => fullHistory(doc, say),
189
199
  'heap-per-step': () => heapPerStep(doc, standard, say),
190
200
  'sonar-lint-per-side': () => sonarLintPerSide(doc, say),
191
- 'optional-providers': () => optionalProviders(doc, say),
201
+ 'required-providers': () => requiredProviders(doc, say),
192
202
  'test-sharding': () => testSharding(doc, say),
193
203
  'lint-parallel': () => lintParallel(doc, say),
194
204
  'cache-saved-always': () => cacheSavedAlways(doc, say),
@@ -2,7 +2,7 @@
2
2
  // followed through variables, parameters (state.arguments), array callbacks, for-of and literal tables.
3
3
  import { nameText as nameOf } from '../../lib/ts-ast.mjs';
4
4
  import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
5
- import { excludesNull, propertiesOf, propertyValues, writesTarget } from './unused-symbol-properties.mjs';
5
+ import { excludesNull, ownerProperty, propertiesOf, propertyValues, writesTarget } from './unused-symbol-properties.mjs';
6
6
 
7
7
  const ARRAY_CALLBACK = /^(?:map|flatMap|forEach|filter|find|some|every)$/u;
8
8
 
@@ -142,6 +142,64 @@ function callValues(state, node, seen) {
142
142
  ? resolveAll(state, returnedExpressions(ts, fn), seen) : [node];
143
143
  }
144
144
 
145
+ const unionMembers = type => {
146
+ if (!type) return [];
147
+ return type.isUnion() ? type.types : [type];
148
+ };
149
+
150
+ /** The object literals the program builds for a declared object type: those the checker types by that interface or alias. */
151
+ function literalsByType(state) {
152
+ const { ts, checker } = state;
153
+ if (state.typedLiterals) return state.typedLiterals;
154
+ const index = new Map();
155
+ const visit = node => {
156
+ if (ts.isObjectLiteralExpression(node)) {
157
+ const type = checker.getContextualType(node);
158
+ const owners = unionMembers(type);
159
+ for (const owner of owners) {
160
+ const symbol = owner.aliasSymbol ?? owner.symbol;
161
+ if (symbol) index.set(symbol, [...index.get(symbol) ?? [], node]);
162
+ }
163
+ }
164
+ ts.forEachChild(node, visit);
165
+ };
166
+ for (const source of state.flowSources?.values() ?? []) visit(source);
167
+ state.typedLiterals = index;
168
+ return index;
169
+ }
170
+
171
+ /** A property declared `readonly` on an interface or type literal cannot be reassigned, so only the object literals typed by it write it. */
172
+ const readonlyDeclaration = (ts, symbol, name) => {
173
+ const member = symbol.members?.get(name) ?? symbol.declarations?.flatMap(declaration => ts.isTypeAliasDeclaration(declaration) ? [declaration.type] : [])
174
+ .find(type => ts.isTypeLiteralNode(type))?.symbol?.members?.get(name);
175
+ return Boolean(member?.declarations?.length) && member.declarations.every(declaration => ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Readonly);
176
+ };
177
+
178
+ /**
179
+ * What a read of `owner.name` can hold when the owner is a value of a declared object type and the property is `readonly`: the initializers
180
+ * of that property in every object literal the program types by that declaration, in a typed table of constants. Any owner type that is not
181
+ * such a declaration, or a literal whose property comes from an unknown source, leaves the read unresolved.
182
+ */
183
+ function typedPropertyValues(state, node) {
184
+ const { ts, checker } = state;
185
+ const owner = checker.getNonNullableType(checker.getTypeAtLocation(node.expression));
186
+ const owners = owner.isUnion() ? owner.types : [owner];
187
+ const values = [];
188
+ for (const type of owners) {
189
+ const symbol = type.aliasSymbol ?? type.symbol;
190
+ const literals = symbol ? literalsByType(state).get(symbol) : null;
191
+ if (!literals?.length || !readonlyDeclaration(ts, symbol, node.name.text)) return [];
192
+ for (const literal of literals) {
193
+ const property = ownerProperty(state, literal, node.name.text, new Set(), valuesOf);
194
+ if (property === null) return [];
195
+ values.push(...property.values);
196
+ }
197
+ }
198
+ return values;
199
+ }
200
+
201
+ const isNullish = (ts, node) => node.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(node) && node.text === 'undefined';
202
+
145
203
  /** Leaf expressions `expression` may evaluate to. A translator variable and any unresolvable expression is its own leaf. */
146
204
  export function valuesOf(state, expression, visited = new Set()) {
147
205
  const { ts } = state;
@@ -152,12 +210,20 @@ export function valuesOf(state, expression, visited = new Set()) {
152
210
  const orLeaf = values => values.length ? values : [node];
153
211
  const narrowed = values => orLeaf(excludesNull(state, node) ? values.filter(value => value.kind !== ts.SyntaxKind.NullKeyword) : values);
154
212
  if (ts.isConditionalExpression(node)) return [...valuesOf(state, node.whenTrue, seen), ...valuesOf(state, node.whenFalse, seen)];
213
+ // `a ?? b` and `a || b` hold either operand.
214
+ if (ts.isBinaryExpression(node) && [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind))
215
+ return [...valuesOf(state, node.left, seen), ...valuesOf(state, node.right, seen)];
155
216
  if (ts.isCallExpression(node)) return orLeaf(callValues(state, node, seen));
156
217
  if (ts.isPropertyAccessExpression(node)) {
157
218
  const member = classMemberValue(state, symbolAt(state, node)?.valueDeclaration);
158
219
  if (member) return [member];
159
220
  }
160
- if (ts.isPropertyAccessExpression(node)) return narrowed(resolveAll(state, propertiesOf(state, valuesOf(state, node.expression, seen), node.name.text, seen, valuesOf), seen));
221
+ if (ts.isPropertyAccessExpression(node)) {
222
+ // Reading a property of null or undefined throws, so a nullish branch of the owner holds no value of the property.
223
+ const owners = valuesOf(state, node.expression, seen).filter(owner => !isNullish(ts, owner));
224
+ const known = resolveAll(state, propertiesOf(state, owners, node.name.text, seen, valuesOf), seen);
225
+ return narrowed(known.length ? known : resolveAll(state, typedPropertyValues(state, node), seen));
226
+ }
161
227
  if (ts.isElementAccessExpression(node)) return orLeaf(resolveAll(state, tableValues(state, node, seen), seen));
162
228
  if (!ts.isIdentifier(node)) return [node];
163
229
  const symbol = symbolAt(state, node);
@@ -35,7 +35,7 @@ function entryProperty(state, entry, name, seen, resolve) {
35
35
  }
36
36
 
37
37
  /** Fold writes in source order, with branch-local cycle detection and explicit absence. */
38
- function ownerProperty(state, owner, name, visited, resolve) {
38
+ export function ownerProperty(state, owner, name, visited, resolve) {
39
39
  if ((!state.ts.isObjectLiteralExpression(owner) && !state.ts.isJsxAttributes(owner)) || visited.has(owner)) return null;
40
40
  const seen = new Set([...visited, owner]);
41
41
  return owner.properties.reduce((before, entry) => mergeProperty(before, entryProperty(state, entry, name, seen, resolve)), ABSENT_PROPERTY);
@@ -57,16 +57,32 @@ function projectOptions(ts, repoRoot, files) {
57
57
  return projects.sort((a, b) => b.directory.length - a.directory.length);
58
58
  }
59
59
 
60
+ /**
61
+ * The standard library the type checker reads. Without it `Array`, `ReadonlyArray` and `Promise` are unresolved, so the callback
62
+ * parameter of `rows.filter(...).map((row) => t(row.label))` has no type and a key typed as a union of literals cannot be read.
63
+ */
64
+ const LIB_FILE = 'lib.es2023.d.ts';
65
+ const libraryFiles = new Map();
66
+ const isLibraryFile = (ts, file) => /(?:^|[/\\])lib\.[\w.-]+\.d\.ts$/u.test(file) && slash(file).startsWith(slash(path.dirname(ts.getDefaultLibFilePath({}))));
67
+ function libraryFile(ts, file) {
68
+ if (!isLibraryFile(ts, file)) return undefined;
69
+ if (!libraryFiles.has(file)) {
70
+ const text = ts.sys.readFile(file);
71
+ libraryFiles.set(file, text === undefined ? undefined : ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true));
72
+ }
73
+ return libraryFiles.get(file);
74
+ }
75
+
60
76
  function trackedProgram(ts, repoRoot, files, sources) {
61
- const options = { noLib: true, types: [], allowJs: true, resolveJsonModule: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler };
77
+ const options = { lib: [LIB_FILE], types: [], allowJs: true, resolveJsonModule: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler };
62
78
  const projects = projectOptions(ts, repoRoot, files);
63
79
  const catalogs = new Set(files.filter(file => JSON_CATALOG.test(file)).map(file => slash(path.resolve(repoRoot, file))));
64
80
  const host = {
65
- getSourceFile: file => sources.get(slash(path.resolve(file))),
66
- getDefaultLibFileName: () => '', writeFile: () => {},
81
+ getSourceFile: file => sources.get(slash(path.resolve(file))) ?? libraryFile(ts, file),
82
+ getDefaultLibFileName: libOptions => ts.getDefaultLibFilePath(libOptions), writeFile: () => {},
67
83
  getCurrentDirectory: () => repoRoot, getDirectories: () => [],
68
- fileExists: file => sources.has(slash(path.resolve(file))) || catalogs.has(slash(path.resolve(file))),
69
- readFile: file => sources.get(slash(path.resolve(file)))?.text,
84
+ fileExists: file => sources.has(slash(path.resolve(file))) || catalogs.has(slash(path.resolve(file))) || isLibraryFile(ts, file),
85
+ readFile: file => sources.get(slash(path.resolve(file)))?.text ?? (isLibraryFile(ts, file) ? ts.sys.readFile(file) : undefined),
70
86
  getCanonicalFileName: slash, useCaseSensitiveFileNames: () => true,
71
87
  getNewLine: () => '\n',
72
88
  };
@@ -153,9 +153,10 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
153
153
  edition,
154
154
  side: null,
155
155
  profile: APP_SCOPE,
156
- // The root declares no app or connection of its own (each side does); its one opt-in slot is the browser journey (`browser: true`).
156
+ // The root declares no app or connection of its own (each side does); its opt-in slots are the browser journey and the managed
157
+ // tsconfig.json that type-checks it, both switched on by `browser: true`.
157
158
  apps: Object.freeze([]),
158
- optionalSlots: Object.freeze(declaration.browser === true ? ['app.browser'] : []),
159
+ optionalSlots: Object.freeze(declaration.browser === true ? ['app.browser', 'app.browser-config'] : []),
159
160
  connections: Object.freeze([]),
160
161
  providers,
161
162
  reads: Object.freeze([]),