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.
@@ -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
+ }