@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,36 @@
1
+ {
2
+ "path": [
3
+ "apps/api/src",
4
+ "apps/web/src"
5
+ ],
6
+ "format": [
7
+ "csharp",
8
+ "typescript",
9
+ "tsx"
10
+ ],
11
+ "minLines": 10,
12
+ "minTokens": 70,
13
+ "maxLines": 100000,
14
+ "maxSize": "5mb",
15
+ "mode": "mild",
16
+ "reporters": [
17
+ "json"
18
+ ],
19
+ "output": "jscpd-report",
20
+ "absolute": true,
21
+ "gitignore": false,
22
+ "silent": true,
23
+ "noTips": true,
24
+ "ignore": [
25
+ "**/obj/**",
26
+ "**/bin/**",
27
+ "**/node_modules/**",
28
+ "**/*.g.cs",
29
+ "**/*.designer.cs",
30
+ "**/*.Designer.cs",
31
+ "**/Migrations/**",
32
+ "**/*.test.ts",
33
+ "**/*.test.tsx",
34
+ "**/__tests__/**"
35
+ ]
36
+ }
@@ -0,0 +1,7 @@
1
+ # Ownership: .jscpd.json
2
+
3
+ Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/.jscpd.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 `.jscpd.json` is documented here. The file is the duplication scope every app of the platform family shares: the jscpd report, the duplication gate (`scripts/check-duplication.mjs`) and the hygiene facet all read it, so they measure one file set.
6
+
7
+ An app that needs a different scope takes the file over by removing the header paragraph above; `workflows update` then leaves both files alone. Its per-file backlog stays in the app's own `scripts/duplication-baseline.json`, which the package never installs.
@@ -0,0 +1,88 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/.semgrep/platform-hygiene.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # Hygiene rules (practice 11, code hygiene) shared by the platform family, run by the blocking
3
+ # Semgrep scan in .github/workflows/app-ci.yml via `--config .semgrep/`. Their findings also feed
4
+ # the `engineering-excellence-hygiene` facet (swallowed-exception counts). Shape from
5
+ # PlainConceptsPlatform/Atlas .semgrep/atlas-hygiene.yml, rule ids neutral so every app shares them.
6
+ #
7
+ # A `catch` with an empty body turns a failure into silence. These rules flag an
8
+ # empty body whatever it catches: a bare `catch`, `catch (Exception)`, or a narrow
9
+ # type. A comment inside the braces is not handling and does not exempt it.
10
+ #
11
+ # One shape is exempt by design: `catch (OperationCanceledException) when
12
+ # (<token>.IsCancellationRequested) { }`, the hosted-service shutdown idiom. It
13
+ # swallows exactly the cancellation its own caller requested, and nothing else; an
14
+ # unfiltered cancellation catch also hides an HttpClient timeout, so it is flagged.
15
+ #
16
+ # Where swallowing is genuinely correct, say why where a reviewer reads it, on the
17
+ # line the finding starts (the `try`) or the line above it:
18
+ #
19
+ # // nosemgrep: platform-hygiene-csharp-empty-catch — <why swallowing is correct here>
20
+ #
21
+ # The facet counts these justifications, so the backlog of swallowed exceptions stays
22
+ # visible even while the gate is green. Test code is out of scope: a test that
23
+ # expects a throw has its own idioms.
24
+ # See Foundations ai/ENGINEERING.md.
25
+ rules:
26
+ - id: platform-hygiene-csharp-empty-catch
27
+ languages: [csharp]
28
+ severity: ERROR
29
+ message: >-
30
+ Empty catch block: the exception is swallowed without logging, rethrowing or a
31
+ fallback. Handle it, or justify it on the `try` line with
32
+ `// nosemgrep: platform-hygiene-csharp-empty-catch — <reason>`.
33
+ metadata:
34
+ category: hygiene
35
+ paths:
36
+ # Product code only: .opencode/plugins and the managed .github actions belong to the
37
+ # agent-harness and agentic-workflows packages.
38
+ include:
39
+ - "/apps/"
40
+ exclude:
41
+ - "**/tests/**"
42
+ - "**/obj/**"
43
+ - "**/Migrations/**"
44
+ pattern-either:
45
+ # Catch-all: never exempt, even next to a filtered cancellation catch.
46
+ - pattern: try { ... } catch { }
47
+ - pattern: try { ... } catch { } finally { ... }
48
+ - pattern: try { ... } catch (Exception) { }
49
+ - pattern: try { ... } catch (Exception $E) { }
50
+ - pattern: try { ... } catch (System.Exception) { }
51
+ - pattern: try { ... } catch (System.Exception $E) { }
52
+ - pattern: try { ... } catch (Exception) { } finally { ... }
53
+ - pattern: try { ... } catch (Exception $E) { } finally { ... }
54
+ - patterns:
55
+ - pattern-either:
56
+ - pattern: try { ... } catch ($T) { }
57
+ - pattern: try { ... } catch ($T $E) { }
58
+ - pattern: try { ... } catch ($T) { } finally { ... }
59
+ - pattern: try { ... } catch ($T $E) { } finally { ... }
60
+ # Semgrep's C# grammar drops the `when` filter, so the exemption is textual.
61
+ - pattern-not-regex: catch\s*\(\s*OperationCanceledException(\s+\w+)?\s*\)\s*when\s*\(\s*[\w.]*IsCancellationRequested\s*\)\s*\{[^}]*\}
62
+
63
+ - id: platform-hygiene-ts-empty-catch
64
+ languages: [typescript, javascript]
65
+ severity: ERROR
66
+ message: >-
67
+ Empty catch block: the error is swallowed without logging, rethrowing or a
68
+ fallback. Handle it, or justify it on the `try` line with
69
+ `// nosemgrep: platform-hygiene-ts-empty-catch — <reason>`.
70
+ metadata:
71
+ category: hygiene
72
+ paths:
73
+ include:
74
+ - "/apps/"
75
+ exclude:
76
+ - "**/*.test.ts"
77
+ - "**/*.test.tsx"
78
+ - "**/*.test.mjs"
79
+ - "**/*.spec.ts"
80
+ - "**/__tests__/**"
81
+ - "**/e2e/**"
82
+ - "**/node_modules/**"
83
+ - "**/dist/**"
84
+ pattern-either:
85
+ - pattern: try { ... } catch { }
86
+ - pattern: try { ... } catch ($E) { }
87
+ - pattern: try { ... } catch { } finally { ... }
88
+ - pattern: try { ... } catch ($E) { } finally { ... }
@@ -0,0 +1,26 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/.zap/rules.tsv. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # ZAP baseline triage for .github/workflows/security-dast.yml.
3
+ #
4
+ # Format, tab-separated: <rule-id> <WARN|IGNORE|FAIL> <justification>
5
+ #
6
+ # Each entry is a reviewed decision about one rule, and the justification column is mandatory.
7
+ # An IGNORE that a fix would remove names the issue tracking that fix. Never add an IGNORE just
8
+ # to turn the job green: fix the finding, or record here why the rule does not apply to the app.
9
+ # An app that needs a triage line of its own takes this file over: remove the header line above.
10
+ #
11
+ # Shared by the platform family. 10020, 10021, 10035, 10038, 10063, 90004 and 10015 are fixed at
12
+ # source by the platform security headers (Api/Security/SecurityHeaders.cs), and 90003 by
13
+ # self-hosting Geist Mono, so none of them appears here. See Foundations ai/ENGINEERING.md.
14
+ #
15
+ # OUTOFSCOPE lines take a URL regex as the third column instead of a justification, so the
16
+ # justification sits in the comment above each one.
17
+ #
18
+ # An anonymous / answers only with a 302 to the Entra sign-in. ZAP follows it and files
19
+ # login.microsoftonline.com's page, headers and cookies (esctx, fpc, x-ms-gateway-slice) under
20
+ # the app's /, so the header and cookie rules fire on a page the app does not serve. The app's own
21
+ # headers on that redirect come from the same middleware as /login, which stays in scope.
22
+ * OUTOFSCOPE ^https://[^/]+/$
23
+ 10049 IGNORE Non-Storable / Storable Content: informational, describes caching semantics, not a defect. The shell and API are no-store on purpose; hashed _next/static assets are immutable on purpose.
24
+ 10109 IGNORE Modern Web Application: informational, ZAP noting the target is a JavaScript SPA, which is why the scan uses the AJAX spider.
25
+ 10112 IGNORE Session Management Response Identified: informational, ZAP recognising the App Service ARRAffinity cookie as a session token. Not a finding.
26
+ 10055 IGNORE CSP analysis: style-src carries 'unsafe-inline' because Radix UI and React inline style attributes need it; script-src does not use unsafe-inline: the build emits per-script SHA-256 hashes (scripts/extract-csp-hashes.mjs). The rest of the policy is pinned by SecurityHeadersTests.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/check-complexity.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 { CC_MAX, CRAP_MAX, buildBaseline, evaluate, pruneBaseline } from './lib/complexity-gate.mjs';
7
+ import { apiMethods, scoreMethods, webMethods } from './lib/engineering-excellence.mjs';
8
+
9
+ const DEFAULT_BASELINE = fileURLToPath(new URL('./complexity-baseline.json', import.meta.url));
10
+ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
11
+
12
+ const args = process.argv.slice(2);
13
+ const opt = (name) => {
14
+ const i = args.indexOf(`--${name}`);
15
+ return i === -1 ? undefined : args[i + 1];
16
+ };
17
+ const flag = (name) => args.includes(`--${name}`);
18
+
19
+ const api = opt('api');
20
+ const webFinal = opt('web-final');
21
+ const eslint = opt('eslint');
22
+ if (!api || !webFinal || !eslint) {
23
+ console.error('usage: node scripts/check-complexity.mjs --api <Cobertura.xml> --web-final <coverage-final.json> --eslint <eslint.json> [--baseline <file>] [--prune | --write-baseline]');
24
+ process.exit(2);
25
+ }
26
+ const baselineFile = opt('baseline') ?? DEFAULT_BASELINE;
27
+
28
+ const methods = scoreMethods([
29
+ ...apiMethods(readFileSync(api, 'utf8')),
30
+ ...webMethods(JSON.parse(readFileSync(eslint, 'utf8')), JSON.parse(readFileSync(webFinal, 'utf8'))),
31
+ ]);
32
+
33
+ const writeJson = (file, obj) => writeFileSync(file, `${JSON.stringify(obj, null, 2)}\n`);
34
+
35
+ if (flag('write-baseline')) {
36
+ const b = buildBaseline(methods);
37
+ writeJson(baselineFile, b);
38
+ console.log(`· wrote ${Object.keys(b).length} baseline entries 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, methods);
46
+ writeJson(baselineFile, pruned);
47
+ console.log(`· baseline ${Object.keys(baseline).length} -> ${Object.keys(pruned).length} entries`);
48
+ process.exit(0);
49
+ }
50
+
51
+ const sourceCache = new Map();
52
+ const readSource = (file) => {
53
+ if (!sourceCache.has(file)) {
54
+ const path = `${REPO_ROOT}${file}`;
55
+ sourceCache.set(file, existsSync(path) ? readFileSync(path, 'utf8').split('\n') : null);
56
+ }
57
+ return sourceCache.get(file);
58
+ };
59
+
60
+ const { violations, justified, baselined, fixed } = evaluate(methods, baseline, readSource);
61
+ const pct = (r) => `${(r * 100).toFixed(0)}%`;
62
+ const WHY = {
63
+ new: 'not in the baseline',
64
+ 'cc-increased': 'complexity rose above its baseline',
65
+ 'crap-increased': 'CRAP rose above its baseline (coverage dropped or complexity rose)',
66
+ };
67
+
68
+ console.log(`### Complexity gate (CC ≤ ${CC_MAX}, CRAP ≤ ${CRAP_MAX})`);
69
+ console.log('');
70
+ console.log(`${methods.length} methods · ${baselined} baselined backlog · ${justified.length} justified · ${violations.length} violations`);
71
+ if (justified.length) {
72
+ console.log('', 'Justified (`complexity-justified:`):', '');
73
+ for (const j of justified) console.log(`- \`${j.file}:${j.line}\` ${j.method} — CC ${j.cc}, CRAP ${j.crap} — ${j.reason} (#${j.issue})`);
74
+ }
75
+ if (fixed.length) {
76
+ console.log('', `${fixed.length} baseline entries no longer violate — run \`node scripts/check-complexity.mjs ... --prune\` to ratchet the baseline down.`);
77
+ }
78
+ if (violations.length) {
79
+ console.log('', 'Violations — simplify the method, or test it until CRAP ≤ 30:', '');
80
+ for (const v of violations.sort((a, b) => b.crap - a.crap)) {
81
+ const base = v.baseline ? ` (baseline CC ${v.baseline.cc}, CRAP ${v.baseline.crap})` : '';
82
+ console.log(`- ✗ \`${v.file}:${v.line}\` ${v.method} — CC ${v.cc}, coverage ${pct(v.coverage)}, CRAP ${v.crap}: ${WHY[v.why]}${base}`);
83
+ }
84
+ process.exit(1);
85
+ }
86
+ console.log('', '✓ no method above the thresholds outside the baseline');
@@ -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