@prestack/thread 0.1.20 → 0.1.22

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/dist/core/attach.js +21 -0
  3. package/dist/core/descend.d.ts +2 -0
  4. package/dist/core/descend.js +18 -3
  5. package/dist/core/generators/commit.js +55 -3
  6. package/dist/core/generators/dev.d.ts +1 -0
  7. package/dist/core/generators/dev.js +68 -0
  8. package/dist/core/rows.d.ts +5 -0
  9. package/dist/core/rows.js +45 -0
  10. package/dist/core/tree.d.ts +1 -0
  11. package/dist/core/tree.js +7 -1
  12. package/dist/derivation/generators/build.d.ts +3 -1
  13. package/dist/derivation/generators/build.js +103 -22
  14. package/dist/derivation/generators/release.js +74 -26
  15. package/dist/derivation/release.js +3 -3
  16. package/dist/integrations/cli.d.ts +3 -9
  17. package/dist/integrations/cli.js +96 -36
  18. package/dist/integrations/declared.d.ts +8 -0
  19. package/dist/integrations/declared.js +22 -0
  20. package/dist/integrations/pglite.js +13 -3
  21. package/dist/integrations/target.d.ts +4 -1
  22. package/dist/integrations/target.js +20 -6
  23. package/dist/integrity/attach.js +1 -5
  24. package/dist/integrity/generators/publish.js +2 -1
  25. package/dist/integrity/slopnators/sl_stale_path.js +10 -0
  26. package/dist/integrity/slopnators/sl_unlisted_program.js +2 -2
  27. package/dist/operations/access.js +3 -2
  28. package/dist/operations/capabilities.d.ts +2 -0
  29. package/dist/operations/capabilities.js +2 -0
  30. package/dist/operations/generators/capability.js +234 -24
  31. package/dist/operations/generators/compile.d.ts +21 -0
  32. package/dist/operations/generators/compile.js +151 -51
  33. package/dist/operations/generators/permits.d.ts +2 -1
  34. package/dist/operations/generators/permits.js +52 -47
  35. package/dist/operations/generators/seed.d.ts +3 -0
  36. package/dist/operations/generators/seed.js +25 -3
  37. package/dist/operations/nodes.d.ts +2 -2
  38. package/package.json +1 -1
  39. package/dist/core/actor.ts +0 -21
  40. package/dist/core/attach.ts +0 -613
  41. package/dist/core/chain.ts +0 -145
  42. package/dist/core/condition.ts +0 -227
  43. package/dist/core/descend.ts +0 -1173
  44. package/dist/core/errors/catalogue.ts +0 -314
  45. package/dist/core/errors/classes.ts +0 -51
  46. package/dist/core/errors/error.ts +0 -152
  47. package/dist/core/errors/namespaces.ts +0 -19
  48. package/dist/core/finding.ts +0 -37
  49. package/dist/core/generators/commit.ts +0 -157
  50. package/dist/core/init.ts +0 -222
  51. package/dist/core/invoked.ts +0 -52
  52. package/dist/core/round.ts +0 -74
  53. package/dist/core/rows.ts +0 -459
  54. package/dist/core/scope.ts +0 -20
  55. package/dist/core/thread.ts +0 -29
  56. package/dist/core/tree.ts +0 -90
  57. package/dist/core/types.ts +0 -351
  58. package/dist/derivation/attach.ts +0 -57
  59. package/dist/derivation/generators/build.ts +0 -371
  60. package/dist/derivation/generators/check.ts +0 -118
  61. package/dist/derivation/generators/determinism.ts +0 -145
  62. package/dist/derivation/generators/release.ts +0 -236
  63. package/dist/derivation/release.ts +0 -43
  64. package/dist/integrations/attach.ts +0 -187
  65. package/dist/integrations/cli.ts +0 -615
  66. package/dist/integrations/disk.ts +0 -466
  67. package/dist/integrations/generators/read.ts +0 -127
  68. package/dist/integrations/generators/vault.ts +0 -210
  69. package/dist/integrations/https.ts +0 -216
  70. package/dist/integrations/pglite.ts +0 -206
  71. package/dist/integrations/secret.ts +0 -54
  72. package/dist/integrations/target.ts +0 -656
  73. package/dist/integrations/vault.ts +0 -148
  74. package/dist/integrity/attach.ts +0 -150
  75. package/dist/integrity/check-threads.ts +0 -166
  76. package/dist/integrity/constraint.ts +0 -1246
  77. package/dist/integrity/generators/compiler.ts +0 -138
  78. package/dist/integrity/generators/gate.ts +0 -227
  79. package/dist/integrity/generators/issues.ts +0 -160
  80. package/dist/integrity/generators/publish.ts +0 -232
  81. package/dist/integrity/generators/trial.ts +0 -359
  82. package/dist/integrity/lint.ts +0 -214
  83. package/dist/integrity/lsp/bin.ts +0 -44
  84. package/dist/integrity/lsp/diagnostics.ts +0 -92
  85. package/dist/integrity/lsp/findings.ts +0 -63
  86. package/dist/integrity/lsp/lint.ts +0 -124
  87. package/dist/integrity/lsp/protocol.ts +0 -78
  88. package/dist/integrity/lsp/server.ts +0 -224
  89. package/dist/integrity/slopnator.ts +0 -324
  90. package/dist/integrity/slopnators/sl_broken_lineage.ts +0 -38
  91. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +0 -60
  92. package/dist/integrity/slopnators/sl_derived_drift.ts +0 -101
  93. package/dist/integrity/slopnators/sl_door_in_a_node.ts +0 -250
  94. package/dist/integrity/slopnators/sl_hardcoded_style.ts +0 -55
  95. package/dist/integrity/slopnators/sl_inert_island.ts +0 -84
  96. package/dist/integrity/slopnators/sl_inert_slopnator.ts +0 -93
  97. package/dist/integrity/slopnators/sl_missing_row.ts +0 -140
  98. package/dist/integrity/slopnators/sl_no_barrel.ts +0 -48
  99. package/dist/integrity/slopnators/sl_no_child_process.ts +0 -100
  100. package/dist/integrity/slopnators/sl_no_comment.ts +0 -43
  101. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +0 -276
  102. package/dist/integrity/slopnators/sl_no_import_cycle.ts +0 -66
  103. package/dist/integrity/slopnators/sl_no_such_command.ts +0 -81
  104. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +0 -63
  105. package/dist/integrity/slopnators/sl_outside_an_operation.ts +0 -25
  106. package/dist/integrity/slopnators/sl_screen_holds_only_declared.ts +0 -70
  107. package/dist/integrity/slopnators/sl_secret_literal.ts +0 -68
  108. package/dist/integrity/slopnators/sl_stale_path.ts +0 -178
  109. package/dist/integrity/slopnators/sl_twice_meant.ts +0 -57
  110. package/dist/integrity/slopnators/sl_twice_parented.ts +0 -82
  111. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +0 -481
  112. package/dist/integrity/slopnators/sl_undeclared_destination.ts +0 -113
  113. package/dist/integrity/slopnators/sl_undeclared_element.ts +0 -44
  114. package/dist/integrity/slopnators/sl_undeclared_list.ts +0 -358
  115. package/dist/integrity/slopnators/sl_undeclared_node.ts +0 -75
  116. package/dist/integrity/slopnators/sl_undeclared_operation.ts +0 -257
  117. package/dist/integrity/slopnators/sl_undeclared_variant.ts +0 -152
  118. package/dist/integrity/slopnators/sl_undeclared_webhook.ts +0 -108
  119. package/dist/integrity/slopnators/sl_underived_route.ts +0 -72
  120. package/dist/integrity/slopnators/sl_undescribed.ts +0 -65
  121. package/dist/integrity/slopnators/sl_ungoverned_island.ts +0 -60
  122. package/dist/integrity/slopnators/sl_unlisted_program.ts +0 -46
  123. package/dist/integrity/slopnators/sl_unnamed_island.ts +0 -41
  124. package/dist/integrity/slopnators/sl_unpaired_member.ts +0 -139
  125. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +0 -71
  126. package/dist/integrity/slopnators/sl_unprefixed_member.ts +0 -110
  127. package/dist/integrity/slopnators/sl_unthreaded.ts +0 -421
  128. package/dist/integrity/slopnators/sl_untranslated_key.ts +0 -140
  129. package/dist/integrity/slopnators/sl_unvaulted_secret.ts +0 -132
  130. package/dist/integrity/slopnators/sl_unversioned.ts +0 -52
  131. package/dist/integrity/sweep.ts +0 -395
  132. package/dist/knowledge/attach.ts +0 -327
  133. package/dist/knowledge/generators/docsgen.ts +0 -186
  134. package/dist/knowledge/generators/glossary.ts +0 -73
  135. package/dist/knowledge/glossary.ts +0 -6
  136. package/dist/operations/access.ts +0 -131
  137. package/dist/operations/attach.ts +0 -408
  138. package/dist/operations/capabilities.ts +0 -88
  139. package/dist/operations/dispatch.ts +0 -294
  140. package/dist/operations/error-results.ts +0 -31
  141. package/dist/operations/generators/capability.ts +0 -1054
  142. package/dist/operations/generators/compile.ts +0 -701
  143. package/dist/operations/generators/permits.ts +0 -224
  144. package/dist/operations/generators/seed.ts +0 -359
  145. package/dist/operations/generators/trial.ts +0 -666
  146. package/dist/operations/nodes.ts +0 -53
  147. package/dist/operations/operations.ts +0 -91
  148. package/dist/operations/result.ts +0 -28
