@starci/hfs 4.6.0 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/ci/cli.mjs +2 -0
  3. package/ci/local.mjs +148 -0
  4. package/coverage/cli.mjs +49 -0
  5. package/package.json +2 -1
  6. package/release/candidate-ci.mjs +111 -0
  7. package/release/gate.mjs +4 -5
  8. package/release/push.mjs +10 -1
  9. package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
  10. package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
  11. package/runtime/knowledge/hfs/rules.yaml +19 -2
  12. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  13. package/runtime/modules/schemas/application-stacks.schema.yaml +24 -0
  14. package/runtime/packages/cli/src/catalog.generated.mjs +123 -30
  15. package/runtime/scripts/hfs/lcov-merge.mjs +134 -0
  16. package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
  17. package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
  18. package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
  19. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
  20. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  21. package/scaffold/app.mjs +3 -2
  22. package/src/main.mjs +4 -2
  23. package/sync/ci-commands.mjs +42 -25
  24. package/templates/app/ci-workflows/build-cache-save.yml +8 -0
  25. package/templates/app/ci-workflows/github/workflows/ci.yml +49 -9
  26. package/templates/app/ci-workflows/install-steps.yml +3 -2
  27. package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
  28. package/templates/app/ci-workflows/jest-cache.yml +4 -4
  29. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +50 -10
  30. package/templates/app/hooks/husky/pre-push +4 -2
  31. package/release/ci-red.mjs +0 -102
@@ -1701,6 +1701,16 @@ CI_SECRET_SEALED:
1701
1701
  owner: op-retry
1702
1702
  kind: check-finding
1703
1703
 
1704
+ CI_STANDARD:
1705
+ title: "A workflow breaks the StarCi CI standard"
1706
+ title_vi: "Workflow vi phạm chuẩn CI của StarCi"
1707
+ meaning_vi: "Workflow CI (của app, của ví dụ hoặc của runtime) không đáp ứng một mục của knowledge/hfs/ci-standard.yaml; mục bị vi phạm được nêu trong ngoặc vuông."
1708
+ causes_vi:
1709
+ - "Vi phạm R301: job shape, lịch sử đầy đủ và STARCI_BASE_REF, heap cho bước nặng, lint Sonar theo từng phía, probe nhà cung cấp tùy chọn, timeout hoặc ghim action theo sha."
1710
+ nextStep_vi: "App: chạy starci app sync --write để render lại CI chuẩn (không sửa tay); runtime: sửa workflow theo mục được nêu."
1711
+ owner: op-retry
1712
+ kind: check-finding
1713
+
1704
1714
  CI_TRIGGERS_RELEASE_ONLY:
1705
1715
  title: "Workflow trigger other than a release tag, a person or the runtime's main"
1706
1716
  title_vi: "Workflow có trigger không phải tag phát hành, người chạy tay hay main của runtime"
@@ -287,6 +287,25 @@ $defs:
287
287
  enum: [public, private]
288
288
  secrets: {type: array, items: {$ref: '#/$defs/ciSecret'}}
289
289
  vars: {type: array, items: {$ref: '#/$defs/ciVariable'}}
290
+ jobs:
291
+ description: >-
292
+ Per-job settings of the managed CI (the CI standard, knowledge/hfs/ci-standard.yaml defaults): timeoutMinutes per job and the number of unit-run shards.
293
+ Omitted settings take the standard's defaults; starci app sync renders them.
294
+ type: object
295
+ additionalProperties: false
296
+ properties:
297
+ fast: {$ref: '#/$defs/ciJob'}
298
+ lint: {$ref: '#/$defs/ciJob'}
299
+ test:
300
+ type: object
301
+ additionalProperties: false
302
+ properties:
303
+ timeoutMinutes: {type: integer, minimum: 5, maximum: 360}
304
+ shards: {type: integer, minimum: 1, maximum: 20}
305
+ coverage: {$ref: '#/$defs/ciJob'}
306
+ build: {$ref: '#/$defs/ciJob'}
307
+ sonar: {$ref: '#/$defs/ciJob'}
308
+ db-types: {$ref: '#/$defs/ciJob'}
290
309
  dependabot:
291
310
  type: object
292
311
  additionalProperties: false
@@ -300,6 +319,11 @@ $defs:
300
319
  properties:
