@prestack/thread 0.0.1
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/CHANGELOG.md +192 -0
- package/dist/attach.d.ts +1 -0
- package/dist/attach.js +1 -0
- package/dist/core/actor.d.ts +12 -0
- package/dist/core/actor.js +8 -0
- package/dist/core/asking.d.ts +180 -0
- package/dist/core/asking.js +1109 -0
- package/dist/core/attach.d.ts +54 -0
- package/dist/core/attach.js +510 -0
- package/dist/core/chain.d.ts +16 -0
- package/dist/core/chain.js +114 -0
- package/dist/core/cli.d.ts +2 -0
- package/dist/core/cli.js +1682 -0
- package/dist/core/condition.d.ts +52 -0
- package/dist/core/condition.js +198 -0
- package/dist/core/descend.d.ts +81 -0
- package/dist/core/descend.js +879 -0
- package/dist/core/finding.d.ts +12 -0
- package/dist/core/finding.js +23 -0
- package/dist/core/generators/meta.d.ts +2 -0
- package/dist/core/generators/meta.js +823 -0
- package/dist/core/init.d.ts +15 -0
- package/dist/core/init.js +134 -0
- package/dist/core/invoked.d.ts +9 -0
- package/dist/core/invoked.js +41 -0
- package/dist/core/round.d.ts +1 -0
- package/dist/core/round.js +57 -0
- package/dist/core/rows.d.ts +26 -0
- package/dist/core/rows.js +354 -0
- package/dist/core/scope.d.ts +6 -0
- package/dist/core/scope.js +12 -0
- package/dist/core/thread.d.ts +26 -0
- package/dist/core/thread.js +5 -0
- package/dist/core/tree.d.ts +12 -0
- package/dist/core/tree.js +56 -0
- package/dist/core/types.d.ts +49 -0
- package/dist/core/types.js +311 -0
- package/dist/derivation/attach.d.ts +2 -0
- package/dist/derivation/attach.js +61 -0
- package/dist/derivation/generators/build.d.ts +16 -0
- package/dist/derivation/generators/build.js +217 -0
- package/dist/derivation/generators/check.d.ts +18 -0
- package/dist/derivation/generators/check.js +78 -0
- package/dist/derivation/generators/derivation.d.ts +3 -0
- package/dist/derivation/generators/derivation.js +273 -0
- package/dist/derivation/generators/determinism.d.ts +20 -0
- package/dist/derivation/generators/determinism.js +110 -0
- package/dist/derivation/generators/release.d.ts +3 -0
- package/dist/derivation/generators/release.js +183 -0
- package/dist/derivation/makers/release.d.ts +1 -0
- package/dist/derivation/makers/release.js +107 -0
- package/dist/derivation/release.d.ts +10 -0
- package/dist/derivation/release.js +12 -0
- package/dist/integrations/attach.d.ts +6 -0
- package/dist/integrations/attach.js +177 -0
- package/dist/integrations/generators/read.d.ts +1 -0
- package/dist/integrations/generators/read.js +92 -0
- package/dist/integrations/generators/sdk.d.ts +1 -0
- package/dist/integrations/generators/sdk.js +148 -0
- package/dist/integrations/generators/secret.d.ts +1 -0
- package/dist/integrations/generators/secret.js +233 -0
- package/dist/integrations/generators/target.d.ts +1 -0
- package/dist/integrations/generators/target.js +765 -0
- package/dist/integrations/generators/vault.d.ts +1 -0
- package/dist/integrations/generators/vault.js +164 -0
- package/dist/integrations/sdk.d.ts +6 -0
- package/dist/integrations/sdk.js +151 -0
- package/dist/integrations/secret.d.ts +16 -0
- package/dist/integrations/secret.js +30 -0
- package/dist/integrations/target.d.ts +49 -0
- package/dist/integrations/target.js +225 -0
- package/dist/integrations/vault.d.ts +12 -0
- package/dist/integrations/vault.js +104 -0
- package/dist/integrity/attach.d.ts +2 -0
- package/dist/integrity/attach.js +213 -0
- package/dist/integrity/check-threads.d.ts +10 -0
- package/dist/integrity/check-threads.js +153 -0
- package/dist/integrity/constraint.d.ts +16 -0
- package/dist/integrity/constraint.js +1105 -0
- package/dist/integrity/generators/compiler.d.ts +1 -0
- package/dist/integrity/generators/compiler.js +130 -0
- package/dist/integrity/generators/gate.d.ts +1 -0
- package/dist/integrity/generators/gate.js +172 -0
- package/dist/integrity/generators/issues.d.ts +1 -0
- package/dist/integrity/generators/issues.js +100 -0
- package/dist/integrity/generators/publish.d.ts +1 -0
- package/dist/integrity/generators/publish.js +186 -0
- package/dist/integrity/generators/rule.d.ts +2 -0
- package/dist/integrity/generators/rule.js +213 -0
- package/dist/integrity/generators/seal.d.ts +4 -0
- package/dist/integrity/generators/seal.js +101 -0
- package/dist/integrity/generators/trial.d.ts +39 -0
- package/dist/integrity/generators/trial.js +254 -0
- package/dist/integrity/lint.d.ts +50 -0
- package/dist/integrity/lint.js +161 -0
- package/dist/integrity/lsp/bin.d.ts +2 -0
- package/dist/integrity/lsp/bin.js +35 -0
- package/dist/integrity/lsp/diagnostics.d.ts +25 -0
- package/dist/integrity/lsp/diagnostics.js +57 -0
- package/dist/integrity/lsp/findings.d.ts +20 -0
- package/dist/integrity/lsp/findings.js +39 -0
- package/dist/integrity/lsp/lint.d.ts +7 -0
- package/dist/integrity/lsp/lint.js +95 -0
- package/dist/integrity/lsp/protocol.d.ts +20 -0
- package/dist/integrity/lsp/protocol.js +57 -0
- package/dist/integrity/lsp/server.d.ts +15 -0
- package/dist/integrity/lsp/server.js +171 -0
- package/dist/integrity/sealed.d.ts +1 -0
- package/dist/integrity/sealed.js +56 -0
- package/dist/integrity/slopnator.d.ts +90 -0
- package/dist/integrity/slopnator.js +267 -0
- package/dist/integrity/slopnators/sl_broken_lineage.d.ts +2 -0
- package/dist/integrity/slopnators/sl_broken_lineage.js +35 -0
- package/dist/integrity/slopnators/sl_declared_elsewhere.d.ts +2 -0
- package/dist/integrity/slopnators/sl_declared_elsewhere.js +33 -0
- package/dist/integrity/slopnators/sl_derived_drift.d.ts +8 -0
- package/dist/integrity/slopnators/sl_derived_drift.js +91 -0
- package/dist/integrity/slopnators/sl_hardcoded_style.d.ts +2 -0
- package/dist/integrity/slopnators/sl_hardcoded_style.js +46 -0
- package/dist/integrity/slopnators/sl_inert_island.d.ts +2 -0
- package/dist/integrity/slopnators/sl_inert_island.js +85 -0
- package/dist/integrity/slopnators/sl_inert_slopnator.d.ts +2 -0
- package/dist/integrity/slopnators/sl_inert_slopnator.js +76 -0
- package/dist/integrity/slopnators/sl_missing_row.d.ts +2 -0
- package/dist/integrity/slopnators/sl_missing_row.js +180 -0
- package/dist/integrity/slopnators/sl_no_barrel.d.ts +2 -0
- package/dist/integrity/slopnators/sl_no_barrel.js +41 -0
- package/dist/integrity/slopnators/sl_no_child_process.d.ts +2 -0
- package/dist/integrity/slopnators/sl_no_child_process.js +61 -0
- package/dist/integrity/slopnators/sl_no_comment.d.ts +2 -0
- package/dist/integrity/slopnators/sl_no_comment.js +41 -0
- package/dist/integrity/slopnators/sl_no_dispatch_bypass.d.ts +2 -0
- package/dist/integrity/slopnators/sl_no_dispatch_bypass.js +301 -0
- package/dist/integrity/slopnators/sl_no_import_cycle.d.ts +2 -0
- package/dist/integrity/slopnators/sl_no_import_cycle.js +42 -0
- package/dist/integrity/slopnators/sl_no_such_command.d.ts +2 -0
- package/dist/integrity/slopnators/sl_no_such_command.js +55 -0
- package/dist/integrity/slopnators/sl_only_takes_a_literal.d.ts +2 -0
- package/dist/integrity/slopnators/sl_only_takes_a_literal.js +59 -0
- package/dist/integrity/slopnators/sl_secret_literal.d.ts +2 -0
- package/dist/integrity/slopnators/sl_secret_literal.js +89 -0
- package/dist/integrity/slopnators/sl_stale_path.d.ts +2 -0
- package/dist/integrity/slopnators/sl_stale_path.js +92 -0
- package/dist/integrity/slopnators/sl_twice_meant.d.ts +2 -0
- package/dist/integrity/slopnators/sl_twice_meant.js +30 -0
- package/dist/integrity/slopnators/sl_twice_parented.d.ts +2 -0
- package/dist/integrity/slopnators/sl_twice_parented.js +34 -0
- package/dist/integrity/slopnators/sl_undeclared_crossing.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undeclared_crossing.js +78 -0
- package/dist/integrity/slopnators/sl_undeclared_destination.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undeclared_destination.js +56 -0
- package/dist/integrity/slopnators/sl_undeclared_element.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undeclared_element.js +34 -0
- package/dist/integrity/slopnators/sl_undeclared_node.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undeclared_node.js +59 -0
- package/dist/integrity/slopnators/sl_undeclared_operation.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undeclared_operation.js +120 -0
- package/dist/integrity/slopnators/sl_undeclared_variant.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undeclared_variant.js +117 -0
- package/dist/integrity/slopnators/sl_undescribed.d.ts +2 -0
- package/dist/integrity/slopnators/sl_undescribed.js +54 -0
- package/dist/integrity/slopnators/sl_ungoverned_island.d.ts +2 -0
- package/dist/integrity/slopnators/sl_ungoverned_island.js +58 -0
- package/dist/integrity/slopnators/sl_unkept_rows.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unkept_rows.js +104 -0
- package/dist/integrity/slopnators/sl_unnamed_island.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unnamed_island.js +37 -0
- package/dist/integrity/slopnators/sl_unpaired_member.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unpaired_member.js +114 -0
- package/dist/integrity/slopnators/sl_unpaired_slopnator.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unpaired_slopnator.js +60 -0
- package/dist/integrity/slopnators/sl_unprefixed_member.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unprefixed_member.js +126 -0
- package/dist/integrity/slopnators/sl_unseamed_reach.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unseamed_reach.js +73 -0
- package/dist/integrity/slopnators/sl_unthreaded.d.ts +56 -0
- package/dist/integrity/slopnators/sl_unthreaded.js +352 -0
- package/dist/integrity/slopnators/sl_untranslated_key.d.ts +2 -0
- package/dist/integrity/slopnators/sl_untranslated_key.js +157 -0
- package/dist/integrity/slopnators/sl_unversioned.d.ts +2 -0
- package/dist/integrity/slopnators/sl_unversioned.js +31 -0
- package/dist/integrity/sweep.d.ts +24 -0
- package/dist/integrity/sweep.js +264 -0
- package/dist/knowledge/attach.d.ts +2 -0
- package/dist/knowledge/attach.js +339 -0
- package/dist/knowledge/generators/docsgen.d.ts +1 -0
- package/dist/knowledge/generators/docsgen.js +145 -0
- package/dist/knowledge/generators/glossary.d.ts +1 -0
- package/dist/knowledge/generators/glossary.js +56 -0
- package/dist/knowledge/generators/skills.d.ts +1 -0
- package/dist/knowledge/generators/skills.js +222 -0
- package/dist/knowledge/glossary.d.ts +6 -0
- package/dist/knowledge/glossary.js +1 -0
- package/dist/operations/access.d.ts +27 -0
- package/dist/operations/access.js +61 -0
- package/dist/operations/attach.d.ts +6 -0
- package/dist/operations/attach.js +414 -0
- package/dist/operations/capabilities.d.ts +37 -0
- package/dist/operations/capabilities.js +35 -0
- package/dist/operations/cost.d.ts +14 -0
- package/dist/operations/cost.js +37 -0
- package/dist/operations/dispatch.d.ts +14 -0
- package/dist/operations/dispatch.js +327 -0
- package/dist/operations/error-results.d.ts +4 -0
- package/dist/operations/error-results.js +25 -0
- package/dist/operations/errors/catalog.d.ts +58 -0
- package/dist/operations/errors/catalog.js +91 -0
- package/dist/operations/errors/classes.d.ts +73 -0
- package/dist/operations/errors/classes.js +22 -0
- package/dist/operations/errors/errors.d.ts +52 -0
- package/dist/operations/errors/errors.js +257 -0
- package/dist/operations/generators/capability.d.ts +5 -0
- package/dist/operations/generators/capability.js +603 -0
- package/dist/operations/generators/compile.d.ts +2 -0
- package/dist/operations/generators/compile.js +541 -0
- package/dist/operations/generators/grant.d.ts +1 -0
- package/dist/operations/generators/grant.js +267 -0
- package/dist/operations/generators/operation.d.ts +1 -0
- package/dist/operations/generators/operation.js +811 -0
- package/dist/operations/generators/permits.d.ts +1 -0
- package/dist/operations/generators/permits.js +196 -0
- package/dist/operations/generators/seed.d.ts +20 -0
- package/dist/operations/generators/seed.js +285 -0
- package/dist/operations/generators/trial.d.ts +53 -0
- package/dist/operations/generators/trial.js +376 -0
- package/dist/operations/http.d.ts +30 -0
- package/dist/operations/http.js +121 -0
- package/dist/operations/makers/capability.d.ts +1 -0
- package/dist/operations/makers/capability.js +117 -0
- package/dist/operations/makers/trial.d.ts +1 -0
- package/dist/operations/makers/trial.js +413 -0
- package/dist/operations/memory.d.ts +104 -0
- package/dist/operations/memory.js +365 -0
- package/dist/operations/metering.d.ts +55 -0
- package/dist/operations/metering.js +247 -0
- package/dist/operations/nodes.d.ts +35 -0
- package/dist/operations/nodes.js +15 -0
- package/dist/operations/operations.d.ts +18 -0
- package/dist/operations/operations.js +5 -0
- package/dist/operations/queue-policy.d.ts +3 -0
- package/dist/operations/queue-policy.js +8 -0
- package/dist/operations/result.d.ts +19 -0
- package/dist/operations/result.js +10 -0
- package/dist/operations/seams.d.ts +239 -0
- package/dist/operations/seams.js +45 -0
- package/dist/operations/store.d.ts +14 -0
- package/dist/operations/store.js +1 -0
- package/package.json +16 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { dirname, join, normalize } from 'node:path';
|
|
2
|
+
import { parser } from 'typescript-eslint';
|
|
3
|
+
import { descend } from "../../core/descend.js";
|
|
4
|
+
import { here, index, parses, unreadable } from "./sl_unthreaded.js";
|
|
5
|
+
const RELATIVE = /^\.\.?\//;
|
|
6
|
+
const UNDER = 'operations/';
|
|
7
|
+
const NODES = 'nodes';
|
|
8
|
+
/*
|
|
9
|
+
* The one thing an operation may be handed to.
|
|
10
|
+
*
|
|
11
|
+
* It is still `dispatch` and it is still the only one. What moved 2026-08-28 is where it lives:
|
|
12
|
+
* it was imported from this framework, and it is now the app's own `operations/_dispatch.ts`,
|
|
13
|
+
* written by `thread compile`. The name a caller writes did not change, so neither did this.
|
|
14
|
+
*/
|
|
15
|
+
const INVOKER = 'dispatch';
|
|
16
|
+
const slash = (one) => one.split('\\').join('/');
|
|
17
|
+
/* Where a name is bound, rather than used. An import is how the operation arrives. */
|
|
18
|
+
const BINDS = new Set(['ImportSpecifier', 'ImportDefaultSpecifier', 'ImportNamespaceSpecifier']);
|
|
19
|
+
/*
|
|
20
|
+
* Where a name is a shape, not a thing.
|
|
21
|
+
*
|
|
22
|
+
* `const one: typeof operation` runs nothing and reaches nothing — it borrows the type and is erased
|
|
23
|
+
* before anything executes. Refusing it was a false positive found by reading, not by running: the
|
|
24
|
+
* probe written to test the reassignment form used exactly this line to type its own variable, and
|
|
25
|
+
* the law would have refused the probe for its type annotation rather than for the bypass under it.
|
|
26
|
+
*/
|
|
27
|
+
const TYPED = new Set(['TSTypeQuery', 'TSTypeReference', 'TSQualifiedName', 'TSImportType']);
|
|
28
|
+
let bodies;
|
|
29
|
+
/*
|
|
30
|
+
* Which files are operation bodies, asked of the tree rather than guessed from a directory.
|
|
31
|
+
*
|
|
32
|
+
* An operation body is a file a row beneath `operations` DERIVES and that is not a node's body, and
|
|
33
|
+
* the descent already knows every one of them. Reading the directory instead was close enough while
|
|
34
|
+
* an operation was the only thing living there — and stopped being close enough the moment an
|
|
35
|
+
* operation was folded into a body plus the steps it is woven from: the woven body imports its own
|
|
36
|
+
* steps, the steps sat under `operations/`, and this law refused the one file it exists to protect
|
|
37
|
+
* for importing the one file it is made of. A rule that refuses the sanctioned form is deciding
|
|
38
|
+
* nothing, which is the same failure this law was rewritten for on 2026-08-25.
|
|
39
|
+
*/
|
|
40
|
+
function operations() {
|
|
41
|
+
if (bodies !== undefined)
|
|
42
|
+
return bodies;
|
|
43
|
+
const pass = descend();
|
|
44
|
+
const under = (id, root, seen) => {
|
|
45
|
+
if (id === root)
|
|
46
|
+
return true;
|
|
47
|
+
if (seen.has(id))
|
|
48
|
+
return false;
|
|
49
|
+
seen.add(id);
|
|
50
|
+
return (pass.coined.get(id)?.from ?? []).some((one) => under(one, root, seen));
|
|
51
|
+
};
|
|
52
|
+
const found = new Set();
|
|
53
|
+
for (const [at, threadings] of pass.threaded) {
|
|
54
|
+
for (const one of threadings) {
|
|
55
|
+
if (one.list !== 'derives')
|
|
56
|
+
continue;
|
|
57
|
+
if (!under(one.by, 'operations', new Set()))
|
|
58
|
+
continue;
|
|
59
|
+
if (under(one.by, NODES, new Set()))
|
|
60
|
+
continue;
|
|
61
|
+
found.add(at);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
bodies = found;
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
/*
|
|
68
|
+
* Whether a path is an operation body.
|
|
69
|
+
*
|
|
70
|
+
* The tree decides for every file the tree reaches. For a file it does not reach — a probe, a
|
|
71
|
+
* scratch file, anything not yet declared — the shape decides: an operation is seeded as one file
|
|
72
|
+
* directly inside `operations/`, so a single segment is a body and a deeper path is something an
|
|
73
|
+
* operation is built from. Nothing the tree does not reach can be part of an app anyway; that
|
|
74
|
+
* fallback is what makes this law's own proofs mean the same thing in every repository.
|
|
75
|
+
*/
|
|
76
|
+
function isBody(path) {
|
|
77
|
+
const known = index().reached;
|
|
78
|
+
if (known.has(path))
|
|
79
|
+
return operations().has(path);
|
|
80
|
+
if (!path.startsWith(UNDER))
|
|
81
|
+
return false;
|
|
82
|
+
return !path.slice(UNDER.length).includes('/');
|
|
83
|
+
}
|
|
84
|
+
const TRIED = ['', '.ts', '.tsx'];
|
|
85
|
+
function reaches(said, from) {
|
|
86
|
+
if (said === '')
|
|
87
|
+
return false;
|
|
88
|
+
const at = slash(normalize(RELATIVE.test(said) ? join(from, said) : said));
|
|
89
|
+
return TRIED.some((one) => isBody(`${at}${one}`));
|
|
90
|
+
}
|
|
91
|
+
/* Whatever a run-time reach spells out, whole or in pieces. */
|
|
92
|
+
function spelled(node, said) {
|
|
93
|
+
if (node === undefined || node === null || typeof node !== 'object')
|
|
94
|
+
return;
|
|
95
|
+
if (node.type === 'Literal' && typeof node.value === 'string')
|
|
96
|
+
said.push(node.value);
|
|
97
|
+
for (const one of node.quasis ?? []) {
|
|
98
|
+
const held = one.value?.cooked ?? one.value?.raw;
|
|
99
|
+
if (typeof held === 'string')
|
|
100
|
+
said.push(held);
|
|
101
|
+
}
|
|
102
|
+
for (const value of Object.values(node)) {
|
|
103
|
+
if (Array.isArray(value))
|
|
104
|
+
for (const one of value)
|
|
105
|
+
spelled(one, said);
|
|
106
|
+
else if (value !== null && typeof value === 'object')
|
|
107
|
+
spelled(value, said);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const IMPORTS = /import\s+((?:type\s+)?[^'"]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
111
|
+
const LOCAL = /(?:^|[,{]|\s)(?:[A-Za-z_$][\w$]*\s+as\s+)?([A-Za-z_$][\w$]*)/g;
|
|
112
|
+
/*
|
|
113
|
+
* `dispatch` IS NOT AN OPERATION, AND IT LIVES WHERE THE OPERATIONS LIVE.
|
|
114
|
+
*
|
|
115
|
+
* `bound` below calls every name imported from under the operations directory an operation, which is
|
|
116
|
+
* right for every file there but one: an app's own dispatch is written to `operations/_dispatch.ts`,
|
|
117
|
+
* so a file importing `dispatch` from beside its operations had `dispatch` itself added to the set —
|
|
118
|
+
* and then this law refused that file for doing something with an operation other than handing it to
|
|
119
|
+
* dispatch. The thing it was accused of mishandling was the handler.
|
|
120
|
+
*
|
|
121
|
+
* It had never surfaced because nothing imported dispatch from inside that directory. Measured
|
|
122
|
+
* 2026-08-30, when the generated client gained a binding that calls the app's own dispatch for an
|
|
123
|
+
* operation needing no server, and the app refused a file its own generator had just written.
|
|
124
|
+
*
|
|
125
|
+
* The other three below are not names at all — they are the words of an import clause that the
|
|
126
|
+
* regex cannot tell from an identifier. This one is a real binding that is really imported and
|
|
127
|
+
* really is not an operation, which is the same answer for a different reason.
|
|
128
|
+
*/
|
|
129
|
+
const NOT_A_NAME = new Set(['type', 'as', 'from', 'import', 'dispatch']);
|
|
130
|
+
/*
|
|
131
|
+
* The names this file bound by importing an operation body.
|
|
132
|
+
*
|
|
133
|
+
* A type-only import binds nothing that can be run and is skipped: `import type { Operation }` names
|
|
134
|
+
* a shape, and a shape cannot be dispatched or reached past.
|
|
135
|
+
*/
|
|
136
|
+
function bound(text, from) {
|
|
137
|
+
const found = new Set();
|
|
138
|
+
for (const one of text.matchAll(IMPORTS)) {
|
|
139
|
+
const clause = one[1] ?? '';
|
|
140
|
+
if (/^type\s/.test(clause))
|
|
141
|
+
continue;
|
|
142
|
+
if (!reaches(one[2] ?? '', from))
|
|
143
|
+
continue;
|
|
144
|
+
for (const name of clause.matchAll(LOCAL)) {
|
|
145
|
+
const held = name[1];
|
|
146
|
+
if (held !== undefined && !NOT_A_NAME.has(held))
|
|
147
|
+
found.add(held);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return found;
|
|
151
|
+
}
|
|
152
|
+
/*
|
|
153
|
+
* Parsed here rather than taken from the visitor.
|
|
154
|
+
*
|
|
155
|
+
* `holds` is handed a node when a law runs as an eslint rule and nothing at all when the sweep runs
|
|
156
|
+
* it over a file — measured 2026-08-25, the sweep calls it once per file with `node` undefined, so a
|
|
157
|
+
* reading that only inspected what it was given threw on the first file it saw. What a file DOES
|
|
158
|
+
* with a binding is a question about the whole file anyway, not about one node in it.
|
|
159
|
+
*
|
|
160
|
+
* The parent comes down with the node because the whole rule is about where a name appears.
|
|
161
|
+
*/
|
|
162
|
+
function walked(node, up, seen) {
|
|
163
|
+
if (node === null || typeof node !== 'object')
|
|
164
|
+
return undefined;
|
|
165
|
+
if (Array.isArray(node)) {
|
|
166
|
+
for (const one of node) {
|
|
167
|
+
const said = walked(one, up, seen);
|
|
168
|
+
if (said !== undefined)
|
|
169
|
+
return said;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const held = node;
|
|
174
|
+
const real = typeof held.type === 'string';
|
|
175
|
+
if (real) {
|
|
176
|
+
const said = seen(held, up);
|
|
177
|
+
if (said !== undefined)
|
|
178
|
+
return said;
|
|
179
|
+
}
|
|
180
|
+
for (const value of Object.values(node)) {
|
|
181
|
+
const said = walked(value, real ? held : up, seen);
|
|
182
|
+
if (said !== undefined)
|
|
183
|
+
return said;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
export const slopnator = {
|
|
188
|
+
holds: (_node, at) => {
|
|
189
|
+
const path = here(at.filename);
|
|
190
|
+
if (unreadable(path, at.text) !== undefined)
|
|
191
|
+
return false;
|
|
192
|
+
if (!parses(path))
|
|
193
|
+
return false;
|
|
194
|
+
const text = String(at.text);
|
|
195
|
+
if (!text.includes(UNDER))
|
|
196
|
+
return false;
|
|
197
|
+
const from = dirname(path);
|
|
198
|
+
const mine = bound(text, from);
|
|
199
|
+
let ast;
|
|
200
|
+
try {
|
|
201
|
+
ast = parser.parseForESLint(text, {
|
|
202
|
+
range: true,
|
|
203
|
+
ecmaVersion: 'latest',
|
|
204
|
+
sourceType: 'module',
|
|
205
|
+
ecmaFeatures: { jsx: true },
|
|
206
|
+
}).ast;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
/*
|
|
212
|
+
* One rule, stated once.
|
|
213
|
+
*
|
|
214
|
+
* The first reading of this enumerated the SHAPES of a bypass — a member access, a call, an
|
|
215
|
+
* argument — and every shape it did not think of walked through: an alias, a destructure, an
|
|
216
|
+
* array holding the name, a comma operator. Enumerating forms is how a refusal ends up with a
|
|
217
|
+
* list of everything somebody happened to try. The rule is not a list. An operation goes to
|
|
218
|
+
* `dispatch` and nowhere else, so the name may appear where it is bound and as an argument to
|
|
219
|
+
* `dispatch`, and every other appearance is the finding — whatever it happens to look like.
|
|
220
|
+
*/
|
|
221
|
+
return (walked(ast, undefined, (held, parent) => {
|
|
222
|
+
/*
|
|
223
|
+
* A body reached at run time never binds a name at all, so the rule above cannot see it.
|
|
224
|
+
*
|
|
225
|
+
* What is read is everything the reach spells out, whole or in pieces: a plain literal, a
|
|
226
|
+
* concatenation, a template. Measured 2026-08-26 against the reading this replaces, which
|
|
227
|
+
* only ever inspected a plain `Literal` — `await import('../operations/' + name + '.ts')`
|
|
228
|
+
* passed it without a word, which is the exact form its own comment said it was written to
|
|
229
|
+
* catch. Nothing legitimate needs any of it: dispatch is handed the operation, and the one
|
|
230
|
+
* file that hands it over imports it statically.
|
|
231
|
+
*/
|
|
232
|
+
const dynamic = held.type === 'ImportExpression'
|
|
233
|
+
? held.source
|
|
234
|
+
: held.type === 'CallExpression' && held.callee?.type === 'Identifier' && held.callee.name === 'require'
|
|
235
|
+
? held.arguments?.[0]
|
|
236
|
+
: undefined;
|
|
237
|
+
if (dynamic !== undefined) {
|
|
238
|
+
const said = [];
|
|
239
|
+
spelled(dynamic, said);
|
|
240
|
+
const reaching = said.find((one) => one.includes(UNDER) || reaches(one, from));
|
|
241
|
+
if (reaching !== undefined) {
|
|
242
|
+
return (`this reaches \`${reaching}\` at run time, which points at an operation. An operation is handed ` +
|
|
243
|
+
`to \`${INVOKER}\`, and a file that goes looking for one is looking for a second way in`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (held.type !== 'Identifier' || typeof held.name !== 'string' || !mine.has(held.name))
|
|
247
|
+
return undefined;
|
|
248
|
+
const up = parent?.type ?? '';
|
|
249
|
+
if (BINDS.has(up) || TYPED.has(up))
|
|
250
|
+
return undefined;
|
|
251
|
+
if (parent?.type === 'CallExpression' &&
|
|
252
|
+
parent.callee?.type === 'Identifier' &&
|
|
253
|
+
parent.callee.name === INVOKER &&
|
|
254
|
+
(parent.arguments ?? []).includes(held)) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
return (`\`${held.name}\` is an operation, and this does something with it other than hand it to ` +
|
|
258
|
+
`\`${INVOKER}\`. Every gate that decides whether it may run is in \`${INVOKER}\` — naming it, ` +
|
|
259
|
+
'aliasing it, taking it apart or passing it on all reach past every one of them');
|
|
260
|
+
}) ?? false);
|
|
261
|
+
},
|
|
262
|
+
/*
|
|
263
|
+
* These were driven at `holds` directly, because the trial harness cannot run them.
|
|
264
|
+
*
|
|
265
|
+
* A shipped law is meant to be proved by `thread trials`, and today nothing proves one anywhere:
|
|
266
|
+
* the harness cannot run in this repository at all — the framework has no tree, and the command
|
|
267
|
+
* that once tried was removed in 6e98a6d after it was measured exiting 1 — and in an app
|
|
268
|
+
* `thread trials` answers "0 trials from the 0 rule(s) this tree declares",
|
|
269
|
+
* because a shipped constraint is proved in the package that ships it. So every proof below was
|
|
270
|
+
* written to a real probe file and handed to `slopnator.holds` by hand, 2026-08-26: fourteen
|
|
271
|
+
* refused, six accepted, no failures. That is a stand-in for the harness and it is written down
|
|
272
|
+
* here rather than only in a report, because a proof nobody can run is indistinguishable from a
|
|
273
|
+
* proof that passes, and the next person to read this list deserves to know which one it is.
|
|
274
|
+
*/
|
|
275
|
+
proves: {
|
|
276
|
+
wrong: [
|
|
277
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nawait operation.run(input)",
|
|
278
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nconst { run } = operation\n\nawait run(hands, held)",
|
|
279
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nawait operation(input)",
|
|
280
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nawait somewhere(operation, input)",
|
|
281
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nconst one = operation\n\nawait one.run(input)",
|
|
282
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nconst { requires } = operation",
|
|
283
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nlet one\none = operation\nawait one.run(input)",
|
|
284
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nconst [one] = [operation]\n\nawait one.run(input)",
|
|
285
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nawait (0, operation).run(input)",
|
|
286
|
+
"import { operation } from './operations/wf_send_invoice.ts'\n\nexport { operation }",
|
|
287
|
+
"const held = await import('./operations/wf_send_invoice.ts')",
|
|
288
|
+
"const held = require('../operations/wf_send_invoice.ts')",
|
|
289
|
+
"const held = await import('../operations/' + name + '.ts')",
|
|
290
|
+
'const held = await import(`../operations/${name}.ts`)',
|
|
291
|
+
],
|
|
292
|
+
right: [
|
|
293
|
+
"import { dispatch } from './operations/_dispatch.ts'\n\nimport { operation } from './operations/wf_send_invoice.ts'\n\nawait dispatch(operation, input, actor, seams)",
|
|
294
|
+
"import type { Operation } from '../../operations/operations.ts'\n\nexport const held: Operation = one",
|
|
295
|
+
"import { dispatch } from './operations/_dispatch.ts'\n\nawait dispatch(one, input, actor, seams)",
|
|
296
|
+
"import { dispatch } from './operations/_dispatch.ts'\n\nimport { operation } from './operations/wf_send_invoice.ts'\n\nlet one: typeof operation\n\nawait dispatch(operation, input, actor, seams)",
|
|
297
|
+
"import * as steps from './operations/wf_send_invoice/steps.ts'\n\nexport const held = steps.reads_the_invoice",
|
|
298
|
+
"import * as steps from './steps/wf_send_invoice.ts'\n\nexport const held = steps.reads_the_invoice",
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { findTree } from "../../core/tree.js";
|
|
4
|
+
const ROOT = findTree().at;
|
|
5
|
+
const IMPORTS = /(?:^|\n)\s*import\s[^'"]*['"]([^'"]+)['"]/g;
|
|
6
|
+
const target = (from, spec) => spec.startsWith('.') ? resolve(dirname(from), spec) : undefined;
|
|
7
|
+
export const slopnator = {
|
|
8
|
+
on: ['ImportDeclaration'],
|
|
9
|
+
holds: (node, at) => {
|
|
10
|
+
const spec = node.source?.value;
|
|
11
|
+
if (typeof spec !== 'string')
|
|
12
|
+
return false;
|
|
13
|
+
const here = resolve(ROOT, at.filename);
|
|
14
|
+
const there = target(here, spec);
|
|
15
|
+
if (there === undefined || !existsSync(there))
|
|
16
|
+
return false;
|
|
17
|
+
for (const found of readFileSync(there, 'utf8').matchAll(IMPORTS)) {
|
|
18
|
+
const back = found[1] ?? '';
|
|
19
|
+
if (target(there, back) === here)
|
|
20
|
+
return `${spec} imports \`${back}\` straight back, so the two initialise each other`;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
},
|
|
24
|
+
proves: {
|
|
25
|
+
wrong: [
|
|
26
|
+
{
|
|
27
|
+
files: [
|
|
28
|
+
{ at: 'threads/_probe_one.ts', source: "import { two } from './_probe_two.ts'\nexport const one = two\n" },
|
|
29
|
+
{ at: 'threads/_probe_two.ts', source: "import { one } from './_probe_one.ts'\nexport const two = one\n" },
|
|
30
|
+
],
|
|
31
|
+
at: 'threads/_probe_one.ts',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
right: [
|
|
35
|
+
"import type { Law } from '../../core/generators/meta.ts'",
|
|
36
|
+
"import { SLUG, type Shaped } from '../../core/types.ts'",
|
|
37
|
+
"import { existsSync } from 'node:fs'",
|
|
38
|
+
"import type { Rule } from 'eslint'",
|
|
39
|
+
"import { CONSTRAINTS } from '../constraint.ts'",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { findTree } from "../../core/tree.js";
|
|
4
|
+
const ROOT = findTree().at;
|
|
5
|
+
const keyOf = (property) => {
|
|
6
|
+
if (property.computed === true)
|
|
7
|
+
return undefined;
|
|
8
|
+
const key = property.key;
|
|
9
|
+
if (key?.type === 'Identifier')
|
|
10
|
+
return key.name;
|
|
11
|
+
if (key?.type === 'Literal' && typeof key.value === 'string')
|
|
12
|
+
return key.value;
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
function scripts() {
|
|
16
|
+
try {
|
|
17
|
+
const held = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8'));
|
|
18
|
+
return new Set(Object.keys(held.scripts ?? {}));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return new Set();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const slopnator = {
|
|
25
|
+
on: ['Property'],
|
|
26
|
+
holds: (node) => {
|
|
27
|
+
const property = node;
|
|
28
|
+
const key = keyOf(property);
|
|
29
|
+
if (key !== 'runs' && key !== 'by')
|
|
30
|
+
return false;
|
|
31
|
+
const held = property.value;
|
|
32
|
+
if (held?.type !== 'Literal' || typeof held.value !== 'string')
|
|
33
|
+
return false;
|
|
34
|
+
const spoken = held.value.trim();
|
|
35
|
+
if (!spoken.startsWith('pnpm '))
|
|
36
|
+
return false;
|
|
37
|
+
const named = spoken.replace(/^pnpm (run )?/, '').split(/\s+/)[0] ?? '';
|
|
38
|
+
const known = scripts();
|
|
39
|
+
if (known.size === 0 || known.has(named))
|
|
40
|
+
return false;
|
|
41
|
+
return `\`${key}: ${spoken}\` names the script \`${named}\`, and package.json has no such script`;
|
|
42
|
+
},
|
|
43
|
+
proves: {
|
|
44
|
+
wrong: [
|
|
45
|
+
"const M = {\n runs: 'pnpm nope:check',\n}",
|
|
46
|
+
"const M = {\n by: 'pnpm also-not-a-script',\n}",
|
|
47
|
+
],
|
|
48
|
+
right: [
|
|
49
|
+
"const M = {\n runs: 'pnpm typecheck',\n}",
|
|
50
|
+
"const M = {\n by: 'pnpm gate:generate',\n}",
|
|
51
|
+
"const M = {\n runs: 'node scripts/_generated/gate.mjs',\n}",
|
|
52
|
+
"const M = {\n describes: 'A sentence mentioning pnpm nope:check without naming it as a command.',\n}",
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const slopnator = {
|
|
2
|
+
on: ['CallExpression'],
|
|
3
|
+
holds: (node) => {
|
|
4
|
+
const held = node;
|
|
5
|
+
if (held.callee?.type !== 'Identifier' || held.callee.name !== 'only')
|
|
6
|
+
return false;
|
|
7
|
+
if (held.arguments?.length !== 3)
|
|
8
|
+
return false;
|
|
9
|
+
const keys = held.arguments[1];
|
|
10
|
+
if (keys?.type !== 'ArrayExpression')
|
|
11
|
+
return false;
|
|
12
|
+
const typed = (keys.elements ?? []).filter((one) => one?.type !== 'SpreadElement');
|
|
13
|
+
if (typed.length === 0)
|
|
14
|
+
return false;
|
|
15
|
+
return `it hands \`only\` ${String(typed.length)} key(s) written here, and the receipt prints its questions from somewhere else`;
|
|
16
|
+
},
|
|
17
|
+
proves: {
|
|
18
|
+
wrong: [
|
|
19
|
+
{
|
|
20
|
+
files: [
|
|
21
|
+
{
|
|
22
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
23
|
+
source: "only(piped, ['name', 'describes'], 'a thing')\n",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
files: [
|
|
30
|
+
{
|
|
31
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
32
|
+
source: "only(one, ['stop', 'sets'], 'a stop')\n",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
right: [
|
|
39
|
+
{
|
|
40
|
+
files: [
|
|
41
|
+
{
|
|
42
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
43
|
+
source: "const TAKES = ASKS.map((one) => one.key)\nonly(piped, TAKES, 'a thing')\n",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
files: [
|
|
50
|
+
{
|
|
51
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
52
|
+
source: "only(all, [...allowed, ...worded], 'a thing')\n",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
at: 'packages/core/src/integrations/generators/probe.ts',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Credentials that announce themselves.
|
|
3
|
+
*
|
|
4
|
+
* A vendor prefix is the one thing here that is certain rather than probable: nothing else in a
|
|
5
|
+
* source file begins `sk_live_` or `AKIA` followed by twenty characters. These are matched on their
|
|
6
|
+
* own, with no need to look at what they were assigned to.
|
|
7
|
+
*/
|
|
8
|
+
const ANNOUNCED = [
|
|
9
|
+
{ what: 'a Stripe secret key', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}/ },
|
|
10
|
+
{ what: 'a Stripe restricted key', pattern: /\brk_(?:live|test)_[A-Za-z0-9]{16,}/ },
|
|
11
|
+
{ what: 'a GitHub token', pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}/ },
|
|
12
|
+
{ what: 'a GitHub personal access token', pattern: /\bgithub_pat_[A-Za-z0-9_]{30,}/ },
|
|
13
|
+
{ what: 'an AWS access key id', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
|
|
14
|
+
{ what: 'a Slack token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/ },
|
|
15
|
+
{ what: 'an OpenAI key', pattern: /\bsk-[A-Za-z0-9_-]{32,}/ },
|
|
16
|
+
{ what: 'an Anthropic key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
17
|
+
{ what: 'a Google API key', pattern: /\bAIza[A-Za-z0-9_-]{35}\b/ },
|
|
18
|
+
{ what: 'a JSON web token', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
19
|
+
{ what: 'a private key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
|
20
|
+
{ what: 'a password inside a connection string', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]{6,}@/ },
|
|
21
|
+
];
|
|
22
|
+
/*
|
|
23
|
+
* A value that does not announce itself, read off what it was called.
|
|
24
|
+
*
|
|
25
|
+
* Needs both halves — a name that means credential AND something long enough and mixed enough to be
|
|
26
|
+
* one. Either alone is noise: `const key = 'id'` is a map key, and a long opaque string is as likely
|
|
27
|
+
* to be a hash, a path or a fixture. `process.env.X`, an import, a template hole and a reference to
|
|
28
|
+
* a declared row are all values rather than literals and are not matched at all.
|
|
29
|
+
*/
|
|
30
|
+
const NAMED = /\b(?:secret|password|passwd|api_?key|apikey|access_?token|auth_?token|private_?key|client_?secret|credential)s?\b\s*[:=]\s*(['"`])([^'"`\n]{16,})\1/gi;
|
|
31
|
+
const MIXED = /^(?=.*[a-z])(?=.*[A-Z0-9])[A-Za-z0-9_+/=.-]{16,}$/;
|
|
32
|
+
/*
|
|
33
|
+
* What is deliberately NOT a finding.
|
|
34
|
+
*
|
|
35
|
+
* A secret this app declares is a NAME, and naming one is the whole remedy this law recommends — so
|
|
36
|
+
* `sec_stripe_key` sitting in a row, a `requires`, or a call to the vault must never be the thing
|
|
37
|
+
* that trips it. The same for an environment variable, which is a value read at run time rather than
|
|
38
|
+
* a value written down, and for the obvious placeholders people write while wiring something up.
|
|
39
|
+
*/
|
|
40
|
+
const NOT = [
|
|
41
|
+
/^(?:sec|pub)_[a-z0-9_]+$/,
|
|
42
|
+
/^\$\{/,
|
|
43
|
+
/^(?:x{3,}|X{3,}|\.{3,}|<[^>]+>|change[-_]?me|your[-_]|example|placeholder|redacted|todo|test|dummy|sample|fake|dGhpcyBpcyBub3QgYSBzZWNyZXQ)/i,
|
|
44
|
+
];
|
|
45
|
+
const CODE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|json|env|ya?ml|toml|sql|md|sh)$/;
|
|
46
|
+
export const slopnator = {
|
|
47
|
+
holds: (_node, at) => {
|
|
48
|
+
if (!CODE.test(at.filename))
|
|
49
|
+
return false;
|
|
50
|
+
/*
|
|
51
|
+
* The sealed store is ciphertext and is meant to be here.
|
|
52
|
+
*
|
|
53
|
+
* It is base64 by construction, so the entropy half below would find every value in it — and
|
|
54
|
+
* the fix it would recommend is the thing the file already is. A law whose refusal has no honest
|
|
55
|
+
* remedy is one people learn to ignore.
|
|
56
|
+
*/
|
|
57
|
+
if (/(?:^|\/)\.thread\/vault\.sealed\.json$/.test(at.filename))
|
|
58
|
+
return false;
|
|
59
|
+
const text = String(at.text);
|
|
60
|
+
for (const one of ANNOUNCED) {
|
|
61
|
+
if (!one.pattern.test(text))
|
|
62
|
+
continue;
|
|
63
|
+
return (`this holds ${one.what}, written into the file. It is in every copy of this repository, in its history for ` +
|
|
64
|
+
'ever, and in whatever bundle reaches it');
|
|
65
|
+
}
|
|
66
|
+
for (const found of text.matchAll(NAMED)) {
|
|
67
|
+
const value = found[2] ?? '';
|
|
68
|
+
if (NOT.some((each) => each.test(value)))
|
|
69
|
+
continue;
|
|
70
|
+
if (!MIXED.test(value))
|
|
71
|
+
continue;
|
|
72
|
+
return (`this writes a ${(found[0].split(/[:=]/)[0] ?? '').trim()} down as a literal ${String(value.length)} characters ` +
|
|
73
|
+
'long. A credential in a file is in every copy of this repository and in its history for ever');
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
},
|
|
77
|
+
proves: {
|
|
78
|
+
wrong: [
|
|
79
|
+
"export const stripe = { apiKey: 'sk_live_51H8xQvL2eZvKYlo2C0Nx9K4mQwErTyUiOp' }\n",
|
|
80
|
+
"const config = { password: 'Hunter2Hunter2Hunter2' }\n",
|
|
81
|
+
"export const db = 'postgres://app:s3cr3tpassword@db.example.com:5432/main'\n",
|
|
82
|
+
],
|
|
83
|
+
right: [
|
|
84
|
+
"export const stripe = { apiKey: 'sec_stripe_key' }\n",
|
|
85
|
+
"const config = { password: process.env['DB_PASSWORD'] }\n",
|
|
86
|
+
"export const node = nd_action(async (hands) => await hands.ht_stripe.charge({ json: { amount: 100 } }))\n",
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { descend } from "../../core/descend.js";
|
|
4
|
+
import { findTree } from "../../core/tree.js";
|
|
5
|
+
const ROOT = findTree().at;
|
|
6
|
+
const NAMES_A_FILE = /\.[a-z]{1,5}$/;
|
|
7
|
+
/*
|
|
8
|
+
* A FILE A GENERATOR WRITES IS ABSENT UNTIL THE GENERATOR HAS RUN, AND THAT IS NOT A STALE PATH.
|
|
9
|
+
*
|
|
10
|
+
* This law and the descent disagreed about the same file, and the descent was right. A row carrying
|
|
11
|
+
* `by:` names a file a command writes; the descent reports it missing as a WARNING for exactly that
|
|
12
|
+
* reason — "a derived file is absent until it is derived". This read the same path with a bare
|
|
13
|
+
* `existsSync` and refused it, with advice — "fix the path, or delete the sentence naming it" —
|
|
14
|
+
* that would have deleted a correct declaration.
|
|
15
|
+
*
|
|
16
|
+
* Measured 2026-08-29 on a real install: `thread init`, `thread attach @thread/integrations`,
|
|
17
|
+
* `thread gate`, and the sweep was red on `_threads/glossary.ts` — a file `thread glossary` writes,
|
|
18
|
+
* on the first gate run of an app whose author had written nothing. Every other check in that gate
|
|
19
|
+
* answers this state by saying there is nothing to check yet.
|
|
20
|
+
*
|
|
21
|
+
* Read off the tree rather than guessed from the text. The descent already knows which rows carry a
|
|
22
|
+
* `by`, so the two can no longer come apart, and a path nothing derives is refused exactly as before.
|
|
23
|
+
*/
|
|
24
|
+
const derived = (() => {
|
|
25
|
+
const found = new Set();
|
|
26
|
+
try {
|
|
27
|
+
const pass = descend();
|
|
28
|
+
for (const [at, held] of pass.threaded) {
|
|
29
|
+
if (held.some((one) => one.row.by !== undefined))
|
|
30
|
+
found.add(at);
|
|
31
|
+
}
|
|
32
|
+
for (const one of pass.derived.values())
|
|
33
|
+
if (one.at !== undefined)
|
|
34
|
+
found.add(one.at);
|
|
35
|
+
}
|
|
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
|
+
const ours = () => {
|
|
42
|
+
try {
|
|
43
|
+
return new Set(readdirSync(ROOT).filter((one) => one !== 'node_modules' && statSync(resolve(ROOT, one), { throwIfNoEntry: false })?.isDirectory() === true));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return new Set();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
export const slopnator = {
|
|
50
|
+
on: ['Literal'],
|
|
51
|
+
holds: (node) => {
|
|
52
|
+
const value = node.value;
|
|
53
|
+
if (typeof value !== 'string' || !value.includes('/'))
|
|
54
|
+
return false;
|
|
55
|
+
const tops = ours();
|
|
56
|
+
if (tops.size === 0 || !tops.has(value.split('/')[0] ?? ''))
|
|
57
|
+
return false;
|
|
58
|
+
const brace = value.indexOf('{');
|
|
59
|
+
if (brace !== -1) {
|
|
60
|
+
const cut = value.lastIndexOf('/', brace);
|
|
61
|
+
const dir = cut === -1 ? '.' : value.slice(0, cut);
|
|
62
|
+
if (statSync(resolve(ROOT, dir), { throwIfNoEntry: false })?.isDirectory() === true)
|
|
63
|
+
return false;
|
|
64
|
+
return `\`${value}\` writes into \`${dir}\`, which is not a directory on disk`;
|
|
65
|
+
}
|
|
66
|
+
if (!NAMES_A_FILE.test(value))
|
|
67
|
+
return false;
|
|
68
|
+
if (existsSync(resolve(ROOT, value)))
|
|
69
|
+
return false;
|
|
70
|
+
if (derived.has(value))
|
|
71
|
+
return false;
|
|
72
|
+
return `\`${value}\` is not on disk`;
|
|
73
|
+
},
|
|
74
|
+
proves: {
|
|
75
|
+
wrong: [
|
|
76
|
+
"const at = 'threads/nope.ts'",
|
|
77
|
+
"const at = 'scripts/gone.ts'",
|
|
78
|
+
"const at = 'docs/nowhere/{page}.md'",
|
|
79
|
+
"const at = 'migrations/gone.sql'",
|
|
80
|
+
"const at = 'plans/gone.md'",
|
|
81
|
+
],
|
|
82
|
+
right: [
|
|
83
|
+
"const at = 'packages/core/integrity/src/constraint.ts'",
|
|
84
|
+
"const at = 'packages/core/integrity/src/lint.ts'",
|
|
85
|
+
"const at = 'docs/reference/{page}.md'",
|
|
86
|
+
"const at = 'threads/'",
|
|
87
|
+
"const said = 'a sentence about packages/core/integrity/src/constraint.ts and nothing else'",
|
|
88
|
+
"const at = 'app/layout.tsx'",
|
|
89
|
+
"const at = 'node_modules/thread/package.json'",
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|