@prestack/thread 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/core/actor.ts +20 -0
  2. package/dist/core/asking.d.ts +0 -1
  3. package/dist/core/asking.js +0 -1
  4. package/dist/core/asking.ts +1342 -0
  5. package/dist/core/attach.js +2 -2
  6. package/dist/core/attach.ts +585 -0
  7. package/dist/core/chain.js +8 -1
  8. package/dist/core/chain.ts +148 -0
  9. package/dist/core/cli.js +97 -18
  10. package/dist/core/cli.ts +2004 -0
  11. package/dist/core/condition.ts +227 -0
  12. package/dist/core/descend.d.ts +1 -0
  13. package/dist/core/descend.js +36 -0
  14. package/dist/core/descend.ts +1001 -0
  15. package/dist/core/finding.ts +38 -0
  16. package/dist/core/generators/meta.js +6 -0
  17. package/dist/core/generators/meta.ts +1006 -0
  18. package/dist/core/init.ts +170 -0
  19. package/dist/core/invoked.ts +47 -0
  20. package/dist/core/round.ts +57 -0
  21. package/dist/core/rows.ts +369 -0
  22. package/dist/core/scope.ts +18 -0
  23. package/dist/core/thread.ts +29 -0
  24. package/dist/core/tree.ts +67 -0
  25. package/dist/core/types.ts +350 -0
  26. package/dist/derivation/attach.js +1 -1
  27. package/dist/derivation/attach.ts +68 -0
  28. package/dist/derivation/generators/build.ts +232 -0
  29. package/dist/derivation/generators/check.ts +118 -0
  30. package/dist/derivation/generators/derivation.ts +347 -0
  31. package/dist/derivation/generators/determinism.ts +145 -0
  32. package/dist/derivation/generators/release.js +19 -9
  33. package/dist/derivation/generators/release.ts +213 -0
  34. package/dist/derivation/makers/release.ts +135 -0
  35. package/dist/derivation/release.ts +23 -0
  36. package/dist/integrations/attach.js +2 -2
  37. package/dist/integrations/attach.ts +207 -0
  38. package/dist/integrations/generators/read.ts +127 -0
  39. package/dist/integrations/generators/secret.ts +289 -0
  40. package/dist/integrations/generators/target.js +16 -6
  41. package/dist/integrations/generators/target.ts +944 -0
  42. package/dist/integrations/generators/vault.js +46 -14
  43. package/dist/integrations/generators/vault.ts +212 -0
  44. package/dist/integrations/{sdk.js → module.js} +4 -4
  45. package/dist/integrations/module.ts +189 -0
  46. package/dist/integrations/secret.ts +50 -0
  47. package/dist/integrations/target.d.ts +3 -1
  48. package/dist/integrations/target.js +14 -11
  49. package/dist/integrations/target.ts +340 -0
  50. package/dist/integrations/vault.d.ts +2 -0
  51. package/dist/integrations/vault.js +4 -2
  52. package/dist/integrations/vault.ts +136 -0
  53. package/dist/integrity/attach.ts +234 -0
  54. package/dist/integrity/check-threads.js +2 -26
  55. package/dist/integrity/check-threads.ts +166 -0
  56. package/dist/integrity/constraint.ts +1202 -0
  57. package/dist/integrity/generators/compiler.ts +161 -0
  58. package/dist/integrity/generators/gate.ts +201 -0
  59. package/dist/integrity/generators/issues.ts +160 -0
  60. package/dist/integrity/generators/publish.js +14 -5
  61. package/dist/integrity/generators/publish.ts +247 -0
  62. package/dist/integrity/generators/rule.ts +271 -0
  63. package/dist/integrity/generators/seal.ts +117 -0
  64. package/dist/integrity/generators/trial.ts +320 -0
  65. package/dist/integrity/lint.ts +227 -0
  66. package/dist/integrity/lsp/bin.ts +42 -0
  67. package/dist/integrity/lsp/diagnostics.ts +92 -0
  68. package/dist/integrity/lsp/findings.ts +63 -0
  69. package/dist/integrity/lsp/lint.ts +140 -0
  70. package/dist/integrity/lsp/protocol.ts +78 -0
  71. package/dist/integrity/lsp/server.ts +224 -0
  72. package/dist/integrity/sealed.js +2 -2
  73. package/dist/integrity/sealed.ts +56 -0
  74. package/dist/integrity/slopnator.ts +350 -0
  75. package/dist/integrity/slopnators/sl_broken_lineage.ts +36 -0
  76. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +39 -0
  77. package/dist/integrity/slopnators/sl_derived_drift.ts +98 -0
  78. package/dist/integrity/slopnators/sl_hardcoded_style.ts +55 -0
  79. package/dist/integrity/slopnators/sl_inert_island.ts +91 -0
  80. package/dist/integrity/slopnators/sl_inert_slopnator.ts +93 -0
  81. package/dist/integrity/slopnators/sl_missing_row.ts +182 -0
  82. package/dist/integrity/slopnators/sl_no_barrel.ts +48 -0
  83. package/dist/integrity/slopnators/sl_no_child_process.ts +74 -0
  84. package/dist/integrity/slopnators/sl_no_comment.ts +43 -0
  85. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +328 -0
  86. package/dist/integrity/slopnators/sl_no_import_cycle.ts +50 -0
  87. package/dist/integrity/slopnators/sl_no_such_command.ts +64 -0
  88. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +63 -0
  89. package/dist/integrity/slopnators/sl_secret_literal.ts +100 -0
  90. package/dist/integrity/slopnators/sl_stale_path.ts +94 -0
  91. package/dist/integrity/slopnators/sl_twice_meant.ts +31 -0
  92. package/dist/integrity/slopnators/sl_twice_parented.ts +37 -0
  93. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +87 -0
  94. package/dist/integrity/slopnators/sl_undeclared_destination.ts +65 -0
  95. package/dist/integrity/slopnators/sl_undeclared_element.ts +44 -0
  96. package/dist/integrity/slopnators/sl_undeclared_node.ts +69 -0
  97. package/dist/integrity/slopnators/sl_undeclared_operation.ts +147 -0
  98. package/dist/integrity/slopnators/sl_undeclared_variant.ts +148 -0
  99. package/dist/integrity/slopnators/sl_undescribed.ts +65 -0
  100. package/dist/integrity/slopnators/sl_ungoverned_island.ts +61 -0
  101. package/dist/integrity/slopnators/sl_unkept_rows.ts +111 -0
  102. package/dist/integrity/slopnators/sl_unnamed_island.ts +41 -0
  103. package/dist/integrity/slopnators/sl_unpaired_member.ts +131 -0
  104. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +58 -0
  105. package/dist/integrity/slopnators/sl_unprefixed_member.ts +136 -0
  106. package/dist/integrity/slopnators/sl_unseamed_reach.ts +79 -0
  107. package/dist/integrity/slopnators/sl_unthreaded.ts +419 -0
  108. package/dist/integrity/slopnators/sl_untranslated_key.ts +164 -0
  109. package/dist/integrity/slopnators/sl_unversioned.ts +36 -0
  110. package/dist/integrity/sweep.ts +307 -0
  111. package/dist/knowledge/attach.ts +350 -0
  112. package/dist/knowledge/generators/docsgen.ts +174 -0
  113. package/dist/knowledge/generators/glossary.ts +73 -0
  114. package/dist/knowledge/generators/skills.ts +263 -0
  115. package/dist/knowledge/glossary.ts +6 -0
  116. package/dist/operations/access.ts +88 -0
  117. package/dist/operations/attach.ts +475 -0
  118. package/dist/operations/capabilities.ts +65 -0
  119. package/dist/operations/cost.ts +58 -0
  120. package/dist/operations/dispatch.ts +348 -0
  121. package/dist/operations/error-results.ts +31 -0
  122. package/dist/operations/errors/catalog.ts +168 -0
  123. package/dist/operations/errors/classes.ts +51 -0
  124. package/dist/operations/errors/errors.ts +314 -0
  125. package/dist/operations/generators/capability.js +29 -19
  126. package/dist/operations/generators/capability.ts +704 -0
  127. package/dist/operations/generators/compile.js +20 -1
  128. package/dist/operations/generators/compile.ts +643 -0
  129. package/dist/operations/generators/grant.ts +336 -0
  130. package/dist/operations/generators/operation.ts +972 -0
  131. package/dist/operations/generators/permits.ts +223 -0
  132. package/dist/operations/generators/seed.ts +347 -0
  133. package/dist/operations/generators/trial.js +4 -1
  134. package/dist/operations/generators/trial.ts +529 -0
  135. package/dist/operations/http.ts +164 -0
  136. package/dist/operations/makers/capability.ts +166 -0
  137. package/dist/operations/makers/trial.ts +520 -0
  138. package/dist/operations/memory.ts +470 -0
  139. package/dist/operations/metering.ts +328 -0
  140. package/dist/operations/nodes.ts +54 -0
  141. package/dist/operations/operations.ts +32 -0
  142. package/dist/operations/queue-policy.ts +10 -0
  143. package/dist/operations/result.ts +28 -0
  144. package/dist/operations/seams.d.ts +4 -4
  145. package/dist/operations/seams.ts +364 -0
  146. package/dist/operations/store.ts +29 -0
  147. package/package.json +8 -1
  148. package/dist/integrations/generators/sdk.d.ts +0 -1
  149. package/dist/integrations/generators/sdk.js +0 -148
  150. /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
+ }