@spexcode/spec-core 0.6.2 → 0.6.4

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 (58) hide show
  1. package/dist/anchors.d.ts +94 -0
  2. package/dist/anchors.js +730 -0
  3. package/dist/git.d.ts +166 -0
  4. package/dist/git.js +2736 -0
  5. package/dist/graph-delta.d.ts +44 -0
  6. package/dist/graph-delta.js +72 -0
  7. package/dist/graph.d.ts +34 -0
  8. package/dist/graph.js +237 -0
  9. package/dist/graphDelta.d.ts +3 -0
  10. package/dist/graphDelta.js +13 -0
  11. package/dist/harness-identity.d.ts +31 -0
  12. package/dist/harness-identity.js +20 -0
  13. package/dist/identity-presets.d.ts +152 -0
  14. package/dist/identity-presets.js +132 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.js +19 -0
  17. package/dist/layout.d.ts +183 -0
  18. package/dist/layout.js +548 -0
  19. package/dist/process-identity.d.ts +37 -0
  20. package/dist/process-identity.js +214 -0
  21. package/dist/project-identity.d.ts +12 -0
  22. package/dist/project-identity.js +71 -0
  23. package/dist/project-store.d.ts +3 -0
  24. package/dist/project-store.js +14 -0
  25. package/dist/resilience.d.ts +2 -0
  26. package/dist/resilience.js +40 -0
  27. package/dist/review/index.d.ts +3 -0
  28. package/{src → dist}/review/index.js +3 -3
  29. package/dist/review/reviewFilters.d.ts +77 -0
  30. package/dist/review/reviewFilters.js +308 -0
  31. package/dist/review/reviewQuery.d.ts +66 -0
  32. package/dist/review/reviewQuery.js +180 -0
  33. package/dist/review/session.d.ts +4 -0
  34. package/dist/review/session.js +8 -0
  35. package/dist/reviewSnapshot.d.ts +15 -0
  36. package/dist/reviewSnapshot.js +12 -0
  37. package/dist/root-lru.d.ts +4 -0
  38. package/{src/root-lru.ts → dist/root-lru.js} +26 -30
  39. package/dist/specs.d.ts +117 -0
  40. package/dist/specs.js +489 -0
  41. package/package.json +23 -8
  42. package/src/anchors.ts +0 -728
  43. package/src/git.ts +0 -2556
  44. package/src/graph.ts +0 -251
  45. package/src/harness-identity.ts +0 -26
  46. package/src/identity-presets.d.ts +0 -13
  47. package/src/identity-presets.js +0 -138
  48. package/src/index.ts +0 -20
  49. package/src/layout.ts +0 -637
  50. package/src/process-identity.ts +0 -207
  51. package/src/project-identity.ts +0 -73
  52. package/src/project-store.ts +0 -17
  53. package/src/resilience.ts +0 -41
  54. package/src/review/reviewFilters.js +0 -324
  55. package/src/review/reviewQuery.js +0 -174
  56. package/src/review/session.js +0 -13
  57. package/src/reviewSnapshot.ts +0 -28
  58. package/src/specs.ts +0 -498