301
320
  name: {$ref: '#/$defs/ciName'}
302
321
  purpose: {type: string}
322
+ ciJob:
323
+ type: object
324
+ additionalProperties: false
325
+ properties:
326
+ timeoutMinutes: {type: integer, minimum: 5, maximum: 360}
303
327
  ciSecret:
304
328
  anyOf:
305
329
  - type: object
@@ -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": "93fd9007c93fe7430043d8b6682fd46dd0e1d5f9f9b7094fc94ffad2ccdcceeb",
4
+ "hash": "7cf3ce3c9e0abb27fa139c93abbbf15a2f8cc8aec47ba95f022d946b53d8998f",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -177,7 +177,7 @@ export const CATALOG = {
177
177
  "runtimeSide": "host"
178
178
  },
179
179
  "ci": {
180
- "summary": "set declared GitHub CI secrets and variables and verify their custody",
180
+ "summary": "set declared GitHub CI secrets and variables, verify their custody, or run the managed CI locally",
181
181
  "owner": "@starci/hfs",
182
182
  "positional": [
183
183
  {
@@ -185,7 +185,8 @@ export const CATALOG = {
185
185
  "enum": [
186
186
  "secret",
187
187
  "var",
188
- "verify"
188
+ "verify",
189
+ "local"
189
190
  ],
190
191
  "required": true,
191
192
  "variadic": false
@@ -234,12 +235,32 @@ export const CATALOG = {
234
235
  {
235
236
  "name": "json",
236
237
  "type": "boolean",
237
- "summary": "print verification findings as JSON"
238
+ "summary": "print verification findings (or the local plan and results) as JSON"
239
+ },
240
+ {
241
+ "name": "jobs",
242
+ "type": "string",
243
+ "summary": "local: the jobs to run, comma separated (default fast,lint,test,coverage,build; sonar and db-types only when named)"
244
+ },
245
+ {
246
+ "name": "parallel",
247
+ "type": "string",
248
+ "summary": "local: job instances run at once (default half the CPUs, at most 4)"
249
+ },
250
+ {
251
+ "name": "logs",
252
+ "type": "string",
253
+ "summary": "local: the directory the job logs go to (default a new temp directory)"
254
+ },
255
+ {
256
+ "name": "dry-run",
257
+ "type": "boolean",
258
+ "summary": "local: print which steps would run and which only GitHub can run"
238
259
  }
239
260
  ],
240
261
  "exit": {
241
- "0": "action completed or verification clean",
242
- "1": "verification findings",
262
+ "0": "action completed, verification clean or every local job green",
263
+ "1": "verification findings or a local job red",
243
264
  "2": "refusal or bad usage"
244
265
  },
245
266
  "json": "flag",
@@ -250,13 +271,71 @@ export const CATALOG = {
250
271
  "starci app ci var set SONAR_HOST_URL",
251
272
  "starci app ci var sync",
252
273
  "starci app ci var sync --execute",
253
- "starci app ci verify --json"
274
+ "starci app ci verify --json",
275
+ "starci app ci local --dry-run",
276
+ "starci app ci local --jobs lint,test --parallel 4"
277
+ ],
278
+ "editions": [
279
+ "full",
280
+ "lite"
281
+ ],
282
+ "impl": null,
283
+ "conventions": [
284
+ "local reads .github/workflows/ci.yml and runs every job instance (matrix values substituted) on this machine, in parallel, a job after its needs",
285
+ "it skips what only GitHub runs (uses: actions, npm ci, the lint baseline, a step whose if: needs a Sonar token, a tag ref or step outputs) and lists it",
286
+ "the unit shards write be/coverage/shards/shard-<i>, which the coverage job merges; each instance logs to a temp directory"
287
+ ],
288
+ "runtimeSide": "host"
289
+ },
290
+ "coverage": {
291
+ "summary": "merge the lcov reports of the sharded unit run into one report",
292
+ "owner": "@starci/hfs",
293
+ "positional": [
294
+ {
295
+ "name": "action",
296
+ "enum": [
297
+ "merge"
298
+ ],
299
+ "required": true,
300
+ "variadic": false
301
+ },
302
+ {
303
+ "name": "paths",
304
+ "required": true,
305
+ "variadic": true
306
+ }
307
+ ],
308
+ "flags": [
309
+ {
310
+ "name": "out",
311
+ "type": "string",
312
+ "summary": "the merged lcov to write (the managed CI writes be/coverage/lcov.info)"
313
+ },
314
+ {
315
+ "name": "cwd",
316
+ "type": "string",
317
+ "summary": "the app root (default the current directory)"
318
+ }
319
+ ],
320
+ "exit": {
321
+ "0": "merged",
322
+ "2": "bad usage or no report found"
323
+ },
324
+ "json": "none",
325
+ "examples": [
326
+ "starci app coverage merge --out be/coverage/lcov.info be/coverage/shards"
254
327
  ],
255
328
  "editions": [
256
329
  "full",
257
330
  "lite"
258
331
  ],
259
332
  "impl": null,
333
+ "conventions": [
334
+ "the CI standard shards the unit run (npm test -- --shard=i/N); each shard writes an lcov over the whole coverage scope",
335
+ "merge sums every line, function and branch hit by identity across the reports and writes one record per source file",
336
+ "a path is an lcov file or a directory searched for every lcov.info below it; no report found refuses (exit 2), never an empty report",
337
+ "the coverage proof (starci app check --coverage-from), the Codecov upload and the Sonar import read the merged report"
338
+ ],
260
339
  "runtimeSide": "host"
261
340
  },
262
341
  "db": {
@@ -618,8 +697,8 @@ export const CATALOG = {
618
697
  ],
619
698
  "exit": {
620
699
  "0": "gate green or push done or nothing to do",
621
- "1": "a gate is red or the push was refused by the hook",
622
- "2": "refusal or bad usage including RELEASE_CI_RED and RELEASE_SONAR_UNAVAILABLE"
700
+ "1": "a gate is red, the candidate CI of the sha is not green, or the push was refused by the hook",
701
+ "2": "refusal or bad usage including RELEASE_SONAR_UNAVAILABLE"
623
702
  },
624
703
  "json": "flag",
625
704
  "examples": [
@@ -641,10 +720,11 @@ export const CATALOG = {
641
720
  "declared release.gates gets audit appended if missing",
642
721
  "--sonar appends @sonar/scan@4.3.0 against services.sonar.host (host.public for a host mapping), plus organization for sonarcloud;",
643
722
  "disabled or unavailable Sonar or an unset token refuses RELEASE_SONAR_UNAVAILABLE",
644
- "gate checks the newest completed ci.yml run on the default branch, including reachable push/tag runs;",
645
- "failure, cancelled or timed_out refuses RELEASE_CI_RED with its URL and SHA;",
646
- "unreadable GitHub or no run warns RELEASE_CI_UNKNOWN and continues",
647
- "only the owner's starci release ci-override --sha <sha> --reason <text> record can override red CI; there is no --no-ci-check flag",
723
+ "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",
724
+ "push --execute sends HEAD to refs/heads/release/candidate-<version> first and waits for its ci.yml run (it triggers on release/candidate-*)",
725
+ "green moves the branch and the tag in one atomic push; red refuses RELEASE_CANDIDATE_CI_RED naming the failing jobs",
726
+ "no run, or no end in time, refuses RELEASE_CANDIDATE_CI_TIMEOUT; unreadable GitHub refuses RELEASE_CI_UNKNOWN; nothing reaches the branch",
727
+ "only the owner's starci release ci-override --sha <sha> --reason <text> record lets a red candidate through; there is no --no-ci-check flag",
648
728
  "gate refuses a dirty tree, HEAD other than --source, a moved HEAD and a tree dirtied by the gates; record and tag only when all gates are green",
649
729
  "a gate with summary jest is red on failed tests and on a coverage threshold miss even when the process exits 0",
650
730
  "push needs the annotated tag and L4 record of HEAD, runs git push --dry-run --atomic first and never uses --no-verify"
@@ -7723,6 +7803,11 @@ export const CATALOG = {
7723
7803
  "name": "keep",
7724
7804
  "type": "boolean",
7725
7805
  "summary": "keep and print the temporary application path"
7806
+ },
7807
+ {
7808
+ "name": "source",
7809
+ "type": "boolean",
7810
+ "summary": "scaffold with this checkout and install the @starci scope from its packed tarballs (the pre-publish form release scaffold-e2e runs)"
7726
7811
  }
7727
7812
  ],
7728
7813
  "exit": {
@@ -7733,7 +7818,8 @@ export const CATALOG = {
7733
7818
  "json": "none",
7734
7819
  "examples": [
7735
7820
  "starci release app-installs",
7736
- "starci release app-installs --keep"
7821
+ "starci release app-installs --keep",
7822
+ "starci release app-installs --source"
7737
7823
  ],
7738
7824
  "editions": [
7739
7825
  "full"
@@ -7778,7 +7864,7 @@ export const CATALOG = {
7778
7864
  "runtimeSide": "tree"
7779
7865
  },
7780
7866
  "ci-override": {
7781
- "summary": "the owner accepts red CI for one SHA and records who and why",
7867
+ "summary": "the owner accepts a red candidate CI for one SHA (emergency path) and records who and why",
7782
7868
  "owner": "runtime",
7783
7869
  "flags": [
7784
7870
  {
@@ -7813,14 +7899,17 @@ export const CATALOG = {
7813
7899
  "script": "scripts/supervisor/release-ci-override-cli.mjs"
7814
7900
  },
7815
7901
  "conventions": [
7816
- "OWNER RULING 2026-10-17 (chat to main, relayed): alpha.20 \"cut refuses when the latest CI on main is red, with a documented owner override verb\";",
7817
- "the override is owner-only, records who and why in the owner rulings, and agents cannot run it.",
7902
+ "OWNER RULING candidate-ci (2026-10-18): a release reaches main only when the candidate CI of its own sha is green;",
7903
+ "this verb is the one documented EMERGENCY path for a red candidate.",
7904
+ "The override is owner-only, records who and why, and agents cannot run it (OWNER RULING alpha20-ci-override-owner-only).",
7818
7905
  "Every bound role is refused by the command guard and by this script. The owner must use a terminal and type the first 12 SHA characters exactly.",
7819
7906
  "Writes <git-common-dir>/starci-release/<sha>.ci-override.json, schema starci/ci-override@1,",
7820
7907
  "with sha, reason, by (git user.name and OS user), at, and runUrl (empty if GitHub is unavailable).",
7821
- "For the runtime, appends ci-override-<sha12> under rulings before the top-level supersedes key in modules/kernel/owner-rulings.yaml.",
7822
- "Commit that entry into the tree being cut. App repositories receive only the record.",
7823
- "An override applies only to its exact SHA. It does not permit a cut when CI is unknown. Existing ruling IDs with another reason refuse."
7908
+ "release cut and app release push read that record for the exact candidate sha only; the release record logs its use.",
7909
+ "For the runtime it also appends ci-override-<sha12> under rulings in modules/kernel/owner-rulings.yaml as the audit trail.",
7910
+ "App repositories receive only the record.",
7911
+ "An override applies only to its exact SHA. It never permits a release whose CI is unknown or timed out.",
7912
+ "Existing ruling IDs with another reason refuse."
7824
7913
  ],
7825
7914
  "runtimeSide": "tree"
7826
7915
  },
@@ -7942,8 +8031,8 @@ export const CATALOG = {
7942
8031
  }
7943
8032
  ],
7944
8033
  "exit": {
7945
- "0": "the release was cut and main plus its tag were pushed atomically, or --plan found nothing missing",
7946
- "1": "refused (RELEASE_CI_RED, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, suite red, main moved, secret scan) and nothing was pushed",
8034
+ "0": "the candidate CI of the release sha was green, then main plus its tag were pushed atomically, or --plan found nothing missing",
8035
+ "1": "refused (RELEASE_CANDIDATE_CI_RED, RELEASE_CANDIDATE_CI_TIMEOUT, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, suite red, main moved, secret scan) and nothing was pushed",
7947
8036
  "2": "bad usage"
7948
8037
  },
7949
8038
  "json": "flag",
@@ -7960,14 +8049,18 @@ export const CATALOG = {
7960
8049
  "script": "scripts/supervisor/release-cut-cli.mjs"
7961
8050
  },
7962
8051
  "conventions": [
7963
- "OWNER RULING 2026-10-17 (chat to main, relayed): alpha.20 \"cut refuses when the latest CI on main is red, with a documented owner override verb\";",
7964
- "the override is owner-only, records who and why in the owner rulings, and agents cannot run it.",
7965
- "Before the suite, including --plan, read the newest completed CI workflow run on main.",
7966
- "Only success continues; every other conclusion refuses RELEASE_CI_RED and names the run URL and headSha.",
7967
- "Unreadable GitHub or no completed run refuses RELEASE_CI_UNKNOWN: a cut must not proceed blind, including under suite ci.",
7968
- "The owner may run starci release ci-override --sha <headSha> --reason <text>.",
7969
- "A red run requires both starci-release/<headSha>.ci-override.json in the git common dir and ci-override-<sha12>",
7970
- "in modules/kernel/owner-rulings.yaml of the tree being cut. Commit the ruling before cutting."
8052
+ "OWNER RULING candidate-ci (2026-10-18): never again have main receive a release whose CI is red, with the owner confirming afterwards.",
8053
+ "Before the suite, including --plan, the workflows the candidate CI waits for (release-cut.yaml candidate.workflows) must be readable,",
8054
+ "else RELEASE_CI_UNKNOWN. The latest run on main is no longer read: a red main needs no owner override before the next release.",
8055
+ "After the suite and the secret scan the exact HEAD is pushed to refs/heads/release/candidate-<version>;",
8056
+ "a re-cut of the same version force-moves only that ref, never main.",
8057
+ "The cut waits for every required workflow run of that sha (pollSeconds, graceMinutes, waitMinutes in release-cut.yaml).",
8058
+ "Red refuses RELEASE_CANDIDATE_CI_RED with the run URLs and the failing jobs and steps; nothing reaches main.",
8059
+ "No run within the grace, or no end within the wait, refuses RELEASE_CANDIDATE_CI_TIMEOUT.",
8060
+ "A red candidate keeps its ref for diagnosis; after a cut the candidate ref is left on the remote, naming the released sha.",
8061
+ "Only on green is the annotated tag created and main plus the tag pushed atomically; the release record logs the candidate verdict.",
8062
+ "Emergency path, owner-only: starci release ci-override --sha <HEAD> --reason <text> lets a red candidate through.",
8063
+ "The release record logs its use."
7971
8064
  ],
7972
8065
  "runtimeSide": "tree"
7973
8066
  },
@@ -0,0 +1,134 @@
1
+ // lcov-merge.mjs - the lcov reports of the sharded unit run merged into one (`starci app coverage merge`, the coverage job of the managed CI). Each shard of
2
+ // `npm test -- --shard=i/N` writes its own lcov over the whole collectCoverageFrom scope, so the merge sums every line, function and branch hit by its
3
+ // identity across the shards and writes ONE record per source file, with the summaries recomputed.
4
+ // A record of zeros (no line, function or branch hit) is the placeholder a shard writes for a file none of its tests loaded: jest instruments it on its own
5
+ // (babel-istanbul), so its function names and branch ids are not the ones of the shard that ran it (v8), and summing them would add phantom uncovered
6
+ // entries. Such a placeholder is dropped whenever another report holds a record of the file with a hit; a file that is a placeholder everywhere keeps one,
7
+ // so it stays uncovered and the proof refuses it. Branch ids are not stable either: the v8 provider numbers a file's blocks in the order it met them, and a
8
+ // shard that ran fewer functions numbers them differently. A branch is therefore keyed by its line and its ordinal among the blocks of that line (BRDA
9
+ // <line>,<ordinal>,<branch>), and a record that loaded the file without calling any of its functions brings its lines and functions but not its branches
10
+ // when another report called them. The coverage proof (`starci app check --coverage-from`), the Codecov upload and the Sonar import then read
11
+ // the one be/coverage/lcov.info they always read. Pure over the texts; the file walk is lcovFilesUnder.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { byCodeUnit } from '../lib/list.mjs';
15
+ import { addHits as add } from './rules/coverage-below.mjs';
16
+
17
+ const BOM = String.fromCodePoint(0xfeff);
18
+ const count = (value) => (/^\d+$/.test(value) ? Number(value) : 0);
19
+
20
+ function recordOf(records, source) {
21
+ if (!records.has(source)) records.set(source, { lines: new Map(), fnLines: new Map(), fnHits: new Map(), branches: new Map(), rawBranches: [] });
22
+ return records.get(source);
23
+ }
24
+
25
+ /** Function data: FN:<line>[,<end>],<name> declares, FNDA:<hits>,<name> counts (a name may hold commas). */
26
+ function functionLine(record, tag, value) {
27
+ const at = value.indexOf(',');
28
+ if (at < 0) return;
29
+ if (tag === 'FN') {
30
+ let name = value.slice(at + 1);
31
+ let line = value.slice(0, at);
32
+ if (/^\d+,/.test(name)) { line = `${line},${name.slice(0, name.indexOf(','))}`; name = name.slice(name.indexOf(',') + 1); }
33
+ if (!record.fnLines.has(name)) record.fnLines.set(name, line);
34
+ if (!record.fnHits.has(name)) record.fnHits.set(name, 0);
35
+ } else add(record.fnHits, value.slice(at + 1), count(value.slice(0, at)));
36
+ }
37
+
38
+ function dataLine(record, tag, value) {
39
+ if (tag === 'DA') {
40
+ const [line, hits] = value.split(',');
41
+ add(record.lines, Number(line), count(hits ?? ''));
42
+ } else if (tag === 'FN' || tag === 'FNDA') functionLine(record, tag, value);
43
+ else if (tag === 'BRDA') {
44
+ const parts = value.split(',');
45
+ if (parts.length === 4) record.rawBranches.push({ line: Number(parts[0]), block: Number(parts[1]), branch: Number(parts[2]), hits: parts[3] === '-' ? 0 : count(parts[3]) });
46
+ }
47
+ }
48
+
49
+ /** The branches of a record keyed by line and the ordinal of their block on that line: stable across shards that numbered blocks differently. */
50
+ function keyBranches(record) {
51
+ const blocksByLine = new Map();
52
+ for (const entry of [...record.rawBranches].sort((a, b) => a.block - b.block || a.branch - b.branch)) {
53
+ const blocks = blocksByLine.get(entry.line) ?? [];
54
+ if (!blocks.includes(entry.block)) blocks.push(entry.block);
55
+ blocksByLine.set(entry.line, blocks);
56
+ add(record.branches, `${entry.line},${blocks.indexOf(entry.block)},${entry.branch}`, entry.hits);
57
+ }
58
+ return record;
59
+ }
60
+
61
+ /** The records of one text by source file (a source repeated inside one text merges into one record). */
62
+ function recordsOfText(text) {
63
+ const records = new Map();
64
+ let current = null;
65
+ const body = String(text).startsWith(BOM) ? String(text).slice(1) : String(text);
66
+ for (const raw of body.split(/\r?\n/)) {
67
+ const line = raw.trim();
68
+ if (line === 'end_of_record') { current = null; continue; }
69
+ const colon = line.indexOf(':');
70
+ if (colon < 0) continue;
71
+ const tag = line.slice(0, colon);
72
+ const value = line.slice(colon + 1);
73
+ if (tag === 'SF') current = recordOf(records, value);
74
+ else if (current) dataLine(current, tag, value);
75
+ }
76
+ for (const record of records.values()) keyBranches(record);
77
+ return records;
78
+ }
79
+
80
+ const anyHit = (map) => [...map.values()].some((hits) => hits > 0);
81
+ /** A record no test touched: no line, function or branch hit (the placeholder of a shard that never loaded the file). */
82
+ const placeholder = (record) => !anyHit(record.lines) && !anyHit(record.fnHits) && !anyHit(record.branches);
83
+
84
+ function mergeInto(target, record, { branches = true } = {}) {
85
+ for (const [line, hits] of record.lines) add(target.lines, line, hits);
86
+ for (const [name, line] of record.fnLines) if (!target.fnLines.has(name)) target.fnLines.set(name, line);
87
+ for (const [name, hits] of record.fnHits) add(target.fnHits, name, hits);
88
+ if (branches) for (const [branch, hits] of record.branches) add(target.branches, branch, hits);
89
+ }
90
+
91
+ /** Every record of every text merged by source file (SF) and identity; a placeholder counts only when no report touched the file. */
92
+ function parseAll(texts) {
93
+ const bySource = new Map();
94
+ for (const text of texts) for (const [source, record] of recordsOfText(text)) bySource.set(source, [...(bySource.get(source) ?? []), record]);
95
+ const records = new Map();
96
+ for (const [source, list] of bySource) {
97
+ const touched = list.filter((record) => !placeholder(record));
98
+ const target = recordOf(records, source);
99
+ const called = touched.some((record) => anyHit(record.fnHits));
100
+ // A record that loaded the file but called none of its functions keeps only its lines and functions when another record called them.
101
+ for (const record of touched.length ? touched : list.slice(0, 1)) mergeInto(target, record, { branches: !called || anyHit(record.fnHits) });
102
+ }
103
+ return records;
104
+ }
105
+
106
+ const branchOrder = (a, b) => a.split(',').map(Number).reduce((order, part, index) => order || part - Number(b.split(',')[index]), 0);
107
+
108
+ function serialize(source, record) {
109
+ const out = ['TN:', `SF:${source}`];
110
+ const functions = [...record.fnLines.keys()].sort((a, b) => Number(record.fnLines.get(a).split(',')[0]) - Number(record.fnLines.get(b).split(',')[0]) || byCodeUnit(a, b));
111
+ for (const name of functions) out.push(`FN:${record.fnLines.get(name)},${name}`);
112
+ for (const name of functions) out.push(`FNDA:${record.fnHits.get(name) ?? 0},${name}`);
113
+ out.push(`FNF:${functions.length}`, `FNH:${functions.filter((name) => (record.fnHits.get(name) ?? 0) > 0).length}`);
114
+ const lines = [...record.lines.keys()].sort((a, b) => a - b);
115
+ for (const line of lines) out.push(`DA:${line},${record.lines.get(line)}`);
116
+ out.push(`LF:${lines.length}`, `LH:${lines.filter((line) => record.lines.get(line) > 0).length}`);
117
+ const branches = [...record.branches.keys()].sort(branchOrder);
118
+ for (const branch of branches) out.push(`BRDA:${branch},${record.branches.get(branch)}`);
119
+ out.push(`BRF:${branches.length}`, `BRH:${branches.filter((branch) => record.branches.get(branch) > 0).length}`, 'end_of_record');
120
+ return out.join('\n');
121
+ }
122
+
123
+ /** One lcov text of the merged `texts`: a record per source file, sorted by path, hits summed by line, function name and branch identity. */
124
+ export function mergeLcov(texts) {
125
+ const records = parseAll(texts);
126
+ return [...records.keys()].sort(byCodeUnit).map((source) => serialize(source, records.get(source))).join('\n') + (records.size ? '\n' : '');
127
+ }
128
+
129
+ /** The lcov files a path names: the file itself, or every `lcov.info` below a directory (sorted). */
130
+ export function lcovFilesUnder(target) {
131
+ if (!fs.statSync(target).isDirectory()) return [target];
132
+ return fs.readdirSync(target, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile() && entry.name === 'lcov.info')
133
+ .map((entry) => path.join(entry.parentPath ?? entry.path, entry.name)).sort(byCodeUnit);
134
+ }
@@ -34,6 +34,7 @@ import { stackToolingFindings } from './stack-tooling.mjs';
34
34
  import { stacksDeployFindings } from './stacks-deploy.mjs';
35
35
  import { ciCustodyFindings } from './ci-custody.mjs';
36
36
  import { workflowContextsFindings } from './workflow-contexts.mjs';
37
+ import { ciStandardAppFindings } from './ci-standard.mjs';
37
38
  import { feBuildArgsFindings } from './fe-build-args.mjs';
38
39
  // finders-import-end
39
40
 
@@ -42,6 +43,7 @@ export const A15_CODES = [
42
43
  // codes-begin
43
44
  'FE_PUBLIC_BUILD_ARG_MISSING',
44
45
  'HFS_WORKFLOW_CONTEXT',
46
+ 'CI_STANDARD',
45
47
  'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
46
48
  'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
47
49
  // codes-end
@@ -51,6 +53,7 @@ const FINDERS = [
51
53
  // finders-begin
52
54
  feBuildArgsFindings,
53
55
  workflowContextsFindings,
56
+ ciStandardAppFindings,
54
57
  featureFileFindings,
55
58
  lexiconFindings,
56
59
  assetsFindings,