@starci/hfs 4.12.0 → 4.13.0
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 +43 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// session.mts - the disk-backed CheckSession of `starci app check` and `starci app lint`. It decides per family whether a run is full or diff
|
|
2
|
+
// (the rules of contexts/project/check-cache.md "When a run is full"), serves the trusted records of a folder, takes the records a family
|
|
3
|
+
// replaces and writes them once on flush: one JSON Lines file per area, byte-stable, only the files whose bytes changed. In CI it writes
|
|
4
|
+
// nothing and compares the committed cache with what it inherited or computed instead.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createContentIndex } from './content-index.mjs';
|
|
8
|
+
import { computeFingerprint, fingerprintDifferences, parseFingerprint, serializeFingerprint } from './fingerprint.mjs';
|
|
9
|
+
import { hashFile } from './hash.mjs';
|
|
10
|
+
import { groupByArea, parseRecords, serializeRecords } from './jsonl.mjs';
|
|
11
|
+
import { readCacheDir, resolveTrust, verifyCommitted } from './trust.mjs';
|
|
12
|
+
import { CACHE_DIR } from './types.mjs';
|
|
13
|
+
/** `STARCI_APP_CHECK_CACHE`: `full` ignores the cache (the release gate sets it), `verify` is read by the caller (runs diff, then full, compares). */
|
|
14
|
+
export const CACHE_ENV = 'STARCI_APP_CHECK_CACHE';
|
|
15
|
+
const FINGERPRINT_FILE = 'fingerprint.jsonl';
|
|
16
|
+
const dirOf = (root, folder) => path.join(root, ...CACHE_DIR.split('/'), ...folder.split('/'));
|
|
17
|
+
function removeEmptyDirs(dir, stop) {
|
|
18
|
+
for (let at = dir; at.startsWith(stop) && at !== stop; at = path.dirname(at)) {
|
|
19
|
+
try {
|
|
20
|
+
fs.rmdirSync(at);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeIfChanged(file, text, written) {
|
|
28
|
+
let current;
|
|
29
|
+
try {
|
|
30
|
+
current = fs.readFileSync(file, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
current = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (current === text)
|
|
36
|
+
return;
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
fs.writeFileSync(file, text);
|
|
39
|
+
written.push(file);
|
|
40
|
+
}
|
|
41
|
+
function filesUnder(dir) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const walk = (at) => {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(at, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (entry.isDirectory())
|
|
53
|
+
walk(path.join(at, entry.name));
|
|
54
|
+
else
|
|
55
|
+
out.push(path.join(at, entry.name));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(dir);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
export function createDiskSession(options) {
|
|
62
|
+
const { root, env, tracked } = options;
|
|
63
|
+
const index = createContentIndex(root, tracked);
|
|
64
|
+
const fingerprint = options.fingerprint ?? computeFingerprint({ root, tracked, cliVersion: options.cliVersion, hfsDir: options.hfsDir });
|
|
65
|
+
const trust = options.trust ?? resolveTrust({ root, env });
|
|
66
|
+
const forcedFull = env[CACHE_ENV] === 'full';
|
|
67
|
+
const reports = [];
|
|
68
|
+
const replaced = new Map();
|
|
69
|
+
const parsed = new Map();
|
|
70
|
+
const trustedCache = () => trust.files();
|
|
71
|
+
const trustedRecords = (folder) => {
|
|
72
|
+
const hit = parsed.get(folder);
|
|
73
|
+
if (hit !== undefined)
|
|
74
|
+
return hit;
|
|
75
|
+
const prefix = `${folder}/`;
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const records = [];
|
|
78
|
+
for (const [file, text] of [...trustedCache()].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
79
|
+
if (!file.startsWith(prefix) || !file.endsWith('.jsonl'))
|
|
80
|
+
continue;
|
|
81
|
+
for (const record of parseRecords(text).records) {
|
|
82
|
+
const line = JSON.stringify(record);
|
|
83
|
+
if (seen.has(line))
|
|
84
|
+
continue;
|
|
85
|
+
seen.add(line);
|
|
86
|
+
records.push(record);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
parsed.set(folder, records);
|
|
90
|
+
return records;
|
|
91
|
+
};
|
|
92
|
+
const cachedFingerprint = parseFingerprint(trustedCache().get(FINGERPRINT_FILE) ?? '');
|
|
93
|
+
const differences = cachedFingerprint.length === 0 ? [] : fingerprintDifferences(cachedFingerprint, fingerprint.parts);
|
|
94
|
+
let verdictList = [];
|
|
95
|
+
const global = () => {
|
|
96
|
+
if (forcedFull)
|
|
97
|
+
return { mode: 'full', reason: `${CACHE_ENV}=full, the release gate never reads the cache` };
|
|
98
|
+
if (!fingerprint.portable)
|
|
99
|
+
return { mode: 'full', reason: `no portable fingerprint, nothing is written (${fingerprint.localReasons[0] ?? 'a local package'})` };
|
|
100
|
+
if (trust.kind === 'none')
|
|
101
|
+
return { mode: 'full', reason: `no trusted base (${trust.reason})` };
|
|
102
|
+
if (cachedFingerprint.length === 0)
|
|
103
|
+
return { mode: 'full', reason: trust.kind === 'base' ? 'no cache at the trusted base' : 'no cache in the tree' };
|
|
104
|
+
if (differences.length > 0)
|
|
105
|
+
return { mode: 'full', reason: `the fingerprint differs (${differences.slice(0, 3).join(', ')}${differences.length > 3 ? ', ...' : ''})` };
|
|
106
|
+
return undefined;
|
|
107
|
+
};
|
|
108
|
+
const writes = !forcedFull && fingerprint.portable && trust.kind === 'worktree';
|
|
109
|
+
const session = {
|
|
110
|
+
root,
|
|
111
|
+
index,
|
|
112
|
+
fingerprint,
|
|
113
|
+
trust,
|
|
114
|
+
writes,
|
|
115
|
+
progress: options.progress,
|
|
116
|
+
get reports() {
|
|
117
|
+
return reports;
|
|
118
|
+
},
|
|
119
|
+
decide(family) {
|
|
120
|
+
if (options.full?.has(family) === true)
|
|
121
|
+
return { mode: 'full', reason: '--full' };
|
|
122
|
+
return global() ?? { mode: 'diff', reason: `against ${trust.reason}` };
|
|
123
|
+
},
|
|
124
|
+
records(folder) {
|
|
125
|
+
return replaced.get(folder)?.records ?? trustedRecords(folder);
|
|
126
|
+
},
|
|
127
|
+
replace(folder, records, replaceOptions) {
|
|
128
|
+
replaced.set(folder, { records, depth: replaceOptions?.depth ?? 2 });
|
|
129
|
+
},
|
|
130
|
+
unchanged: (paths) => paths.every((file) => hashFile(path.join(root, file)) === index.hashOf(file)),
|
|
131
|
+
report: (report) => void reports.push(report),
|
|
132
|
+
verdicts: () => verdictList,
|
|
133
|
+
flush() {
|
|
134
|
+
const written = [];
|
|
135
|
+
const removed = [];
|
|
136
|
+
const warnings = [];
|
|
137
|
+
// A file edited while the run read it gets no record.
|
|
138
|
+
const settled = new Map();
|
|
139
|
+
for (const [folder, { records }] of replaced) {
|
|
140
|
+
settled.set(folder, records.filter((record) => index.hashOf(record.path) === undefined || hashFile(path.join(root, record.path)) === record.hash));
|
|
141
|
+
}
|
|
142
|
+
if (trust.kind === 'base') {
|
|
143
|
+
const committed = new Map();
|
|
144
|
+
for (const [file, text] of readCacheDir(root)) {
|
|
145
|
+
if (!file.endsWith('.jsonl'))
|
|
146
|
+
continue;
|
|
147
|
+
for (const folderName of settled.keys()) {
|
|
148
|
+
if (file.startsWith(`${folderName}/`))
|
|
149
|
+
committed.set(folderName, [...(committed.get(folderName) ?? []), ...parseRecords(text).records]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const trusted = new Map([...settled.keys()].map((folder) => [folder, trustedRecords(folder)]));
|
|
153
|
+
const computed = new Map(settled);
|
|
154
|
+
verdictList = verifyCommitted({ trusted, computed, committed });
|
|
155
|
+
return { written, removed, warnings };
|
|
156
|
+
}
|
|
157
|
+
if (!writes)
|
|
158
|
+
return { written, removed, warnings };
|
|
159
|
+
try {
|
|
160
|
+
const base = path.join(root, ...CACHE_DIR.split('/'));
|
|
161
|
+
const stalePrint = cachedFingerprint.length === 0 || differences.length > 0;
|
|
162
|
+
if (stalePrint) {
|
|
163
|
+
// A new fingerprint invalidates every folder this run did not rewrite: they were computed under the old one.
|
|
164
|
+
for (const file of filesUnder(base)) {
|
|
165
|
+
const rel = path.relative(base, file).split(path.sep).join('/');
|
|
166
|
+
if (rel === FINGERPRINT_FILE || [...settled.keys()].some((folder) => rel.startsWith(`${folder}/`)))
|
|
167
|
+
continue;
|
|
168
|
+
fs.rmSync(file);
|
|
169
|
+
removed.push(file);
|
|
170
|
+
removeEmptyDirs(path.dirname(file), base);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const [folder, records] of settled) {
|
|
174
|
+
const depth = replaced.get(folder)?.depth ?? 2;
|
|
175
|
+
const dir = dirOf(root, folder);
|
|
176
|
+
const wanted = new Map([...groupByArea(records, depth)].map(([file, group]) => [path.join(dir, ...file.split('/')), serializeRecords(group)]));
|
|
177
|
+
for (const [file, text] of wanted)
|
|
178
|
+
writeIfChanged(file, text, written);
|
|
179
|
+
for (const file of filesUnder(dir)) {
|
|
180
|
+
if (wanted.has(file))
|
|
181
|
+
continue;
|
|
182
|
+
fs.rmSync(file);
|
|
183
|
+
removed.push(file);
|
|
184
|
+
removeEmptyDirs(path.dirname(file), base);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (replaced.size > 0 || stalePrint)
|
|
188
|
+
writeIfChanged(path.join(base, FINGERPRINT_FILE), serializeFingerprint(fingerprint.parts), written);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
warnings.push(`the cache could not be written: ${String(error.message ?? error).split('\n')[0]}`);
|
|
192
|
+
}
|
|
193
|
+
return { written: written.map((file) => path.relative(root, file).split(path.sep).join('/')), removed: removed.map((file) => path.relative(root, file).split(path.sep).join('/')), warnings };
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
return session;
|
|
197
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ContentIndex } from './types.mjs';
|
|
2
|
+
export interface SideKey {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
/** Every path the key covers: tracked paths of the side and the untracked program inputs. */
|
|
5
|
+
readonly inputs: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
/** The untracked files the side's TypeScript program includes (generated code), side-relative paths prefixed with `side/`. */
|
|
8
|
+
export declare function untrackedProgramInputs(root: string, side: string, tracked: ReadonlySet<string>): string[];
|
|
9
|
+
/** The key of `side` (`be` or `fe`) of the app at `root`. */
|
|
10
|
+
export declare function sideKey(root: string, index: ContentIndex, side: string): SideKey;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// side-key.mts - the key of a side for the steps that read its whole program (the architecture machine, the contract emit, the boot proof,
|
|
2
|
+
// the type-aware lint rules, tsc). It is the sha256 of the hash of each tracked path of the side (`absent` for one deleted in the work tree),
|
|
3
|
+
// its staged entries (the machine reads the index), and the untracked files its TypeScript program reads: what its tsconfig includes that git
|
|
4
|
+
// does not track, generated code included. One edit anywhere in the side changes the key, so a scoped step re-runs the whole side.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { combineHashes, hashFile, hashText } from './hash.mjs';
|
|
8
|
+
import { git } from './git.mjs';
|
|
9
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
10
|
+
/** The untracked files the side's TypeScript program includes (generated code), side-relative paths prefixed with `side/`. */
|
|
11
|
+
export function untrackedProgramInputs(root, side, tracked) {
|
|
12
|
+
const ts = loadTypescript(root);
|
|
13
|
+
const tsconfig = path.join(root, side, 'tsconfig.json');
|
|
14
|
+
if (ts === null || !fs.existsSync(tsconfig))
|
|
15
|
+
return [];
|
|
16
|
+
try {
|
|
17
|
+
const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
|
|
18
|
+
if (read.error !== undefined)
|
|
19
|
+
return [];
|
|
20
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.join(root, side));
|
|
21
|
+
const found = [];
|
|
22
|
+
for (const name of parsed.fileNames) {
|
|
23
|
+
const rel = path.relative(root, name).split(path.sep).join('/');
|
|
24
|
+
if (rel.startsWith('..') || rel.includes('/node_modules/') || tracked.has(rel))
|
|
25
|
+
continue;
|
|
26
|
+
found.push(rel);
|
|
27
|
+
}
|
|
28
|
+
return found.sort();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The key of `side` (`be` or `fe`) of the app at `root`. */
|
|
35
|
+
export function sideKey(root, index, side) {
|
|
36
|
+
const prefix = `${side}/`;
|
|
37
|
+
const trackedPaths = index.paths.filter((file) => file.startsWith(prefix));
|
|
38
|
+
const parts = {};
|
|
39
|
+
for (const file of trackedPaths)
|
|
40
|
+
parts[`tracked:${file}`] = index.hashOf(file) ?? 'absent';
|
|
41
|
+
const staged = git(root, ['ls-files', '-s', '-z', '--', prefix]);
|
|
42
|
+
parts['staged'] = hashText(staged.stdout);
|
|
43
|
+
const untracked = untrackedProgramInputs(root, side, new Set(index.paths));
|
|
44
|
+
for (const file of untracked)
|
|
45
|
+
parts[`untracked:${file}`] = hashFile(path.join(root, file)) ?? 'absent';
|
|
46
|
+
return { key: combineHashes(parts), inputs: [...trackedPaths, ...untracked] };
|
|
47
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { CacheRecord, CheckSession, FamilyReport, JsonValue } from './types.mjs';
|
|
2
|
+
/** The folder of the session that holds the records of this family. */
|
|
3
|
+
export declare const TEST_FOLDER = "test-coverage";
|
|
4
|
+
/** The step of a unit spec's record. */
|
|
5
|
+
export declare const SPEC_STEP = "unit-spec";
|
|
6
|
+
/** The Jest projects a warm run never skips: they reach services, data and time a key cannot hold. */
|
|
7
|
+
export declare const ALWAYS_FULL_PROJECTS: readonly ["integration", "e2e", "contract"];
|
|
8
|
+
/** One source's items as the preset's reporter writes them: statements `[sl, sc, el, ec, flag]`, functions `[name, decl, loc, flag]`, branches `[type, loc, [[loc, flag]...]]`. */
|
|
9
|
+
export interface SourceItems {
|
|
10
|
+
readonly s: JsonValue;
|
|
11
|
+
readonly f: JsonValue;
|
|
12
|
+
readonly b: JsonValue;
|
|
13
|
+
}
|
|
14
|
+
/** What the reporter of @starci/jest-preset wrote for one spec (`SpecCoverageFile`), with source paths relative to the Jest root. */
|
|
15
|
+
export interface SpecCoverageOutput {
|
|
16
|
+
readonly spec: string;
|
|
17
|
+
readonly passed: boolean;
|
|
18
|
+
readonly sources: Readonly<Record<string, SourceItems>>;
|
|
19
|
+
}
|
|
20
|
+
/** What a Jest run of some specs came to. */
|
|
21
|
+
export interface RunOutcome {
|
|
22
|
+
/** The process exit status (non-zero when any spec failed; the records, not the status, say which). */
|
|
23
|
+
readonly status: number;
|
|
24
|
+
/** Jest's own output, for the diagnosis of a failed spec. */
|
|
25
|
+
readonly output: string;
|
|
26
|
+
}
|
|
27
|
+
/** What the family needs of Jest: the specs, each spec's closure, the project's configuration, and a way to run specs. All paths are app-relative posix. */
|
|
28
|
+
export interface JestEnvironment {
|
|
29
|
+
/** Every unit spec. */
|
|
30
|
+
readonly specs: readonly string[];
|
|
31
|
+
/** Every local module the spec loads, transitively, the setup files of the project included. Never a path under `node_modules`. */
|
|
32
|
+
closureOf(spec: string): readonly string[];
|
|
33
|
+
/** The digest of the unit project's configuration, free of host paths. */
|
|
34
|
+
readonly configHash: string;
|
|
35
|
+
/** Runs `specs` (all of them when `specs` is every spec) writing the per-spec coverage into `coverageDir`. */
|
|
36
|
+
run(specs: readonly string[], coverageDir: string): RunOutcome;
|
|
37
|
+
/** Reads what the reporter wrote into `coverageDir`. */
|
|
38
|
+
readCoverage(coverageDir: string): readonly SpecCoverageOutput[];
|
|
39
|
+
}
|
|
40
|
+
/** The union of the stored records, written as the lcov and coverage-final.json of the Jest root (`<root>/coverage`). */
|
|
41
|
+
export type MergeSpecRecords = (records: ReadonlyArray<Readonly<Record<string, SourceItems>>>, args: {
|
|
42
|
+
outDir: string;
|
|
43
|
+
reportRoot: string;
|
|
44
|
+
recordRoot: string;
|
|
45
|
+
}) => void;
|
|
46
|
+
/** The per-file threshold (R273) judged on a report; `starci app check --coverage-from` is this judgement. */
|
|
47
|
+
export type JudgeCoverage = (lcov: string) => {
|
|
48
|
+
readonly ok: boolean;
|
|
49
|
+
readonly findings: ReadonlyArray<{
|
|
50
|
+
readonly code?: string;
|
|
51
|
+
readonly path?: string;
|
|
52
|
+
readonly message?: string;
|
|
53
|
+
}>;
|
|
54
|
+
};
|
|
55
|
+
export interface TestFamilyOptions {
|
|
56
|
+
readonly session: CheckSession;
|
|
57
|
+
readonly jest: JestEnvironment;
|
|
58
|
+
/** The Jest root, app-relative posix (`be`). Source paths in the records are app-relative, so they carry it. */
|
|
59
|
+
readonly side: string;
|
|
60
|
+
readonly merge: MergeSpecRecords;
|
|
61
|
+
readonly judge: JudgeCoverage;
|
|
62
|
+
/** Where the temporary per-spec coverage goes; defaults to a fresh folder of the OS temp dir. */
|
|
63
|
+
readonly scratch?: string;
|
|
64
|
+
}
|
|
65
|
+
/** The outcome of the family. `ok` is false when a spec failed or the merged total is below the threshold. */
|
|
66
|
+
export interface TestFamilyResult {
|
|
67
|
+
readonly ok: boolean;
|
|
68
|
+
readonly ran: readonly string[];
|
|
69
|
+
readonly reused: readonly string[];
|
|
70
|
+
/** Specs that failed or did not report: nothing of theirs is stored. */
|
|
71
|
+
readonly failed: readonly string[];
|
|
72
|
+
/** The merged lcov, or undefined when no spec has a record. */
|
|
73
|
+
readonly lcov: string | undefined;
|
|
74
|
+
readonly threshold: ReturnType<JudgeCoverage> | undefined;
|
|
75
|
+
readonly report: FamilyReport;
|
|
76
|
+
/** Jest's output of the specs that ran, kept for the diagnosis of failures. */
|
|
77
|
+
readonly output: string;
|
|
78
|
+
}
|
|
79
|
+
/** The snapshot file Jest keeps beside a spec. */
|
|
80
|
+
export declare function snapshotOf(spec: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* The closure key of a spec: the spec, each module of its closure and its snapshot by content hash (`absent` for one the work tree lacks, so a deleted dependency
|
|
83
|
+
* changes the key), and the project configuration. The same files give the same key on every host.
|
|
84
|
+
*/
|
|
85
|
+
export declare function specKey({ spec, closure, configHash, hashOf }: {
|
|
86
|
+
spec: string;
|
|
87
|
+
closure: readonly string[];
|
|
88
|
+
configHash: string;
|
|
89
|
+
hashOf: (file: string) => string | undefined;
|
|
90
|
+
}): string;
|
|
91
|
+
/** The sources a stored record holds (app-relative paths), empty for a record that does not hold any. */
|
|
92
|
+
export declare function sourcesOf(record: CacheRecord): Record<string, SourceItems>;
|
|
93
|
+
/**
|
|
94
|
+
* Runs the unit specs against the cache. Diff mode runs the specs with no current record, full mode all of them; a passing spec's coverage is stored under its key,
|
|
95
|
+
* a failing one's is not, and the folder is replaced with exactly the current specs' records. The merged total is written to `<side>/coverage/lcov.info` and judged.
|
|
96
|
+
*/
|
|
97
|
+
export declare function runTestFamily(options: TestFamilyOptions): TestFamilyResult;
|
|
98
|
+
/** The part of a Jest project config the closure needs. */
|
|
99
|
+
interface ProjectConfigLike {
|
|
100
|
+
readonly rootDir: string;
|
|
101
|
+
readonly displayName?: {
|
|
102
|
+
readonly name?: string;
|
|
103
|
+
};
|
|
104
|
+
readonly setupFiles?: readonly string[];
|
|
105
|
+
readonly setupFilesAfterEnv?: readonly string[];
|
|
106
|
+
readonly [key: string]: unknown;
|
|
107
|
+
}
|
|
108
|
+
/** The digest of a project config with host paths turned into `<root>` and the keys that differ per host dropped. */
|
|
109
|
+
export declare function projectConfigHash(config: ProjectConfigLike): string;
|
|
110
|
+
/**
|
|
111
|
+
* The app's own Jest, resolved from `jestFrom` (a package.json or folder inside the app's install): its unit project's configuration, its haste map and resolver
|
|
112
|
+
* (so `moduleNameMapper`, `jest.mock` targets and setup files count as Jest sees them), and its binary to run specs. `side` is the Jest root, app-relative.
|
|
113
|
+
*/
|
|
114
|
+
export declare function createJestEnvironment({ appRoot, side, jestFrom, project }: {
|
|
115
|
+
appRoot: string;
|
|
116
|
+
side: string;
|
|
117
|
+
jestFrom?: string;
|
|
118
|
+
project?: string;
|
|
119
|
+
}): Promise<JestEnvironment>;
|
|
120
|
+
/** The installed preset's union merge (resolved like the shard merge of `app coverage`): the records in, the lcov and coverage-final.json out. */
|
|
121
|
+
export declare function installedSpecMerge(appRoot: string, from?: string): MergeSpecRecords;
|
|
122
|
+
/**
|
|
123
|
+
* The sharded full run of CI (`--test --full --shard i/n`): the shard's Jest run in the shape the shard merge reads, `coverage-final.json` of the files it loads,
|
|
124
|
+
* and nothing recorded. The merge job joins the shards (`starci app coverage merge`) and judges the merged report with `--coverage-from`.
|
|
125
|
+
*/
|
|
126
|
+
export declare function shardArgs(index: number, total: number): {
|
|
127
|
+
args: string[];
|
|
128
|
+
env: Record<string, string>;
|
|
129
|
+
};
|
|
130
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// test-family.mts - the `--test` family of the app check cache (contexts/project/check-cache.md, "Unit coverage"). Each unit spec has a closure key: the sha256 of
|
|
2
|
+
// the spec, every module Jest's own resolver reaches from it (with the project's moduleNameMapper, jest.mock targets and setup files), its snapshot file and its
|
|
3
|
+
// project's configuration. A warm run runs only the specs whose key changed and keeps the records of the rest. The coverage of each spec is stored per source as
|
|
4
|
+
// covered or not covered items (never hit counts), a failed spec stores nothing, and the per-file threshold is judged on the merged total of every record, never on
|
|
5
|
+
// the partial run Jest itself saw. The lcov written is that merged total, the one Codecov and Sonar read. Integration, e2e and contract specs are not this family's:
|
|
6
|
+
// they always run in full (ALWAYS_FULL_PROJECTS).
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { combineHashes, hashFile } from './hash.mjs';
|
|
13
|
+
import { canonicalJson } from './jsonl.mjs';
|
|
14
|
+
/** The folder of the session that holds the records of this family. */
|
|
15
|
+
export const TEST_FOLDER = 'test-coverage';
|
|
16
|
+
/** The step of a unit spec's record. */
|
|
17
|
+
export const SPEC_STEP = 'unit-spec';
|
|
18
|
+
/** The Jest projects a warm run never skips: they reach services, data and time a key cannot hold. */
|
|
19
|
+
export const ALWAYS_FULL_PROJECTS = ['integration', 'e2e', 'contract'];
|
|
20
|
+
const posix = (file) => file.split(path.sep).join('/');
|
|
21
|
+
/** The snapshot file Jest keeps beside a spec. */
|
|
22
|
+
export function snapshotOf(spec) {
|
|
23
|
+
return `${path.posix.dirname(spec)}/__snapshots__/${path.posix.basename(spec)}.snap`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The closure key of a spec: the spec, each module of its closure and its snapshot by content hash (`absent` for one the work tree lacks, so a deleted dependency
|
|
27
|
+
* changes the key), and the project configuration. The same files give the same key on every host.
|
|
28
|
+
*/
|
|
29
|
+
export function specKey({ spec, closure, configHash, hashOf }) {
|
|
30
|
+
const parts = { '#config': configHash };
|
|
31
|
+
for (const file of [spec, snapshotOf(spec), ...closure])
|
|
32
|
+
parts[file] = hashOf(file) ?? 'absent';
|
|
33
|
+
return combineHashes(parts);
|
|
34
|
+
}
|
|
35
|
+
function isSourceItems(value) {
|
|
36
|
+
return typeof value === 'object' && value !== null && 's' in value && 'f' in value && 'b' in value;
|
|
37
|
+
}
|
|
38
|
+
/** The sources a stored record holds (app-relative paths), empty for a record that does not hold any. */
|
|
39
|
+
export function sourcesOf(record) {
|
|
40
|
+
const data = record.data;
|
|
41
|
+
const sources = typeof data === 'object' && data !== null && !Array.isArray(data) ? data['sources'] : undefined;
|
|
42
|
+
if (typeof sources !== 'object' || sources === null || Array.isArray(sources))
|
|
43
|
+
return {};
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [source, items] of Object.entries(sources))
|
|
46
|
+
if (isSourceItems(items))
|
|
47
|
+
out[source] = items;
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
function recordOf(spec, key, sources, side) {
|
|
51
|
+
const prefixed = {};
|
|
52
|
+
for (const [source, items] of Object.entries(sources))
|
|
53
|
+
prefixed[side === '' || side === '.' ? source : `${side}/${source}`] = items;
|
|
54
|
+
return { path: spec, step: SPEC_STEP, hash: key, findings: [], data: { sources: prefixed } };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Runs the unit specs against the cache. Diff mode runs the specs with no current record, full mode all of them; a passing spec's coverage is stored under its key,
|
|
58
|
+
* a failing one's is not, and the folder is replaced with exactly the current specs' records. The merged total is written to `<side>/coverage/lcov.info` and judged.
|
|
59
|
+
*/
|
|
60
|
+
export function runTestFamily(options) {
|
|
61
|
+
const { session, jest, side, merge, judge } = options;
|
|
62
|
+
const decision = session.decide('test');
|
|
63
|
+
const hashOf = (file) => session.index.hashOf(file) ?? hashFile(path.join(session.root, file));
|
|
64
|
+
const keys = new Map(jest.specs.map((spec) => [spec, specKey({ spec, closure: jest.closureOf(spec), configHash: jest.configHash, hashOf })]));
|
|
65
|
+
const stored = new Map(session.records(TEST_FOLDER).filter((record) => record.step === SPEC_STEP).map((record) => [record.path, record]));
|
|
66
|
+
const reusable = (spec) => {
|
|
67
|
+
const record = stored.get(spec);
|
|
68
|
+
return decision.mode === 'diff' && record !== undefined && record.hash === keys.get(spec) ? record : undefined;
|
|
69
|
+
};
|
|
70
|
+
const toRun = jest.specs.filter((spec) => reusable(spec) === undefined);
|
|
71
|
+
const kept = jest.specs.flatMap((spec) => reusable(spec) ?? []);
|
|
72
|
+
const progress = session.progress.step('test', 'unit specs', jest.specs.length);
|
|
73
|
+
progress.tick(kept.length, kept.length, 0);
|
|
74
|
+
const fresh = [];
|
|
75
|
+
const failed = [];
|
|
76
|
+
let output = '';
|
|
77
|
+
if (toRun.length > 0) {
|
|
78
|
+
const scratch = options.scratch ?? fs.mkdtempSync(path.join(os.tmpdir(), 'starci-spec-coverage-'));
|
|
79
|
+
try {
|
|
80
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
81
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
82
|
+
output = jest.run(toRun, scratch).output;
|
|
83
|
+
const reported = new Map(jest.readCoverage(scratch).map((file) => [file.spec, file]));
|
|
84
|
+
for (const spec of toRun) {
|
|
85
|
+
const file = reported.get(posix(path.relative(path.join(session.root, side), path.join(session.root, spec))));
|
|
86
|
+
const key = specKey({ spec, closure: jest.closureOf(spec), configHash: jest.configHash, hashOf });
|
|
87
|
+
// A spec that failed, did not report, or whose files changed while it ran stores nothing.
|
|
88
|
+
if (file?.passed === true && key === keys.get(spec))
|
|
89
|
+
fresh.push(recordOf(spec, key, file.sources, side));
|
|
90
|
+
else
|
|
91
|
+
failed.push(spec);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (options.scratch === undefined)
|
|
96
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
progress.tick(jest.specs.length, kept.length, toRun.length);
|
|
100
|
+
progress.end();
|
|
101
|
+
const records = [...kept, ...fresh];
|
|
102
|
+
session.replace(TEST_FOLDER, records);
|
|
103
|
+
let lcov;
|
|
104
|
+
let threshold;
|
|
105
|
+
if (records.length > 0) {
|
|
106
|
+
const rootDir = path.join(session.root, side);
|
|
107
|
+
const outDir = path.join(rootDir, 'coverage');
|
|
108
|
+
merge(records.map(sourcesOf), { outDir, reportRoot: rootDir, recordRoot: session.root });
|
|
109
|
+
lcov = path.join(outDir, 'lcov.info');
|
|
110
|
+
// A failed spec leaves its sources short in the total, so the threshold is judged only when every spec reported.
|
|
111
|
+
if (failed.length === 0)
|
|
112
|
+
threshold = judge(lcov);
|
|
113
|
+
}
|
|
114
|
+
const thresholdFindings = threshold?.findings.length ?? 0;
|
|
115
|
+
const report = {
|
|
116
|
+
family: 'test',
|
|
117
|
+
mode: decision.mode,
|
|
118
|
+
reason: decision.reason,
|
|
119
|
+
checked: toRun.length,
|
|
120
|
+
reused: kept.length,
|
|
121
|
+
newFindings: failed.length + thresholdFindings,
|
|
122
|
+
knownFindings: 0,
|
|
123
|
+
fixedFindings: 0,
|
|
124
|
+
};
|
|
125
|
+
session.report(report);
|
|
126
|
+
return { ok: failed.length === 0 && (threshold?.ok ?? records.length === 0), ran: toRun, reused: kept.map((record) => record.path), failed, lcov, threshold, report, output };
|
|
127
|
+
}
|
|
128
|
+
/** The digest of a project config with host paths turned into `<root>` and the keys that differ per host dropped. */
|
|
129
|
+
export function projectConfigHash(config) {
|
|
130
|
+
const root = config.rootDir;
|
|
131
|
+
const variants = [root, root.split(path.sep).join('/')].filter((value, index, all) => value !== '' && all.indexOf(value) === index);
|
|
132
|
+
const strip = (value) => {
|
|
133
|
+
if (typeof value === 'string')
|
|
134
|
+
return variants.reduce((text, variant) => text.split(variant).join('<root>'), value).split('\\').join('/');
|
|
135
|
+
if (Array.isArray(value))
|
|
136
|
+
return value.map(strip);
|
|
137
|
+
if (typeof value === 'object' && value !== null)
|
|
138
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !['cacheDirectory', 'id', 'cwd'].includes(key)).map(([key, item]) => [key, strip(item)]));
|
|
139
|
+
return value;
|
|
140
|
+
};
|
|
141
|
+
return combineHashes({ config: canonicalJson(strip(config)) });
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The app's own Jest, resolved from `jestFrom` (a package.json or folder inside the app's install): its unit project's configuration, its haste map and resolver
|
|
145
|
+
* (so `moduleNameMapper`, `jest.mock` targets and setup files count as Jest sees them), and its binary to run specs. `side` is the Jest root, app-relative.
|
|
146
|
+
*/
|
|
147
|
+
export async function createJestEnvironment({ appRoot, side, jestFrom, project = 'unit' }) {
|
|
148
|
+
const root = path.resolve(appRoot, side);
|
|
149
|
+
const fromApp = createRequire(jestFrom ?? path.join(root, 'package.json'));
|
|
150
|
+
const jestPackage = fromApp.resolve('jest/package.json');
|
|
151
|
+
const fromJest = createRequire(jestPackage);
|
|
152
|
+
const fromCore = createRequire(fromJest.resolve('@jest/core'));
|
|
153
|
+
const { readConfigs } = fromCore('jest-config');
|
|
154
|
+
const runtimeModule = fromCore('jest-runtime');
|
|
155
|
+
const Runtime = runtimeModule.default;
|
|
156
|
+
const { DependencyResolver } = fromCore('jest-resolve-dependencies');
|
|
157
|
+
const jestBin = path.join(path.dirname(jestPackage), 'bin', 'jest.js');
|
|
158
|
+
// The preset's config reads the working directory (the back end it describes), so it is read from the Jest root.
|
|
159
|
+
const previous = process.cwd();
|
|
160
|
+
let config;
|
|
161
|
+
let context;
|
|
162
|
+
try {
|
|
163
|
+
process.chdir(root);
|
|
164
|
+
const { configs } = await readConfigs({ $0: 'jest', _: [], selectProjects: [project] }, [root]);
|
|
165
|
+
config = configs.find((entry) => entry.displayName?.name === project);
|
|
166
|
+
if (config === undefined)
|
|
167
|
+
throw new Error(`the Jest config under ${side} has no project named ${project}`);
|
|
168
|
+
context = await Runtime.createContext(config, { maxWorkers: 1, watchman: false, console: { log: () => undefined, warn: () => undefined, error: () => undefined } });
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
process.chdir(previous);
|
|
172
|
+
}
|
|
173
|
+
const resolver = new DependencyResolver(context.resolver, context.hasteFS, undefined);
|
|
174
|
+
const tracked = (file) => !file.split(/[\\/]/u).includes('node_modules');
|
|
175
|
+
const closureFrom = (roots) => {
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const queue = roots.filter(tracked);
|
|
178
|
+
while (queue.length > 0) {
|
|
179
|
+
const file = queue.pop();
|
|
180
|
+
if (file === undefined || seen.has(file))
|
|
181
|
+
continue;
|
|
182
|
+
seen.add(file);
|
|
183
|
+
for (const next of resolver.resolve(file))
|
|
184
|
+
if (tracked(next) && !seen.has(next))
|
|
185
|
+
queue.push(next);
|
|
186
|
+
}
|
|
187
|
+
return [...seen];
|
|
188
|
+
};
|
|
189
|
+
const relative = (file) => posix(path.relative(appRoot, file));
|
|
190
|
+
const setup = (name) => [...(config[name] ?? [])];
|
|
191
|
+
const setupClosure = closureFrom([...setup('setupFiles'), ...setup('setupFilesAfterEnv')]).map(relative);
|
|
192
|
+
const listed = spawnSync(process.execPath, [jestBin, '--selectProjects', project, '--listTests'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
193
|
+
if (listed.status !== 0)
|
|
194
|
+
throw new Error(`jest --listTests failed under ${side}: ${(listed.stderr || listed.stdout).split('\n')[0]}`);
|
|
195
|
+
const specs = listed.stdout.split(/\r?\n/u).map((line) => line.trim()).filter((line) => path.isAbsolute(line)).map((line) => relative(path.resolve(line))).sort();
|
|
196
|
+
const closures = new Map();
|
|
197
|
+
return {
|
|
198
|
+
specs,
|
|
199
|
+
configHash: projectConfigHash(config),
|
|
200
|
+
closureOf(spec) {
|
|
201
|
+
const cached = closures.get(spec);
|
|
202
|
+
if (cached !== undefined)
|
|
203
|
+
return cached;
|
|
204
|
+
const own = closureFrom([path.resolve(appRoot, spec)]).map(relative).filter((file) => file !== spec);
|
|
205
|
+
const closure = [...new Set([...own, ...setupClosure])].sort();
|
|
206
|
+
closures.set(spec, closure);
|
|
207
|
+
return closure;
|
|
208
|
+
},
|
|
209
|
+
run(toRun, coverageDir) {
|
|
210
|
+
const base = [jestBin, '--selectProjects', project, '--coverage', '--ci'];
|
|
211
|
+
const everything = toRun.length === specs.length;
|
|
212
|
+
const sideRelative = toRun.map((spec) => posix(path.relative(root, path.resolve(appRoot, spec))));
|
|
213
|
+
// One process for all of them; a command line that would not fit (Windows) goes in several.
|
|
214
|
+
const chunks = everything ? [[]] : [];
|
|
215
|
+
for (const spec of everything ? [] : sideRelative) {
|
|
216
|
+
const last = chunks[chunks.length - 1];
|
|
217
|
+
if (last !== undefined && last.join(' ').length + spec.length < 24000)
|
|
218
|
+
last.push(spec);
|
|
219
|
+
else
|
|
220
|
+
chunks.push([spec]);
|
|
221
|
+
}
|
|
222
|
+
let status = 0;
|
|
223
|
+
let output = '';
|
|
224
|
+
for (const chunk of chunks) {
|
|
225
|
+
const args = chunk.length > 0 ? [...base, '--runTestsByPath', ...chunk] : base;
|
|
226
|
+
const result = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', env: { ...process.env, STARCI_SPEC_COVERAGE_DIR: coverageDir }, maxBuffer: 256 * 1024 * 1024 });
|
|
227
|
+
status = status || (result.status ?? 1);
|
|
228
|
+
output += `${result.stdout}${result.stderr}`;
|
|
229
|
+
}
|
|
230
|
+
return { status, output };
|
|
231
|
+
},
|
|
232
|
+
readCoverage(coverageDir) {
|
|
233
|
+
return fs.readdirSync(coverageDir).filter((name) => name.endsWith('.json')).map((name) => JSON.parse(fs.readFileSync(path.join(coverageDir, name), 'utf8')));
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** The installed preset's union merge (resolved like the shard merge of `app coverage`): the records in, the lcov and coverage-final.json out. */
|
|
238
|
+
export function installedSpecMerge(appRoot, from) {
|
|
239
|
+
return (records, { outDir, reportRoot, recordRoot }) => {
|
|
240
|
+
let load;
|
|
241
|
+
try {
|
|
242
|
+
load = createRequire(from ?? path.join(appRoot, 'package.json'))('@starci/jest-preset/spec-coverage');
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
throw new Error(`@starci/jest-preset/spec-coverage is not installed under ${appRoot} (${error.code ?? error.message}); install the canon @starci/jest-preset`);
|
|
246
|
+
}
|
|
247
|
+
load.mergeSpecCoverage({ records: [...records], outDir, projectRoot: reportRoot, recordRoot });
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The sharded full run of CI (`--test --full --shard i/n`): the shard's Jest run in the shape the shard merge reads, `coverage-final.json` of the files it loads,
|
|
252
|
+
* and nothing recorded. The merge job joins the shards (`starci app coverage merge`) and judges the merged report with `--coverage-from`.
|
|
253
|
+
*/
|
|
254
|
+
export function shardArgs(index, total) {
|
|
255
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 1 || total < index)
|
|
256
|
+
throw new Error(`--shard needs <i>/<n> with 1 <= i <= n, got ${index}/${total}`);
|
|
257
|
+
return { args: [`--shard=${index}/${total}`, '--coverage', '--ci', '--selectProjects', 'unit'], env: { STARCI_COVERAGE_SHARD: '1' } };
|
|
258
|
+
}
|