@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.
- 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/actions/report-workflow-errors/action.yml +63 -16
- package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -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,89 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/check-coverage.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
// Enforces per-assembly line-coverage floors from a Cobertura XML report, and the web floor from
|
|
3
|
+
// the vitest v8 report. Shape vendored from PlainConceptsPlatform/Atlas scripts/check-coverage.mjs.
|
|
4
|
+
//
|
|
5
|
+
// Why a floor map (not one global number): coverage is only honest where the test run actually
|
|
6
|
+
// exercises the assembly. The CI report this runs against is the MERGED unit + acceptance +
|
|
7
|
+
// integration coverage (the Application assembly is mostly exercised by the SQL-backed integration suite).
|
|
8
|
+
//
|
|
9
|
+
// The web floor gates the v8 Cobertura report from `vitest run --coverage`. It reads the report's
|
|
10
|
+
// root line-rate: the web suite is one run, so there is nothing to merge.
|
|
11
|
+
//
|
|
12
|
+
// The floors are the app's, in quality.config.json (`coverage.api`, `coverage.reportOnly`,
|
|
13
|
+
// `coverage.web`). They sit just under the measured baseline and only ever ratchet up; never
|
|
14
|
+
// lower one to go green. The rule is in Foundations, ai/ENGINEERING.md, "Thresholds and ratchets".
|
|
15
|
+
//
|
|
16
|
+
// Usage: node scripts/check-coverage.mjs <coverage.cobertura.xml>
|
|
17
|
+
// node scripts/check-coverage.mjs --web <cobertura-coverage.xml>
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import { coberturaRoot, packageRates } from './lib/engineering-excellence.mjs';
|
|
22
|
+
import { loadQualityConfig } from './lib/quality-config.mjs';
|
|
23
|
+
|
|
24
|
+
const pct = (r) => `${(r * 100).toFixed(2)}%`;
|
|
25
|
+
|
|
26
|
+
function checkApi(file, { api: floors, reportOnly }) {
|
|
27
|
+
const rates = packageRates(readFileSync(file, 'utf8'));
|
|
28
|
+
let failed = false;
|
|
29
|
+
if (Object.keys(floors).length === 0) {
|
|
30
|
+
console.error('✗ quality.config.json sets no coverage.api floors: nothing would be gated');
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
for (const [pkg, floor] of Object.entries(floors)) {
|
|
34
|
+
const rate = rates[pkg];
|
|
35
|
+
if (rate === undefined) {
|
|
36
|
+
console.error(`✗ ${pkg}: no coverage data in ${file} (assembly not measured)`);
|
|
37
|
+
failed = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (rate < floor) {
|
|
41
|
+
console.error(`✗ ${pkg}: ${pct(rate)} < floor ${pct(floor)}`);
|
|
42
|
+
failed = true;
|
|
43
|
+
} else {
|
|
44
|
+
console.log(`✓ ${pkg}: ${pct(rate)} (floor ${pct(floor)})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const pkg of reportOnly) {
|
|
48
|
+
if (rates[pkg] !== undefined) {
|
|
49
|
+
console.log(`· ${pkg}: ${pct(rates[pkg])} (report-only — not gated yet)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return failed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function checkWeb(file, { web: floor }) {
|
|
56
|
+
if (floor === null) {
|
|
57
|
+
console.error('✗ web: quality.config.json sets no coverage.web floor');
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const { lineRate } = coberturaRoot(readFileSync(file, 'utf8'));
|
|
61
|
+
if (Number.isNaN(lineRate)) {
|
|
62
|
+
console.error(`✗ web: no line-rate in ${file}`);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (lineRate < floor) {
|
|
66
|
+
console.error(`✗ web: ${pct(lineRate)} < floor ${pct(floor)}`);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
console.log(`✓ web: ${pct(lineRate)} (floor ${pct(floor)})`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
74
|
+
const args = process.argv.slice(2);
|
|
75
|
+
const web = args[0] === '--web';
|
|
76
|
+
const file = web ? args[1] : args[0];
|
|
77
|
+
if (!file) {
|
|
78
|
+
console.error('usage: node scripts/check-coverage.mjs [--web] <coverage.cobertura.xml>');
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
let coverage;
|
|
82
|
+
try {
|
|
83
|
+
({ coverage } = loadQualityConfig());
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`✗ ${err.message}`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
process.exit((web ? checkWeb(file, coverage) : checkApi(file, coverage)) ? 1 : 0);
|
|
89
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/check-duplication.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { buildBaseline, evaluate, pruneBaseline } from './lib/duplication-gate.mjs';
|
|
7
|
+
import { duplicatedLinesByFile, lineCount, repoPath } from './lib/engineering-excellence.mjs';
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const opt = (name) => {
|
|
11
|
+
const i = args.indexOf(`--${name}`);
|
|
12
|
+
return i === -1 ? undefined : args[i + 1];
|
|
13
|
+
};
|
|
14
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
15
|
+
|
|
16
|
+
const report = opt('report') ?? 'jscpd-report/jscpd-report.json';
|
|
17
|
+
const baselineFile = opt('baseline') ?? fileURLToPath(new URL('./duplication-baseline.json', import.meta.url));
|
|
18
|
+
if (!existsSync(report)) {
|
|
19
|
+
console.error(`✗ ${report} not found — run \`npx jscpd -c .jscpd.json\` first`);
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const jscpd = JSON.parse(readFileSync(report, 'utf8'));
|
|
24
|
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
25
|
+
const lengthOf = (file) => (existsSync(`${REPO_ROOT}${file}`) ? lineCount(readFileSync(`${REPO_ROOT}${file}`, 'utf8')) : Infinity);
|
|
26
|
+
const duplicated = duplicatedLinesByFile(jscpd, lengthOf);
|
|
27
|
+
const clonesOf = (file) =>
|
|
28
|
+
(jscpd.duplicates ?? [])
|
|
29
|
+
.map((d) => [d.firstFile, d.secondFile].map((f) => ({ file: repoPath(f.name), start: f.start, end: f.end })))
|
|
30
|
+
.filter((pair) => pair.some((f) => f.file === file))
|
|
31
|
+
.map((pair) => pair.map((f) => `${f.file}:${f.start}-${f.end}`).join(' ↔ '));
|
|
32
|
+
const writeJson = (file, obj) => writeFileSync(file, `${JSON.stringify(obj, null, 2)}\n`);
|
|
33
|
+
const total = (b) => Object.values(b).reduce((n, v) => n + v, 0);
|
|
34
|
+
|
|
35
|
+
if (flag('write-baseline')) {
|
|
36
|
+
const b = buildBaseline(duplicated);
|
|
37
|
+
writeJson(baselineFile, b);
|
|
38
|
+
console.log(`· wrote ${Object.keys(b).length} files (${total(b)} duplicated lines) to ${baselineFile}`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const baseline = existsSync(baselineFile) ? JSON.parse(readFileSync(baselineFile, 'utf8')) : {};
|
|
43
|
+
|
|
44
|
+
if (flag('prune')) {
|
|
45
|
+
const pruned = pruneBaseline(baseline, duplicated);
|
|
46
|
+
writeJson(baselineFile, pruned);
|
|
47
|
+
console.log(`· baseline ${Object.keys(baseline).length} -> ${Object.keys(pruned).length} files, ${total(baseline)} -> ${total(pruned)} lines`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { violations, baselined, fixed } = evaluate(duplicated, baseline);
|
|
52
|
+
console.log('### Duplication gate (jscpd, no new clones)');
|
|
53
|
+
console.log('');
|
|
54
|
+
console.log(`${Object.keys(duplicated).length} files with clones · ${baselined} within their baseline · ${violations.length} violations`);
|
|
55
|
+
if (fixed.length) {
|
|
56
|
+
console.log('', `${fixed.length} baseline entries improved — run \`node scripts/check-duplication.mjs --prune\` to ratchet the baseline down.`);
|
|
57
|
+
}
|
|
58
|
+
if (violations.length) {
|
|
59
|
+
console.log('', 'Violations — reuse the existing code instead of copying it:', '');
|
|
60
|
+
for (const v of violations) {
|
|
61
|
+
const why = v.why === 'new' ? 'not in the baseline' : `baseline ${v.baseline}`;
|
|
62
|
+
console.log(`- ✗ \`${v.file}\` — ${v.lines} duplicated lines (${why})`);
|
|
63
|
+
for (const c of clonesOf(v.file)) console.log(` - ${c}`);
|
|
64
|
+
}
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
console.log('', '✓ no duplicated lines above the baseline');
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/check-mutation.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { mutationScores, tallyMutants } from './lib/engineering-excellence.mjs';
|
|
7
|
+
import { loadQualityConfig } from './lib/quality-config.mjs';
|
|
8
|
+
|
|
9
|
+
// The covered-code floor per scope is the app's: `mutation` in quality.config.json.
|
|
10
|
+
|
|
11
|
+
// Mutant states and the score formula live in scripts/lib/engineering-excellence.mjs so the
|
|
12
|
+
// Engineering Excellence facet reports exactly what this gate judged. `Killed`/`Timeout` are
|
|
13
|
+
// detections; `CompileError`/`RuntimeError` are Stryker's problem rather than the
|
|
14
|
+
// suite's, and `Ignored` was filtered deliberately, so all three are excluded.
|
|
15
|
+
|
|
16
|
+
function main() {
|
|
17
|
+
const [scope, file] = process.argv.slice(2);
|
|
18
|
+
if (!scope || !file) {
|
|
19
|
+
console.error('usage: node scripts/check-mutation.mjs <scope> <mutation-report.json>');
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
let floors;
|
|
23
|
+
try {
|
|
24
|
+
floors = loadQualityConfig().mutation;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(`✗ ${err.message}`);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
if (!(scope in floors)) {
|
|
30
|
+
console.error(`✗ unknown scope '${scope}' — quality.config.json sets mutation floors for: ${Object.keys(floors).join(', ') || 'none'}`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The gate step runs with if: always(), so a Stryker run that died before writing
|
|
35
|
+
// its report lands here. Say that plainly instead of throwing an ENOENT stack.
|
|
36
|
+
let report;
|
|
37
|
+
try {
|
|
38
|
+
report = JSON.parse(readFileSync(file, 'utf8'));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`✗ ${scope}: cannot read ${file} — ${err.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const s = mutationScores(tallyMutants(report));
|
|
44
|
+
const { survived, noCoverage } = s;
|
|
45
|
+
|
|
46
|
+
if (s.valid === 0) {
|
|
47
|
+
console.error(`✗ ${scope}: no valid mutants in ${file} — the run produced nothing to score`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const pct = (r) => `${(r * 100).toFixed(2)}%`;
|
|
52
|
+
const floor = floors[scope];
|
|
53
|
+
const { coveredScore, fullScore } = s;
|
|
54
|
+
|
|
55
|
+
// Printed on every run, pass or fail: the NoCoverage population is not a
|
|
56
|
+
// deficiency to gate on, it is the backlog of logic no unit test reaches.
|
|
57
|
+
console.log(`· ${scope}: full score ${pct(fullScore)} · ${noCoverage} mutants with no unit coverage · ${survived} survived`);
|
|
58
|
+
|
|
59
|
+
if (coveredScore < floor) {
|
|
60
|
+
console.error(`✗ ${scope}: covered-code ${pct(coveredScore)} < floor ${pct(floor)}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`✓ ${scope}: covered-code ${pct(coveredScore)} (floor ${pct(floor)})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/dast-wait-stable.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Wait until a deployed instance of the app is the NEW build and stays up, before (or between) DAST
|
|
4
|
+
# scans. Used by .github/workflows/security-dast.yml; shape from
|
|
5
|
+
# PlainConceptsPlatform/Atlas scripts/dast-wait-stable.sh.
|
|
6
|
+
#
|
|
7
|
+
# dast-wait-stable.sh <host> <streak> [expected-sha]
|
|
8
|
+
#
|
|
9
|
+
# With an expected SHA, first poll /api/v1/health until it reports that commit (the deploy's own
|
|
10
|
+
# build-identity gate). After a deploy the old process can keep answering 200 for about a minute
|
|
11
|
+
# before the new one takes over, with 502s in between, and a streak of 200s from the OLD process
|
|
12
|
+
# would start the scan on platform pages.
|
|
13
|
+
#
|
|
14
|
+
# Then require <streak> consecutive 200s on /login (the SPA shell) 10 s apart, within 6 minutes.
|
|
15
|
+
# Not /: with Entra SSO on, an anonymous GET / is redirected to the sign-in (SignInRedirect).
|
|
16
|
+
# Exits 0 when stable, 1 otherwise.
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
host="$1"
|
|
20
|
+
streak_needed="$2"
|
|
21
|
+
sha="${3:-}"
|
|
22
|
+
|
|
23
|
+
# wait_build <url> <sha> <attempts>: poll every 15 s until the body names the commit.
|
|
24
|
+
wait_build() {
|
|
25
|
+
local url="$1" expected="$2" attempts="$3" body
|
|
26
|
+
for _ in $(seq 1 "$attempts"); do
|
|
27
|
+
body="$(curl -s --max-time 20 "$url" || true)"
|
|
28
|
+
case "$body" in *"$expected"*) return 0 ;; esac
|
|
29
|
+
sleep 15
|
|
30
|
+
done
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if [ -n "$sha" ]; then
|
|
35
|
+
if ! wait_build "https://${host}/api/v1/health" "$sha" 24; then
|
|
36
|
+
echo "::error::${host} never reported build ${sha} on /api/v1/health in 6 minutes: the deploy did not land, or the old process is still serving"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
echo "Build ${sha:0:7} is serving /api/v1/health"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
streak=0
|
|
43
|
+
for attempt in $(seq 1 36); do
|
|
44
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://${host}/login" || echo 000)
|
|
45
|
+
if [ "$code" = "200" ]; then streak=$((streak + 1)); else streak=0; fi
|
|
46
|
+
if [ "$streak" -ge "$streak_needed" ]; then
|
|
47
|
+
echo "SPA shell stable (${streak_needed} consecutive 200s by attempt ${attempt})"
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
sleep 10
|
|
51
|
+
done
|
|
52
|
+
echo "::error::${host}/login never answered 200 ${streak_needed} times in a row in 6 minutes: the scan would read platform errors, not the app"
|
|
53
|
+
exit 1
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/engineering-excellence.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { CC_MAX, CRAP_MAX, evaluate } from './lib/complexity-gate.mjs';
|
|
8
|
+
import { evaluate as evaluateDuplication } from './lib/duplication-gate.mjs';
|
|
9
|
+
import { loadQualityConfig } from './lib/quality-config.mjs';
|
|
10
|
+
import { scopedSources } from './lib/source-files.mjs';
|
|
11
|
+
import {
|
|
12
|
+
apiMethods,
|
|
13
|
+
complexityFacetData,
|
|
14
|
+
coverageFacetData,
|
|
15
|
+
duplicatedLinesByFile,
|
|
16
|
+
envelope,
|
|
17
|
+
hygieneFacetData,
|
|
18
|
+
lineCount,
|
|
19
|
+
mutationFacetData,
|
|
20
|
+
scoreMethods,
|
|
21
|
+
sbomFacetData,
|
|
22
|
+
securityFacetData,
|
|
23
|
+
validateFacet,
|
|
24
|
+
webMethods,
|
|
25
|
+
} from './lib/engineering-excellence.mjs';
|
|
26
|
+
|
|
27
|
+
const BUILT = ['coverage', 'complexity', 'mutation', 'security', 'sbom', 'hygiene'];
|
|
28
|
+
const SCHEMA = JSON.parse(readFileSync(new URL('./engineering-excellence.schema.json', import.meta.url), 'utf8'));
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const [facet, ...rest] = argv;
|
|
32
|
+
const opts = { job: [] };
|
|
33
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
34
|
+
const key = rest[i]?.replace(/^--/, '');
|
|
35
|
+
const value = rest[i + 1];
|
|
36
|
+
if (!key || value === undefined) usage(`missing value for ${rest[i]}`);
|
|
37
|
+
if (key === 'job') opts.job.push(value);
|
|
38
|
+
else opts[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return { facet, opts };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function usage(msg) {
|
|
44
|
+
if (msg) console.error(`✗ ${msg}`);
|
|
45
|
+
console.error(`usage: node scripts/engineering-excellence.mjs <${BUILT.join('|')}> --out <file> [inputs] — see the header of this file`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const read = (file) => readFileSync(file, 'utf8');
|
|
50
|
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
51
|
+
const sourceLines = (file) => (existsSync(`${REPO_ROOT}${file}`) ? read(`${REPO_ROOT}${file}`).split('\n') : null);
|
|
52
|
+
const readOptional = (file) => {
|
|
53
|
+
if (!file) return null;
|
|
54
|
+
if (existsSync(file)) return read(file);
|
|
55
|
+
console.log(`· ${file} not found — recorded as null (the producing job did not write it)`);
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
const required = (opts, key) => opts[key] ?? usage(`--${key} is required`);
|
|
59
|
+
|
|
60
|
+
function mutationReports(dir) {
|
|
61
|
+
const reports = {};
|
|
62
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
63
|
+
const scope = entry.isDirectory() && entry.name.match(/^mutation-(.+)$/)?.[1];
|
|
64
|
+
if (!scope) continue;
|
|
65
|
+
const file = join(dir, entry.name, 'mutation-report.json');
|
|
66
|
+
// A scope whose Stryker run died has no report; the gate already failed it.
|
|
67
|
+
if (existsSync(file)) reports[scope] = JSON.parse(read(file));
|
|
68
|
+
else console.log(`· ${scope}: no mutation-report.json — scope omitted`);
|
|
69
|
+
}
|
|
70
|
+
return reports;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function build(facet, opts) {
|
|
74
|
+
switch (facet) {
|
|
75
|
+
case 'coverage': {
|
|
76
|
+
const { coverage } = loadQualityConfig();
|
|
77
|
+
return coverageFacetData({
|
|
78
|
+
apiXml: read(required(opts, 'api')),
|
|
79
|
+
webXml: read(required(opts, 'web')),
|
|
80
|
+
floors: { api: coverage.api, web: coverage.web },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
case 'complexity': {
|
|
84
|
+
const methods = [
|
|
85
|
+
...apiMethods(read(required(opts, 'api'))),
|
|
86
|
+
...webMethods(JSON.parse(read(required(opts, 'eslint'))), JSON.parse(read(required(opts, 'web-final')))),
|
|
87
|
+
];
|
|
88
|
+
const data = complexityFacetData(methods);
|
|
89
|
+
// The backlog size is the gate's ratchet: it should only ever go down.
|
|
90
|
+
if (opts.baseline && existsSync(opts.baseline)) {
|
|
91
|
+
const r = evaluate(scoreMethods(methods), JSON.parse(read(opts.baseline)), sourceLines);
|
|
92
|
+
data.gate = { ccMax: CC_MAX, crapMax: CRAP_MAX, baselined: r.baselined, violations: r.violations.length, justified: r.justified.length };
|
|
93
|
+
}
|
|
94
|
+
return data;
|
|
95
|
+
}
|
|
96
|
+
case 'mutation':
|
|
97
|
+
return mutationFacetData(mutationReports(required(opts, 'reports')), loadQualityConfig().mutation);
|
|
98
|
+
case 'security': {
|
|
99
|
+
const trivy = readOptional(opts.trivy);
|
|
100
|
+
const semgrep = readOptional(opts.semgrep);
|
|
101
|
+
const jobs = Object.fromEntries(opts.job.map((j) => j.split('=')));
|
|
102
|
+
return securityFacetData({
|
|
103
|
+
trivy: trivy && JSON.parse(trivy),
|
|
104
|
+
semgrep: semgrep && JSON.parse(semgrep),
|
|
105
|
+
jobs,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
case 'sbom':
|
|
109
|
+
return sbomFacetData(read(required(opts, 'cyclonedx')));
|
|
110
|
+
case 'hygiene': {
|
|
111
|
+
const cfg = JSON.parse(read(join(REPO_ROOT, '.jscpd.json')));
|
|
112
|
+
const files = scopedSources(cfg, REPO_ROOT);
|
|
113
|
+
const lengths = new Map(files.map((f) => [f.file, lineCount(f.text)]));
|
|
114
|
+
const duplicated = duplicatedLinesByFile(JSON.parse(read(required(opts, 'jscpd'))), (f) => lengths.get(f));
|
|
115
|
+
const semgrep = readOptional(opts.semgrep);
|
|
116
|
+
let gate = null;
|
|
117
|
+
if (opts.baseline && existsSync(opts.baseline)) {
|
|
118
|
+
const baseline = JSON.parse(read(opts.baseline));
|
|
119
|
+
const r = evaluateDuplication(duplicated, baseline);
|
|
120
|
+
gate = {
|
|
121
|
+
baselinedFiles: Object.keys(baseline).length,
|
|
122
|
+
baselineLines: Object.values(baseline).reduce((n, v) => n + v, 0),
|
|
123
|
+
violations: r.violations.length,
|
|
124
|
+
fixed: r.fixed.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return hygieneFacetData({
|
|
128
|
+
files,
|
|
129
|
+
duplicated,
|
|
130
|
+
semgrep: semgrep && JSON.parse(semgrep),
|
|
131
|
+
thresholds: { minLines: cfg.minLines, minTokens: cfg.minTokens },
|
|
132
|
+
gate,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
return usage(`unknown facet '${facet}'`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pct = (r) => (r === null || r === undefined ? 'n/a' : `${(r * 100).toFixed(2)}%`);
|
|
141
|
+
|
|
142
|
+
// A short Markdown digest for $GITHUB_STEP_SUMMARY, so the numbers are readable on
|
|
143
|
+
// the run itself, not only in the dashboard.
|
|
144
|
+
function summary(facet, d) {
|
|
145
|
+
const lines = [`### Engineering Excellence · ${facet}`, ''];
|
|
146
|
+
if (facet === 'coverage') {
|
|
147
|
+
lines.push('| Scope | Lines | Branches | Floor |', '|---|---|---|---|');
|
|
148
|
+
for (const [k, s] of Object.entries(d.scopes)) lines.push(`| ${k} | ${pct(s.lineRate)} | ${pct(s.branchRate)} | ${pct(s.floor)} |`);
|
|
149
|
+
lines.push('', `Global: ${pct(d.global.lineRate)}`);
|
|
150
|
+
} else if (facet === 'complexity') {
|
|
151
|
+
lines.push('| Scope | Methods | CC p90 | CC max | CC>10 | CRAP>30 |', '|---|---|---|---|---|---|');
|
|
152
|
+
for (const [k, s] of Object.entries(d.scopes)) lines.push(`| ${k} | ${s.methods} | ${s.ccP90} | ${s.ccMax} | ${s.over10} | ${s.crapOver30} |`);
|
|
153
|
+
if (d.gate) lines.push('', `Gate backlog: ${d.gate.baselined} baselined · ${d.gate.violations} violations · ${d.gate.justified} justified`);
|
|
154
|
+
lines.push('', 'Top CRAP hotspots:', '');
|
|
155
|
+
for (const h of d.hotspots.slice(0, 10)) lines.push(`- \`${h.file}:${h.line}\` ${h.method} — CC ${h.cc}, cov ${pct(h.coverage)}, CRAP ${h.crap}`);
|
|
156
|
+
} else if (facet === 'mutation') {
|
|
157
|
+
lines.push('| Scope | Covered-code | Full | Survived | NoCoverage | Floor |', '|---|---|---|---|---|---|');
|
|
158
|
+
for (const [k, s] of Object.entries(d.scopes)) lines.push(`| ${k} | ${pct(s.coveredScore)} | ${pct(s.fullScore)} | ${s.survived} | ${s.noCoverage} | ${pct(s.floor)} |`);
|
|
159
|
+
lines.push('', `Global covered-code: ${pct(d.global.coveredScore)}`);
|
|
160
|
+
} else if (facet === 'security') {
|
|
161
|
+
if (d.trivy) lines.push(`Trivy vulns: ${JSON.stringify(d.trivy.vuln)} · fixable ${d.trivy.fixableVulns}`);
|
|
162
|
+
if (d.semgrep) lines.push(`Semgrep: ${d.semgrep.ERROR} error · ${d.semgrep.WARNING} warning · ${d.semgrep.INFO} info`);
|
|
163
|
+
lines.push(`Jobs: ${Object.entries(d.jobs).map(([k, v]) => `${k}=${v}`).join(', ')}`);
|
|
164
|
+
} else if (facet === 'sbom') {
|
|
165
|
+
lines.push(`${d.components} components · ${JSON.stringify(d.byEcosystem)} · ${d.unlicensed} without a license`);
|
|
166
|
+
} else if (facet === 'hygiene') {
|
|
167
|
+
const n = (v) => (v === null ? 'n/a' : v);
|
|
168
|
+
lines.push('| Scope | Files | Duplication | Dup. lines | Swallowed | Justified | Dead code | Comment density |', '|---|---|---|---|---|---|---|---|');
|
|
169
|
+
for (const [k, s] of Object.entries(d.scopes)) {
|
|
170
|
+
lines.push(`| ${k} | ${s.files} | ${pct(s.duplicationRate)} | ${s.duplicatedLines} | ${n(s.swallowedExceptions)} | ${s.justifiedSwallows} | ${s.deadCodeWarnings} | ${n(s.commentDensity)} |`);
|
|
171
|
+
}
|
|
172
|
+
if (d.gate) lines.push('', `Duplication gate: ${d.gate.baselinedFiles} baselined files (${d.gate.baselineLines} lines) · ${d.gate.violations} violations · ${d.gate.fixed} improved`);
|
|
173
|
+
}
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { facet, opts } = parseArgs(process.argv.slice(2));
|
|
178
|
+
if (!BUILT.includes(facet)) usage(`unknown facet '${facet}'`);
|
|
179
|
+
const out = required(opts, 'out');
|
|
180
|
+
const doc = envelope(facet, build(facet, opts));
|
|
181
|
+
const errors = validateFacet(doc, SCHEMA);
|
|
182
|
+
if (errors.length) {
|
|
183
|
+
console.error(`✗ ${facet}: facet violates scripts/engineering-excellence.schema.json`);
|
|
184
|
+
for (const e of errors.slice(0, 20)) console.error(` ${e}`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`);
|
|
188
|
+
console.log(summary(facet, doc.data));
|