@starci/hfs 4.7.1 → 4.7.2

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.2 - 2026-10-18
4
+ - Fixed: `app coverage merge` merges the shard istanbul maps through the installed @starci/jest-preset/merge (4.7.1 merged lcov text and reported phantom gaps on real data); the managed unit step sets STARCI_COVERAGE_SHARD and uploads coverage-final.json; `app lint` accepts a canon inside the app's linked node_modules.
5
+
3
6
  ## 4.7.1 - 2026-10-18
4
7
  - Fixed: the package files list carries coverage/ (4.7.0 shipped `app coverage merge` without coverage/cli.mjs); the lint upload condition of the managed CI names the per-side report with format() instead of a nested ${{ }} that actionlint reads as always true.
5
8
 
package/coverage/cli.mjs CHANGED
@@ -1,12 +1,12 @@
1
- // cli.mjs - `starci app coverage merge --out <lcov> <path>...`: the lcov reports of the sharded unit run (one per `npm test -- --shard=i/N`, downloaded into a
2
- // directory by the coverage job of the managed CI) merged into the one report the coverage proof, the Codecov upload and the Sonar import read
3
- // (runtime/scripts/hfs/lcov-merge.mjs: hits summed by line, function and branch identity, one record per source file). A path is an lcov file or a
4
- // directory searched for every lcov.info below it; no report found is a refusal, so a merge never writes an empty report that would read as "nothing to measure".
5
- import fs from 'node:fs';
1
+ // cli.mjs - `starci app coverage merge --out <lcov> <report-or-dir>...`: the coverage of the sharded unit run (one coverage-final.json per
2
+ // `STARCI_COVERAGE_SHARD=1 npm test -- --shard=i/N`, downloaded into a directory by the coverage job of the managed CI) merged into the one lcov the
3
+ // coverage proof, the Codecov upload and the Sonar import read. The merge is the app's installed @starci/jest-preset (`@starci/jest-preset/merge`):
4
+ // istanbul-lib-coverage merges the shard maps by source location, and the preset's own lcov reporter writes the result exactly as an unsharded run
5
+ // would. A path is a coverage-final.json or a directory searched for every one below it; no report found refuses (exit 2), never an empty report.
6
6
  import path from 'node:path';
7
- import { lcovFilesUnder, mergeLcov } from '../runtime/scripts/hfs/lcov-merge.mjs';
7
+ import { createRequire } from 'node:module';
8
8
 
9
- const USAGE = 'usage: starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]';
9
+ const USAGE = 'usage: starci app coverage merge --out <lcov> <coverage-final.json-or-dir>... [--cwd <dir>]';
10
10
 
11
11
  function parse(argv) {
12
12
  const [action, ...rest] = argv;
@@ -23,27 +23,30 @@ function parse(argv) {
23
23
  else opts.inputs.push(arg);
24
24
  }
25
25
  if (!opts.out || !opts.inputs.length) throw new Error(USAGE);
26
+ if (path.basename(opts.out) !== 'lcov.info') throw new Error(`--out names the lcov.info the coverage proof reads; ${USAGE}`);
26
27
  return opts;
27
28
  }
28
29
 
