@plainconceptsplatform/workflows 1.10.0 → 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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog-installation.js +5 -0
  3. package/dist/catalog-listing.js +6 -1
  4. package/dist/index.js +27 -4
  5. package/dist/template-bundles.d.ts +21 -0
  6. package/dist/template-bundles.js +108 -0
  7. package/dist/workflow-catalog.d.ts +5 -1
  8. package/dist/workflow-catalog.js +8 -0
  9. package/loops/actions/report-workflow-errors/action.yml +63 -16
  10. package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
  11. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -0
  12. package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
  13. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
  14. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
  15. package/loops/templates/quality-gates/.jscpd.json +36 -0
  16. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  17. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  18. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  19. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  20. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  21. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  22. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  23. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  24. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  25. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  26. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  27. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  28. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  29. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  30. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  31. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  32. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  33. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  34. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  35. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  36. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  37. 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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
+ }