@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/candidate-ci.mjs +20 -10
- package/release/gate.mjs +37 -19
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +17 -17
- package/runtime/knowledge/hfs/slots.yaml +20 -1
- package/runtime/knowledge/patterns/be/saga.yaml +1 -1
- package/runtime/knowledge/sonar-gate.yaml +12 -12
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
- package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
- package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/lib/gate-loosening.mjs +248 -0
- package/runtime/scripts/lib/managed-json.mjs +51 -0
- package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
- package/stack/prepare.mjs +10 -3
- package/stack/release-store.mjs +3 -3
- package/stack/remote.mjs +9 -0
- package/sync/ci-commands.mjs +38 -90
- package/sync/index.mjs +10 -5
- package/templates/app/browser-config/browser/tsconfig.json +11 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
- package/templates/app/hooks/husky/pre-push +4 -2
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +0 -1
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
- package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
- package/templates/fe/auth/supabase-client.ts.tpl +7 -7
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
- package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
- 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/
|
|
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,
|
|
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:
|
|
30
|
-
ignoreBelowChangedLines:
|
|
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
|
|
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:
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
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
|
|
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
|
|
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;
|
|
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:
|
|
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,
|
|
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: {
|
|
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
|
-
//
|
|
8
|
-
// test-sharding the test job is a shard matrix; each shard runs --shard=i/N
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
'
|
|
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))
|
|
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 = {
|
|
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:
|
|
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
|
|
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([]),
|