@plainconceptsplatform/workflows 1.11.0 → 1.13.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/index.js +18 -5
- package/dist/template-bundles.js +18 -4
- package/dist/workflow-catalog.d.ts +3 -1
- package/dist/workflow-catalog.js +4 -2
- package/loops/actions/file-audit-issues/action.yml +193 -0
- package/loops/actions/file-audit-issues/audit-issues.cjs +245 -0
- package/loops/actions/issue-size.cjs +36 -0
- package/loops/actions/measure-issue-size/action.yml +47 -0
- package/loops/actions/merge-dependabot/action.yml +54 -0
- package/loops/actions/merge-dependabot/merge-dependabot.mjs +343 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +96 -0
- package/loops/docs/diagrams.md +20 -13
- package/loops/templates/ci/app-ci-dotnet-next.yml +15 -9
- package/loops/templates/ci/app-ci-node-monorepo.yml +5 -1
- package/loops/templates/dependabot-merge/.github/workflows/dependabot-merge.yml +78 -0
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +74 -43
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +317 -42
- package/loops/workflows/agent-audit.md +46 -29
- package/loops/workflows/agent-docs-audit.md +44 -18
- package/loops/workflows/agent-refine.md +17 -30
- package/loops/workflows/work-router.yml +19 -0
- package/package.json +1 -1
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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
|
-
//
|
|
4
|
-
// .
|
|
5
|
-
//
|
|
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.
|
|
6
8
|
//
|
|
7
|
-
// Usage: node scripts/check-platform-conformance.mjs [--base <git ref>]
|
|
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)
|
|
8
11
|
|
|
9
12
|
import { execFileSync } from 'node:child_process';
|
|
10
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
-
import {
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { basename, join } from 'node:path';
|
|
12
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
17
|
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
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';
|
|
17
22
|
const CONFLICT_MARKER = /^(?:<{7}|>{7})(?: |$)/m;
|
|
18
23
|
|
|
24
|
+
export class ContractError extends Error {}
|
|
25
|
+
|
|
19
26
|
// gitwildmatch, the .gitignore syntax: a pattern with no inner slash matches at any depth, `**`
|
|
20
27
|
// crosses directories, and a match on a directory covers everything below it.
|
|
21
28
|
export function globToRegExp(glob) {
|
|
@@ -40,13 +47,12 @@ export function globToRegExp(glob) {
|
|
|
40
47
|
}
|
|
41
48
|
} else re += c.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
42
49
|
}
|
|
43
|
-
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the glob comes from the committed
|
|
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
|
|
44
51
|
return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${re}(?:/.*)?$`);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
export function
|
|
48
|
-
return
|
|
49
|
-
.split(/\r?\n/)
|
|
54
|
+
export function parsePatterns(globs) {
|
|
55
|
+
return globs
|
|
50
56
|
.map((line) => line.trim())
|
|
51
57
|
.filter((line) => line !== '' && !line.startsWith('#'))
|
|
52
58
|
.map((line) => {
|
|
@@ -56,8 +62,10 @@ export function parseManifest(text) {
|
|
|
56
62
|
});
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
export const parseManifest = (text) => parsePatterns(text.split(/\r?\n/));
|
|
66
|
+
|
|
59
67
|
// The last pattern that matches decides, as in .gitignore.
|
|
60
|
-
export function
|
|
68
|
+
export function isOwned(patterns, path) {
|
|
61
69
|
let matched = false;
|
|
62
70
|
for (const { negate, regex } of patterns) if (regex.test(path)) matched = !negate;
|
|
63
71
|
return matched;
|
|
@@ -70,15 +78,179 @@ const unquote = (value) => {
|
|
|
70
78
|
return v.replace(/\s+#.*$/, '');
|
|
71
79
|
};
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
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) {
|
|
75
247
|
const entries = [];
|
|
76
248
|
const problems = [];
|
|
77
249
|
let inList = false;
|
|
78
250
|
text.split(/\r?\n/).forEach((raw, index) => {
|
|
79
251
|
const line = raw.replace(/\s+$/, '');
|
|
80
252
|
if (line.trim() === '' || line.trim().startsWith('#')) return;
|
|
81
|
-
const at = `${
|
|
253
|
+
const at = `${file}:${index + 1}`;
|
|
82
254
|
const top = line.match(/^exceptions:\s*(\[\s*\])?\s*(?:#.*)?$/);
|
|
83
255
|
if (top) {
|
|
84
256
|
inList = !top[1];
|
|
@@ -91,7 +263,7 @@ export function parseExceptions(text) {
|
|
|
91
263
|
else problems.push(`${at}: cannot read "${line.trim()}"; expected exceptions: [{ path, reason, issue, expires }]`);
|
|
92
264
|
});
|
|
93
265
|
for (const entry of entries) {
|
|
94
|
-
const at = `${
|
|
266
|
+
const at = `${file}:${entry.line}`;
|
|
95
267
|
const missing = ['path', 'reason', 'issue', 'expires'].filter((key) => !entry[key]);
|
|
96
268
|
if (missing.length > 0) problems.push(`${at}: exception without ${missing.join(', ')}`);
|
|
97
269
|
else if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
|
|
@@ -102,10 +274,14 @@ export function parseExceptions(text) {
|
|
|
102
274
|
return { exceptions: valid.map((entry) => ({ ...entry, regex: globToRegExp(entry.path) })), problems };
|
|
103
275
|
}
|
|
104
276
|
|
|
105
|
-
const templateCommit = (answers) => answers?.match(/^_commit:\s*(.+?)\s*$/m)?.[1]?.replace(/^["']|["']$/g, '');
|
|
106
|
-
|
|
107
277
|
function git(root, args) {
|
|
108
|
-
return execFileSync('git', 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
|
+
});
|
|
109
285
|
}
|
|
110
286
|
|
|
111
287
|
function gitShow(root, ref, path) {
|
|
@@ -116,63 +292,162 @@ function gitShow(root, ref, path) {
|
|
|
116
292
|
}
|
|
117
293
|
}
|
|
118
294
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
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
|
+
}
|
|
123
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) {
|
|
124
325
|
const failures = [];
|
|
125
326
|
const notes = [];
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
+
}
|
|
129
362
|
|
|
130
|
-
const
|
|
363
|
+
const failures = [];
|
|
364
|
+
const notes = [];
|
|
365
|
+
const patterns = parsePatterns(contract.owned);
|
|
366
|
+
const { exceptions, problems } = parseExceptions(read(contract.exceptions) ?? '', contract.exceptions);
|
|
131
367
|
failures.push(...problems);
|
|
132
368
|
const live = exceptions.filter((entry) => entry.expires >= today);
|
|
133
369
|
for (const entry of exceptions.filter((e) => e.expires < today)) {
|
|
134
|
-
failures.push(`${
|
|
370
|
+
failures.push(`${contract.exceptions}:${entry.line}: the exception for ${entry.path} expired on ${entry.expires} (${entry.issue}); converge the code or renew it`);
|
|
135
371
|
}
|
|
136
372
|
|
|
137
373
|
const tracked = git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
|
|
138
|
-
for (const file of tracked.filter((path) =>
|
|
374
|
+
for (const file of tracked.filter((path) => isOwned(patterns, path))) {
|
|
139
375
|
const text = read(file);
|
|
140
376
|
if (text !== undefined && !text.includes('\0') && CONFLICT_MARKER.test(text)) {
|
|
141
377
|
failures.push(`${file}: unresolved template-sync conflict markers`);
|
|
142
378
|
}
|
|
143
379
|
}
|
|
144
380
|
|
|
381
|
+
const version = recordedVersion(contract, read);
|
|
145
382
|
if (base !== undefined) {
|
|
146
383
|
const changed = git(root, ['diff', '--name-only', '--no-renames', `${base}...HEAD`]).split('\n').filter(Boolean);
|
|
147
|
-
const
|
|
148
|
-
const before =
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
|
|
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`);
|
|
152
390
|
} else {
|
|
153
|
-
for (const path of
|
|
391
|
+
for (const path of owned) {
|
|
154
392
|
const covered = live.find((entry) => entry.regex.test(path));
|
|
155
393
|
if (covered) notes.push(`${path}: allowed by the exception until ${covered.expires} (${covered.issue})`);
|
|
156
|
-
else failures.push(`${path}:
|
|
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}`);
|
|
157
395
|
}
|
|
158
396
|
}
|
|
159
397
|
}
|
|
398
|
+
if (compare) {
|
|
399
|
+
const result = compareWithSource(root, contract, patterns, live, version);
|
|
400
|
+
failures.push(...result.failures);
|
|
401
|
+
notes.push(...result.notes);
|
|
402
|
+
}
|
|
160
403
|
return { adopted: true, failures, notes };
|
|
161
404
|
}
|
|
162
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
|
+
|
|
163
427
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
164
428
|
const args = process.argv.slice(2);
|
|
165
429
|
const baseIndex = args.indexOf('--base');
|
|
166
430
|
const base = baseIndex === -1 ? undefined : args[baseIndex + 1];
|
|
167
|
-
|
|
168
|
-
|
|
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');
|
|
169
434
|
process.exit(2);
|
|
170
435
|
}
|
|
171
436
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
172
|
-
|
|
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') });
|
|
173
448
|
if (!adopted) process.exit(0);
|
|
174
449
|
for (const note of notes) console.log(`· ${note}`);
|
|
175
450
|
for (const failure of failures) console.error(`✗ ${failure}`);
|
|
176
|
-
if (failures.length === 0) console.log('✓
|
|
451
|
+
if (failures.length === 0) console.log('✓ owned files match the template');
|
|
177
452
|
process.exit(failures.length === 0 ? 0 : 1);
|
|
178
453
|
}
|
|
@@ -19,15 +19,23 @@ env:
|
|
|
19
19
|
# One line: gh-aw joins a multi-line env value onto a single line when it compiles the lock.
|
|
20
20
|
AUDIT_FOCUS: "Architectural layer violations and dependencies pointing the wrong way. Missing or misleading tests around behaviour that already shipped. Security gaps: unvalidated input, missing authorization, secrets in code. Performance anti-patterns, N+1 queries in particular. Documentation that no longer matches the code it describes."
|
|
21
21
|
AUDIT_MARKER: "<!-- agent-audit -->"
|
|
22
|
+
# The largest issue the audit files, measured with refine's own rule (issue-size.cjs). Three
|
|
23
|
+
# quarters of agent-refine.md's REFINE_MAX_BODY_CHARS and REFINE_MAX_WORK_UNITS, so a story a
|
|
24
|
+
# person edits a little still refines. A body over these is trimmed before it is filed.
|
|
25
|
+
AUDIT_MAX_BODY_CHARS: "6000"
|
|
26
|
+
AUDIT_MAX_WORK_UNITS: "18"
|
|
27
|
+
AUDIT_MAX_STORIES: "3"
|
|
22
28
|
GIT_AUTHOR_NAME: "github-actions[bot]"
|
|
23
29
|
GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
24
30
|
GIT_COMMITTER_NAME: "github-actions[bot]"
|
|
25
31
|
GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
26
32
|
description: |
|
|
27
|
-
Read-only repository audit. Finds 5-7 problems, scores each 1-10, files a
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a compact
|
|
34
|
+
tracking report of every finding, labelled `audit` + `bug`, plus one story issue for each of
|
|
35
|
+
the top 3, labelled `bug` + `refine` and linked to the report as sub-issues. Refine refines
|
|
36
|
+
each story on its own; the report never goes to Refine. Every body is measured with refine's
|
|
37
|
+
size rule before it is filed and trimmed to fit, because one issue holding every finding and
|
|
38
|
+
three stories was over refine's limit every time and was refused. Replaces .loops/recipes/guardrails-audit-loop.yaml.
|
|
31
39
|
|
|
32
40
|
Router-only worker: triggered exclusively via workflow_call from work-router.yml.
|
|
33
41
|
Contract input: trigger-kind(scheduled|manual).
|
|
@@ -54,7 +62,8 @@ on:
|
|
|
54
62
|
type: string
|
|
55
63
|
default: manual
|
|
56
64
|
|
|
57
|
-
# Rung 1. Do not pile reports on top of unactioned reports.
|
|
65
|
+
# Rung 1. Do not pile reports on top of unactioned reports. Only the report carries `audit`;
|
|
66
|
+
# its stories do not, or one audit would fill the three slots by itself.
|
|
58
67
|
#
|
|
59
68
|
# `-label:stale-audit` is what keeps this backpressure from becoming a stop. Three reports
|
|
60
69
|
# nobody ever actioned used to disable the weekly audit for good: the query counted them
|
|
@@ -115,19 +124,27 @@ jobs:
|
|
|
115
124
|
with:
|
|
116
125
|
client-id: ${{ secrets.BOT_APP_ID }}
|
|
117
126
|
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if: needs.safe_outputs.outputs.created_issue_number != ''
|
|
124
|
-
uses: ./.github/actions/add-issue-labels
|
|
127
|
+
# Staged, so this is the only write: the report, then each story with its labels and a
|
|
128
|
+
# link back, then the report's list of stories. Each body passes refine's size rule first.
|
|
129
|
+
- name: Download agent output
|
|
130
|
+
id: output
|
|
131
|
+
uses: ./.github/actions/download-agent-output
|
|
125
132
|
with:
|
|
133
|
+
artifact-name: ${{ needs.activation.outputs.artifact_prefix }}agent
|
|
134
|
+
- name: File the report and its stories
|
|
135
|
+
uses: ./.github/actions/file-audit-issues
|
|
136
|
+
with:
|
|
137
|
+
output-file: ${{ steps.output.outputs.output-file }}
|
|
126
138
|
token: ${{ steps.app-token.outputs.token }}
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
max-body-chars: ${{ env.AUDIT_MAX_BODY_CHARS }}
|
|
140
|
+
max-work-units: ${{ env.AUDIT_MAX_WORK_UNITS }}
|
|
141
|
+
max-stories: ${{ env.AUDIT_MAX_STORIES }}
|
|
142
|
+
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
143
|
+
report-labels: |-
|
|
129
144
|
audit
|
|
130
145
|
bug
|
|
146
|
+
story-labels: |-
|
|
147
|
+
bug
|
|
131
148
|
refine
|
|
132
149
|
|
|
133
150
|
# An audit that produced nothing reported success. The whole run -- a full agent, its tokens,
|
|
@@ -138,7 +155,7 @@ jobs:
|
|
|
138
155
|
#
|
|
139
156
|
# This fires when the agent succeeded and the safe-outputs handler processed nothing at all.
|
|
140
157
|
#
|
|
141
|
-
# Step
|
|
158
|
+
# Step 7 of the prompt gives a clean codebase its own outcome -- call `noop` and stop -- and
|
|
142
159
|
# that outcome must not be reported as a failure. `noop` is a registered safe output here
|
|
143
160
|
# (`noop: max 1`), so the handler receives it as a message and the processed count is not zero,
|
|
144
161
|
# which keeps a deliberately empty audit out of this branch. That is read from the handler
|
|
@@ -171,8 +188,11 @@ safe-outputs:
|
|
|
171
188
|
# under noise nobody closes.
|
|
172
189
|
report-failure-as-issue: false
|
|
173
190
|
threat-detection: false
|
|
191
|
+
# The report and the stories are filed by `conclude`, after they are measured.
|
|
192
|
+
staged: true
|
|
193
|
+
# The report and up to three stories.
|
|
174
194
|
create-issue:
|
|
175
|
-
max:
|
|
195
|
+
max: 4
|
|
176
196
|
|
|
177
197
|
# The fleet is two machines, so this clock is also how long a stuck run can hold half of it.
|
|
178
198
|
# 240 went on to every worker at once when the provider was slow, which fixed the deaths and
|
|
@@ -227,21 +247,18 @@ timeout-minutes: 90
|
|
|
227
247
|
again. What this prevents is re-filing something the team looked at and chose to live
|
|
228
248
|
with, which is the noise that makes an audit backlog get ignored.
|
|
229
249
|
|
|
230
|
-
5.
|
|
231
|
-
conclude job will apply them. The body MUST have two sections:
|
|
250
|
+
5. File the report: call `create_issue` with title "Audit: <YYYY-MM-DD>" and nothing else in the title. Do NOT specify labels; the conclude job applies them. The report is a compact tracking list, not a story: Refine never reads it. One entry per finding, highest score first, each exactly this shape and no longer than about 600 characters:
|
|
232
251
|
|
|
233
|
-
|
|
234
|
-
|
|
252
|
+
```
|
|
253
|
+
### <n>. <one-line problem> (score <s>)
|
|
254
|
+
**Where:** `path/to/file.ext:line`, `other/file.ext:line`
|
|
255
|
+
**Problem:** One or two sentences: what happens and its impact.
|
|
256
|
+
**Fix:** One or two sentences: the change that resolves it.
|
|
257
|
+
```
|
|
235
258
|
|
|
236
|
-
|
|
237
|
-
findings by score into user stories; that skill owns their shape. Name the likely files to
|
|
238
|
-
change. Mark this section clearly with a heading like `## Top 3 , To Implement`.
|
|
259
|
+
No bullet lists, no Gherkin, no user stories, no reproduction walkthroughs. Do not write any issue number (`#123`) in the report: the workflow adds the links to the stories, and every number in it is read as work the report waits on.
|
|
239
260
|
|
|
240
|
-
|
|
241
|
-
because a report of several unrelated defects across different files is exactly the shape
|
|
242
|
-
that does not land as one pull request, splits it into one properly estimated issue per
|
|
243
|
-
finding. Write each finding so it survives that split: self-contained, naming its own
|
|
244
|
-
files and its own acceptance criteria, never "as above" or "same as finding 2".
|
|
261
|
+
6. File the stories: call skill("pc-plan-story") and write the top ${{ env.AUDIT_MAX_STORIES }} findings by score as user stories; that skill owns their shape. Call `create_issue` once per story, after the report, titled with the story's own title (never "Audit: ..."), no labels. Refine refines each story on its own, so each must stand alone: it names its own files and its own acceptance criteria, never "as above" or "see finding 2", and never mentions the other stories or the report. Keep each story well under ${{ env.AUDIT_MAX_BODY_CHARS }} characters and ${{ env.AUDIT_MAX_WORK_UNITS }} bullet lines: the workflow measures every issue against that limit before filing and cuts anything over it from the end.
|
|
245
262
|
|
|
246
|
-
|
|
263
|
+
7. If nothing met the bar, call `noop` and stop. Filing nothing is the right outcome when
|
|
247
264
|
the codebase is clean.
|