@plainconceptsplatform/workflows 1.10.1 → 1.11.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/README.md +1 -1
- package/dist/catalog-installation.js +5 -0
- package/dist/catalog-listing.js +6 -1
- package/dist/index.js +27 -4
- package/dist/template-bundles.d.ts +21 -0
- package/dist/template-bundles.js +108 -0
- package/dist/workflow-catalog.d.ts +5 -1
- package/dist/workflow-catalog.js +8 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
- package/loops/templates/quality-gates/.jscpd.json +36 -0
- package/loops/templates/quality-gates/.jscpd.json.md +7 -0
- package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
- package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
- package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
- package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
- package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
- package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
- package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
- package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
- package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
// Vendored from PlainConceptsPlatform/Atlas scripts/lib/engineering-excellence.mjs (Atlas#1939, hygiene
|
|
3
|
+
// from Atlas#1942). Family changes: `moduleOf` reads any <App>.<Project> assembly, and the hygiene
|
|
4
|
+
// Semgrep rules are .semgrep/platform-hygiene.yml. The facet contract, scripts/engineering-excellence.schema.json,
|
|
5
|
+
// is vendored verbatim. See Foundations ai/ENGINEERING.md.
|
|
6
|
+
//
|
|
7
|
+
// Parsers and facet builders for the Engineering Excellence artifacts that the Project-Health
|
|
8
|
+
// collector reads.
|
|
9
|
+
//
|
|
10
|
+
// Pure functions over report text/JSON, no I/O, so the gates (check-coverage.mjs,
|
|
11
|
+
// check-mutation.mjs) and the facet emitter (engineering-excellence.mjs) share one calculation:
|
|
12
|
+
// a facet can never disagree with the gate that ran in the same job.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export const SCHEMA_VERSION = 1;
|
|
17
|
+
export const FACETS = ['coverage', 'complexity', 'mutation', 'security', 'sbom', 'hygiene'];
|
|
18
|
+
export const CC_THRESHOLD = 10;
|
|
19
|
+
export const CRAP_THRESHOLD = 30;
|
|
20
|
+
const HOTSPOT_LIMIT = 20;
|
|
21
|
+
|
|
22
|
+
const attr = (tag, name) => {
|
|
23
|
+
for (const m of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) if (m[1] === name) return m[2];
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
const decode = (s) =>
|
|
27
|
+
s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
28
|
+
const ratio = (num, den) => (den === 0 ? null : num / den);
|
|
29
|
+
const round = (n, d = 4) => (n === null ? null : Math.round(n * 10 ** d) / 10 ** d);
|
|
30
|
+
|
|
31
|
+
// Repo-relative path, so a hotspot links to the same file on every runner.
|
|
32
|
+
export function repoPath(file, scope) {
|
|
33
|
+
const f = file.replace(/\\/g, '/');
|
|
34
|
+
const m = f.match(/(apps\/(?:api|web)\/.*)$/);
|
|
35
|
+
if (m) return m[1];
|
|
36
|
+
return scope === 'web' ? `apps/web/${f.replace(/^\.?\//, '')}` : f;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Compiler/source-generator output (obj/, *.g.cs — the [GeneratedRegex] matchers
|
|
40
|
+
// alone reach CC 1600) is nobody's code to simplify, so it stays out of modules,
|
|
41
|
+
// complexity and hotspots. The gated assembly line-rate still includes it, as it
|
|
42
|
+
// always has: the facet reports the gate's number, not a recount.
|
|
43
|
+
export const isGenerated = (file) => /(^|\/)obj\/|\.g\.cs$|\.g\.i\.cs$|\.designer\.cs$/i.test(file.replace(/\\/g, '/'));
|
|
44
|
+
|
|
45
|
+
// The module a file belongs to. Backend: the bounded-context folder directly under
|
|
46
|
+
// the assembly (src/App.Domain/<Context>/...), a PascalCase folder with a dot, which no web folder
|
|
47
|
+
// is. Web: up to two folders under src/ (features/claim-editor, pages/projects, shared/ui). Files
|
|
48
|
+
// at the root report as "(root)".
|
|
49
|
+
export function moduleOf(file) {
|
|
50
|
+
const f = file.replace(/\\/g, '/');
|
|
51
|
+
const api = f.match(/(?:^|\/)src\/[A-Z][A-Za-z0-9]*\.[^/]+\/(.+)$/);
|
|
52
|
+
if (api) {
|
|
53
|
+
const parts = api[1].split('/');
|
|
54
|
+
return parts.length > 1 ? parts[0] : '(root)';
|
|
55
|
+
}
|
|
56
|
+
const web = f.match(/(?:^|\/)src\/(.+)$/);
|
|
57
|
+
const rest = (web ? web[1] : f).split('/');
|
|
58
|
+
const dirs = rest.slice(0, -1).slice(0, 2);
|
|
59
|
+
return dirs.length ? dirs.join('/') : '(root)';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function coberturaRoot(xml) {
|
|
63
|
+
const tag = xml.match(/<coverage\b[^>]*>/)?.[0] ?? '';
|
|
64
|
+
return {
|
|
65
|
+
lineRate: parseFloat(attr(tag, 'line-rate') ?? 'NaN'),
|
|
66
|
+
branchRate: parseFloat(attr(tag, 'branch-rate') ?? 'NaN'),
|
|
67
|
+
linesCovered: parseInt(attr(tag, 'lines-covered') ?? '0', 10),
|
|
68
|
+
linesValid: parseInt(attr(tag, 'lines-valid') ?? '0', 10),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// <package line-rate> per package — the number check-coverage.mjs gates on.
|
|
73
|
+
export function packageRates(xml) {
|
|
74
|
+
const rates = {};
|
|
75
|
+
for (const m of xml.matchAll(/<package name="([^"]+)"[^>]*\bline-rate="([0-9.]+)"/g)) {
|
|
76
|
+
rates[m[1]] = parseFloat(m[2]);
|
|
77
|
+
}
|
|
78
|
+
return rates;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function lineStats(linesXml) {
|
|
82
|
+
let valid = 0, covered = 0, branchValid = 0, branchCovered = 0;
|
|
83
|
+
const hits = new Map();
|
|
84
|
+
for (const m of linesXml.matchAll(/<line\b[^>]*>/g)) {
|
|
85
|
+
const tag = m[0];
|
|
86
|
+
const n = parseInt(attr(tag, 'number'), 10);
|
|
87
|
+
const h = parseInt(attr(tag, 'hits') ?? '0', 10);
|
|
88
|
+
valid++;
|
|
89
|
+
if (h > 0) covered++;
|
|
90
|
+
hits.set(n, h);
|
|
91
|
+
const cc = attr(tag, 'condition-coverage')?.match(/\((\d+)\/(\d+)\)/);
|
|
92
|
+
if (cc) {
|
|
93
|
+
branchCovered += parseInt(cc[1], 10);
|
|
94
|
+
branchValid += parseInt(cc[2], 10);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { valid, covered, branchValid, branchCovered, hits };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Every <class> with its line counts and its <method>s. Class-level <lines> sit
|
|
101
|
+
// after <methods>; the method blocks are stripped first so no line counts twice.
|
|
102
|
+
export function coberturaClasses(xml) {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const pkg of xml.matchAll(/<package\b([^>]*)>([\s\S]*?)<\/package>/g)) {
|
|
105
|
+
const pkgName = attr(pkg[1], 'name');
|
|
106
|
+
for (const cls of pkg[2].matchAll(/<class\b([^>]*)>([\s\S]*?)<\/class>/g)) {
|
|
107
|
+
const body = cls[2];
|
|
108
|
+
const methods = [];
|
|
109
|
+
for (const m of body.matchAll(/<method\b([^>]*)>([\s\S]*?)<\/method>/g)) {
|
|
110
|
+
const stats = lineStats(m[2]);
|
|
111
|
+
const lines = [...stats.hits.keys()];
|
|
112
|
+
methods.push({
|
|
113
|
+
name: decode(attr(m[1], 'name') ?? ''),
|
|
114
|
+
complexity: attr(m[1], 'complexity') !== undefined ? parseFloat(attr(m[1], 'complexity')) : null,
|
|
115
|
+
lineRate: parseFloat(attr(m[1], 'line-rate') ?? 'NaN'),
|
|
116
|
+
line: lines.length ? Math.min(...lines) : null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const own = lineStats(body.replace(/<methods>[\s\S]*?<\/methods>/g, ''));
|
|
120
|
+
out.push({
|
|
121
|
+
package: pkgName,
|
|
122
|
+
name: decode(attr(cls[1], 'name') ?? ''),
|
|
123
|
+
filename: decode(attr(cls[1], 'filename') ?? ''),
|
|
124
|
+
...own,
|
|
125
|
+
methods,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function addLines(acc, c) {
|
|
133
|
+
acc.linesValid += c.valid;
|
|
134
|
+
acc.linesCovered += c.covered;
|
|
135
|
+
acc.branchesValid += c.branchValid;
|
|
136
|
+
acc.branchesCovered += c.branchCovered;
|
|
137
|
+
return acc;
|
|
138
|
+
}
|
|
139
|
+
const emptyLines = () => ({ linesValid: 0, linesCovered: 0, branchesValid: 0, branchesCovered: 0 });
|
|
140
|
+
const finishLines = (a) => ({
|
|
141
|
+
lineRate: round(ratio(a.linesCovered, a.linesValid)),
|
|
142
|
+
branchRate: round(ratio(a.branchesCovered, a.branchesValid)),
|
|
143
|
+
linesCovered: a.linesCovered,
|
|
144
|
+
linesValid: a.linesValid,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// One coverage scope per gated assembly (from the merged backend report) plus
|
|
148
|
+
// `web`. `floors` comes from check-coverage.mjs so the facet carries the same bar.
|
|
149
|
+
export function coverageFacetData({ apiXml, webXml, floors }) {
|
|
150
|
+
const scopes = {};
|
|
151
|
+
if (apiXml) {
|
|
152
|
+
const rates = packageRates(apiXml);
|
|
153
|
+
const byPkg = {};
|
|
154
|
+
for (const c of coberturaClasses(apiXml)) {
|
|
155
|
+
const p = (byPkg[c.package] ??= { total: emptyLines(), modules: {} });
|
|
156
|
+
addLines(p.total, c);
|
|
157
|
+
if (!isGenerated(c.filename)) addLines((p.modules[moduleOf(c.filename)] ??= emptyLines()), c);
|
|
158
|
+
}
|
|
159
|
+
for (const [pkg, p] of Object.entries(byPkg)) {
|
|
160
|
+
scopes[pkg] = {
|
|
161
|
+
...finishLines(p.total),
|
|
162
|
+
// The gate reads the package attribute; report that, not our recount.
|
|
163
|
+
lineRate: round(rates[pkg] ?? ratio(p.total.linesCovered, p.total.linesValid)),
|
|
164
|
+
floor: floors.api?.[pkg] ?? null,
|
|
165
|
+
modules: Object.fromEntries(Object.entries(p.modules).sort().map(([k, v]) => [k, finishLines(v)])),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (webXml) {
|
|
170
|
+
const root = coberturaRoot(webXml);
|
|
171
|
+
const total = emptyLines();
|
|
172
|
+
const modules = {};
|
|
173
|
+
for (const c of coberturaClasses(webXml)) {
|
|
174
|
+
addLines(total, c);
|
|
175
|
+
addLines((modules[moduleOf(c.filename)] ??= emptyLines()), c);
|
|
176
|
+
}
|
|
177
|
+
scopes.web = {
|
|
178
|
+
...finishLines(total),
|
|
179
|
+
lineRate: round(root.lineRate),
|
|
180
|
+
linesCovered: root.linesCovered,
|
|
181
|
+
linesValid: root.linesValid,
|
|
182
|
+
floor: floors.web ?? null,
|
|
183
|
+
modules: Object.fromEntries(Object.entries(modules).sort().map(([k, v]) => [k, finishLines(v)])),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const g = Object.values(scopes).reduce(
|
|
187
|
+
(a, s) => ({ c: a.c + s.linesCovered, v: a.v + s.linesValid }),
|
|
188
|
+
{ c: 0, v: 0 },
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
scopes,
|
|
192
|
+
global: { lineRate: round(ratio(g.c, g.v)), linesCovered: g.c, linesValid: g.v },
|
|
193
|
+
passed: Object.values(scopes).every((s) => s.floor === null || s.lineRate >= s.floor),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// CRAP (Savoia & Evans): CC^2 * (1 - cov)^3 + CC, coverage as a 0..1 fraction.
|
|
198
|
+
export const crap = (cc, cov) => cc * cc * (1 - cov) ** 3 + cc;
|
|
199
|
+
|
|
200
|
+
const percentile = (sorted, p) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] : null);
|
|
201
|
+
|
|
202
|
+
function summarise(methods) {
|
|
203
|
+
const ccs = methods.map((m) => m.cc).sort((a, b) => a - b);
|
|
204
|
+
return {
|
|
205
|
+
methods: methods.length,
|
|
206
|
+
ccP50: percentile(ccs, 0.5),
|
|
207
|
+
ccP90: percentile(ccs, 0.9),
|
|
208
|
+
ccMax: ccs.length ? ccs[ccs.length - 1] : null,
|
|
209
|
+
over10: methods.filter((m) => m.cc > CC_THRESHOLD).length,
|
|
210
|
+
crapOver30: methods.filter((m) => m.crap > CRAP_THRESHOLD).length,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Backend methods straight from Cobertura: coverlet writes `complexity` on each
|
|
215
|
+
// <method>, so complexity costs no extra tool.
|
|
216
|
+
export function apiMethods(xml) {
|
|
217
|
+
const out = [];
|
|
218
|
+
// ReportGenerator lists a method once per class that owns it (partial classes,
|
|
219
|
+
// nested generated types), so the same file:line can appear twice.
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
for (const c of coberturaClasses(xml)) {
|
|
222
|
+
if (isGenerated(c.filename)) continue;
|
|
223
|
+
for (const m of c.methods) {
|
|
224
|
+
if (m.complexity === null || Number.isNaN(m.lineRate)) continue;
|
|
225
|
+
const key = `${c.filename}:${m.line}:${m.name}`;
|
|
226
|
+
if (seen.has(key)) continue;
|
|
227
|
+
seen.add(key);
|
|
228
|
+
out.push({
|
|
229
|
+
scope: c.package,
|
|
230
|
+
module: moduleOf(c.filename),
|
|
231
|
+
file: repoPath(c.filename, c.package),
|
|
232
|
+
method: `${c.name.split('.').pop()}.${m.name}`,
|
|
233
|
+
line: m.line,
|
|
234
|
+
cc: m.complexity,
|
|
235
|
+
coverage: m.lineRate,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const contains = (loc, line, col) =>
|
|
243
|
+
(line > loc.start.line || (line === loc.start.line && col >= loc.start.column)) &&
|
|
244
|
+
(line < loc.end.line || (line === loc.end.line && col <= loc.end.column));
|
|
245
|
+
const isNamed = (fn) => Boolean(fn.name) && !fn.name.startsWith('(anonymous');
|
|
246
|
+
const span = (loc) => (loc.end.line - loc.start.line) * 1e6 + (loc.end.column - loc.start.column);
|
|
247
|
+
|
|
248
|
+
export function webMethods(eslintResults, coverageFinal) {
|
|
249
|
+
const byFile = new Map();
|
|
250
|
+
for (const fc of Object.values(coverageFinal ?? {})) byFile.set(repoPath(fc.path, 'web'), fc);
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const r of eslintResults) {
|
|
253
|
+
const file = repoPath(r.filePath, 'web');
|
|
254
|
+
const fc = byFile.get(file);
|
|
255
|
+
if (!fc) continue; // excluded from coverage (tests, mocks): not production code
|
|
256
|
+
const fns = Object.entries(fc.fnMap).map(([id, fn]) => ({ id, ...fn }));
|
|
257
|
+
const stmts = Object.entries(fc.statementMap);
|
|
258
|
+
for (const msg of r.messages ?? []) {
|
|
259
|
+
if (msg.ruleId !== 'complexity') continue;
|
|
260
|
+
const cc = parseInt(msg.message.match(/complexity of (\d+)/)?.[1] ?? '', 10);
|
|
261
|
+
if (Number.isNaN(cc)) continue;
|
|
262
|
+
// ESLint columns are 1-based, istanbul's 0-based.
|
|
263
|
+
const fn = fns
|
|
264
|
+
.filter((f) => contains(f.loc, msg.line, msg.column - 1))
|
|
265
|
+
.sort((x, y) => span(x.loc) - span(y.loc))[0];
|
|
266
|
+
if (!fn) continue;
|
|
267
|
+
let valid = 0, covered = 0;
|
|
268
|
+
for (const [id, loc] of stmts) {
|
|
269
|
+
if (!contains(fn.loc, loc.start.line, loc.start.column)) continue;
|
|
270
|
+
valid++;
|
|
271
|
+
if (fc.s[id] > 0) covered++;
|
|
272
|
+
}
|
|
273
|
+
const named = msg.message.match(/'([^']+)'/)?.[1] ?? (isNamed(fn) ? fn.name : null);
|
|
274
|
+
// Callbacks have no name; qualify them by the innermost named function around
|
|
275
|
+
// them, so the complexity baseline keys one component's callbacks, not a file's.
|
|
276
|
+
const parent = named ? null : fns
|
|
277
|
+
.filter((f) => f !== fn && isNamed(f) && contains(f.loc, fn.loc.start.line, fn.loc.start.column))
|
|
278
|
+
.sort((x, y) => span(x.loc) - span(y.loc))[0];
|
|
279
|
+
out.push({
|
|
280
|
+
scope: 'web',
|
|
281
|
+
module: moduleOf(file),
|
|
282
|
+
file,
|
|
283
|
+
method: named ?? (parent ? `${parent.name}/anonymous` : 'anonymous'),
|
|
284
|
+
line: fn.loc.start.line,
|
|
285
|
+
cc,
|
|
286
|
+
coverage: valid === 0 ? (fc.f[fn.id] > 0 ? 1 : 0) : covered / valid,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Shared by the facet and the blocking gate (check-complexity.mjs), so both see the
|
|
294
|
+
// same CRAP for the same method.
|
|
295
|
+
export const scoreMethods = (methods) =>
|
|
296
|
+
methods.map((m) => ({ ...m, coverage: round(m.coverage), crap: round(crap(m.cc, m.coverage), 2) }));
|
|
297
|
+
|
|
298
|
+
export function complexityFacetData(methods) {
|
|
299
|
+
const scored = scoreMethods(methods);
|
|
300
|
+
const scopes = {};
|
|
301
|
+
const byScope = Map.groupBy(scored, (m) => m.scope);
|
|
302
|
+
for (const [scope, list] of [...byScope].sort(([a], [b]) => a.localeCompare(b))) {
|
|
303
|
+
const modules = Object.fromEntries(
|
|
304
|
+
[...Map.groupBy(list, (m) => m.module)].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, summarise(v)]),
|
|
305
|
+
);
|
|
306
|
+
scopes[scope] = { ...summarise(list), modules };
|
|
307
|
+
}
|
|
308
|
+
const hotspots = scored
|
|
309
|
+
.filter((m) => m.crap > CRAP_THRESHOLD)
|
|
310
|
+
.sort((a, b) => b.crap - a.crap || b.cc - a.cc)
|
|
311
|
+
.slice(0, HOTSPOT_LIMIT);
|
|
312
|
+
return {
|
|
313
|
+
thresholds: { cc: CC_THRESHOLD, crap: CRAP_THRESHOLD },
|
|
314
|
+
scopes,
|
|
315
|
+
global: summarise(scored),
|
|
316
|
+
hotspots,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Same state split as check-mutation.mjs documents: Killed/Timeout detect,
|
|
321
|
+
// Survived/NoCoverage do not, CompileError/RuntimeError/Ignored are excluded.
|
|
322
|
+
const DETECTED = ['Killed', 'Timeout'];
|
|
323
|
+
|
|
324
|
+
export function tallyMutants(report) {
|
|
325
|
+
const counts = {};
|
|
326
|
+
for (const info of Object.values(report.files ?? {})) {
|
|
327
|
+
for (const mutant of info.mutants ?? []) counts[mutant.status] = (counts[mutant.status] ?? 0) + 1;
|
|
328
|
+
}
|
|
329
|
+
return counts;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function mutationScores(counts) {
|
|
333
|
+
const killed = counts.Killed ?? 0;
|
|
334
|
+
const timeout = counts.Timeout ?? 0;
|
|
335
|
+
const survived = counts.Survived ?? 0;
|
|
336
|
+
const noCoverage = counts.NoCoverage ?? 0;
|
|
337
|
+
const detected = DETECTED.reduce((n, s) => n + (counts[s] ?? 0), 0);
|
|
338
|
+
const covered = detected + survived;
|
|
339
|
+
const valid = covered + noCoverage;
|
|
340
|
+
return {
|
|
341
|
+
coveredScore: covered === 0 ? 0 : detected / covered,
|
|
342
|
+
fullScore: valid === 0 ? 0 : detected / valid,
|
|
343
|
+
killed,
|
|
344
|
+
timeout,
|
|
345
|
+
survived,
|
|
346
|
+
noCoverage,
|
|
347
|
+
valid,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const roundScores = (s) => ({ ...s, coveredScore: round(s.coveredScore), fullScore: round(s.fullScore) });
|
|
352
|
+
|
|
353
|
+
export function mutationFacetData(reportsByScope, floors) {
|
|
354
|
+
const scopes = {};
|
|
355
|
+
const total = {};
|
|
356
|
+
for (const [scope, report] of Object.entries(reportsByScope).sort()) {
|
|
357
|
+
const counts = tallyMutants(report);
|
|
358
|
+
for (const [k, v] of Object.entries(counts)) total[k] = (total[k] ?? 0) + v;
|
|
359
|
+
const modules = {};
|
|
360
|
+
for (const [file, info] of Object.entries(report.files ?? {})) {
|
|
361
|
+
const mod = (modules[moduleOf(file)] ??= {});
|
|
362
|
+
for (const m of info.mutants ?? []) mod[m.status] = (mod[m.status] ?? 0) + 1;
|
|
363
|
+
}
|
|
364
|
+
const s = mutationScores(counts);
|
|
365
|
+
const floor = floors[scope] ?? null;
|
|
366
|
+
scopes[scope] = {
|
|
367
|
+
...roundScores(s),
|
|
368
|
+
floor,
|
|
369
|
+
passed: floor === null || s.coveredScore >= floor,
|
|
370
|
+
modules: Object.fromEntries(
|
|
371
|
+
Object.entries(modules).sort().map(([k, v]) => [k, roundScores(mutationScores(v))]),
|
|
372
|
+
),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
scopes,
|
|
377
|
+
// Mutant-weighted across scopes, so a small scope cannot swing the headline.
|
|
378
|
+
global: roundScores(mutationScores(total)),
|
|
379
|
+
passed: Object.values(scopes).every((s) => s.passed),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
|
|
384
|
+
const zeroSev = () => Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
|
|
385
|
+
|
|
386
|
+
export function trivySummary(report) {
|
|
387
|
+
const out = { vuln: zeroSev(), misconfig: zeroSev(), secret: zeroSev(), fixableVulns: 0 };
|
|
388
|
+
for (const r of report.Results ?? []) {
|
|
389
|
+
for (const v of r.Vulnerabilities ?? []) {
|
|
390
|
+
out.vuln[v.Severity in out.vuln ? v.Severity : 'UNKNOWN']++;
|
|
391
|
+
if (v.FixedVersion) out.fixableVulns++;
|
|
392
|
+
}
|
|
393
|
+
for (const m of r.Misconfigurations ?? []) {
|
|
394
|
+
if (m.Status && m.Status !== 'FAIL') continue;
|
|
395
|
+
out.misconfig[m.Severity in out.misconfig ? m.Severity : 'UNKNOWN']++;
|
|
396
|
+
}
|
|
397
|
+
for (const s of r.Secrets ?? []) out.secret[s.Severity in out.secret ? s.Severity : 'UNKNOWN']++;
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function semgrepSummary(report) {
|
|
403
|
+
const out = { ERROR: 0, WARNING: 0, INFO: 0, total: 0, scanErrors: (report.errors ?? []).length };
|
|
404
|
+
for (const r of report.results ?? []) {
|
|
405
|
+
const sev = r.extra?.severity;
|
|
406
|
+
if (sev in out) out[sev]++;
|
|
407
|
+
out.total++;
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// `jobs` are the needs.<job>.result values of the blocking scanners — the verdict
|
|
413
|
+
// a human saw on the run, recorded as-is.
|
|
414
|
+
export function securityFacetData({ trivy, semgrep, jobs }) {
|
|
415
|
+
return {
|
|
416
|
+
trivy: trivy ? trivySummary(trivy) : null,
|
|
417
|
+
semgrep: semgrep ? semgrepSummary(semgrep) : null,
|
|
418
|
+
jobs,
|
|
419
|
+
passed: Object.values(jobs).every((r) => r === 'success'),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function sbomFacetData(text) {
|
|
424
|
+
const bom = JSON.parse(text);
|
|
425
|
+
const components = bom.components ?? [];
|
|
426
|
+
const byEcosystem = {};
|
|
427
|
+
const licenses = {};
|
|
428
|
+
let unlicensed = 0;
|
|
429
|
+
for (const c of components) {
|
|
430
|
+
const eco = c.purl?.match(/^pkg:([^/]+)\//)?.[1] ?? 'unknown';
|
|
431
|
+
byEcosystem[eco] = (byEcosystem[eco] ?? 0) + 1;
|
|
432
|
+
const ids = (c.licenses ?? [])
|
|
433
|
+
.map((l) => l.license?.id ?? l.license?.name ?? l.expression)
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
if (ids.length === 0) unlicensed++;
|
|
436
|
+
for (const id of new Set(ids)) licenses[id] = (licenses[id] ?? 0) + 1;
|
|
437
|
+
}
|
|
438
|
+
const sortDesc = (o) => Object.fromEntries(Object.entries(o).sort(([, a], [, b]) => b - a));
|
|
439
|
+
return {
|
|
440
|
+
format: bom.bomFormat ?? 'CycloneDX',
|
|
441
|
+
specVersion: bom.specVersion ?? null,
|
|
442
|
+
components: components.length,
|
|
443
|
+
byEcosystem: sortDesc(byEcosystem),
|
|
444
|
+
licenses: sortDesc(licenses),
|
|
445
|
+
unlicensed,
|
|
446
|
+
sha256: createHash('sha256').update(text).digest('hex'),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The Semgrep rules in .semgrep/platform-hygiene.yml. Semgrep prefixes a local rule's id
|
|
451
|
+
// with its config path (`.semgrep.platform-hygiene-...`), so findings match on the suffix.
|
|
452
|
+
export const SWALLOW_RULES = ['platform-hygiene-csharp-empty-catch', 'platform-hygiene-ts-empty-catch'];
|
|
453
|
+
const isSwallowRule = (checkId) => SWALLOW_RULES.some((r) => checkId === r || checkId?.endsWith(`.${r}`));
|
|
454
|
+
const JUSTIFIED_SWALLOW = new RegExp(`nosemgrep:\\s*(?:${SWALLOW_RULES.join('|')})\\b`, 'g');
|
|
455
|
+
|
|
456
|
+
// The subset of glob syntax .jscpd.json uses (`**/`, `*`, literal paths), so the facet
|
|
457
|
+
// and jscpd read their file scope from one config.
|
|
458
|
+
export function globToRegExp(glob) {
|
|
459
|
+
let re = '';
|
|
460
|
+
for (let i = 0; i < glob.length; i++) {
|
|
461
|
+
const c = glob[i];
|
|
462
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
463
|
+
re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*';
|
|
464
|
+
i += glob[i + 2] === '/' ? 2 : 1;
|
|
465
|
+
} else if (c === '*') re += '[^/]*';
|
|
466
|
+
else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
467
|
+
}
|
|
468
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the glob comes from the committed .jscpd.json, metacharacters are escaped, and the output has no nested quantifier
|
|
469
|
+
return new RegExp(`^${re}$`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const FORMAT_EXTENSIONS = { csharp: ['.cs'], typescript: ['.ts'], tsx: ['.tsx'], javascript: ['.js', '.mjs'] };
|
|
473
|
+
|
|
474
|
+
// `cfg` is the parsed .jscpd.json; `file` is repo-relative.
|
|
475
|
+
export function scannedBy(cfg) {
|
|
476
|
+
const ignore = (cfg.ignore ?? []).map(globToRegExp);
|
|
477
|
+
const exts = (cfg.format ?? []).flatMap((f) => FORMAT_EXTENSIONS[f] ?? []);
|
|
478
|
+
const roots = (cfg.path ?? []).map((p) => `${p.replace(/\/$/, '')}/`);
|
|
479
|
+
return (file) =>
|
|
480
|
+
roots.some((r) => file.startsWith(r)) &&
|
|
481
|
+
exts.some((e) => file.endsWith(e)) &&
|
|
482
|
+
!ignore.some((re) => re.test(file));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
486
|
+
|
|
487
|
+
export const hygieneScopeOf = (file) => file.match(/^apps\/(api|web)\//)?.[1] ?? null;
|
|
488
|
+
|
|
489
|
+
export function duplicatedLinesByFile(report, lengthOf = () => Infinity) {
|
|
490
|
+
const lines = new Map();
|
|
491
|
+
for (const d of report.duplicates ?? []) {
|
|
492
|
+
for (const f of [d.firstFile, d.secondFile]) {
|
|
493
|
+
if (!f) continue;
|
|
494
|
+
const file = repoPath(f.name);
|
|
495
|
+
const end = Math.min(f.end, f.start + Math.max(d.lines ?? 0, 1) - 1, lengthOf(file) ?? Infinity);
|
|
496
|
+
const set = lines.get(file) ?? new Set();
|
|
497
|
+
for (let n = f.start; n <= end; n++) set.add(n);
|
|
498
|
+
lines.set(file, set);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return Object.fromEntries([...lines].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, v.size]));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Line-based and C-family only (C#, TS/TSX share `//` and `/* */`). A line is a
|
|
505
|
+
// comment line when it holds nothing but comment; a trailing comment leaves it a code
|
|
506
|
+
// line. Good enough for a trend, and why the density is report-only.
|
|
507
|
+
export function commentStats(text) {
|
|
508
|
+
let code = 0, comment = 0, inBlock = false;
|
|
509
|
+
for (const raw of text.split('\n')) {
|
|
510
|
+
let line = raw.trim();
|
|
511
|
+
if (inBlock) {
|
|
512
|
+
const end = line.indexOf('*/');
|
|
513
|
+
if (end === -1) {
|
|
514
|
+
if (line) comment++;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
inBlock = false;
|
|
518
|
+
line = line.slice(end + 2).trim();
|
|
519
|
+
if (!line || line.startsWith('//')) {
|
|
520
|
+
comment++;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
code++;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (!line) continue;
|
|
527
|
+
if (line.startsWith('//')) comment++;
|
|
528
|
+
else if (line.startsWith('/*')) {
|
|
529
|
+
const end = line.indexOf('*/', 2);
|
|
530
|
+
if (end === -1) inBlock = true;
|
|
531
|
+
const rest = end === -1 ? '' : line.slice(end + 2).trim();
|
|
532
|
+
if (rest && !rest.startsWith('//')) code++;
|
|
533
|
+
else comment++;
|
|
534
|
+
} else code++;
|
|
535
|
+
}
|
|
536
|
+
return { codeLines: code, commentLines: comment };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export const justifiedSwallows = (text) => (text.match(JUSTIFIED_SWALLOW) ?? []).length;
|
|
540
|
+
|
|
541
|
+
// Files with an unjustified swallow, from the blocking scan's JSON. On main the scan
|
|
542
|
+
// is blocking, so this is normally zero and the justified count carries the trend.
|
|
543
|
+
export function swallowFindings(semgrep) {
|
|
544
|
+
const byFile = {};
|
|
545
|
+
for (const r of semgrep.results ?? []) {
|
|
546
|
+
if (!isSwallowRule(r.check_id)) continue;
|
|
547
|
+
byFile[r.path] = (byFile[r.path] ?? 0) + 1;
|
|
548
|
+
}
|
|
549
|
+
return byFile;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const emptyHygiene = () => ({
|
|
553
|
+
files: 0, lines: 0, duplicatedLines: 0, codeLines: 0, commentLines: 0, swallowedExceptions: 0, justifiedSwallows: 0,
|
|
554
|
+
});
|
|
555
|
+
function addHygiene(acc, f) {
|
|
556
|
+
acc.files++;
|
|
557
|
+
acc.lines += f.lines;
|
|
558
|
+
acc.duplicatedLines += f.duplicatedLines;
|
|
559
|
+
acc.codeLines += f.codeLines;
|
|
560
|
+
acc.commentLines += f.commentLines;
|
|
561
|
+
acc.swallowedExceptions += f.swallowedExceptions;
|
|
562
|
+
acc.justifiedSwallows += f.justifiedSwallows;
|
|
563
|
+
return acc;
|
|
564
|
+
}
|
|
565
|
+
const finishHygiene = (a, swallowKnown) => ({
|
|
566
|
+
...a,
|
|
567
|
+
duplicationRate: round(ratio(a.duplicatedLines, a.lines)),
|
|
568
|
+
commentDensity: round(ratio(a.commentLines, a.codeLines)),
|
|
569
|
+
swallowedExceptions: swallowKnown ? a.swallowedExceptions : null,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
export function hygieneFacetData({ files, duplicated, semgrep, deadCode = {}, thresholds, gate = null, commentSlop }) {
|
|
573
|
+
const swallowed = semgrep ? swallowFindings(semgrep) : {};
|
|
574
|
+
const measured = files
|
|
575
|
+
.map(({ file, text }) => ({
|
|
576
|
+
file,
|
|
577
|
+
scope: hygieneScopeOf(file),
|
|
578
|
+
module: moduleOf(file),
|
|
579
|
+
lines: lineCount(text),
|
|
580
|
+
duplicatedLines: duplicated[file] ?? 0,
|
|
581
|
+
...commentStats(text),
|
|
582
|
+
swallowedExceptions: swallowed[file] ?? 0,
|
|
583
|
+
justifiedSwallows: justifiedSwallows(text),
|
|
584
|
+
}))
|
|
585
|
+
.filter((f) => f.scope);
|
|
586
|
+
const scopes = {};
|
|
587
|
+
for (const [scope, list] of [...Map.groupBy(measured, (f) => f.scope)].sort(([a], [b]) => a.localeCompare(b))) {
|
|
588
|
+
const modules = Object.fromEntries(
|
|
589
|
+
[...Map.groupBy(list, (f) => f.module)]
|
|
590
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
591
|
+
.map(([k, v]) => [k, finishHygiene(v.reduce(addHygiene, emptyHygiene()), Boolean(semgrep))]),
|
|
592
|
+
);
|
|
593
|
+
scopes[scope] = {
|
|
594
|
+
...finishHygiene(list.reduce(addHygiene, emptyHygiene()), Boolean(semgrep)),
|
|
595
|
+
deadCodeWarnings: deadCode[scope] ?? 0,
|
|
596
|
+
modules,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const hotspots = measured
|
|
600
|
+
.filter((f) => f.duplicatedLines > 0)
|
|
601
|
+
.sort((a, b) => b.duplicatedLines - a.duplicatedLines || a.file.localeCompare(b.file))
|
|
602
|
+
.slice(0, HOTSPOT_LIMIT)
|
|
603
|
+
.map((f) => ({
|
|
604
|
+
scope: f.scope,
|
|
605
|
+
module: f.module,
|
|
606
|
+
file: f.file,
|
|
607
|
+
duplicatedLines: f.duplicatedLines,
|
|
608
|
+
lines: f.lines,
|
|
609
|
+
duplicationRate: round(ratio(f.duplicatedLines, f.lines)),
|
|
610
|
+
}));
|
|
611
|
+
return {
|
|
612
|
+
thresholds,
|
|
613
|
+
scopes,
|
|
614
|
+
global: finishHygiene(measured.reduce(addHygiene, emptyHygiene()), Boolean(semgrep)),
|
|
615
|
+
deadCode: { gated: true, warnings: Object.values(deadCode).reduce((n, v) => n + v, 0) },
|
|
616
|
+
hotspots,
|
|
617
|
+
gate,
|
|
618
|
+
...(commentSlop ? { commentSlop } : {}),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function envelope(facet, data, env = process.env, now = new Date()) {
|
|
623
|
+
return {
|
|
624
|
+
schemaVersion: SCHEMA_VERSION,
|
|
625
|
+
facet,
|
|
626
|
+
repo: env.GITHUB_REPOSITORY ?? null,
|
|
627
|
+
ref: env.GITHUB_REF ?? null,
|
|
628
|
+
commitSha: env.GITHUB_SHA ?? null,
|
|
629
|
+
runId: env.GITHUB_RUN_ID ? Number(env.GITHUB_RUN_ID) : null,
|
|
630
|
+
runAttempt: env.GITHUB_RUN_ATTEMPT ? Number(env.GITHUB_RUN_ATTEMPT) : null,
|
|
631
|
+
generatedAt: now.toISOString(),
|
|
632
|
+
data,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// The subset of JSON Schema that scripts/engineering-excellence.schema.json uses: local $ref,
|
|
637
|
+
// type, required, properties, additionalProperties, items, enum, minimum, maximum.
|
|
638
|
+
// Enough to keep the contract honest without adding a validator dependency.
|
|
639
|
+
export function validate(value, schema, path = '$', root = schema) {
|
|
640
|
+
if (schema.$ref) {
|
|
641
|
+
const target = schema.$ref.replace(/^#\//, '').split('/').reduce((o, k) => o?.[k], root);
|
|
642
|
+
if (!target) return [`${path}: unresolved $ref ${schema.$ref}`];
|
|
643
|
+
return validate(value, target, path, root);
|
|
644
|
+
}
|
|
645
|
+
const errors = [];
|
|
646
|
+
const types = [].concat(schema.type ?? []);
|
|
647
|
+
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : Number.isInteger(v) ? 'integer' : typeof v);
|
|
648
|
+
if (types.length) {
|
|
649
|
+
const t = typeOf(value);
|
|
650
|
+
const ok = types.some((want) => want === t || (want === 'number' && t === 'integer'));
|
|
651
|
+
if (!ok) return [`${path}: expected ${types.join('|')}, got ${t}`];
|
|
652
|
+
}
|
|
653
|
+
if (schema.enum && !schema.enum.includes(value)) errors.push(`${path}: ${JSON.stringify(value)} not in enum`);
|
|
654
|
+
if (typeof value === 'number') {
|
|
655
|
+
if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${path}: ${value} < ${schema.minimum}`);
|
|
656
|
+
if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${path}: ${value} > ${schema.maximum}`);
|
|
657
|
+
}
|
|
658
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
659
|
+
for (const key of schema.required ?? []) if (!(key in value)) errors.push(`${path}: missing '${key}'`);
|
|
660
|
+
for (const [key, v] of Object.entries(value)) {
|
|
661
|
+
const sub = schema.properties?.[key] ?? (typeof schema.additionalProperties === 'object' ? schema.additionalProperties : null);
|
|
662
|
+
if (sub) errors.push(...validate(v, sub, `${path}.${key}`, root));
|
|
663
|
+
else if (schema.additionalProperties === false) errors.push(`${path}: unexpected '${key}'`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (Array.isArray(value) && schema.items) value.forEach((v, i) => errors.push(...validate(v, schema.items, `${path}[${i}]`, root)));
|
|
667
|
+
return errors;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function validateFacet(doc, schema) {
|
|
671
|
+
const errors = validate(doc, schema.$defs.envelope, '$', schema);
|
|
672
|
+
if (errors.length) return errors;
|
|
673
|
+
return validate(doc.data, schema.$defs[doc.facet], '$.data', schema);
|
|
674
|
+
}
|