@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.
Files changed (34) 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/templates/ci/app-ci-dotnet-next.yml +778 -117
  10. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +202 -0
  11. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +453 -0
  12. package/loops/templates/quality-gates/.jscpd.json +36 -0
  13. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  14. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  15. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  16. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  17. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  18. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  19. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  20. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  21. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  22. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  23. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  24. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  25. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  26. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  27. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  28. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  29. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  30. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  31. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  32. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  33. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  34. package/package.json +2 -2
@@ -0,0 +1,284 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ // Run: node --test scripts/lib/
3
+ import assert from 'node:assert/strict';
4
+ import { readFileSync } from 'node:fs';
5
+ import { test } from 'node:test';
6
+ import {
7
+ apiMethods,
8
+ complexityFacetData,
9
+ coverageFacetData,
10
+ crap,
11
+ envelope,
12
+ isGenerated,
13
+ moduleOf,
14
+ mutationFacetData,
15
+ mutationScores,
16
+ sbomFacetData,
17
+ securityFacetData,
18
+ semgrepSummary,
19
+ tallyMutants,
20
+ trivySummary,
21
+ validateFacet,
22
+ webMethods,
23
+ } from './engineering-excellence.mjs';
24
+
25
+ const SCHEMA = JSON.parse(readFileSync(new URL('../engineering-excellence.schema.json', import.meta.url), 'utf8'));
26
+ const ENV = { GITHUB_REPOSITORY: 'o/r', GITHUB_REF: 'refs/heads/main', GITHUB_SHA: 'abc', GITHUB_RUN_ID: '42', GITHUB_RUN_ATTEMPT: '1' };
27
+ const NOW = new Date('2026-10-11T00:00:00Z');
28
+
29
+ // Shaped like ReportGenerator's merged output: class-level <lines> after <methods>.
30
+ const API_XML = `<?xml version="1.0"?>
31
+ <coverage line-rate="0.6" branch-rate="0.5" lines-covered="3" lines-valid="5">
32
+ <packages>
33
+ <package name="App.Domain" line-rate="0.6" branch-rate="0.5">
34
+ <classes>
35
+ <class name="App.Domain.Inventory.Asset" filename="/home/runner/work/app/app/apps/api/src/App.Domain/Inventory/Asset.cs" line-rate="0.6667">
36
+ <methods>
37
+ <method name="Retire" signature="()" line-rate="0.5" complexity="12">
38
+ <lines><line number="10" hits="1"/><line number="11" hits="0"/></lines>
39
+ </method>
40
+ </methods>
41
+ <lines>
42
+ <line number="10" hits="1" branch="True" condition-coverage="50% (1/2)"/>
43
+ <line number="11" hits="0"/>
44
+ <line number="20" hits="4"/>
45
+ </lines>
46
+ </class>
47
+ <class name="App.Domain.Entity" filename="/home/runner/work/app/app/apps/api/src/App.Domain/Entity.cs" line-rate="0.5">
48
+ <methods>
49
+ <method name="get_Id" signature="()" line-rate="1" complexity="1">
50
+ <lines><line number="3" hits="2"/></lines>
51
+ </method>
52
+ </methods>
53
+ <lines><line number="3" hits="2"/><line number="4" hits="0"/></lines>
54
+ </class>
55
+ <class name="App.Domain.Generated" filename="/home/runner/work/app/app/apps/api/src/App.Domain/obj/Release/net10.0/Regex.g.cs" line-rate="0">
56
+ <methods>
57
+ <method name="TryMatch" signature="()" line-rate="0" complexity="900">
58
+ <lines><line number="1" hits="0"/></lines>
59
+ </method>
60
+ </methods>
61
+ <lines><line number="1" hits="0"/></lines>
62
+ </class>
63
+ </classes>
64
+ </package>
65
+ </packages>
66
+ </coverage>`;
67
+
68
+ const WEB_XML = `<coverage lines-valid="4" lines-covered="1" line-rate="0.25" branch-rate="0">
69
+ <packages>
70
+ <package name="src.lib" line-rate="0.25">
71
+ <classes>
72
+ <class name="format.ts" filename="src/lib/format.ts" line-rate="0.25">
73
+ <methods/>
74
+ <lines><line number="1" hits="1"/><line number="2" hits="0"/><line number="3" hits="0"/><line number="4" hits="0"/></lines>
75
+ </class>
76
+ </classes>
77
+ </package>
78
+ </packages>
79
+ </coverage>`;
80
+
81
+ const doc = (facet, data) => envelope(facet, data, ENV, NOW);
82
+
83
+ test('moduleOf_backendFile_returnsBoundedContextFolder', () => {
84
+ assert.equal(moduleOf('/x/apps/api/src/App.Application/Inventory/Assets/Svc.cs'), 'Inventory');
85
+ assert.equal(moduleOf('/x/apps/api/src/App.SharedKernel/AuditHash.cs'), '(root)');
86
+ });
87
+
88
+ test('moduleOf_webFile_returnsUpToTwoFoldersUnderSrc', () => {
89
+ assert.equal(moduleOf('src/components/assets/AssetCard.tsx'), 'components/assets');
90
+ assert.equal(moduleOf('/x/apps/web/src/pages/a/b/Deep.tsx'), 'pages/a');
91
+ assert.equal(moduleOf('src/App.tsx'), '(root)');
92
+ });
93
+
94
+ test('isGenerated_objAndGeneratedSources_areExcluded', () => {
95
+ assert.equal(isGenerated('/x/src/App.Application/obj/Release/net10.0/RegexGenerator.g.cs'), true);
96
+ assert.equal(isGenerated('/x/src/App.Application/Foo.g.cs'), true);
97
+ assert.equal(isGenerated('/x/src/App.Application/Objects/Foo.cs'), false);
98
+ });
99
+
100
+ test('coverageFacetData_mergedReport_reportsGateRateAndPerContextCounts', () => {
101
+ const d = coverageFacetData({ apiXml: API_XML, webXml: WEB_XML, floors: { api: { 'App.Domain': 0.85 }, web: 0.2 } });
102
+ const domain = d.scopes['App.Domain'];
103
+ // The scope reports the package attribute the gate judges, not a recount.
104
+ assert.equal(domain.lineRate, 0.6);
105
+ assert.equal(domain.floor, 0.85);
106
+ assert.deepEqual(Object.keys(domain.modules), ['(root)', 'Inventory']);
107
+ assert.deepEqual(domain.modules.Inventory, { lineRate: 0.6667, branchRate: 0.5, linesCovered: 2, linesValid: 3 });
108
+ assert.equal(domain.modules['(root)'].linesValid, 2);
109
+ assert.equal('obj' in domain.modules, false);
110
+ assert.equal(d.scopes.web.lineRate, 0.25);
111
+ assert.equal(d.scopes.web.linesValid, 4);
112
+ assert.equal(d.passed, false);
113
+ assert.deepEqual(validateFacet(doc('coverage', d), SCHEMA), []);
114
+ });
115
+
116
+ test('coverageFacetData_allScopesAtOrAboveFloor_passes', () => {
117
+ const d = coverageFacetData({ apiXml: API_XML, webXml: WEB_XML, floors: { api: { 'App.Domain': 0.6 }, web: 0.25 } });
118
+ assert.equal(d.passed, true);
119
+ });
120
+
121
+ test('crap_fullyCoveredMethod_equalsComplexity', () => {
122
+ assert.equal(crap(5, 1), 5);
123
+ assert.equal(crap(5, 0), 30);
124
+ assert.equal(crap(10, 0.5), 22.5);
125
+ });
126
+
127
+ test('apiMethods_generatedAndDuplicateMethods_areSkipped', () => {
128
+ const xml = API_XML.replace('</classes>', `
129
+ <class name="App.Domain.Inventory.Asset/Nested" filename="/home/runner/work/app/app/apps/api/src/App.Domain/Inventory/Asset.cs">
130
+ <methods><method name="Retire" line-rate="0.5" complexity="12"><lines><line number="10" hits="1"/></lines></method></methods>
131
+ <lines/>
132
+ </class></classes>`);
133
+ const methods = apiMethods(xml);
134
+ assert.deepEqual(methods.map((m) => m.method).sort(), ['Asset.Retire', 'Entity.get_Id']);
135
+ const retire = methods.find((m) => m.method === 'Asset.Retire');
136
+ assert.equal(retire.file, 'apps/api/src/App.Domain/Inventory/Asset.cs');
137
+ assert.equal(retire.line, 10);
138
+ assert.equal(retire.cc, 12);
139
+ });
140
+
141
+ test('webMethods_eslintHeadInsideNestedFunctions_attributesInnermostSpan', () => {
142
+ const coverageFinal = {
143
+ '/r/apps/web/src/lib/format.ts': {
144
+ path: '/r/apps/web/src/lib/format.ts',
145
+ fnMap: {
146
+ 0: { name: 'outer', loc: { start: { line: 1, column: 0 }, end: { line: 10, column: 1 } } },
147
+ 1: { name: '(anonymous_1)', loc: { start: { line: 3, column: 10 }, end: { line: 5, column: 3 } } },
148
+ },
149
+ f: { 0: 1, 1: 0 },
150
+ statementMap: {
151
+ 0: { start: { line: 2, column: 2 }, end: { line: 2, column: 9 } },
152
+ 1: { start: { line: 4, column: 4 }, end: { line: 4, column: 9 } },
153
+ 2: { start: { line: 6, column: 2 }, end: { line: 6, column: 9 } },
154
+ },
155
+ s: { 0: 1, 1: 0, 2: 1 },
156
+ },
157
+ };
158
+ const eslint = [{
159
+ filePath: '/r/apps/web/src/lib/format.ts',
160
+ messages: [
161
+ { ruleId: 'complexity', message: "Function 'outer' has a complexity of 4. Maximum allowed is 0.", line: 1, column: 10 },
162
+ { ruleId: 'complexity', message: 'Arrow function has a complexity of 11. Maximum allowed is 0.', line: 3, column: 14 },
163
+ { ruleId: 'no-unused-vars', message: 'x', line: 2, column: 1 },
164
+ ],
165
+ }, { filePath: '/r/apps/web/src/lib/format.test.ts', messages: [{ ruleId: 'complexity', message: 'has a complexity of 3.', line: 1, column: 1 }] }];
166
+ const [outer, arrow, ...rest] = webMethods(eslint, coverageFinal);
167
+ assert.equal(rest.length, 0, 'files outside coverage (tests) are not production code');
168
+ assert.deepEqual({ method: outer.method, cc: outer.cc, coverage: outer.coverage }, { method: 'outer', cc: 4, coverage: 2 / 3 });
169
+ assert.deepEqual({ method: arrow.method, line: arrow.line, cc: arrow.cc, coverage: arrow.coverage }, { method: 'outer/anonymous', line: 3, cc: 11, coverage: 0 });
170
+ assert.equal(arrow.module, 'lib');
171
+ });
172
+
173
+ test('complexityFacetData_methods_summarisesPerScopeAndRanksHotspotsByCrap', () => {
174
+ const m = (scope, module, cc, coverage, method) => ({ scope, module, file: `f/${method}`, method, line: 1, cc, coverage });
175
+ const d = complexityFacetData([
176
+ m('web', 'lib', 1, 1, 'a'),
177
+ m('web', 'lib', 12, 0, 'b'),
178
+ m('web', 'pages', 40, 0.5, 'c'),
179
+ m('App.Domain', 'Inventory', 6, 0, 'd'),
180
+ ]);
181
+ assert.deepEqual(d.scopes.web, {
182
+ methods: 3, ccP50: 12, ccP90: 40, ccMax: 40, over10: 2, crapOver30: 2,
183
+ modules: {
184
+ lib: { methods: 2, ccP50: 12, ccP90: 12, ccMax: 12, over10: 1, crapOver30: 1 },
185
+ pages: { methods: 1, ccP50: 40, ccP90: 40, ccMax: 40, over10: 1, crapOver30: 1 },
186
+ },
187
+ });
188
+ assert.deepEqual(d.hotspots.map((h) => [h.method, h.crap]), [['c', 240], ['b', 156], ['d', 42]]);
189
+ assert.equal(d.global.methods, 4);
190
+ assert.deepEqual(validateFacet(doc('complexity', d), SCHEMA), []);
191
+ });
192
+
193
+ const report = (files) => ({ files: Object.fromEntries(Object.entries(files).map(([f, st]) => [f, { mutants: st.map((status) => ({ status })) }])) });
194
+
195
+ test('mutationScores_noCoverageAndCompileErrors_onlyNoCoverageCountsInFullScore', () => {
196
+ const s = mutationScores(tallyMutants(report({ a: ['Killed', 'Killed', 'Timeout', 'Survived', 'NoCoverage', 'CompileError', 'Ignored'] })));
197
+ assert.equal(s.coveredScore, 3 / 4);
198
+ assert.equal(s.fullScore, 3 / 5);
199
+ assert.equal(s.valid, 5);
200
+ });
201
+
202
+ test('mutationFacetData_reports_scoresPerModuleAndWeightsGlobalByMutants', () => {
203
+ const d = mutationFacetData({
204
+ domain: report({
205
+ '/w/apps/api/src/App.Domain/Renting/Contract.cs': ['Killed', 'Killed', 'Survived'],
206
+ '/w/apps/api/src/App.Domain/Common/Guard.cs': ['Killed'],
207
+ }),
208
+ web: report({ 'src/lib/format.ts': ['Survived'] }),
209
+ }, { domain: 0.7, web: 0.92 });
210
+ assert.equal(d.scopes.domain.coveredScore, 0.75);
211
+ assert.equal(d.scopes.domain.passed, true);
212
+ assert.equal(d.scopes.domain.modules.Renting.coveredScore, 0.6667);
213
+ assert.equal(d.scopes.web.passed, false);
214
+ assert.equal(d.global.coveredScore, 0.6);
215
+ assert.equal(d.passed, false);
216
+ assert.deepEqual(validateFacet(doc('mutation', d), SCHEMA), []);
217
+ });
218
+
219
+ test('trivySummary_findings_countsBySeverityAndScannerIgnoringPassedChecks', () => {
220
+ const t = trivySummary({ Results: [
221
+ { Vulnerabilities: [{ Severity: 'HIGH', FixedVersion: '1.2' }, { Severity: 'LOW' }] },
222
+ { Misconfigurations: [{ Severity: 'MEDIUM', Status: 'FAIL' }, { Severity: 'HIGH', Status: 'PASS' }] },
223
+ { Secrets: [{ Severity: 'CRITICAL' }] },
224
+ ] });
225
+ assert.equal(t.vuln.HIGH, 1);
226
+ assert.equal(t.vuln.LOW, 1);
227
+ assert.equal(t.fixableVulns, 1);
228
+ assert.equal(t.misconfig.MEDIUM, 1);
229
+ assert.equal(t.misconfig.HIGH, 0);
230
+ assert.equal(t.secret.CRITICAL, 1);
231
+ });
232
+
233
+ const SEMGREP = { results: [{ extra: { severity: 'ERROR' } }, { extra: { severity: 'WARNING' } }], errors: [{}] };
234
+
235
+ test('semgrepSummary_findingsAndScanErrors_countsBySeverity', () => {
236
+ assert.deepEqual(semgrepSummary(SEMGREP), { ERROR: 1, WARNING: 1, INFO: 0, total: 2, scanErrors: 1 });
237
+ });
238
+
239
+ test('securityFacetData_anyBlockingJobFailed_isNotPassed', () => {
240
+ const d = securityFacetData({ trivy: { Results: [] }, semgrep: SEMGREP, jobs: { secretScan: 'success', sast: 'failure' } });
241
+
242
+ assert.equal(d.passed, false);
243
+ assert.deepEqual(validateFacet(doc('security', d), SCHEMA), []);
244
+ });
245
+
246
+ test('securityFacetData_reportsMissing_recordsNullBlocksAndStillValidates', () => {
247
+ const d = securityFacetData({ trivy: null, semgrep: null, jobs: { sast: 'success' } });
248
+
249
+ assert.equal(d.trivy, null);
250
+ assert.equal(d.passed, true);
251
+ assert.deepEqual(validateFacet(doc('security', d), SCHEMA), []);
252
+ });
253
+
254
+ test('sbomFacetData_cyclonedx_countsEcosystemsLicensesAndUnlicensed', () => {
255
+ const text = JSON.stringify({ bomFormat: 'CycloneDX', specVersion: '1.6', components: [
256
+ { purl: 'pkg:nuget/Foo@1', licenses: [{ license: { id: 'MIT' } }] },
257
+ { purl: 'pkg:npm/bar@2', licenses: [{ expression: 'Apache-2.0 OR MIT' }] },
258
+ { purl: 'pkg:npm/baz@3', licenses: [{ license: { id: 'MIT' } }, { license: { id: 'MIT' } }] },
259
+ { name: 'no-purl' },
260
+ ] });
261
+ const d = sbomFacetData(text);
262
+ assert.equal(d.components, 4);
263
+ assert.deepEqual(d.byEcosystem, { npm: 2, nuget: 1, unknown: 1 });
264
+ assert.deepEqual(d.licenses, { MIT: 2, 'Apache-2.0 OR MIT': 1 });
265
+ assert.equal(d.unlicensed, 1);
266
+ assert.match(d.sha256, /^[0-9a-f]{64}$/);
267
+ assert.deepEqual(validateFacet(doc('sbom', d), SCHEMA), []);
268
+ });
269
+
270
+ test('validateFacet_contractViolations_areReported', () => {
271
+ assert.deepEqual(validateFacet({ ...doc('sbom', {}), extra: 1 }, SCHEMA), ["$: unexpected 'extra'"]);
272
+ const bad = doc('mutation', { scopes: { domain: { coveredScore: 1.5 } }, global: {}, passed: true });
273
+ const errors = validateFacet(bad, SCHEMA);
274
+ assert.ok(errors.includes('$.data.scopes.domain.coveredScore: 1.5 > 1'), errors.join('\n'));
275
+ assert.ok(errors.some((e) => e.includes("missing 'floor'")));
276
+ });
277
+
278
+ test('envelope_githubEnv_carriesProvenance', () => {
279
+ const e = doc('sbom', {});
280
+ assert.deepEqual(
281
+ { repo: e.repo, ref: e.ref, commitSha: e.commitSha, runId: e.runId, runAttempt: e.runAttempt, generatedAt: e.generatedAt, schemaVersion: e.schemaVersion },
282
+ { repo: 'o/r', ref: 'refs/heads/main', commitSha: 'abc', runId: 42, runAttempt: 1, generatedAt: '2026-10-11T00:00:00.000Z', schemaVersion: 1 },
283
+ );
284
+ });
@@ -0,0 +1,179 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ // Run: node --test scripts/lib/hygiene-facet.test.mjs
3
+ // Vendored from PlainConceptsPlatform/Atlas scripts/lib/hygiene-facet.test.mjs (Atlas#1942): paths and
4
+ // rule ids made neutral for the platform family, and Atlas's mock-data exclusion is dropped.
5
+ import assert from 'node:assert/strict';
6
+ import { readFileSync } from 'node:fs';
7
+ import { test } from 'node:test';
8
+ import {
9
+ commentStats,
10
+ duplicatedLinesByFile,
11
+ envelope,
12
+ globToRegExp,
13
+ hygieneFacetData,
14
+ justifiedSwallows,
15
+ scannedBy,
16
+ swallowFindings,
17
+ validateFacet,
18
+ } from './engineering-excellence.mjs';
19
+
20
+ const SCHEMA = JSON.parse(readFileSync(new URL('../engineering-excellence.schema.json', import.meta.url), 'utf8'));
21
+ const JSCPD_CONFIG = JSON.parse(readFileSync(new URL('../../.jscpd.json', import.meta.url), 'utf8'));
22
+ const doc = (data) => envelope('hygiene', data, { GITHUB_SHA: 'abc', GITHUB_RUN_ID: '1' }, new Date('2026-10-11T00:00:00Z'));
23
+
24
+ // Shaped like jscpd 5's JSON reporter with `absolute: true`.
25
+ const ROOT = '/home/runner/work/app/app';
26
+ const clone = (a, aStart, aEnd, b, bStart, bEnd) => ({
27
+ format: 'csharp',
28
+ lines: aEnd - aStart + 1,
29
+ firstFile: { name: `${ROOT}/${a}`, start: aStart, end: aEnd },
30
+ secondFile: { name: `${ROOT}/${b}`, start: bStart, end: bEnd },
31
+ });
32
+ const ENDPOINTS = 'apps/api/src/App.Api/Audit/AuditEndpoints.cs';
33
+ const EXPORT = 'apps/api/src/App.Api/Audit/AuditExport.cs';
34
+ const TABLE = 'apps/web/src/components/assets/AssetTable.tsx';
35
+ const REPORT = {
36
+ duplicates: [
37
+ clone(ENDPOINTS, 10, 21, ENDPOINTS, 40, 51),
38
+ // Overlaps the first clone's lines 10-21 by six lines: counted once.
39
+ clone(ENDPOINTS, 16, 27, EXPORT, 1, 12),
40
+ ],
41
+ };
42
+
43
+ const SEMGREP = {
44
+ results: [
45
+ { check_id: '.semgrep.platform-hygiene-ts-empty-catch', path: TABLE, extra: { severity: 'ERROR' } },
46
+ { check_id: 'csharp.lang.security.something-else', path: EXPORT, extra: { severity: 'WARNING' } },
47
+ ],
48
+ errors: [],
49
+ };
50
+
51
+ const lines = (n, line = 'x();') => Array.from({ length: n }, () => line).join('\n') + '\n';
52
+ const FILES = [
53
+ { file: ENDPOINTS, text: `// The audit read model.\n${lines(59)}` },
54
+ { file: EXPORT, text: `// nosemgrep: platform-hygiene-csharp-empty-catch — the client is gone\n${lines(39)}` },
55
+ { file: TABLE, text: lines(20) },
56
+ ];
57
+
58
+ test('duplicatedLinesByFile_overlappingClones_countsEachLineOncePerFile', () => {
59
+ // AuditEndpoints: 10-21 and 40-51 from the first clone, plus 22-27 from the second.
60
+ assert.deepEqual(duplicatedLinesByFile(REPORT), { [ENDPOINTS]: 30, [EXPORT]: 12 });
61
+ });
62
+
63
+ test('duplicatedLinesByFile_fragmentEndPastItsFile_isClampedToCloneLengthAndFileEnd', () => {
64
+ // jscpd 4.3.0 shape: a 16-line clone whose second fragment "ends" at another file's last line.
65
+ const bogus = { duplicates: [{ ...clone(EXPORT, 381, 396, ENDPOINTS, 74, 1605), lines: 16 }] };
66
+
67
+ assert.deepEqual(duplicatedLinesByFile(bogus), { [ENDPOINTS]: 16, [EXPORT]: 16 });
68
+ assert.deepEqual(duplicatedLinesByFile(bogus, (f) => (f === ENDPOINTS ? 86 : 400)), { [ENDPOINTS]: 13, [EXPORT]: 16 });
69
+ });
70
+
71
+ test('duplicatedLinesByFile_noDuplicates_isEmpty', () => {
72
+ assert.deepEqual(duplicatedLinesByFile({ duplicates: [] }), {});
73
+ });
74
+
75
+ test('commentStats_mixedSource_separatesCommentOnlyLinesFromCode', () => {
76
+ const text = [
77
+ '/// <summary>Doc.</summary>',
78
+ 'public int X() => 1; // trailing comments leave a code line',
79
+ '',
80
+ '/* one-line block */',
81
+ '/*',
82
+ ' * multi-line',
83
+ ' */',
84
+ '/* closes */ var y = 2;',
85
+ 'var url = "http://not-a-comment";',
86
+ '{/* JSX comment child */}',
87
+ ].join('\n');
88
+
89
+ assert.deepEqual(commentStats(text), { codeLines: 4, commentLines: 5 });
90
+ });
91
+
92
+ test('commentStats_blockCommentEndingBeforeCode_countsTheCode', () => {
93
+ assert.deepEqual(commentStats('/*\n still comment\n*/ return 1;\n'), { codeLines: 1, commentLines: 2 });
94
+ });
95
+
96
+ test('justifiedSwallows_hygieneAndOtherNosemgrep_countsOnlyHygieneRules', () => {
97
+ const text = [
98
+ '// nosemgrep: platform-hygiene-csharp-empty-catch — a reason',
99
+ '// nosemgrep: platform-hygiene-ts-empty-catch — another reason',
100
+ '// nosemgrep: csharp.lang.security.sqli — parameterised',
101
+ ].join('\n');
102
+
103
+ assert.equal(justifiedSwallows(text), 2);
104
+ });
105
+
106
+ test('swallowFindings_semgrepJson_countsOnlyHygieneRuleFindingsPerFile', () => {
107
+ assert.deepEqual(swallowFindings(SEMGREP), { [TABLE]: 1 });
108
+ });
109
+
110
+ test('globToRegExp_jscpdIgnoreGlobs_matchAtAnyDepthOrLiteralPath', () => {
111
+ assert.ok(globToRegExp('**/obj/**').test('apps/api/src/App.Api/obj/Debug/x.g.cs'));
112
+ assert.ok(globToRegExp('**/*.test.tsx').test('apps/web/src/App.test.tsx'));
113
+ assert.ok(globToRegExp('apps/web/src/data/mock.ts').test('apps/web/src/data/mock.ts'));
114
+ assert.equal(globToRegExp('apps/web/src/data/mock.ts').test('apps/web/src/data/mockXts'), false);
115
+ assert.equal(globToRegExp('**/*.g.cs').test('apps/api/src/App.Api/Program.cs'), false);
116
+ });
117
+
118
+ test('scannedBy_repoJscpdConfig_excludesTestsGeneratedAndMigrations', () => {
119
+ const scanned = scannedBy(JSCPD_CONFIG);
120
+
121
+ assert.ok(scanned('apps/api/src/App.Domain/Inventory/Asset.cs'));
122
+ assert.ok(scanned('apps/web/src/pages/Settings.tsx'));
123
+ assert.equal(scanned('apps/api/tests/App.UnitTests/AssetTests.cs'), false, 'outside the scanned roots');
124
+ assert.equal(scanned('apps/api/src/App.Infrastructure/Persistence/Migrations/20260101_Init.cs'), false);
125
+ assert.equal(scanned('apps/api/src/App.Api/obj/Release/App.Api.AssemblyInfo.cs'), false);
126
+ assert.equal(scanned('apps/api/src/App.Domain/Regexes.g.cs'), false);
127
+ assert.equal(scanned('apps/web/src/components/DataTable.test.tsx'), false);
128
+ assert.equal(scanned('apps/web/src/index.css'), false, 'not a scanned format');
129
+ });
130
+
131
+ test('hygieneFacetData_fixtures_aggregatesPerScopeAndModuleAndValidates', () => {
132
+ const d = hygieneFacetData({
133
+ files: FILES,
134
+ duplicated: duplicatedLinesByFile(REPORT),
135
+ semgrep: SEMGREP,
136
+ thresholds: { minLines: 10, minTokens: 70 },
137
+ gate: { baselinedFiles: 2, baselineLines: 42, violations: 0, fixed: 0 },
138
+ });
139
+
140
+ assert.deepEqual(validateFacet(doc(d), SCHEMA), []);
141
+ const api = d.scopes.api;
142
+ assert.equal(api.files, 2);
143
+ assert.equal(api.lines, 100);
144
+ assert.equal(api.duplicatedLines, 42);
145
+ assert.equal(api.duplicationRate, 0.42);
146
+ assert.equal(api.codeLines, 98);
147
+ assert.equal(api.commentLines, 2);
148
+ assert.equal(api.commentDensity, 0.0204);
149
+ assert.equal(api.swallowedExceptions, 0);
150
+ assert.equal(api.justifiedSwallows, 1);
151
+ assert.equal(api.deadCodeWarnings, 0);
152
+ assert.deepEqual(Object.keys(api.modules), ['Audit']);
153
+ assert.equal(d.scopes.web.swallowedExceptions, 1);
154
+ assert.equal(d.scopes.web.modules['components/assets'].duplicationRate, 0);
155
+ assert.equal(d.global.files, 3);
156
+ assert.equal(d.global.duplicationRate, 0.35);
157
+ assert.deepEqual(d.deadCode, { gated: true, warnings: 0 });
158
+ assert.deepEqual(d.hotspots.map((h) => [h.file, h.duplicatedLines, h.duplicationRate]), [
159
+ [ENDPOINTS, 30, 0.5],
160
+ [EXPORT, 12, 0.3],
161
+ ]);
162
+ });
163
+
164
+ test('hygieneFacetData_noSemgrepReport_recordsNullSwallowCountsNotZero', () => {
165
+ const d = hygieneFacetData({ files: FILES, duplicated: {}, semgrep: null, thresholds: { minLines: 10, minTokens: 70 } });
166
+
167
+ assert.equal(d.scopes.api.swallowedExceptions, null);
168
+ assert.equal(d.global.swallowedExceptions, null);
169
+ assert.equal(d.global.justifiedSwallows, 1, 'the justification count comes from the source, not the scan');
170
+ assert.equal(d.gate, null);
171
+ assert.deepEqual(validateFacet(doc(d), SCHEMA), []);
172
+ });
173
+
174
+ test('validateFacet_hygieneRateOutOfRange_isReported', () => {
175
+ const d = hygieneFacetData({ files: FILES, duplicated: {}, semgrep: null, thresholds: { minLines: 10, minTokens: 70 } });
176
+ d.global.duplicationRate = 1.5;
177
+
178
+ assert.deepEqual(validateFacet(doc(d), SCHEMA), ['$.data.global.duplicationRate: 1.5 > 1']);
179
+ });
@@ -0,0 +1,65 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/quality-config.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ // The app's quality bar from quality.config.json at the repository root: the gate scripts are shared
3
+ // by the platform family, the numbers they enforce are not. Schema: docs/family-templates.md in agentic-workflows.
4
+
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ export const CONFIG_FILE = fileURLToPath(new URL('../../quality.config.json', import.meta.url));
9
+
10
+ const KEYS = { root: ['$schema', 'coverage', 'mutation'], coverage: ['api', 'reportOnly', 'web'] };
11
+
12
+ const isRate = (value) => typeof value === 'number' && value >= 0 && value <= 1;
13
+
14
+ function rates(map, where, problems) {
15
+ if (map === undefined) return {};
16
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) {
17
+ problems.push(`${where} must be an object of name -> rate`);
18
+ return {};
19
+ }
20
+ for (const [name, value] of Object.entries(map)) {
21
+ if (!isRate(value)) problems.push(`${where}.${name} must be a number between 0 and 1, got ${JSON.stringify(value)}`);
22
+ }
23
+ return map;
24
+ }
25
+
26
+ function unknownKeys(object, allowed, where, problems) {
27
+ for (const key of Object.keys(object ?? {})) {
28
+ if (!allowed.includes(key)) problems.push(`${where}${key} is not a known setting (expected one of: ${allowed.join(', ')})`);
29
+ }
30
+ }
31
+
32
+ export function parseQualityConfig(text, file = CONFIG_FILE) {
33
+ let raw;
34
+ try {
35
+ raw = JSON.parse(text);
36
+ } catch (err) {
37
+ throw new Error(`${file} is not valid JSON: ${err.message}`);
38
+ }
39
+ const problems = [];
40
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${file} must hold a JSON object`);
41
+ unknownKeys(raw, KEYS.root, '', problems);
42
+ const coverage = raw.coverage ?? {};
43
+ unknownKeys(coverage, KEYS.coverage, 'coverage.', problems);
44
+ const api = rates(coverage.api, 'coverage.api', problems);
45
+ const reportOnly = coverage.reportOnly ?? [];
46
+ if (!Array.isArray(reportOnly) || reportOnly.some((name) => typeof name !== 'string')) {
47
+ problems.push('coverage.reportOnly must be an array of assembly names');
48
+ }
49
+ if (coverage.web !== undefined && !isRate(coverage.web)) {
50
+ problems.push(`coverage.web must be a number between 0 and 1, got ${JSON.stringify(coverage.web)}`);
51
+ }
52
+ const mutation = rates(raw.mutation, 'mutation', problems);
53
+ if (problems.length > 0) throw new Error(`${file}:\n ${problems.join('\n ')}`);
54
+ return { coverage: { api, reportOnly, web: coverage.web ?? null }, mutation };
55
+ }
56
+
57
+ export function loadQualityConfig(file = CONFIG_FILE) {
58
+ if (!existsSync(file)) {
59
+ throw new Error(
60
+ `${file} not found. The quality gates read the app's floors from it; see "quality.config.json" in ` +
61
+ 'https://github.com/PlainConceptsPlatform/agentic-workflows/blob/main/docs/family-templates.md',
62
+ );
63
+ }
64
+ return parseQualityConfig(readFileSync(file, 'utf8'), file);
65
+ }
@@ -0,0 +1,29 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/quality-config.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ // Run: node --test scripts/lib/quality-config.test.mjs
3
+ import assert from 'node:assert/strict';
4
+ import { test } from 'node:test';
5
+ import { parseQualityConfig } from './quality-config.mjs';
6
+
7
+ test('parseQualityConfig_fullConfig_returnsFloors', () => {
8
+ const config = parseQualityConfig(JSON.stringify({
9
+ coverage: { api: { 'App.Domain': 0.94 }, reportOnly: ['App.Api'], web: 0.64 },
10
+ mutation: { domain: 0.82, web: 0.79 },
11
+ }));
12
+ assert.deepEqual(config, {
13
+ coverage: { api: { 'App.Domain': 0.94 }, reportOnly: ['App.Api'], web: 0.64 },
14
+ mutation: { domain: 0.82, web: 0.79 },
15
+ });
16
+ });
17
+
18
+ test('parseQualityConfig_missingSections_defaultsToNothingGated', () => {
19
+ assert.deepEqual(parseQualityConfig('{}'), { coverage: { api: {}, reportOnly: [], web: null }, mutation: {} });
20
+ });
21
+
22
+ test('parseQualityConfig_percentInsteadOfRate_listsEveryProblem', () => {
23
+ assert.throws(
24
+ () => parseQualityConfig(JSON.stringify({ coverage: { api: { 'App.Domain': 94 }, web: '64' }, mutaton: {} }), 'quality.config.json'),
25
+ (err) => /coverage\.api\.App\.Domain must be a number between 0 and 1/.test(err.message)
26
+ && /coverage\.web must be a number between 0 and 1/.test(err.message)
27
+ && /mutaton is not a known setting/.test(err.message),
28
+ );
29
+ });
@@ -0,0 +1,26 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/source-files.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ // Vendored from PlainConceptsPlatform/Atlas scripts/lib/source-files.mjs (Atlas#1942).
3
+ // The source files in .jscpd.json's scope (roots, formats, ignores), read from disk, so
4
+ // the hygiene facet and the duplication gate measure one file set.
5
+
6
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { join, relative } from 'node:path';
8
+ import { scannedBy } from './engineering-excellence.mjs';
9
+
10
+ // `root` is the repository root; returns [{ file, text }] with repo-relative paths, sorted.
11
+ export function scopedSources(cfg, root) {
12
+ const scanned = scannedBy(cfg);
13
+ const out = [];
14
+ const walk = (dir) => {
15
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
16
+ const full = join(dir, entry.name);
17
+ if (entry.isDirectory()) walk(full);
18
+ else {
19
+ const file = relative(root, full);
20
+ if (scanned(file)) out.push({ file, text: readFileSync(full, 'utf8') });
21
+ }
22
+ }
23
+ };
24
+ for (const p of cfg.path) if (existsSync(join(root, p))) walk(join(root, p));
25
+ return out.sort((a, b) => a.file.localeCompare(b.file));
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "build": "tsc --project tsconfig.build.json && node ./scripts/copy-loops.mjs",
30
- "test": "vitest run",
30
+ "test": "vitest run --dir src",
31
31
  "typecheck": "tsc --noEmit --project tsconfig.json",
32
32
  "release": "pnpm build && pnpm test && npm publish --access public",
33
33
  "prepack": "node ./scripts/copy-loops.mjs"