@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,164 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
5
|
+
import { named as packageOf } from '../../core/scope.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* A KEY THE INTERFACE NAMES AND A LANGUAGE DOES NOT ANSWER.
|
|
9
|
+
*
|
|
10
|
+
* "i18n keys must be translated OR ITS AN ERROR ... a new constraint.ts row and slopnator that
|
|
11
|
+
* checks i18n entries against the keys in the UI and if there's anything missing in whatever
|
|
12
|
+
* file, it's a build error, compile error, gate error, everything. Cannot be allowed."
|
|
13
|
+
* — founder, 2026-08-29
|
|
14
|
+
*
|
|
15
|
+
* The generator already writes a line into every language file for every key it finds, so a key is
|
|
16
|
+
* never structurally absent — what it cannot do is invent the sentence. This is the half that
|
|
17
|
+
* refuses: a line holding nothing is a key nobody has answered, and an app that ships one shows a
|
|
18
|
+
* person an empty button.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately independent of the generator rather than calling it. A check that runs the thing it
|
|
21
|
+
* is checking passes whenever that thing passes, which is no check at all — this reads the files
|
|
22
|
+
* that are actually on disk and compares them to the markup that is actually there.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SKIP = new Set(['node_modules', '.next', '.git', 'dist', '_generated'])
|
|
26
|
+
|
|
27
|
+
const CODE = /\.(tsx?|jsx?)$/
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* Which props carry a key, read off the installed package's own declarations.
|
|
31
|
+
*
|
|
32
|
+
* A list written here would go stale the first time an element gained a prop — silently, by finding
|
|
33
|
+
* fewer keys than there are, which is the failure this whole law exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
const TYPED = /readonly\s+([A-Za-z][A-Za-z0-9]*)\??\s*:\s*MessageKey\b/g
|
|
36
|
+
|
|
37
|
+
function walk(at: string, found: string[] = []): readonly string[] {
|
|
38
|
+
let entries: readonly { name: string; isDirectory: () => boolean }[]
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(at, { withFileTypes: true })
|
|
41
|
+
} catch {
|
|
42
|
+
return found
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (SKIP.has(entry.name)) continue
|
|
46
|
+
const path = join(at, entry.name)
|
|
47
|
+
if (entry.isDirectory()) walk(path, found)
|
|
48
|
+
else if (CODE.test(entry.name)) found.push(path)
|
|
49
|
+
}
|
|
50
|
+
return found
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function keyProps(root: string): readonly string[] {
|
|
54
|
+
const found = new Set<string>()
|
|
55
|
+
const home = join(root, 'node_modules', packageOf('ui'))
|
|
56
|
+
if (!existsSync(home)) return []
|
|
57
|
+
for (const file of walk(home)) {
|
|
58
|
+
for (const one of readFileSync(file, 'utf8').matchAll(TYPED)) found.add(one[1] ?? '')
|
|
59
|
+
}
|
|
60
|
+
return [...found].sort()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function languagesIn(root: string): { readonly held: readonly string[]; readonly source: string | undefined } {
|
|
64
|
+
const at = join(root, '_threads', 'localizations.ts')
|
|
65
|
+
if (!existsSync(at)) return { held: [], source: undefined }
|
|
66
|
+
const text = readFileSync(at, 'utf8')
|
|
67
|
+
const held: string[] = []
|
|
68
|
+
let source: string | undefined
|
|
69
|
+
for (const one of text.matchAll(/id:\s*(?:SOURCE_ROW|'([^']+)')\s*,[\s\S]{0,200}?is:\s*'([^']*)'/g)) {
|
|
70
|
+
const id = one[1]
|
|
71
|
+
if (id === undefined || id === 'source') {
|
|
72
|
+
source = one[2]
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
held.push(id)
|
|
76
|
+
}
|
|
77
|
+
return { held, source }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const slopnator: Slopnator = {
|
|
81
|
+
checks: (root) => {
|
|
82
|
+
const { held } = languagesIn(root)
|
|
83
|
+
if (held.length === 0) return []
|
|
84
|
+
|
|
85
|
+
const props = keyProps(root)
|
|
86
|
+
if (props.length === 0) return []
|
|
87
|
+
|
|
88
|
+
const named = new RegExp(`\\b(?:${props.join('|')})\\s*=\\s*"([^"]+)"`, 'g')
|
|
89
|
+
|
|
90
|
+
const used = new Map<string, string>()
|
|
91
|
+
for (const file of walk(root)) {
|
|
92
|
+
const text = readFileSync(file, 'utf8')
|
|
93
|
+
for (const one of text.matchAll(named)) {
|
|
94
|
+
const key = one[1] ?? ''
|
|
95
|
+
if (!used.has(key)) used.set(key, file.slice(root.length).replace(/^\/+/, ''))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (used.size === 0) return []
|
|
99
|
+
|
|
100
|
+
const findings: string[] = []
|
|
101
|
+
for (const language of held) {
|
|
102
|
+
const at = join(root, 'i18n', `${language}.json`)
|
|
103
|
+
if (!existsSync(at)) {
|
|
104
|
+
findings.push(
|
|
105
|
+
`i18n/${language}.json is not there and \`${language}\` is a declared language, so every key this ` +
|
|
106
|
+
'interface names goes unanswered in it — run `thread i18n`',
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
let wording: Readonly<Record<string, unknown>>
|
|
111
|
+
try {
|
|
112
|
+
wording = JSON.parse(readFileSync(at, 'utf8')) as Readonly<Record<string, unknown>>
|
|
113
|
+
} catch {
|
|
114
|
+
findings.push(
|
|
115
|
+
`i18n/${language}.json will not parse, so what this app says in \`${language}\` cannot be known — ` +
|
|
116
|
+
'a reader that cannot read is not a reader that found nothing',
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
for (const [key, where] of [...used].sort()) {
|
|
121
|
+
const said = wording[key]
|
|
122
|
+
if (typeof said === 'string' && said !== '') continue
|
|
123
|
+
findings.push(
|
|
124
|
+
`\`${key}\` is named by ${where} and says nothing in \`${language}\` — an untranslated key is a ` +
|
|
125
|
+
'blank on the screen, and it is an error rather than a word that comes out in the wrong language',
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return findings
|
|
130
|
+
},
|
|
131
|
+
proves: {
|
|
132
|
+
wrong: [
|
|
133
|
+
{
|
|
134
|
+
files: [
|
|
135
|
+
{ at: '_threads/localizations.ts', source: "export const LOCALIZATION = {\n holds: [\n { id: 'source', is: 'en' },\n { id: 'en', is: 'English' },\n ],\n}\n" },
|
|
136
|
+
{ at: `node_modules/${packageOf('ui')}/probe.ts`, source: 'export interface P {\n readonly label: MessageKey\n}\n' },
|
|
137
|
+
{ at: 'compositions/cmp_probe.tsx', source: 'export const P = () => <Button label="probe.blank" />\n' },
|
|
138
|
+
{ at: 'i18n/en.json', source: '{\n "probe.blank": ""\n}\n' },
|
|
139
|
+
],
|
|
140
|
+
at: 'compositions/cmp_probe.tsx',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
files: [
|
|
144
|
+
{ at: '_threads/localizations.ts', source: "export const LOCALIZATION = {\n holds: [\n { id: 'source', is: 'en' },\n { id: 'en', is: 'English' },\n ],\n}\n" },
|
|
145
|
+
{ at: `node_modules/${packageOf('ui')}/probe.ts`, source: 'export interface P {\n readonly label: MessageKey\n}\n' },
|
|
146
|
+
{ at: 'compositions/cmp_probe.tsx', source: 'export const P = () => <Button label="probe.missing" />\n' },
|
|
147
|
+
{ at: 'i18n/en.json', source: '{\n "probe.other": "Something"\n}\n' },
|
|
148
|
+
],
|
|
149
|
+
at: 'compositions/cmp_probe.tsx',
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
right: [
|
|
153
|
+
{
|
|
154
|
+
files: [
|
|
155
|
+
{ at: '_threads/localizations.ts', source: "export const LOCALIZATION = {\n holds: [\n { id: 'source', is: 'en' },\n { id: 'en', is: 'English' },\n ],\n}\n" },
|
|
156
|
+
{ at: `node_modules/${packageOf('ui')}/probe.ts`, source: 'export interface P {\n readonly label: MessageKey\n}\n' },
|
|
157
|
+
{ at: 'compositions/cmp_probe.tsx', source: 'export const P = () => <Button label="probe.answered" />\n' },
|
|
158
|
+
{ at: 'i18n/en.json', source: '{\n "probe.answered": "Answered"\n}\n' },
|
|
159
|
+
],
|
|
160
|
+
at: 'compositions/cmp_probe.tsx',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Slopnator } from '../slopnator.ts'
|
|
2
|
+
import { here, parses, rowsIn } from './sl_unthreaded.ts'
|
|
3
|
+
|
|
4
|
+
export const slopnator: Slopnator = {
|
|
5
|
+
holds: (_node, at) => {
|
|
6
|
+
const path = here(at.filename)
|
|
7
|
+
|
|
8
|
+
if (!parses(path)) return false
|
|
9
|
+
|
|
10
|
+
let governing: readonly { readonly list?: string; readonly id?: string; readonly version?: number }[]
|
|
11
|
+
try {
|
|
12
|
+
governing = rowsIn(at.text)
|
|
13
|
+
} catch {
|
|
14
|
+
return 'this file cannot be parsed, so the declarations it governs with were never read and nothing about their versions was measured'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const node of governing) {
|
|
18
|
+
if (node.list !== undefined || node.version !== undefined) continue
|
|
19
|
+
return (
|
|
20
|
+
`"${node.id ?? ''}" is annotated with the interface that makes other things conform to it and states no \`version\`, ` +
|
|
21
|
+
'so a change to what it governs is a diff nobody downstream can detect and nothing can be carried forward from the shape it used to have'
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return false
|
|
26
|
+
},
|
|
27
|
+
proves: {
|
|
28
|
+
wrong: [
|
|
29
|
+
"export const L: Constraint = {\n id: 'a-law',\n at: 'packages/core/integrity/src/constraint.ts',\n describes: 'A rule that must be enforced.',\n}",
|
|
30
|
+
],
|
|
31
|
+
right: [
|
|
32
|
+
"export const L: Constraint = {\n id: 'a-law',\n at: 'packages/core/integrity/src/constraint.ts',\n describes: 'A rule that must be enforced.',\n version: 3,\n}",
|
|
33
|
+
"export const HELPER = {\n id: 'a-helper',\n at: 'threads/meta.ts',\n describes: 'Nothing conforms to this.',\n}",
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { stdout } from 'node:process'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import { descend } from '../core/descend.ts'
|
|
8
|
+
import { runChecker, type Finding } from '../core/finding.ts'
|
|
9
|
+
import { exit, invoked } from '../core/invoked.ts'
|
|
10
|
+
|
|
11
|
+
import { parser } from 'typescript-eslint'
|
|
12
|
+
|
|
13
|
+
import { CONSTRAINTS, type Constraint } from './constraint.ts'
|
|
14
|
+
import { NOT_WRITTEN, ROOT, governs, load, reads, rules, slopnatorName, sweeps, visits, type Reading, type Slopnator, type Spoken, type Visiting } from './slopnator.ts'
|
|
15
|
+
import { ruleMessage } from './lint.ts'
|
|
16
|
+
|
|
17
|
+
const PARSEABLE = /\.(ts|tsx|mts|cts|mjs|cjs|js|jsx)$/
|
|
18
|
+
|
|
19
|
+
interface Node {
|
|
20
|
+
readonly type?: string
|
|
21
|
+
readonly comments?: readonly Spoken[]
|
|
22
|
+
readonly [key: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tree(text: string): Node | undefined {
|
|
26
|
+
const read = (jsx: boolean): { ast: Node } =>
|
|
27
|
+
(parser.parseForESLint as (t: string, o: unknown) => { ast: Node })(text, {
|
|
28
|
+
range: true,
|
|
29
|
+
loc: true,
|
|
30
|
+
comment: true,
|
|
31
|
+
ecmaVersion: 'latest',
|
|
32
|
+
sourceType: 'module',
|
|
33
|
+
ecmaFeatures: { jsx },
|
|
34
|
+
})
|
|
35
|
+
try {
|
|
36
|
+
return read(true).ast
|
|
37
|
+
} catch {
|
|
38
|
+
try {
|
|
39
|
+
return read(false).ast
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function visit(root: Node, on: ReadonlySet<string>, each: (node: Node, ancestors: readonly Node[]) => void): void {
|
|
47
|
+
const stack: Node[] = []
|
|
48
|
+
const step = (held: unknown): void => {
|
|
49
|
+
if (held === null || typeof held !== 'object') return
|
|
50
|
+
if (Array.isArray(held)) {
|
|
51
|
+
for (const one of held) step(one)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const node = held as Node
|
|
55
|
+
const named = typeof node.type === 'string'
|
|
56
|
+
if (named && on.has(node.type as string)) each(node, [...stack])
|
|
57
|
+
if (named) stack.push(node)
|
|
58
|
+
for (const key of Object.keys(node)) {
|
|
59
|
+
if (key === 'parent') continue
|
|
60
|
+
step((node as Record<string, unknown>)[key])
|
|
61
|
+
}
|
|
62
|
+
if (named) stack.pop()
|
|
63
|
+
}
|
|
64
|
+
step(root)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Caught {
|
|
68
|
+
readonly rule: string
|
|
69
|
+
readonly file: string
|
|
70
|
+
readonly found: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ELSEWHERE = new Set(['node_modules', '.git'])
|
|
74
|
+
|
|
75
|
+
function ignoredIn(root: string): ReadonlySet<string> {
|
|
76
|
+
const at = join(root, '.gitignore')
|
|
77
|
+
if (!existsSync(at)) return new Set()
|
|
78
|
+
return new Set(
|
|
79
|
+
readFileSync(at, 'utf8')
|
|
80
|
+
.split('\n')
|
|
81
|
+
.map((line) => line.trim().replace(/^\//, '').replace(/\/$/, ''))
|
|
82
|
+
.filter((line) => line !== '' && !line.startsWith('#') && !line.includes('*') && !line.startsWith('!')),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function authored(): ReadonlySet<string> {
|
|
87
|
+
const found = new Set<string>()
|
|
88
|
+
try {
|
|
89
|
+
for (const [at, threadings] of descend().threaded) {
|
|
90
|
+
if (threadings.some((one) => one.list === 'derives' && (one.row.by ?? one.row.runs) !== undefined)) found.add(at)
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
|
|
94
|
+
}
|
|
95
|
+
return found
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function tracked(root: string): readonly string[] {
|
|
99
|
+
const derived = authored()
|
|
100
|
+
const mine = (one: string): boolean => !derived.has(one)
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
|
|
104
|
+
const listed = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
|
|
105
|
+
cwd: root,
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
108
|
+
})
|
|
109
|
+
.split('\n')
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
if (listed.length > 0) {
|
|
112
|
+
return [...new Set(listed)].filter((one) => existsSync(join(root, one)) && mine(one)).sort()
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const found: string[] = []
|
|
119
|
+
const ignored = ignoredIn(root)
|
|
120
|
+
|
|
121
|
+
const walk = (at: string, under: string): void => {
|
|
122
|
+
for (const one of readdirSync(at, { withFileTypes: true })) {
|
|
123
|
+
if (one.name.startsWith('.')) continue
|
|
124
|
+
if (ELSEWHERE.has(one.name) || ignored.has(one.name) || ignored.has(under === '' ? one.name : `${under}/${one.name}`)) continue
|
|
125
|
+
const here = under === '' ? one.name : `${under}/${one.name}`
|
|
126
|
+
if (one.isDirectory()) walk(join(at, one.name), here)
|
|
127
|
+
else if (mine(here)) found.push(here)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
walk(root, '')
|
|
132
|
+
return found.sort()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sweep(
|
|
136
|
+
root: string,
|
|
137
|
+
all: readonly Constraint[],
|
|
138
|
+
slopnators: ReadonlyMap<string, Slopnator>,
|
|
139
|
+
files: readonly string[],
|
|
140
|
+
): {
|
|
141
|
+
readonly findings: readonly Caught[]
|
|
142
|
+
readonly swept: number
|
|
143
|
+
readonly tracked: number
|
|
144
|
+
readonly rules: number
|
|
145
|
+
readonly unrun: readonly string[]
|
|
146
|
+
readonly unmet: readonly string[]
|
|
147
|
+
readonly linted: readonly string[]
|
|
148
|
+
} {
|
|
149
|
+
const readers = all
|
|
150
|
+
.map((rule) => ({ rule, slopnator: slopnators.get(slopnatorName(rule.id)) }))
|
|
151
|
+
.filter((one): one is { rule: Constraint; slopnator: Reading } => one.slopnator !== undefined && sweeps(one.slopnator))
|
|
152
|
+
|
|
153
|
+
const walkers = all
|
|
154
|
+
.map((rule) => ({ rule, slopnator: slopnators.get(slopnatorName(rule.id)) }))
|
|
155
|
+
.filter((one): one is { rule: Constraint; slopnator: Visiting } => one.slopnator !== undefined && visits(one.slopnator))
|
|
156
|
+
|
|
157
|
+
const findings: Caught[] = []
|
|
158
|
+
let swept = 0
|
|
159
|
+
|
|
160
|
+
const met = new Set<string>()
|
|
161
|
+
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
const reading = readers.filter((one) => governs(one.rule, file))
|
|
164
|
+
const walking = walkers.filter((one) => governs(one.rule, file))
|
|
165
|
+
for (const one of reading) met.add(one.rule.id)
|
|
166
|
+
for (const one of walking) met.add(one.rule.id)
|
|
167
|
+
if (reading.length === 0 && walking.length === 0) continue
|
|
168
|
+
|
|
169
|
+
let text: string
|
|
170
|
+
try {
|
|
171
|
+
text = readFileSync(join(root, file), 'utf8')
|
|
172
|
+
} catch {
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
swept += 1
|
|
176
|
+
|
|
177
|
+
for (const { rule, slopnator } of reading) {
|
|
178
|
+
const held = slopnator.holds(undefined, {
|
|
179
|
+
filename: file,
|
|
180
|
+
text,
|
|
181
|
+
ancestors: () => [],
|
|
182
|
+
comments: () => [],
|
|
183
|
+
})
|
|
184
|
+
if (held === false || held === NOT_WRITTEN) continue
|
|
185
|
+
findings.push({ rule: rule.id, file, found: typeof held === 'string' ? held : slopnatorName(rule.id) })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (walking.length === 0 || !PARSEABLE.test(file)) continue
|
|
189
|
+
|
|
190
|
+
const ast = tree(text)
|
|
191
|
+
if (ast === undefined) {
|
|
192
|
+
for (const { rule } of walking) {
|
|
193
|
+
findings.push({
|
|
194
|
+
rule: rule.id,
|
|
195
|
+
file,
|
|
196
|
+
found:
|
|
197
|
+
'this file could not be parsed, so nothing about its structure was measured — ' +
|
|
198
|
+
'a green result here says nothing about this rule on this file',
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const comments = ast.comments ?? []
|
|
205
|
+
for (const { rule, slopnator } of walking) {
|
|
206
|
+
const on = new Set(slopnator.on)
|
|
207
|
+
if (on.size === 0) {
|
|
208
|
+
findings.push({
|
|
209
|
+
rule: rule.id,
|
|
210
|
+
file,
|
|
211
|
+
found: 'its slopnator visits no node type, so it measured nothing here rather than finding nothing',
|
|
212
|
+
})
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
visit(ast, on, (node, ancestors) => {
|
|
216
|
+
const held = slopnator.holds(node, {
|
|
217
|
+
filename: file,
|
|
218
|
+
text,
|
|
219
|
+
ancestors: () => ancestors,
|
|
220
|
+
comments: () => comments,
|
|
221
|
+
})
|
|
222
|
+
if (held === false || held === NOT_WRITTEN) return
|
|
223
|
+
findings.push({ rule: rule.id, file, found: typeof held === 'string' ? held : slopnatorName(rule.id) })
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const rule of all) {
|
|
229
|
+
const slopnator = slopnators.get(slopnatorName(rule.id))
|
|
230
|
+
if (slopnator === undefined || reads(slopnator)) continue
|
|
231
|
+
for (const found of slopnator.checks(root)) findings.push({ rule: rule.id, file: '', found })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const unrun = all.filter((rule) => slopnators.get(slopnatorName(rule.id)) === undefined).map((rule) => rule.id)
|
|
235
|
+
const linted = walkers.map((one) => one.rule.id)
|
|
236
|
+
const unmet = [...new Set([...readers, ...walkers].map((one) => one.rule.id))].filter((id) => !met.has(id)).sort()
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
findings,
|
|
240
|
+
swept,
|
|
241
|
+
tracked: files.length,
|
|
242
|
+
unrun,
|
|
243
|
+
unmet,
|
|
244
|
+
linted,
|
|
245
|
+
rules:
|
|
246
|
+
readers.length +
|
|
247
|
+
walkers.length +
|
|
248
|
+
all.filter((rule) => {
|
|
249
|
+
const slopnator = slopnators.get(slopnatorName(rule.id))
|
|
250
|
+
return slopnator !== undefined && !reads(slopnator)
|
|
251
|
+
}).length,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (invoked(import.meta.url)) {
|
|
256
|
+
const governing = [...CONSTRAINTS, ...((await rules()) ?? [])]
|
|
257
|
+
const slopnators = await load(ROOT, governing)
|
|
258
|
+
const named = new Map(governing.map((rule) => [rule.id, rule]))
|
|
259
|
+
const run = sweep(ROOT, governing, slopnators, tracked(ROOT))
|
|
260
|
+
|
|
261
|
+
const warned = new Set<string>()
|
|
262
|
+
const grouped = new Map<string, string[]>()
|
|
263
|
+
const files = new Map<string, Set<string>>()
|
|
264
|
+
for (const finding of run.findings) {
|
|
265
|
+
if (named.get(finding.rule)?.severity === 'warning') warned.add(finding.rule)
|
|
266
|
+
const held = grouped.get(finding.rule) ?? []
|
|
267
|
+
held.push(finding.file === '' ? ` ${finding.found}` : ` ${finding.file} — ${finding.found}`)
|
|
268
|
+
grouped.set(finding.rule, held)
|
|
269
|
+
const where = files.get(finding.rule) ?? new Set<string>()
|
|
270
|
+
where.add(finding.file)
|
|
271
|
+
files.set(finding.rule, where)
|
|
272
|
+
}
|
|
273
|
+
const refused = new Set(run.findings.map((one) => one.file).filter((one) => one !== ''))
|
|
274
|
+
|
|
275
|
+
const findings: Finding[] = [
|
|
276
|
+
...[...grouped.entries()].map(([id, where]): Finding => {
|
|
277
|
+
const rule = named.get(id)
|
|
278
|
+
const count = files.get(id)?.size ?? 0
|
|
279
|
+
const said = where.length === count ? `${String(count)} file(s)` : `${String(where.length)} finding(s) in ${String(count)} file(s)`
|
|
280
|
+
return {
|
|
281
|
+
detail: `${id} — ${rule?.bans ?? 'refused'}. ${said}:\n${where.sort().join('\n')}`,
|
|
282
|
+
instead: rule?.instead ?? `read what ${id} refuses and stop writing it`,
|
|
283
|
+
...(rule?.severity === 'warning' ? { severity: 'warning' as const } : {}),
|
|
284
|
+
}
|
|
285
|
+
}),
|
|
286
|
+
...run.unrun.map((rule): Finding => ({
|
|
287
|
+
detail: `${rule} DID NOT RUN — no slopnator is on disk for it, so this sweep measured nothing for that rule and a green result here says nothing about it`,
|
|
288
|
+
instead: 'write the walk the rule names, or delete the rule and its row together — half a rule reads as a whole one from every direction',
|
|
289
|
+
})),
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
runChecker(
|
|
293
|
+
'sweep',
|
|
294
|
+
findings,
|
|
295
|
+
`each naming one rule and the files it refuses — ${String(run.findings.length)} refusal(s) across ${String(refused.size)} file(s), ` +
|
|
296
|
+
`over ${String(run.rules)} rule(s) run across ${String(run.swept)} of ${String(run.tracked)} file(s) in this tree, ` +
|
|
297
|
+
`${String(run.linted.length)} of them reading a syntax tree` +
|
|
298
|
+
`${run.unrun.length === 0 ? '' : `, ${String(run.unrun.length)} that DID NOT RUN AT ALL`}` +
|
|
299
|
+
`${run.unmet.length === 0 ? '' : `, and ${String(run.unmet.length)} that governed no file here and so measured nothing: ${run.unmet.join(', ')}`}`,
|
|
300
|
+
)
|
|
301
|
+
const forbidding = findings.length - run.findings.filter((one) => named.get(one.rule)?.severity === 'warning').length
|
|
302
|
+
if (forbidding > 0) exit(1)
|
|
303
|
+
if (warned.size > 0) {
|
|
304
|
+
stdout.write(`sweep: every finding above is a warning — ${[...warned].sort().join(', ')} state \`severity: 'warning'\`, so the gate does not stop here.\n`)
|
|
305
|
+
}
|
|
306
|
+
exit(0)
|
|
307
|
+
}
|