@starci/hfs 4.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.1 - 2026-10-18
4
+ - 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
+
3
6
  ## 4.7.0 - 2026-10-18
4
7
  - 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
8
  - 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,49 @@
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';
6
+ import path from 'node:path';
7
+ import { lcovFilesUnder, mergeLcov } from '../runtime/scripts/hfs/lcov-merge.mjs';
8
+
9
+ const USAGE = 'usage: starci app coverage merge --out <lcov> <lcov-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
+ return opts;
27
+ }
28
+
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) } = {}) {
31
+ let opts;
32
+ try { opts = parse(argv); } catch (error) { stderr(`starci app coverage: ${error.message}\n`); return 2; }
33
+ 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));
39
+ }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
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.6
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.6
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -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.1
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.1
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -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": "7cf3ce3c9e0abb27fa139c93abbbf15a2f8cc8aec47ba95f022d946b53d8998f",
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",
@@ -293,6 +298,11 @@ 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": [
@@ -1,17 +1,24 @@
1
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.
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.
6
12
  import fs from 'node:fs';
7
13
  import path from 'node:path';
8
14
  import { byCodeUnit } from '../lib/list.mjs';
9
15
  import { addHits as add } from './rules/coverage-below.mjs';
10
16
 
17
+ const BOM = String.fromCodePoint(0xfeff);
11
18
  const count = (value) => (/^\d+$/.test(value) ? Number(value) : 0);
12
19
 
13
20
  function recordOf(records, source) {
14
- if (!records.has(source)) records.set(source, { lines: new Map(), fnLines: new Map(), fnHits: new Map(), branches: new Map() });
21
+ if (!records.has(source)) records.set(source, { lines: new Map(), fnLines: new Map(), fnHits: new Map(), branches: new Map(), rawBranches: [] });
15
22
  return records.get(source);
16
23
  }
17
24
 
@@ -35,25 +42,63 @@ function dataLine(record, tag, value) {
35
42
  } else if (tag === 'FN' || tag === 'FNDA') functionLine(record, tag, value);
36
43
  else if (tag === 'BRDA') {
37
44
  const parts = value.split(',');
38
- if (parts.length === 4) add(record.branches, parts.slice(0, 3).join(','), parts[3] === '-' ? 0 : count(parts[3]));
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]) });
39
46
  }
40
47
  }
41
48
 
42
- /** Every record of every text, merged by source file (SF) and identity. */
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. */
43
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]);
44
95
  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
- }
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) });
57
102
  }
58
103
  return records;
59
104
  }
@@ -258,7 +258,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
258
258
  ' env:',
259
259
  ' NODE_OPTIONS: --max-old-space-size=6144',
260
260
  ` - uses: ${UPLOAD_ARTIFACT}`,
261
- ` if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.${side}.sonar.json') != ''`,
261
+ // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
262
+ " if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
262
263
  ' with:',
263
264
  ` name: sonar-lint-${side}`,
264
265
  ` path: reports/lint.${side}.sonar.json`,