@plainconceptsplatform/workflows 1.10.1 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/catalog-installation.js +5 -0
- package/dist/catalog-listing.js +6 -1
- package/dist/index.js +27 -4
- package/dist/template-bundles.d.ts +21 -0
- package/dist/template-bundles.js +108 -0
- package/dist/workflow-catalog.d.ts +5 -1
- package/dist/workflow-catalog.js +8 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +778 -117
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +202 -0
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +453 -0
- package/loops/templates/quality-gates/.jscpd.json +36 -0
- package/loops/templates/quality-gates/.jscpd.json.md +7 -0
- package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
- package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
- package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
- package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
- package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
- package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
- package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
- package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
- package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
# Pulls the newest version of the upstream template named in .platform/contract.yml (see
|
|
5
|
+
# docs/platform-contracts.md in agentic-workflows). Plain YAML, no model. Without a contract it does
|
|
6
|
+
# nothing. With one it runs `sync.update` and opens or updates one pull request from
|
|
7
|
+
# `platform/template-sync`. copier is installed when the command starts with `copier`; any other
|
|
8
|
+
# command is the repository's to provide. Conflict markers left in an owned file fail the
|
|
9
|
+
# conformance guard, so the merge gate repairs the pull request like any red one.
|
|
10
|
+
#
|
|
11
|
+
# The Platform App token (BOT_APP_ID / BOT_PRIVATE_KEY) reads a private template and pushes, because
|
|
12
|
+
# a push made with GITHUB_TOKEN starts no workflow and CI would never run on the sync.
|
|
13
|
+
on:
|
|
14
|
+
schedule:
|
|
15
|
+
- cron: "17 5 * * 1"
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
|
|
21
|
+
concurrency:
|
|
22
|
+
group: platform-template-sync
|
|
23
|
+
cancel-in-progress: false
|
|
24
|
+
|
|
25
|
+
env:
|
|
26
|
+
# copier 9.19.1 needs Python 3.10 or newer on the runner.
|
|
27
|
+
COPIER_VERSION: "9.19.1"
|
|
28
|
+
NODE_VERSION: "22"
|
|
29
|
+
SYNC_BRANCH: platform/template-sync
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
sync:
|
|
33
|
+
name: Sync the platform template
|
|
34
|
+
runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
|
|
35
|
+
timeout-minutes: 20
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
|
+
with:
|
|
39
|
+
fetch-depth: 0
|
|
40
|
+
persist-credentials: false
|
|
41
|
+
|
|
42
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
43
|
+
with:
|
|
44
|
+
node-version: ${{ env.NODE_VERSION }}
|
|
45
|
+
|
|
46
|
+
# An invalid contract fails here, with the file and the field in the error.
|
|
47
|
+
- name: Read the platform contract
|
|
48
|
+
id: contract
|
|
49
|
+
run: |
|
|
50
|
+
set -euo pipefail
|
|
51
|
+
node scripts/check-platform-conformance.mjs --contract | tee -a "$GITHUB_OUTPUT"
|
|
52
|
+
|
|
53
|
+
- name: Decide whether to sync
|
|
54
|
+
id: decide
|
|
55
|
+
env:
|
|
56
|
+
CONTRACT: ${{ steps.contract.outputs.contract }}
|
|
57
|
+
SELF: ${{ steps.contract.outputs.self }}
|
|
58
|
+
UPDATE: ${{ steps.contract.outputs.update }}
|
|
59
|
+
run: |
|
|
60
|
+
set -euo pipefail
|
|
61
|
+
run=false
|
|
62
|
+
if [ "$CONTRACT" != present ]; then
|
|
63
|
+
echo "No .platform/contract.yml (nor .copier-answers.yml with .platform/manifest): nothing to sync."
|
|
64
|
+
elif [ "$SELF" = true ]; then
|
|
65
|
+
echo "This repository is the template source.url names: nothing to sync."
|
|
66
|
+
elif [ -z "$UPDATE" ]; then
|
|
67
|
+
echo "The platform contract declares no sync.update: nothing to sync."
|
|
68
|
+
else
|
|
69
|
+
run=true
|
|
70
|
+
fi
|
|
71
|
+
echo "run=$run" >> "$GITHUB_OUTPUT"
|
|
72
|
+
|
|
73
|
+
- name: Generate App token
|
|
74
|
+
id: app-token
|
|
75
|
+
if: steps.decide.outputs.run == 'true'
|
|
76
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
77
|
+
with:
|
|
78
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
79
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
80
|
+
owner: ${{ github.repository_owner }}
|
|
81
|
+
|
|
82
|
+
# uvx where uv is installed, then pipx, then a virtual environment from the runner's Python.
|
|
83
|
+
- name: Install copier
|
|
84
|
+
if: steps.decide.outputs.run == 'true' && startsWith(steps.contract.outputs.update, 'copier ')
|
|
85
|
+
run: |
|
|
86
|
+
set -euo pipefail
|
|
87
|
+
bin="$RUNNER_TEMP/copier-bin"
|
|
88
|
+
mkdir -p "$bin"
|
|
89
|
+
if command -v uvx >/dev/null 2>&1; then
|
|
90
|
+
run="uvx --from copier==$COPIER_VERSION copier"
|
|
91
|
+
elif command -v pipx >/dev/null 2>&1; then
|
|
92
|
+
run="pipx run --spec copier==$COPIER_VERSION copier"
|
|
93
|
+
else
|
|
94
|
+
python3 -m venv "$RUNNER_TEMP/copier-venv"
|
|
95
|
+
"$RUNNER_TEMP/copier-venv/bin/pip" install --quiet "copier==$COPIER_VERSION"
|
|
96
|
+
run="$RUNNER_TEMP/copier-venv/bin/copier"
|
|
97
|
+
fi
|
|
98
|
+
cat > "$bin/copier" <<EOF
|
|
99
|
+
#!/usr/bin/env bash
|
|
100
|
+
exec $run "\$@"
|
|
101
|
+
EOF
|
|
102
|
+
chmod +x "$bin/copier"
|
|
103
|
+
echo "$bin" >> "$GITHUB_PATH"
|
|
104
|
+
"$bin/copier" --version
|
|
105
|
+
|
|
106
|
+
# The token reaches git through process-scoped config only, never a file on the runner.
|
|
107
|
+
- name: Update from the template
|
|
108
|
+
id: update
|
|
109
|
+
if: steps.decide.outputs.run == 'true'
|
|
110
|
+
env:
|
|
111
|
+
UPDATE: ${{ steps.contract.outputs.update }}
|
|
112
|
+
BEFORE: ${{ steps.contract.outputs.version }}
|
|
113
|
+
GIT_CONFIG_COUNT: "1"
|
|
114
|
+
GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/.insteadOf
|
|
115
|
+
GIT_CONFIG_VALUE_0: https://github.com/
|
|
116
|
+
GIT_AUTHOR_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
|
|
117
|
+
GIT_AUTHOR_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
|
|
118
|
+
GIT_COMMITTER_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
|
|
119
|
+
GIT_COMMITTER_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
|
|
120
|
+
run: |
|
|
121
|
+
set -euo pipefail
|
|
122
|
+
echo "sync.update: $UPDATE"
|
|
123
|
+
bash -euo pipefail -c "$UPDATE"
|
|
124
|
+
if [ -z "$(git status --porcelain)" ]; then
|
|
125
|
+
echo "Already on the newest template (${BEFORE:-no version recorded}). Nothing to do."
|
|
126
|
+
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
127
|
+
exit 0
|
|
128
|
+
fi
|
|
129
|
+
after=$(node scripts/check-platform-conformance.mjs --contract | sed -n 's/^version=//p')
|
|
130
|
+
git add --all
|
|
131
|
+
conflicts=$(git grep --cached -l -E '^(<<<<<<<|>>>>>>>)( |$)' -- . || true)
|
|
132
|
+
{
|
|
133
|
+
echo "changed=true"
|
|
134
|
+
echo "tag=${after:-$(git rev-parse --short HEAD)}"
|
|
135
|
+
echo "files<<EOF"
|
|
136
|
+
git diff --cached --name-status
|
|
137
|
+
echo "EOF"
|
|
138
|
+
echo "conflicts<<EOF"
|
|
139
|
+
[ -z "$conflicts" ] || printf '%s\n' "$conflicts"
|
|
140
|
+
echo "EOF"
|
|
141
|
+
} >> "$GITHUB_OUTPUT"
|
|
142
|
+
|
|
143
|
+
# One sync pull request at a time. An open one for the same version is left alone, so the
|
|
144
|
+
# conflict resolutions pushed to it survive; a newer version replaces its branch and its text.
|
|
145
|
+
- name: Open or update the sync pull request
|
|
146
|
+
if: steps.update.outputs.changed == 'true'
|
|
147
|
+
env:
|
|
148
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
149
|
+
REPO: ${{ github.repository }}
|
|
150
|
+
BASE: ${{ github.event.repository.default_branch }}
|
|
151
|
+
SOURCE_NAME: ${{ steps.contract.outputs.source_name }}
|
|
152
|
+
SOURCE_WEB: ${{ steps.contract.outputs.source_web }}
|
|
153
|
+
BEFORE: ${{ steps.contract.outputs.version }}
|
|
154
|
+
TAG: ${{ steps.update.outputs.tag }}
|
|
155
|
+
UPDATE: ${{ steps.contract.outputs.update }}
|
|
156
|
+
FILES: ${{ steps.update.outputs.files }}
|
|
157
|
+
CONFLICTS: ${{ steps.update.outputs.conflicts }}
|
|
158
|
+
GIT_CONFIG_COUNT: "1"
|
|
159
|
+
GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/.insteadOf
|
|
160
|
+
GIT_CONFIG_VALUE_0: https://github.com/
|
|
161
|
+
GIT_AUTHOR_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
|
|
162
|
+
GIT_AUTHOR_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
|
|
163
|
+
GIT_COMMITTER_NAME: ${{ steps.app-token.outputs.app-slug }}[bot]
|
|
164
|
+
GIT_COMMITTER_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
|
|
165
|
+
run: |
|
|
166
|
+
set -euo pipefail
|
|
167
|
+
marker="<!-- platform-template-sync: $TAG -->"
|
|
168
|
+
open=$(gh pr list --repo "$REPO" --head "$SYNC_BRANCH" --state open --json number --jq '.[0].number // empty')
|
|
169
|
+
if [ -n "$open" ] && gh pr view "$open" --repo "$REPO" --json body --jq .body | grep -qF -- "$marker"; then
|
|
170
|
+
echo "Pull request #$open already syncs $TAG. Nothing to do."
|
|
171
|
+
exit 0
|
|
172
|
+
fi
|
|
173
|
+
title="chore(platform): sync $SOURCE_NAME to $TAG"
|
|
174
|
+
git switch --create "$SYNC_BRANCH"
|
|
175
|
+
git commit --quiet --message "$title"
|
|
176
|
+
git push --force "https://github.com/$REPO.git" "HEAD:refs/heads/$SYNC_BRANCH"
|
|
177
|
+
case "$SOURCE_WEB" in
|
|
178
|
+
https://github.com/*) target="[\`$TAG\`]($SOURCE_WEB/releases/tag/$TAG)" ;;
|
|
179
|
+
*) target="\`$TAG\`" ;;
|
|
180
|
+
esac
|
|
181
|
+
body="$RUNNER_TEMP/sync-body.md"
|
|
182
|
+
{
|
|
183
|
+
echo "$marker"
|
|
184
|
+
echo "Sync of the code owned by $SOURCE_NAME from \`${BEFORE:-no recorded version}\` to $target, made by \`$UPDATE\`."
|
|
185
|
+
echo
|
|
186
|
+
if [ -n "$CONFLICTS" ]; then
|
|
187
|
+
echo "### Conflicts"
|
|
188
|
+
echo
|
|
189
|
+
echo "These files carry inline conflict markers where this repository had edited owned code. CI fails on them until they are resolved."
|
|
190
|
+
echo
|
|
191
|
+
printf '%s\n' "$CONFLICTS" | awk '{ printf "- `%s`\n", $0 }'
|
|
192
|
+
echo
|
|
193
|
+
fi
|
|
194
|
+
echo "### Changed files"
|
|
195
|
+
echo
|
|
196
|
+
printf '%s\n' "$FILES" | awk -F '\t' '{ printf "- `%s` %s\n", $NF, $1 }'
|
|
197
|
+
} > "$body"
|
|
198
|
+
if [ -n "$open" ]; then
|
|
199
|
+
gh pr edit "$open" --repo "$REPO" --title "$title" --body-file "$body"
|
|
200
|
+
else
|
|
201
|
+
gh pr create --repo "$REPO" --base "$BASE" --head "$SYNC_BRANCH" --title "$title" --body-file "$body"
|
|
202
|
+
fi
|
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
// Keeps the paths a platform contract (.platform/contract.yml) marks as `owned` equal to the template
|
|
4
|
+
// they come from. A pull request may change them only in a template sync (it changes `sync.marker` or
|
|
5
|
+
// moves `sync.version`) or under an unexpired exception; conflict markers and expired exceptions fail.
|
|
6
|
+
// With --compare, owned files are also compared with `source.url` at the recorded version.
|
|
7
|
+
// Without a contract it passes silently. .copier-answers.yml + .platform/manifest is read as one.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node scripts/check-platform-conformance.mjs [--base <git ref>] [--compare]
|
|
10
|
+
// node scripts/check-platform-conformance.mjs --contract (key=value lines for $GITHUB_OUTPUT)
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { basename, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
|
+
|
|
18
|
+
export const CONTRACT = '.platform/contract.yml';
|
|
19
|
+
const LEGACY_ANSWERS = '.copier-answers.yml';
|
|
20
|
+
const LEGACY_MANIFEST = '.platform/manifest';
|
|
21
|
+
const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml';
|
|
22
|
+
const CONFLICT_MARKER = /^(?:<{7}|>{7})(?: |$)/m;
|
|
23
|
+
|
|
24
|
+
export class ContractError extends Error {}
|
|
25
|
+
|
|
26
|
+
// gitwildmatch, the .gitignore syntax: a pattern with no inner slash matches at any depth, `**`
|
|
27
|
+
// crosses directories, and a match on a directory covers everything below it.
|
|
28
|
+
export function globToRegExp(glob) {
|
|
29
|
+
let pattern = glob.endsWith('/') ? `${glob}**` : glob;
|
|
30
|
+
const anchored = pattern.replace(/\/\*\*$/, '').includes('/');
|
|
31
|
+
pattern = pattern.replace(/^\//, '');
|
|
32
|
+
let re = '';
|
|
33
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
34
|
+
const c = pattern[i];
|
|
35
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
36
|
+
const slash = pattern[i + 2] === '/';
|
|
37
|
+
re += slash ? '(?:.*/)?' : '.*';
|
|
38
|
+
i += slash ? 2 : 1;
|
|
39
|
+
} else if (c === '*') re += '[^/]*';
|
|
40
|
+
else if (c === '?') re += '[^/]';
|
|
41
|
+
else if (c === '[') {
|
|
42
|
+
const end = pattern.indexOf(']', i + 1);
|
|
43
|
+
if (end === -1) re += '\\[';
|
|
44
|
+
else {
|
|
45
|
+
re += `[${pattern.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`;
|
|
46
|
+
i = end;
|
|
47
|
+
}
|
|
48
|
+
} else re += c.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the glob comes from the committed contract and every metacharacter outside a class is escaped
|
|
51
|
+
return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${re}(?:/.*)?$`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parsePatterns(globs) {
|
|
55
|
+
return globs
|
|
56
|
+
.map((line) => line.trim())
|
|
57
|
+
.filter((line) => line !== '' && !line.startsWith('#'))
|
|
58
|
+
.map((line) => {
|
|
59
|
+
const negate = line.startsWith('!');
|
|
60
|
+
const glob = negate ? line.slice(1) : line;
|
|
61
|
+
return { glob, negate, regex: globToRegExp(glob) };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const parseManifest = (text) => parsePatterns(text.split(/\r?\n/));
|
|
66
|
+
|
|
67
|
+
// The last pattern that matches decides, as in .gitignore.
|
|
68
|
+
export function isOwned(patterns, path) {
|
|
69
|
+
let matched = false;
|
|
70
|
+
for (const { negate, regex } of patterns) if (regex.test(path)) matched = !negate;
|
|
71
|
+
return matched;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const unquote = (value) => {
|
|
75
|
+
const v = value.trim();
|
|
76
|
+
const quoted = v.match(/^"(.*)"$|^'(.*)'$/);
|
|
77
|
+
if (quoted) return quoted[1] ?? quoted[2];
|
|
78
|
+
return v.replace(/\s+#.*$/, '');
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function stripComment(line) {
|
|
82
|
+
let quote = '';
|
|
83
|
+
for (let i = 0; i < line.length; i++) {
|
|
84
|
+
const c = line[i];
|
|
85
|
+
if (quote) {
|
|
86
|
+
if (c === quote) quote = '';
|
|
87
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
88
|
+
else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
|
|
89
|
+
}
|
|
90
|
+
return line;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scalar(raw, at) {
|
|
94
|
+
const text = raw.trim();
|
|
95
|
+
if (/^[|>][+-]?$/.test(text) || /^[&*!{]/.test(text)) throw new ContractError(`${at}: "${text}" is not supported; write the value on one line`);
|
|
96
|
+
if (text.startsWith('[')) {
|
|
97
|
+
if (!text.endsWith(']')) throw new ContractError(`${at}: unclosed "["`);
|
|
98
|
+
const inner = text.slice(1, -1).trim();
|
|
99
|
+
return inner === '' ? [] : (inner.match(/"[^"]*"|'[^']*'|[^,]+/g) ?? []).map((item) => scalar(item, at));
|
|
100
|
+
}
|
|
101
|
+
if (/^["']/.test(text)) return unquote(text);
|
|
102
|
+
if (/^-?\d+$/.test(text)) return Number(text);
|
|
103
|
+
if (text === 'true' || text === 'false') return text === 'true';
|
|
104
|
+
if (text === '' || text === '~' || text === 'null') return null;
|
|
105
|
+
return text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The YAML a contract needs: nested mappings, block and flow lists of scalars, quotes and comments.
|
|
109
|
+
export function parseYamlSubset(text, file) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
text.split(/\r?\n/).forEach((raw, index) => {
|
|
112
|
+
const content = stripComment(raw).replace(/\s+$/, '');
|
|
113
|
+
if (content.trim() === '' || /^---\s*$/.test(content)) return;
|
|
114
|
+
if (/^\s*\t/.test(raw)) throw new ContractError(`${file}:${index + 1}: indent with spaces, not tabs`);
|
|
115
|
+
lines.push({ indent: content.length - content.trimStart().length, text: content.trim(), at: `${file}:${index + 1}` });
|
|
116
|
+
});
|
|
117
|
+
let next = 0;
|
|
118
|
+
const isItem = (line) => line.text === '-' || line.text.startsWith('- ');
|
|
119
|
+
const block = (indent) => {
|
|
120
|
+
if (isItem(lines[next])) {
|
|
121
|
+
const list = [];
|
|
122
|
+
while (next < lines.length && lines[next].indent === indent && isItem(lines[next])) {
|
|
123
|
+
const { text, at } = lines[next++];
|
|
124
|
+
if (text === '-') throw new ContractError(`${at}: a list item must be a value on the same line`);
|
|
125
|
+
list.push(scalar(text.slice(2), at));
|
|
126
|
+
}
|
|
127
|
+
return list;
|
|
128
|
+
}
|
|
129
|
+
const map = {};
|
|
130
|
+
while (next < lines.length && lines[next].indent === indent && !isItem(lines[next])) {
|
|
131
|
+
const { text, at } = lines[next++];
|
|
132
|
+
const pair = text.match(/^([A-Za-z_][\w-]*):(?:\s+(.*))?$/);
|
|
133
|
+
if (!pair) throw new ContractError(`${at}: cannot read "${text}"; expected "key: value"`);
|
|
134
|
+
const following = lines[next];
|
|
135
|
+
const nested = following && (following.indent > indent || (following.indent === indent && isItem(following)));
|
|
136
|
+
if (pair[2] !== undefined) map[pair[1]] = scalar(pair[2], at);
|
|
137
|
+
else map[pair[1]] = nested ? block(following.indent) : null;
|
|
138
|
+
}
|
|
139
|
+
return map;
|
|
140
|
+
};
|
|
141
|
+
if (lines.length === 0) return {};
|
|
142
|
+
if (lines[0].indent !== 0 || isItem(lines[0])) throw new ContractError(`${lines[0].at}: the file must be a mapping of top-level keys`);
|
|
143
|
+
const root = block(0);
|
|
144
|
+
if (next < lines.length) throw new ContractError(`${lines[next].at}: unexpected indentation`);
|
|
145
|
+
return root;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isText = (value) => typeof value === 'string' && value.trim() !== '';
|
|
149
|
+
const isMapping = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
150
|
+
const repositoryName = (url) => basename(url.replace(/\.git$/, '').replace(/\/+$/, '')) || url;
|
|
151
|
+
|
|
152
|
+
export function validateContract(data, file = CONTRACT) {
|
|
153
|
+
const fail = (field, problem) => {
|
|
154
|
+
throw new ContractError(`${file}: ${field} ${problem}`);
|
|
155
|
+
};
|
|
156
|
+
if (!isMapping(data)) fail('the file', 'must be a YAML mapping');
|
|
157
|
+
if (data.version === undefined || data.version === null) fail('version', 'is required (version: 1)');
|
|
158
|
+
if (data.version !== 1) fail('version', `${JSON.stringify(data.version)} is not supported; this guard reads version 1`);
|
|
159
|
+
if (!isMapping(data.source)) fail('source', 'is required: a mapping with at least url');
|
|
160
|
+
if (!isText(data.source.url)) fail('source.url', 'is required: where the shared code lives');
|
|
161
|
+
for (const key of ['name', 'changes']) {
|
|
162
|
+
if (data.source[key] != null && !isText(data.source[key])) fail(`source.${key}`, 'must be text');
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(data.owned) || data.owned.length === 0) fail('owned', 'is required: a list of repository-relative globs');
|
|
165
|
+
data.owned.forEach((glob, index) => isText(glob) || fail(`owned[${index}]`, 'must be a glob'));
|
|
166
|
+
if (data.exceptions != null && !isText(data.exceptions)) fail('exceptions', 'must be a repository-relative path');
|
|
167
|
+
const sync = data.sync ?? {};
|
|
168
|
+
if (!isMapping(sync)) fail('sync', 'must be a mapping');
|
|
169
|
+
for (const key of ['marker', 'update']) if (sync[key] != null && !isText(sync[key])) fail(`sync.${key}`, 'must be text');
|
|
170
|
+
if (sync.version != null) {
|
|
171
|
+
if (!isMapping(sync.version)) fail('sync.version', 'must be a mapping with file and key');
|
|
172
|
+
for (const key of ['file', 'key']) if (!isText(sync.version[key])) fail(`sync.version.${key}`, 'is required');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
version: 1,
|
|
176
|
+
source: { name: data.source.name ?? repositoryName(data.source.url), url: data.source.url, changes: data.source.changes ?? data.source.url },
|
|
177
|
+
owned: data.owned,
|
|
178
|
+
exceptions: data.exceptions ?? DEFAULT_EXCEPTIONS,
|
|
179
|
+
sync: { marker: sync.marker ?? undefined, version: sync.version ?? undefined, update: sync.update ?? undefined },
|
|
180
|
+
file,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// What harness 3.7.0 and workflows 1.11.0 read before the contract existed.
|
|
185
|
+
export function legacyContract(answers, manifest) {
|
|
186
|
+
const source = topLevelValue(answers, '_src_path');
|
|
187
|
+
if (!source) throw new ContractError(`${LEGACY_ANSWERS}: _src_path is required to read it as a platform contract`);
|
|
188
|
+
const url = source.replace(/^git\+/, '').replace(/^gh:/, 'https://github.com/').replace(/^gl:/, 'https://gitlab.com/');
|
|
189
|
+
return {
|
|
190
|
+
version: 1,
|
|
191
|
+
source: { name: repositoryName(url), url, changes: url },
|
|
192
|
+
owned: manifest.split(/\r?\n/),
|
|
193
|
+
exceptions: DEFAULT_EXCEPTIONS,
|
|
194
|
+
sync: {
|
|
195
|
+
marker: LEGACY_ANSWERS,
|
|
196
|
+
version: { file: LEGACY_ANSWERS, key: '_commit' },
|
|
197
|
+
update: 'copier update --trust --defaults --conflict inline',
|
|
198
|
+
},
|
|
199
|
+
file: `${LEGACY_ANSWERS} + ${LEGACY_MANIFEST}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function topLevelValue(text, key) {
|
|
204
|
+
if (text === undefined) return undefined;
|
|
205
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
206
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the key is escaped
|
|
207
|
+
const value = text.match(new RegExp(`^${escaped}:[ \\t]*(.*?)[ \\t]*$`, 'm'))?.[1];
|
|
208
|
+
return value === undefined ? undefined : unquote(stripComment(value)) || undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// undefined when the repository declares no contract, explicit or legacy.
|
|
212
|
+
export function readContract(root) {
|
|
213
|
+
const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
|
|
214
|
+
const text = read(CONTRACT);
|
|
215
|
+
if (text !== undefined) return validateContract(parseYamlSubset(text, CONTRACT));
|
|
216
|
+
const answers = read(LEGACY_ANSWERS);
|
|
217
|
+
const manifest = read(LEGACY_MANIFEST);
|
|
218
|
+
return answers !== undefined && manifest !== undefined ? legacyContract(answers, manifest) : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const recordedVersion = (contract, readFile) =>
|
|
222
|
+
contract.sync.version ? topLevelValue(readFile(contract.sync.version.file), contract.sync.version.key) : undefined;
|
|
223
|
+
|
|
224
|
+
export function normalizeUrl(url) {
|
|
225
|
+
return url
|
|
226
|
+
.trim()
|
|
227
|
+
.replace(/^git\+/, '')
|
|
228
|
+
.replace(/^gh:/, 'https://github.com/')
|
|
229
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
230
|
+
.replace(/^(https?:\/\/)[^@/]+@/, '$1')
|
|
231
|
+
.replace(/\.git$/, '')
|
|
232
|
+
.replace(/\/+$/, '')
|
|
233
|
+
.toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The template repository carries the contract too; its owned paths are its own.
|
|
237
|
+
function isSourceRepository(root, contract) {
|
|
238
|
+
try {
|
|
239
|
+
return normalizeUrl(git(root, ['remote', 'get-url', 'origin'])) === normalizeUrl(contract.source.url);
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The subset of YAML the exceptions file uses: one `exceptions:` list of flat mappings.
|
|
246
|
+
export function parseExceptions(text, file = DEFAULT_EXCEPTIONS) {
|
|
247
|
+
const entries = [];
|
|
248
|
+
const problems = [];
|
|
249
|
+
let inList = false;
|
|
250
|
+
text.split(/\r?\n/).forEach((raw, index) => {
|
|
251
|
+
const line = raw.replace(/\s+$/, '');
|
|
252
|
+
if (line.trim() === '' || line.trim().startsWith('#')) return;
|
|
253
|
+
const at = `${file}:${index + 1}`;
|
|
254
|
+
const top = line.match(/^exceptions:\s*(\[\s*\])?\s*(?:#.*)?$/);
|
|
255
|
+
if (top) {
|
|
256
|
+
inList = !top[1];
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const item = line.match(/^\s*-\s+([A-Za-z_]+):(.*)$/);
|
|
260
|
+
const field = line.match(/^\s+([A-Za-z_]+):(.*)$/);
|
|
261
|
+
if (inList && item) entries.push({ line: index + 1, [item[1]]: unquote(item[2]) });
|
|
262
|
+
else if (inList && field && entries.length > 0) entries.at(-1)[field[1]] = unquote(field[2]);
|
|
263
|
+
else problems.push(`${at}: cannot read "${line.trim()}"; expected exceptions: [{ path, reason, issue, expires }]`);
|
|
264
|
+
});
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
const at = `${file}:${entry.line}`;
|
|
267
|
+
const missing = ['path', 'reason', 'issue', 'expires'].filter((key) => !entry[key]);
|
|
268
|
+
if (missing.length > 0) problems.push(`${at}: exception without ${missing.join(', ')}`);
|
|
269
|
+
else if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
|
|
270
|
+
problems.push(`${at}: expires must be a date (YYYY-MM-DD), got "${entry.expires}"`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const valid = entries.filter((entry) => entry.path && entry.reason && entry.issue && !Number.isNaN(Date.parse(entry.expires)));
|
|
274
|
+
return { exceptions: valid.map((entry) => ({ ...entry, regex: globToRegExp(entry.path) })), problems };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function git(root, args) {
|
|
278
|
+
return execFileSync('git', args, {
|
|
279
|
+
cwd: root,
|
|
280
|
+
encoding: 'utf8',
|
|
281
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
282
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
283
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function gitShow(root, ref, path) {
|
|
288
|
+
try {
|
|
289
|
+
return git(root, ['show', `${ref}:${path}`]);
|
|
290
|
+
} catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// `git ls-tree -r` and `git ls-files -s` lines, as path -> blob id; submodules are skipped.
|
|
296
|
+
function blobIds(listing, idField) {
|
|
297
|
+
const ids = new Map();
|
|
298
|
+
for (const entry of listing.split('\0').filter(Boolean)) {
|
|
299
|
+
const [meta, path] = entry.split('\t');
|
|
300
|
+
const fields = meta.split(' ');
|
|
301
|
+
if (fields[0] !== '160000') ids.set(path, fields[idField]);
|
|
302
|
+
}
|
|
303
|
+
return ids;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Blob ids of the source's files at the version: a tag or commit, or `git describe` output.
|
|
307
|
+
function sourceBlobs(url, version) {
|
|
308
|
+
const scratch = mkdtempSync(join(tmpdir(), 'platform-source-'));
|
|
309
|
+
try {
|
|
310
|
+
git(scratch, ['init', '--quiet', '--bare']);
|
|
311
|
+
let ref = 'FETCH_HEAD';
|
|
312
|
+
try {
|
|
313
|
+
git(scratch, ['fetch', '--quiet', '--depth', '1', url, version]);
|
|
314
|
+
} catch {
|
|
315
|
+
git(scratch, ['fetch', '--quiet', url, '+refs/tags/*:refs/tags/*', '+refs/heads/*:refs/heads/*']);
|
|
316
|
+
ref = version.match(/-\d+-g([0-9a-f]{4,})$/)?.[1] ?? version;
|
|
317
|
+
}
|
|
318
|
+
return blobIds(git(scratch, ['ls-tree', '-r', '-z', `${ref}^{commit}`]), 2);
|
|
319
|
+
} finally {
|
|
320
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function compareWithSource(root, contract, patterns, live, version) {
|
|
325
|
+
const failures = [];
|
|
326
|
+
const notes = [];
|
|
327
|
+
const { name, url, changes } = contract.source;
|
|
328
|
+
if (!version) return { failures: [`cannot compare with ${name}: ${contract.sync.version?.file ?? 'the contract'} records no template version (sync.version)`], notes };
|
|
329
|
+
let source;
|
|
330
|
+
try {
|
|
331
|
+
source = sourceBlobs(url, version);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const reason = String(error.stderr ?? error.message).trim().split('\n').at(-1);
|
|
334
|
+
return { failures, notes: [`cannot read ${url} at ${version} (${reason}); owned files were not compared with the source`] };
|
|
335
|
+
}
|
|
336
|
+
const local = blobIds(git(root, ['ls-files', '-s', '-z']), 1);
|
|
337
|
+
for (const [path, id] of source) {
|
|
338
|
+
if (!isOwned(patterns, path) || local.get(path) === id) continue;
|
|
339
|
+
const problem = local.has(path) ? `differs from ${name} at ${version}` : `is missing; ${name} at ${version} has it`;
|
|
340
|
+
const covered = live.find((entry) => entry.regex.test(path));
|
|
341
|
+
if (covered) notes.push(`${path}: ${problem}, allowed by the exception until ${covered.expires} (${covered.issue})`);
|
|
342
|
+
else failures.push(`${path}: ${problem}; change it in ${name} (${changes}) or add an exception to ${contract.exceptions}`);
|
|
343
|
+
}
|
|
344
|
+
const onlyHere = [...local.keys()].filter((path) => isOwned(patterns, path) && !source.has(path));
|
|
345
|
+
if (onlyHere.length > 0) notes.push(`${onlyHere.length} owned file(s) are not in ${name} at ${version} under the same path; not compared`);
|
|
346
|
+
return { failures, notes };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function checkConformance(root, { base, compare = false, today = new Date().toISOString().slice(0, 10) } = {}) {
|
|
350
|
+
const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
|
|
351
|
+
let contract;
|
|
352
|
+
try {
|
|
353
|
+
contract = readContract(root);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error instanceof ContractError) return { adopted: true, failures: [error.message], notes: [] };
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
if (contract === undefined) return { adopted: false, failures: [], notes: [] };
|
|
359
|
+
if (isSourceRepository(root, contract)) {
|
|
360
|
+
return { adopted: true, failures: [], notes: [`this repository is ${contract.source.name}, the source of its own owned paths`] };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const failures = [];
|
|
364
|
+
const notes = [];
|
|
365
|
+
const patterns = parsePatterns(contract.owned);
|
|
366
|
+
const { exceptions, problems } = parseExceptions(read(contract.exceptions) ?? '', contract.exceptions);
|
|
367
|
+
failures.push(...problems);
|
|
368
|
+
const live = exceptions.filter((entry) => entry.expires >= today);
|
|
369
|
+
for (const entry of exceptions.filter((e) => e.expires < today)) {
|
|
370
|
+
failures.push(`${contract.exceptions}:${entry.line}: the exception for ${entry.path} expired on ${entry.expires} (${entry.issue}); converge the code or renew it`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const tracked = git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
|
|
374
|
+
for (const file of tracked.filter((path) => isOwned(patterns, path))) {
|
|
375
|
+
const text = read(file);
|
|
376
|
+
if (text !== undefined && !text.includes('\0') && CONFLICT_MARKER.test(text)) {
|
|
377
|
+
failures.push(`${file}: unresolved template-sync conflict markers`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const version = recordedVersion(contract, read);
|
|
382
|
+
if (base !== undefined) {
|
|
383
|
+
const changed = git(root, ['diff', '--name-only', '--no-renames', `${base}...HEAD`]).split('\n').filter(Boolean);
|
|
384
|
+
const owned = changed.filter((path) => isOwned(patterns, path));
|
|
385
|
+
const before = contract.sync.version ? topLevelValue(gitShow(root, base, contract.sync.version.file), contract.sync.version.key) : undefined;
|
|
386
|
+
const moved = contract.sync.version !== undefined && before !== version;
|
|
387
|
+
if (moved || (contract.sync.marker && changed.includes(contract.sync.marker))) {
|
|
388
|
+
const step = moved ? `${before ?? '(adoption)'} -> ${version}` : `(${contract.sync.marker} changed)`;
|
|
389
|
+
notes.push(`template sync ${step}: ${owned.length} owned file(s) may change`);
|
|
390
|
+
} else {
|
|
391
|
+
for (const path of owned) {
|
|
392
|
+
const covered = live.find((entry) => entry.regex.test(path));
|
|
393
|
+
if (covered) notes.push(`${path}: allowed by the exception until ${covered.expires} (${covered.issue})`);
|
|
394
|
+
else failures.push(`${path}: owned by ${contract.source.name} and changed outside a template sync; change it in ${contract.source.name} (${contract.source.changes}), or add an exception to ${contract.exceptions}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (compare) {
|
|
399
|
+
const result = compareWithSource(root, contract, patterns, live, version);
|
|
400
|
+
failures.push(...result.failures);
|
|
401
|
+
notes.push(...result.notes);
|
|
402
|
+
}
|
|
403
|
+
return { adopted: true, failures, notes };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// One key=value line per field, for $GITHUB_OUTPUT; contract=absent when there is none.
|
|
407
|
+
export function contractOutputs(root) {
|
|
408
|
+
const contract = readContract(root);
|
|
409
|
+
if (contract === undefined) return 'contract=absent\n';
|
|
410
|
+
const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
|
|
411
|
+
const web = contract.source.url.replace(/^git\+/, '').replace(/\.git$/, '');
|
|
412
|
+
const fields = {
|
|
413
|
+
contract: 'present',
|
|
414
|
+
self: String(isSourceRepository(root, contract)),
|
|
415
|
+
source_name: contract.source.name,
|
|
416
|
+
source_url: contract.source.url,
|
|
417
|
+
source_web: /^https?:\/\//.test(web) ? web : '',
|
|
418
|
+
version: recordedVersion(contract, read) ?? '',
|
|
419
|
+
update: contract.sync.update ?? '',
|
|
420
|
+
};
|
|
421
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
422
|
+
if (/[\r\n]/.test(value)) throw new ContractError(`${contract.file}: ${key} must be a single line`);
|
|
423
|
+
}
|
|
424
|
+
return Object.entries(fields).map(([key, value]) => `${key}=${value}\n`).join('');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
428
|
+
const args = process.argv.slice(2);
|
|
429
|
+
const baseIndex = args.indexOf('--base');
|
|
430
|
+
const base = baseIndex === -1 ? undefined : args[baseIndex + 1];
|
|
431
|
+
const unknown = args.filter((arg, i) => i !== baseIndex + 1 || baseIndex === -1).filter((arg) => !['--base', '--compare', '--contract'].includes(arg));
|
|
432
|
+
if ((baseIndex !== -1 && !base) || unknown.length > 0) {
|
|
433
|
+
console.error('usage: node scripts/check-platform-conformance.mjs [--base <git ref>] [--compare] | --contract');
|
|
434
|
+
process.exit(2);
|
|
435
|
+
}
|
|
436
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
437
|
+
if (args.includes('--contract')) {
|
|
438
|
+
try {
|
|
439
|
+
process.stdout.write(contractOutputs(root));
|
|
440
|
+
process.exit(0);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (!(error instanceof ContractError)) throw error;
|
|
443
|
+
console.error(`✗ ${error.message}`);
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const { adopted, failures, notes } = checkConformance(root, { base, compare: args.includes('--compare') });
|
|
448
|
+
if (!adopted) process.exit(0);
|
|
449
|
+
for (const note of notes) console.log(`· ${note}`);
|
|
450
|
+
for (const failure of failures) console.error(`✗ ${failure}`);
|
|
451
|
+
if (failures.length === 0) console.log('✓ owned files match the template');
|
|
452
|
+
process.exit(failures.length === 0 ? 0 : 1);
|
|
453
|
+
}
|