@@ -1,371 +0,0 @@
1
- import { builtinModules, createRequire } from 'node:module'
2
- import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
3
- import { argv, cwd, stderr, stdout } from 'node:process'
4
- import { pathToFileURL } from 'node:url'
5
-
6
- import { build, initialize, stop, type BuildOptions, type Loader, type Plugin } from 'esbuild-wasm/esm/browser.js'
7
-
8
- import { descend, reached, type Pass } from '../../core/descend.ts'
9
- import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from '../../integrations/disk.ts'
10
- import { exit, invoked } from '../../core/invoked.ts'
11
- import { valuesIn } from '../../core/rows.ts'
12
- import { findTree } from '../../core/tree.ts'
13
- import type { Secret } from '../../integrations/secret.ts'
14
- import { servesBrowser, type Target } from '../../integrations/target.ts'
15
-
16
- const TREE = findTree()
17
-
18
- const ROOT = TREE.at
19
-
20
- const OUTSIDE = /(^|\/)node_modules(\/|$)/
21
-
22
- const BUILDS = 'thread build'
23
-
24
- const here = (path: string): string => relative(ROOT, path).split('\\').join('/')
25
-
26
- const LOADERS: Readonly<Record<string, Loader>> = {
27
- '.ts': 'ts',
28
- '.mts': 'ts',
29
- '.cts': 'ts',
30
- '.tsx': 'tsx',
31
- '.js': 'js',
32
- '.mjs': 'js',
33
- '.cjs': 'js',
34
- '.jsx': 'jsx',
35
- '.json': 'json',
36
- }
37
-
38
- const TRIED = Object.keys(LOADERS)
39
-
40
- const IMPORTED = ['import', 'module', 'node', 'default']
41
-
42
- const REQUIRED = ['require', 'module', 'node', 'default']
43
-
44
- const RUNTIME = new Set(builtinModules)
45
-
46
- const WASM = createRequire(import.meta.url).resolve('esbuild-wasm/esbuild.wasm')
47
-
48
- let compiled: WebAssembly.Module | undefined
49
-
50
- const filed = (at: string): boolean => statSync(at, { throwIfNoEntry: false })?.isFile() === true
51
-
52
- const inside = (top: string, at: string): boolean => {
53
- const said = relative(top, at)
54
- return !said.startsWith('..') && !isAbsolute(said)
55
- }
56
-
57
- function located(base: string): string | undefined {
58
- const found = [base, ...TRIED.map((one) => base + one), ...TRIED.map((one) => join(base, `index${one}`))].find(filed)
59
- return found === undefined ? undefined : realpathSync(found)
60
- }
61
-
62
- function chosen(held: unknown, conditions: readonly string[]): string | undefined {
63
- if (typeof held === 'string') return held
64
- if (Array.isArray(held)) return held.map((one: unknown) => chosen(one, conditions)).find((one) => one !== undefined)
65
- if (typeof held !== 'object' || held === null) return undefined
66
- for (const [condition, one] of Object.entries(held as Readonly<Record<string, unknown>>)) {
67
- if (!conditions.includes(condition)) continue
68
- const found = chosen(one, conditions)
69
- if (found !== undefined) return found
70
- }
71
- return undefined
72
- }
73
-
74
- function exported(held: unknown, sub: string, conditions: readonly string[]): string | undefined {
75
- if (typeof held !== 'object' || held === null || Array.isArray(held) || !Object.keys(held).some((one) => one.startsWith('.'))) {
76
- return sub === '.' ? chosen(held, conditions) : undefined
77
- }
78
- const paths = held as Readonly<Record<string, unknown>>
79
- if (sub in paths) return chosen(paths[sub], conditions)
80
- for (const [key, one] of Object.entries(paths)) {
81
- const star = key.indexOf('*')
82
- if (star === -1) continue
83
- const before = key.slice(0, star)
84
- const after = key.slice(star + 1)
85
- if (sub.length < key.length - 1 || !sub.startsWith(before) || !sub.endsWith(after)) continue
86
- return chosen(one, conditions)?.split('*').join(sub.slice(before.length, sub.length - after.length))
87
- }
88
- return undefined
89
- }
90
-
91
- function packaged(specifier: string, from: string, conditions: readonly string[], top: string): string | undefined {
92
- const name = specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
93
- const sub = `.${specifier.slice(name.length)}`
94
- for (let at = from; inside(top, at); at = dirname(at)) {
95
- const root = join(at, 'node_modules', name)
96
- const manifest = join(root, 'package.json')
97
- if (filed(manifest)) {
98
- const held = JSON.parse(readFileSync(manifest, 'utf8')) as { readonly exports?: unknown; readonly main?: unknown; readonly module?: unknown }
99
- if (held.exports !== undefined) {
100
- const found = exported(held.exports, sub, conditions)
101
- return found === undefined ? undefined : located(join(root, found))
102
- }
103
- const main = typeof held.main === 'string' ? held.main : typeof held.module === 'string' ? held.module : 'index.js'
104
- return located(join(root, sub === '.' ? main : sub))
105
- }
106
- if (at === top) return undefined
107
- }
108
- return undefined
109
- }
110
-
111
- const folder = (outside: boolean, top: string): Plugin => ({
112
- name: 'thread-folder',
113
- setup(held) {
114
- held.onResolve({ filter: /.*/ }, (args) => {
115
- const bare = !args.path.startsWith('.') && !isAbsolute(args.path)
116
- if (args.path.startsWith('node:') || RUNTIME.has(args.path)) return { path: args.path, external: true, sideEffects: false }
117
- if (bare && outside) return { path: args.path, external: true }
118
- const conditions = args.kind === 'require-call' || args.kind === 'require-resolve' ? REQUIRED : IMPORTED
119
- const path = resolve(args.resolveDir, args.path)
120
- const found = bare ? packaged(args.path, args.resolveDir, conditions, top) : inside(top, path) ? located(path) : undefined
121
- if (found !== undefined) return { path: found }
122
- if (args.kind === 'require-call') return { path: args.path, external: true }
123
- return {
124
- errors: [
125
- {
126
- text:
127
- `${args.path} is imported by ${args.importer === '' ? 'the entry' : args.importer}, and nothing in ${top} answers ` +
128
- 'to that name, so there is nothing to bundle in its place. A bundle reads only the folder it is built in',
129
- },
130
- ],
131
- }
132
- })
133
- held.onLoad({ filter: /.*/ }, (args) => ({
134
- contents: readFileSync(args.path, 'utf8'),
135
- loader: LOADERS[extname(args.path)] ?? 'js',
136
- resolveDir: dirname(args.path),
137
- }))
138
- },
139
- })
140
-
141
- export async function bundle(options: BuildOptions): Promise<string> {
142
- compiled ??= new WebAssembly.Module(Buffer.from(readFileSync(WASM, 'latin1'), 'latin1'))
143
- const hosted = globalThis as { self?: unknown }
144
- hosted.self ??= globalThis
145
- await initialize({ wasmModule: compiled, worker: false })
146
- try {
147
- const { packages, plugins, absWorkingDir = cwd(), ...rest } = options
148
- const made = await build({
149
- absWorkingDir,
150
- ...rest,
151
- bundle: true,
152
- write: false,
153
- plugins: [...(plugins ?? []), folder(packages === 'external', absWorkingDir)],
154
- })
155
- const text = made.outputFiles?.[0]?.text
156
- if (text === undefined) throw new Error('the bundle came back holding no file, so there is nothing to write where it goes')
157
- return text
158
- } finally {
159
- await stop()
160
- }
161
- }
162
-
163
- function wanted(pass: Pass): readonly { entry: string; out: string }[] {
164
-
165
- const owns = new Map<string, string>()
166
- for (const [at, threadings] of pass.threaded) {
167
- for (const one of threadings) {
168
- const id = one.row.id
169
- if (id !== undefined && !owns.has(id)) owns.set(id, at)
170
- }
171
- }
172
-
173
- const found: { entry: string; out: string }[] = []
174
- for (const [at, threadings] of pass.threaded) {
175
- for (const one of threadings) {
176
- if (one.list !== 'derives' || one.row.by !== BUILDS) continue
177
- const entry = owns.get(one.by)
178
- if (entry !== undefined) found.push({ entry, out: at })
179
- }
180
- }
181
- return found
182
- }
183
-
184
- function compilers(pass: Pass): readonly string[] {
185
- const found: string[] = []
186
- for (const [at, threadings] of pass.threaded) {
187
- for (const one of threadings) {
188
- if (one.list !== 'declares' || one.row.by !== BUILDS) continue
189
- if (!found.includes(at)) found.push(at)
190
- }
191
- }
192
- return found
193
- }
194
-
195
- const JSX = /\.(c|m)?(t|j)sx$/
196
-
197
- function browsing(entry: string): boolean {
198
- return JSX.test(entry)
199
- }
200
-
201
- function sheet<T>(file: string, list: string): readonly T[] {
202
- const path = resolve(ROOT, `${TREE.name}/${file}`)
203
- if (!existsSync(path)) return []
204
- try {
205
- const held = valuesIn(readFileSync(path, 'utf8')) as Record<string, readonly T[] | undefined>
206
- return held[list] ?? []
207
- } catch {
208
- return []
209
- }
210
- }
211
-
212
- function withheld(pass: Pass): ReadonlyMap<string, string> {
213
- const targets = sheet<Target>('targets.ts', 'TARGETS')
214
- if (targets.length === 0) return new Map()
215
- const secrets = sheet<Secret>('secrets.ts', 'SECRETS')
216
-
217
- const because = new Map<string, string>()
218
- for (const one of targets) {
219
- const said = servesBrowser(one, secrets)
220
- if (said !== true) because.set(one.id, said)
221
- }
222
- if (because.size === 0) return new Map()
223
-
224
- const guilty = new Map<string, string>()
225
- const consider = (row: { readonly id?: string; readonly requires?: readonly string[] }): void => {
226
- if (row.id === undefined || row.requires === undefined) return
227
- for (const word of row.requires) {
228
- const said = because.get(word)
229
- if (said === undefined) continue
230
- guilty.set(row.id, `\`${row.id}\` requires \`${word}\`, and that target cannot be reached from a page. ${said}`)
231
- }
232
- }
233
- for (const one of pass.stated.values()) consider(one)
234
- for (const held of pass.threaded.values()) for (const each of held) consider(each.row)
235
- if (guilty.size === 0) return new Map()
236
-
237
- const found = new Map<string, string>()
238
- for (const [at, held] of pass.threaded) {
239
- for (const each of held) {
240
- const said = guilty.get(each.by)
241
- if (said !== undefined) found.set(resolve(ROOT, at), said)
242
- }
243
- }
244
- return found
245
- }
246
-
247
- function withholding(refusals: ReadonlyMap<string, string>): {
248
- readonly name: string
249
- readonly setup: (build: { onLoad: (opts: { filter: RegExp }, run: (args: { path: string }) => unknown) => void }) => void
250
- } {
251
- return {
252
- name: 'thread-withheld',
253
- setup(build) {
254
- build.onLoad({ filter: /.*/ }, (args) => {
255
- const said = refusals.get(args.path)
256
- if (said === undefined) return undefined
257
- return {
258
- errors: [
259
- {
260
- text:
261
- `${said} This entry is JSX, so it is built for a browser, and everything a browser bundle ` +
262
- 'reaches is in the bundle. Move the operation behind something the page calls, or take the ' +
263
- 'target off its row.',
264
- },
265
- ],
266
- }
267
- })
268
- },
269
- }
270
- }
271
-
272
- function declared(): { readonly name: string; readonly setup: (build: { onLoad: (opts: { filter: RegExp }, run: (args: { path: string }) => unknown) => void }) => void } {
273
- const wired = new Set(reached())
274
- return {
275
- name: 'thread-declared',
276
- setup(held) {
277
- held.onLoad({ filter: /\.(c|m)?(t|j)sx?$/ }, (args) => {
278
- const path = here(args.path)
279
- if (OUTSIDE.test(path) || path.startsWith('..') || wired.has(path)) return undefined
280
- return {
281
- errors: [
282
- {
283
- text:
284
- `${path} is not declared — the descent from ${TREE.name}/thread.ts never reaches it, so nothing ` +
285
- 'in this app agreed it exists. Declare it from the row that owns it, or delete it. A file nothing ' +
286
- 'names cannot be part of what this app ships',
287
- },
288
- ],
289
- }
290
- })
291
- },
292
- }
293
- }
294
-
295
- async function main(): Promise<void> {
296
- const pass = descend()
297
- const all = wanted(pass)
298
- const checking = argv.includes('--check')
299
-
300
- const compiling: unknown[] = []
301
- for (const one of compilers(pass)) {
302
- const held = (await import(pathToFileURL(resolve(ROOT, one)).href)) as { plugin?: unknown }
303
- if (held.plugin === undefined) {
304
- stderr.write(
305
- `build: ${one} is declared \`by: '${BUILDS}'\` and exports no \`plugin\`, so the build has nothing to run ` +
306
- 'from it. A file that says it compiles and compiles nothing is worse than one that says neither.\n',
307
- )
308
- exit(1)
309
- }
310
- compiling.push(held.plugin)
311
- }
312
-
313
- if (all.length === 0) {
314
- if (checking) {
315
- stdout.write(`build: nothing in ${TREE.name}/ derives a file \`by: '${BUILDS}'\`, so there is no artifact to be out of date.\n`)
316
- return
317
- }
318
- stderr.write(
319
- `build: nothing in ${TREE.name}/ derives a file \`by: '${BUILDS}'\`, so this app states no artifact to build. ` +
320
- 'Declare one from the row that owns the entry, and the build reads its entry from that row.\n',
321
- )
322
- exit(1)
323
- }
324
-
325
- for (const { entry, out } of all) {
326
- const at = resolve(ROOT, out)
327
- const before = checking && existsSync(at) ? readFileSync(at, 'utf8') : undefined
328
-
329
- if (checking && before === undefined) {
330
- stderr.write(`build: ${out} is declared and not on disk. Run \`${BUILDS}\` and commit what it writes.\n`)
331
- exit(1)
332
- }
333
-
334
- const forBrowser = browsing(entry)
335
- const refusals = forBrowser ? withheld(pass) : new Map<string, string>()
336
-
337
- let held: string
338
- try {
339
- held = await bundle({
340
- entryPoints: [resolve(ROOT, entry)],
341
- format: 'esm',
342
- platform: forBrowser ? 'browser' : 'node',
343
- ...(forBrowser ? {} : { packages: 'external' as const }),
344
- outfile: at,
345
- plugins: [declared() as never, ...(refusals.size === 0 ? [] : [withholding(refusals) as never]), ...(compiling as never[])],
346
- logLevel: 'silent',
347
- })
348
- } catch (thrown) {
349
- stderr.write(`build: ${out} was not written — ${thrown instanceof Error ? thrown.message : String(thrown)}\n`)
350
- exit(1)
351
- }
352
-
353
- if (checking) {
354
- if (held !== before) {
355
- stderr.write(
356
- `build: ${out} is not what ${BUILDS} writes. A built artifact has no author — run \`${BUILDS}\` and commit the result.\n`,
357
- )
358
- exit(1)
359
- }
360
- stdout.write(`build: ${out} is up to date — ${String(new Set(reached()).size)} declared file(s) reachable.\n`)
361
- continue
362
- }
363
-
364
- writeFileSync(at, held, 'utf8')
365
- stdout.write(`build: wrote ${out} from ${entry} — ${String(new Set(reached()).size)} declared file(s) reachable.\n`)
366
- }
367
- }
368
-
369
- if (invoked(import.meta.url)) await main()
370
-
371
- export { declared, wanted }
@@ -1,118 +0,0 @@
1
- import { bodyHash, parseGenerated, type GeneratedFile } from './determinism.ts'
2
-
3
- export type CheckStatus = 'ok' | 'missing' | 'stale' | 'hand-edited' | 'orphaned'
4
-
5
- export interface FileCheck {
6
- readonly path: string
7
- readonly status: CheckStatus
8
- readonly detail: string
9
- }
10
-
11
- export interface CheckReport {
12
- readonly ok: boolean
13
- readonly checks: readonly FileCheck[]
14
- }
15
-
16
- export type ReadFile = (path: string) => string | null
17
-
18
- export interface CheckOptions {
19
-
20
- readonly previouslyGenerated?: readonly string[]
21
- }
22
-
23
- export function checkGenerated(
24
- expected: readonly GeneratedFile[],
25
- read: ReadFile,
26
- options: CheckOptions = {},
27
- ): CheckReport {
28
- const checks: FileCheck[] = []
29
- const expectedPaths = new Set(expected.map((file) => file.path))
30
-
31
- for (const file of expected) {
32
- const actual = read(file.path)
33
-
34
- if (actual === null) {
35
- checks.push({ path: file.path, status: 'missing', detail: 'not committed — run `thread generate`' })
36
- continue
37
- }
38
-
39
- if (actual === file.contents) {
40
- checks.push({ path: file.path, status: 'ok', detail: 'up to date' })
41
- continue
42
- }
43
-
44
- const parsed = parseGenerated(file.path, actual)
45
- if (parsed === null) {
46
- checks.push({
47
- path: file.path,
48
- status: 'hand-edited',
49
- detail: 'the generated header is gone — this file was replaced by hand',
50
- })
51
- continue
52
- }
53
-
54
- if (bodyHash(parsed.body) !== parsed.declaredHash) {
55
- checks.push({
56
- path: file.path,
57
- status: 'hand-edited',
58
- detail:
59
- 'the body no longer matches its own header hash. Move the change into ' +
60
- `${parsed.source} first — regenerating will discard it.`,
61
- })
62
- continue
63
- }
64
-
65
- checks.push({
66
- path: file.path,
67
- status: 'stale',
68
- detail: `out of date with ${parsed.source} — run \`thread generate\``,
69
- })
70
- }
71
-
72
- for (const path of options.previouslyGenerated ?? []) {
73
- if (expectedPaths.has(path)) continue
74
- checks.push({
75
- path,
76
- status: 'orphaned',
77
- detail: 'generation no longer owns this file — delete it',
78
- })
79
- }
80
-
81
- const sorted = checks.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
82
- return { ok: sorted.every((check) => check.status === 'ok'), checks: sorted }
83
- }
84
-
85
- export function formatCheckReport(report: CheckReport): string {
86
- if (report.ok) return `generated output is up to date (${report.checks.length} files).`
87
-
88
- const problems = report.checks.filter((check) => check.status !== 'ok')
89
- return [
90
- `${problems.length} of ${report.checks.length} generated file(s) need attention:`,
91
- ...problems.map((check) => ` ${check.status.padEnd(12)} ${check.path}\n ${check.detail}`),
92
- ].join('\n')
93
- }
94
-
95
- export function detectHandEdits(
96
- paths: readonly string[],
97
- read: ReadFile,
98
- ): readonly FileCheck[] {
99
- const edited: FileCheck[] = []
100
-
101
- for (const path of [...paths].sort()) {
102
- const contents = read(path)
103
- if (contents === null) continue
104
-
105
- const parsed = parseGenerated(path, contents)
106
- if (parsed === null) continue
107
-
108
- if (bodyHash(parsed.body) !== parsed.declaredHash) {
109
- edited.push({
110
- path,
111
- status: 'hand-edited',
112
- detail: `edited by hand. The change belongs in ${parsed.source}.`,
113
- })
114
- }
115
- }
116
-
117
- return edited
118
- }
@@ -1,145 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
-
3
- import { named } from '../../core/scope.ts'
4
-
5
- export interface GeneratedFile {
6
-
7
- readonly path: string
8
-
9
- readonly contents: string
10
- }
11
-
12
- export function sortedBy<T>(items: Iterable<T>, key: (item: T) => string): T[] {
13
- return [...items].sort((a, b) => compare(key(a), key(b)))
14
- }
15
-
16
- export function compare(a: string, b: string): number {
17
- return a < b ? -1 : a > b ? 1 : 0
18
- }
19
-
20
- export function sortedEntries<V>(record: Readonly<Record<string, V>>): [string, V][] {
21
- return Object.keys(record)
22
- .sort(compare)
23
- .map((key) => [key, record[key] as V])
24
- }
25
-
26
- const BANNER = `GENERATED by ${named('derivation')} — do not edit by hand.`
27
- const HASH_LABEL = 'codegen-hash:'
28
-
29
- interface HeaderSyntax {
30
- readonly wrap: (text: string) => string
31
- readonly unwrap: (line: string) => string | null
32
- }
33
-
34
- function headerSyntax(path: string): HeaderSyntax {
35
- if (path.endsWith('.md')) {
36
- return {
37
- wrap: (text) => `<!-- ${text} -->`,
38
- unwrap: (line) =>
39
- line.startsWith('<!-- ') && line.endsWith(' -->') ? line.slice(5, -4) : null,
40
- }
41
- }
42
- const prefix = path.endsWith('.sql') ? '--' : path.endsWith('.ts') || path.endsWith('.tsx') ? '//' : null
43
- if (prefix === null) {
44
- throw new Error(
45
- `${named('derivation')} emits only .ts, .sql and .md, received "${path}". ` +
46
- 'A format with no comment syntax cannot carry a header, and an unheadered generated ' +
47
- 'file is one somebody edits by hand.',
48
- )
49
- }
50
- return {
51
- wrap: (text) => `${prefix} ${text}`,
52
- unwrap: (line) => (line.startsWith(`${prefix} `) ? line.slice(prefix.length + 1) : null),
53
- }
54
- }
55
-
56
- export function bodyHash(body: string): string {
57
- return createHash('sha256').update(body, 'utf8').digest('hex')
58
- }
59
-
60
- const HEADER_LINES = 5
61
-
62
- export function withHeader(path: string, source: string, body: string): GeneratedFile {
63
- const { wrap } = headerSyntax(path)
64
- const normalized = body.endsWith('\n') ? body : `${body}\n`
65
-
66
- const header = [
67
- wrap(BANNER),
68
- wrap(`Source: ${source}`),
69
- wrap('Edit the island and re-run `thread generate`. CI fails when this file is stale.'),
70
- wrap(`${HASH_LABEL} ${bodyHash(normalized)}`),
71
- '',
72
- ]
73
-
74
- return { path, contents: `${header.join('\n')}\n${normalized}` }
75
- }
76
-
77
- export interface ParsedFile {
78
- readonly source: string
79
- readonly declaredHash: string
80
- readonly body: string
81
- }
82
-
83
- export function parseGenerated(path: string, contents: string): ParsedFile | null {
84
- let unwrap: HeaderSyntax['unwrap']
85
- try {
86
- unwrap = headerSyntax(path).unwrap
87
- } catch {
88
- return null
89
- }
90
- const lines = contents.split('\n')
91
-
92
- if (unwrap(lines[0] ?? '') !== BANNER) return null
93
-
94
- const sourceLine = unwrap(lines[1] ?? '')
95
- const hashLine = unwrap(lines[3] ?? '')
96
- if (sourceLine === null || !sourceLine.startsWith('Source: ')) return null
97
- if (hashLine === null || !hashLine.startsWith(`${HASH_LABEL} `)) return null
98
-
99
- return {
100
- source: sourceLine.slice('Source: '.length),
101
- declaredHash: hashLine.slice(HASH_LABEL.length + 1),
102
- body: lines.slice(HEADER_LINES).join('\n'),
103
- }
104
- }
105
-
106
- export function lines(...parts: readonly (string | readonly string[])[]): string {
107
- return parts.flat().join('\n').replace(/[ \t]+$/gm, '')
108
- }
109
-
110
- export function tsString(value: string): string {
111
- return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`
112
- }
113
-
114
- export function stableJson(value: unknown, indent = 0): string {
115
- const pad = ' '.repeat(indent)
116
- const padInner = ' '.repeat(indent + 1)
117
-
118
- if (value === null) return 'null'
119
- if (Array.isArray(value)) {
120
- if (value.length === 0) return '[]'
121
- return `[\n${value.map((item) => `${padInner}${stableJson(item, indent + 1)}`).join(',\n')}\n${pad}]`
122
- }
123
- if (typeof value === 'object') {
124
- const keys = Object.keys(value as Record<string, unknown>).sort()
125
- if (keys.length === 0) return '{}'
126
- return `{\n${keys
127
- .map((key) => `${padInner}${JSON.stringify(key)}: ${stableJson((value as Record<string, unknown>)[key], indent + 1)}`)
128
- .join(',\n')}\n${pad}}`
129
- }
130
- return JSON.stringify(value)
131
- }
132
-
133
- export function pascal(id: string): string {
134
- return id
135
- .replace(/^(ent_|fld_|rel_|wf_|role_)/, '')
136
- .split(/[_.]/)
137
- .filter(Boolean)
138
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
139
- .join('')
140
- }
141
-
142
- export function camel(id: string): string {
143
- const p = pascal(id)
144
- return p.charAt(0).toLowerCase() + p.slice(1)
145
- }