@starci/hfs 4.7.0 → 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,11 @@
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
+
6
+ ## 4.7.1 - 2026-10-18
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.
8
+
3
9
  ## 4.7.0 - 2026-10-18
4
10
  - Added: `app release push --execute` pushes the release sha to `release/candidate-<version>` and moves the branch and the tag only when its ci.yml run is green (RELEASE_CANDIDATE_CI_RED / _TIMEOUT, RELEASE_CI_UNKNOWN; the owner's sha-bound override record is the emergency path); the managed ci.yml and pre-push hook carry the candidate ref; R301 CI_STANDARD in the app check.
5
11
  - Added: the managed CI shards the unit run (`ci.jobs.test.shards`, default 6), lints each side in its own job and merges coverage in a coverage job (`starci app coverage merge`); `ci.jobs.<job>.timeoutMinutes`; jest/build caches keyed by the lockfile and saved always; `starci app ci local` runs the rendered jobs locally in parallel.
package/ci/local.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // - the unit shards write their lcov to be/coverage/shards/shard-<i> (on GitHub each shard has its own runner and uploads it), which is where the
8
8
  // coverage job's `starci app coverage merge` reads them;
9
9
  // - sonar and db-types run only when --jobs names them.
10
- // Each instance logs to <tmp>/starci-ci-local-*/<job>[-<matrix>].log. Exit 0 every instance green, 1 one red, 2 bad usage or no ci.yml. Run steps through bash.
10
+ // Each instance logs to <job>[-<matrix>].log in --logs <dir>, else a new <tmp>/starci-ci-local-* directory. Exit 0 every instance green, 1 one red, 2 bad usage or no ci.yml. Run steps through bash.
11
11
  import fs from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
@@ -19,15 +19,16 @@ const GITHUB_ONLY_IF = /SONAR_TOKEN\s*!=\s*''|refs\/tags|startsWith\(github\.ref
19
19
  const LOCAL_IF = /^(?:\$\{\{\s*)?(?:always\(\)|!cancelled\(\)|env\.SONAR_TOKEN\s*==\s*'')(?:\s*\}\})?$/;
20
20
 
21
21
  function parseArgs(argv) {
22
- const opts = { jobs: null, parallel: Math.max(1, Math.min(4, Math.floor(os.cpus().length / 2))), dryRun: false, json: false };
22
+ const opts = { logs: null, jobs: null, parallel: Math.max(1, Math.min(4, Math.floor(os.cpus().length / 2))), dryRun: false, json: false };
23
23
  for (let index = 0; index < argv.length; index += 1) {
24
24
  const arg = argv[index];
25
25
  if (arg === '--dry-run') opts.dryRun = true;
26
26
  else if (arg === '--json') opts.json = true;
27
- else if (arg === '--jobs' || arg === '--parallel') {
27
+ else if (arg === '--jobs' || arg === '--parallel' || arg === '--logs') {
28
28
  const value = argv[index + 1];
29
29
  if (!value || value.startsWith('--')) throw new Error(`${arg} needs a value`);
30
- if (arg === '--jobs') opts.jobs = value.split(',').map((job) => job.trim()).filter(Boolean);
30
+ if (arg === '--logs') opts.logs = value;
31
+ else if (arg === '--jobs') opts.jobs = value.split(',').map((job) => job.trim()).filter(Boolean);
31
32
  else if (!/^[1-9]\d*$/.test(value)) throw new Error('--parallel takes a positive integer');
32
33
  else opts.parallel = Number(value);
33
34
  index += 1;
@@ -124,7 +125,7 @@ async function runPlan(plan, { parallel, root, env, logs, report }) {
124
125
  /** `starci app ci local`. */
125
126
  export async function ciLocalMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), env = process.env } = {}) {
126
127
  let opts;
127
- try { opts = parseArgs(argv); } catch (error) { stderr(`starci app ci local: ${error.message}; usage: starci app ci local [--jobs <id,...>] [--parallel <n>] [--dry-run] [--json]\n`); return 2; }
128
+ try { opts = parseArgs(argv); } catch (error) { stderr(`starci app ci local: ${error.message}; usage: starci app ci local [--jobs <id,...>] [--parallel <n>] [--logs <dir>] [--dry-run] [--json]\n`); return 2; }
128
129
  const file = path.join(cwd, '.github', 'workflows', 'ci.yml');
129
130
  if (!fs.existsSync(file)) { stderr('starci app ci local: no .github/workflows/ci.yml (run starci app sync --write)\n'); return 2; }
130
131
  const plan = localPlan(parseYaml(fs.readFileSync(file, 'utf8')), opts.jobs);
@@ -134,7 +135,8 @@ export async function ciLocalMain(argv, { cwd = process.cwd(), stdout = (s) => p
134
135
  else for (const instance of plan) stdout(`${instance.label}${instance.needs.length ? ` (after ${instance.needs.join(', ')})` : ''}\n${instance.steps.map((step) => ` ${step.run !== undefined ? 'run ' : 'skip'} ${step.name}${step.skip ? ` - ${step.skip}` : ''}`).join('\n')}\n`);
135
136
  return 0;
136
137
  }
137
- const logs = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-ci-local-'));
138
+ const logs = opts.logs ? path.resolve(cwd, opts.logs) : fs.mkdtempSync(path.join(os.tmpdir(), 'starci-ci-local-'));
139
+ fs.mkdirSync(logs, { recursive: true });
138
140
  const defaultBranch = 'main';
139
141
  const runEnv = { ...env, CI: 'true', STARCI_BASE_REF: env.STARCI_BASE_REF ?? `origin/${defaultBranch}`, SONAR_TOKEN: '' };
140
142
  stdout(`starci app ci local: ${plan.length} job instance(s), ${opts.parallel} at a time; logs in ${logs}\n`);
@@ -0,0 +1,52 @@
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
+ import path from 'node:path';
7
+ import { createRequire } from 'node:module';
8
+
9
+ const USAGE = 'usage: starci app coverage merge --out <lcov> <coverage-final.json-or-dir>... [--cwd <dir>]';
10
+
11
+ function parse(argv) {
12
+ const [action, ...rest] = argv;
13
+ if (action !== 'merge') throw new Error(USAGE);
14
+ const opts = { inputs: [] };
15
+ for (let index = 0; index < rest.length; index += 1) {
16
+ const arg = rest[index];
17
+ if (arg === '--out' || arg === '--cwd') {
18
+ const value = rest[index + 1];
19
+ if (!value || value.startsWith('--')) throw new Error(`${arg} needs a value; ${USAGE}`);
20
+ opts[arg.slice(2)] = value;
21
+ index += 1;
22
+ } else if (arg.startsWith('--')) throw new Error(`unknown flag ${arg}; ${USAGE}`);
23
+ else opts.inputs.push(arg);
24
+ }
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}`);
27
+ return opts;
28
+ }
29
+
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 } = {}) {
39
+ let opts;
40
+ try { opts = parse(argv); } catch (error) { stderr(`starci app coverage: ${error.message}\n`); return 2; }
41
+ const cwd = path.resolve(baseCwd, opts.cwd ?? '.');
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;
51
+ }
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.0",
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",
@@ -22,6 +22,7 @@
22
22
  "release/**",
23
23
  "db/**",
24
24
  "ci/**",
25
+ "coverage/**",
25
26
  "lint/**",
26
27
  "scaffold/**",
27
28
  "report/**",
@@ -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.5
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.5
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.0
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.0
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": "441c47efec9ac314fb4ffd8b203fd6469aac65710ca9cd12aace0c032c2c537f",
4
+ "hash": "1d8f6ce139e98259571df0aee8deccce139214f6aea6fc86ade937ca2dfb445d",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -247,6 +247,11 @@ export const CATALOG = {
247
247
  "type": "string",
248
248
  "summary": "local: job instances run at once (default half the CPUs, at most 4)"
249
249
  },
250
+ {
251
+ "name": "logs",
252
+ "type": "string",
253
+ "summary": "local: the directory the job logs go to (default a new temp directory)"
254
+ },
250
255
  {
251
256
  "name": "dry-run",
252
257
  "type": "boolean",
@@ -283,7 +288,7 @@ export const CATALOG = {
283
288
  "runtimeSide": "host"
284
289
  },
285
290
  "coverage": {
286
- "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",
287
292
  "owner": "@starci/hfs",
288
293
  "positional": [
289
294
  {
@@ -293,13 +298,18 @@ export const CATALOG = {
293
298
  ],
294
299
  "required": true,
295
300
  "variadic": false
301
+ },
302
+ {
303
+ "name": "paths",
304
+ "required": true,
305
+ "variadic": true
296
306
  }
297
307
  ],
298
308
  "flags": [
299
309
  {
300
310
  "name": "out",
301
311
  "type": "string",
302
- "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)"
303
313
  },
304
314
  {
305
315
  "name": "cwd",
@@ -309,7 +319,7 @@ export const CATALOG = {
309
319
  ],
310
320
  "exit": {
311
321
  "0": "merged",
312
- "2": "bad usage or no report found"
322
+ "2": "bad usage, no installed preset or no report found"
313
323
  },
314
324
  "json": "none",
315
325
  "examples": [
@@ -321,10 +331,12 @@ export const CATALOG = {
321
331
  ],
322
332
  "impl": null,
323
333
  "conventions": [
324
- "the CI standard shards the unit run (npm test -- --shard=i/N); each shard writes an lcov over the whole coverage scope",
325
- "merge sums every line, function and branch hit by identity across the reports and writes one record per source file",
326
- "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",
327
- "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"
328
340
  ],
329
341
  "runtimeSide": "host"
330
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'),
@@ -258,7 +260,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
258
260
  ' env:',
259
261
  ' NODE_OPTIONS: --max-old-space-size=6144',
260
262
  ` - uses: ${UPLOAD_ARTIFACT}`,
261
- ` if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.${side}.sonar.json') != ''`,
263
+ // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
264
+ " if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
262
265
  ' with:',
263
266
  ` name: sonar-lint-${side}`,
264
267
  ` path: reports/lint.${side}.sonar.json`,
@@ -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,89 +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 (a file no test of that shard loads is a record of zeros), so the
3
- // merge sums every line, function and branch hit by its identity across the shards and writes ONE record per source file, with the summaries recomputed:
4
- // the coverage proof (`starci app check --coverage-from`), the Codecov upload and the Sonar import then read the one be/coverage/lcov.info they always read.
5
- // Pure over the texts; the file walk is lcovFilesUnder.
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import { byCodeUnit } from '../lib/list.mjs';
9
- import { addHits as add } from './rules/coverage-below.mjs';
10
-
11
- const count = (value) => (/^\d+$/.test(value) ? Number(value) : 0);
12
-
13
- function recordOf(records, source) {
14
- if (!records.has(source)) records.set(source, { lines: new Map(), fnLines: new Map(), fnHits: new Map(), branches: new Map() });
15
- return records.get(source);
16
- }
17
-
18
- /** Function data: FN:<line>[,<end>],<name> declares, FNDA:<hits>,<name> counts (a name may hold commas). */
19
- function functionLine(record, tag, value) {
20
- const at = value.indexOf(',');
21
- if (at < 0) return;
22
- if (tag === 'FN') {
23
- let name = value.slice(at + 1);
24
- let line = value.slice(0, at);
25
- if (/^\d+,/.test(name)) { line = `${line},${name.slice(0, name.indexOf(','))}`; name = name.slice(name.indexOf(',') + 1); }
26
- if (!record.fnLines.has(name)) record.fnLines.set(name, line);
27
- if (!record.fnHits.has(name)) record.fnHits.set(name, 0);
28
- } else add(record.fnHits, value.slice(at + 1), count(value.slice(0, at)));
29
- }
30
-
31
- function dataLine(record, tag, value) {
32
- if (tag === 'DA') {
33
- const [line, hits] = value.split(',');
34
- add(record.lines, Number(line), count(hits ?? ''));
35
- } else if (tag === 'FN' || tag === 'FNDA') functionLine(record, tag, value);
36
- else if (tag === 'BRDA') {
37
- const parts = value.split(',');
38
- if (parts.length === 4) add(record.branches, parts.slice(0, 3).join(','), parts[3] === '-' ? 0 : count(parts[3]));
39
- }
40
- }
41
-
42
- /** Every record of every text, merged by source file (SF) and identity. */
43
- function parseAll(texts) {
44
- const records = new Map();
45
- for (const text of texts) {
46
- let current = null;
47
- for (const raw of String(text).replace(/^/, '').split(/\r?\n/)) {
48
- const line = raw.trim();
49
- if (line === 'end_of_record') { current = null; continue; }
50
- const colon = line.indexOf(':');
51
- if (colon < 0) continue;
52
- const tag = line.slice(0, colon);
53
- const value = line.slice(colon + 1);
54
- if (tag === 'SF') current = recordOf(records, value);
55
- else if (current) dataLine(current, tag, value);
56
- }
57
- }
58
- return records;
59
- }
60
-
61
- const branchOrder = (a, b) => a.split(',').map(Number).reduce((order, part, index) => order || part - Number(b.split(',')[index]), 0);
62
-
63
- function serialize(source, record) {
64
- const out = ['TN:', `SF:${source}`];
65
- 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));
66
- for (const name of functions) out.push(`FN:${record.fnLines.get(name)},${name}`);
67
- for (const name of functions) out.push(`FNDA:${record.fnHits.get(name) ?? 0},${name}`);
68
- out.push(`FNF:${functions.length}`, `FNH:${functions.filter((name) => (record.fnHits.get(name) ?? 0) > 0).length}`);
69
- const lines = [...record.lines.keys()].sort((a, b) => a - b);
70
- for (const line of lines) out.push(`DA:${line},${record.lines.get(line)}`);
71
- out.push(`LF:${lines.length}`, `LH:${lines.filter((line) => record.lines.get(line) > 0).length}`);
72
- const branches = [...record.branches.keys()].sort(branchOrder);
73
- for (const branch of branches) out.push(`BRDA:${branch},${record.branches.get(branch)}`);
74
- out.push(`BRF:${branches.length}`, `BRH:${branches.filter((branch) => record.branches.get(branch) > 0).length}`, 'end_of_record');
75
- return out.join('\n');
76
- }
77
-
78
- /** One lcov text of the merged `texts`: a record per source file, sorted by path, hits summed by line, function name and branch identity. */
79
- export function mergeLcov(texts) {
80
- const records = parseAll(texts);
81
- return [...records.keys()].sort(byCodeUnit).map((source) => serialize(source, records.get(source))).join('\n') + (records.size ? '\n' : '');
82
- }
83
-
84
- /** The lcov files a path names: the file itself, or every `lcov.info` below a directory (sorted). */
85
- export function lcovFilesUnder(target) {
86
- if (!fs.statSync(target).isDirectory()) return [target];
87
- return fs.readdirSync(target, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile() && entry.name === 'lcov.info')
88
- .map((entry) => path.join(entry.parentPath ?? entry.path, entry.name)).sort(byCodeUnit);
89
- }