@spexcode/spec-core 0.6.2 → 0.6.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/anchors.d.ts +94 -0
- package/dist/anchors.js +730 -0
- package/dist/git.d.ts +166 -0
- package/dist/git.js +2736 -0
- package/dist/graph.d.ts +34 -0
- package/dist/graph.js +237 -0
- package/dist/graphDelta.d.ts +45 -0
- package/dist/graphDelta.js +84 -0
- package/dist/harness-identity.d.ts +31 -0
- package/dist/harness-identity.js +20 -0
- package/dist/identity-presets.d.ts +152 -0
- package/dist/identity-presets.js +132 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +183 -0
- package/dist/layout.js +548 -0
- package/dist/process-identity.d.ts +37 -0
- package/dist/process-identity.js +214 -0
- package/dist/project-identity.d.ts +12 -0
- package/dist/project-identity.js +71 -0
- package/dist/project-store.d.ts +3 -0
- package/dist/project-store.js +14 -0
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.js +40 -0
- package/dist/review/index.d.ts +3 -0
- package/{src → dist}/review/index.js +3 -3
- package/dist/review/reviewFilters.d.ts +77 -0
- package/dist/review/reviewFilters.js +308 -0
- package/dist/review/reviewQuery.d.ts +66 -0
- package/dist/review/reviewQuery.js +180 -0
- package/dist/review/session.d.ts +4 -0
- package/dist/review/session.js +8 -0
- package/dist/reviewSnapshot.d.ts +15 -0
- package/dist/reviewSnapshot.js +12 -0
- package/dist/root-lru.d.ts +4 -0
- package/{src/root-lru.ts → dist/root-lru.js} +26 -30
- package/dist/specs.d.ts +117 -0
- package/dist/specs.js +489 -0
- package/package.json +19 -8
- package/src/anchors.ts +0 -728
- package/src/git.ts +0 -2556
- package/src/graph.ts +0 -251
- package/src/harness-identity.ts +0 -26
- package/src/identity-presets.d.ts +0 -13
- package/src/identity-presets.js +0 -138
- package/src/index.ts +0 -20
- package/src/layout.ts +0 -637
- package/src/process-identity.ts +0 -207
- package/src/project-identity.ts +0 -73
- package/src/project-store.ts +0 -17
- package/src/resilience.ts +0 -41
- package/src/review/reviewFilters.js +0 -324
- package/src/review/reviewQuery.js +0 -174
- package/src/review/session.js +0 -13
- package/src/reviewSnapshot.ts +0 -28
- 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
|
-
}
|