@wyattjoh/demur 0.4.2 → 0.5.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 +79 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +10 -2
- package/src/cli.ts +237 -0
- package/src/training-review-model.ts +176 -0
- package/src/training-review-tui.tsx +947 -0
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { Predicate } from "effect";
|
|
4
|
+
import { loadCostTotals } from "../extensions/demur/cost-tracker.ts";
|
|
5
|
+
import {
|
|
6
|
+
loadTrainingRecords,
|
|
7
|
+
loadTrainingReviews,
|
|
8
|
+
recordTrainingReview,
|
|
9
|
+
type TrainingRecord,
|
|
10
|
+
type TrainingReview,
|
|
11
|
+
type TrainingReviewInput,
|
|
12
|
+
} from "../extensions/demur/training-store.ts";
|
|
3
13
|
import { guard } from "./guard.ts";
|
|
4
14
|
import {
|
|
5
15
|
deleteApiKey,
|
|
@@ -7,12 +17,20 @@ import {
|
|
|
7
17
|
storeApiKey,
|
|
8
18
|
type ResolvedApiKey,
|
|
9
19
|
} from "./key.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildTrainingReviewEntries,
|
|
22
|
+
getTrainingReviewFilter,
|
|
23
|
+
type TrainingReviewSnapshot,
|
|
24
|
+
} from "./training-review-model.ts";
|
|
25
|
+
import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
|
|
10
26
|
import type { Verdict } from "./types.ts";
|
|
11
27
|
|
|
12
28
|
const USAGE = `Usage:
|
|
29
|
+
demur
|
|
13
30
|
demur auth login
|
|
14
31
|
demur auth status
|
|
15
32
|
demur auth logout
|
|
33
|
+
demur training review [--plain]
|
|
16
34
|
demur judge "<command>" [--cwd=<path>]`;
|
|
17
35
|
|
|
18
36
|
/**
|
|
@@ -23,18 +41,49 @@ export type CliDependencies = {
|
|
|
23
41
|
resolveApiKey(): Promise<ResolvedApiKey | undefined>;
|
|
24
42
|
storeApiKey(value: string): Promise<void>;
|
|
25
43
|
deleteApiKey(): Promise<boolean>;
|
|
44
|
+
loadTrainingRecords(): Promise<ReadonlyArray<TrainingRecord>>;
|
|
45
|
+
loadTrainingReviews(): Promise<ReadonlyArray<TrainingReview>>;
|
|
46
|
+
loadGlobalEstimatedCostUsd(): Promise<number>;
|
|
47
|
+
recordTrainingReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
48
|
+
runTrainingReviewTui(
|
|
49
|
+
snapshot: TrainingReviewSnapshot,
|
|
50
|
+
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
51
|
+
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
52
|
+
): Promise<TrainingReviewTuiResult>;
|
|
53
|
+
isInteractive(): boolean;
|
|
26
54
|
readSecret(prompt: string): Promise<string>;
|
|
55
|
+
readLine(prompt: string): Promise<string>;
|
|
27
56
|
cwd(): string;
|
|
28
57
|
stdout(message: string): void;
|
|
29
58
|
stderr(message: string): void;
|
|
30
59
|
};
|
|
31
60
|
|
|
61
|
+
let lineInput:
|
|
62
|
+
| {
|
|
63
|
+
terminal: ReturnType<typeof createInterface>;
|
|
64
|
+
lines: AsyncIterator<string>;
|
|
65
|
+
}
|
|
66
|
+
| undefined;
|
|
67
|
+
|
|
32
68
|
const defaultDependencies: CliDependencies = {
|
|
33
69
|
judge: (command, cwd) => guard(command, cwd, "cli"),
|
|
34
70
|
resolveApiKey,
|
|
35
71
|
storeApiKey,
|
|
36
72
|
deleteApiKey,
|
|
73
|
+
loadTrainingRecords,
|
|
74
|
+
loadTrainingReviews,
|
|
75
|
+
loadGlobalEstimatedCostUsd: async () =>
|
|
76
|
+
(await loadCostTotals()).estimatedCostUsd,
|
|
77
|
+
recordTrainingReview,
|
|
78
|
+
runTrainingReviewTui: async (snapshot, reloadSnapshot, recordReview) => {
|
|
79
|
+
const { runTrainingReviewTui } = await import(
|
|
80
|
+
"./training-review-tui.tsx"
|
|
81
|
+
);
|
|
82
|
+
return runTrainingReviewTui(snapshot, reloadSnapshot, recordReview);
|
|
83
|
+
},
|
|
84
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
37
85
|
readSecret,
|
|
86
|
+
readLine,
|
|
38
87
|
cwd: () => process.cwd(),
|
|
39
88
|
stdout: (message) => console.log(message),
|
|
40
89
|
stderr: (message) => console.error(message),
|
|
@@ -52,10 +101,24 @@ export async function runCli(
|
|
|
52
101
|
dependencies: CliDependencies = defaultDependencies,
|
|
53
102
|
): Promise<number> {
|
|
54
103
|
try {
|
|
104
|
+
if (args.length === 0) {
|
|
105
|
+
if (!dependencies.isInteractive()) {
|
|
106
|
+
dependencies.stderr(
|
|
107
|
+
"demur: the interactive app requires a terminal; use `demur training review --plain` for line-oriented review.",
|
|
108
|
+
);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
return await runTraining(["review"], dependencies);
|
|
112
|
+
}
|
|
113
|
+
|
|
55
114
|
if (args[0] === "auth") {
|
|
56
115
|
return await runAuth(args.slice(1), dependencies);
|
|
57
116
|
}
|
|
58
117
|
|
|
118
|
+
if (args[0] === "training") {
|
|
119
|
+
return await runTraining(args.slice(1), dependencies);
|
|
120
|
+
}
|
|
121
|
+
|
|
59
122
|
if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
|
|
60
123
|
dependencies.stdout(USAGE);
|
|
61
124
|
return 0;
|
|
@@ -66,6 +129,8 @@ export async function runCli(
|
|
|
66
129
|
} catch (error: unknown) {
|
|
67
130
|
dependencies.stderr(`demur: ${errorDetail(error)}`);
|
|
68
131
|
return 1;
|
|
132
|
+
} finally {
|
|
133
|
+
closeLineInput();
|
|
69
134
|
}
|
|
70
135
|
}
|
|
71
136
|
|
|
@@ -136,6 +201,158 @@ async function runAuth(
|
|
|
136
201
|
return 2;
|
|
137
202
|
}
|
|
138
203
|
|
|
204
|
+
async function runTraining(
|
|
205
|
+
args: ReadonlyArray<string>,
|
|
206
|
+
dependencies: CliDependencies,
|
|
207
|
+
): Promise<number> {
|
|
208
|
+
const plain = args.includes("--plain");
|
|
209
|
+
const operands = args.filter((arg) => arg !== "--plain");
|
|
210
|
+
if (
|
|
211
|
+
operands.length !== 1 ||
|
|
212
|
+
operands[0] !== "review" ||
|
|
213
|
+
args.filter((arg) => arg === "--plain").length > 1
|
|
214
|
+
) {
|
|
215
|
+
dependencies.stderr(USAGE);
|
|
216
|
+
return 2;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const loadSnapshot = () => loadTrainingReviewSnapshot(dependencies);
|
|
220
|
+
const snapshot = await loadSnapshot();
|
|
221
|
+
|
|
222
|
+
if (!plain && dependencies.isInteractive()) {
|
|
223
|
+
const result = await dependencies.runTrainingReviewTui(
|
|
224
|
+
snapshot,
|
|
225
|
+
loadSnapshot,
|
|
226
|
+
dependencies.recordTrainingReview,
|
|
227
|
+
);
|
|
228
|
+
printReviewSummary(result, dependencies);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const pending = buildTrainingReviewEntries(snapshot)
|
|
233
|
+
.filter((entry) => getTrainingReviewFilter(entry) === "unreviewed")
|
|
234
|
+
.map((entry) => entry.record);
|
|
235
|
+
if (pending.length === 0) {
|
|
236
|
+
dependencies.stdout("No unreviewed demur training records.");
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return runPlainTrainingReview(pending, dependencies);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function loadTrainingReviewSnapshot(
|
|
244
|
+
dependencies: CliDependencies,
|
|
245
|
+
): Promise<TrainingReviewSnapshot> {
|
|
246
|
+
const [records, reviews, globalEstimatedCostUsd] = await Promise.all([
|
|
247
|
+
dependencies.loadTrainingRecords(),
|
|
248
|
+
dependencies.loadTrainingReviews(),
|
|
249
|
+
dependencies.loadGlobalEstimatedCostUsd(),
|
|
250
|
+
]);
|
|
251
|
+
return { records, reviews, globalEstimatedCostUsd };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function runPlainTrainingReview(
|
|
255
|
+
pending: ReadonlyArray<TrainingRecord>,
|
|
256
|
+
dependencies: CliDependencies,
|
|
257
|
+
): Promise<number> {
|
|
258
|
+
let reviewed = 0;
|
|
259
|
+
let corrected = 0;
|
|
260
|
+
let skipped = 0;
|
|
261
|
+
for (const [index, record] of pending.entries()) {
|
|
262
|
+
printTrainingRecord(record, index + 1, pending.length, dependencies);
|
|
263
|
+
const expectedDecision = await readExpectedDecision(record, dependencies);
|
|
264
|
+
if (expectedDecision === "quit") break;
|
|
265
|
+
if (expectedDecision === "skip") {
|
|
266
|
+
skipped += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const isCorrection = expectedDecision !== record.verdict.decision;
|
|
271
|
+
const note = isCorrection
|
|
272
|
+
? (await dependencies.readLine("Correction note (optional): ")).trim() ||
|
|
273
|
+
undefined
|
|
274
|
+
: undefined;
|
|
275
|
+
await dependencies.recordTrainingReview({
|
|
276
|
+
recordId: record.id,
|
|
277
|
+
originalDecision: record.verdict.decision,
|
|
278
|
+
expectedDecision,
|
|
279
|
+
note,
|
|
280
|
+
});
|
|
281
|
+
reviewed += 1;
|
|
282
|
+
if (isCorrection) corrected += 1;
|
|
283
|
+
dependencies.stdout(
|
|
284
|
+
isCorrection
|
|
285
|
+
? `Recorded correction: ${record.verdict.decision} → ${expectedDecision}.`
|
|
286
|
+
: `Accepted ${record.verdict.decision} decision.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
printReviewSummary({ reviewed, corrected, skipped }, dependencies);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function printReviewSummary(
|
|
295
|
+
result: TrainingReviewTuiResult,
|
|
296
|
+
dependencies: CliDependencies,
|
|
297
|
+
): void {
|
|
298
|
+
const skipped = result.skipped === 0
|
|
299
|
+
? "."
|
|
300
|
+
: `; ${result.skipped} skipped.`;
|
|
301
|
+
dependencies.stdout(
|
|
302
|
+
`Reviewed ${result.reviewed} record${result.reviewed === 1 ? "" : "s"}; ${result.corrected} corrected${skipped}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readExpectedDecision(
|
|
307
|
+
record: TrainingRecord,
|
|
308
|
+
dependencies: CliDependencies,
|
|
309
|
+
): Promise<"allow" | "ask" | "deny" | "skip" | "quit"> {
|
|
310
|
+
while (true) {
|
|
311
|
+
const input = (
|
|
312
|
+
await dependencies.readLine(
|
|
313
|
+
`Expected decision [enter=${record.verdict.decision}, allow, ask, deny, skip, quit]: `,
|
|
314
|
+
)
|
|
315
|
+
).trim().toLowerCase();
|
|
316
|
+
|
|
317
|
+
if (input === "") return record.verdict.decision;
|
|
318
|
+
if (
|
|
319
|
+
input === "allow" ||
|
|
320
|
+
input === "ask" ||
|
|
321
|
+
input === "deny" ||
|
|
322
|
+
input === "skip" ||
|
|
323
|
+
input === "quit"
|
|
324
|
+
) {
|
|
325
|
+
return input;
|
|
326
|
+
}
|
|
327
|
+
dependencies.stderr(`Unknown review choice: ${input}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function printTrainingRecord(
|
|
332
|
+
record: TrainingRecord,
|
|
333
|
+
index: number,
|
|
334
|
+
total: number,
|
|
335
|
+
dependencies: CliDependencies,
|
|
336
|
+
): void {
|
|
337
|
+
dependencies.stdout("");
|
|
338
|
+
dependencies.stdout(
|
|
339
|
+
`[${index}/${total}] ${record.recordedAt} · ${record.mode} · host ${record.hostAction}`,
|
|
340
|
+
);
|
|
341
|
+
dependencies.stdout(`cwd: ${record.cwd}`);
|
|
342
|
+
dependencies.stdout(`command: ${record.command}`);
|
|
343
|
+
dependencies.stdout(
|
|
344
|
+
`verdict: ${record.verdict.decision.toUpperCase()} — ${record.verdict.reason}`,
|
|
345
|
+
);
|
|
346
|
+
if (record.verdict.judgments !== undefined) {
|
|
347
|
+
dependencies.stdout(
|
|
348
|
+
`judgments: ${JSON.stringify(record.verdict.judgments)}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (record.verdict.failure !== undefined) {
|
|
352
|
+
dependencies.stdout(`failure: ${record.verdict.failure}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
139
356
|
async function runJudge(
|
|
140
357
|
args: ReadonlyArray<string>,
|
|
141
358
|
dependencies: CliDependencies,
|
|
@@ -186,6 +403,26 @@ async function runJudge(
|
|
|
186
403
|
return 0;
|
|
187
404
|
}
|
|
188
405
|
|
|
406
|
+
async function readLine(prompt: string): Promise<string> {
|
|
407
|
+
if (lineInput === undefined) {
|
|
408
|
+
const terminal = createInterface({ input: process.stdin });
|
|
409
|
+
lineInput = {
|
|
410
|
+
terminal,
|
|
411
|
+
lines: terminal[Symbol.asyncIterator](),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
process.stderr.write(prompt);
|
|
416
|
+
const next = await lineInput.lines.next();
|
|
417
|
+
if (next.done) throw new Error("training review input ended");
|
|
418
|
+
return next.value;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function closeLineInput(): void {
|
|
422
|
+
lineInput?.terminal.close();
|
|
423
|
+
lineInput = undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
189
426
|
async function readSecret(prompt: string): Promise<string> {
|
|
190
427
|
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
|
|
191
428
|
return (await Bun.stdin.text()).trim();
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TrainingRecord,
|
|
3
|
+
TrainingReview,
|
|
4
|
+
TrainingReviewInput,
|
|
5
|
+
} from "../extensions/demur/training-store.ts";
|
|
6
|
+
import type { Decision } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Complete append-only training evidence and human review state.
|
|
10
|
+
*/
|
|
11
|
+
export type TrainingReviewSnapshot = {
|
|
12
|
+
records: ReadonlyArray<TrainingRecord>;
|
|
13
|
+
reviews: ReadonlyArray<TrainingReview>;
|
|
14
|
+
globalEstimatedCostUsd: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One training record paired with every review appended for its stable ID.
|
|
19
|
+
*/
|
|
20
|
+
export type TrainingReviewEntry = {
|
|
21
|
+
record: TrainingRecord;
|
|
22
|
+
reviews: ReadonlyArray<TrainingReview>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Latest human-review status derived for one training entry.
|
|
27
|
+
*/
|
|
28
|
+
export type TrainingReviewStatus = "unreviewed" | Decision;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Status tabs available in the interactive training-review queue.
|
|
32
|
+
*/
|
|
33
|
+
export type TrainingReviewFilter = "all" | TrainingReviewStatus;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pair each captured record with its append-only review history.
|
|
37
|
+
*
|
|
38
|
+
* @param snapshot - Complete record and review state
|
|
39
|
+
* @returns Entries in capture order with reviews in append order
|
|
40
|
+
*/
|
|
41
|
+
export function buildTrainingReviewEntries(
|
|
42
|
+
snapshot: TrainingReviewSnapshot,
|
|
43
|
+
): ReadonlyArray<TrainingReviewEntry> {
|
|
44
|
+
const reviewsByRecord = new Map<string, Array<TrainingReview>>();
|
|
45
|
+
for (const review of snapshot.reviews) {
|
|
46
|
+
const reviews = reviewsByRecord.get(review.recordId) ?? [];
|
|
47
|
+
reviews.push(review);
|
|
48
|
+
reviewsByRecord.set(review.recordId, reviews);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return snapshot.records.map((record) => ({
|
|
52
|
+
record,
|
|
53
|
+
reviews: reviewsByRecord.get(record.id) ?? [],
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Return the latest append-only human review for an entry.
|
|
59
|
+
*
|
|
60
|
+
* @param entry - Training record and its review history
|
|
61
|
+
* @returns Latest review, or undefined when the record is unreviewed
|
|
62
|
+
*/
|
|
63
|
+
export function getLatestTrainingReview(
|
|
64
|
+
entry: TrainingReviewEntry,
|
|
65
|
+
): TrainingReview | undefined {
|
|
66
|
+
return entry.reviews.at(-1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Classify an entry by its latest human answer.
|
|
71
|
+
*
|
|
72
|
+
* @param entry - Training record and its review history
|
|
73
|
+
* @returns Unreviewed or the latest expected decision
|
|
74
|
+
*/
|
|
75
|
+
export function getTrainingReviewFilter(
|
|
76
|
+
entry: TrainingReviewEntry,
|
|
77
|
+
): TrainingReviewStatus {
|
|
78
|
+
return getLatestTrainingReview(entry)?.expectedDecision ?? "unreviewed";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Select one status tab and fuzzy-search matching working directories.
|
|
83
|
+
*
|
|
84
|
+
* @param entries - Complete training history
|
|
85
|
+
* @param filter - Active latest-answer status
|
|
86
|
+
* @param cwdQuery - Optional fuzzy working-directory query
|
|
87
|
+
* @returns Matching entries, ranked by path match when a query is present
|
|
88
|
+
*/
|
|
89
|
+
export function filterTrainingReviewEntries(
|
|
90
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
91
|
+
filter: TrainingReviewFilter,
|
|
92
|
+
cwdQuery: string,
|
|
93
|
+
): ReadonlyArray<TrainingReviewEntry> {
|
|
94
|
+
const statusMatches = filter === "all"
|
|
95
|
+
? entries
|
|
96
|
+
: entries.filter((entry) => getTrainingReviewFilter(entry) === filter);
|
|
97
|
+
const query = cwdQuery.trim().toLowerCase();
|
|
98
|
+
if (query === "") return statusMatches;
|
|
99
|
+
|
|
100
|
+
return statusMatches
|
|
101
|
+
.map((entry, index) => ({
|
|
102
|
+
entry,
|
|
103
|
+
index,
|
|
104
|
+
score: fuzzyPathScore(entry.record.cwd, query),
|
|
105
|
+
}))
|
|
106
|
+
.filter(
|
|
107
|
+
(candidate): candidate is {
|
|
108
|
+
entry: TrainingReviewEntry;
|
|
109
|
+
index: number;
|
|
110
|
+
score: number;
|
|
111
|
+
} => candidate.score !== undefined,
|
|
112
|
+
)
|
|
113
|
+
.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
114
|
+
.map((candidate) => candidate.entry);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build an append-only review revision from a human decision.
|
|
119
|
+
*
|
|
120
|
+
* @param record - Training evidence being reviewed
|
|
121
|
+
* @param expectedDecision - Human-selected expected outcome
|
|
122
|
+
* @param note - Optional correction explanation
|
|
123
|
+
* @returns Normalized review input for persistence
|
|
124
|
+
*/
|
|
125
|
+
export function createTrainingReviewInput(
|
|
126
|
+
record: TrainingRecord,
|
|
127
|
+
expectedDecision: Decision,
|
|
128
|
+
note: string | undefined,
|
|
129
|
+
): TrainingReviewInput {
|
|
130
|
+
const corrected = expectedDecision !== record.verdict.decision;
|
|
131
|
+
return {
|
|
132
|
+
recordId: record.id,
|
|
133
|
+
originalDecision: record.verdict.decision,
|
|
134
|
+
expectedDecision,
|
|
135
|
+
note: corrected ? note?.trim() || undefined : undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function fuzzyPathScore(path: string, query: string): number | undefined {
|
|
140
|
+
const candidate = path.toLowerCase();
|
|
141
|
+
const substringIndex = candidate.indexOf(query);
|
|
142
|
+
if (substringIndex >= 0) {
|
|
143
|
+
return 10_000 + segmentStartBonus(candidate, substringIndex) -
|
|
144
|
+
substringIndex;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const positions: Array<number> = [];
|
|
148
|
+
let candidateIndex = 0;
|
|
149
|
+
for (const character of query) {
|
|
150
|
+
const matchIndex = candidate.indexOf(character, candidateIndex);
|
|
151
|
+
if (matchIndex < 0) return undefined;
|
|
152
|
+
positions.push(matchIndex);
|
|
153
|
+
candidateIndex = matchIndex + 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const first = positions[0];
|
|
157
|
+
const last = positions.at(-1);
|
|
158
|
+
if (first === undefined || last === undefined) return undefined;
|
|
159
|
+
|
|
160
|
+
const span = last - first + 1;
|
|
161
|
+
let consecutive = 0;
|
|
162
|
+
let segmentStarts = 0;
|
|
163
|
+
for (const [index, position] of positions.entries()) {
|
|
164
|
+
if (index > 0 && position === positions[index - 1]! + 1) {
|
|
165
|
+
consecutive += 1;
|
|
166
|
+
}
|
|
167
|
+
segmentStarts += segmentStartBonus(candidate, position);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return query.length * 100 - span * 4 - first + consecutive * 12 +
|
|
171
|
+
segmentStarts;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function segmentStartBonus(path: string, index: number): number {
|
|
175
|
+
return index === 0 || path[index - 1] === "/" ? 40 : 0;
|
|
176
|
+
}
|