@asmlift/core 0.1.0 → 0.3.0
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/README.md +18 -23
- package/package.json +1 -1
- package/src/backend/cfamily.ts +30 -4
- package/src/contracts.ts +30 -0
- package/src/declare.ts +225 -0
- package/src/detect.ts +5 -2
- package/src/frontend/format.ts +11 -3
- package/src/frontend/frontend.ts +12 -2
- package/src/frontend/mips.ts +206 -2
- package/src/frontend/splat.ts +305 -0
- package/src/frontend/thumb.ts +119 -6
- package/src/l3/ast.ts +8 -2
- package/src/l3/symbol-refs.ts +61 -0
- package/src/l3/typing.ts +4 -0
- package/src/macros.ts +126 -0
- package/src/pipeline.ts +15 -4
- package/src/proto.ts +55 -0
- package/src/raise/magicdiv.ts +1 -1
- package/src/rank.ts +215 -76
- package/src/structure/structure.ts +462 -45
- package/src/symbols.ts +426 -0
- package/src/trace.ts +8 -2
package/src/rank.ts
CHANGED
|
@@ -12,17 +12,19 @@ import { cBackend } from './backend/c';
|
|
|
12
12
|
import { assertDerefsTyped, assertResolved } from './contracts';
|
|
13
13
|
import type { AsmData } from './frontend/asmdata';
|
|
14
14
|
import { frontendFor } from './frontend/registry';
|
|
15
|
-
import { Fn } from './ir/core';
|
|
15
|
+
import { Fn, type Value, defOpMap } from './ir/core';
|
|
16
16
|
import { T } from './ir/types';
|
|
17
17
|
import { verify } from './ir/verify';
|
|
18
18
|
import type { LanguageBackend, SFn } from './l3/ast';
|
|
19
19
|
import { registerishSpellings } from './l3/regspell';
|
|
20
20
|
import { reindexWalks } from './l3/reindex';
|
|
21
|
+
import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
|
|
21
22
|
import { RewritePattern } from './pattern/engine';
|
|
22
23
|
import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
|
|
23
|
-
import type
|
|
24
|
+
import { type Prototypes, prototypesFromSymbols } from './proto';
|
|
24
25
|
import { runPreRecovery } from './raise/pre-recovery';
|
|
25
26
|
import { recoverTypes } from './raise/recover';
|
|
27
|
+
import { type SymbolMap, symbolsByName } from './symbols';
|
|
26
28
|
import { type TargetDescription, structureOptionsFor } from './target';
|
|
27
29
|
|
|
28
30
|
/** The signedness of the entry parameters — the classic ambiguity asm cannot resolve.
|
|
@@ -51,25 +53,104 @@ function pinScalarParams(fn: Fn, signed: boolean, ptrIdx: Set<number>): void {
|
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** Bare-global ACCESS FACTS for name-only map symbols — the width/signedness authority the
|
|
57
|
+
* declaration synthesis (declare.ts) uses when the map has no shape. The map knows only the
|
|
58
|
+
* NAME (symtab-only projects: marioparty3); the candidate's own IR knows exactly how the cell
|
|
59
|
+
* is accessed, and the bare `gSym = v` / `x = gSym` spelling compiles to those bytes only
|
|
60
|
+
* under a decl of that exact width (`extern u16 g;` is `sh` where a guessed u32 is `sw`).
|
|
61
|
+
* Mirrors structure()'s scalar-global rule: a fact is recorded only for a symbol accessed
|
|
62
|
+
* EXCLUSIVELY at offset 0 with ONE width and ONE load signedness — anything else (interior
|
|
63
|
+
* offsets, address arithmetic, width or sign conflicts) records nothing, because those
|
|
64
|
+
* spellings go through `&gSym` casts where every object decl is address-identical. */
|
|
65
|
+
function bareGlobalAccessFacts(fn: Fn): Map<string, { width: number; signed: boolean }> {
|
|
66
|
+
const defs = defOpMap(fn);
|
|
67
|
+
const symOf = (v: Value): string | null => {
|
|
68
|
+
const d = defs.get(v);
|
|
69
|
+
return d?.opcode === 'gaddr' && d.attrs.code !== true ? (d.attrs.sym as string) : null;
|
|
70
|
+
};
|
|
71
|
+
const acc = new Map<string, { widths: Set<number>; signs: Set<boolean>; interior: boolean }>();
|
|
72
|
+
const get = (s: string) => acc.get(s) ?? acc.set(s, { widths: new Set(), signs: new Set(), interior: false }).get(s)!;
|
|
73
|
+
for (const b of fn.blocks) {
|
|
74
|
+
for (const op of b.ops) {
|
|
75
|
+
if (op.opcode === 'load' || op.opcode === 'store') {
|
|
76
|
+
const s = symOf(op.operands[0]);
|
|
77
|
+
if (s) {
|
|
78
|
+
const a = get(s);
|
|
79
|
+
if ((op.attrs.off as number) !== 0) {
|
|
80
|
+
a.interior = true;
|
|
81
|
+
} else {
|
|
82
|
+
a.widths.add(op.attrs.width as number);
|
|
83
|
+
if (op.opcode === 'load') {
|
|
84
|
+
a.signs.add(((op.attrs.signed as boolean) ?? false) && (op.attrs.width as number) < 4);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else if (op.opcode === 'aload' || op.opcode === 'astore') {
|
|
89
|
+
const s = symOf(op.operands[0]);
|
|
90
|
+
if (s) {
|
|
91
|
+
get(s).interior = true;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// any other use of the address (arithmetic, a call arg, a comparison) is interior/escape
|
|
95
|
+
for (const o of op.operands) {
|
|
96
|
+
const s = symOf(o);
|
|
97
|
+
if (s) {
|
|
98
|
+
get(s).interior = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const out = new Map<string, { width: number; signed: boolean }>();
|
|
105
|
+
for (const [s, a] of acc) {
|
|
106
|
+
if (!a.interior && a.widths.size === 1 && a.signs.size <= 1) {
|
|
107
|
+
out.set(s, { width: [...a.widths][0], signed: a.signs.has(true) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
54
113
|
export interface EnumerateOptions {
|
|
55
114
|
patterns?: RewritePattern[];
|
|
56
115
|
backend?: LanguageBackend;
|
|
57
116
|
prototypes?: Prototypes;
|
|
58
117
|
asmData?: AsmData;
|
|
118
|
+
/** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
|
|
119
|
+
symbols?: SymbolMap;
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
/** One distinct candidate spelling (a signedness × branch-sense lever combination), emitted to source. */
|
|
62
123
|
export interface Candidate {
|
|
63
124
|
label: string;
|
|
64
125
|
source: string;
|
|
126
|
+
/** the map-derived VALUE references this candidate's tree contains — what the scoring
|
|
127
|
+
* layer's declaration synthesis renders. DERIVED, never carried: computed once from the
|
|
128
|
+
* exact tree this candidate's source was emitted from, at the moment the candidate is
|
|
129
|
+
* finalized (l3/symbol-refs.ts — no pipeline stage caches refs, so they cannot go stale).
|
|
130
|
+
* Present on EVERY spelling variant that names mapped symbols — including '/raw-globals',
|
|
131
|
+
* whose tree still names pool/reloc-derived globals (it only drops the map's shaped
|
|
132
|
+
* SPELLINGS). Absent without a map — synthesis then has nothing to do. */
|
|
133
|
+
symbolRefs?: SymbolRef[];
|
|
65
134
|
}
|
|
66
135
|
/** A candidate paired with its score `S` (the injected scorer's result shape — must carry `.score`). */
|
|
67
136
|
export interface Scored<S> extends Candidate {
|
|
68
137
|
score: S;
|
|
69
138
|
}
|
|
139
|
+
/** A candidate the scorer REFUSED — its C did not build. Recorded rather than discarded: a
|
|
140
|
+
* spelling that fails to compile is a defect in the emitter or in the facts it was given, and a
|
|
141
|
+
* scoring harness that shows only the surviving sibling reports a clean win over a hidden
|
|
142
|
+
* failure. */
|
|
143
|
+
export interface DroppedCandidate {
|
|
144
|
+
label: string;
|
|
145
|
+
/** the scorer's first error line (a compiler diagnostic, usually) */
|
|
146
|
+
error: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
70
149
|
export interface RankedResult<S> {
|
|
71
150
|
best: Scored<S>; // lowest score
|
|
72
151
|
candidates: Scored<S>[]; // sorted best (lowest) first
|
|
152
|
+
/** candidates whose scoreFn threw — empty when every spelling built */
|
|
153
|
+
dropped: DroppedCandidate[];
|
|
73
154
|
}
|
|
74
155
|
|
|
75
156
|
/** Emit the DISTINCT type/branch-sense candidate spellings for `name` — PURE, no scoring.
|
|
@@ -83,9 +164,14 @@ export function enumerateCandidates(
|
|
|
83
164
|
opts: EnumerateOptions = {},
|
|
84
165
|
): Candidate[] {
|
|
85
166
|
const backend = opts.backend ?? cBackend;
|
|
86
|
-
|
|
167
|
+
// Same merge as `decompile`: the project's DWARF signatures fill in what the caller did not
|
|
168
|
+
// state, so both the annotate pass and the ranked candidates reason about one prototype table.
|
|
169
|
+
const prototypes = prototypesFromSymbols(opts.symbols, opts.prototypes ?? {});
|
|
87
170
|
const frontend = frontendFor(target);
|
|
88
|
-
const baseOpts =
|
|
171
|
+
const baseOpts = {
|
|
172
|
+
...structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false),
|
|
173
|
+
...(opts.symbols ? { symbols: symbolsByName(opts.symbols) } : {}),
|
|
174
|
+
};
|
|
89
175
|
// Branch-sense is a differ-ranked LEVER, the same class as param signedness: a divergent `if`
|
|
90
176
|
// can be spelled with either sense (`if (c) A else B` vs `if (!c) B else A`), and which one the
|
|
91
177
|
// source compiler emitted is genuinely ambiguous from asm. There is no safe global heuristic
|
|
@@ -101,75 +187,120 @@ export function enumerateCandidates(
|
|
|
101
187
|
// so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
|
|
102
188
|
// compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
|
|
103
189
|
// the totality contract / return-sinking of the full spine are not run on it.)
|
|
104
|
-
const probe = frontend.lift(name, asm, target, prototypes, opts.asmData);
|
|
190
|
+
const probe = frontend.lift(name, asm, target, prototypes, opts.asmData, opts.symbols);
|
|
105
191
|
verify(probe);
|
|
106
192
|
applyIdiomPatterns(probe, target, opts.patterns);
|
|
107
193
|
runPreRecovery(probe, target, () => verify(probe));
|
|
108
194
|
recoverTypes(probe);
|
|
109
195
|
const ptrIdx = new Set<number>(probe.blocks[0].params.flatMap((p, i) => (NO_PIN_KINDS.has(p.type.kind) ? [i] : [])));
|
|
196
|
+
// Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
|
|
197
|
+
// from the probe: widths/offsets are lift-time facts, identical across every candidate.
|
|
198
|
+
const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
|
|
110
199
|
|
|
111
200
|
const seen = new Set<string>();
|
|
112
201
|
const out: Candidate[] = [];
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
202
|
+
// The SYMBOL-MAP spelling is itself a ranked LEVER on the same footing as signedness/branch
|
|
203
|
+
// sense: naming a global changes agbcc's codegen (the eager-load effect), and which side
|
|
204
|
+
// byte-wins is genuinely per-function — the dogfood's landed matches split between extern
|
|
205
|
+
// spellings and raw-address macros. So when a map is present the raw-global spelling is ALSO
|
|
206
|
+
// enumerated ('/raw-globals') and the differ referees; the dedup below collapses the pair
|
|
207
|
+
// wherever the map changed nothing, so this never scores worse than either side alone.
|
|
208
|
+
const symbolVariants: { suffix: string; symbols?: typeof opts.symbols }[] = opts.symbols
|
|
209
|
+
? [
|
|
210
|
+
{ suffix: '', symbols: opts.symbols },
|
|
211
|
+
{ suffix: '/raw-globals', symbols: undefined },
|
|
212
|
+
]
|
|
213
|
+
: [{ suffix: '' }];
|
|
214
|
+
for (const sv of symbolVariants) {
|
|
215
|
+
const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
|
|
216
|
+
for (const cand of SIGN_CANDS) {
|
|
217
|
+
const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
|
|
218
|
+
verify(fn);
|
|
219
|
+
applyIdiomPatterns(fn, target, opts.patterns);
|
|
220
|
+
// The shared tower spine (pipeline.ts) — the candidate's ONE difference from decompile() is the
|
|
221
|
+
// signedness pin, injected between pre-recovery and recoverTypes via the beforeRecover hook.
|
|
222
|
+
raiseRecovered(fn, target, { beforeRecover: () => pinScalarParams(fn, cand.signed, ptrIdx) });
|
|
223
|
+
for (const s of senseCands) {
|
|
224
|
+
// structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
|
|
225
|
+
// senses structure the same recovered function without re-lifting.
|
|
226
|
+
const sfn = structureChecked(fn, { ...svOpts, preserveDivergentBranchSense: s.sense });
|
|
227
|
+
// The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
|
|
228
|
+
// signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
|
|
229
|
+
// genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
|
|
230
|
+
// when a loop re-spells, BOTH representations are emitted and the differ referees. The
|
|
231
|
+
// re-spelling passes the same boundary contracts as the primary; one that fails them is
|
|
232
|
+
// dropped here — never scored, never able to win.
|
|
233
|
+
// Each spelling's symbol refs are DERIVED from its own final tree right where the
|
|
234
|
+
// spelling is emitted — the single point a candidate comes into existence. No pipeline
|
|
235
|
+
// stage carries refs (SFn has no such field), so a future l3 pass that rewrites the tree
|
|
236
|
+
// can never leave a stale ref behind: whatever tree reaches emit is the tree the refs
|
|
237
|
+
// describe, by construction. Collected against the FULL name-keyed map for EVERY
|
|
238
|
+
// spelling variant — the '/raw-globals' sibling drops the map's shaped SPELLINGS, but
|
|
239
|
+
// its tree still NAMES pool/reloc-derived globals (ARM `.word gSym`, MIPS `%lo(gSym)`),
|
|
240
|
+
// and those references need declarations in the self-declared scoring world exactly
|
|
241
|
+
// like the named variant's (without them every raw sibling fails to compile there,
|
|
242
|
+
// and the eval-winning raw candidate becomes unreproducible outside project headers).
|
|
243
|
+
const refsOf = (tree: SFn): { symbolRefs?: SymbolRef[] } => {
|
|
244
|
+
const refs = baseOpts.symbols
|
|
245
|
+
? collectSymbolRefs(tree.body, baseOpts.symbols, tree.name).map((r) => {
|
|
246
|
+
// name-only symbols carry the IR-derived access facts — the width authority
|
|
247
|
+
// for their synthesized declaration (shaped symbols keep the map's truth)
|
|
248
|
+
const access = r.info.shape === undefined ? accessFacts.get(r.name) : undefined;
|
|
249
|
+
return access ? { ...r, access } : r;
|
|
250
|
+
})
|
|
251
|
+
: [];
|
|
252
|
+
return refs.length ? { symbolRefs: refs } : {};
|
|
253
|
+
};
|
|
254
|
+
const spellings: { suffix: string; source: string; symbolRefs?: SymbolRef[] }[] = [
|
|
255
|
+
{ suffix: '', source: backend.emit(sfn), ...refsOf(sfn) },
|
|
256
|
+
];
|
|
257
|
+
// Representation re-spellings — each a lever on the same footing as signedness/branch sense,
|
|
258
|
+
// each guarded: it must pass the same boundary contracts as the primary AND emit (a backend
|
|
259
|
+
// that declines by throwing — Pascal loud-fails unspellable shapes — drops the candidate,
|
|
260
|
+
// never aborts the enumeration). A dropped re-spelling loses nothing: the primary remains.
|
|
261
|
+
//
|
|
262
|
+
// POLICY: re-spellings derive from the BASE spelling only — levers do not compose
|
|
263
|
+
// (an /indexed + /regcopy product is deferred until a row demands it). And a lever must
|
|
264
|
+
// PRESERVE SEMANTICS by construction: the differ referees byte-exactness (a wrong candidate
|
|
265
|
+
// can never fake a score-0 match), but on a NONMATCH row the best-scoring source is shown
|
|
266
|
+
// to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
|
|
267
|
+
// defect class this project exists to avoid. Hence each lever's decline-over-approximate
|
|
268
|
+
// gates, adversarially audited.
|
|
269
|
+
const respell = (suffix: string, alt: SFn): void => {
|
|
270
|
+
try {
|
|
271
|
+
assertResolved(alt);
|
|
272
|
+
assertDerefsTyped(alt);
|
|
273
|
+
spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
|
|
274
|
+
} catch {
|
|
275
|
+
// contract-failing or unspellable re-spelling: drop it, keep the primary
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const indexed = reindexWalks(sfn);
|
|
279
|
+
if (indexed) {
|
|
280
|
+
respell('/indexed', indexed);
|
|
150
281
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
282
|
+
// the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
|
|
283
|
+
// the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
|
|
284
|
+
// ambiguous, so both are ranked)
|
|
285
|
+
const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
|
|
286
|
+
registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
|
|
287
|
+
for (const sp of spellings) {
|
|
288
|
+
const source = sp.source;
|
|
289
|
+
// Collapse a spelling that produced identical source (a function with no divergent `if`
|
|
290
|
+
// structures the same either way): no point scoring a duplicate spelling. Deduping the
|
|
291
|
+
// WHOLE emitted set (not just scored survivors) is equivalent — an identical source
|
|
292
|
+
// scores identically, so it can never change `best` — and it keeps the candidate set to
|
|
293
|
+
// the genuinely distinct spellings.
|
|
294
|
+
if (seen.has(source)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
seen.add(source);
|
|
298
|
+
out.push({
|
|
299
|
+
label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
|
|
300
|
+
source,
|
|
301
|
+
...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
|
|
302
|
+
});
|
|
170
303
|
}
|
|
171
|
-
seen.add(source);
|
|
172
|
-
out.push({ label: `${cand.label}${s.suffix}${sp.suffix}`, source });
|
|
173
304
|
}
|
|
174
305
|
}
|
|
175
306
|
}
|
|
@@ -185,24 +316,32 @@ export function enumerateCandidates(
|
|
|
185
316
|
export function rankBy<S extends { score: number }>(
|
|
186
317
|
candidates: Candidate[],
|
|
187
318
|
symbol: string,
|
|
188
|
-
scoreFn: (source: string, symbol: string) => S,
|
|
319
|
+
scoreFn: (source: string, symbol: string, candidate: Candidate) => S,
|
|
189
320
|
): RankedResult<S> {
|
|
190
|
-
const results: Scored<S>[] = [];
|
|
191
|
-
|
|
192
|
-
|
|
321
|
+
const results: (Scored<S> & { order: number })[] = [];
|
|
322
|
+
const dropped: DroppedCandidate[] = []; // spellings that failed to build; only fatal if ALL do
|
|
323
|
+
let lastScoreErr: unknown = null;
|
|
324
|
+
candidates.forEach((c, order) => {
|
|
193
325
|
try {
|
|
194
|
-
results.push({ ...c, score: scoreFn(c.source, symbol) });
|
|
326
|
+
results.push({ ...c, order, score: scoreFn(c.source, symbol, c) });
|
|
195
327
|
} catch (e) {
|
|
196
328
|
lastScoreErr = e;
|
|
329
|
+
dropped.push({ label: c.label, error: firstLine(e) });
|
|
197
330
|
}
|
|
198
|
-
}
|
|
331
|
+
});
|
|
199
332
|
if (results.length === 0) {
|
|
200
|
-
|
|
201
|
-
lastScoreErr instanceof Error
|
|
202
|
-
? lastScoreErr.message.split('\n')[0]
|
|
203
|
-
: String(lastScoreErr ?? 'no candidate produced');
|
|
204
|
-
throw new Error(`no scorable candidate for '${symbol}': ${why}`, { cause: lastScoreErr });
|
|
333
|
+
throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
|
|
205
334
|
}
|
|
206
|
-
|
|
207
|
-
|
|
335
|
+
// Score first; ENUMERATION ORDER breaks a tie. That order is meaningful, not incidental:
|
|
336
|
+
// enumerateCandidates emits the symbol-map spellings before their `/raw-globals` siblings, so
|
|
337
|
+
// when both compile to the same bytes the named one wins and the reader gets `gCounter` rather
|
|
338
|
+
// than a bare address. Spelled as an explicit comparator because relying on Array#sort's
|
|
339
|
+
// stability would make the preference an accident of two unrelated decisions.
|
|
340
|
+
results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
|
|
341
|
+
return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
|
|
345
|
+
function firstLine(e: unknown): string {
|
|
346
|
+
return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
|
|
208
347
|
}
|