29
- /** Exit 0 merged, 2 bad usage or no report found. */
30
- export async function coverageMain(argv, { cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s) } = {}) {
30
+ /** The app's installed preset merge (resolved from the app root, like the presets `starci app sync` loads). */
31
+ function installedMerge(cwd) {
32
+ try { return createRequire(path.join(cwd, 'package.json'))('@starci/jest-preset/merge').mergeCoverage; } catch (error) {
33
+ throw new Error(`@starci/jest-preset/merge is not installed under ${cwd} (${error.code ?? error.message}); install the canon @starci/jest-preset`);
34
+ }
35
+ }
36
+
37
+ /** Exit 0 merged, 2 bad usage, no preset or no report found. `merge` replaces the installed preset merge in a spec. */
38
+ export async function coverageMain(argv, { cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), merge } = {}) {
31
39
  let opts;
32
40
  try { opts = parse(argv); } catch (error) { stderr(`starci app coverage: ${error.message}\n`); return 2; }
33
41
  const cwd = path.resolve(baseCwd, opts.cwd ?? '.');
34
- const files = [];
35
- for (const input of opts.inputs) {
36
- const target = path.resolve(cwd, input);
37
- if (!fs.existsSync(target)) { stderr(`starci app coverage: ${input} does not exist\n`); return 2; }
38
- files.push(...lcovFilesUnder(target));
42
+ try {
43
+ const run = merge ?? installedMerge(cwd);
44
+ const outDir = path.dirname(path.resolve(cwd, opts.out));
45
+ const result = run({ inputs: opts.inputs.map((input) => path.resolve(cwd, input)), outDir });
46
+ stdout(`starci app coverage merge: ${result.reports} shard report(s), ${result.files} source file(s) -> ${path.relative(cwd, path.join(outDir, 'lcov.info')).replaceAll('\\', '/')}\n`);
47
+ return 0;
48
+ } catch (error) {
49
+ stderr(`starci app coverage: ${error.message}\n`);
50
+ return 2;
39
51
  }
40
- const out = path.resolve(cwd, opts.out);
41
- const sources = files.filter((file) => path.resolve(file) !== out);
42
- if (!sources.length) { stderr(`starci app coverage: no lcov report under ${opts.inputs.join(', ')}; the unit shards wrote none\n`); return 2; }
43
- const merged = mergeLcov(sources.map((file) => fs.readFileSync(file, 'utf8')));
44
- fs.mkdirSync(path.dirname(out), { recursive: true });
45
- fs.writeFileSync(out, merged);
46
- const records = merged.split('\n').filter((line) => line.startsWith('SF:')).length;
47
- stdout(`starci app coverage merge: ${sources.length} report(s), ${records} source file(s) -> ${path.relative(cwd, out).replaceAll('\\', '/')}\n`);
48
- return 0;
49
52
  }
package/lint/run.mjs CHANGED
@@ -140,7 +140,10 @@ const inside = (root, file) => { const rel = path.relative(root, file); return r
140
140
  /** Read the installed canon's source selectors, then enumerate that scope; unavailable or unsupported selectors cannot prove coverage. */
141
141
  async function sourceFiles({ repoRoot, cwd, side, scope }) {
142
142
  const entry = createRequire(path.join(cwd, 'package.json')).resolve(`@starci/eslint-canon-${side}`);
143
- if (!inside(fs.realpathSync(repoRoot), fs.realpathSync(entry))) throw new Error('the installed canon is outside the app root');
143
+ // The canon must be the app's own install: resolved inside the app root, or inside the app's node_modules when that folder is a link to the install
144
+ // (the runtime's examples lint in a sealed copy whose node_modules links the example's own install, scripts/hfs/example-secrets.mjs).
145
+ const ownInstall = [repoRoot, path.join(repoRoot, 'node_modules')].filter((dir) => fs.existsSync(dir)).map((dir) => fs.realpathSync(dir));
146
+ if (!ownInstall.some((root) => inside(root, fs.realpathSync(entry)))) throw new Error('the installed canon is outside the app root');
144
147
  const canon = await import(pathToFileURL(entry).href);
145
148
  const factory = side === 'be' ? canon.starciBeConfig : canon.starciFeConfig;
146
149
  if (typeof factory !== 'function' || typeof canon.loadHfs !== 'function') throw new Error('the installed canon has no supported config factory');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.1",
3
+ "version": "4.7.2",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.6
32
+ version: 3.4.7
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.6
39
+ version: 8.4.7
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -62,7 +62,7 @@ pins:
62
62
  side: both
63
63
  source: packages/prettier-config/package.json
64
64
  '@starci/jest-preset':
65
- version: 2.3.1
65
+ version: 2.3.2
66
66
  group: starci
67
67
  install: registry
68
68
  side: be
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.1
79
+ version: 1.7.2
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.1
86
+ version: 4.7.2
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -57,7 +57,7 @@ items:
57
57
  check: ci-standard
58
58
  - id: test-sharding
59
59
  title: the unit run in shards, merged once
60
- requires: "the test job is a matrix over shard (ci.jobs.test.shards, default defaults.shards); each shard runs npm test with --shard=<i>/<N> and without the per-file threshold, uploads its lcov, and the coverage job merges them (starci app coverage merge) before the coverage proof proves 100 per file"
60
+ requires: "the test job is a matrix over shard (ci.jobs.test.shards, default defaults.shards); each shard runs npm test with --shard=<i>/<N>, STARCI_COVERAGE_SHARD=1 (the preset measures only the files the shard loads, no all-files instrumentation, no per-file threshold) and uploads its istanbul JSON; the coverage job merges them by source location (starci app coverage merge, @starci/jest-preset/merge) and the coverage proof then requires every measured file to be present in the merge at 100 per file"
61
61
  covers: [app-ci]
62
62
  check: ci-standard
63
63
  - id: lint-parallel
@@ -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": "7cf3ce3c9e0abb27fa139c93abbbf15a2f8cc8aec47ba95f022d946b53d8998f",
4
+ "hash": "1d8f6ce139e98259571df0aee8deccce139214f6aea6fc86ade937ca2dfb445d",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -288,7 +288,7 @@ export const CATALOG = {
288
288
  "runtimeSide": "host"
289
289
  },
290
290
  "coverage": {
291
- "summary": "merge the lcov reports of the sharded unit run into one report",
291
+ "summary": "merge the coverage of the sharded unit run into one lcov report",
292
292
  "owner": "@starci/hfs",
293
293
  "positional": [
294
294
  {
@@ -309,7 +309,7 @@ export const CATALOG = {
309
309
  {
310
310
  "name": "out",
311
311
  "type": "string",
312
- "summary": "the merged lcov to write (the managed CI writes be/coverage/lcov.info)"
312
+ "summary": "the merged lcov.info to write (the managed CI writes be/coverage/lcov.info)"
313
313
  },
314
314
  {
315
315
  "name": "cwd",
@@ -319,7 +319,7 @@ export const CATALOG = {
319
319
  ],
320
320
  "exit": {
321
321
  "0": "merged",
322
- "2": "bad usage or no report found"
322
+ "2": "bad usage, no installed preset or no report found"
323
323
  },
324
324
  "json": "none",
325
325
  "examples": [
@@ -331,10 +331,12 @@ export const CATALOG = {
331
331
  ],
332
332
  "impl": null,
333
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"
334
+ "the CI standard shards the unit run: STARCI_COVERAGE_SHARD=1 npm test -- --shard=i/N",
335
+ "each shard measures only the files it loads and writes istanbul JSON (coverage-final.json)",
336
+ "merge runs the app's installed @starci/jest-preset/merge: istanbul-lib-coverage joins the shard maps by source location",
337
+ "the merged lcov is written by the preset's own lcov reporter, as an unsharded run writes it",
338
+ "a path is a coverage-final.json or a directory searched for every one below it; no report found refuses (exit 2)",
339
+ "the coverage proof (starci app check --coverage-from) refuses a measured file the merge does not hold"
338
340
  ],
339
341
  "runtimeSide": "host"
340
342
  },
@@ -53,6 +53,10 @@ export async function materializeExample(repoRoot, { createIdentity = generateAp
53
53
  function trackExample(repoRoot) {
54
54
  const env = withoutGitLocalEnv(process.env);
55
55
  if (!initGit(repoRoot, { env }).ok) throw new Error('could not initialize the isolated example git tree');
56
+ // The linked node_modules (linkExampleDependencies: a junction on Windows, a symlink elsewhere) is the install, never a tracked entry: git treats a
57
+ // symlink as a file, which the app's `node_modules/` (directory) ignore line does not match, so on Linux `git add -A` tracked it (HFS_TRACKED_MUST_BE_IGNORED,
58
+ // HFS_ROOT_ENTRY_FORBIDDEN in every examples.yml lint since the link). The copy's own exclude file ignores it in any form.
59
+ fs.appendFileSync(path.join(repoRoot, '.git', 'info', 'exclude'), ['', '# the linked install of the example (example-secrets.mjs)', 'node_modules', ''].join('\n'));
56
60
  const staged = addGit(['-A', '--', '.'], { cwd: repoRoot, env });
57
61
  if (staged.error || staged.status !== 0) throw new Error('could not stage the isolated example git tree');
58
62
  }
@@ -75,6 +75,7 @@ function testSharding(doc, say) {
75
75
  const total = /--shard=\$\{\{\s*matrix\.shard\s*\}\}\/(\d+)/.exec(unit ? runOf(unit) : '');
76
76
  if (unit && !total) out.push(say('test-sharding', 'the unit run must pass --shard=${{ matrix.shard }}/<N>'));
77
77
  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`));
78
+ 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'));
78
79
  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'));
79
80
  const coverage = record(doc.jobs?.coverage) ? stepsOf(doc.jobs.coverage) : [];
80
81
  const merge = coverage.findIndex((step) => /app coverage merge\b/.test(runOf(step)));
@@ -18,7 +18,7 @@ const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(va
18
18
  const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
19
19
 
20
20
  const emptyRecord = (source) => ({ source, lines: new Map(), functions: new Map(), branches: new Map(), summary: {}, problems: [] });
21
- export const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
21
+ const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
22
22
  const parseProblem = (record, line, tag) => record.problems.push(`invalid ${tag} at lcov line ${line}`);
23
23
 
24
24
  function lineData(record, value, line) {
@@ -234,6 +234,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
234
234
  return {
235
235
  ...settings,
236
236
  lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
237
+ // The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
238
+ shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
237
239
  ciBuildEnv: build.env,
238
240
  ciBuildSteps: build.steps,
239
241
  ciLint: exampleCommand(source, 'npm run lint'),
@@ -54,16 +54,18 @@ jobs:
54
54
  steps:
55
55
  {{> app/ci-workflows/install-steps.yml}}
56
56
  {{> app/ci-workflows/jest-cache.yml}}
57
- # Each shard measures without the per-file threshold (a shard loads only its share of the specs, and a small suite may leave a shard empty);
58
- # the coverage job proves 100 per file on the merged report.
57
+ # A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
58
+ # JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. A small suite may leave a shard empty.
59
59
  - name: unit
60
60
  run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
61
+ env:
62
+ STARCI_COVERAGE_SHARD: "1"
61
63
  {{> app/ci-workflows/jest-cache-save.yml}}
62
64
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
63
- if: always() && hashFiles('{{lcovReport}}') != ''
65
+ if: always() && hashFiles('{{shardCoverageReport}}') != ''
64
66
  with:
65
67
  name: coverage-shard-${{ matrix.shard }}
66
- path: {{lcovReport}}
68
+ path: {{shardCoverageReport}}
67
69
 
68
70
  coverage:
69
71
  needs: test
@@ -54,16 +54,18 @@ jobs:
54
54
  steps:
55
55
  {{> app/ci-workflows/install-steps.yml}}
56
56
  {{> app/ci-workflows/jest-cache.yml}}
57
- # Each shard measures without the per-file threshold (a shard loads only its share of the specs, and a small suite may leave a shard empty);
58
- # the coverage job proves 100 per file on the merged report.
57
+ # A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
58
+ # JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. A small suite may leave a shard empty.
59
59
  - name: unit
60
60
  run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
61
+ env:
62
+ STARCI_COVERAGE_SHARD: "1"
61
63
  {{> app/ci-workflows/jest-cache-save.yml}}
62
64
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
63
- if: always() && hashFiles('{{lcovReport}}') != ''
65
+ if: always() && hashFiles('{{shardCoverageReport}}') != ''
64
66
  with:
65
67
  name: coverage-shard-${{ matrix.shard }}
66
- path: {{lcovReport}}
68
+ path: {{shardCoverageReport}}
67
69
 
68
70
  coverage:
69
71
  needs: test
@@ -1,134 +0,0 @@
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
- }