code-foundry 1.12.0 → 1.14.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.
@@ -0,0 +1,309 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { join, resolve } from 'node:path'
13
+ import { spawnSync } from 'node:child_process'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { taskProfile } from '../runtime.mjs'
16
+ import { evidencePath, fingerprint, TASKS } from '../lib/task-policy.mjs'
17
+
18
+ const runtime = fileURLToPath(new URL('../runtime.mjs', import.meta.url))
19
+ const fastTasks = new Set(['format', 'lint', 'type_check', 'build', 'unit', 'performance'])
20
+ export const agentUsage = `code-foundry plan [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--json]
21
+ code-foundry check [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--timeout SECONDS] [--json]
22
+
23
+ plan discovers tasks without executing them. check invokes the shared CI runtime.
24
+ --changed annotates affected paths; it never prunes required or custom checks.
25
+ fast includes CI tasks, unit tests, and performance; audit adds integration/E2E/smoke.
26
+ Neither command substitutes for GitHub Security, CodeQL, release policy, or review.
27
+ `
28
+
29
+ /** @typedef {{command: 'plan'|'check', target: string, tier: 'fast'|'audit', changed: boolean, base: string, json: boolean, timeout: number}} AgentOptions */
30
+ /** @typedef {{runtime?: string, core?: string}} RuntimePaths */
31
+
32
+ /** @param {string[]} argv @returns {AgentOptions} */
33
+ export function parseAgentArgs(argv) {
34
+ const [command, ...args] = argv
35
+ if (command !== 'plan' && command !== 'check') throw new Error('Expected plan or check')
36
+ /** @type {AgentOptions} */
37
+ const options = {
38
+ command,
39
+ target: process.cwd(),
40
+ tier: 'fast',
41
+ changed: false,
42
+ base: 'HEAD',
43
+ json: false,
44
+ timeout: 600000,
45
+ }
46
+ const seen = new Set()
47
+ while (args.length) {
48
+ const key = args.shift()
49
+ if (!key || seen.has(key)) throw new Error(`Duplicate or missing option: ${key}`)
50
+ seen.add(key)
51
+ if (key === '--json') options.json = true
52
+ else if (key === '--changed') options.changed = true
53
+ else {
54
+ if (!['--target', '--tier', '--base', '--timeout'].includes(key))
55
+ throw new Error(`Unknown option: ${key}`)
56
+ const value = args.shift()
57
+ if (!value || value.startsWith('-')) throw new Error(`Missing value for ${key}`)
58
+ if (key === '--target') options.target = resolve(value)
59
+ if (key === '--base') options.base = value
60
+ if (key === '--tier') {
61
+ if (!['fast', 'audit'].includes(value)) throw new Error('tier must be fast or audit')
62
+ options.tier = /** @type {'fast'|'audit'} */ (value)
63
+ }
64
+ if (key === '--timeout') {
65
+ const seconds = Number(value)
66
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600)
67
+ throw new Error('timeout must be 1–3600 whole seconds')
68
+ options.timeout = seconds * 1000
69
+ }
70
+ }
71
+ }
72
+ if (seen.has('--base') && !options.changed) throw new Error('--base requires --changed')
73
+ if (command === 'plan' && seen.has('--timeout'))
74
+ throw new Error('--timeout applies only to check execution')
75
+ return options
76
+ }
77
+
78
+ /** @param {string} root @param {string[]} args */
79
+ function git(root, args) {
80
+ const result = spawnSync('git', args, {
81
+ cwd: root,
82
+ encoding: 'utf8',
83
+ timeout: 10000,
84
+ maxBuffer: 8 * 1024 * 1024,
85
+ })
86
+ if (result.error || result.status !== 0)
87
+ throw new Error(`Unable to inspect repository: git ${args[0]}`)
88
+ return result.stdout
89
+ }
90
+
91
+ /** @param {string} value */
92
+ function hasInvalidBaseRefCharacter(value) {
93
+ return [...value].some((character) => {
94
+ const code = character.codePointAt(0) ?? 0
95
+ return code <= 0x20 || code === 0x7f
96
+ })
97
+ }
98
+
99
+ /** @param {AgentOptions} options */
100
+ export function sourceContext(options) {
101
+ const sourceSha = git(options.target, ['rev-parse', '--verify', 'HEAD']).trim()
102
+ if (!/^[0-9a-f]{40}$/i.test(sourceSha))
103
+ throw new Error('Repository HEAD must resolve to a commit')
104
+ const dirty = git(options.target, ['status', '--porcelain=v1', '-z'])
105
+ .split('\0')
106
+ .filter(Boolean)
107
+ .some((line) => !line.slice(3).startsWith('.code-foundry/'))
108
+ let baseSha = null
109
+ /** @type {string[]} */
110
+ let changedFiles = []
111
+ if (options.changed) {
112
+ if (options.base.startsWith('-') || hasInvalidBaseRefCharacter(options.base))
113
+ throw new Error('Invalid base ref')
114
+ baseSha = git(options.target, ['rev-parse', '--verify', `${options.base}^{commit}`]).trim()
115
+ const lists = [
116
+ git(options.target, ['diff', '--name-only', '-z', `${baseSha}...HEAD`, '--']),
117
+ git(options.target, ['diff', '--cached', '--name-only', '-z', '--']),
118
+ git(options.target, ['diff', '--name-only', '-z', '--']),
119
+ git(options.target, ['ls-files', '--others', '--exclude-standard', '-z']),
120
+ ]
121
+ changedFiles = [
122
+ ...new Set(
123
+ lists
124
+ .flatMap((value) => value.split('\0'))
125
+ .filter((file) => file && !file.startsWith('.code-foundry/'))
126
+ ),
127
+ ]
128
+ // This freshly created array is local to the plan.
129
+ // oxlint-disable-next-line unicorn/no-array-sort
130
+ changedFiles.sort()
131
+ }
132
+ return { sourceSha, dirty, baseSha, changedFiles }
133
+ }
134
+
135
+ /** @param {AgentOptions} options @param {RuntimePaths} [paths] */
136
+ export function planChecks(options, paths = {}) {
137
+ const source = sourceContext(options)
138
+ // Direct discovery shares CI policy, but unlike ci task_profile it writes no skip receipts.
139
+ const tasks = TASKS.map((task) => ({
140
+ ...taskProfile(options.target, task, paths.core),
141
+ selected: options.tier === 'audit' || fastTasks.has(task),
142
+ argv: [process.execPath, paths.runtime ?? runtime, 'ci', task],
143
+ }))
144
+ return {
145
+ schemaVersion: 1,
146
+ kind: 'code-foundry-validation-plan',
147
+ status: 'planned',
148
+ tier: options.tier,
149
+ ...source,
150
+ tasks,
151
+ changePolicy: 'annotation-only; no tasks pruned without a proven dependency graph',
152
+ remoteValidationRequired: true,
153
+ deferredRemoteChecks: [
154
+ 'Security',
155
+ 'CodeQL',
156
+ 'release policy when applicable',
157
+ 'required reviews',
158
+ ],
159
+ }
160
+ }
161
+
162
+ /** @param {string} root */
163
+ function outputDirectory(root) {
164
+ let directory = resolve(root)
165
+ for (const component of ['.code-foundry', 'agent-results']) {
166
+ directory = join(directory, component)
167
+ if (!existsSync(directory)) mkdirSync(directory)
168
+ if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
169
+ throw new Error('Agent evidence directory must be a real directory inside the repository')
170
+ }
171
+ return directory
172
+ }
173
+
174
+ /** @param {AgentOptions} options @param {RuntimePaths} [paths] */
175
+ export function checkRepository(options, paths = {}) {
176
+ const plan = planChecks(options, paths)
177
+ const directory = outputDirectory(options.target)
178
+ const lock = join(directory, 'active.lock')
179
+ try {
180
+ mkdirSync(lock)
181
+ } catch {
182
+ throw new Error(
183
+ 'Another agent check may be running; inspect agent-results/active.lock before recovery'
184
+ )
185
+ }
186
+ let runDirectory
187
+ try {
188
+ runDirectory = mkdtempSync(join(directory, 'check-'))
189
+ } catch (error) {
190
+ try {
191
+ rmSync(lock, { recursive: true, force: true })
192
+ } catch {
193
+ // Preserve the original temporary-directory error.
194
+ }
195
+ throw error
196
+ }
197
+ const reportFile = join(runDirectory, 'summary.json')
198
+ /** @type {Record<string, any>} */
199
+ const report = {
200
+ ...plan,
201
+ kind: 'code-foundry-validation-result',
202
+ status: 'failed',
203
+ startedAt: new Date().toISOString(),
204
+ completedAt: null,
205
+ tasks: [],
206
+ report: reportFile,
207
+ deferredTasks: plan.tasks.filter((task) => !task.selected).map((task) => task.task),
208
+ }
209
+ let failed = false
210
+ try {
211
+ for (const item of plan.tasks.filter((task) => task.selected)) {
212
+ if (!item.applicable || failed) {
213
+ report.tasks.push({
214
+ ...item,
215
+ status: failed && item.applicable ? 'blocked' : 'skipped',
216
+ reason: failed && item.applicable ? 'an earlier task failed' : item.reason,
217
+ })
218
+ continue
219
+ }
220
+ const receiptFile = evidencePath(options.target, `.code-foundry/results/${item.task}.json`)
221
+ const before = fingerprint(receiptFile)
222
+ const result = spawnSync(item.argv[0], item.argv.slice(1), {
223
+ cwd: options.target,
224
+ timeout: options.timeout,
225
+ // Keep stdout exclusively available for the public JSON contract.
226
+ stdio: ['ignore', 2, 2],
227
+ env: { ...process.env, GITHUB_OUTPUT: '', GITHUB_STEP_SUMMARY: '' },
228
+ })
229
+ let receipt = null
230
+ let reason = result.error
231
+ ? `Runtime execution failed: ${/** @type {NodeJS.ErrnoException} */ (result.error).code ?? result.error.name}`
232
+ : ''
233
+ if (fingerprint(receiptFile) !== before && existsSync(receiptFile)) {
234
+ try {
235
+ receipt = JSON.parse(readFileSync(receiptFile, 'utf8'))
236
+ if (
237
+ receipt.kind !== 'code-foundry-task-result' ||
238
+ receipt.schemaVersion !== 1 ||
239
+ receipt.task !== item.task ||
240
+ receipt.sourceSha !== plan.sourceSha
241
+ )
242
+ throw new Error('Receipt identity does not match the planned task/source')
243
+ } catch (error) {
244
+ reason = error instanceof Error ? error.message : String(error)
245
+ receipt = null
246
+ }
247
+ }
248
+ const passed = result.status === 0 && receipt?.status === 'passed' && !reason
249
+ const snapshot = receipt ? join(runDirectory, `${item.task}.json`) : null
250
+ if (snapshot) writeFileSync(snapshot, `${JSON.stringify(receipt, null, 2)}\n`)
251
+ failed = !passed
252
+ report.tasks.push({
253
+ ...item,
254
+ status: passed ? 'passed' : 'failed',
255
+ exitCode: result.status,
256
+ signal: result.signal,
257
+ reason: reason || receipt?.reason || 'missing fresh successful task evidence',
258
+ receipt: snapshot,
259
+ artifacts: receipt?.artifacts ?? [],
260
+ })
261
+ }
262
+ report.status = failed
263
+ ? 'failed'
264
+ : report.tasks.some((/** @type {any} */ task) => task.status === 'passed')
265
+ ? 'passed'
266
+ : 'skipped'
267
+ return report
268
+ } finally {
269
+ report.completedAt = new Date().toISOString()
270
+ try {
271
+ writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`)
272
+ } finally {
273
+ rmSync(lock, { recursive: true })
274
+ }
275
+ }
276
+ }
277
+
278
+ /** @param {string[]} argv */
279
+ export function agentCommand(argv) {
280
+ const jsonOutput = argv.includes('--json')
281
+ if (argv.includes('--help') || argv.includes('-h')) {
282
+ console.log(agentUsage)
283
+ return 0
284
+ }
285
+ try {
286
+ const options = parseAgentArgs(argv)
287
+ const result = options.command === 'plan' ? planChecks(options) : checkRepository(options)
288
+ if (options.json) console.log(JSON.stringify(result, null, 2))
289
+ else {
290
+ console.log(`${options.command}: ${result.status} (${options.tier}, ${result.sourceSha})`)
291
+ for (const item of result.tasks)
292
+ console.log(
293
+ `${item.task}: ${item.status ?? (item.selected ? (item.applicable ? 'planned' : 'skipped') : 'deferred')} — ${item.reason}`
294
+ )
295
+ console.log('GitHub security checks and review requirements remain separate.')
296
+ }
297
+ return result.status === 'failed' ? 1 : 0
298
+ } catch (error) {
299
+ const result = {
300
+ schemaVersion: 1,
301
+ kind: 'code-foundry-validation-error',
302
+ status: 'failed',
303
+ reason: error instanceof Error ? error.message : String(error),
304
+ }
305
+ if (jsonOutput) console.log(JSON.stringify(result, null, 2))
306
+ else console.error(result.reason)
307
+ return 1
308
+ }
309
+ }
@@ -181,10 +181,15 @@ export function doctor(root, options = {}) {
181
181
  'validation caller is missing the Validation job; the Validation / Gate aggregate check cannot form.'
182
182
  )
183
183
  }
184
- if (!/types:\n\s+- ready_for_review/.test(caller)) {
185
- error('validation caller must wait for the ready_for_review transition.')
184
+ if (
185
+ !/types:\n\s+- ready_for_review\n\s+- synchronize/.test(caller) ||
186
+ !/github\.event\.pull_request\.draft == false/.test(caller)
187
+ ) {
188
+ error(
189
+ 'validation caller must run for ready pull requests and their new commits while ignoring drafts.'
190
+ )
186
191
  }
187
- if (/\s+- (?:opened|synchronize|reopened|converted_to_draft)\s*$/.test(caller)) {
192
+ if (/^\s+- (?:opened|reopened|converted_to_draft)\s*$/m.test(caller)) {
188
193
  error('validation caller registers draft-time checks; run code-foundry sync.')
189
194
  }
190
195
  if (