@starci/hfs 4.7.1 → 4.7.3
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 +7 -0
- package/coverage/cli.mjs +26 -23
- package/lint/run.mjs +4 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/packages/cli/src/catalog.generated.mjs +10 -8
- package/runtime/scripts/hfs/example-secrets.mjs +4 -0
- package/runtime/scripts/hfs/rules/ci-standard.mjs +1 -0
- package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
- package/sync/ci-commands.mjs +7 -4
- package/templates/app/ci-workflows/github/workflows/ci.yml +9 -5
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +9 -5
- package/runtime/scripts/hfs/lcov-merge.mjs +0 -134
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.3 - 2026-10-18
|
|
4
|
+
- Fixed: the managed CI typecheck step declares the CI standard's raised Node heap, preventing the NIVO release candidate's typecheck from exhausting Node's default heap; the CI standard checks typecheck steps too.
|
|
5
|
+
- Fixed: the fresh coverage job runs codegen before the whole-app coverage proof, preparing ignored GraphQL wire types before the app check or its isolated example copy starts; a failed generator stops the proof.
|
|
6
|
+
|
|
7
|
+
## 4.7.2 - 2026-10-18
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
3
10
|
## 4.7.1 - 2026-10-18
|
|
4
11
|
- 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
12
|
|
package/coverage/cli.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// cli.mjs - `starci app coverage merge --out <lcov> <
|
|
2
|
-
// directory by the coverage job of the managed CI) merged into the one
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
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 {
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
8
|
|
|
9
|
-
const USAGE = 'usage: starci app coverage merge --out <lcov> <
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
files.
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
32
|
+
version: 3.4.9
|
|
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.
|
|
39
|
+
version: 8.4.9
|
|
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.
|
|
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.
|
|
79
|
+
version: 1.7.3
|
|
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.
|
|
86
|
+
version: 4.7.3
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -27,7 +27,7 @@ defaults:
|
|
|
27
27
|
# The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
|
|
28
28
|
heap:
|
|
29
29
|
minMiB: 6144
|
|
30
|
-
steps: ['npm run lint', 'app lint', 'app check --coverage-from']
|
|
30
|
+
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from']
|
|
31
31
|
|
|
32
32
|
items:
|
|
33
33
|
- id: candidate-trigger
|
|
@@ -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
|
|
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": "
|
|
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
|
|
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
|
|
335
|
-
"
|
|
336
|
-
"
|
|
337
|
-
"the
|
|
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
|
-
|
|
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) {
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -210,11 +210,12 @@ function sonarMergeSteps(condition) {
|
|
|
210
210
|
const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
211
211
|
/** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard. */
|
|
212
212
|
function ciJobSettings(declaration) {
|
|
213
|
-
const defaults = parseYaml(fs.readFileSync(STANDARD, 'utf8'))
|
|
213
|
+
const { defaults, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
|
|
214
214
|
const jobs = declaration?.ci?.jobs ?? {};
|
|
215
215
|
const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
|
|
216
216
|
const shards = Number(jobs.test?.shards ?? defaults.shards);
|
|
217
217
|
return {
|
|
218
|
+
nodeOptions: `--max-old-space-size=${heap.minMiB}`,
|
|
218
219
|
timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
|
|
219
220
|
timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
|
|
220
221
|
testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
|
|
@@ -234,11 +235,13 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
234
235
|
return {
|
|
235
236
|
...settings,
|
|
236
237
|
lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
|
|
238
|
+
// The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
|
|
239
|
+
shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
|
|
237
240
|
ciBuildEnv: build.env,
|
|
238
241
|
ciBuildSteps: build.steps,
|
|
239
242
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
240
243
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
241
|
-
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
244
|
+
ciCoverageProof: 'npm run codegen --silent && ' + exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
242
245
|
ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
|
|
243
246
|
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
244
247
|
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
@@ -250,13 +253,13 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
250
253
|
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
251
254
|
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
252
255
|
' env:',
|
|
253
|
-
|
|
256
|
+
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
254
257
|
...(sonar ? [
|
|
255
258
|
' - name: lint with Sonar import',
|
|
256
259
|
" if: env.SONAR_TOKEN != ''",
|
|
257
260
|
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
258
261
|
' env:',
|
|
259
|
-
|
|
262
|
+
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
260
263
|
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
261
264
|
// 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
265
|
" if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
|
|
@@ -30,6 +30,8 @@ jobs:
|
|
|
30
30
|
run: npm run format:check
|
|
31
31
|
- name: typecheck
|
|
32
32
|
run: npm run typecheck
|
|
33
|
+
env:
|
|
34
|
+
NODE_OPTIONS: {{nodeOptions}}
|
|
33
35
|
|
|
34
36
|
lint:
|
|
35
37
|
timeout-minutes: {{timeoutLint}}
|
|
@@ -54,16 +56,18 @@ jobs:
|
|
|
54
56
|
steps:
|
|
55
57
|
{{> app/ci-workflows/install-steps.yml}}
|
|
56
58
|
{{> app/ci-workflows/jest-cache.yml}}
|
|
57
|
-
#
|
|
58
|
-
# the coverage job proves 100 per file on the merged report.
|
|
59
|
+
# A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
|
|
60
|
+
# 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
61
|
- name: unit
|
|
60
62
|
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
|
|
63
|
+
env:
|
|
64
|
+
STARCI_COVERAGE_SHARD: "1"
|
|
61
65
|
{{> app/ci-workflows/jest-cache-save.yml}}
|
|
62
66
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
63
|
-
if: always() && hashFiles('{{
|
|
67
|
+
if: always() && hashFiles('{{shardCoverageReport}}') != ''
|
|
64
68
|
with:
|
|
65
69
|
name: coverage-shard-${{ matrix.shard }}
|
|
66
|
-
path: {{
|
|
70
|
+
path: {{shardCoverageReport}}
|
|
67
71
|
|
|
68
72
|
coverage:
|
|
69
73
|
needs: test
|
|
@@ -84,7 +88,7 @@ jobs:
|
|
|
84
88
|
- name: coverage proof
|
|
85
89
|
run: {{ciCoverageProof}}
|
|
86
90
|
env:
|
|
87
|
-
NODE_OPTIONS:
|
|
91
|
+
NODE_OPTIONS: {{nodeOptions}}
|
|
88
92
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
89
93
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
90
94
|
with:
|
|
@@ -30,6 +30,8 @@ jobs:
|
|
|
30
30
|
run: npm run format:check
|
|
31
31
|
- name: typecheck
|
|
32
32
|
run: npm run typecheck
|
|
33
|
+
env:
|
|
34
|
+
NODE_OPTIONS: {{nodeOptions}}
|
|
33
35
|
|
|
34
36
|
lint:
|
|
35
37
|
timeout-minutes: {{timeoutLint}}
|
|
@@ -54,16 +56,18 @@ jobs:
|
|
|
54
56
|
steps:
|
|
55
57
|
{{> app/ci-workflows/install-steps.yml}}
|
|
56
58
|
{{> app/ci-workflows/jest-cache.yml}}
|
|
57
|
-
#
|
|
58
|
-
# the coverage job proves 100 per file on the merged report.
|
|
59
|
+
# A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
|
|
60
|
+
# 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
61
|
- name: unit
|
|
60
62
|
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
|
|
63
|
+
env:
|
|
64
|
+
STARCI_COVERAGE_SHARD: "1"
|
|
61
65
|
{{> app/ci-workflows/jest-cache-save.yml}}
|
|
62
66
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
63
|
-
if: always() && hashFiles('{{
|
|
67
|
+
if: always() && hashFiles('{{shardCoverageReport}}') != ''
|
|
64
68
|
with:
|
|
65
69
|
name: coverage-shard-${{ matrix.shard }}
|
|
66
|
-
path: {{
|
|
70
|
+
path: {{shardCoverageReport}}
|
|
67
71
|
|
|
68
72
|
coverage:
|
|
69
73
|
needs: test
|
|
@@ -84,7 +88,7 @@ jobs:
|
|
|
84
88
|
- name: coverage proof
|
|
85
89
|
run: {{ciCoverageProof}}
|
|
86
90
|
env:
|
|
87
|
-
NODE_OPTIONS:
|
|
91
|
+
NODE_OPTIONS: {{nodeOptions}}
|
|
88
92
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
89
93
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
90
94
|
with:
|
|
@@ -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
|
-
}
|