@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,166 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { argv, exit, stdout } from 'node:process'
|
|
5
|
+
|
|
6
|
+
import { RECEIPT, answer, asking, given, issue, only, refuse, spend, wordsIn } from '../../core/asking.ts'
|
|
7
|
+
import { findTree } from '../../core/tree.ts'
|
|
8
|
+
|
|
9
|
+
import { CAPABILITIES_AT, SURFACES, isSurface, type Capability, type Surface } from '../capabilities.ts'
|
|
10
|
+
|
|
11
|
+
const TREE = findTree()
|
|
12
|
+
|
|
13
|
+
const at = resolve(TREE.at, CAPABILITIES_AT)
|
|
14
|
+
|
|
15
|
+
if (!existsSync(at)) {
|
|
16
|
+
refuse(
|
|
17
|
+
`${CAPABILITIES_AT} is not there, so this app has nothing reachable to switch. Declare an operation with ` +
|
|
18
|
+
'`thread create operation` — it writes this table on its way out — or run `thread capabilities`.',
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let table: readonly Capability[]
|
|
23
|
+
try {
|
|
24
|
+
table = JSON.parse(readFileSync(at, 'utf8')) as readonly Capability[]
|
|
25
|
+
} catch (thrown) {
|
|
26
|
+
refuse(
|
|
27
|
+
`${CAPABILITIES_AT} could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}. ` +
|
|
28
|
+
'Run `thread capabilities` — it is derived, so it can always be written again.',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (table.length === 0) {
|
|
33
|
+
refuse('this app declares no operation, so there is nothing to switch on or off yet.')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mark = (one: Capability): readonly string[] => [
|
|
37
|
+
` ${one.operation} — ${one.describes}`,
|
|
38
|
+
` ${SURFACES.map((each) => `${one.live.includes(each) ? '[on] ' : '[off]'} ${each}`).join(' ')}`,
|
|
39
|
+
` ${one.browsable ? 'may be called from a page' : 'server only — it reaches a target that needs a server secret'}`,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const listing = [`${String(table.length)} operation(s):`, ...table.flatMap(mark)].join('\n')
|
|
43
|
+
|
|
44
|
+
const words = wordsIn(['edit'])
|
|
45
|
+
|
|
46
|
+
const verb = words[0]
|
|
47
|
+
|
|
48
|
+
stdout.write(`${listing}\n`)
|
|
49
|
+
|
|
50
|
+
const ASKS: readonly { readonly key: string; readonly says: readonly string[] }[] = [
|
|
51
|
+
{
|
|
52
|
+
key: 'live',
|
|
53
|
+
says: [
|
|
54
|
+
'which surfaces are on, comma-separated. Answering nothing switches every',
|
|
55
|
+
'one of them off, which leaves the operation declared, compiled and proven',
|
|
56
|
+
'and reachable by nobody — a real thing to want, and said out loud rather',
|
|
57
|
+
'than refused.',
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const TAKES: readonly string[] = ASKS.map((one) => one.key)
|
|
63
|
+
|
|
64
|
+
const ASKED: readonly string[] = ASKS.flatMap((one) =>
|
|
65
|
+
one.says.map((line, at) => ` ${at === 0 ? `${one.key}:`.padEnd(8) : ' '.repeat(8)}${line}`),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const method = (nonce: string): string =>
|
|
69
|
+
[
|
|
70
|
+
'',
|
|
71
|
+
'what this changes:',
|
|
72
|
+
'',
|
|
73
|
+
` thread create capability edit <operation> ${RECEIPT} ${nonce}`,
|
|
74
|
+
'',
|
|
75
|
+
` ${RECEIPT} is spent by one run and refused if the table moved after it was issued.`,
|
|
76
|
+
'',
|
|
77
|
+
`a surface is a way this app is CALLED, and there are ${String(SURFACES.length)}: ${SURFACES.join(', ')}.`,
|
|
78
|
+
'',
|
|
79
|
+
' cli a command a terminal runs',
|
|
80
|
+
' api a route a request arrives on, and the OpenAPI document written from it',
|
|
81
|
+
' mcp a tool an agent is offered. Switch this off and an AI actor cannot call the operation,',
|
|
82
|
+
' while everything else about it goes on working',
|
|
83
|
+
'',
|
|
84
|
+
'`edit` asks, one answer per line:',
|
|
85
|
+
'',
|
|
86
|
+
...ASKED,
|
|
87
|
+
'',
|
|
88
|
+
'It writes the flag and runs `thread capabilities`, which carries every flag across and rewrites',
|
|
89
|
+
'the tool list, the OpenAPI document and the client bundle from them.',
|
|
90
|
+
].join('\n')
|
|
91
|
+
|
|
92
|
+
const flagged = argv.indexOf(RECEIPT)
|
|
93
|
+
|
|
94
|
+
if (verb === undefined && flagged === -1) {
|
|
95
|
+
const nonce = issue(TREE.at, listing)
|
|
96
|
+
stdout.write(`${method(nonce)}\n`)
|
|
97
|
+
stdout.write(`\ncapability: receipt ${nonce} — spent on use, and stale the moment ${CAPABILITIES_AT} changes.\n`)
|
|
98
|
+
exit(0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
spend(TREE.at, listing, argv[flagged + 1], CAPABILITIES_AT)
|
|
102
|
+
|
|
103
|
+
if (verb !== 'edit') {
|
|
104
|
+
refuse(`there is nothing called "${verb ?? ''}" to do here — say edit. A capability is derived, so there is none to add`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const which = words[1]
|
|
108
|
+
if (which === undefined) {
|
|
109
|
+
refuse('`edit` takes the name of an operation this app declares')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const one = table.find((each) => each.operation === which)
|
|
113
|
+
if (one === undefined) {
|
|
114
|
+
refuse(`\`${which}\` is not an operation this app declares. What is here is ${table.map((each) => each.operation).join(', ')}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const piped = given()
|
|
118
|
+
only(piped, TAKES, 'editing a capability')
|
|
119
|
+
const { ask, shut } = asking(piped)
|
|
120
|
+
|
|
121
|
+
const said = (
|
|
122
|
+
piped.length > 0
|
|
123
|
+
? (answer(piped, 'live') ?? '')
|
|
124
|
+
: await ask('live', `which surfaces are on — ${SURFACES.join(', ')} (now: ${one.live.join(', ') || 'none'}): `)
|
|
125
|
+
).trim()
|
|
126
|
+
shut()
|
|
127
|
+
|
|
128
|
+
const wanted = said
|
|
129
|
+
.split(',')
|
|
130
|
+
.map((each) => each.trim().toLowerCase())
|
|
131
|
+
.filter((each) => each !== '')
|
|
132
|
+
|
|
133
|
+
const wrong = wanted.filter((each) => !isSurface(each))
|
|
134
|
+
if (wrong.length > 0) {
|
|
135
|
+
refuse(
|
|
136
|
+
`there is no surface called ${wrong.map((each) => `"${each}"`).join(', ')}. There are ${String(SURFACES.length)}: ` +
|
|
137
|
+
`${SURFACES.join(', ')}. Nothing was written.`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const live = SURFACES.filter((each) => wanted.includes(each))
|
|
142
|
+
|
|
143
|
+
writeFileSync(
|
|
144
|
+
at,
|
|
145
|
+
`${JSON.stringify(
|
|
146
|
+
table.map((each) => (each.operation === one.operation ? { ...each, live } : each)),
|
|
147
|
+
null,
|
|
148
|
+
2,
|
|
149
|
+
)}\n`,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const off = SURFACES.filter((each) => !live.includes(each))
|
|
153
|
+
stdout.write(
|
|
154
|
+
`capability: ${one.operation} is live on ${live.join(', ') || 'nothing'}` +
|
|
155
|
+
`${off.length === 0 ? '' : `, and off for ${off.join(', ')}`}.\n`,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for (const what of [['capabilities'], ['docs']]) {
|
|
159
|
+
const made = spawnSync('npx', ['thread', ...what], { cwd: TREE.at, encoding: 'utf8' })
|
|
160
|
+
stdout.write(`${`${made.stdout ?? ''}${made.stderr ?? ''}`.trim()}\n`)
|
|
161
|
+
if (made.status !== 0) {
|
|
162
|
+
stdout.write(`capability: the flag is written and stands; \`thread ${what.join(' ')}\` refused above.\n`)
|
|
163
|
+
exit(1)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
exit(0)
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { relative, resolve } from 'node:path'
|
|
4
|
+
import { argv, exit, stderr, stdout } from 'node:process'
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import { answer, asking, given, only, refuse, slug, wordsIn } from '../../core/asking.ts'
|
|
8
|
+
import { generators, settling } from '../../core/chain.ts'
|
|
9
|
+
import { descend, shapeFields } from '../../core/descend.ts'
|
|
10
|
+
import { listBounds, withRow } from '../../core/rows.ts'
|
|
11
|
+
import { findTree, inTree } from '../../core/tree.ts'
|
|
12
|
+
|
|
13
|
+
import type { Call, Target } from '../../integrations/target.ts'
|
|
14
|
+
|
|
15
|
+
import { PUBLIC_ROLE } from '../../core/actor.ts'
|
|
16
|
+
|
|
17
|
+
import { declaredWords, undeclared } from '../access.ts'
|
|
18
|
+
import type { Operation } from '../operations.ts'
|
|
19
|
+
import { operations, types } from '../generators/seed.ts'
|
|
20
|
+
import type { Scenario } from '../generators/trial.ts'
|
|
21
|
+
|
|
22
|
+
const TREE = findTree()
|
|
23
|
+
|
|
24
|
+
const SCENARIOS = 'SCENARIOS'
|
|
25
|
+
|
|
26
|
+
import { quoted } from '../../core/rows.ts'
|
|
27
|
+
|
|
28
|
+
async function sheet<T>(file: string, list: string): Promise<readonly T[]> {
|
|
29
|
+
const at = inTree(TREE, file)
|
|
30
|
+
if (!existsSync(at)) return []
|
|
31
|
+
try {
|
|
32
|
+
const held = (await import(pathToFileURL(at).href)) as Record<string, readonly T[] | undefined>
|
|
33
|
+
return held[list] ?? []
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pass = descend()
|
|
40
|
+
const known = types(pass)
|
|
41
|
+
|
|
42
|
+
interface Held {
|
|
43
|
+
readonly id: string
|
|
44
|
+
readonly describes: string
|
|
45
|
+
readonly requires: readonly string[]
|
|
46
|
+
readonly takes: string
|
|
47
|
+
readonly body: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const declared: readonly Held[] = [...operations(pass, known)]
|
|
51
|
+
.map(([id, one]): Held | undefined => {
|
|
52
|
+
const takes = one.takes[0]?.at
|
|
53
|
+
if (takes === undefined || one.body === undefined) return undefined
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
describes: pass.stated.get(id)?.describes ?? '',
|
|
57
|
+
requires: one.requires,
|
|
58
|
+
takes,
|
|
59
|
+
body: one.body,
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.filter((one): one is Held => one !== undefined)
|
|
63
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
64
|
+
|
|
65
|
+
if (declared.length === 0) {
|
|
66
|
+
refuse('this app declares no compiled operation, so there is nothing to run. Make one with `thread create operation`.')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function scenarios(takes: string): Promise<readonly Scenario[]> {
|
|
70
|
+
const at = resolve(TREE.at, takes)
|
|
71
|
+
if (!existsSync(at)) return []
|
|
72
|
+
try {
|
|
73
|
+
const held = (await import(pathToFileURL(at).href)) as { readonly SCENARIOS?: readonly Scenario[] }
|
|
74
|
+
return held.SCENARIOS ?? []
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const already = new Map<string, readonly Scenario[]>()
|
|
81
|
+
for (const one of declared) already.set(one.id, await scenarios(one.takes))
|
|
82
|
+
|
|
83
|
+
const listing = [
|
|
84
|
+
`${String(declared.length)} operation(s):`,
|
|
85
|
+
...declared.flatMap((one) => {
|
|
86
|
+
const mine = already.get(one.id) ?? []
|
|
87
|
+
return [
|
|
88
|
+
` ${one.id} — ${one.describes}`,
|
|
89
|
+
` requires ${one.requires.join(', ') || 'nothing'}`,
|
|
90
|
+
` ${mine.length === 0 ? 'no scenario yet — nothing has ever run it' : `tried with ${mine.map((each) => each.id).join(', ')}`}`,
|
|
91
|
+
]
|
|
92
|
+
}),
|
|
93
|
+
].join('\n')
|
|
94
|
+
|
|
95
|
+
interface Declaring {
|
|
96
|
+
readonly id: string
|
|
97
|
+
readonly holds: readonly { readonly id: string; readonly options?: readonly string[] }[]
|
|
98
|
+
readonly rows?: readonly Record<string, unknown>[]
|
|
99
|
+
readonly must?: readonly { readonly unique?: readonly string[] }[]
|
|
100
|
+
readonly actor?: { readonly roles: string; readonly entitlements?: string }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const entities = await sheet<Declaring>('entities.ts', 'ENTITIES')
|
|
104
|
+
|
|
105
|
+
const keyIn = (one: Declaring): string | undefined =>
|
|
106
|
+
(one.must ?? []).flatMap((each) => (each.unique?.length === 1 ? [each.unique[0] as string] : []))[0]
|
|
107
|
+
|
|
108
|
+
const wordsFrom = (held: unknown): readonly string[] => {
|
|
109
|
+
if (typeof held === 'string') return [held]
|
|
110
|
+
if (Array.isArray(held)) return held.filter((each): each is string => typeof each === 'string')
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Caller {
|
|
115
|
+
readonly id: string
|
|
116
|
+
readonly entity: string
|
|
117
|
+
readonly roles: readonly string[]
|
|
118
|
+
readonly entitlements: readonly string[]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const casting: readonly Caller[] = entities.flatMap((one) => {
|
|
122
|
+
const said = one.actor
|
|
123
|
+
const key = keyIn(one)
|
|
124
|
+
if (said === undefined || key === undefined) return []
|
|
125
|
+
return (one.rows ?? []).flatMap((row): readonly Caller[] => {
|
|
126
|
+
const named = row[key]
|
|
127
|
+
if (typeof named !== 'string') return []
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
id: named,
|
|
131
|
+
entity: one.id,
|
|
132
|
+
roles: wordsFrom(row[said.roles]),
|
|
133
|
+
entitlements: said.entitlements === undefined ? [] : wordsFrom(row[said.entitlements]),
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const callers: readonly string[] = casting.map(
|
|
140
|
+
(one) =>
|
|
141
|
+
` ${one.id} — a ${one.entity}, ${one.roles.join(' and ') || 'holding no role'}` +
|
|
142
|
+
`${one.entitlements.length === 0 ? '' : `, entitled to ${one.entitlements.join(' and ')}`}`,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
interface Seeded {
|
|
146
|
+
readonly id: string
|
|
147
|
+
readonly entity: string
|
|
148
|
+
readonly row: Record<string, unknown>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const seedable: readonly Seeded[] = entities.flatMap((one) => {
|
|
152
|
+
const key = keyIn(one)
|
|
153
|
+
if (key === undefined) return []
|
|
154
|
+
return (one.rows ?? []).flatMap((row): readonly Seeded[] => {
|
|
155
|
+
const named = row[key]
|
|
156
|
+
return typeof named === 'string' ? [{ id: named, entity: one.id, row }] : []
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const words = wordsIn([])
|
|
161
|
+
|
|
162
|
+
stdout.write(`${listing}\n`)
|
|
163
|
+
|
|
164
|
+
const SAYS = new Map<string, string>([
|
|
165
|
+
['id', 'what to call this scenario'],
|
|
166
|
+
['describes', 'what this scenario is, in one sentence somebody who has not seen it could act on'],
|
|
167
|
+
['input', 'the input to run the operation with, as JSON — blank for {}'],
|
|
168
|
+
['actor', 'who is running it — name a declared row of an entity that says who its callers are'],
|
|
169
|
+
['rows', 'rows this starts from — name declared rows, comma separated, blank for an empty store'],
|
|
170
|
+
['answers', 'what each declared call answers with — a call nothing answers gets a 404'],
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
const SCENARIO = fileURLToPath(new URL('../generators/trial.ts', import.meta.url))
|
|
174
|
+
|
|
175
|
+
const SHAPE = shapeFields('Scenario', relative(TREE.at, SCENARIO).split('\\').join('/'))
|
|
176
|
+
|
|
177
|
+
if (SHAPE.length === 0) {
|
|
178
|
+
refuse(
|
|
179
|
+
'the shape of a scenario could not be read, so what this command may ask for is unknown. Asking anyway ' +
|
|
180
|
+
'would write a row against a shape nothing checked — which is how a scenario ends up stating fields no ' +
|
|
181
|
+
'trial reads, and fields the runner reads that nothing ever wrote.',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ASKS: readonly { readonly key: string; readonly says: string }[] = SHAPE.filter((one) =>
|
|
186
|
+
SAYS.has(one.name),
|
|
187
|
+
).map((one) => ({ key: one.name, says: SAYS.get(one.name) ?? '' }))
|
|
188
|
+
|
|
189
|
+
const UNASKED: readonly string[] = SHAPE.filter((one) => !SAYS.has(one.name)).map((one) => one.name)
|
|
190
|
+
|
|
191
|
+
const TAKES: readonly string[] = ASKS.map((one) => one.key)
|
|
192
|
+
|
|
193
|
+
const ASKED: readonly string[] = ASKS.map((one) => ` ${`${one.key}:`.padEnd(16)}${one.says}`)
|
|
194
|
+
|
|
195
|
+
const method = (): string =>
|
|
196
|
+
[
|
|
197
|
+
'',
|
|
198
|
+
'what this runs:',
|
|
199
|
+
'',
|
|
200
|
+
' thread run trial <operation>',
|
|
201
|
+
'',
|
|
202
|
+
'it takes, as a flag on one command:',
|
|
203
|
+
'',
|
|
204
|
+
` thread run trial <operation> --<answer> <value> ... `,
|
|
205
|
+
'',
|
|
206
|
+
'That form needs no terminal, which is what makes it the one that always works. One answer to a',
|
|
207
|
+
'line on stdin does the same, and so does a file of them named by `--answers <path>`.',
|
|
208
|
+
'',
|
|
209
|
+
'the answers:',
|
|
210
|
+
'',
|
|
211
|
+
...ASKED,
|
|
212
|
+
'',
|
|
213
|
+
'It asks for the input to try the operation with, checks it against that operation\'s own schema',
|
|
214
|
+
'before writing anything, adds the scenario to the file the operation already takes, and then runs',
|
|
215
|
+
'`thread trials` — which hands the compiled function to this app\'s own dispatch and records',
|
|
216
|
+
'everything the run did.',
|
|
217
|
+
'',
|
|
218
|
+
'DO NOT INVENT A CALLER. `actor` names a row this app already declares, and the roles and',
|
|
219
|
+
'entitlements that row holds are read off it. There is no field here for typing a role, because a',
|
|
220
|
+
'role is one of the options on the column an entity names as saying what its callers may do — the',
|
|
221
|
+
'app declares the closed set and this command holds you to it. Naming somebody who is not declared',
|
|
222
|
+
'is refused, and the refusal lists who IS. Blank leaves the scenario with no actor at all, which is',
|
|
223
|
+
'the right answer for an operation anybody may run, and the wrong one for an operation with a grant.',
|
|
224
|
+
'',
|
|
225
|
+
'Same for `rows`. Name rows the entities already declare. A store put into a state nobody declared',
|
|
226
|
+
'is a trial proving something about data that does not exist anywhere else in this app.',
|
|
227
|
+
'',
|
|
228
|
+
...(callers.length === 0
|
|
229
|
+
? [
|
|
230
|
+
'This app declares no entity that says who its callers are, so there is nobody to name and every',
|
|
231
|
+
'scenario here runs anonymous. An entity says so by naming the column its roles come from.',
|
|
232
|
+
'',
|
|
233
|
+
]
|
|
234
|
+
: ['who this app declares as callers:', '', ...callers, '']),
|
|
235
|
+
'An operation that reaches a declared target is asked one more thing: what each of its calls',
|
|
236
|
+
'answers with. That is the only part of an outward call nothing can derive — the verb, the path,',
|
|
237
|
+
'the address and the credential all come off rows. A call nothing answers gets a 404, which is a',
|
|
238
|
+
'real answer about an operation that did not handle one.',
|
|
239
|
+
'',
|
|
240
|
+
'Running it again with the same operation adds another scenario rather than replacing one. A',
|
|
241
|
+
'recording is evidence, and evidence is not overwritten because somebody ran the command twice.',
|
|
242
|
+
...(UNASKED.length === 0
|
|
243
|
+
? []
|
|
244
|
+
: [
|
|
245
|
+
'',
|
|
246
|
+
`A scenario also states ${UNASKED.join(', ')}, and this command cannot write ${UNASKED.length === 1 ? 'it' : 'them'}.`,
|
|
247
|
+
'The trial runner reads what is there. Nothing here can put it there, so no scenario this command',
|
|
248
|
+
'writes will ever carry one — said out loud because a field nobody can reach reads as a field',
|
|
249
|
+
'nobody needs, and that is how four of them sat unwritable without anyone noticing.',
|
|
250
|
+
]),
|
|
251
|
+
].join('\n')
|
|
252
|
+
|
|
253
|
+
const which = words[0]
|
|
254
|
+
|
|
255
|
+
if (which === undefined) {
|
|
256
|
+
stdout.write(`${method()}\n`)
|
|
257
|
+
exit(1)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const one = declared.find((each) => each.id === which)
|
|
261
|
+
if (one === undefined) {
|
|
262
|
+
refuse(`\`${which}\` is not a compiled operation of this app. What is here is ${declared.map((each) => each.id).join(', ')}`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const from = resolve(TREE.at, one.takes)
|
|
266
|
+
if (!existsSync(from)) {
|
|
267
|
+
refuse(`\`${one.id}\` takes ${one.takes} and that file is not there. Run \`thread seed\`.`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const taking = (await import(pathToFileURL(from).href)) as {
|
|
271
|
+
readonly input?: { readonly safeParse: (value: unknown) => { readonly success: boolean; readonly error?: unknown } }
|
|
272
|
+
}
|
|
273
|
+
const schema = taking.input
|
|
274
|
+
if (schema === undefined) {
|
|
275
|
+
refuse(`${one.takes} exports no \`input\`, so nothing states what \`${one.id}\` takes. Run \`thread seed\`.`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const targets = await sheet<Target>('targets.ts', 'TARGETS')
|
|
279
|
+
const calls = await sheet<Call>('targets.ts', 'CALLS')
|
|
280
|
+
const reachable = targets
|
|
281
|
+
.filter((each) => one.requires.includes(each.id))
|
|
282
|
+
.flatMap((each) => calls.filter((call) => call.of === each.id && each.takes.includes(call.id)))
|
|
283
|
+
|
|
284
|
+
const piped = given()
|
|
285
|
+
only(piped, TAKES, 'a trial')
|
|
286
|
+
const { ask, shut } = asking(piped)
|
|
287
|
+
|
|
288
|
+
const mine = already.get(one.id) ?? []
|
|
289
|
+
const suggested = `s${String(mine.length + 1)}`
|
|
290
|
+
const named = slug(piped.length > 0 ? (answer(piped, 'id') ?? suggested) : (await ask('id', `what to call this scenario (blank for "${suggested}"): `)) || suggested)
|
|
291
|
+
if (mine.some((each) => each.id === named)) {
|
|
292
|
+
shut()
|
|
293
|
+
refuse(`\`${one.id}\` already has a scenario called \`${named}\`, and a trial is named after the one it ran`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const meaning = (
|
|
297
|
+
piped.length > 0
|
|
298
|
+
? (answer(piped, 'describes') ?? '')
|
|
299
|
+
: await ask('describes', 'what this scenario is, in one sentence: ')
|
|
300
|
+
).trim()
|
|
301
|
+
|
|
302
|
+
if (meaning === '') {
|
|
303
|
+
shut()
|
|
304
|
+
refuse(
|
|
305
|
+
`a scenario states what it is, and \`${named}\` is only a name. Every declared thing here carries a ` +
|
|
306
|
+
'`describes`, because the word on its own tells the next person nothing about which scenario they are ' +
|
|
307
|
+
'looking at or why it was worth recording. Say what world this run is given and what it is meant to show.',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const typed = (
|
|
312
|
+
piped.length > 0
|
|
313
|
+
? (answer(piped, 'input') ?? '')
|
|
314
|
+
: await ask('input', `the input to run ${one.id} with, as JSON (blank for {}): `)
|
|
315
|
+
).trim()
|
|
316
|
+
|
|
317
|
+
let value: unknown
|
|
318
|
+
try {
|
|
319
|
+
value = typed === '' ? {} : JSON.parse(typed)
|
|
320
|
+
} catch (thrown) {
|
|
321
|
+
shut()
|
|
322
|
+
refuse(`that is not JSON: ${thrown instanceof Error ? thrown.message : String(thrown)}. Nothing was written.`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const held = schema.safeParse(value)
|
|
326
|
+
if (!held.success) {
|
|
327
|
+
shut()
|
|
328
|
+
refuse(
|
|
329
|
+
`\`${one.id}\` would refuse that input, so a trial of it would record a refusal and prove nothing: ` +
|
|
330
|
+
`${JSON.stringify(held.error)}. Nothing was written.`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const listed = (said: string): readonly string[] =>
|
|
335
|
+
said
|
|
336
|
+
.split(',')
|
|
337
|
+
.map((one) => one.trim())
|
|
338
|
+
.filter((one) => one !== '')
|
|
339
|
+
|
|
340
|
+
const running = (
|
|
341
|
+
piped.length > 0
|
|
342
|
+
? (answer(piped, 'actor') ?? '')
|
|
343
|
+
: await ask('actor', 'who is running it — name a declared row (blank for nobody, which is anonymous): ')
|
|
344
|
+
).trim()
|
|
345
|
+
|
|
346
|
+
if (running !== '' && casting.length === 0) {
|
|
347
|
+
shut()
|
|
348
|
+
refuse(
|
|
349
|
+
`this app declares nobody to run \`${one.id}\` as. An entity says who its callers are by naming the ` +
|
|
350
|
+
'column its roles come from, and then declaring rows on it. Until one does, every scenario here runs ' +
|
|
351
|
+
'anonymous, and naming somebody would be inventing them.',
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const played = casting.find((each) => each.id === running)
|
|
356
|
+
|
|
357
|
+
if (running !== '' && played === undefined) {
|
|
358
|
+
shut()
|
|
359
|
+
refuse(
|
|
360
|
+
`this app declares no caller called \`${running}\` — it declares ` +
|
|
361
|
+
`${casting.map((each) => `\`${each.id}\``).join(', ')}. A caller is a row an entity declares, not a name ` +
|
|
362
|
+
'to type here: the roles it holds are read off that row, so somebody invented at this prompt would carry ' +
|
|
363
|
+
'roles this app never granted and the trial would prove access that does not exist.',
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (played !== undefined) {
|
|
368
|
+
for (const which of ['roles', 'entitlements'] as const) {
|
|
369
|
+
const wrong = undeclared(played[which].join(','), declaredWords(entities, which, PUBLIC_ROLE), which)
|
|
370
|
+
if (wrong !== undefined) {
|
|
371
|
+
shut()
|
|
372
|
+
refuse(
|
|
373
|
+
`\`${played.id}\` is a declared row of \`${played.entity}\` and ${wrong}. The row holds a word its own ` +
|
|
374
|
+
'column does not offer, so running as it would prove access against a role this app never declared. ' +
|
|
375
|
+
'Fix the row, not the scenario. Nothing was written.',
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const actor =
|
|
382
|
+
played === undefined
|
|
383
|
+
? undefined
|
|
384
|
+
: { id: played.id, roles: [...played.roles], entitlements: [...played.entitlements] }
|
|
385
|
+
|
|
386
|
+
const seeded = (
|
|
387
|
+
piped.length > 0
|
|
388
|
+
? (answer(piped, 'rows') ?? '')
|
|
389
|
+
: await ask('rows', 'rows this starts from — declared rows, comma separated, blank for an empty store: ')
|
|
390
|
+
).trim()
|
|
391
|
+
|
|
392
|
+
const wanted = listed(seeded)
|
|
393
|
+
|
|
394
|
+
const stray = wanted.filter((each) => !seedable.some((one) => one.id === each))
|
|
395
|
+
if (stray.length > 0) {
|
|
396
|
+
shut()
|
|
397
|
+
refuse(
|
|
398
|
+
`this app declares no row called ${stray.map((each) => `\`${each}\``).join(', ')} — ` +
|
|
399
|
+
(seedable.length === 0
|
|
400
|
+
? 'it declares none at all. A store starts from rows the entities themselves declare, so that what a ' +
|
|
401
|
+
'trial proves is about data this app really has. Declare them with `thread create entity edit`.'
|
|
402
|
+
: `it declares ${seedable.map((one) => `\`${one.id}\``).join(', ')}. Nothing was written.`),
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const starting: readonly { readonly entity: string; readonly row: Record<string, unknown> }[] = wanted.flatMap(
|
|
407
|
+
(each) => {
|
|
408
|
+
const found = seedable.find((one) => one.id === each)
|
|
409
|
+
return found === undefined ? [] : [{ entity: found.entity, row: found.row }]
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
const answers: Record<string, { status: number; body?: string }> = {}
|
|
414
|
+
if (piped.length > 0) {
|
|
415
|
+
const said = (answer(piped, 'answers') ?? '').trim()
|
|
416
|
+
if (said !== '') {
|
|
417
|
+
try {
|
|
418
|
+
Object.assign(answers, JSON.parse(said) as Record<string, { status: number; body?: string }>)
|
|
419
|
+
} catch (thrown) {
|
|
420
|
+
shut()
|
|
421
|
+
refuse(`\`answers\` is not JSON: ${thrown instanceof Error ? thrown.message : String(thrown)}. Nothing was written.`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} else if (reachable.length > 0) {
|
|
425
|
+
stdout.write(`\n${one.id} can make ${String(reachable.length)} declared call(s). What does each answer with?\n`)
|
|
426
|
+
for (const call of reachable) {
|
|
427
|
+
const key = `${call.method} ${call.path}`
|
|
428
|
+
const status = (await ask('status', ` ${key} — status (blank to leave it unanswered, which is a 404): `)).trim()
|
|
429
|
+
if (status === '') continue
|
|
430
|
+
const code = Number(status)
|
|
431
|
+
if (!Number.isInteger(code) || code < 100 || code > 599) {
|
|
432
|
+
shut()
|
|
433
|
+
refuse(`"${status}" is not an HTTP status. Nothing was written.`)
|
|
434
|
+
}
|
|
435
|
+
const body = (await ask('body', ' body, as JSON or blank: ')).trim()
|
|
436
|
+
answers[key] = body === '' ? { status: code } : { status: code, body }
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
shut()
|
|
440
|
+
|
|
441
|
+
const row = [
|
|
442
|
+
' {',
|
|
443
|
+
` id: ${quoted(named)},`,
|
|
444
|
+
` describes: ${quoted(meaning)},`,
|
|
445
|
+
` input: ${JSON.stringify(value)},`,
|
|
446
|
+
...(actor === undefined ? [] : [` actor: ${JSON.stringify(actor)},`]),
|
|
447
|
+
...(starting.length === 0 ? [] : [` rows: ${JSON.stringify(starting)},`]),
|
|
448
|
+
...(Object.keys(answers).length === 0 ? [] : [` answers: ${JSON.stringify(answers)},`]),
|
|
449
|
+
' },',
|
|
450
|
+
].join('\n')
|
|
451
|
+
|
|
452
|
+
const SEEDED = 'export const input = z.unknown()'
|
|
453
|
+
|
|
454
|
+
const NAMEABLE = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
455
|
+
|
|
456
|
+
function stated(value: unknown): string | undefined {
|
|
457
|
+
if (typeof value === 'string') return 'z.string()'
|
|
458
|
+
if (typeof value === 'number') return 'z.number()'
|
|
459
|
+
if (typeof value === 'boolean') return 'z.boolean()'
|
|
460
|
+
if (value === null) return 'z.null()'
|
|
461
|
+
if (Array.isArray(value)) {
|
|
462
|
+
const first: unknown = value[0]
|
|
463
|
+
const of = first === undefined ? undefined : stated(first)
|
|
464
|
+
return of === undefined ? undefined : `z.array(${of})`
|
|
465
|
+
}
|
|
466
|
+
if (typeof value !== 'object') return undefined
|
|
467
|
+
const parts: string[] = []
|
|
468
|
+
for (const [key, held] of Object.entries(value)) {
|
|
469
|
+
const one = stated(held)
|
|
470
|
+
if (one === undefined) return undefined
|
|
471
|
+
parts.push(`${NAMEABLE.test(key) ? key : quoted(key)}: ${one}`)
|
|
472
|
+
}
|
|
473
|
+
return `z.object({ ${parts.join(', ')} })`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function stating(text: string, value: unknown): string {
|
|
477
|
+
const schema = stated(value)
|
|
478
|
+
if (schema === undefined || !text.includes(SEEDED)) return text
|
|
479
|
+
return text.replace(SEEDED, `export const input = ${schema}`)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let text = readFileSync(from, 'utf8')
|
|
483
|
+
const bounds = listBounds(text, SCENARIOS)
|
|
484
|
+
if (bounds === undefined) {
|
|
485
|
+
text = `${text.trimEnd()}\n\nexport const ${SCENARIOS}: readonly Scenario[] = [\n${row}\n]\n`
|
|
486
|
+
text = text.replace(
|
|
487
|
+
"import { z } from 'zod'\n",
|
|
488
|
+
"import { z } from 'zod'\n\nimport type { Scenario } from '../generators/trial.ts'\n",
|
|
489
|
+
)
|
|
490
|
+
text = stating(text, value)
|
|
491
|
+
} else {
|
|
492
|
+
text = withRow(text, bounds, row)
|
|
493
|
+
}
|
|
494
|
+
writeFileSync(from, text, 'utf8')
|
|
495
|
+
|
|
496
|
+
stdout.write(`\ntrial: wrote scenario \`${named}\` into ${one.takes}.\n`)
|
|
497
|
+
|
|
498
|
+
const ran = spawnSync('npx', ['thread', 'trials'], { cwd: TREE.at, encoding: 'utf8' })
|
|
499
|
+
stdout.write(`${`${ran.stdout ?? ''}${ran.stderr ?? ''}`.trim()}\n`)
|
|
500
|
+
if (ran.status !== 0) {
|
|
501
|
+
stdout.write('trial: the recording was refused above. The scenario is written and stands.\n')
|
|
502
|
+
exit(1)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const checked = spawnSync('npx', ['thread', 'trials', '--check'], { cwd: TREE.at, encoding: 'utf8' })
|
|
506
|
+
stdout.write(`${`${checked.stdout ?? ''}${checked.stderr ?? ''}`.trim()}\n`)
|
|
507
|
+
if (checked.status !== 0) exit(1)
|
|
508
|
+
|
|
509
|
+
const settled = settling(TREE.at, await generators(TREE), ['thread trials', 'thread run trial'])
|
|
510
|
+
for (const one of settled.ran) stdout.write(`${one}\n`)
|
|
511
|
+
|
|
512
|
+
if (settled.unsettled.length > 0) {
|
|
513
|
+
stderr.write(
|
|
514
|
+
`trial: the scenario is written and the run is recorded, and ${settled.unsettled.join(', ')} would not settle ` +
|
|
515
|
+
`after ${String(settled.rounds)} round(s). Run it yourself and read what it says.\n`,
|
|
516
|
+
)
|
|
517
|
+
exit(1)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
exit(0)
|