@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,5 @@
|
|
|
1
|
+
# Ownership: scripts/engineering-excellence.schema.json
|
|
2
|
+
|
|
3
|
+
Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/engineering-excellence.schema.json. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
4
|
+
|
|
5
|
+
JSON (RFC 8259) does not permit comments, so the ownership header of `engineering-excellence.schema.json` is documented here. The schema is the facet contract the Project-Health collector reads, vendored verbatim from PlainConceptsPlatform/Atlas. `scripts/engineering-excellence.mjs` validates every facet against it before writing, so an app never uploads a facet the collector would reject.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/complexity-gate.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
export const CC_MAX = 15;
|
|
3
|
+
export const CRAP_MAX = 30;
|
|
4
|
+
// CRAP moves with coverage; a fraction of a point on an untouched method is
|
|
5
|
+
// measurement noise between runs, not a regression worth a red build.
|
|
6
|
+
export const CRAP_TOLERANCE = 1;
|
|
7
|
+
const JUSTIFY_WINDOW = 5;
|
|
8
|
+
|
|
9
|
+
export const methodKey = (m) => `${m.file}::${m.method}`;
|
|
10
|
+
export const violates = (m) => m.cc > CC_MAX || m.crap > CRAP_MAX;
|
|
11
|
+
|
|
12
|
+
// Overloads share a key; the baseline keeps the worst of them.
|
|
13
|
+
function worstByKey(methods) {
|
|
14
|
+
const out = new Map();
|
|
15
|
+
for (const m of methods) {
|
|
16
|
+
const k = methodKey(m);
|
|
17
|
+
const prev = out.get(k);
|
|
18
|
+
if (!prev || m.cc > prev.cc || (m.cc === prev.cc && m.crap > prev.crap)) out.set(k, m);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildBaseline(methods) {
|
|
24
|
+
const entries = [...worstByKey(methods.filter(violates))]
|
|
25
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
26
|
+
.map(([k, m]) => [k, { cc: m.cc, crap: Math.round(m.crap * 100) / 100 }]);
|
|
27
|
+
return Object.fromEntries(entries);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// An escape hatch a reviewer can see: `complexity-justified: <reason> (#<issue>)`
|
|
31
|
+
// in a comment within the few lines above the method's first line.
|
|
32
|
+
const JUSTIFIED = /complexity-justified:\s*(\S.*?)\s*\(#(\d+)\)/;
|
|
33
|
+
export function justification(sourceLines, line) {
|
|
34
|
+
if (!sourceLines || !line) return null;
|
|
35
|
+
for (let n = line - 1; n >= Math.max(1, line - JUSTIFY_WINDOW); n--) {
|
|
36
|
+
const m = sourceLines[n - 1]?.match(JUSTIFIED);
|
|
37
|
+
if (m) return { reason: m[1], issue: Number(m[2]) };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `readSource(file)` returns the file's lines (or null); injected so this stays pure.
|
|
43
|
+
export function evaluate(methods, baseline, readSource = () => null) {
|
|
44
|
+
const violations = [];
|
|
45
|
+
const justified = [];
|
|
46
|
+
let baselined = 0;
|
|
47
|
+
const current = worstByKey(methods);
|
|
48
|
+
for (const [k, m] of current) {
|
|
49
|
+
if (!violates(m)) continue;
|
|
50
|
+
const base = baseline[k];
|
|
51
|
+
if (base && m.cc <= base.cc && m.crap <= base.crap + CRAP_TOLERANCE) {
|
|
52
|
+
baselined++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const j = justification(readSource(m.file), m.line);
|
|
56
|
+
if (j) {
|
|
57
|
+
justified.push({ ...m, ...j });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const why = !base ? 'new' : m.cc > base.cc ? 'cc-increased' : 'crap-increased';
|
|
61
|
+
violations.push({ ...m, why, baseline: base ?? null });
|
|
62
|
+
}
|
|
63
|
+
// Entries that no longer violate (refactored, tested, deleted) can leave the
|
|
64
|
+
// baseline; --prune does that and only ever shrinks it.
|
|
65
|
+
const fixed = Object.keys(baseline).filter((k) => !current.has(k) || !violates(current.get(k))).sort();
|
|
66
|
+
return { violations, justified, baselined, fixed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Rewrite the baseline only downwards: drop fixed entries and lower values that
|
|
70
|
+
// improved. Never adds a key and never raises a value.
|
|
71
|
+
export function pruneBaseline(baseline, methods) {
|
|
72
|
+
const current = worstByKey(methods);
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [k, base] of Object.entries(baseline)) {
|
|
75
|
+
const m = current.get(k);
|
|
76
|
+
if (!m || !violates(m)) continue;
|
|
77
|
+
out[k] = { cc: Math.min(base.cc, m.cc), crap: Math.min(base.crap, Math.round(m.crap * 100) / 100) };
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
// Run: node --test scripts/lib/complexity-gate.test.mjs
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { buildBaseline, evaluate, justification, pruneBaseline } from './complexity-gate.mjs';
|
|
6
|
+
import { scoreMethods } from './engineering-excellence.mjs';
|
|
7
|
+
|
|
8
|
+
const m = (method, cc, coverage, file = 'apps/api/src/App.Domain/Renting/Contract.cs', line = 10) =>
|
|
9
|
+
scoreMethods([{ scope: 'App.Domain', module: 'Renting', file, method, line, cc, coverage }])[0];
|
|
10
|
+
|
|
11
|
+
test('evaluate_newMethodAboveCcCap_isViolationEvenWhenFullyCovered', () => {
|
|
12
|
+
const { violations } = evaluate([m('Contract.Price', 16, 1)], {});
|
|
13
|
+
|
|
14
|
+
assert.equal(violations.length, 1);
|
|
15
|
+
assert.equal(violations[0].why, 'new');
|
|
16
|
+
assert.equal(violations[0].crap, 16);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('evaluate_newMethodUnderCcCapButUntested_isViolationOnCrap', () => {
|
|
20
|
+
// CC 12 at 0% coverage: 144 + 12 = 156 > 30.
|
|
21
|
+
const { violations } = evaluate([m('Contract.Renew', 12, 0)], {});
|
|
22
|
+
|
|
23
|
+
assert.deepEqual(violations.map((v) => [v.method, v.crap]), [['Contract.Renew', 156]]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('evaluate_complexMethodTestedInProportion_passes', () => {
|
|
27
|
+
// CC 12 at 60% coverage: 144 * 0.064 + 12 = 21.2 <= 30.
|
|
28
|
+
const r = evaluate([m('Contract.Renew', 12, 0.6), m('Contract.Id', 1, 0)], {});
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(r.violations, []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('evaluate_baselinedMethodUnchanged_passesAndCountsAsBacklog', () => {
|
|
34
|
+
const method = m('Contract.Parse', 40, 0.5);
|
|
35
|
+
const r = evaluate([method], { [`${method.file}::Contract.Parse`]: { cc: 40, crap: method.crap } });
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(r.violations, []);
|
|
38
|
+
assert.equal(r.baselined, 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('evaluate_baselinedMethodMoreComplex_isViolation', () => {
|
|
42
|
+
const method = m('Contract.Parse', 41, 0.5);
|
|
43
|
+
const { violations } = evaluate([method], { [`${method.file}::Contract.Parse`]: { cc: 40, crap: 9999 } });
|
|
44
|
+
|
|
45
|
+
assert.equal(violations[0].why, 'cc-increased');
|
|
46
|
+
assert.deepEqual(violations[0].baseline, { cc: 40, crap: 9999 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('evaluate_baselinedMethodLostCoverageBeyondTolerance_isViolation', () => {
|
|
50
|
+
const method = m('Contract.Parse', 20, 0.4); // CRAP 106.4
|
|
51
|
+
const key = `${method.file}::Contract.Parse`;
|
|
52
|
+
|
|
53
|
+
assert.equal(evaluate([method], { [key]: { cc: 20, crap: 105.5 } }).violations.length, 0, 'within the 1-point tolerance');
|
|
54
|
+
assert.equal(evaluate([method], { [key]: { cc: 20, crap: 100 } }).violations[0].why, 'crap-increased');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('evaluate_justifiedMethod_isExemptAndReported', () => {
|
|
58
|
+
const method = m('Contract.Route', 18, 1, 'apps/web/src/lib/route.ts', 4);
|
|
59
|
+
const source = ['', '// complexity-justified: exhaustive status table, splitting hides it (#1941)', '', 'export function route() {'];
|
|
60
|
+
|
|
61
|
+
const r = evaluate([method], {}, () => source);
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(r.violations, []);
|
|
64
|
+
assert.deepEqual(r.justified.map((j) => [j.method, j.issue]), [['Contract.Route', 1941]]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('justification_commentWithoutIssueOrTooFarAbove_isIgnored', () => {
|
|
68
|
+
const lines = ['// complexity-justified: trust me', 'a', 'b', 'c', 'd', 'e', 'f', 'fn()'];
|
|
69
|
+
assert.equal(justification(lines, 8), null, 'no (#issue)');
|
|
70
|
+
const far = ['// complexity-justified: ok (#1)', 'a', 'b', 'c', 'd', 'e', 'fn()'];
|
|
71
|
+
assert.equal(justification(far, 7), null, 'more than 5 lines above');
|
|
72
|
+
assert.deepEqual(justification(far, 6), { reason: 'ok', issue: 1 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('evaluate_baselineEntriesNoLongerViolating_areReportedFixed', () => {
|
|
76
|
+
const tested = m('Contract.Parse', 12, 1);
|
|
77
|
+
const r = evaluate([tested], {
|
|
78
|
+
[`${tested.file}::Contract.Parse`]: { cc: 12, crap: 156 },
|
|
79
|
+
'apps/web/src/gone.ts::removed': { cc: 30, crap: 900 },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(r.fixed, ['apps/api/src/App.Domain/Renting/Contract.cs::Contract.Parse', 'apps/web/src/gone.ts::removed']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('buildBaseline_overloads_keepsOnlyViolatorsAtTheirWorst', () => {
|
|
86
|
+
const b = buildBaseline([m('Contract.Parse', 20, 0), m('Contract.Parse', 25, 0), m('Contract.Id', 1, 0)]);
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(b, { 'apps/api/src/App.Domain/Renting/Contract.cs::Contract.Parse': { cc: 25, crap: 650 } });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('pruneBaseline_improvementsAndRegressions_onlyEverShrinks', () => {
|
|
92
|
+
const key = (name) => `apps/api/src/App.Domain/Renting/Contract.cs::${name}`;
|
|
93
|
+
const baseline = {
|
|
94
|
+
[key('Improved')]: { cc: 30, crap: 900 },
|
|
95
|
+
[key('Worse')]: { cc: 20, crap: 100 },
|
|
96
|
+
[key('Fixed')]: { cc: 16, crap: 16 },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const pruned = pruneBaseline(baseline, [m('Improved', 25, 1), m('Worse', 22, 0), m('Fixed', 10, 1), m('Brand', 50, 0)]);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(pruned, {
|
|
102
|
+
[key('Improved')]: { cc: 25, crap: 25 },
|
|
103
|
+
[key('Worse')]: { cc: 20, crap: 100 },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/duplication-gate.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
// Rules for the blocking duplication gate (practice 11, vendored from PlainConceptsPlatform/Atlas, Atlas#1942). Pure functions; the CLI is
|
|
3
|
+
// scripts/check-duplication.mjs, and the per-file numbers come from
|
|
4
|
+
// duplicatedLinesByFile in engineering-excellence.mjs, so the gate and the hygiene facet count the
|
|
5
|
+
// same lines.
|
|
6
|
+
//
|
|
7
|
+
// Same model as the complexity gate (complexity-gate.mjs): a baseline, not a diff.
|
|
8
|
+
// scripts/duplication-baseline.json records, per file, how many of its lines sit
|
|
9
|
+
// inside a jscpd clone today. A file may keep that many but never more, and a file
|
|
10
|
+
// outside the baseline may have none. Any clone at or above jscpd's minimum (.jscpd.json)
|
|
11
|
+
// that a PR adds therefore fails, in every file it touches, while the existing
|
|
12
|
+
// backlog stays green. The ratchet is the file only ever shrinking.
|
|
13
|
+
|
|
14
|
+
export function buildBaseline(duplicated) {
|
|
15
|
+
return Object.fromEntries(
|
|
16
|
+
Object.entries(duplicated)
|
|
17
|
+
.filter(([, lines]) => lines > 0)
|
|
18
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function evaluate(duplicated, baseline) {
|
|
23
|
+
const violations = [];
|
|
24
|
+
let baselined = 0;
|
|
25
|
+
for (const [file, lines] of Object.entries(duplicated).sort(([a], [b]) => a.localeCompare(b))) {
|
|
26
|
+
if (lines === 0) continue;
|
|
27
|
+
const base = baseline[file];
|
|
28
|
+
if (base !== undefined && lines <= base) {
|
|
29
|
+
baselined++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
violations.push({ file, lines, baseline: base ?? null, why: base === undefined ? 'new' : 'increased' });
|
|
33
|
+
}
|
|
34
|
+
// Entries whose file improved or lost its clones; --prune lowers or drops them.
|
|
35
|
+
const fixed = Object.keys(baseline)
|
|
36
|
+
.filter((file) => (duplicated[file] ?? 0) < baseline[file])
|
|
37
|
+
.sort();
|
|
38
|
+
return { violations, baselined, fixed };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only ever downwards: lowers improved entries, drops files with no clone left (or
|
|
42
|
+
// gone), never adds a file and never raises a count.
|
|
43
|
+
export function pruneBaseline(baseline, duplicated) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [file, base] of Object.entries(baseline)) {
|
|
46
|
+
const lines = Math.min(base, duplicated[file] ?? 0);
|
|
47
|
+
if (lines > 0) out[file] = lines;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
// Run: node --test scripts/lib/duplication-gate.test.mjs
|
|
3
|
+
// Vendored from PlainConceptsPlatform/Atlas scripts/lib/duplication-gate.test.mjs (Atlas#1942).
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { buildBaseline, evaluate, pruneBaseline } from './duplication-gate.mjs';
|
|
7
|
+
|
|
8
|
+
const A = 'apps/api/src/App.Api/Audit/AuditEndpoints.cs';
|
|
9
|
+
const B = 'apps/web/src/pages/Settings.tsx';
|
|
10
|
+
const C = 'apps/web/src/lib/api.ts';
|
|
11
|
+
|
|
12
|
+
test('evaluate_cloneInFileOutsideBaseline_isNewViolation', () => {
|
|
13
|
+
const r = evaluate({ [A]: 30, [C]: 12 }, { [A]: 30 });
|
|
14
|
+
|
|
15
|
+
assert.deepEqual(r.violations, [{ file: C, lines: 12, baseline: null, why: 'new' }]);
|
|
16
|
+
assert.equal(r.baselined, 1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('evaluate_baselinedFileGainsDuplicatedLines_isIncreasedViolation', () => {
|
|
20
|
+
const r = evaluate({ [A]: 41 }, { [A]: 30 });
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(r.violations, [{ file: A, lines: 41, baseline: 30, why: 'increased' }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('evaluate_baselinedFilesAtOrUnderBaseline_passAndReportImprovements', () => {
|
|
26
|
+
const r = evaluate({ [A]: 30, [B]: 5 }, { [A]: 30, [B]: 20, [C]: 12 });
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(r.violations, []);
|
|
29
|
+
assert.equal(r.baselined, 2);
|
|
30
|
+
assert.deepEqual(r.fixed, [C, B], 'C lost its clones and B shrank, sorted by path');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('evaluate_noClonesAndEmptyBaseline_passes', () => {
|
|
34
|
+
assert.deepEqual(evaluate({}, {}), { violations: [], baselined: 0, fixed: [] });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('buildBaseline_duplicatedLines_keepsOnlyFilesWithClonesSorted', () => {
|
|
38
|
+
const b = buildBaseline({ [C]: 12, [A]: 30, [B]: 0 });
|
|
39
|
+
|
|
40
|
+
assert.deepEqual(Object.entries(b), [[A, 30], [C, 12]]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('pruneBaseline_improvementsAndRegressions_onlyEverShrinks', () => {
|
|
44
|
+
const pruned = pruneBaseline({ [A]: 30, [B]: 20, [C]: 12 }, { [A]: 45, [B]: 8, 'apps/web/src/new.ts': 50 });
|
|
45
|
+
|
|
46
|
+
assert.deepEqual(pruned, { [A]: 30, [B]: 8 }, 'A is not raised, B is lowered, C is dropped, the new file is not added');
|
|
47
|
+
});
|