@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.
Files changed (37) 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/actions/report-workflow-errors/action.yml +63 -16
  10. package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
  11. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -0
  12. package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
  13. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
  14. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
  15. package/loops/templates/quality-gates/.jscpd.json +36 -0
  16. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  17. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  18. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  19. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  20. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  21. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  22. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  23. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  24. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  25. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  26. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  27. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  28. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  29. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  30. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  31. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  32. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  33. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  34. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  35. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  36. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  37. package/package.json +2 -2
@@ -0,0 +1,171 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/platform-template/.github/workflows/platform-template-sync.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: "Platform: Template sync"
3
+
4
+ # Brings the platform code of a family app up to the newest Kitchen-Sink template tag. Plain YAML,
5
+ # no model: copier does the merge, and a person or the merge gate takes it from the pull request.
6
+ #
7
+ # Does nothing without .copier-answers.yml, the marker a repository gets when it adopts the
8
+ # template. With it, `copier update` runs against the newest `v*` tag of the template named in
9
+ # `_src_path`. No change means the app is current and the run ends there. Otherwise the result is
10
+ # committed to `platform/sync-<tag>` and opened as a pull request titled with the tag. A file the
11
+ # app had edited gets copier's inline conflict markers; scripts/check-platform-conformance.mjs
12
+ # fails CI on them, so the merge gate repairs the pull request (`remediated`) like any red one.
13
+ #
14
+ # Kitchen-Sink is private, so the Platform App token (BOT_APP_ID / BOT_PRIVATE_KEY) reads it. The
15
+ # same token pushes the branch and opens the pull request, because a push made with GITHUB_TOKEN
16
+ # starts no workflow and CI would never run on the sync.
17
+ on:
18
+ schedule:
19
+ - cron: "17 5 * * 1"
20
+ workflow_dispatch:
21
+
22
+ permissions:
23
+ contents: read
24
+
25
+ concurrency:
26
+ group: platform-template-sync
27
+ cancel-in-progress: false
28
+
29
+ env:
30
+ # copier 9.19.1 needs Python 3.10 or newer on the runner.
31
+ COPIER_VERSION: "9.19.1"
32
+
33
+ jobs:
34
+ sync:
35
+ name: Sync the platform template
36
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
37
+ timeout-minutes: 20
38
+ steps:
39
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
+ with:
41
+ fetch-depth: 0
42
+ persist-credentials: false
43
+
44
+ - name: Has this repository adopted the template?
45
+ id: marker
46
+ run: |
47
+ if [ -f .copier-answers.yml ]; then
48
+ echo "adopted=true" >> "$GITHUB_OUTPUT"
49
+ else
50
+ echo "adopted=false" >> "$GITHUB_OUTPUT"
51
+ echo "No .copier-answers.yml: this repository has not adopted the platform template. Nothing to do."
52
+ fi
53
+
54
+ - name: Generate App token
55
+ id: app-token
56
+ if: steps.marker.outputs.adopted == 'true'
57
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
58
+ with:
59
+ app-id: ${{ secrets.BOT_APP_ID }}
60
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
61
+ owner: ${{ github.repository_owner }}
62
+
63
+ # uvx where uv is installed, then pipx, then a virtual environment from the runner's Python.
64
+ - name: Install copier
65
+ if: steps.marker.outputs.adopted == 'true'
66
+ run: |
67
+ set -euo pipefail
68
+ bin="$RUNNER_TEMP/copier-bin"
69
+ mkdir -p "$bin"
70
+ if command -v uvx >/dev/null 2>&1; then
71
+ run="uvx --from copier==$COPIER_VERSION copier"
72
+ elif command -v pipx >/dev/null 2>&1; then
73
+ run="pipx run --spec copier==$COPIER_VERSION copier"
74
+ else
75
+ python3 -m venv "$RUNNER_TEMP/copier-venv"
76
+ "$RUNNER_TEMP/copier-venv/bin/pip" install --quiet "copier==$COPIER_VERSION"
77
+ run="$RUNNER_TEMP/copier-venv/bin/copier"
78
+ fi
79
+ cat > "$bin/copier" <<EOF
80
+ #!/usr/bin/env bash
81
+ exec $run "\$@"
82
+ EOF
83
+ chmod +x "$bin/copier"
84
+ echo "$bin" >> "$GITHUB_PATH"
85
+ "$bin/copier" --version
86
+
87
+ # The token reaches git through process-scoped config only, never a file on the runner.
88
+ - name: Update from the template
89
+ id: update
90
+ if: steps.marker.outputs.adopted == 'true'
91
+ env:
92
+ GIT_CONFIG_COUNT: "1"
93
+ GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/.insteadOf
94
+ GIT_CONFIG_VALUE_0: https://github.com/
95
+ GIT_AUTHOR_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
96
+ GIT_AUTHOR_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
97
+ GIT_COMMITTER_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
98
+ GIT_COMMITTER_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
99
+ run: |
100
+ set -euo pipefail
101
+ commit_of() { sed -n 's/^_commit:[[:space:]]*//p' .copier-answers.yml | tr -d "\"'" | head -n 1; }
102
+ before=$(commit_of)
103
+ copier update --trust --defaults --conflict inline
104
+ after=$(commit_of)
105
+ if [ -z "$(git status --porcelain)" ]; then
106
+ echo "Already on the newest template ($before). Nothing to do."
107
+ echo "changed=false" >> "$GITHUB_OUTPUT"
108
+ exit 0
109
+ fi
110
+ git add --all
111
+ conflicts=$(git grep --cached -l -E '^(<<<<<<<|>>>>>>>)( |$)' -- . || true)
112
+ {
113
+ echo "changed=true"
114
+ echo "before=$before"
115
+ echo "tag=$after"
116
+ echo "files<<EOF"
117
+ git diff --cached --name-status
118
+ echo "EOF"
119
+ echo "conflicts<<EOF"
120
+ [ -z "$conflicts" ] || printf '%s\n' "$conflicts"
121
+ echo "EOF"
122
+ } >> "$GITHUB_OUTPUT"
123
+
124
+ - name: Open the sync pull request
125
+ if: steps.update.outputs.changed == 'true'
126
+ env:
127
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
128
+ REPO: ${{ github.repository }}
129
+ BASE: ${{ github.event.repository.default_branch }}
130
+ BEFORE: ${{ steps.update.outputs.before }}
131
+ TAG: ${{ steps.update.outputs.tag }}
132
+ FILES: ${{ steps.update.outputs.files }}
133
+ CONFLICTS: ${{ steps.update.outputs.conflicts }}
134
+ GIT_CONFIG_COUNT: "1"
135
+ GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/.insteadOf
136
+ GIT_CONFIG_VALUE_0: https://github.com/
137
+ GIT_AUTHOR_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
138
+ GIT_AUTHOR_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
139
+ GIT_COMMITTER_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
140
+ GIT_COMMITTER_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
141
+ run: |
142
+ set -euo pipefail
143
+ branch="platform/sync-$TAG"
144
+ if [ -n "$(gh pr list --repo "$REPO" --head "$branch" --state open --json number --jq '.[].number')" ]; then
145
+ echo "A pull request for $TAG is already open on $branch. Nothing to do."
146
+ exit 0
147
+ fi
148
+ src=$(sed -n 's/^_src_path:[[:space:]]*//p' .copier-answers.yml | tr -d "\"'" | head -n 1)
149
+ web="${src#git+}"
150
+ web="${web%.git}"
151
+ git switch --create "$branch"
152
+ git commit --quiet --message "chore(platform): sync the platform template to $TAG"
153
+ git push --force "https://github.com/$REPO.git" "HEAD:refs/heads/$branch"
154
+ body="$RUNNER_TEMP/sync-body.md"
155
+ {
156
+ echo "Platform template sync from \`$BEFORE\` to [\`$TAG\`]($web/releases/tag/$TAG), made by \`copier update\`."
157
+ echo
158
+ if [ -n "$CONFLICTS" ]; then
159
+ echo "### Conflicts"
160
+ echo
161
+ echo "These files carry inline conflict markers where the app had edited platform code. CI fails on them until they are resolved."
162
+ echo
163
+ printf '%s\n' "$CONFLICTS" | awk '{ printf "- `%s`\n", $0 }'
164
+ echo
165
+ fi
166
+ echo "### Changed files"
167
+ echo
168
+ printf '%s\n' "$FILES" | awk -F '\t' '{ printf "- `%s` %s\n", $NF, $1 }'
169
+ } > "$body"
170
+ gh pr create --repo "$REPO" --base "$BASE" --head "$branch" \
171
+ --title "chore(platform): sync the platform template to $TAG" --body-file "$body"
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/platform-template/scripts/check-platform-conformance.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ // The .platform/manifest paths change only in a template sync (a pull request that moves `_commit` in
4
+ // .copier-answers.yml) or under an unexpired .platform/exceptions.yml entry. Conflict markers in a
5
+ // manifest file and expired exceptions fail too. Offline; without .copier-answers.yml it passes silently.
6
+ //
7
+ // Usage: node scripts/check-platform-conformance.mjs [--base <git ref>] (no --base: markers and exceptions only)
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const ANSWERS = '.copier-answers.yml';
15
+ const MANIFEST = '.platform/manifest';
16
+ const EXCEPTIONS = '.platform/exceptions.yml';
17
+ const CONFLICT_MARKER = /^(?:<{7}|>{7})(?: |$)/m;
18
+
19
+ // gitwildmatch, the .gitignore syntax: a pattern with no inner slash matches at any depth, `**`
20
+ // crosses directories, and a match on a directory covers everything below it.
21
+ export function globToRegExp(glob) {
22
+ let pattern = glob.endsWith('/') ? `${glob}**` : glob;
23
+ const anchored = pattern.replace(/\/\*\*$/, '').includes('/');
24
+ pattern = pattern.replace(/^\//, '');
25
+ let re = '';
26
+ for (let i = 0; i < pattern.length; i++) {
27
+ const c = pattern[i];
28
+ if (c === '*' && pattern[i + 1] === '*') {
29
+ const slash = pattern[i + 2] === '/';
30
+ re += slash ? '(?:.*/)?' : '.*';
31
+ i += slash ? 2 : 1;
32
+ } else if (c === '*') re += '[^/]*';
33
+ else if (c === '?') re += '[^/]';
34
+ else if (c === '[') {
35
+ const end = pattern.indexOf(']', i + 1);
36
+ if (end === -1) re += '\\[';
37
+ else {
38
+ re += `[${pattern.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`;
39
+ i = end;
40
+ }
41
+ } else re += c.replace(/[.+^${}()|\\]/g, '\\$&');
42
+ }
43
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the glob comes from the committed manifest and every metacharacter outside a class is escaped
44
+ return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${re}(?:/.*)?$`);
45
+ }
46
+
47
+ export function parseManifest(text) {
48
+ return text
49
+ .split(/\r?\n/)
50
+ .map((line) => line.trim())
51
+ .filter((line) => line !== '' && !line.startsWith('#'))
52
+ .map((line) => {
53
+ const negate = line.startsWith('!');
54
+ const glob = negate ? line.slice(1) : line;
55
+ return { glob, negate, regex: globToRegExp(glob) };
56
+ });
57
+ }
58
+
59
+ // The last pattern that matches decides, as in .gitignore.
60
+ export function inManifest(patterns, path) {
61
+ let matched = false;
62
+ for (const { negate, regex } of patterns) if (regex.test(path)) matched = !negate;
63
+ return matched;
64
+ }
65
+
66
+ const unquote = (value) => {
67
+ const v = value.trim();
68
+ const quoted = v.match(/^"(.*)"$|^'(.*)'$/);
69
+ if (quoted) return quoted[1] ?? quoted[2];
70
+ return v.replace(/\s+#.*$/, '');
71
+ };
72
+
73
+ // The subset of YAML .platform/exceptions.yml uses: one `exceptions:` list of flat mappings.
74
+ export function parseExceptions(text) {
75
+ const entries = [];
76
+ const problems = [];
77
+ let inList = false;
78
+ text.split(/\r?\n/).forEach((raw, index) => {
79
+ const line = raw.replace(/\s+$/, '');
80
+ if (line.trim() === '' || line.trim().startsWith('#')) return;
81
+ const at = `${EXCEPTIONS}:${index + 1}`;
82
+ const top = line.match(/^exceptions:\s*(\[\s*\])?\s*(?:#.*)?$/);
83
+ if (top) {
84
+ inList = !top[1];
85
+ return;
86
+ }
87
+ const item = line.match(/^\s*-\s+([A-Za-z_]+):(.*)$/);
88
+ const field = line.match(/^\s+([A-Za-z_]+):(.*)$/);
89
+ if (inList && item) entries.push({ line: index + 1, [item[1]]: unquote(item[2]) });
90
+ else if (inList && field && entries.length > 0) entries.at(-1)[field[1]] = unquote(field[2]);
91
+ else problems.push(`${at}: cannot read "${line.trim()}"; expected exceptions: [{ path, reason, issue, expires }]`);
92
+ });
93
+ for (const entry of entries) {
94
+ const at = `${EXCEPTIONS}:${entry.line}`;
95
+ const missing = ['path', 'reason', 'issue', 'expires'].filter((key) => !entry[key]);
96
+ if (missing.length > 0) problems.push(`${at}: exception without ${missing.join(', ')}`);
97
+ else if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
98
+ problems.push(`${at}: expires must be a date (YYYY-MM-DD), got "${entry.expires}"`);
99
+ }
100
+ }
101
+ const valid = entries.filter((entry) => entry.path && entry.reason && entry.issue && !Number.isNaN(Date.parse(entry.expires)));
102
+ return { exceptions: valid.map((entry) => ({ ...entry, regex: globToRegExp(entry.path) })), problems };
103
+ }
104
+
105
+ const templateCommit = (answers) => answers?.match(/^_commit:\s*(.+?)\s*$/m)?.[1]?.replace(/^["']|["']$/g, '');
106
+
107
+ function git(root, args) {
108
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
109
+ }
110
+
111
+ function gitShow(root, ref, path) {
112
+ try {
113
+ return git(root, ['show', `${ref}:${path}`]);
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ export function checkConformance(root, { base, today = new Date().toISOString().slice(0, 10) } = {}) {
120
+ const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
121
+ const answers = read(ANSWERS);
122
+ if (answers === undefined) return { adopted: false, failures: [], notes: [] };
123
+
124
+ const failures = [];
125
+ const notes = [];
126
+ const manifestText = read(MANIFEST);
127
+ if (manifestText === undefined) return { adopted: true, failures: [`${ANSWERS} exists but ${MANIFEST} does not`], notes };
128
+ const patterns = parseManifest(manifestText);
129
+
130
+ const { exceptions, problems } = parseExceptions(read(EXCEPTIONS) ?? '');
131
+ failures.push(...problems);
132
+ const live = exceptions.filter((entry) => entry.expires >= today);
133
+ for (const entry of exceptions.filter((e) => e.expires < today)) {
134
+ failures.push(`${EXCEPTIONS}:${entry.line}: the exception for ${entry.path} expired on ${entry.expires} (${entry.issue}); converge the code or renew it`);
135
+ }
136
+
137
+ const tracked = git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
138
+ for (const file of tracked.filter((path) => inManifest(patterns, path))) {
139
+ const text = read(file);
140
+ if (text !== undefined && !text.includes('\0') && CONFLICT_MARKER.test(text)) {
141
+ failures.push(`${file}: unresolved template-sync conflict markers`);
142
+ }
143
+ }
144
+
145
+ if (base !== undefined) {
146
+ const changed = git(root, ['diff', '--name-only', '--no-renames', `${base}...HEAD`]).split('\n').filter(Boolean);
147
+ const platform = changed.filter((path) => inManifest(patterns, path));
148
+ const before = templateCommit(gitShow(root, base, ANSWERS));
149
+ const after = templateCommit(answers);
150
+ if (before !== after) {
151
+ notes.push(`template sync ${before ?? '(adoption)'} -> ${after}: ${platform.length} platform file(s) may change`);
152
+ } else {
153
+ for (const path of platform) {
154
+ const covered = live.find((entry) => entry.regex.test(path));
155
+ if (covered) notes.push(`${path}: allowed by the exception until ${covered.expires} (${covered.issue})`);
156
+ else failures.push(`${path}: platform file changed outside a template sync; change it in Kitchen-Sink, or add an exception to ${EXCEPTIONS}`);
157
+ }
158
+ }
159
+ }
160
+ return { adopted: true, failures, notes };
161
+ }
162
+
163
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
164
+ const args = process.argv.slice(2);
165
+ const baseIndex = args.indexOf('--base');
166
+ const base = baseIndex === -1 ? undefined : args[baseIndex + 1];
167
+ if (baseIndex !== -1 && !base) {
168
+ console.error('usage: node scripts/check-platform-conformance.mjs [--base <git ref>]');
169
+ process.exit(2);
170
+ }
171
+ const root = fileURLToPath(new URL('..', import.meta.url));
172
+ const { adopted, failures, notes } = checkConformance(root, { base });
173
+ if (!adopted) process.exit(0);
174
+ for (const note of notes) console.log(`· ${note}`);
175
+ for (const failure of failures) console.error(`✗ ${failure}`);
176
+ if (failures.length === 0) console.log('✓ platform files match the template');
177
+ process.exit(failures.length === 0 ? 0 : 1);
178
+ }
@@ -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');