@prestack/thread 0.0.1 → 0.0.3
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/dist/core/actor.ts +20 -0
- package/dist/core/asking.d.ts +0 -1
- package/dist/core/asking.js +0 -1
- package/dist/core/asking.ts +1342 -0
- package/dist/core/attach.js +2 -2
- package/dist/core/attach.ts +585 -0
- package/dist/core/chain.js +8 -1
- package/dist/core/chain.ts +148 -0
- package/dist/core/cli.js +97 -18
- package/dist/core/cli.ts +2004 -0
- package/dist/core/condition.ts +227 -0
- package/dist/core/descend.d.ts +1 -0
- package/dist/core/descend.js +36 -0
- package/dist/core/descend.ts +1001 -0
- package/dist/core/finding.ts +38 -0
- package/dist/core/generators/meta.js +6 -0
- package/dist/core/generators/meta.ts +1006 -0
- package/dist/core/init.ts +170 -0
- package/dist/core/invoked.ts +47 -0
- package/dist/core/round.ts +57 -0
- package/dist/core/rows.ts +369 -0
- package/dist/core/scope.ts +18 -0
- package/dist/core/thread.ts +29 -0
- package/dist/core/tree.ts +67 -0
- package/dist/core/types.ts +350 -0
- package/dist/derivation/attach.js +1 -1
- package/dist/derivation/attach.ts +68 -0
- package/dist/derivation/generators/build.ts +232 -0
- package/dist/derivation/generators/check.ts +118 -0
- package/dist/derivation/generators/derivation.ts +347 -0
- package/dist/derivation/generators/determinism.ts +145 -0
- package/dist/derivation/generators/release.js +19 -9
- package/dist/derivation/generators/release.ts +213 -0
- package/dist/derivation/makers/release.ts +135 -0
- package/dist/derivation/release.ts +23 -0
- package/dist/integrations/attach.js +2 -2
- package/dist/integrations/attach.ts +207 -0
- package/dist/integrations/generators/read.ts +127 -0
- package/dist/integrations/generators/secret.ts +289 -0
- package/dist/integrations/generators/target.js +16 -6
- package/dist/integrations/generators/target.ts +944 -0
- package/dist/integrations/generators/vault.js +46 -14
- package/dist/integrations/generators/vault.ts +212 -0
- package/dist/integrations/{sdk.js → module.js} +4 -4
- package/dist/integrations/module.ts +189 -0
- package/dist/integrations/secret.ts +50 -0
- package/dist/integrations/target.d.ts +3 -1
- package/dist/integrations/target.js +14 -11
- package/dist/integrations/target.ts +340 -0
- package/dist/integrations/vault.d.ts +2 -0
- package/dist/integrations/vault.js +4 -2
- package/dist/integrations/vault.ts +136 -0
- package/dist/integrity/attach.ts +234 -0
- package/dist/integrity/check-threads.js +2 -26
- package/dist/integrity/check-threads.ts +166 -0
- package/dist/integrity/constraint.ts +1202 -0
- package/dist/integrity/generators/compiler.ts +161 -0
- package/dist/integrity/generators/gate.ts +201 -0
- package/dist/integrity/generators/issues.ts +160 -0
- package/dist/integrity/generators/publish.js +14 -5
- package/dist/integrity/generators/publish.ts +247 -0
- package/dist/integrity/generators/rule.ts +271 -0
- package/dist/integrity/generators/seal.ts +117 -0
- package/dist/integrity/generators/trial.ts +320 -0
- package/dist/integrity/lint.ts +227 -0
- package/dist/integrity/lsp/bin.ts +42 -0
- package/dist/integrity/lsp/diagnostics.ts +92 -0
- package/dist/integrity/lsp/findings.ts +63 -0
- package/dist/integrity/lsp/lint.ts +140 -0
- package/dist/integrity/lsp/protocol.ts +78 -0
- package/dist/integrity/lsp/server.ts +224 -0
- package/dist/integrity/sealed.js +2 -2
- package/dist/integrity/sealed.ts +56 -0
- package/dist/integrity/slopnator.ts +350 -0
- package/dist/integrity/slopnators/sl_broken_lineage.ts +36 -0
- package/dist/integrity/slopnators/sl_declared_elsewhere.ts +39 -0
- package/dist/integrity/slopnators/sl_derived_drift.ts +98 -0
- package/dist/integrity/slopnators/sl_hardcoded_style.ts +55 -0
- package/dist/integrity/slopnators/sl_inert_island.ts +91 -0
- package/dist/integrity/slopnators/sl_inert_slopnator.ts +93 -0
- package/dist/integrity/slopnators/sl_missing_row.ts +182 -0
- package/dist/integrity/slopnators/sl_no_barrel.ts +48 -0
- package/dist/integrity/slopnators/sl_no_child_process.ts +74 -0
- package/dist/integrity/slopnators/sl_no_comment.ts +43 -0
- package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +328 -0
- package/dist/integrity/slopnators/sl_no_import_cycle.ts +50 -0
- package/dist/integrity/slopnators/sl_no_such_command.ts +64 -0
- package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +63 -0
- package/dist/integrity/slopnators/sl_secret_literal.ts +100 -0
- package/dist/integrity/slopnators/sl_stale_path.ts +94 -0
- package/dist/integrity/slopnators/sl_twice_meant.ts +31 -0
- package/dist/integrity/slopnators/sl_twice_parented.ts +37 -0
- package/dist/integrity/slopnators/sl_undeclared_crossing.ts +87 -0
- package/dist/integrity/slopnators/sl_undeclared_destination.ts +65 -0
- package/dist/integrity/slopnators/sl_undeclared_element.ts +44 -0
- package/dist/integrity/slopnators/sl_undeclared_node.ts +69 -0
- package/dist/integrity/slopnators/sl_undeclared_operation.ts +147 -0
- package/dist/integrity/slopnators/sl_undeclared_variant.ts +148 -0
- package/dist/integrity/slopnators/sl_undescribed.ts +65 -0
- package/dist/integrity/slopnators/sl_ungoverned_island.ts +61 -0
- package/dist/integrity/slopnators/sl_unkept_rows.ts +111 -0
- package/dist/integrity/slopnators/sl_unnamed_island.ts +41 -0
- package/dist/integrity/slopnators/sl_unpaired_member.ts +131 -0
- package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +58 -0
- package/dist/integrity/slopnators/sl_unprefixed_member.ts +136 -0
- package/dist/integrity/slopnators/sl_unseamed_reach.ts +79 -0
- package/dist/integrity/slopnators/sl_unthreaded.ts +419 -0
- package/dist/integrity/slopnators/sl_untranslated_key.ts +164 -0
- package/dist/integrity/slopnators/sl_unversioned.ts +36 -0
- package/dist/integrity/sweep.ts +307 -0
- package/dist/knowledge/attach.ts +350 -0
- package/dist/knowledge/generators/docsgen.ts +174 -0
- package/dist/knowledge/generators/glossary.ts +73 -0
- package/dist/knowledge/generators/skills.ts +263 -0
- package/dist/knowledge/glossary.ts +6 -0
- package/dist/operations/access.ts +88 -0
- package/dist/operations/attach.ts +475 -0
- package/dist/operations/capabilities.ts +65 -0
- package/dist/operations/cost.ts +58 -0
- package/dist/operations/dispatch.ts +348 -0
- package/dist/operations/error-results.ts +31 -0
- package/dist/operations/errors/catalog.ts +168 -0
- package/dist/operations/errors/classes.ts +51 -0
- package/dist/operations/errors/errors.ts +314 -0
- package/dist/operations/generators/capability.js +29 -19
- package/dist/operations/generators/capability.ts +704 -0
- package/dist/operations/generators/compile.js +20 -1
- package/dist/operations/generators/compile.ts +643 -0
- package/dist/operations/generators/grant.ts +336 -0
- package/dist/operations/generators/operation.ts +972 -0
- package/dist/operations/generators/permits.ts +223 -0
- package/dist/operations/generators/seed.ts +347 -0
- package/dist/operations/generators/trial.js +4 -1
- package/dist/operations/generators/trial.ts +529 -0
- package/dist/operations/http.ts +164 -0
- package/dist/operations/makers/capability.ts +166 -0
- package/dist/operations/makers/trial.ts +520 -0
- package/dist/operations/memory.ts +470 -0
- package/dist/operations/metering.ts +328 -0
- package/dist/operations/nodes.ts +54 -0
- package/dist/operations/operations.ts +32 -0
- package/dist/operations/queue-policy.ts +10 -0
- package/dist/operations/result.ts +28 -0
- package/dist/operations/seams.d.ts +4 -4
- package/dist/operations/seams.ts +364 -0
- package/dist/operations/store.ts +29 -0
- package/package.json +8 -1
- package/dist/integrations/generators/sdk.d.ts +0 -1
- package/dist/integrations/generators/sdk.js +0 -148
- /package/dist/integrations/{sdk.d.ts → module.d.ts} +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { relative, resolve } from 'node:path'
|
|
3
|
+
import { argv, stderr, stdout } from 'node:process'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { build } from 'esbuild'
|
|
7
|
+
|
|
8
|
+
import { descend, reached, type Pass } from '../../core/descend.ts'
|
|
9
|
+
import { exit, invoked } from '../../core/invoked.ts'
|
|
10
|
+
import { findTree } from '../../core/tree.ts'
|
|
11
|
+
import type { Secret } from '../../integrations/secret.ts'
|
|
12
|
+
import { servesBrowser, type Target } from '../../integrations/target.ts'
|
|
13
|
+
|
|
14
|
+
const TREE = findTree()
|
|
15
|
+
|
|
16
|
+
const ROOT = TREE.at
|
|
17
|
+
|
|
18
|
+
const OUTSIDE = /(^|\/)node_modules(\/|$)/
|
|
19
|
+
|
|
20
|
+
const BUILDS = 'thread build'
|
|
21
|
+
|
|
22
|
+
const here = (path: string): string => relative(ROOT, path).split('\\').join('/')
|
|
23
|
+
|
|
24
|
+
function wanted(pass: Pass): readonly { entry: string; out: string }[] {
|
|
25
|
+
|
|
26
|
+
const owns = new Map<string, string>()
|
|
27
|
+
for (const [at, threadings] of pass.threaded) {
|
|
28
|
+
for (const one of threadings) {
|
|
29
|
+
const id = one.row.id
|
|
30
|
+
if (id !== undefined && !owns.has(id)) owns.set(id, at)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const found: { entry: string; out: string }[] = []
|
|
35
|
+
for (const [at, threadings] of pass.threaded) {
|
|
36
|
+
for (const one of threadings) {
|
|
37
|
+
if (one.list !== 'derives' || one.row.by !== BUILDS) continue
|
|
38
|
+
const entry = owns.get(one.by)
|
|
39
|
+
if (entry !== undefined) found.push({ entry, out: at })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return found
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compilers(pass: Pass): readonly string[] {
|
|
46
|
+
const found: string[] = []
|
|
47
|
+
for (const [at, threadings] of pass.threaded) {
|
|
48
|
+
for (const one of threadings) {
|
|
49
|
+
if (one.list !== 'declares' || one.row.by !== BUILDS) continue
|
|
50
|
+
if (!found.includes(at)) found.push(at)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const JSX = /\.(c|m)?(t|j)sx$/
|
|
57
|
+
|
|
58
|
+
function browsing(entry: string): boolean {
|
|
59
|
+
return JSX.test(entry)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sheet<T>(file: string, list: string): Promise<readonly T[]> {
|
|
63
|
+
const path = resolve(ROOT, `${TREE.name}/${file}`)
|
|
64
|
+
if (!existsSync(path)) return []
|
|
65
|
+
try {
|
|
66
|
+
const held = (await import(pathToFileURL(path).href)) as Record<string, readonly T[] | undefined>
|
|
67
|
+
return held[list] ?? []
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function withheld(pass: Pass): Promise<ReadonlyMap<string, string>> {
|
|
74
|
+
const targets = await sheet<Target>('targets.ts', 'TARGETS')
|
|
75
|
+
if (targets.length === 0) return new Map()
|
|
76
|
+
const secrets = await sheet<Secret>('secrets.ts', 'SECRETS')
|
|
77
|
+
|
|
78
|
+
const because = new Map<string, string>()
|
|
79
|
+
for (const one of targets) {
|
|
80
|
+
const said = servesBrowser(one, secrets)
|
|
81
|
+
if (said !== true) because.set(one.id, said)
|
|
82
|
+
}
|
|
83
|
+
if (because.size === 0) return new Map()
|
|
84
|
+
|
|
85
|
+
const guilty = new Map<string, string>()
|
|
86
|
+
const consider = (row: { readonly id?: string; readonly requires?: readonly string[] }): void => {
|
|
87
|
+
if (row.id === undefined || row.requires === undefined) return
|
|
88
|
+
for (const word of row.requires) {
|
|
89
|
+
const said = because.get(word)
|
|
90
|
+
if (said === undefined) continue
|
|
91
|
+
guilty.set(row.id, `\`${row.id}\` requires \`${word}\`, and that target cannot be reached from a page. ${said}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const one of pass.stated.values()) consider(one)
|
|
95
|
+
for (const held of pass.threaded.values()) for (const each of held) consider(each.row)
|
|
96
|
+
if (guilty.size === 0) return new Map()
|
|
97
|
+
|
|
98
|
+
const found = new Map<string, string>()
|
|
99
|
+
for (const [at, held] of pass.threaded) {
|
|
100
|
+
for (const each of held) {
|
|
101
|
+
const said = guilty.get(each.by)
|
|
102
|
+
if (said !== undefined) found.set(resolve(ROOT, at), said)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return found
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function withholding(refusals: ReadonlyMap<string, string>): {
|
|
109
|
+
readonly name: string
|
|
110
|
+
readonly setup: (build: { onLoad: (opts: { filter: RegExp }, run: (args: { path: string }) => unknown) => void }) => void
|
|
111
|
+
} {
|
|
112
|
+
return {
|
|
113
|
+
name: 'thread-withheld',
|
|
114
|
+
setup(build) {
|
|
115
|
+
build.onLoad({ filter: /.*/ }, (args) => {
|
|
116
|
+
const said = refusals.get(args.path)
|
|
117
|
+
if (said === undefined) return undefined
|
|
118
|
+
return {
|
|
119
|
+
errors: [
|
|
120
|
+
{
|
|
121
|
+
text:
|
|
122
|
+
`${said} This entry is JSX, so it is built for a browser, and everything a browser bundle ` +
|
|
123
|
+
'reaches is in the bundle. Move the operation behind something the page calls, or take the ' +
|
|
124
|
+
'target off its row.',
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function declared(): { readonly name: string; readonly setup: (build: { onLoad: (opts: { filter: RegExp }, run: (args: { path: string }) => unknown) => void }) => void } {
|
|
134
|
+
const wired = new Set(reached())
|
|
135
|
+
return {
|
|
136
|
+
name: 'thread-declared',
|
|
137
|
+
setup(held) {
|
|
138
|
+
held.onLoad({ filter: /\.(c|m)?(t|j)sx?$/ }, (args) => {
|
|
139
|
+
const path = here(args.path)
|
|
140
|
+
if (OUTSIDE.test(path) || path.startsWith('..') || wired.has(path)) return undefined
|
|
141
|
+
return {
|
|
142
|
+
errors: [
|
|
143
|
+
{
|
|
144
|
+
text:
|
|
145
|
+
`${path} is not declared — the descent from ${TREE.name}/thread.ts never reaches it, so nothing ` +
|
|
146
|
+
'in this app agreed it exists. Declare it from the row that owns it, or delete it. A file nothing ' +
|
|
147
|
+
'names cannot be part of what this app ships',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function main(): Promise<void> {
|
|
157
|
+
const pass = descend()
|
|
158
|
+
const all = wanted(pass)
|
|
159
|
+
const checking = argv.includes('--check')
|
|
160
|
+
|
|
161
|
+
const compiling: unknown[] = []
|
|
162
|
+
for (const one of compilers(pass)) {
|
|
163
|
+
const held = (await import(pathToFileURL(resolve(ROOT, one)).href)) as { plugin?: unknown }
|
|
164
|
+
if (held.plugin === undefined) {
|
|
165
|
+
stderr.write(
|
|
166
|
+
`build: ${one} is declared \`by: '${BUILDS}'\` and exports no \`plugin\`, so the build has nothing to run ` +
|
|
167
|
+
'from it. A file that says it compiles and compiles nothing is worse than one that says neither.\n',
|
|
168
|
+
)
|
|
169
|
+
exit(1)
|
|
170
|
+
}
|
|
171
|
+
compiling.push(held.plugin)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (all.length === 0) {
|
|
175
|
+
if (checking) {
|
|
176
|
+
stdout.write(`build: nothing in ${TREE.name}/ derives a file \`by: '${BUILDS}'\`, so there is no artifact to be out of date.\n`)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
stderr.write(
|
|
180
|
+
`build: nothing in ${TREE.name}/ derives a file \`by: '${BUILDS}'\`, so this app states no artifact to build. ` +
|
|
181
|
+
'Declare one from the row that owns the entry, and the build reads its entry from that row.\n',
|
|
182
|
+
)
|
|
183
|
+
exit(1)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const { entry, out } of all) {
|
|
187
|
+
const at = resolve(ROOT, out)
|
|
188
|
+
const before = checking && existsSync(at) ? readFileSync(at, 'utf8') : undefined
|
|
189
|
+
|
|
190
|
+
if (checking && before === undefined) {
|
|
191
|
+
stderr.write(`build: ${out} is declared and not on disk. Run \`${BUILDS}\` and commit what it writes.\n`)
|
|
192
|
+
exit(1)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const forBrowser = browsing(entry)
|
|
196
|
+
const refusals = forBrowser ? await withheld(pass) : new Map<string, string>()
|
|
197
|
+
|
|
198
|
+
const held = await build({
|
|
199
|
+
entryPoints: [resolve(ROOT, entry)],
|
|
200
|
+
bundle: true,
|
|
201
|
+
format: 'esm',
|
|
202
|
+
platform: forBrowser ? 'browser' : 'node',
|
|
203
|
+
...(forBrowser ? {} : { packages: 'external' as const }),
|
|
204
|
+
outfile: at,
|
|
205
|
+
write: !checking,
|
|
206
|
+
plugins: [declared() as never, ...(refusals.size === 0 ? [] : [withholding(refusals) as never]), ...(compiling as never[])],
|
|
207
|
+
logLevel: 'silent',
|
|
208
|
+
}).catch((thrown: unknown) => {
|
|
209
|
+
stderr.write(`build: ${out} was not written — ${thrown instanceof Error ? thrown.message : String(thrown)}\n`)
|
|
210
|
+
exit(1)
|
|
211
|
+
return undefined
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (checking) {
|
|
215
|
+
const now = held?.outputFiles?.[0]?.text
|
|
216
|
+
if (now !== before) {
|
|
217
|
+
stderr.write(
|
|
218
|
+
`build: ${out} is not what ${BUILDS} writes. A built artifact has no author — run \`${BUILDS}\` and commit the result.\n`,
|
|
219
|
+
)
|
|
220
|
+
exit(1)
|
|
221
|
+
}
|
|
222
|
+
stdout.write(`build: ${out} is up to date — ${String(new Set(reached()).size)} declared file(s) reachable.\n`)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
stdout.write(`build: wrote ${out} from ${entry} — ${String(new Set(reached()).size)} declared file(s) reachable.\n`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (invoked(import.meta.url)) await main()
|
|
231
|
+
|
|
232
|
+
export { declared, wanted }
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { argv, stdout } from 'node:process'
|
|
4
|
+
|
|
5
|
+
import { exit } from '../../core/invoked.ts'
|
|
6
|
+
import { ELSEWHERE, RECEIPT, answer, answering, asking, asksOf, given as pipedIn, guidance, issue, marked, named as answerNames, needed, only, ownAnswers, refuse, rowLines, seeding, spend, stating, within, wordsIn, type Asked } from '../../core/asking.ts'
|
|
7
|
+
import { holdsRow, listBounds, withRow, withoutRow } from '../../core/rows.ts'
|
|
8
|
+
import { NAMED } from '../../core/scope.ts'
|
|
9
|
+
import { findTree, inTree } from '../../core/tree.ts'
|
|
10
|
+
|
|
11
|
+
import { generators } from '../../core/chain.ts'
|
|
12
|
+
import { quoted } from '../../core/rows.ts'
|
|
13
|
+
|
|
14
|
+
const TREE = findTree()
|
|
15
|
+
|
|
16
|
+
const FILE = 'derivation.ts'
|
|
17
|
+
|
|
18
|
+
const LIST = 'GENERATORS'
|
|
19
|
+
|
|
20
|
+
const at = within(TREE.at, inTree(TREE, FILE))
|
|
21
|
+
|
|
22
|
+
const kebab = (one: string): string => one.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
23
|
+
|
|
24
|
+
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
25
|
+
|
|
26
|
+
const COMMAND = /^thread [a-z][a-z0-9]*(?:[ -][a-z0-9]+)*(?: --[a-z][a-z0-9-]*)*$/
|
|
27
|
+
|
|
28
|
+
export const ASKS: readonly Asked[] = asksOf(
|
|
29
|
+
'generator',
|
|
30
|
+
'Threaded',
|
|
31
|
+
{
|
|
32
|
+
name: {
|
|
33
|
+
guide: 'kebab-case, and it is the id of the row AND the file this seeds, e.g. manifest',
|
|
34
|
+
asks: 'name: ',
|
|
35
|
+
},
|
|
36
|
+
describes: {
|
|
37
|
+
guide: 'one sentence a reader who has not seen the code could act on',
|
|
38
|
+
asks: 'describes — one sentence a reader who has not seen the code could act on: ',
|
|
39
|
+
},
|
|
40
|
+
folder: {
|
|
41
|
+
guide: 'where the file goes, relative to this tree, e.g. src/generators',
|
|
42
|
+
asks: 'folder — where the file goes, e.g. src/generators: ',
|
|
43
|
+
},
|
|
44
|
+
by: {
|
|
45
|
+
guide: 'the command that WRITES it, e.g. thread manifest',
|
|
46
|
+
asks: 'by — the command that writes it, e.g. thread manifest: ',
|
|
47
|
+
optional: false,
|
|
48
|
+
},
|
|
49
|
+
runs: {
|
|
50
|
+
guide: 'the command that SETTLES it, e.g. thread manifest --check',
|
|
51
|
+
asks: 'runs — the command that settles it, blank for none: ',
|
|
52
|
+
},
|
|
53
|
+
body: {
|
|
54
|
+
guide: 'what this generator does, written out — every line until one holding only a full stop',
|
|
55
|
+
asks: 'body — what it derives, ended by a line holding only a full stop: ',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
['id', 'at', 'version'],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
export const ADDS: readonly Asked[] = asksOf(
|
|
62
|
+
'generator',
|
|
63
|
+
'Threaded',
|
|
64
|
+
{
|
|
65
|
+
at: {
|
|
66
|
+
guide: 'the path of a generator already on disk, relative to this tree, e.g. thread/integrity/generators/seal.ts',
|
|
67
|
+
asks: 'at — the generator already on disk: ',
|
|
68
|
+
},
|
|
69
|
+
name: {
|
|
70
|
+
guide: 'kebab-case, and it is the id of the row, e.g. seal-generator',
|
|
71
|
+
asks: 'name: ',
|
|
72
|
+
},
|
|
73
|
+
describes: {
|
|
74
|
+
guide: 'one sentence a reader who has not seen the code could act on',
|
|
75
|
+
asks: 'describes — one sentence a reader who has not seen the code could act on: ',
|
|
76
|
+
},
|
|
77
|
+
by: {
|
|
78
|
+
guide: 'the command that runs it, e.g. thread seal',
|
|
79
|
+
asks: 'by — the command that runs it, e.g. thread seal: ',
|
|
80
|
+
optional: false,
|
|
81
|
+
},
|
|
82
|
+
runs: {
|
|
83
|
+
guide: 'the command that SETTLES what it writes, e.g. thread seal --check',
|
|
84
|
+
asks: 'runs — the command that settles it, blank for none: ',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
['id', 'version'],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const said = (one: string, from: readonly Asked[] = ASKS): string => from.find((each) => each.at === one)?.asks ?? `${one}: `
|
|
91
|
+
|
|
92
|
+
const existing = await generators(TREE)
|
|
93
|
+
|
|
94
|
+
const mark = (one: { readonly id?: string; readonly at?: string; readonly describes?: string }): string =>
|
|
95
|
+
marked([one.id ?? '', one.at ?? '', one.describes ?? ''])
|
|
96
|
+
|
|
97
|
+
const listing = [
|
|
98
|
+
existing.length === 0
|
|
99
|
+
? 'no generators yet — nothing in this tree derives a file, so nothing here is written by a command.'
|
|
100
|
+
: `${String(existing.length)} generator(s) this tree already has. Read them before you declare another:`,
|
|
101
|
+
...existing.map(
|
|
102
|
+
(one) =>
|
|
103
|
+
` [${mark(one)}] ${one.id ?? '(NO ID)'} — written by \`${one.by ?? '(NOTHING)'}\` — ${one.describes ?? '(NO SENTENCE)'}`,
|
|
104
|
+
),
|
|
105
|
+
].join('\n')
|
|
106
|
+
|
|
107
|
+
const words = wordsIn(['new', 'add'])
|
|
108
|
+
|
|
109
|
+
stdout.write(`${listing}\n`)
|
|
110
|
+
|
|
111
|
+
const flagged = argv.indexOf(RECEIPT)
|
|
112
|
+
|
|
113
|
+
if (flagged === -1) {
|
|
114
|
+
const nonce = issue(TREE.at, listing)
|
|
115
|
+
stdout.write(
|
|
116
|
+
[
|
|
117
|
+
'',
|
|
118
|
+
...answering('generator'),
|
|
119
|
+
'',
|
|
120
|
+
...guidance(ASKS),
|
|
121
|
+
'',
|
|
122
|
+
` npx thread create generator new <name> ${RECEIPT} ${nonce}`,
|
|
123
|
+
'',
|
|
124
|
+
'or point a row at a generator that is already on disk — it writes no file:',
|
|
125
|
+
'',
|
|
126
|
+
...guidance(ADDS),
|
|
127
|
+
'',
|
|
128
|
+
` npx thread create generator add <path> ${RECEIPT} ${nonce}`,
|
|
129
|
+
'',
|
|
130
|
+
...ELSEWHERE,
|
|
131
|
+
'',
|
|
132
|
+
`${RECEIPT} is spent by one run, and is refused if ${at} changed after it was issued.`,
|
|
133
|
+
'',
|
|
134
|
+
'DO NOT DECLARE WHAT ALREADY EXISTS. The list above is every generator this tree has, each with ' +
|
|
135
|
+
'the sentence it states. Read it before you answer — a second generator writing the same file is ' +
|
|
136
|
+
'two owners for one address, and the tree keeps whichever ran last. If nothing above fits, say so ' +
|
|
137
|
+
'and stop rather than coining a name.',
|
|
138
|
+
'',
|
|
139
|
+
'Whatever you name as `runs:` becomes a step in `npx thread gate`, and the gate fails when it ' +
|
|
140
|
+
'fails. Leave it blank and nothing ever checks what this generator wrote — the file gets written ' +
|
|
141
|
+
'and nobody asks whether it is right.',
|
|
142
|
+
'',
|
|
143
|
+
'The body is the generator. You write it here and this command puts it at the address above, so the ' +
|
|
144
|
+
'command you named exists and answers from the moment this run finishes. Hand it nothing and this ' +
|
|
145
|
+
'refuses, because a generator that writes nothing cannot be told from one that had nothing to do.',
|
|
146
|
+
'',
|
|
147
|
+
].join('\n'),
|
|
148
|
+
)
|
|
149
|
+
stdout.write(`create: receipt ${nonce} — spent on use, and stale the moment ${at} changes.\n`)
|
|
150
|
+
exit(0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
spend(TREE.at, listing, argv[flagged + 1], at)
|
|
154
|
+
|
|
155
|
+
if (words[0] === 'add') {
|
|
156
|
+
const all = pipedIn()
|
|
157
|
+
const { piped, ask, line, shut } = asking(all)
|
|
158
|
+
const own = ownAnswers(all)
|
|
159
|
+
|
|
160
|
+
if (piped) only(own, answerNames(ADDS), '\`add\`')
|
|
161
|
+
|
|
162
|
+
const source = (words[1] ?? (piped ? needed(own, 'at', '\`add\`') : await ask('at', said('at', ADDS)))).trim().replace(/^\.?\/+/, '')
|
|
163
|
+
if (!existsSync(join(TREE.at, source))) {
|
|
164
|
+
shut()
|
|
165
|
+
refuse(`nothing is at ${source}. \`add\` points a row at a generator that is already on disk — it writes none.`)
|
|
166
|
+
}
|
|
167
|
+
if (existing.some((one) => one.at === source)) {
|
|
168
|
+
shut()
|
|
169
|
+
refuse(`${source} is already the address of a generator in ${at}. One address has one owner.`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const name = kebab(piped ? needed(own, 'name', '\`add\`') : await ask('name', said('name', ADDS)))
|
|
173
|
+
if (!KEBAB.test(name)) {
|
|
174
|
+
shut()
|
|
175
|
+
refuse(`\`${name}\` cannot be the id of a generator — it is kebab-case, e.g. seal-generator.`)
|
|
176
|
+
}
|
|
177
|
+
if (existing.some((one) => one.id === name)) {
|
|
178
|
+
shut()
|
|
179
|
+
refuse(`\`${name}\` is already declared in ${at}. One id, one generator — two would silently keep whichever ran second.`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const describes = piped ? needed(own, 'describes', '\`add\`') : await ask('describes', said('describes', ADDS))
|
|
183
|
+
if (describes === '') {
|
|
184
|
+
shut()
|
|
185
|
+
refuse(`\`${name}\` was given no meaning, and a row without one is a name nobody can read.`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const by = (piped ? needed(own, 'by', '\`add\`') : await ask('by', said('by', ADDS))).trim()
|
|
189
|
+
if (!COMMAND.test(by)) {
|
|
190
|
+
shut()
|
|
191
|
+
refuse(`\`${by}\` is not a command this tree can run — it reads \`thread <word>\`, e.g. thread seal.`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const runsSaid = (piped ? (answer(own, 'runs') ?? '') : await ask('runs', said('runs', ADDS))).trim()
|
|
195
|
+
if (runsSaid !== '' && !COMMAND.test(runsSaid)) {
|
|
196
|
+
shut()
|
|
197
|
+
refuse(`\`${runsSaid}\` is not a command this tree can run — it reads \`thread <word> [--flag]\`, e.g. thread seal --check.`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { stated, kids } = await stating(ADDS, all, piped, ask, line, ['name', 'id', 'at', 'describes', 'version', 'by', 'runs'])
|
|
201
|
+
|
|
202
|
+
shut()
|
|
203
|
+
|
|
204
|
+
const row = [
|
|
205
|
+
' {',
|
|
206
|
+
` id: ${quoted(name)},`,
|
|
207
|
+
` at: ${quoted(source)},`,
|
|
208
|
+
` describes: ${quoted(describes)},`,
|
|
209
|
+
' version: 1,',
|
|
210
|
+
` by: ${quoted(by)},`,
|
|
211
|
+
...(runsSaid === '' ? [] : [` runs: ${quoted(runsSaid)},`]),
|
|
212
|
+
...rowLines(' ', stated, kids),
|
|
213
|
+
' },',
|
|
214
|
+
].join('\n')
|
|
215
|
+
|
|
216
|
+
const path = inTree(TREE, FILE)
|
|
217
|
+
const text = readFileSync(path, 'utf8')
|
|
218
|
+
const where = listBounds(text, LIST)
|
|
219
|
+
if (where === undefined) {
|
|
220
|
+
refuse(`${at} states no \`${LIST}\` list to declare a generator in.`)
|
|
221
|
+
}
|
|
222
|
+
if (holdsRow(text, where, name)) refuse(`\`${name}\` is already declared in ${at}.`)
|
|
223
|
+
|
|
224
|
+
writeFileSync(path, withRow(text, where, row), 'utf8')
|
|
225
|
+
stdout.write(`create: declared ${name} in ${at}, pointing at ${source}, which it left untouched.\n`)
|
|
226
|
+
for (const told of seeding(kids, TREE.at)) stdout.write(`create: ${told}\n`)
|
|
227
|
+
stdout.write(`create: \`${by}\` exists from now on, because the CLI reads every \`by:\` this tree declares. Run it and ${source} is what answers.\n`)
|
|
228
|
+
exit(0)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const all = pipedIn()
|
|
232
|
+
const { piped, ask, line, shut } = asking(all)
|
|
233
|
+
|
|
234
|
+
const own = ownAnswers(all)
|
|
235
|
+
|
|
236
|
+
if (piped) only(own, answerNames(ASKS), '`new`')
|
|
237
|
+
|
|
238
|
+
const name = kebab(words[1] ?? (piped ? needed(own, 'name', '`new`') : await ask('name', said('name'))))
|
|
239
|
+
if (!KEBAB.test(name)) {
|
|
240
|
+
shut()
|
|
241
|
+
refuse(`\`${name}\` cannot be the id of a generator — it is kebab-case, e.g. manifest or release-config.`)
|
|
242
|
+
}
|
|
243
|
+
if (existing.some((one) => one.id === name)) {
|
|
244
|
+
shut()
|
|
245
|
+
refuse(
|
|
246
|
+
`\`${name}\` is already declared in ${at}. One id, one generator — two would silently keep whichever ran second.`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const describes = piped
|
|
251
|
+
? needed(own, 'describes', '`new`')
|
|
252
|
+
: await ask('describes', said('describes'))
|
|
253
|
+
if (describes === '') {
|
|
254
|
+
shut()
|
|
255
|
+
refuse(`\`${name}\` was given no meaning, and a row without one is a name nobody can read.`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const folderSaid = piped ? needed(own, 'folder', '`new`') : await ask('folder', said('folder'))
|
|
259
|
+
const folder = folderSaid.trim().replace(/^\.?\/+/, '').replace(/\/+$/, '')
|
|
260
|
+
if (folder === '') {
|
|
261
|
+
shut()
|
|
262
|
+
refuse(`\`${name}\` names no folder, so there is no address to seed it at and no path for its row to state.`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const by = (piped ? needed(own, 'by', '`new`') : await ask('by', said('by'))).trim()
|
|
266
|
+
if (!COMMAND.test(by)) {
|
|
267
|
+
shut()
|
|
268
|
+
refuse(
|
|
269
|
+
`\`${by}\` is not a command this tree can run — it reads \`thread <word>\`, e.g. thread manifest. ` +
|
|
270
|
+
'That string is what makes the command exist: the CLI builds its list from the `by:` every generator row states, ' +
|
|
271
|
+
'so a row with no `by:` declares a file nothing writes.',
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const runsSaid = (piped ? (answer(own, 'runs') ?? '') : await ask('runs', said('runs'))).trim()
|
|
276
|
+
if (runsSaid !== '' && !COMMAND.test(runsSaid)) {
|
|
277
|
+
shut()
|
|
278
|
+
refuse(`\`${runsSaid}\` is not a command this tree can run — it reads \`thread <word> [--flag]\`, e.g. thread manifest --check.`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const body = (piped ? needed(own, 'body', '`new`') : await ask('body', said('body'))).replace(/\s+$/, '')
|
|
282
|
+
if (body === '') {
|
|
283
|
+
shut()
|
|
284
|
+
refuse(
|
|
285
|
+
`\`${name}\` was given no body, so this would write a file that derives nothing. A generator that returns ` +
|
|
286
|
+
'having written nothing cannot be told from one that ran and had nothing left to do, and the settle chain ' +
|
|
287
|
+
'would go green on it. Write what it derives and run this again.',
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
const { stated, kids } = await stating(ASKS, all, piped, ask, line, ['name', 'id', 'at', 'describes', 'version', 'folder', 'by', 'runs', 'body'])
|
|
293
|
+
|
|
294
|
+
shut()
|
|
295
|
+
|
|
296
|
+
const source = `${folder}/${name}.ts`
|
|
297
|
+
|
|
298
|
+
if (existing.some((one) => one.at === source)) {
|
|
299
|
+
refuse(
|
|
300
|
+
`${source} is already the address of a generator in ${at}. One address has one owner, so declaring a ` +
|
|
301
|
+
'second would leave the tree keeping whichever wrote last.',
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const row = [
|
|
306
|
+
' {',
|
|
307
|
+
` id: ${quoted(name)},`,
|
|
308
|
+
` at: ${quoted(source)},`,
|
|
309
|
+
` describes: ${quoted(describes)},`,
|
|
310
|
+
' version: 1,',
|
|
311
|
+
` by: ${quoted(by)},`,
|
|
312
|
+
...(runsSaid === '' ? [] : [` runs: ${quoted(runsSaid)},`]),
|
|
313
|
+
...rowLines(' ', stated, kids),
|
|
314
|
+
' },',
|
|
315
|
+
].join('\n')
|
|
316
|
+
|
|
317
|
+
const path = inTree(TREE, FILE)
|
|
318
|
+
const text = readFileSync(path, 'utf8')
|
|
319
|
+
const where = listBounds(text, LIST)
|
|
320
|
+
if (where === undefined) {
|
|
321
|
+
refuse(
|
|
322
|
+
`${at} states no \`${LIST}\` list to declare a generator in. Run \`npx thread attach ${NAMED}\` — ` +
|
|
323
|
+
"the sheet is the package's to write, not yours.",
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (holdsRow(text, where, name)) refuse(`\`${name}\` is already declared in ${at}.`)
|
|
327
|
+
|
|
328
|
+
writeFileSync(path, withRow(text, where, row), 'utf8')
|
|
329
|
+
stdout.write(`create: declared ${name} in ${at}.\n`)
|
|
330
|
+
|
|
331
|
+
for (const told of seeding(kids, TREE.at)) stdout.write(`create: ${told}\n`)
|
|
332
|
+
|
|
333
|
+
const seeded = join(TREE.at, source)
|
|
334
|
+
if (existsSync(seeded)) {
|
|
335
|
+
stdout.write(`create: ${source} is already on disk — left untouched, and declared above.\n`)
|
|
336
|
+
} else {
|
|
337
|
+
mkdirSync(dirname(seeded), { recursive: true })
|
|
338
|
+
writeFileSync(seeded, `${body}\n`, 'utf8')
|
|
339
|
+
stdout.write(`create: wrote ${source} — ${String(body.split('\n').length)} line(s), the body you gave it.\n`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
stdout.write(
|
|
343
|
+
`create: \`${by}\` exists from now on, because the CLI reads every \`by:\` this tree declares — nothing had ` +
|
|
344
|
+
`to be added to a list of commands. Run it and ${source} is what answers.\n`,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
exit(0)
|