@plainconceptsplatform/workflows 1.10.1 → 1.12.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 +778 -117
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +202 -0
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +453 -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,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));
|