package/src/anchors.ts DELETED
@@ -1,728 +0,0 @@
1
- import { join } from 'node:path'
2
- import { createRequire } from 'node:module'
3
- import { git, gitRequiredA, gitObjectFormat, isGitObjectId, batchRevisionOids, batchBlobTexts, combinedDiffOwnedChanges, driftPathWindow, readImmutableHunkFacts, persistImmutableHunkFacts, withEventLedgerBuild, type DiffLineRange, type DriftIndex, type DriftPathEvent, type ImmutableHunkRanges } from './git.js'
4
-
5
- const RS = '\x1e'
6
-
7
- // ---- the anchor vocabulary ([[code-anchor]]) ----
8
- // A spec's `code:` entry may pin ONE named unit: `path#symbol` (`#Class.method` for a class method).
9
- // Everything below the entry parse splits into two layers:
10
- // - the LANGUAGE SEAM: pure extractors (content, filename) -> Unit[] — no git, no cache, no fs.
11
- // Each extension maps to exactly ONE designated extractor; there is NO cross-tier fallback.
12
- // - the LANGUAGE-AGNOSTIC ENGINE: file-revision memo (keyed by the complete immutable parse input), anchor resolution
13
- // (dead/ambiguous), diff-hunk ∩ unit-range intersection over the drift window. It never knows
14
- // which language it is measuring.
15
-
16
- export type Unit = { name: string; kind: string; start: number; end: number; typeOnly?: boolean }
17
-
18
- export type Extractor = {
19
- id: string
20
- claims(ext: string): boolean
21
- // true = usable here. A string is WHY it cannot run — lint turns that into a visible error and
22
- // skips the affected anchors as unverified while continuing the other checks (never a crash or fake pass).
23
- ready(): true | string
24
- // PURE function of its arguments (importable by an external benchmark/scorer as-is). Throws when the
25
- // content cannot be parsed — the caller maps that to a conservative verdict, never a silent skip.
26
- extract(content: string, filename: string): Unit[]
27
- // Every input that can affect extract() must be represented here before its result enters the memo.
28
- memoKey: (filename: string) => string
29
- }
30
-
31
- export type CodeEntry = { path: string; anchor: string | null }
32
- export function parseCodeEntry(raw: string): CodeEntry {
33
- const i = raw.indexOf('#')
34
- if (i < 0) return { path: raw.trim(), anchor: null }
35
- return { path: raw.slice(0, i).trim(), anchor: raw.slice(i + 1).trim() || null }
36
- }
37
-
38
- // ---- relation parsing: ONE structured path+selector grammar for code: AND related: ----
39
- // A relation's raw rows group per base path: a row is bare (`path`, whole-file — today's semantics,
40
- // unchanged) or scoped (`path#symbol`), and any number of scoped rows on the SAME base file fold into
41
- // one entry whose selectors are OR'd (a commit hitting any counts once; no selector-count cap — the
42
- // benchmark roster's 1–3 was an annotation rubric, never product syntax). STRUCTURAL verdicts live
43
- // here, pure and loud: an exact duplicate row, mixing bare with selectors on one base path, and a
44
- // selector on a glob are all `problems` the caller turns into integrity errors. Filesystem/git
45
- // verdicts (existence, directories, dead/ambiguous units, extractor readiness) stay the caller's —
46
- // this parser never touches fs.
47
- export type RelationEntry = { path: string; selectors: string[] }
48
- export type RelationParse = { entries: RelationEntry[]; problems: string[] }
49
- export function relationClaimsPath(claim: string, file: string): boolean {
50
- if (claim === file) return true
51
- if (file.startsWith(claim.replace(/\/+$/, '') + '/')) return true
52
- if (!claim.includes('*')) return false
53
- const pattern = claim.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
54
- return new RegExp(`^${pattern}$`).test(file)
55
- }
56
- export function parseRelation(raws: string[], relation: 'code' | 'related'): RelationParse {
57
- const order: string[] = []
58
- const byPath = new Map<string, { bare: boolean; selectors: string[] }>()
59
- const problems: string[] = []
60
- for (const raw of raws) {
61
- const { path, anchor } = parseCodeEntry(raw)
62
- let e = byPath.get(path)
63
- if (!e) { e = { bare: false, selectors: [] }; byPath.set(path, e); order.push(path) }
64
- if (anchor === null) {
65
- if (e.bare) problems.push(`${relation}: lists '${path}' twice — drop the duplicate entry`)
66
- e.bare = true
67
- } else if (e.selectors.includes(anchor)) {
68
- problems.push(`${relation}: lists selector '${path}#${anchor}' twice — drop the duplicate`)
69
- } else e.selectors.push(anchor)
70
- }
71
- for (const path of order) {
72
- const e = byPath.get(path)!
73
- if (e.bare && e.selectors.length)
74
- problems.push(`${relation}: mixes bare '${path}' with '${path}#…' selectors — one base path is either whole-file or selector-scoped, never both; drop one form`)
75
- if (e.selectors.length && path.includes('*'))
76
- problems.push(`${relation}: '${path}#${e.selectors[0]}' puts a selector on a glob — a selector scopes ONE real file`)
77
- }
78
- return { entries: order.map((p) => ({ path: p, selectors: byPath.get(p)!.selectors })), problems }
79
- }
80
-
81
- // ---- extractor: ts-ast (the designated extractor for the JS family) ----
82
- // Parse-only via the HOST project's own typescript, so the parse matches what the project itself compiles
83
- // with. If it cannot resolve, ready() returns a loud unverified verdict and lint skips these anchors
84
- // without crashing (no bundled compiler, regex fallback, or fake pass for JS).
85
- const JS_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'])
86
- const TS_AST_MEMO_SCHEMA = 'ts-ast-memo-v1'
87
-
88
- export function tsAstExtractor(root: string): Extractor {
89
- let ts: any | null | undefined // undefined = unprobed; null = unresolvable
90
- let tsModulePath = ''
91
- let readiness: true | string | undefined
92
- const probe = () => {
93
- if (ts !== undefined) return
94
- try {
95
- const require = createRequire(join(root, 'package.json'))
96
- tsModulePath = require.resolve('typescript')
97
- ts = require('typescript')
98
- } catch { ts = null }
99
- }
100
- return {
101
- id: 'ts-ast',
102
- claims: (ext) => JS_EXTS.has(ext),
103
- ready() {
104
- if (readiness !== undefined) return readiness
105
- probe()
106
- if (!ts) return (readiness = `typescript is not resolvable from the governed repository (${root}) — JS-family anchors were skipped and remain unverified; run 'npm i -D typescript@5', or remove the #anchor`)
107
- // resolvability is not usability: typescript@7 (the Go rewrite) may resolve yet not expose the JS
108
- // compiler API this extractor drives. Probe the ACTUAL surface with a tiny parse. Once a candidate
109
- // resolves, incompatibility is loud rather than silently changing parser versions.
110
- try {
111
- const sf = ts.createSourceFile('probe.ts', 'const x = 1', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
112
- if (!sf?.statements?.length || sf.parseDiagnostics?.length) throw new Error('probe parse failed')
113
- readiness = true
114
- } catch {
115
- readiness = `host typescript (v${ts?.version ?? 'unknown'}) resolves but its createSourceFile API is unusable (a TS7/Go build?) — pin 'npm i -D typescript@5', or remove the #anchor`
116
- }
117
- return readiness
118
- },
119
- extract(content, filename) {
120
- probe()
121
- if (!ts) throw new Error('ts-ast extractor is not ready (typescript unresolvable)')
122
- const kind = /\.(tsx)$/.test(filename) ? ts.ScriptKind.TSX
123
- : /\.(jsx)$/.test(filename) ? ts.ScriptKind.JSX
124
- : /\.(ts|mts|cts)$/.test(filename) ? ts.ScriptKind.TS
125
- : ts.ScriptKind.JS
126
- const sf = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest, false, kind)
127
- // parse-only gate: a file that does not parse yields GARBAGE units (a shell script's `x=$(...)`
128
- // parses as a const) — throw so the caller renders an honest "cannot parse" verdict instead.
129
- if (sf.parseDiagnostics?.length) throw new Error(`${filename} does not parse as ${ts.ScriptKind[kind]} (${sf.parseDiagnostics.length} syntax error(s))`)
130
- const line = (pos: number) => sf.getLineAndCharacterOfPosition(pos).line + 1
131
- const units: Unit[] = []
132
- const push = (name: string, ukind: string, node: any, typeOnly = false) =>
133
- units.push({ name, kind: ukind, start: line(node.getStart(sf)), end: line(node.end), ...(typeOnly ? { typeOnly } : {}) })
134
- for (const st of sf.statements) {
135
- if (ts.isFunctionDeclaration(st)) push(st.name ? st.name.text : '(default)', 'function', st)
136
- else if (ts.isClassDeclaration(st)) {
137
- const cname = st.name ? st.name.text : '(default)'
138
- push(cname, 'class', st)
139
- for (const m of st.members) {
140
- if ((ts.isMethodDeclaration(m) || ts.isConstructorDeclaration(m) || ts.isGetAccessorDeclaration(m) || ts.isSetAccessorDeclaration(m)) && m.body) {
141
- const mname = ts.isConstructorDeclaration(m) ? 'constructor' : (m.name && ts.isIdentifier(m.name) ? m.name.text : '(computed)')
142
- push(`${cname}.${mname}`, 'method', m)
143
- }
144
- }
145
- } else if (ts.isVariableStatement(st)) {
146
- for (const d of st.declarationList.declarations) {
147
- if (!ts.isIdentifier(d.name)) continue // destructuring — not anchorable by one name
148
- const fn = d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
149
- // range = the whole statement (multi-declarator lines co-move; each name shares the range)
150
- units.push({ name: d.name.text, kind: fn ? 'const-fn' : 'const-data', start: line(st.getStart(sf)), end: line(st.end) })
151
- }
152
- } else if (ts.isEnumDeclaration(st)) push(st.name.text, 'enum', st)
153
- else if (ts.isInterfaceDeclaration(st)) push(st.name.text, 'interface', st, true)
154
- else if (ts.isTypeAliasDeclaration(st)) push(st.name.text, 'type', st, true)
155
- }
156
- return units
157
- },
158
- memoKey(filename) {
159
- probe()
160
- const host = ts ? `${tsModulePath}\0${ts.version ?? 'unknown'}` : `unresolved\0${root}`
161
- return `${TS_AST_MEMO_SCHEMA}\0${host}\0target:Latest\0parents:false\0${filename}`
162
- },
163
- }
164
- }
165
-
166
- // ---- extractor: heuristic(langSpec) — a generic regex engine fed LANGUAGE DATA, not language branches ----
167
- // The designated extractor for languages described by a LangSpec row; adding a language = adding a data
168
- // row + a registry entry (never a new engine). The JS family is deliberately NOT routed here (its
169
- // designated extractor is ts-ast above); JS_LANG_R5B below exists as the validated reference row for the
170
- // engine's shape and for the external benchmark to score.
171
- export type LangSpec = {
172
- id: string
173
- extensions: string[]
174
- // column-0 declaration patterns; capture group 1 = the unit name (or the declarator list when declList)
175
- decls: {
176
- re: RegExp
177
- kind: string
178
- typeOnly?: boolean
179
- classOpener?: boolean
180
- declList?: boolean
181
- scopeOpener?: boolean
182
- memberOf?: { parentKind: string; kind: string }
183
- }[]
184
- // class-member pattern, active while inside a classOpener's balanced-bracket body (name -> Class.name)
185
- member?: { re: RegExp; blacklist?: RegExp }
186
- // indentation-significant languages use declaration nesting for qualified names and ranges. The
187
- // declaration regexes remain language data; this only selects a generic boundary strategy.
188
- indentScopes?: { decorator?: RegExp }
189
- // a column-0 line matching this ENDS the previous unit (comment-aware so trailing comment blocks
190
- // attach to the NEXT unit, not the previous one)
191
- boundary: RegExp
192
- }
193
-
194
- const balance = (s: string) => { let n = 0; for (const ch of s) { if ('([{'.includes(ch)) n++; else if (')]}'.includes(ch)) n-- } return n }
195
- // split a declarator-list head on top-level commas: `COLS = 220, ROWS = 50` -> [COLS, ROWS]
196
- function declNames(head: string): string[] {
197
- let d = 0, seg = ''
198
- const segs: string[] = []
199
- for (const ch of head) {
200
- if ('([{<'.includes(ch)) d++
201
- else if (')]}>'.includes(ch)) d--
202
- if (ch === ',' && d === 0) { segs.push(seg); seg = '' } else seg += ch
203
- }
204
- segs.push(seg)
205
- return segs.map((s) => s.match(/^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/)?.[1]).filter((x): x is string => !!x)
206
- }
207
-
208
- const indentation = (line: string): number => {
209
- let n = 0
210
- for (const ch of line) {
211
- if (ch === ' ') n++
212
- else if (ch === '\t') n += 8 - (n % 8)
213
- else break
214
- }
215
- return n
216
- }
217
-
218
- function extractIndentScoped(content: string, spec: LangSpec): Unit[] {
219
- const lines = content.split('\n')
220
- type ScopedUnit = Unit & { declaration: number; indent: number }
221
- const units: ScopedUnit[] = []
222
- const scopes: { indent: number; name: string; kind: string }[] = []
223
-
224
- for (let i = 0; i < lines.length; i++) {
225
- const line = lines[i]
226
- if (!line.trim() || /^\s*#/.test(line)) continue
227
- const indent = indentation(line)
228
- while (scopes.length && scopes[scopes.length - 1].indent >= indent) scopes.pop()
229
- for (const d of spec.decls) {
230
- const m = line.match(d.re)
231
- if (!m) continue
232
- const local = m[1]
233
- const name = [...scopes.map((s) => s.name), local].join('.')
234
- const parent = scopes[scopes.length - 1]
235
- const kind = d.memberOf && parent?.kind === d.memberOf.parentKind ? d.memberOf.kind : d.kind
236
- let start = i + 1
237
- if (spec.indentScopes?.decorator) {
238
- for (let j = i - 1; j >= 0; j--) {
239
- if (indentation(lines[j]) !== indent || !spec.indentScopes.decorator.test(lines[j])) break
240
- start = j + 1
241
- }
242
- }
243
- units.push({ name, kind, start, end: i + 1, declaration: i, indent, ...(d.typeOnly ? { typeOnly: true } : {}) })
244
- if (d.scopeOpener) scopes.push({ indent, name: local, kind: d.kind })
245
- break
246
- }
247
- }
248
-
249
- for (const unit of units) {
250
- let boundary: number | null = null
251
- for (let i = unit.declaration + 1; i < lines.length; i++) {
252
- const line = lines[i]
253
- if (!line.trim()) continue
254
- const indent = indentation(line)
255
- if (/^\s*#/.test(line)) {
256
- if (indent <= unit.indent && boundary === null) boundary = i
257
- continue
258
- }
259
- if (indent <= unit.indent) { boundary ??= i; break }
260
- boundary = null
261
- }
262
- unit.end = Math.max(unit.start, (boundary ?? lines.length) )
263
- }
264
- return units.map(({ declaration: _declaration, indent: _indent, ...unit }) => unit)
265
- }
266
-
267
- export function heuristicExtractor(spec: LangSpec): Extractor {
268
- return {
269
- id: spec.id,
270
- claims: (ext) => spec.extensions.includes(ext),
271
- ready: () => true,
272
- extract(content) {
273
- if (spec.indentScopes) return extractIndentScoped(content, spec)
274
- const lines = content.split('\n')
275
- const units: Unit[] = []
276
- let cls: string | null = null, depth = 0
277
- for (let i = 0; i < lines.length; i++) {
278
- const l = lines[i]
279
- if (cls) {
280
- const m = spec.member && l.match(spec.member.re)
281
- if (m && !spec.member!.blacklist?.test(m[1])) units.push({ name: `${cls}.${m[1]}`, kind: 'method', start: i + 1, end: i + 1 })
282
- depth += balance(l)
283
- if (depth <= 0) cls = null
284
- continue
285
- }
286
- for (const d of spec.decls) {
287
- const m = l.match(d.re)
288
- if (!m) continue
289
- for (const name of d.declList ? declNames(m[1]) : [m[1]])
290
- units.push({ name, kind: d.kind, start: i + 1, end: i + 1, ...(d.typeOnly ? { typeOnly: true } : {}) })
291
- if (d.classOpener) { cls = m[1]; depth = balance(l) }
292
- break
293
- }
294
- }
295
- // R5b ranges: a unit ends before the next column-0 boundary line; a method is also capped by the
296
- // next unit's start (methods sit inside their class's indentation, below boundary's radar).
297
- const bset: number[] = []
298
- for (let i = 0; i < lines.length; i++) if (spec.boundary.test(lines[i])) bset.push(i + 1)
299
- const starts = units.map((u) => u.start).sort((a, b) => a - b)
300
- for (const u of units) {
301
- const nb = bset.find((b) => b > u.start)
302
- let end = nb ?? lines.length + 1
303
- if (u.kind === 'method') { const ns = starts.find((x) => x > u.start); if (ns && ns < end) end = ns }
304
- u.end = Math.max(u.start, end - 1)
305
- }
306
- return units
307
- },
308
- memoKey(filename) {
309
- const regex = (r: RegExp | undefined) => r ? `${r.source}/${r.flags}` : null
310
- return JSON.stringify({
311
- schema: 'heuristic-memo-v1', filename, id: spec.id, extensions: spec.extensions,
312
- decls: spec.decls.map((d) => ({
313
- re: regex(d.re), kind: d.kind, typeOnly: !!d.typeOnly, classOpener: !!d.classOpener,
314
- declList: !!d.declList, scopeOpener: !!d.scopeOpener, memberOf: d.memberOf ?? null,
315
- })),
316
- member: spec.member ? { re: regex(spec.member.re), blacklist: regex(spec.member.blacklist) } : null,
317
- indentScopes: spec.indentScopes ? { decorator: regex(spec.indentScopes.decorator) } : null,
318
- boundary: regex(spec.boundary),
319
- })
320
- },
321
- }
322
- }
323
-
324
- // The validated JS-family reference row (R5b: name precision 99.7% / recall 100% / range 98.9% on the
325
- // 41-file oracle) — NOT registered for JS (ts-ast is designated); kept as the engine's reference shape
326
- // and the benchmark's scoring subject.
327
- export const JS_LANG_R5B: LangSpec = {
328
- id: 'heuristic-js',
329
- extensions: [...JS_EXTS],
330
- decls: [
331
- { re: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/, kind: 'function' },
332
- { re: /^(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: 'class', classOpener: true },
333
- { re: /^(?:export\s+)?(?:declare\s+)?enum\s+([A-Za-z_$][\w$]*)/, kind: 'enum' },
334
- { re: /^(?:export\s+)?(?:declare\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: 'interface', typeOnly: true },
335
- { re: /^(?:export\s+)?(?:declare\s+)?type\s+([A-Za-z_$][\w$]*)/, kind: 'type', typeOnly: true },
336
- { re: /^(?:export\s+)?(?:const|let|var)\s+(.+)$/, kind: 'const', declList: true },
337
- ],
338
- member: {
339
- re: /^\s+(?:(?:public|private|protected|static|readonly|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/,
340
- blacklist: /^(if|for|while|switch|return|catch|new|await|typeof|throw|else|do)$/,
341
- },
342
- boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
343
- }
344
-
345
- // Python is a LangSpec DATA row over the same generic engine: declaration names come from patterns;
346
- // significant indentation supplies lexical qualification and ranges. It is intentionally structural,
347
- // not a Python runtime or full grammar (the user-facing boundary is documented by [[code-anchor]]).
348
- const PY_ID = String.raw`[\p{ID_Start}_][\p{ID_Continue}_]*`
349
- export const PYTHON_LANG: LangSpec = {
350
- id: 'heuristic-python',
351
- extensions: ['py', 'pyi'],
352
- decls: [
353
- {
354
- re: new RegExp(`^\\s*(?:async\\s+)?def\\s+(${PY_ID})\\s*\\(`, 'u'),
355
- kind: 'function',
356
- scopeOpener: true,
357
- memberOf: { parentKind: 'class', kind: 'method' },
358
- },
359
- { re: new RegExp(`^\\s*class\\s+(${PY_ID})(?:\\s*\\(|\\s*:)`, 'u'), kind: 'class', scopeOpener: true },
360
- ],
361
- indentScopes: { decorator: /^\s*@/ },
362
- boundary: /^\S/,
363
- }
364
-
365
- // ---- registry: extension -> its ONE designated extractor ----
366
- // The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
367
- // heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
368
- // config — whatever the implementation needs rides inside its own factory, never in the registry.
369
- export function extractors(root: string): Extractor[] {
370
- return [tsAstExtractor(root), ...[PYTHON_LANG].map(heuristicExtractor)]
371
- }
372
- // first claiming extractor IS the designation (the registry order defines it); null = no anchor support
373
- // for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
374
- export function extractorFor(regs: Extractor[], ext: string): Extractor | null {
375
- return regs.find((x) => x.claims(ext)) ?? null
376
- }
377
- export const extOf = (path: string): string => {
378
- const base = path.slice(path.lastIndexOf('/') + 1)
379
- const dot = base.lastIndexOf('.')
380
- return dot > 0 ? base.slice(dot + 1) : ''
381
- }
382
-
383
- // ---- anchor resolution (language-agnostic) ----
384
- export type AnchorResolution = { ok: Unit } | { dead: true } | { ambiguous: number }
385
- // @@@ ONE classification for "does this selector resolve here" - the gate (reading the candidate TIP) and the
386
- // freshness probe (reading the WORKING TREE) both have to answer it, and each used to branch on
387
- // dead/ambiguous itself. Two copies of a verdict rule drift, and these two already had: the gate warns on a
388
- // type-only unit, the probe never noticed one. The TEXT each reads stays its own business — a gate must judge
389
- // the tree it is gating, never a dirty worktree — but the VERDICT is decided once, here, so a new outcome
390
- // cannot reach one caller and silently miss the other.
391
- export type SelectorVerdict = { selector: string } & ({ ok: Unit } | { dead: true } | { ambiguous: number })
392
- export function resolveSelectors(units: Unit[], selectors: readonly string[]): SelectorVerdict[] {
393
- return selectors.map((selector) => {
394
- const r = resolveAnchor(units, selector)
395
- if ('dead' in r) return { selector, dead: true as const }
396
- if ('ambiguous' in r) return { selector, ambiguous: r.ambiguous }
397
- return { selector, ok: r.ok }
398
- })
399
- }
400
-
401
- export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
402
- const hits = units.filter((u) => u.name === symbol)
403
- if (!hits.length) return { dead: true }
404
- if (hits.length > 1) return { ambiguous: hits.length }
405
- return { ok: hits[0] }
406
- }
407
-
408
- // The one selector/range intersection used by historical commits and a pinned worktree overlay.
409
- export function selectorsHitRanges(units: readonly Unit[], symbols: readonly string[], ranges: readonly [number, number][]): string[] {
410
- return symbols.filter((symbol) => {
411
- const matching = units.filter((unit) => unit.name === symbol)
412
- return matching.length > 0 && ranges.some(([start, end]) => matching.some((unit) => start <= unit.end && unit.start <= end))
413
- })
414
- }
415
-
416
- // Ordinary zero-context ranges. The side chooses the immutable image whose units the caller resolved.
417
- export function diffHunkRanges(patch: string, side: 'old' | 'new' = 'new'): [number, number][] {
418
- const ranges: [number, number][] = []
419
- for (const match of patch.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
420
- const offset = side === 'old' ? 1 : 3
421
- const start = +match[offset]
422
- const count = match[offset + 1] === undefined ? 1 : +match[offset + 1]
423
- if (count > 0) ranges.push([start, start + count - 1])
424
- }
425
- return ranges
426
- }
427
-
428
- // ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
429
-
430
- // units of a file AS OF a commit, memoized by the complete immutable parse identity. File content identified
431
- // by an object id is immutable, but the extractor also consumes filename and host/config identity; distinct
432
- // file revisions in a window are few.
433
- // 'absent' = no file at that commit; 'unparseable' = the extractor rejected that revision's content.
434
- type FileRevisionUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
435
- const fileRevisionUnitMemo = new Map<string, FileRevisionUnits>()
436
- const MEMO_MAX = 4096
437
- const fileRevisionMemoKey = (objectFormat: string, oid: string, x: Extractor, path: string) => `${objectFormat}\0${oid}\0${x.memoKey(path)}`
438
- async function unitsAtFileRevision(commit: string, path: string, x: Extractor, objectFormat: string, oid: string | null, text?: string): Promise<FileRevisionUnits> {
439
- if (!oid) return { absent: true }
440
- const key = fileRevisionMemoKey(objectFormat, oid, x, path)
441
- const hit = fileRevisionUnitMemo.get(key)
442
- if (hit) return hit
443
- if (text === undefined) throw new Error(`git cat-file --batch omitted object ${oid} for ${commit}:${path}`)
444
- let result: FileRevisionUnits
445
- try { result = { units: x.extract(text, path) } } catch (e: any) { result = { unparseable: e?.message ?? String(e) } }
446
- if (fileRevisionUnitMemo.size >= MEMO_MAX) fileRevisionUnitMemo.clear()
447
- fileRevisionUnitMemo.set(key, result)
448
- return result
449
- }
450
-
451
- // Changed line ranges on both sides of one commit's diff. Ordinary commits have one parent image. Merges
452
- // retain one before-image per parent and only all-parent authored rows; an owned line never widens to an
453
- // adjacent inherited line merely because Git placed both in one `@@@` hunk.
454
- type HunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
455
- // @@@ RANGE_SEMANTICS - hunk ranges must not depend on ambient Git state, because a range that moves with
456
- // config or attributes is neither a sound anchor verdict nor a reusable fact. Outside the commit, three
457
- // classes decide them: presentation (`.gitattributes` `-diff` prints `Binary files … differ` with no `@@` at
458
- // all — measured: one hunk bare, ZERO under `src/x.py -diff`, so an attribute edit silently disabled an
459
- // anchored contract's drift; a textconv or external driver replaces the text compared), the ALGORITHM and
460
- // its heuristics (`diff.algorithm`, `diff.indentHeuristic` — both repo config), and hunk COALESCING
461
- // (`diff.interHunkContext`). All are pinned here, on the two readers this node owns.
462
- //
463
- // The algorithm's VALUE is a product choice, not a formality, because the algorithms genuinely disagree:
464
- // on `c a c a` -> `c c a a a`, myers authors new lines 4-5 and deletes old line 2, while histogram authors
465
- // new lines 2-3 and deletes old line 3 — so a unit on line 3 is a HIT under histogram and a MISS under
466
- // myers, on one commit. histogram is pinned because it aligns the change with the unit that actually moved,
467
- // which for a BLOCKING gate is the conservative direction; myers (Git's default) would silently miss that
468
- // drift. So this pin is not merely determinism: it settles which reading a verdict means.
469
- // `--no-color` is not cosmetic here: under an ambient `color.ui=always` Git prefixes every `@@` with an
470
- // ANSI escape, so a `/^@@/` parse finds NO hunks and every anchored path reads as "nothing changed" — a
471
- // silent zero-drift verdict for the whole corpus, and one that would then be memoized. `-M -l0` pins rename
472
- // detection with no candidate limit, matching the identity stream this engine's events come from, so a large
473
- // rename set cannot quietly change which pairs are compared.
474
- const RANGE_SEMANTICS = [
475
- '--text', '--no-textconv', '--no-ext-diff', '--no-color',
476
- '--diff-algorithm=histogram', '--no-indent-heuristic', '--inter-hunk-context=0',
477
- '-M', '-l0',
478
- ] as const
479
- const hunkMemo = new Map<string, HunkRanges>()
480
- // @@@ the reusable fact is a diff of ORDERED IMAGES, not of a commit id - a commit id is not immutable
481
- // interpretation: `refs/replace` can swap the object, and a graft or an unshallow can change its parents, so
482
- // one (commit,path) can name two different diffs inside one process. The inputs that actually decide the
483
- // hunks are the result image and each parent image, in order, each identified by its resolved blob oid and
484
- // historical path. Keying on those makes the key move exactly when an input does, and the oids are already
485
- // resolved by this read's one `cat-file --batch-check`, so completeness costs no extra child and no new state.
486
- const ABSENT_IMAGE = '-'
487
- const HUNK_FACT_SCHEMA = 'anchor-range-histogram-v1'
488
- const hunkMemoKey = (images: string[]) => `${HUNK_FACT_SCHEMA}\0${images.join('\0')}`
489
- function rememberHunks(key: string, ranges: HunkRanges): HunkRanges {
490
- if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
491
- hunkMemo.set(key, ranges)
492
- return ranges
493
- }
494
- async function hunksAt(root: string, event: DriftPathEvent, key: string): Promise<HunkRanges> {
495
- const paths = [...new Set([event.historicalPath, ...event.parents.map((parent) => parent.historicalPath)])]
496
- const hit = hunkMemo.get(key)
497
- if (hit) return hit
498
- const merge = event.parents.length > 1
499
- const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'show', '--cc', '--combined-all-paths', '--unified=0', ...RANGE_SEMANTICS, '--format=', event.commit,
500
- ...(merge ? [] : ['--', ...paths])],
501
- `cannot derive anchor hunks for ${event.commit}:${event.historicalPath}`)
502
- let ranges: HunkRanges = { after: [], before: [[]] }
503
- if (/^@@@/m.test(out)) {
504
- const owned = combinedDiffOwnedChanges(out).get(event.historicalPath)
505
- if (!owned) throw new Error(`combined diff for ${event.commit} did not expose owned ranges for '${event.historicalPath}'`)
506
- ranges = owned
507
- } else {
508
- for (const m of out.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
509
- const oldStart = +m[1], oldCount = m[2] === undefined ? 1 : +m[2]
510
- const newStart = +m[3], newCount = m[4] === undefined ? 1 : +m[4]
511
- if (oldCount > 0) ranges.before[0].push([oldStart, oldStart + oldCount - 1])
512
- if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
513
- }
514
- }
515
- return rememberHunks(key, ranges)
516
- }
517
-
518
- // Ordinary commits whose two images keep one path share a single query. Renames and merges retain distinct
519
- // image paths, so they stay on hunksAt's exact event-shaped query. Commits ride argv here, so a build-wide
520
- // batch is split to stay clear of the kernel's exec argument limit; the records are order-independent.
521
- //
522
- // @@@ the demand set is the MISSES - a hunk is reusable by the identity of the IMAGES it diffed, never by the
523
- // commit id (see hunkMemoKey): under that identity, plus this seam's pinned interpretation, the fact cannot be
524
- // reinterpreted inside one process, so the batch may ask git only for what it has not read. Asking for the
525
- // whole window again is what made a repeated read cost the CORPUS instead of the movement: a re-lint after one
526
- // trunk commit or one dirty edit re-forked one `log --patch` per anchored path (22 here) to re-derive
527
- // byte-identical answers.
528
- const HUNK_COMMIT_CHUNK = 400
529
- async function hunksAtMany(root: string, path: string, entries: Map<string, string>): Promise<Map<string, HunkRanges>> {
530
- const result = new Map<string, HunkRanges>()
531
- const ordinary: string[] = []
532
- for (const [commit, key] of entries) {
533
- const hit = hunkMemo.get(key)
534
- if (hit) result.set(commit, hit)
535
- else ordinary.push(commit)
536
- }
537
- if (!ordinary.length) return result
538
- for (let cursor = 0; cursor < ordinary.length; cursor += HUNK_COMMIT_CHUNK)
539
- await hunkRecordsInto(root, ordinary.slice(cursor, cursor + HUNK_COMMIT_CHUNK), path, entries, result)
540
- return result
541
- }
542
- async function hunkRecordsInto(root: string, ordinary: string[], path: string, keys: Map<string, string>, result: Map<string, HunkRanges>): Promise<void> {
543
- const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'log', '--no-walk', '--no-merges', '--patch', '--unified=0', ...RANGE_SEMANTICS,
544
- `--format=${RS}%H`, ...ordinary, '--', path], `cannot derive anchor hunks for ${path}`)
545
- for (const rec of out.split(RS)) {
546
- const normalized = rec.replace(/^\n/, '')
547
- if (!normalized) continue
548
- const newline = normalized.indexOf('\n')
549
- const hash = (newline < 0 ? normalized : normalized.slice(0, newline)).trim()
550
- if (!isGitObjectId(root, hash)) throw new Error(`anchor hunk query returned malformed object id '${hash || 'empty'}'`)
551
- const patch = newline < 0 ? '' : normalized.slice(newline + 1)
552
- const ranges: HunkRanges = { after: [], before: [[]] }
553
- for (const m of patch.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
554
- const oldStart = +m[1], oldCount = m[2] === undefined ? 1 : +m[2]
555
- const newStart = +m[3], newCount = m[4] === undefined ? 1 : +m[4]
556
- if (oldCount > 0) ranges.before[0].push([oldStart, oldStart + oldCount - 1])
557
- if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
558
- }
559
- const key = keys.get(hash)
560
- result.set(hash, key === undefined ? ranges : rememberHunks(key, ranges))
561
- }
562
- }
563
-
564
- // the drift window of an anchored file: every commit to `path` not reachable from the spec's version
565
- // and not covered by a valid Spec-OK ack — the SAME set driftFor counts, exposed as commits so the
566
- // anchor engine can probe each one. Ordinary file commits and merge-authored dense-combined paths are
567
- // indexed separately so clean merge transport is never charged twice.
568
- export function windowEvents(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] {
569
- if (!sinceHash) return []
570
- return driftPathWindow(idx, sinceHash, path, nodeId) ?? []
571
- }
572
-
573
- // Which window commits touched an anchored unit. Added lines intersect the result-image unit; deleted lines
574
- // intersect the parent-image unit. A merge owns a deletion only when every parent-side range belongs to the
575
- // selector. This is why events retain their immutable post/preimage paths instead of resolving every blob
576
- // through the current filename. Several selectors are OR'd and one commit still produces one hit row.
577
- // An historical image the designated extractor cannot parse is a conservative hit (`unparseable`).
578
- export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
579
- export type AnchorHitQuery = { win: DriftPathEvent[]; symbols: string[] }
580
- type AnchorRevision = { commit: string; path: string }
581
- const anchorRevisionKey = ({ commit, path }: AnchorRevision) => `${commit}\0${path}`
582
-
583
- // One lint can judge several selectors with overlapping windows. Their Git images and ordinary hunks are
584
- // reusable under the identity of those images, so the batch owns them once and each query keeps its own
585
- // selector verdict.
586
- async function runAnchorQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[], stopAtFirstHit: boolean): Promise<AnchorHit[][]> {
587
- if (!queries.length) return []
588
- return withEventLedgerBuild(root, () => runAnchorQueriesInLedger(root, queries, regs, stopAtFirstHit))
589
- }
590
-
591
- async function runAnchorQueriesInLedger(root: string, queries: AnchorHitQuery[], regs: Extractor[], stopAtFirstHit: boolean): Promise<AnchorHit[][]> {
592
- const objectFormat = gitObjectFormat(root)
593
- const revisions = new Map<string, AnchorRevision>()
594
- const ordinaryEvents: { path: string; commit: string; event: DriftPathEvent }[] = []
595
- for (const { win } of queries) for (const event of win) {
596
- const refs = [{ commit: event.commit, path: event.historicalPath }, ...event.parents.map(({ commit, historicalPath }) => ({ commit, path: historicalPath }))]
597
- for (const ref of refs) revisions.set(anchorRevisionKey(ref), ref)
598
- if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
599
- ordinaryEvents.push({ path: event.historicalPath, commit: event.commit, event })
600
- }
601
- const refs = [...revisions.values()]
602
- const oids = await batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
603
- // Every image this read will diff is now resolved, so the reusable hunk fact can be named by the identity
604
- // that actually decides it: the result image and each parent image, in order, oid + historical path.
605
- const oidByRef = new Map<string, string | null>()
606
- for (let index = 0; index < refs.length; index++) oidByRef.set(anchorRevisionKey(refs[index]), oids[index])
607
- const imageOf = (commit: string, path: string) => `${oidByRef.get(anchorRevisionKey({ commit, path })) ?? ABSENT_IMAGE}:${path}`
608
- const imageIdentity = (event: DriftPathEvent) => hunkMemoKey([
609
- imageOf(event.commit, event.historicalPath),
610
- ...event.parents.map(({ commit, historicalPath }) => imageOf(commit, historicalPath)),
611
- ])
612
- const hunkKeys = new Set<string>()
613
- const ordinaryByPath = new Map<string, Map<string, string>>()
614
- for (const { path, commit, event } of ordinaryEvents) {
615
- const entries = ordinaryByPath.get(path) ?? new Map<string, string>()
616
- const key = imageIdentity(event)
617
- hunkKeys.add(key)
618
- entries.set(commit, key)
619
- ordinaryByPath.set(path, entries)
620
- }
621
- for (const { win } of queries) for (const event of win) hunkKeys.add(imageIdentity(event))
622
- const durableHunks = readImmutableHunkFacts(root, hunkKeys)
623
- for (const [key, ranges] of durableHunks) rememberHunks(key, ranges)
624
- const newHunks = new Map<string, ImmutableHunkRanges>()
625
- const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
626
- for (const [path, entries] of ordinaryByPath) {
627
- const rangesByCommit = await hunksAtMany(root, path, entries)
628
- ordinaryHunks.set(path, rangesByCommit)
629
- for (const [commit, key] of entries) {
630
- const ranges = rangesByCommit.get(commit)
631
- if (ranges && !durableHunks.has(key)) newHunks.set(key, ranges)
632
- }
633
- }
634
- // The image's own memo answers before any bytes move, so the read asks git only for the revisions this
635
- // process has not parsed yet — the retention half of the same rule the hunk demand set follows.
636
- const units = new Map<string, FileRevisionUnits>()
637
- const primeUnits = async (keys: Set<string>): Promise<void> => {
638
- const pending: { key: string; ref: AnchorRevision; oid: string; x: Extractor }[] = []
639
- const wanted = new Set<string>()
640
- for (const key of keys) {
641
- if (units.has(key)) continue
642
- const ref = revisions.get(key)!, oid = oidByRef.get(key) ?? null
643
- const x = extractorFor(regs, extOf(ref.path))
644
- const ready = x?.ready()
645
- if (!x || ready !== true) {
646
- units.set(key, { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
647
- continue
648
- }
649
- if (!oid) { units.set(key, { absent: true }); continue }
650
- const hit = fileRevisionUnitMemo.get(fileRevisionMemoKey(objectFormat, oid, x, ref.path))
651
- if (hit) { units.set(key, hit); continue }
652
- pending.push({ key, ref, oid, x }); wanted.add(oid)
653
- }
654
- if (!pending.length) return
655
- const blobs = await batchBlobTexts(root, [...wanted])
656
- for (const { key, ref, oid, x } of pending)
657
- units.set(key, await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, blobs.get(oid)))
658
- }
659
- const intersects = (ranges: DiffLineRange[], candidates: Unit[]) =>
660
- ranges.some(([start, end]) => candidates.some((unit) => start <= unit.end && unit.start <= end))
661
- // @@@ the scan advances in DOUBLING corpus-wide rounds - an existence read may stop at its window's first
662
- // hit, but which event that is cannot be known before the images are parsed, so the demand set has to be
663
- // discovered rather than declared. Each round still asks the WHOLE unsettled corpus at once (never one
664
- // reading at a time, which is what would re-fork the batch per unit), and doubling bounds the rounds by hit
665
- // DEPTH, not by how many readings are asked. An enumeration read takes its whole remaining window in one
666
- // slice, so it keeps the single-round shape it has always had.
667
- const runs = queries.map(() => ({ hits: new Map<string, { selectors: Set<string>; unparseable?: string }>(), cursor: 0, settled: false }))
668
- for (let chunk = 1; ; chunk *= 2) {
669
- const slices: { run: typeof runs[number]; symbols: string[]; events: DriftPathEvent[] }[] = []
670
- for (let index = 0; index < queries.length; index++) {
671
- const run = runs[index], { win, symbols } = queries[index]
672
- if (run.settled || run.cursor >= win.length) continue
673
- slices.push({ run, symbols, events: win.slice(run.cursor, run.cursor + (stopAtFirstHit ? chunk : win.length - run.cursor)) })
674
- }
675
- if (!slices.length) break
676
- const keys = new Set<string>()
677
- for (const { events } of slices) for (const event of events) {
678
- keys.add(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))
679
- for (const parent of event.parents) keys.add(anchorRevisionKey({ commit: parent.commit, path: parent.historicalPath }))
680
- }
681
- await primeUnits(keys)
682
- for (const { run, symbols, events } of slices) {
683
- for (const event of events) {
684
- run.cursor++
685
- const after = units.get(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))!
686
- const before = event.parents.map(({ commit, historicalPath }) => units.get(anchorRevisionKey({ commit, path: historicalPath }))!)
687
- const key = imageIdentity(event)
688
- const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
689
- ?? await hunksAt(root, event, key)
690
- if (!durableHunks.has(key)) newHunks.set(key, ranges)
691
- if (event.parents.length && ranges.before.length !== before.length)
692
- throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
693
- const hit = run.hits.get(event.commit) ?? { selectors: new Set<string>() }
694
- const broken = [after, ...before].find((image) => 'unparseable' in image)
695
- if (broken && 'unparseable' in broken) {
696
- for (const symbol of symbols) hit.selectors.add(symbol)
697
- hit.unparseable = broken.unparseable
698
- } else {
699
- for (const symbol of symbols) {
700
- const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
701
- const authoredAfter = intersects(ranges.after, afterUnits)
702
- const authoredBefore = before.length > 0 && before.every((image, parent) =>
703
- 'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
704
- if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
705
- }
706
- }
707
- if (hit.selectors.size) run.hits.set(event.commit, hit)
708
- if (stopAtFirstHit && run.hits.size) { run.settled = true; break }
709
- }
710
- }
711
- }
712
- await persistImmutableHunkFacts(root, newHunks)
713
- return runs.map((run) => [...run.hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
714
- }
715
-
716
- // The two consumers ask DIFFERENT questions of this engine, so they enter through different doors rather
717
- // than through one call with a mode flag. Enumeration must scan every window event because its answer IS the
718
- // per-commit, per-selector list; existence is discharged by the first hit, and scanning past it computes rows
719
- // nobody reads.
720
- export async function anchorHitQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<AnchorHit[][]> {
721
- return runAnchorQueries(root, queries, regs, false)
722
- }
723
- export async function anchorHitExists(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<boolean[]> {
724
- return (await runAnchorQueries(root, queries, regs, true)).map((hits) => hits.length > 0)
725
- }
726
- export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
727
- return (await anchorHitQueries(root, [{ win, symbols }], regs))[0]
728
- }