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.
- package/.github/CONTRIBUTING.md +7 -6
- package/.github/workflows/draft-enforcement_self-ci.yml +3 -4
- package/.github/workflows/opencode-security_self-ci.yml +2 -1
- package/.github/workflows/validation_self-ci.yml +6 -4
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +14 -0
- package/README.md +7 -6
- package/docs/PERFORMANCE.md +3 -3
- package/docs/README.md +2 -0
- package/docs/WORKFLOWS.md +14 -14
- package/docs/agent-validation.md +94 -0
- package/docs/fleet-rollouts.md +139 -0
- package/package.json +1 -1
- package/src/cli.mjs +7 -0
- package/src/commands/agent-check.mjs +309 -0
- package/src/commands/doctor.mjs +8 -3
- package/src/commands/fleet.mjs +355 -42
- package/src/commands/sync.mjs +11 -6
- package/src/lib/fleet-manifest.mjs +654 -0
|
@@ -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
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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 (
|
|
185
|
-
|
|
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 (
|
|
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 (
|