code-foundry 1.15.0 → 1.17.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/workflows/consumer-qualification.yml +95 -0
- package/.github/workflows/consumer-qualification_self-ci.yml +19 -0
- package/.github/workflows/release_self-ci.yml +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/docs/PERFORMANCE.md +11 -3
- package/docs/README.md +1 -0
- package/docs/consumer-qualification.md +48 -0
- package/docs/product-quality.md +173 -0
- package/package.json +1 -1
- package/src/commands/sync.mjs +41 -3
- package/src/lib/consumer-qualification.mjs +273 -0
- package/src/lib/product-quality.mjs +685 -0
- package/src/quality.mjs +17 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import {
|
|
6
|
+
cpSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
/** @param {string} file */
|
|
20
|
+
export function digest(file) {
|
|
21
|
+
if (!lstatSync(file).isFile()) throw new Error(`Expected a regular file: ${file}`)
|
|
22
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Snapshot consumer-owned bytes, never Git metadata or dependency directories.
|
|
26
|
+
* @param {string} root @param {string} [prefix]
|
|
27
|
+
* @returns {Record<string, string>}
|
|
28
|
+
*/
|
|
29
|
+
export function snapshot(root, prefix = '') {
|
|
30
|
+
/** @type {Record<string, string>} */
|
|
31
|
+
const result = {}
|
|
32
|
+
for (const entry of readdirSync(join(root, prefix), { withFileTypes: true })) {
|
|
33
|
+
if (['.git', 'node_modules'].includes(entry.name)) continue
|
|
34
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
35
|
+
if (entry.isSymbolicLink()) throw new Error(`Unexpected fixture symlink: ${name}`)
|
|
36
|
+
if (entry.isDirectory()) Object.assign(result, snapshot(root, name))
|
|
37
|
+
else if (entry.isFile()) result[name] = digest(join(root, name))
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} command @param {string[]} args @param {string} cwd */
|
|
43
|
+
function run(command, args, cwd) {
|
|
44
|
+
const result = spawnSync(command, args, {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
timeout: 120_000,
|
|
48
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
49
|
+
env: { ...process.env, CI: 'true', npm_config_update_notifier: 'false' },
|
|
50
|
+
})
|
|
51
|
+
if (result.error || result.status !== 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`${command} ${args.join(' ')} failed (${result.status ?? result.error?.message})\n${result.stderr}\n${result.stdout}`
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return result.stdout
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const FIXTURES = [
|
|
60
|
+
{ name: 'npm-direct', manager: 'npm', lock: 'package-lock.json', topology: 'direct' },
|
|
61
|
+
{ name: 'pnpm-direct', manager: 'pnpm', lock: 'pnpm-lock.yaml', topology: 'direct' },
|
|
62
|
+
{ name: 'yarn-staging', manager: 'yarn', lock: 'yarn.lock', topology: 'staging-release' },
|
|
63
|
+
{ name: 'bun-mise', manager: 'bun', lock: 'bun.lock', topology: 'direct' },
|
|
64
|
+
{ name: 'nested-rust', manager: '', lock: '', topology: 'direct' },
|
|
65
|
+
{ name: 'nested-python', manager: '', lock: '', topology: 'direct' },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/** @param {string} root @param {(typeof FIXTURES)[number]} fixture */
|
|
69
|
+
export function seedFixture(root, fixture) {
|
|
70
|
+
mkdirSync(join(root, '.github/workflows'), { recursive: true })
|
|
71
|
+
const authored = '# Consumer-owned instructions\n\nPreserve this paragraph exactly.\n'
|
|
72
|
+
writeFileSync(join(root, 'AGENTS.md'), authored)
|
|
73
|
+
writeFileSync(
|
|
74
|
+
join(root, '.github/workflows/custom.yml'),
|
|
75
|
+
'name: Custom\non: workflow_dispatch\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - run: echo preserved\n'
|
|
76
|
+
)
|
|
77
|
+
writeFileSync(join(root, 'LICENSE'), 'Consumer-owned license\n')
|
|
78
|
+
if (fixture.manager) {
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(root, 'package.json'),
|
|
81
|
+
JSON.stringify(
|
|
82
|
+
{
|
|
83
|
+
name: `qualification-${fixture.name}`,
|
|
84
|
+
version: '0.0.0',
|
|
85
|
+
private: true,
|
|
86
|
+
scripts: { 'test:unit': 'node -e "process.exit(0)"' },
|
|
87
|
+
},
|
|
88
|
+
null,
|
|
89
|
+
2
|
|
90
|
+
) + '\n'
|
|
91
|
+
)
|
|
92
|
+
const locks = {
|
|
93
|
+
npm: '{"name":"fixture","lockfileVersion":3,"packages":{}}\n',
|
|
94
|
+
pnpm: "lockfileVersion: '9.0'\nimporters: {}\n",
|
|
95
|
+
yarn: '# yarn lockfile v1\n',
|
|
96
|
+
bun: '{"lockfileVersion":1,"workspaces":{},"packages":{}}\n',
|
|
97
|
+
}
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(root, fixture.lock),
|
|
100
|
+
locks[/** @type {keyof typeof locks} */ (fixture.manager)]
|
|
101
|
+
)
|
|
102
|
+
} else if (fixture.name === 'nested-rust') {
|
|
103
|
+
mkdirSync(join(root, 'native/core/src'), { recursive: true })
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(root, 'native/core/Cargo.toml'),
|
|
106
|
+
'[package]\nname = "fixture"\nversion = "0.1.0"\nedition = "2021"\n'
|
|
107
|
+
)
|
|
108
|
+
writeFileSync(join(root, 'native/core/src/lib.rs'), 'pub fn value() -> u8 { 1 }\n')
|
|
109
|
+
} else {
|
|
110
|
+
mkdirSync(join(root, 'services/api'), { recursive: true })
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(root, 'services/api/pyproject.toml'),
|
|
113
|
+
'[project]\nname = "fixture"\nversion = "0.1.0"\n'
|
|
114
|
+
)
|
|
115
|
+
writeFileSync(join(root, 'services/api/app.py'), 'VALUE = 1\n')
|
|
116
|
+
}
|
|
117
|
+
if (fixture.name === 'bun-mise') writeFileSync(join(root, '.mise.toml'), '[tools]\nnode = "22"\n')
|
|
118
|
+
// Explicit topology only; init must still detect languages and lockfiles.
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(root, '.github/code-foundry.yml'),
|
|
121
|
+
`git_workflow: ${fixture.topology}\nmerge_strategy: ${fixture.topology === 'direct' ? 'squash' : 'rebase'}\nrelease_merge_strategy: ${fixture.topology === 'direct' ? 'squash' : 'rebase'}\n`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {string} value */
|
|
126
|
+
function escapeRegExp(value) {
|
|
127
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Localize only this candidate's remote workflow calls for Actionlint's local
|
|
131
|
+
* caller/callee contract checks. Production files are never rewritten.
|
|
132
|
+
* @param {string} text @param {string} repository @param {string} ref
|
|
133
|
+
*/
|
|
134
|
+
export function localizeCalls(text, repository, ref) {
|
|
135
|
+
const pattern = new RegExp(
|
|
136
|
+
`(uses:\\s*)${escapeRegExp(repository)}/\\.github/workflows/([A-Za-z0-9_.-]+)@${escapeRegExp(ref)}(?=\\s|$)`,
|
|
137
|
+
'g'
|
|
138
|
+
)
|
|
139
|
+
return text.replace(pattern, '$1./.github/workflows/$2')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} consumer @param {string} candidate @param {string} destination @param {string} actionlint @param {string} version */
|
|
143
|
+
function lintContracts(consumer, candidate, destination, actionlint, version) {
|
|
144
|
+
mkdirSync(join(destination, '.github/workflows'), { recursive: true })
|
|
145
|
+
run('git', ['init', '-q'], destination)
|
|
146
|
+
cpSync(join(candidate, '.github'), join(destination, '.github'), { recursive: true })
|
|
147
|
+
// Event callers in the candidate are not reusable-workflow dependencies.
|
|
148
|
+
for (const name of readdirSync(join(destination, '.github/workflows'))) {
|
|
149
|
+
if (name.includes('_self-ci')) rmSync(join(destination, '.github/workflows', name))
|
|
150
|
+
}
|
|
151
|
+
const paths = []
|
|
152
|
+
for (const name of readdirSync(join(consumer, '.github/workflows'))) {
|
|
153
|
+
if (!/\.ya?ml$/.test(name)) continue
|
|
154
|
+
const target = join(destination, '.github/workflows', `consumer-${name}`)
|
|
155
|
+
const text = readFileSync(join(consumer, '.github/workflows', name), 'utf8')
|
|
156
|
+
writeFileSync(target, localizeCalls(text, '0xPlayerOne/code-foundry', `v${version}`))
|
|
157
|
+
paths.push(target)
|
|
158
|
+
}
|
|
159
|
+
// Check all candidate callees as well as rendered callers, without executing them.
|
|
160
|
+
const workflows = readdirSync(join(destination, '.github/workflows')).filter((name) =>
|
|
161
|
+
/\.ya?ml$/.test(name)
|
|
162
|
+
)
|
|
163
|
+
run(
|
|
164
|
+
actionlint,
|
|
165
|
+
[
|
|
166
|
+
'-shellcheck=',
|
|
167
|
+
'-pyflakes=',
|
|
168
|
+
...workflows.map((name) => join(destination, '.github/workflows', name)),
|
|
169
|
+
],
|
|
170
|
+
destination
|
|
171
|
+
)
|
|
172
|
+
return paths.length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @param {{ packageFile: string, sourceSha: string, reportPath: string, actionlint?: string }} options */
|
|
176
|
+
export function qualifyCandidate(options) {
|
|
177
|
+
if (!/^[a-f0-9]{40}$/.test(options.sourceSha))
|
|
178
|
+
throw new Error('An exact 40-character source SHA is required')
|
|
179
|
+
const packageFile = resolve(options.packageFile)
|
|
180
|
+
const before = digest(packageFile)
|
|
181
|
+
const temporary = mkdtempSync(join(tmpdir(), 'code-foundry-qualification-'))
|
|
182
|
+
const report = {
|
|
183
|
+
schema_version: 1,
|
|
184
|
+
source_sha: options.sourceSha,
|
|
185
|
+
artifact_sha256: before,
|
|
186
|
+
node: process.version,
|
|
187
|
+
complete: false,
|
|
188
|
+
actionlint: Boolean(options.actionlint),
|
|
189
|
+
/** @type {Array<{ name: string, status: string }>} */ fixtures: [],
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const host = join(temporary, 'host')
|
|
193
|
+
mkdirSync(host)
|
|
194
|
+
writeFileSync(join(host, 'package.json'), '{"private":true}\n')
|
|
195
|
+
run(
|
|
196
|
+
'npm',
|
|
197
|
+
[
|
|
198
|
+
'install',
|
|
199
|
+
'--offline',
|
|
200
|
+
'--ignore-scripts',
|
|
201
|
+
'--no-audit',
|
|
202
|
+
'--no-fund',
|
|
203
|
+
'--package-lock=false',
|
|
204
|
+
packageFile,
|
|
205
|
+
],
|
|
206
|
+
host
|
|
207
|
+
)
|
|
208
|
+
const candidate = join(host, 'node_modules/code-foundry')
|
|
209
|
+
const manifest = JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8'))
|
|
210
|
+
assert.equal(manifest.name, 'code-foundry')
|
|
211
|
+
assert.equal(manifest.bin?.['code-foundry'], 'src/cli.mjs')
|
|
212
|
+
const cli = join(candidate, 'src/cli.mjs')
|
|
213
|
+
assert.match(run(process.execPath, [cli, '--help'], host), /code-foundry/)
|
|
214
|
+
for (const fixture of FIXTURES) {
|
|
215
|
+
const root = join(temporary, fixture.name)
|
|
216
|
+
seedFixture(root, fixture)
|
|
217
|
+
run('git', ['init', '-q'], root)
|
|
218
|
+
const original = snapshot(root)
|
|
219
|
+
run(process.execPath, [cli, 'init', '--target', root], root)
|
|
220
|
+
assert.ok(
|
|
221
|
+
readFileSync(join(root, 'AGENTS.md'), 'utf8').startsWith(
|
|
222
|
+
'# Consumer-owned instructions\n\nPreserve this paragraph exactly.\n'
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
const initialized = snapshot(root)
|
|
226
|
+
for (const [name, hash] of Object.entries(original)) {
|
|
227
|
+
if (name === 'AGENTS.md' || name === '.github/code-foundry.yml') continue
|
|
228
|
+
assert.equal(initialized[name], hash, `${fixture.name}: authored ${name} was changed`)
|
|
229
|
+
}
|
|
230
|
+
if (fixture.manager)
|
|
231
|
+
assert.match(
|
|
232
|
+
readFileSync(join(root, '.github/code-foundry.yml'), 'utf8'),
|
|
233
|
+
new RegExp(`^package_manager: ${fixture.manager}$`, 'm')
|
|
234
|
+
)
|
|
235
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
236
|
+
run(process.execPath, [cli, 'sync', '--target', root], root)
|
|
237
|
+
assert.deepEqual(snapshot(root), initialized, `${fixture.name}: sync is not idempotent`)
|
|
238
|
+
}
|
|
239
|
+
if (options.actionlint)
|
|
240
|
+
lintContracts(
|
|
241
|
+
root,
|
|
242
|
+
candidate,
|
|
243
|
+
join(temporary, `lint-${fixture.name}`),
|
|
244
|
+
options.actionlint,
|
|
245
|
+
manifest.version
|
|
246
|
+
)
|
|
247
|
+
report.fixtures.push({ name: fixture.name, status: 'passed' })
|
|
248
|
+
}
|
|
249
|
+
assert.equal(digest(packageFile), before, 'Candidate archive changed during qualification')
|
|
250
|
+
report.complete = true
|
|
251
|
+
return report
|
|
252
|
+
} finally {
|
|
253
|
+
mkdirSync(dirname(resolve(options.reportPath)), { recursive: true })
|
|
254
|
+
writeFileSync(options.reportPath, JSON.stringify(report, null, 2) + '\n')
|
|
255
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
260
|
+
try {
|
|
261
|
+
const [packageFile, sourceSha, reportPath, actionlint, ...extra] = process.argv.slice(2)
|
|
262
|
+
if (!packageFile || !sourceSha || !reportPath || extra.length)
|
|
263
|
+
throw new Error(
|
|
264
|
+
'Usage: node consumer-qualification.mjs PACKAGE.tgz SOURCE_SHA REPORT.json [ACTIONLINT]'
|
|
265
|
+
)
|
|
266
|
+
console.log(
|
|
267
|
+
JSON.stringify(qualifyCandidate({ packageFile, sourceSha, reportPath, actionlint }))
|
|
268
|
+
)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
271
|
+
process.exitCode = 1
|
|
272
|
+
}
|
|
273
|
+
}
|