@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,94 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
5
|
+
import { descend } from '../../core/descend.ts'
|
|
6
|
+
import { findTree } from '../../core/tree.ts'
|
|
7
|
+
|
|
8
|
+
const ROOT = findTree().at
|
|
9
|
+
const NAMES_A_FILE = /\.[a-z]{1,5}$/
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* A FILE A GENERATOR WRITES IS ABSENT UNTIL THE GENERATOR HAS RUN, AND THAT IS NOT A STALE PATH.
|
|
13
|
+
*
|
|
14
|
+
* This law and the descent disagreed about the same file, and the descent was right. A row carrying
|
|
15
|
+
* `by:` names a file a command writes; the descent reports it missing as a WARNING for exactly that
|
|
16
|
+
* reason — "a derived file is absent until it is derived". This read the same path with a bare
|
|
17
|
+
* `existsSync` and refused it, with advice — "fix the path, or delete the sentence naming it" —
|
|
18
|
+
* that would have deleted a correct declaration.
|
|
19
|
+
*
|
|
20
|
+
* Measured 2026-08-29 on a real install: `thread init`, `thread attach @thread/integrations`,
|
|
21
|
+
* `thread gate`, and the sweep was red on `_threads/glossary.ts` — a file `thread glossary` writes,
|
|
22
|
+
* on the first gate run of an app whose author had written nothing. Every other check in that gate
|
|
23
|
+
* answers this state by saying there is nothing to check yet.
|
|
24
|
+
*
|
|
25
|
+
* Read off the tree rather than guessed from the text. The descent already knows which rows carry a
|
|
26
|
+
* `by`, so the two can no longer come apart, and a path nothing derives is refused exactly as before.
|
|
27
|
+
*/
|
|
28
|
+
const derived = ((): ReadonlySet<string> => {
|
|
29
|
+
const found = new Set<string>()
|
|
30
|
+
try {
|
|
31
|
+
const pass = descend()
|
|
32
|
+
for (const [at, held] of pass.threaded) {
|
|
33
|
+
if (held.some((one) => one.row.by !== undefined)) found.add(at)
|
|
34
|
+
}
|
|
35
|
+
for (const one of pass.derived.values()) if (one.at !== undefined) found.add(one.at)
|
|
36
|
+
} catch {
|
|
37
|
+
/* A tree that cannot be descended is reported by the descent's own step, not swallowed here. */
|
|
38
|
+
}
|
|
39
|
+
return found
|
|
40
|
+
})()
|
|
41
|
+
|
|
42
|
+
const ours = (): ReadonlySet<string> => {
|
|
43
|
+
try {
|
|
44
|
+
return new Set(
|
|
45
|
+
readdirSync(ROOT).filter(
|
|
46
|
+
(one) => one !== 'node_modules' && statSync(resolve(ROOT, one), { throwIfNoEntry: false })?.isDirectory() === true,
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
} catch {
|
|
50
|
+
return new Set()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const slopnator: Slopnator = {
|
|
55
|
+
on: ['Literal'],
|
|
56
|
+
holds: (node) => {
|
|
57
|
+
const value = (node as { value?: unknown }).value
|
|
58
|
+
if (typeof value !== 'string' || !value.includes('/')) return false
|
|
59
|
+
|
|
60
|
+
const tops = ours()
|
|
61
|
+
if (tops.size === 0 || !tops.has(value.split('/')[0] ?? '')) return false
|
|
62
|
+
|
|
63
|
+
const brace = value.indexOf('{')
|
|
64
|
+
if (brace !== -1) {
|
|
65
|
+
const cut = value.lastIndexOf('/', brace)
|
|
66
|
+
const dir = cut === -1 ? '.' : value.slice(0, cut)
|
|
67
|
+
if (statSync(resolve(ROOT, dir), { throwIfNoEntry: false })?.isDirectory() === true) return false
|
|
68
|
+
return `\`${value}\` writes into \`${dir}\`, which is not a directory on disk`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!NAMES_A_FILE.test(value)) return false
|
|
72
|
+
if (existsSync(resolve(ROOT, value))) return false
|
|
73
|
+
if (derived.has(value)) return false
|
|
74
|
+
return `\`${value}\` is not on disk`
|
|
75
|
+
},
|
|
76
|
+
proves: {
|
|
77
|
+
wrong: [
|
|
78
|
+
"const at = 'threads/nope.ts'",
|
|
79
|
+
"const at = 'scripts/gone.ts'",
|
|
80
|
+
"const at = 'docs/nowhere/{page}.md'",
|
|
81
|
+
"const at = 'migrations/gone.sql'",
|
|
82
|
+
"const at = 'plans/gone.md'",
|
|
83
|
+
],
|
|
84
|
+
right: [
|
|
85
|
+
"const at = 'packages/core/integrity/src/constraint.ts'",
|
|
86
|
+
"const at = 'packages/core/integrity/src/lint.ts'",
|
|
87
|
+
"const at = 'docs/reference/{page}.md'",
|
|
88
|
+
"const at = 'threads/'",
|
|
89
|
+
"const said = 'a sentence about packages/core/integrity/src/constraint.ts and nothing else'",
|
|
90
|
+
"const at = 'app/layout.tsx'",
|
|
91
|
+
"const at = 'node_modules/thread/package.json'",
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
2
|
+
import { here, index, meaningsIn, parses, unreadable } from './sl_unthreaded.ts'
|
|
3
|
+
|
|
4
|
+
export const slopnator: Slopnator = {
|
|
5
|
+
holds: (_node, at) => {
|
|
6
|
+
const path = here(at.filename)
|
|
7
|
+
if (unreadable(path, at.text) !== undefined) return false
|
|
8
|
+
const mine = parses(path) ? meaningsIn(at.text) : []
|
|
9
|
+
if (mine.length === 0) return false
|
|
10
|
+
|
|
11
|
+
const meanings = index().meanings
|
|
12
|
+
|
|
13
|
+
for (const one of mine) {
|
|
14
|
+
const held = meanings.get(one.id)
|
|
15
|
+
if (held === undefined || held.size < 2) continue
|
|
16
|
+
return `\`${one.id}\` is coined with ${String(held.size)} different \`describes\`, and the glossary holds one meaning per word`
|
|
17
|
+
}
|
|
18
|
+
return false
|
|
19
|
+
},
|
|
20
|
+
proves: {
|
|
21
|
+
wrong: [
|
|
22
|
+
"const M: Threaded = {\n declares: [\n {\n id: 'one',\n describes: 'The first thing.',\n },\n {\n id: 'one',\n describes: 'A different thing entirely.',\n },\n ],\n}",
|
|
23
|
+
"const M: Threaded = {\n declares: [\n {\n id: 'two',\n describes: 'The stated meaning.',\n },\n {\n id: 'two',\n },\n ],\n}",
|
|
24
|
+
],
|
|
25
|
+
right: [
|
|
26
|
+
"const M: Threaded = {\n declares: [\n {\n id: 'one',\n describes: 'The first thing.',\n },\n {\n id: 'one',\n describes: 'The first thing.',\n },\n ],\n}",
|
|
27
|
+
"const M: Threaded = {\n declares: [\n {\n id: 'one',\n describes: 'The first thing.',\n },\n {\n id: 'two',\n describes: 'A different thing entirely.',\n },\n ],\n}",
|
|
28
|
+
'const M = { declares: [] }',
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
2
|
+
import { atsIn, here, index, parses, unreadable } from './sl_unthreaded.ts'
|
|
3
|
+
|
|
4
|
+
const ONLY_DECLARES = new Set(['declares'])
|
|
5
|
+
|
|
6
|
+
const declaredIn = (text: string): readonly string[] => atsIn(text, ONLY_DECLARES)
|
|
7
|
+
|
|
8
|
+
export const slopnator: Slopnator = {
|
|
9
|
+
holds: (_node, at) => {
|
|
10
|
+
const path = here(at.filename)
|
|
11
|
+
if (unreadable(path, at.text) !== undefined) return false
|
|
12
|
+
const mine = parses(path) ? declaredIn(at.text) : []
|
|
13
|
+
if (mine.length === 0) return false
|
|
14
|
+
|
|
15
|
+
const threaded = index().declared
|
|
16
|
+
|
|
17
|
+
for (const claimed of mine) {
|
|
18
|
+
const times = threaded.get(claimed) ?? 0
|
|
19
|
+
if (times > 1) return `${claimed} is declared ${String(times)} times, and \`declares\` is exactly one parent ever`
|
|
20
|
+
}
|
|
21
|
+
return false
|
|
22
|
+
},
|
|
23
|
+
proves: {
|
|
24
|
+
wrong: [
|
|
25
|
+
"const M: Threaded = {\n declares: [\n {\n at: 'threads/one.ts',\n },\n {\n at: 'threads/one.ts',\n },\n ],\n}",
|
|
26
|
+
"const M: Threaded = {\n declares: [\n {\n at: 'threads/two.ts',\n },\n {\n at: 'threads/three.ts',\n },\n {\n at: 'threads/two.ts',\n },\n ],\n}",
|
|
27
|
+
"const ROW = {\n at: 'threads/one.ts',\n}\nconst M: Threaded = {\n declares: [ROW, ROW],\n}",
|
|
28
|
+
],
|
|
29
|
+
right: [
|
|
30
|
+
"const M: Threaded = {\n declares: [\n {\n at: 'threads/one.ts',\n },\n {\n at: 'threads/two.ts',\n },\n ],\n}",
|
|
31
|
+
"const M: Threaded = {\n derives: [\n {\n at: 'threads/one.ts',\n },\n {\n at: 'threads/one.ts',\n },\n ],\n}",
|
|
32
|
+
'const M = { declares: [] }',
|
|
33
|
+
"const ROW = {\n at: 'threads/one.ts',\n}\nconst M: Threaded = {\n declares: [ROW],\n}",
|
|
34
|
+
"const M: Threaded = {\n declares: [\n {\n at: 'threads/one.ts',\n declares: [\n {\n at: 'threads/two.ts',\n },\n ],\n },\n ],\n}",
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { descend, under } from '../../core/descend.ts'
|
|
2
|
+
|
|
3
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
4
|
+
import { here } from './sl_unthreaded.ts'
|
|
5
|
+
|
|
6
|
+
interface Crossing {
|
|
7
|
+
readonly what: string
|
|
8
|
+
readonly finds: RegExp
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CROSSINGS: readonly Crossing[] = [
|
|
12
|
+
{ what: 'the network', finds: /(?<![.\w])fetch\s*\(/ },
|
|
13
|
+
{ what: 'the network', finds: /(?<![.\w])XMLHttpRequest\b/ },
|
|
14
|
+
{ what: 'the clock', finds: /\bDate\s*\.\s*now\s*\(/ },
|
|
15
|
+
{ what: 'the clock', finds: /\bperformance\s*\.\s*now\s*\(/ },
|
|
16
|
+
{ what: 'the clock', finds: /\bsetInterval\s*\(/ },
|
|
17
|
+
{ what: 'the clock', finds: /\bsetTimeout\s*\(/ },
|
|
18
|
+
{ what: 'the clock', finds: /\brequestAnimationFrame\s*\(/ },
|
|
19
|
+
{ what: 'randomness', finds: /\bMath\s*\.\s*random\s*\(/ },
|
|
20
|
+
{ what: 'randomness', finds: /\bcrypto\s*\.\s*randomUUID\s*\(/ },
|
|
21
|
+
{ what: 'the queue', finds: /\bqueue\s*\.\s*enqueue\s*\(/ },
|
|
22
|
+
{ what: 'a seam', finds: /\b(?:files|llm|http)\s*\.\s*(?:call|run|complete|put|signedUrl|remove|get)\s*\(/ },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const OPERATIONS_ID = 'operations'
|
|
26
|
+
|
|
27
|
+
const NODES_ID = 'nodes'
|
|
28
|
+
|
|
29
|
+
let bodies: ReadonlySet<string> | undefined
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* The files this law is about, read off the tree rather than off a path.
|
|
33
|
+
*
|
|
34
|
+
* A crossing belongs in a declared node and nowhere else, so what the law has to know is which
|
|
35
|
+
* files ARE declared nodes — every row in an operation's `seeds` that requires a node type. It
|
|
36
|
+
* cannot be a path rule: an operation seeds its node bodies and its input file into the same
|
|
37
|
+
* directory, so excepting the directory would except the input file too and leave a hole exactly
|
|
38
|
+
* one file wide per operation. Measured 2026-08-27: with the directory excepted instead, this law
|
|
39
|
+
* fired on `operations/wf_asks/asks_it.ts` — a node body calling the model its operation had
|
|
40
|
+
* declared, which is the one place the law's own `instead` sends you.
|
|
41
|
+
*/
|
|
42
|
+
function declared(): ReadonlySet<string> {
|
|
43
|
+
if (bodies !== undefined) return bodies
|
|
44
|
+
const pass = descend()
|
|
45
|
+
const found = new Set<string>()
|
|
46
|
+
for (const [at, threadings] of pass.threaded) {
|
|
47
|
+
for (const one of threadings) {
|
|
48
|
+
if (one.list !== 'seeds') continue
|
|
49
|
+
if (!under(pass, one.by, OPERATIONS_ID) || under(pass, one.by, NODES_ID)) continue
|
|
50
|
+
if ((one.row.requires ?? []).length === 0) continue
|
|
51
|
+
found.add(at)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
bodies = found
|
|
55
|
+
return found
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const slopnator: Slopnator = {
|
|
59
|
+
holds: (_node, at) => {
|
|
60
|
+
if (declared().has(here(at.filename))) return false
|
|
61
|
+
for (const crossing of CROSSINGS) {
|
|
62
|
+
if (!crossing.finds.test(at.text)) continue
|
|
63
|
+
return (
|
|
64
|
+
`${crossing.what} reached outside a declared node — nothing recorded this crossing, so it ` +
|
|
65
|
+
'cannot be replayed, retried, priced or unwound, and the page renders exactly the same either way'
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
},
|
|
70
|
+
proves: {
|
|
71
|
+
wrong: [
|
|
72
|
+
"const res = await fetch('https://api.stripe.com/v1/charges')",
|
|
73
|
+
'const at = Date.now()',
|
|
74
|
+
'const id = crypto.randomUUID()',
|
|
75
|
+
"await queue.enqueue('job', payload)",
|
|
76
|
+
"await files.put('receipt.pdf', bytes)",
|
|
77
|
+
'const t = setInterval(() => refetch(), 5000)',
|
|
78
|
+
'const t = setTimeout(function again() { refetch(); setTimeout(again, 5000) }, 5000)',
|
|
79
|
+
'window.requestAnimationFrame(step)',
|
|
80
|
+
],
|
|
81
|
+
right: [
|
|
82
|
+
"const node = { id: 'st_charge', kind: 'action', action: 'payment.charge', declares: { seams: ['http'] } }",
|
|
83
|
+
'const rows = useRead(recentOrders)',
|
|
84
|
+
"const { data } = useRead(recentOrders, { refresh: 'every-5s' })",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
2
|
+
|
|
3
|
+
interface Held {
|
|
4
|
+
readonly type?: string
|
|
5
|
+
readonly name?: unknown
|
|
6
|
+
readonly value?: unknown
|
|
7
|
+
readonly computed?: boolean
|
|
8
|
+
readonly object?: Held
|
|
9
|
+
readonly property?: Held
|
|
10
|
+
readonly key?: Held
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HREF = 'href'
|
|
14
|
+
const GLOBALS = new Set(['window', 'globalThis', 'self'])
|
|
15
|
+
const HISTORIES = new Set(['router', 'navigation', 'history'])
|
|
16
|
+
const MOVES = new Set(['push', 'replace', 'prefetch'])
|
|
17
|
+
|
|
18
|
+
const named = (held: Held | undefined): string | undefined => {
|
|
19
|
+
if (held?.type === 'Identifier' || held?.type === 'JSXIdentifier') return typeof held.name === 'string' ? held.name : undefined
|
|
20
|
+
if (held?.type === 'Literal' && typeof held.value === 'string') return held.value
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const spoken = (held: Held): string | undefined => {
|
|
25
|
+
if (held.type === 'JSXAttribute') return named(held.name as Held | undefined)
|
|
26
|
+
if (held.type === 'Property') return held.computed === true ? undefined : named(held.key)
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function moving(held: Held): string | undefined {
|
|
31
|
+
if (held.type !== 'MemberExpression' || held.computed === true) return undefined
|
|
32
|
+
const on = named(held.object)
|
|
33
|
+
const reached = named(held.property)
|
|
34
|
+
if (on === undefined || reached === undefined) return undefined
|
|
35
|
+
if (GLOBALS.has(on) && reached === 'location') return `${on}.location`
|
|
36
|
+
if (HISTORIES.has(on) && MOVES.has(reached)) return `${on}.${reached}()`
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const slopnator: Slopnator = {
|
|
41
|
+
on: ['JSXAttribute', 'Property', 'MemberExpression'],
|
|
42
|
+
holds: (node) => {
|
|
43
|
+
const held = node as Held
|
|
44
|
+
const written = spoken(held) === HREF ? '`href`' : moving(held)
|
|
45
|
+
if (written === undefined) return false
|
|
46
|
+
return (
|
|
47
|
+
`${written} moves somebody without naming a screen, so the map holds no link for it — ` +
|
|
48
|
+
'the destination reads as unreachable to the orphan pass, the app reads as having no path to it, and it navigates correctly'
|
|
49
|
+
)
|
|
50
|
+
},
|
|
51
|
+
proves: {
|
|
52
|
+
wrong: [
|
|
53
|
+
"const link = { href: '/orders/42' }",
|
|
54
|
+
"window.location = '/orders/42'",
|
|
55
|
+
"router.push('/orders/42')",
|
|
56
|
+
'history.replace(next)',
|
|
57
|
+
'navigation.prefetch(next)',
|
|
58
|
+
],
|
|
59
|
+
right: [
|
|
60
|
+
'<Link.label navigateTo="Home" labelKey="home.title" />',
|
|
61
|
+
"const to = { navigateTo: 'Order', labelKey: 'order.title' }",
|
|
62
|
+
'const rows = orders.push(one)',
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
2
|
+
|
|
3
|
+
interface Tag {
|
|
4
|
+
readonly type?: string
|
|
5
|
+
readonly name?: unknown
|
|
6
|
+
readonly namespace?: { readonly name?: unknown }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const HOST = /^[a-z]/
|
|
10
|
+
|
|
11
|
+
function tagOf(node: unknown): string | undefined {
|
|
12
|
+
const name = (node as { name?: Tag } | undefined)?.name
|
|
13
|
+
if (name?.type === 'JSXNamespacedName') {
|
|
14
|
+
const space = typeof name.namespace?.name === 'string' ? name.namespace.name : ''
|
|
15
|
+
const local = typeof (name.name as Tag | undefined)?.name === 'string' ? String((name.name as Tag).name) : ''
|
|
16
|
+
return `${space}:${local}`
|
|
17
|
+
}
|
|
18
|
+
if (name?.type !== 'JSXIdentifier') return undefined
|
|
19
|
+
return typeof name.name === 'string' ? name.name : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const slopnator: Slopnator = {
|
|
23
|
+
on: ['JSXOpeningElement'],
|
|
24
|
+
holds: (node) => {
|
|
25
|
+
const tag = tagOf(node)
|
|
26
|
+
if (tag === undefined || !HOST.test(tag)) return false
|
|
27
|
+
return (
|
|
28
|
+
`<${tag}> is a host element, so it wears no declared style, sits in no catalogue, and the style guide, ` +
|
|
29
|
+
'a rebrand and every overlay read past it — it renders and nothing downstream knows it is there'
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
proves: {
|
|
33
|
+
wrong: [
|
|
34
|
+
'<div className="row" />',
|
|
35
|
+
'<button onClick={save}>save</button>',
|
|
36
|
+
'<svg:path d="M0 0" />',
|
|
37
|
+
],
|
|
38
|
+
right: [
|
|
39
|
+
'<Box.card />',
|
|
40
|
+
'<Column>{rows}</Column>',
|
|
41
|
+
'<Button.buttonPrimary label="save" />',
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { dirname, join, normalize } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
4
|
+
import { here, index, parses, unreadable } from './sl_unthreaded.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Every string this file writes, whatever it writes it for.
|
|
8
|
+
*
|
|
9
|
+
* The first reading matched `node: '<name>'`, which was the shape when an operation named its node
|
|
10
|
+
* in a string beside a step of its own; a node builds its own span now, so that matched nothing on
|
|
11
|
+
* any real file. The second matched `import … from '<path>'`, which was the one route anybody had
|
|
12
|
+
* thought of — and a dynamic `import()`, a `require()` and an `export … from` each walked straight
|
|
13
|
+
* through it, measured on a real tree. Enumerating the ways in is how a refusal ends up as a list of
|
|
14
|
+
* whatever somebody happened to try. What matters is the path, not the syntax carrying it.
|
|
15
|
+
*/
|
|
16
|
+
const SAID = /'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"|`((?:[^`\\$]|\\.)*)`/g
|
|
17
|
+
|
|
18
|
+
const RELATIVE = /^\.\.?\//
|
|
19
|
+
|
|
20
|
+
const UNDER = 'nodes/'
|
|
21
|
+
|
|
22
|
+
const slash = (one: string): string => one.split('\\').join('/')
|
|
23
|
+
|
|
24
|
+
function nodeAt(said: string, from: string): string | undefined {
|
|
25
|
+
if (said === '') return undefined
|
|
26
|
+
const path = slash(normalize(RELATIVE.test(said) ? join(from, said) : said))
|
|
27
|
+
return path.startsWith(UNDER) ? path : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const slopnator: Slopnator = {
|
|
31
|
+
holds: (_node, at) => {
|
|
32
|
+
const path = here(at.filename)
|
|
33
|
+
if (unreadable(path, at.text) !== undefined) return false
|
|
34
|
+
if (!parses(path)) return false
|
|
35
|
+
|
|
36
|
+
const declared = index().reached
|
|
37
|
+
const from = dirname(path)
|
|
38
|
+
|
|
39
|
+
for (const found of String(at.text).matchAll(SAID)) {
|
|
40
|
+
const named = nodeAt(found[1] ?? found[2] ?? found[3] ?? '', from)
|
|
41
|
+
if (named === undefined || declared.has(named)) continue
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
`this reaches \`${named}\`, and the descent from the tree root never arrives there — so one of ` +
|
|
45
|
+
'its steps is a node nobody declared. An operation that takes a step nothing agreed to is doing ' +
|
|
46
|
+
'something this app never said it could do, and the flow reads as agreed the whole way down'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false
|
|
51
|
+
},
|
|
52
|
+
proves: {
|
|
53
|
+
wrong: [
|
|
54
|
+
"import { node as ghost } from '../nodes/nd_ghost.ts'",
|
|
55
|
+
"const held = await import('../nodes/nd_ghost.ts')",
|
|
56
|
+
"const held = require('../nodes/nd_ghost.ts')",
|
|
57
|
+
"export { node as ghost } from '../nodes/nd_ghost.ts'",
|
|
58
|
+
"const AT = '../nodes/nd_ghost.ts'",
|
|
59
|
+
"import { node as invented } from 'nodes/nd_nowhere.ts'",
|
|
60
|
+
],
|
|
61
|
+
right: [
|
|
62
|
+
"import { node as read } from '../nodes/nd_read.ts'",
|
|
63
|
+
"import { node as action } from '../nodes/nd_action.ts'\nimport { node as read } from '../nodes/nd_read.ts'",
|
|
64
|
+
"import { z } from 'zod'",
|
|
65
|
+
"import type { Operation } from '../../operations/operations.ts'",
|
|
66
|
+
"import type { Held } from '../../operations/nodes.ts'",
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
2
|
+
|
|
3
|
+
import { descend, read, reached } from '../../core/descend.ts'
|
|
4
|
+
|
|
5
|
+
import { rowsIn } from './sl_unthreaded.ts'
|
|
6
|
+
|
|
7
|
+
const UNDER = 'operations/'
|
|
8
|
+
|
|
9
|
+
const DISPATCH = 'dispatch'
|
|
10
|
+
|
|
11
|
+
function declared(): ReadonlySet<string> {
|
|
12
|
+
const found = new Set<string>()
|
|
13
|
+
|
|
14
|
+
descend()
|
|
15
|
+
|
|
16
|
+
for (const at of reached()) {
|
|
17
|
+
const text = read(at)
|
|
18
|
+
if (text === undefined) continue
|
|
19
|
+
|
|
20
|
+
for (const one of rowsIn(text)) {
|
|
21
|
+
const where = one.at?.split('\\').join('/')
|
|
22
|
+
if (where === undefined || !where.includes(UNDER)) continue
|
|
23
|
+
if (one.id !== undefined) found.add(one.id)
|
|
24
|
+
const named = where.split('/').at(-1)?.replace(/\.[^.]+$/, '')
|
|
25
|
+
if (named !== undefined) found.add(named)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return found
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Called {
|
|
33
|
+
readonly callee?: { readonly type?: string; readonly name?: string; readonly property?: { readonly type?: string; readonly name?: string } }
|
|
34
|
+
readonly arguments?: readonly { readonly type?: string; readonly value?: unknown; readonly name?: string }[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const slopnator: Slopnator = {
|
|
38
|
+
on: ['CallExpression'],
|
|
39
|
+
holds: (node) => {
|
|
40
|
+
const call = node as Called | undefined
|
|
41
|
+
const callee = call?.callee
|
|
42
|
+
const named =
|
|
43
|
+
callee?.type === 'Identifier'
|
|
44
|
+
? callee.name
|
|
45
|
+
: callee?.property?.type === 'Identifier'
|
|
46
|
+
? callee.property.name
|
|
47
|
+
: undefined
|
|
48
|
+
if (named !== DISPATCH) return false
|
|
49
|
+
|
|
50
|
+
const first = call?.arguments?.[0]
|
|
51
|
+
if (first === undefined) {
|
|
52
|
+
return (
|
|
53
|
+
'dispatch is called with nothing to dispatch, so no operation is named and none can be found — the name ' +
|
|
54
|
+
'is the whole of what makes an operation reachable'
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* THE OPERATION ITSELF IS A BETTER ANSWER THAN ITS NAME, AND THIS USED TO REFUSE IT.
|
|
60
|
+
*
|
|
61
|
+
* This asked for a string literal and refused everything else, which was right when the only
|
|
62
|
+
* caller was a UI seam handed a bare name. It has been wrong since dispatch stopped taking one:
|
|
63
|
+
* the note above says so in as many words — "`dispatch` in packages/core/operations/src/dispatch.ts
|
|
64
|
+
* takes an Operation object rather than a name". What ships now imports the compiled operation
|
|
65
|
+
* and hands over the binding, and a binding cannot be assembled at run time: if it named nothing,
|
|
66
|
+
* the file would not compile.
|
|
67
|
+
*
|
|
68
|
+
* Measured 2026-08-31 on a registry install: an operations-only app, everything made by the CLI,
|
|
69
|
+
* failed its own sweep on `operations/_client.ts` — a file its own generator had just written,
|
|
70
|
+
* naming an operation the same tree declares. The gate refuses the build now, so a law that
|
|
71
|
+
* refuses a generated file is an app that cannot ship.
|
|
72
|
+
*
|
|
73
|
+
* Narrow on purpose. An identifier is accepted only when it IS one of the declared operations,
|
|
74
|
+
* which is the shape the generators emit — `import { operation as wf_charge }` and then
|
|
75
|
+
* `dispatch(wf_charge, …)`. A variable holding a computed name is still refused, because it does
|
|
76
|
+
* not match a declaration, and that is the hole this law was written for.
|
|
77
|
+
*/
|
|
78
|
+
const all = declared()
|
|
79
|
+
|
|
80
|
+
if (first.type === 'Identifier' && typeof first.name === 'string') {
|
|
81
|
+
if (all.has(first.name)) return false
|
|
82
|
+
return (
|
|
83
|
+
`dispatch is handed \`${first.name}\`, and nothing declares an operation by that name. A binding is only ` +
|
|
84
|
+
'checkable when it is the operation — import the compiled operation and hand that over, so what runs and ' +
|
|
85
|
+
'what was declared are the same thing rather than two things that agree today'
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
90
|
+
return (
|
|
91
|
+
'dispatch is handed a name this file does not write down, so nothing can hold it against what is declared. ' +
|
|
92
|
+
'A name assembled at runtime reads from every check exactly like a name that was checked and found — the ' +
|
|
93
|
+
'operation is reachable and no declaration was ever consulted'
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (all.has(first.value)) return false
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
`dispatch names \`${String(first.value)}\` and nothing declares an operation by that name, so this reaches for ` +
|
|
101
|
+
'something that does not exist and fails where a person is waiting rather than where it was written. ' +
|
|
102
|
+
(all.size === 0
|
|
103
|
+
? 'Nothing declares an operation at all, so no name would have been accepted — the declaration is what has to change'
|
|
104
|
+
: `Declared: ${[...all].sort().join(', ')}`)
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
proves: {
|
|
108
|
+
wrong: [
|
|
109
|
+
{
|
|
110
|
+
files: [
|
|
111
|
+
{
|
|
112
|
+
at: 'threads/_probe_operations.ts',
|
|
113
|
+
source:
|
|
114
|
+
"export const OPERATIONS: Threaded = {\n id: 'operations',\n declares: [\n {\n id: 'wf_send_invoice',\n at: 'operations/wf_send_invoice.ts',\n },\n ],\n}\n",
|
|
115
|
+
},
|
|
116
|
+
{ at: 'app/_probe_unknown.tsx', source: "export const HELD = dispatch('wf_not_declared')\n" },
|
|
117
|
+
],
|
|
118
|
+
at: 'app/_probe_unknown.tsx',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
files: [
|
|
122
|
+
{
|
|
123
|
+
at: 'threads/_probe_operations.ts',
|
|
124
|
+
source:
|
|
125
|
+
"export const OPERATIONS: Threaded = {\n id: 'operations',\n declares: [\n {\n id: 'wf_send_invoice',\n at: 'operations/wf_send_invoice.ts',\n },\n ],\n}\n",
|
|
126
|
+
},
|
|
127
|
+
{ at: 'app/_probe_built.tsx', source: "export const HELD = dispatch(`wf_${name}`)\n" },
|
|
128
|
+
],
|
|
129
|
+
at: 'app/_probe_built.tsx',
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
right: [
|
|
133
|
+
{
|
|
134
|
+
files: [
|
|
135
|
+
{
|
|
136
|
+
at: 'threads/_probe_operations.ts',
|
|
137
|
+
source:
|
|
138
|
+
"export const OPERATIONS: Threaded = {\n id: 'operations',\n declares: [\n {\n id: 'wf_send_invoice',\n at: 'operations/wf_send_invoice.ts',\n },\n ],\n}\n",
|
|
139
|
+
},
|
|
140
|
+
{ at: 'app/_probe_known.tsx', source: "export const HELD = dispatch('wf_send_invoice')\n" },
|
|
141
|
+
],
|
|
142
|
+
at: 'app/_probe_known.tsx',
|
|
143
|
+
},
|
|
144
|
+
"export const HELD = translate('order.place')",
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
}
|