@starci/hfs 4.12.2 → 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 +35 -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 +74 -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,433 @@
|
|
|
1
|
+
// lint-family.mts - the `lint` family of the app check cache (contexts/project/check-cache.md, ESLint and stylelint): the per-file results of ESLint
|
|
2
|
+
// and stylelint, judged again only for what a change can have altered, on the records of `.starcistacks/cache/lint/<side>/`.
|
|
3
|
+
//
|
|
4
|
+
// ESLint's own `--cache` is not used. A file's result is reused when the side key is unchanged, or when the file is outside the dirty set:
|
|
5
|
+
// 1. the files that changed (hash differs from the record, added, removed) and every file that reaches one through imports, in the new import
|
|
6
|
+
// graph and the recorded one (import-graph.mts); a changed file that declares globals dirties the whole side;
|
|
7
|
+
// 2. every file whose project-graph findings (duplicate code, unused exports, tier direction, ...) differ from the recorded ones, taken from the
|
|
8
|
+
// installed canon's own `projectGraph` run once over the side; ESLint itself lints that file again, so ESLint stays the only emitter of a message;
|
|
9
|
+
// 3. every file whose declared cross-file inputs (`crossFileInputs` of the canons: siblings, listings, manifests, stylesheets) differ from the
|
|
10
|
+
// digest recorded with its result. A canon without the declaration makes the side a whole-side lint.
|
|
11
|
+
// The dirty set is linted by the same launcher, chunking and result validation as a full run (the `eslint` and `stylelint` seams); the rest of the
|
|
12
|
+
// scope takes its record. Everything the family needs of the world is a seam, so a spec drives it with a fake session and a fake linter.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
|
|
17
|
+
import { combineHashes, hashFile, hashText } from './hash.mjs';
|
|
18
|
+
import { buildImportGraph, dirtySetOf, posix } from './import-graph.mjs';
|
|
19
|
+
import { canonicalJson, reconcile, sortFindings } from './jsonl.mjs';
|
|
20
|
+
/** Record steps of the `lint/<side>` folder. */
|
|
21
|
+
export const LINT_STEPS = Object.freeze({ eslint: 'eslint', stylelint: 'stylelint', imports: 'imports', graph: 'graph', meta: 'meta' });
|
|
22
|
+
/** The record path of the side's own record (no file of the tree holds it). */
|
|
23
|
+
export const SIDE_RECORD = '.side';
|
|
24
|
+
// ---------------------------------------------------------------------------------------------------- findings <-> records
|
|
25
|
+
const byLocation = (a, b) => a.line - b.line || a.column - b.column || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0) || (a.message < b.message ? -1 : a.message > b.message ? 1 : 0) || a.end - b.end;
|
|
26
|
+
/** The findings of a record in the order they are stored, with their end lines. */
|
|
27
|
+
function fromEslint(side, file, result) {
|
|
28
|
+
const located = result.messages.map((message) => ({
|
|
29
|
+
line: message.line ?? 0, column: message.column ?? 0, rule: message.ruleId ?? 'eslint-error', message: message.message, end: message.endLine ?? 0,
|
|
30
|
+
}));
|
|
31
|
+
located.sort(byLocation);
|
|
32
|
+
return { findings: located.map((entry) => ({ path: `${side}/${file}`, line: entry.line, column: entry.column, rule: entry.rule, message: entry.message })), ends: located.map((entry) => entry.end) };
|
|
33
|
+
}
|
|
34
|
+
function fromStylelint(side, file, result) {
|
|
35
|
+
const located = result.warnings.map((warning) => ({
|
|
36
|
+
line: warning.line ?? 0, column: warning.column ?? 0, rule: warning.rule || 'stylelint-error', message: warning.text, end: warning.endLine ?? 0,
|
|
37
|
+
}));
|
|
38
|
+
located.sort(byLocation);
|
|
39
|
+
return { findings: located.map((entry) => ({ path: `${side}/${file}`, line: entry.line, column: entry.column, rule: entry.rule, message: entry.message })), ends: located.map((entry) => entry.end) };
|
|
40
|
+
}
|
|
41
|
+
const endsOf = (record) => {
|
|
42
|
+
const data = record.data;
|
|
43
|
+
const ends = data !== null && typeof data === 'object' && !Array.isArray(data) ? data['ends'] : undefined;
|
|
44
|
+
return Array.isArray(ends) ? ends.map((value) => (typeof value === 'number' ? value : 0)) : [];
|
|
45
|
+
};
|
|
46
|
+
const crossOf = (record) => {
|
|
47
|
+
const data = record?.data;
|
|
48
|
+
const cross = data !== null && typeof data === 'object' && !Array.isArray(data) ? data['cross'] : undefined;
|
|
49
|
+
return typeof cross === 'string' ? cross : undefined;
|
|
50
|
+
};
|
|
51
|
+
/** The ESLint result a record stands for. */
|
|
52
|
+
function toEslint(sideRoot, file, record) {
|
|
53
|
+
const ends = endsOf(record);
|
|
54
|
+
return {
|
|
55
|
+
filePath: path.join(sideRoot, file),
|
|
56
|
+
messages: record.findings.map((finding, at) => ({
|
|
57
|
+
ruleId: finding.rule === 'eslint-error' ? null : finding.rule, message: finding.message, line: finding.line, column: finding.column,
|
|
58
|
+
...((ends[at] ?? 0) > 0 ? { endLine: ends[at] } : {}), severity: 2,
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function toStylelint(sideRoot, file, record) {
|
|
63
|
+
const ends = endsOf(record);
|
|
64
|
+
return {
|
|
65
|
+
source: path.join(sideRoot, file),
|
|
66
|
+
warnings: record.findings.map((finding, at) => ({
|
|
67
|
+
rule: finding.rule === 'stylelint-error' ? '' : finding.rule, text: finding.message, line: finding.line, column: finding.column,
|
|
68
|
+
...((ends[at] ?? 0) > 0 ? { endLine: ends[at] } : {}), severity: 'error',
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const keyOf = (finding) => `${finding.path}\0${finding.line}\0${finding.column}\0${finding.rule}\0${finding.message}`;
|
|
73
|
+
// ---------------------------------------------------------------------------------------------------- cross-file inputs
|
|
74
|
+
/** The anchored expressions of a glob (braces expanded). */
|
|
75
|
+
const expressions = (glob) => braceVariants(glob).map((variant) => globExpression(variant));
|
|
76
|
+
const matchesAny = (patterns, file) => patterns.some((pattern) => pattern.test(file));
|
|
77
|
+
/**
|
|
78
|
+
* The digest of what the declared cross-file inputs of `file` hold now: for each declaration that applies, the matching paths of the app with
|
|
79
|
+
* their hashes (`content`) or alone (`exists`). Undefined when no declaration applies. `paths` are app-relative; side-relative globs are
|
|
80
|
+
* resolved below `<side>/`, so a glob may step out of the side with `../` to the app root.
|
|
81
|
+
*/
|
|
82
|
+
export function crossDigestFactory(options) {
|
|
83
|
+
const { side, declarations, paths, hashOf } = options;
|
|
84
|
+
const compiled = declarations.map((declaration) => ({ declaration, applies: declaration.appliesTo.flatMap(expressions) }));
|
|
85
|
+
const cache = new Map();
|
|
86
|
+
const matched = (index, directory) => {
|
|
87
|
+
const declaration = declarations[index];
|
|
88
|
+
const key = `${index}\0${declaration.relativeTo === 'file' ? directory : ''}`;
|
|
89
|
+
const known = cache.get(key);
|
|
90
|
+
if (known !== undefined)
|
|
91
|
+
return known;
|
|
92
|
+
const base = declaration.relativeTo === 'file' ? path.posix.join(side, directory) : side;
|
|
93
|
+
const resolved = declaration.globs.map((glob) => path.posix.normalize(path.posix.join(base, glob)));
|
|
94
|
+
const patterns = resolved.flatMap(expressions);
|
|
95
|
+
// a glob can only match below the folder in front of its first wildcard; testing the other paths would cost a regular expression each
|
|
96
|
+
const roots = resolved.flatMap((glob) => braceVariants(glob)).map((glob) => (glob.search(/[*?]/) < 0 ? glob : glob.slice(0, glob.search(/[*?]/))).replace(/[^/]*$/, ''));
|
|
97
|
+
const lines = paths.filter((candidate) => roots.some((root) => candidate.startsWith(root)) && matchesAny(patterns, candidate)).sort()
|
|
98
|
+
.map((candidate) => (declaration.kind === 'content' ? `${candidate}\0${hashOf(candidate) ?? 'absent'}` : candidate));
|
|
99
|
+
const digest = hashText(lines.join('\n'));
|
|
100
|
+
cache.set(key, digest);
|
|
101
|
+
return digest;
|
|
102
|
+
};
|
|
103
|
+
return (file) => {
|
|
104
|
+
const parts = {};
|
|
105
|
+
compiled.forEach((entry, index) => {
|
|
106
|
+
if (!matchesAny(entry.applies, file))
|
|
107
|
+
return;
|
|
108
|
+
if (entry.declaration.kind === 'git') {
|
|
109
|
+
const long = (options.lineCountOf?.(file) ?? Number.POSITIVE_INFINITY) > (entry.declaration.minLines ?? 0);
|
|
110
|
+
parts[`${index}:${entry.declaration.rule}:git`] = long ? `git:${options.gitToken ?? ''}` : 'short';
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
parts[`${index}:${entry.declaration.rule}:${entry.declaration.kind}`] = matched(index, path.posix.dirname(file) === '.' ? '' : path.posix.dirname(file));
|
|
114
|
+
});
|
|
115
|
+
return Object.keys(parts).length === 0 ? undefined : combineHashes(parts);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------------------------------- the app's TypeScript
|
|
119
|
+
/** The TypeScript the app installed, resolved from the side folder like the linters are (not this package's own copy). */
|
|
120
|
+
export function loadAppTypeScript(sideRoot) {
|
|
121
|
+
const require = createRequire(path.join(sideRoot, 'package.json'));
|
|
122
|
+
return require('typescript');
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------------------------------- one side
|
|
125
|
+
const readText = (file) => {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readFileSync(file, 'utf8');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const isFile = (file) => {
|
|
134
|
+
try {
|
|
135
|
+
return fs.statSync(file).isFile();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/** A record's `data` object, or an empty one. */
|
|
142
|
+
const dataOf = (record) => (record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data) ? record.data : {});
|
|
143
|
+
/** The graph findings of every canon that publishes graph rules, merged by file and sorted. */
|
|
144
|
+
function mergedSnapshot(sideRoot, canons) {
|
|
145
|
+
const merged = {};
|
|
146
|
+
for (const canon of canons) {
|
|
147
|
+
if (!canon.graphRules)
|
|
148
|
+
continue;
|
|
149
|
+
if (canon.projectGraph === null)
|
|
150
|
+
return null;
|
|
151
|
+
for (const [file, findings] of Object.entries(canon.projectGraph({ repoRoot: sideRoot }).byFile))
|
|
152
|
+
merged[posix(file)] = [...(merged[posix(file)] ?? []), ...findings];
|
|
153
|
+
}
|
|
154
|
+
for (const list of Object.values(merged))
|
|
155
|
+
list.sort((a, b) => a.line - b.line || a.column - b.column || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0) || (a.message < b.message ? -1 : a.message > b.message ? 1 : 0));
|
|
156
|
+
return merged;
|
|
157
|
+
}
|
|
158
|
+
const graphRecordFindings = (side, file, findings) => sortFindings(findings.map((finding) => ({ path: `${side}/${file}`, line: finding.line, column: finding.column, rule: finding.code, message: finding.message })));
|
|
159
|
+
/**
|
|
160
|
+
* Judges one side: ESLint (and, for fe, stylelint) over what the records cannot vouch for, the records of the rest, and the new records.
|
|
161
|
+
* Never throws for a tool failure: those come back in `errors`, and a side with an error stores nothing.
|
|
162
|
+
*/
|
|
163
|
+
export async function lintSide(input) {
|
|
164
|
+
const { session, side, sideRoot, scope, stylesheets } = input;
|
|
165
|
+
const inReport = input.report ?? (() => true);
|
|
166
|
+
const scopeSet = new Set(scope);
|
|
167
|
+
const styleSet = new Set(stylesheets);
|
|
168
|
+
const folder = `lint/${side}`;
|
|
169
|
+
const prefix = `${side}/`;
|
|
170
|
+
const errors = [];
|
|
171
|
+
const abs = (file) => path.join(sideRoot, file);
|
|
172
|
+
// what the tree holds now
|
|
173
|
+
const memo = new Map();
|
|
174
|
+
const hashOf = (file) => {
|
|
175
|
+
if (!memo.has(file))
|
|
176
|
+
memo.set(file, session.index.hashOf(`${prefix}${file}`) ?? hashFile(abs(file)));
|
|
177
|
+
return memo.get(file);
|
|
178
|
+
};
|
|
179
|
+
const trackedHere = session.index.paths.filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length));
|
|
180
|
+
const sidePaths = [...new Set([...trackedHere, ...scope, ...stylesheets])].sort();
|
|
181
|
+
const sideKey = combineHashes(Object.fromEntries(sidePaths.map((file) => [file, hashOf(file) ?? 'absent'])));
|
|
182
|
+
const pathsDigest = hashText(sidePaths.join('\n'));
|
|
183
|
+
const appHash = (appRelative) => session.index.hashOf(appRelative) ?? (appRelative.startsWith(prefix) ? hashOf(appRelative.slice(prefix.length)) : hashFile(path.join(session.root, appRelative)));
|
|
184
|
+
const appPaths = [...new Set([...session.index.paths, ...scope.map((file) => `${prefix}${file}`), ...stylesheets.map((file) => `${prefix}${file}`)])];
|
|
185
|
+
// what the cache holds
|
|
186
|
+
const stored = session.records(folder);
|
|
187
|
+
const ofStep = (step) => stored.filter((record) => record.step === step);
|
|
188
|
+
const validEslint = new Map(reconcile(ofStep(LINT_STEPS.eslint), hashOf).map((record) => [record.path, record]));
|
|
189
|
+
const validStyle = new Map(reconcile(ofStep(LINT_STEPS.stylelint), hashOf).map((record) => [record.path, record]));
|
|
190
|
+
const meta = ofStep(LINT_STEPS.meta).find((record) => record.path === SIDE_RECORD);
|
|
191
|
+
const metaData = meta === undefined ? {} : dataOf(meta);
|
|
192
|
+
const previousFindings = new Set([...ofStep(LINT_STEPS.eslint), ...ofStep(LINT_STEPS.stylelint)].filter((record) => inReport(record.path)).flatMap((record) => record.findings.map(keyOf)));
|
|
193
|
+
const progress = session.progress.step('lint', `lint ${side}`, scope.length + stylesheets.length);
|
|
194
|
+
// ------------------------------------------------------------------ the plan
|
|
195
|
+
let whole = input.full;
|
|
196
|
+
let dirtyEslint = new Set();
|
|
197
|
+
let dirtyStyle = new Set();
|
|
198
|
+
let build = null;
|
|
199
|
+
let snapshot = null;
|
|
200
|
+
const crossEslint = new Map();
|
|
201
|
+
const crossStyle = new Map();
|
|
202
|
+
const sideUnchanged = whole === null && meta !== undefined && meta.hash === sideKey && metaData['extra'] === input.extraKey
|
|
203
|
+
&& scope.every((file) => validEslint.has(file)) && stylesheets.every((file) => validStyle.has(file))
|
|
204
|
+
&& ofStep(LINT_STEPS.eslint).every((record) => scopeSet.has(record.path)) && ofStep(LINT_STEPS.stylelint).every((record) => styleSet.has(record.path))
|
|
205
|
+
// a file the program reads that is no lint source (a JSON module, untracked generated code) is part of the side too
|
|
206
|
+
&& ofStep(LINT_STEPS.imports).filter((record) => !scopeSet.has(record.path)).every((record) => hashOf(record.path) === record.hash);
|
|
207
|
+
const lineCountOf = (file) => {
|
|
208
|
+
const text = readText(abs(file));
|
|
209
|
+
if (text === null)
|
|
210
|
+
return 0;
|
|
211
|
+
const lines = text.split(/\r?\n/);
|
|
212
|
+
return lines.at(-1) === '' ? lines.length - 1 : lines.length;
|
|
213
|
+
};
|
|
214
|
+
const crossFor = (canons) => crossDigestFactory({ side, declarations: canons.flatMap((canon) => canon.crossFileInputs ?? []), paths: appPaths, hashOf: appHash, gitToken: input.gitToken ?? '', lineCountOf });
|
|
215
|
+
const eslintCross = crossFor(input.canons);
|
|
216
|
+
const styleCross = crossFor(input.styleCanon === null ? [] : [input.styleCanon]);
|
|
217
|
+
if (!sideUnchanged) {
|
|
218
|
+
for (const file of scope)
|
|
219
|
+
crossEslint.set(file, eslintCross(file));
|
|
220
|
+
for (const file of stylesheets)
|
|
221
|
+
crossStyle.set(file, styleCross(file));
|
|
222
|
+
}
|
|
223
|
+
let reason = whole === null ? '' : `${whole}`;
|
|
224
|
+
if (whole === null && !sideUnchanged) {
|
|
225
|
+
// a plan only a changed side needs
|
|
226
|
+
const undeclared = input.canons.find((canon) => canon.crossFileInputs === null);
|
|
227
|
+
const styleUndeclared = input.styleCanon !== null && input.styleCanon.crossFileInputs === null;
|
|
228
|
+
if (meta === undefined)
|
|
229
|
+
whole = 'no recorded lint of this side';
|
|
230
|
+
else if (metaData['extra'] !== input.extraKey)
|
|
231
|
+
whole = 'the untracked program inputs of the side changed';
|
|
232
|
+
else if (undeclared !== undefined)
|
|
233
|
+
whole = `${undeclared.name} declares no crossFileInputs`;
|
|
234
|
+
else if (input.canons.some((canon) => canon.graphRules && canon.projectGraph === null))
|
|
235
|
+
whole = `${input.canons.find((canon) => canon.graphRules)?.name ?? 'the canon'} exports no projectGraph`;
|
|
236
|
+
if (whole === null) {
|
|
237
|
+
try {
|
|
238
|
+
const ts = input.typescript();
|
|
239
|
+
const previous = new Map();
|
|
240
|
+
for (const record of ofStep(LINT_STEPS.imports)) {
|
|
241
|
+
const data = dataOf(record);
|
|
242
|
+
const imports = Array.isArray(data['imports']) ? data['imports'].filter((value) => typeof value === 'string') : [];
|
|
243
|
+
const kept = previous.get(record.path);
|
|
244
|
+
if (kept === undefined || record.hash === hashOf(record.path))
|
|
245
|
+
previous.set(record.path, { hash: record.hash, imports, declaresGlobals: data['globals'] === true });
|
|
246
|
+
}
|
|
247
|
+
const recordedGraph = previous.size === 0 ? null : previous;
|
|
248
|
+
build = buildImportGraph({ ts, sideRoot, readText, fileExists: isFile, hashOf: (file) => hashOf(file) ?? null }, scope, recordedGraph, { pathsChanged: metaData['paths'] !== pathsDigest });
|
|
249
|
+
const dirty = dirtySetOf(recordedGraph, build.graph);
|
|
250
|
+
if (dirty.wholeSide !== null)
|
|
251
|
+
whole = dirty.wholeSide;
|
|
252
|
+
else
|
|
253
|
+
dirtyEslint = new Set([...dirty.dirty].filter((file) => scopeSet.has(file)));
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
whole = `the import graph of the side cannot be built: ${String(error?.message ?? error).split('\n')[0]}`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (whole === null) {
|
|
260
|
+
for (const file of scope) {
|
|
261
|
+
const valid = validEslint.get(file);
|
|
262
|
+
if (valid === undefined)
|
|
263
|
+
dirtyEslint.add(file);
|
|
264
|
+
else if (crossOf(valid) !== crossEslint.get(file))
|
|
265
|
+
dirtyEslint.add(file);
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
snapshot = mergedSnapshot(sideRoot, input.canons);
|
|
269
|
+
if (snapshot === null)
|
|
270
|
+
whole = 'a canon exports no projectGraph';
|
|
271
|
+
else {
|
|
272
|
+
const recorded = new Map(ofStep(LINT_STEPS.graph).filter((record) => record.hash === hashOf(record.path)).map((record) => [record.path, canonicalJson(record.findings)]));
|
|
273
|
+
const files = new Set([...scope.filter((file) => validEslint.has(file)), ...recorded.keys()]);
|
|
274
|
+
for (const file of files) {
|
|
275
|
+
if (!scopeSet.has(file))
|
|
276
|
+
continue;
|
|
277
|
+
const now = canonicalJson(graphRecordFindings(side, file, snapshot[file] ?? []));
|
|
278
|
+
if ((recorded.get(file) ?? '[]') !== now)
|
|
279
|
+
dirtyEslint.add(file);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
whole = `the project graph of the side cannot be built: ${String(error?.message ?? error).split('\n')[0]}`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// stylelint
|
|
288
|
+
const recordedStyles = new Set(ofStep(LINT_STEPS.stylelint).map((record) => record.path));
|
|
289
|
+
const styleChanged = [...recordedStyles].some((file) => !styleSet.has(file)) || stylesheets.some((file) => !recordedStyles.has(file));
|
|
290
|
+
if (styleUndeclared)
|
|
291
|
+
dirtyStyle = new Set(stylesheets);
|
|
292
|
+
else if (styleChanged)
|
|
293
|
+
dirtyStyle = new Set(stylesheets);
|
|
294
|
+
else
|
|
295
|
+
for (const file of stylesheets) {
|
|
296
|
+
const valid = validStyle.get(file);
|
|
297
|
+
if (valid === undefined || crossOf(valid) !== crossStyle.get(file))
|
|
298
|
+
dirtyStyle.add(file);
|
|
299
|
+
}
|
|
300
|
+
reason = whole ?? `${dirtyEslint.size} of ${scope.length} source files and ${dirtyStyle.size} of ${stylesheets.length} stylesheets differ from the cache`;
|
|
301
|
+
}
|
|
302
|
+
else if (whole === null)
|
|
303
|
+
reason = 'the side key is unchanged';
|
|
304
|
+
if (whole !== null) {
|
|
305
|
+
dirtyEslint = new Set(scope);
|
|
306
|
+
dirtyStyle = new Set(stylesheets);
|
|
307
|
+
}
|
|
308
|
+
// ------------------------------------------------------------------ the run
|
|
309
|
+
const eslintList = [...dirtyEslint].filter(inReport).sort();
|
|
310
|
+
const styleList = [...dirtyStyle].filter(inReport).sort();
|
|
311
|
+
const freshEslint = new Map();
|
|
312
|
+
const freshStyle = new Map();
|
|
313
|
+
const rootOf = (file) => path.relative(sideRoot, file).replace(/\\/g, '/');
|
|
314
|
+
if (eslintList.length > 0) {
|
|
315
|
+
const run = await input.eslint(whole !== null ? null : eslintList);
|
|
316
|
+
errors.push(...run.errors);
|
|
317
|
+
for (const result of run.results)
|
|
318
|
+
freshEslint.set(posix(rootOf(result.filePath)), result);
|
|
319
|
+
}
|
|
320
|
+
if (input.stylelint !== null && styleList.length > 0) {
|
|
321
|
+
const run = await input.stylelint(whole !== null ? null : styleList);
|
|
322
|
+
errors.push(...run.errors);
|
|
323
|
+
for (const result of run.results)
|
|
324
|
+
freshStyle.set(posix(rootOf(result.source)), result);
|
|
325
|
+
}
|
|
326
|
+
// ------------------------------------------------------------------ the answer
|
|
327
|
+
const eslintResults = [];
|
|
328
|
+
const styleResults = [];
|
|
329
|
+
const current = [];
|
|
330
|
+
const eslintRecords = [];
|
|
331
|
+
const styleRecords = [];
|
|
332
|
+
const planHash = (file) => hashOf(file) ?? 'absent';
|
|
333
|
+
for (const file of scope) {
|
|
334
|
+
if (!inReport(file))
|
|
335
|
+
continue;
|
|
336
|
+
const fresh = freshEslint.get(file);
|
|
337
|
+
const reuse = fresh === undefined ? validEslint.get(file) : undefined;
|
|
338
|
+
if (fresh !== undefined) {
|
|
339
|
+
eslintResults.push(fresh);
|
|
340
|
+
const converted = fromEslint(side, file, fresh);
|
|
341
|
+
current.push(...converted.findings);
|
|
342
|
+
const cross = crossEslint.get(file);
|
|
343
|
+
eslintRecords.push({ path: file, step: LINT_STEPS.eslint, hash: planHash(file), findings: converted.findings, data: { ends: converted.ends, ...(cross === undefined ? {} : { cross }) } });
|
|
344
|
+
}
|
|
345
|
+
else if (reuse !== undefined) {
|
|
346
|
+
eslintResults.push(toEslint(sideRoot, file, reuse));
|
|
347
|
+
current.push(...reuse.findings);
|
|
348
|
+
eslintRecords.push(reuse);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const file of stylesheets) {
|
|
352
|
+
if (!inReport(file))
|
|
353
|
+
continue;
|
|
354
|
+
const fresh = freshStyle.get(file);
|
|
355
|
+
const reuse = fresh === undefined ? validStyle.get(file) : undefined;
|
|
356
|
+
if (fresh !== undefined) {
|
|
357
|
+
styleResults.push(fresh);
|
|
358
|
+
const converted = fromStylelint(side, file, fresh);
|
|
359
|
+
current.push(...converted.findings);
|
|
360
|
+
const cross = crossStyle.get(file);
|
|
361
|
+
styleRecords.push({ path: file, step: LINT_STEPS.stylelint, hash: planHash(file), findings: converted.findings, data: { ends: converted.ends, ...(cross === undefined ? {} : { cross }) } });
|
|
362
|
+
}
|
|
363
|
+
else if (reuse !== undefined) {
|
|
364
|
+
styleResults.push(toStylelint(sideRoot, file, reuse));
|
|
365
|
+
current.push(...reuse.findings);
|
|
366
|
+
styleRecords.push(reuse);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const checked = freshEslint.size + freshStyle.size;
|
|
370
|
+
const units = [...scope, ...stylesheets].filter(inReport).length;
|
|
371
|
+
const reused = Math.max(0, units - checked);
|
|
372
|
+
progress.tick(units, reused, checked);
|
|
373
|
+
progress.end();
|
|
374
|
+
// ------------------------------------------------------------------ the records
|
|
375
|
+
let written = false;
|
|
376
|
+
const complete = errors.length === 0 && input.write && input.report === null && !sideUnchanged;
|
|
377
|
+
if (complete) {
|
|
378
|
+
const stable = [...freshEslint.keys(), ...freshStyle.keys()].every((file) => hashFile(abs(file)) === hashOf(file));
|
|
379
|
+
if (stable) {
|
|
380
|
+
const records = [...eslintRecords, ...styleRecords];
|
|
381
|
+
// without an import graph or a project graph the next run lints the side whole; the results themselves are still stored
|
|
382
|
+
try {
|
|
383
|
+
if (build === null) {
|
|
384
|
+
const ts = input.typescript();
|
|
385
|
+
build = buildImportGraph({ ts, sideRoot, readText, fileExists: isFile, hashOf: (file) => hashOf(file) ?? null }, scope, null);
|
|
386
|
+
}
|
|
387
|
+
for (const [file, node] of build.graph)
|
|
388
|
+
records.push({ path: file, step: LINT_STEPS.imports, hash: node.hash, findings: [], data: { globals: node.declaresGlobals, imports: node.imports } });
|
|
389
|
+
snapshot ??= mergedSnapshot(sideRoot, input.canons);
|
|
390
|
+
for (const [file, findings] of Object.entries(snapshot ?? {})) {
|
|
391
|
+
if (scopeSet.has(file) && findings.length > 0)
|
|
392
|
+
records.push({ path: file, step: LINT_STEPS.graph, hash: planHash(file), findings: graphRecordFindings(side, file, findings) });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
records.splice(0, records.length, ...records.filter((record) => record.step === LINT_STEPS.eslint || record.step === LINT_STEPS.stylelint));
|
|
397
|
+
}
|
|
398
|
+
records.push({ path: SIDE_RECORD, step: LINT_STEPS.meta, hash: sideKey, findings: [], data: { extra: input.extraKey, files: scope.length, paths: pathsDigest, stylesheets: stylesheets.length } });
|
|
399
|
+
session.replace(folder, records);
|
|
400
|
+
written = true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const currentKeys = new Set(current.map(keyOf));
|
|
404
|
+
let knownFindings = 0;
|
|
405
|
+
for (const key of currentKeys)
|
|
406
|
+
if (previousFindings.has(key))
|
|
407
|
+
knownFindings += 1;
|
|
408
|
+
const stats = {
|
|
409
|
+
mode: whole !== null || input.full !== null ? 'full' : 'diff',
|
|
410
|
+
reason,
|
|
411
|
+
checked,
|
|
412
|
+
reused,
|
|
413
|
+
newFindings: currentKeys.size - knownFindings,
|
|
414
|
+
knownFindings,
|
|
415
|
+
fixedFindings: [...previousFindings].filter((key) => !currentKeys.has(key)).length,
|
|
416
|
+
written,
|
|
417
|
+
};
|
|
418
|
+
return { eslint: eslintResults, stylelint: styleResults, errors, stats };
|
|
419
|
+
}
|
|
420
|
+
/** The family line of a run over several sides: one report for the family, the sides' counts added and their reasons joined. */
|
|
421
|
+
export function lintFamilyReport(sides) {
|
|
422
|
+
const sum = (pick) => sides.reduce((total, entry) => total + pick(entry.stats), 0);
|
|
423
|
+
return {
|
|
424
|
+
family: 'lint',
|
|
425
|
+
mode: sides.length > 0 && sides.every((entry) => entry.stats.mode === 'full') ? 'full' : 'diff',
|
|
426
|
+
reason: sides.map((entry) => `${entry.side}: ${entry.stats.reason}`).join('; '),
|
|
427
|
+
checked: sum((stats) => stats.checked),
|
|
428
|
+
reused: sum((stats) => stats.reused),
|
|
429
|
+
newFindings: sum((stats) => stats.newFindings),
|
|
430
|
+
knownFindings: sum((stats) => stats.knownFindings),
|
|
431
|
+
fixedFindings: sum((stats) => stats.fixedFindings),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const compare: (a: string, b: string) => number;
|
package/cache/order.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProgressSink } from './types.mjs';
|
|
2
|
+
export interface ProgressOptions {
|
|
3
|
+
/** Where lines go (stderr). */
|
|
4
|
+
write: (text: string) => unknown;
|
|
5
|
+
/** True when `write` reaches a terminal: running lines then rewrite in place. */
|
|
6
|
+
tty?: boolean;
|
|
7
|
+
/** Milliseconds since the epoch; a seam for specs. */
|
|
8
|
+
now?: () => number;
|
|
9
|
+
/** Files the whole run is expected to touch, for the overall percentage; else the steps seen so far. */
|
|
10
|
+
planned?: number;
|
|
11
|
+
/** Seconds of work between running lines. */
|
|
12
|
+
everySeconds?: number;
|
|
13
|
+
}
|
|
14
|
+
/** `3m07s`, `42s`, `1h02m` for a duration in milliseconds. */
|
|
15
|
+
export declare function elapsedText(ms: number): string;
|
|
16
|
+
/** The sink that prints nothing (`--quiet`, and the library default). */
|
|
17
|
+
export declare const silentProgress: ProgressSink;
|
|
18
|
+
/** The stderr progress sink. */
|
|
19
|
+
export declare function createProgress(options: ProgressOptions): ProgressSink;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
2
|
+
/** `3m07s`, `42s`, `1h02m` for a duration in milliseconds. */
|
|
3
|
+
export function elapsedText(ms) {
|
|
4
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
5
|
+
const hours = Math.floor(total / 3600);
|
|
6
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
7
|
+
const seconds = total % 60;
|
|
8
|
+
if (hours > 0)
|
|
9
|
+
return `${hours}h${pad(minutes)}m`;
|
|
10
|
+
return minutes > 0 ? `${minutes}m${pad(seconds)}s` : `${seconds}s`;
|
|
11
|
+
}
|
|
12
|
+
const clock = (at) => {
|
|
13
|
+
const d = new Date(at);
|
|
14
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
15
|
+
};
|
|
16
|
+
const percent = (done, total) => (total <= 0 ? 100 : Math.min(100, Math.floor((done / total) * 100)));
|
|
17
|
+
/** The sink that prints nothing (`--quiet`, and the library default). */
|
|
18
|
+
export const silentProgress = {
|
|
19
|
+
step: () => ({ tick: () => { }, end: () => { } }),
|
|
20
|
+
};
|
|
21
|
+
/** The stderr progress sink. */
|
|
22
|
+
export function createProgress(options) {
|
|
23
|
+
const now = options.now ?? Date.now;
|
|
24
|
+
const started = now();
|
|
25
|
+
const every = (options.everySeconds ?? 10) * 1000;
|
|
26
|
+
let registered = 0;
|
|
27
|
+
let finished = 0;
|
|
28
|
+
let open = false;
|
|
29
|
+
const prefix = (done) => {
|
|
30
|
+
const total = Math.max(options.planned ?? 0, registered);
|
|
31
|
+
return `[${clock(now())} +${elapsedText(now() - started)} ${percent(finished + done, total)}%]`;
|
|
32
|
+
};
|
|
33
|
+
const emit = (text, running) => {
|
|
34
|
+
if (options.tty === true && running) {
|
|
35
|
+
options.write(`\r${text}`);
|
|
36
|
+
open = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
options.write(`${open ? '\r' : ''}${text}\n`);
|
|
40
|
+
open = false;
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
step(family, name, total) {
|
|
44
|
+
registered += total;
|
|
45
|
+
let done = 0;
|
|
46
|
+
const stepStart = now();
|
|
47
|
+
let lastAt = stepStart;
|
|
48
|
+
let lastTenth = 0;
|
|
49
|
+
const body = (reused, checked) => `${family} ${name} ${done}/${total} (${percent(done, total)}%) reused ${reused} checked ${checked}`;
|
|
50
|
+
emit(`${prefix(0)} ${family} ${name} start, ${total} files`, false);
|
|
51
|
+
return {
|
|
52
|
+
tick(newDone, reused, checked) {
|
|
53
|
+
done = newDone;
|
|
54
|
+
const tenth = Math.floor(percent(done, total) / 10);
|
|
55
|
+
if (now() - lastAt < every && tenth <= lastTenth)
|
|
56
|
+
return;
|
|
57
|
+
lastAt = now();
|
|
58
|
+
lastTenth = tenth;
|
|
59
|
+
emit(`${prefix(done)} ${body(reused, checked)}`, true);
|
|
60
|
+
},
|
|
61
|
+
end() {
|
|
62
|
+
finished += total;
|
|
63
|
+
emit(`${prefix(0)} ${family} ${name} done, ${total} files in ${elapsedText(now() - stepStart)}`, false);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { CacheRecord, CheckSession, FamilyReport } from './types.mjs';
|
|
2
|
+
export declare const SONAR_FOLDER = "sonar-baseline";
|
|
3
|
+
export declare const CODECOV_FOLDER = "codecov-baseline";
|
|
4
|
+
/** The step of every reference record. */
|
|
5
|
+
export declare const REFERENCE_STEP = "reference";
|
|
6
|
+
/** The label every prediction carries and every printed line states. */
|
|
7
|
+
export declare const PREDICTION = "prediction";
|
|
8
|
+
/** The new-code conditions of the Sonar quality gate, as its owner (`runtime/knowledge/sonar-gate.yaml`) states them. */
|
|
9
|
+
export interface SonarGate {
|
|
10
|
+
readonly coverageMinPercent: number;
|
|
11
|
+
readonly duplicationMaxPercent: number;
|
|
12
|
+
}
|
|
13
|
+
/** The targets of codecov.yml, in percent. */
|
|
14
|
+
export interface CodecovTargets {
|
|
15
|
+
readonly project: number;
|
|
16
|
+
readonly patch: number;
|
|
17
|
+
}
|
|
18
|
+
/** The gate conditions the predictions are compared with, read from the gate document (`newCode.coverage.minPercent`, `newCode.duplication.maxPercent`). */
|
|
19
|
+
export declare function readSonarGate(gateYaml: string): SonarGate;
|
|
20
|
+
/** The patch and project targets of codecov.yml (`coverage.status.{project,patch}.default.target`, `90%`). */
|
|
21
|
+
export declare function readCodecovTargets(codecovYaml: string): CodecovTargets;
|
|
22
|
+
/** The digest of the analysis scope settings of sonar-project.properties: the settings that decide which lines Sonar sees, sorted, nothing else. */
|
|
23
|
+
export declare function scopeDigest(propertiesText: string): string;
|
|
24
|
+
/** What the reference records are built from. Texts are the owners' files as they stand; the base commit is the merge-base the new code is measured against. */
|
|
25
|
+
export interface BaselineSources {
|
|
26
|
+
readonly baseCommit: string;
|
|
27
|
+
readonly sonarGateYaml: string;
|
|
28
|
+
readonly sonarProperties: string;
|
|
29
|
+
readonly codecovYaml: string;
|
|
30
|
+
}
|
|
31
|
+
/** The records of the two baseline folders: reference data and digests, never a result of a server. */
|
|
32
|
+
export declare function baselineRecords(sources: BaselineSources): {
|
|
33
|
+
readonly sonar: readonly CacheRecord[];
|
|
34
|
+
readonly codecov: readonly CacheRecord[];
|
|
35
|
+
};
|
|
36
|
+
/** The reference parts already stored whose digest differs from the current one (a first adoption has none): the gate, the scope or the targets moved since the baseline was written. */
|
|
37
|
+
export declare function baselineDrift(stored: readonly CacheRecord[], current: readonly CacheRecord[]): string[];
|
|
38
|
+
/** Per file, the lines added or changed since the base commit (new-code lines). */
|
|
39
|
+
export type ChangedLines = Readonly<Record<string, readonly number[]>>;
|
|
40
|
+
/** The lines a `git diff -U0 <base>` adds or changes, per file (`+++ b/path`, hunk headers `@@ -a,b +c,d @@`); a deleted file has none. */
|
|
41
|
+
export declare function changedLinesFromDiff(diff: string): ChangedLines;
|
|
42
|
+
/** Per source, its instrumented lines and the lines the whole suite covers. */
|
|
43
|
+
export type LineCoverage = Readonly<Record<string, {
|
|
44
|
+
readonly instrumented: readonly number[];
|
|
45
|
+
readonly covered: readonly number[];
|
|
46
|
+
}>>;
|
|
47
|
+
/**
|
|
48
|
+
* The line coverage the unit-spec records stand for: a line is instrumented when a statement starts on it and covered when any spec covers one that does. Equal for a
|
|
49
|
+
* base commit's committed records and the work tree's, so the project change is computed on the same footing.
|
|
50
|
+
*/
|
|
51
|
+
export declare function lineCoverageOf(records: readonly CacheRecord[]): LineCoverage;
|
|
52
|
+
/** The base commit's coverage records, read from the commit itself (`git show <base>:<path>`), so the base total is computed and not stored. */
|
|
53
|
+
export declare function recordsAtCommit(baseCommit: string, git: (args: readonly string[]) => string | undefined, folder?: string): CacheRecord[];
|
|
54
|
+
/** `git` run in `cwd`, undefined when it fails (an unreadable base commit is no base total, not a crash). */
|
|
55
|
+
export declare function gitRunner(cwd: string): (args: readonly string[]) => string | undefined;
|
|
56
|
+
/** A duplicated block of a source: where the clones step found it. */
|
|
57
|
+
export interface CloneBlock {
|
|
58
|
+
readonly path: string;
|
|
59
|
+
readonly startLine: number;
|
|
60
|
+
readonly endLine: number;
|
|
61
|
+
}
|
|
62
|
+
export interface QualityInputs {
|
|
63
|
+
readonly changed: ChangedLines;
|
|
64
|
+
readonly coverage: LineCoverage;
|
|
65
|
+
/** The base commit's coverage, or undefined when it cannot be read (then no project change is predicted). */
|
|
66
|
+
readonly baseCoverage: LineCoverage | undefined;
|
|
67
|
+
readonly clones: readonly CloneBlock[];
|
|
68
|
+
readonly gate: SonarGate;
|
|
69
|
+
readonly targets: CodecovTargets;
|
|
70
|
+
/** The files Sonar and Codecov measure (the coverage scope), or every covered file when absent. */
|
|
71
|
+
readonly measured?: (path: string) => boolean;
|
|
72
|
+
/** The files Sonar counts new code in (its sources minus its exclusions), or every changed file when absent. */
|
|
73
|
+
readonly analysed?: (path: string) => boolean;
|
|
74
|
+
}
|
|
75
|
+
/** One prediction. `value` is undefined when nothing in the change could be measured; it is never a verdict. */
|
|
76
|
+
export interface Prediction {
|
|
77
|
+
readonly label: typeof PREDICTION;
|
|
78
|
+
readonly provider: 'sonar' | 'codecov';
|
|
79
|
+
readonly metric: string;
|
|
80
|
+
readonly value: number | undefined;
|
|
81
|
+
/** The numbers the percentage comes from, as text (`28 of 30 changed lines`). */
|
|
82
|
+
readonly basis: string;
|
|
83
|
+
/** What the server holds the metric to, from the baseline. */
|
|
84
|
+
readonly target: string;
|
|
85
|
+
}
|
|
86
|
+
/** Sonar's new-code coverage and duplication and Codecov's patch coverage and project change, each labelled as a prediction. */
|
|
87
|
+
export declare function predict(inputs: QualityInputs): Prediction[];
|
|
88
|
+
/** The lines a run prints: each states that it is a local prediction and what the server holds the metric to. */
|
|
89
|
+
export declare function formatPredictions(predictions: readonly Prediction[]): string[];
|
|
90
|
+
export interface QualityFamilyResult {
|
|
91
|
+
readonly predictions: readonly Prediction[];
|
|
92
|
+
readonly lines: readonly string[];
|
|
93
|
+
/** The reference parts whose owner changed since the baseline was written. */
|
|
94
|
+
readonly drift: readonly string[];
|
|
95
|
+
readonly report: FamilyReport;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The `--sonar` family: writes the two baseline folders from the owners' files and predicts from the merged coverage. It always runs (the predictions read the changed
|
|
99
|
+
* lines, which no key holds) and it never fails a run: `ok` does not exist here, the servers judge.
|
|
100
|
+
*/
|
|
101
|
+
export declare function runQualityFamily({ session, sources, changed, clones, baseCoverage, measured, analysed }: {
|
|
102
|
+
session: CheckSession;
|
|
103
|
+
sources: BaselineSources;
|
|
104
|
+
changed: ChangedLines;
|
|
105
|
+
clones: readonly CloneBlock[];
|
|
106
|
+
baseCoverage: LineCoverage | undefined;
|
|
107
|
+
measured?: (path: string) => boolean;
|
|
108
|
+
analysed?: (path: string) => boolean;
|
|
109
|
+
}): QualityFamilyResult;
|