@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.
@@ -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
- // The .platform/manifest paths change only in a template sync (a pull request that moves `_commit` in
4
- // .copier-answers.yml) or under an unexpired .platform/exceptions.yml entry. Conflict markers in a
5
- // manifest file and expired exceptions fail too. Offline; without .copier-answers.yml it passes silently.
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>] (no --base: markers and exceptions only)
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 { join } from 'node:path';
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 ANSWERS = '.copier-answers.yml';
15
- const MANIFEST = '.platform/manifest';
16
- const EXCEPTIONS = '.platform/exceptions.yml';
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 manifest and every metacharacter outside a class is escaped
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 parseManifest(text) {
48
- return text
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 inManifest(patterns, path) {
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
- // The subset of YAML .platform/exceptions.yml uses: one `exceptions:` list of flat mappings.
74
- export function parseExceptions(text) {
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 = `${EXCEPTIONS}:${index + 1}`;
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 = `${EXCEPTIONS}:${entry.line}`;
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, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
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
- export function checkConformance(root, { base, today = new Date().toISOString().slice(0, 10) } = {}) {
120
- const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
121
- const answers = read(ANSWERS);
122
- if (answers === undefined) return { adopted: false, failures: [], notes: [] };
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 manifestText = read(MANIFEST);
127
- if (manifestText === undefined) return { adopted: true, failures: [`${ANSWERS} exists but ${MANIFEST} does not`], notes };
128
- const patterns = parseManifest(manifestText);
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 { exceptions, problems } = parseExceptions(read(EXCEPTIONS) ?? '');
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(`${EXCEPTIONS}:${entry.line}: the exception for ${entry.path} expired on ${entry.expires} (${entry.issue}); converge the code or renew it`);
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) => inManifest(patterns, 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 platform = changed.filter((path) => inManifest(patterns, path));
148
- const before = templateCommit(gitShow(root, base, ANSWERS));
149
- const after = templateCommit(answers);
150
- if (before !== after) {
151
- notes.push(`template sync ${before ?? '(adoption)'} -> ${after}: ${platform.length} platform file(s) may change`);
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 platform) {
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}: platform file changed outside a template sync; change it in Kitchen-Sink, or add an exception to ${EXCEPTIONS}`);
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
- if (baseIndex !== -1 && !base) {
168
- console.error('usage: node scripts/check-platform-conformance.mjs [--base <git ref>]');
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
- const { adopted, failures, notes } = checkConformance(root, { base });
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('✓ platform files match the template');
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 single issue
28
- with all findings listed and the top 3 refined as actionable user stories. The issue is
29
- labelled `audit` + `bug` + `refine`, so Refine sizes it and splits a multi-defect report
30
- into one estimated issue per finding rather than one pull request that has to fix them all. Replaces .loops/recipes/guardrails-audit-loop.yaml.
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
- # No apply-agent-output here. The safe_outputs job already created the issue and
119
- # exposes its number; calling the action with create-issues would file a second copy
120
- # of the same report. It was only needed while staged: true suppressed the native
121
- # write, and staged is gone.
122
- - name: Apply audit labels to created issues
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
- issue-number: ${{ needs.safe_outputs.outputs.created_issue_number }}
128
- labels: |
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 6 of the prompt gives a clean codebase its own outcome -- call `noop` and stop -- and
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: 1
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. Call `create_issue` **once** with title "Audit: <date>". Do NOT specify labels , the
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
- **Section 1 , All findings:** A numbered list of every finding (5-7) with its score,
234
- file path, and a one-line description. Order by score descending.
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
- **Section 2 , Top 3 to implement:** Call skill("pc-plan-story") and refine the top 3
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
- The issue you file goes to Refine, not straight to implementation. Refine sizes it and,
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
- 6. If nothing met the bar, call `noop` and stop. Filing nothing is the right outcome when
263
+ 7. If nothing met the bar, call `noop` and stop. Filing nothing is the right outcome when
247
264
  the codebase is clean.