@plainconceptsplatform/workflows 1.10.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog-installation.js +5 -0
  3. package/dist/catalog-listing.js +6 -1
  4. package/dist/index.js +27 -4
  5. package/dist/template-bundles.d.ts +21 -0
  6. package/dist/template-bundles.js +108 -0
  7. package/dist/workflow-catalog.d.ts +5 -1
  8. package/dist/workflow-catalog.js +8 -0
  9. package/loops/templates/ci/app-ci-dotnet-next.yml +778 -117
  10. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +202 -0
  11. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +453 -0
  12. package/loops/templates/quality-gates/.jscpd.json +36 -0
  13. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  14. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  15. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  16. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  17. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  18. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  19. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  20. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  21. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  22. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  23. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  24. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  25. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  26. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  27. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  28. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  29. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  30. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  31. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  32. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  33. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  34. package/package.json +2 -2
@@ -0,0 +1,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
+ }