@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.
- 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/actions/report-workflow-errors/action.yml +63 -16
- package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -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,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');
|