@spexcode/spec-eval 0.6.5
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/cache.d.ts +10 -0
- package/dist/cache.js +50 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +981 -0
- package/dist/evaltab.d.ts +98 -0
- package/dist/evaltab.js +176 -0
- package/dist/filing.d.ts +16 -0
- package/dist/filing.js +36 -0
- package/dist/freshness.d.ts +48 -0
- package/dist/freshness.js +799 -0
- package/dist/host.d.ts +46 -0
- package/dist/host.js +53 -0
- package/dist/humanok.d.ts +11 -0
- package/dist/humanok.js +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/remarks.d.ts +31 -0
- package/dist/remarks.js +1 -0
- package/dist/scenariofresh.d.ts +13 -0
- package/dist/scenariofresh.js +303 -0
- package/dist/scenarios.d.ts +99 -0
- package/dist/scenarios.js +650 -0
- package/dist/sessioneval.d.ts +307 -0
- package/dist/sessioneval.js +1883 -0
- package/dist/sidecar.d.ts +55 -0
- package/dist/sidecar.js +82 -0
- package/dist/timeline.d.ts +25 -0
- package/dist/timeline.js +65 -0
- package/dist/ui-path.d.ts +1 -0
- package/dist/ui-path.js +2 -0
- package/package.json +36 -0
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RemarkTrack } from './remarks.js';
|
|
2
|
+
export type EvalHost = {
|
|
3
|
+
loadConfig?: (root: string) => any;
|
|
4
|
+
trackedSourceFiles?: (root: string, roots: string[], policy: any) => string[];
|
|
5
|
+
stripRefSigil?: (value: string) => string;
|
|
6
|
+
commitTrunkData?: (path: string, message: string) => 'committed' | 'no-op' | 'not-primary';
|
|
7
|
+
apiBase?: () => Promise<string>;
|
|
8
|
+
};
|
|
9
|
+
export type ReviewIdentity = {
|
|
10
|
+
id: string;
|
|
11
|
+
node: string | null;
|
|
12
|
+
branch: string | null;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
export type ReviewPayload = {
|
|
16
|
+
id: string;
|
|
17
|
+
node: string | null;
|
|
18
|
+
branch: string | null;
|
|
19
|
+
label: string;
|
|
20
|
+
ahead: number;
|
|
21
|
+
dirtyNonRuntime: number;
|
|
22
|
+
diff: import('@spexcode/spec-core').ReviewDiffFile[];
|
|
23
|
+
gates: {
|
|
24
|
+
conflictsWithMain: boolean;
|
|
25
|
+
lint: {
|
|
26
|
+
errorCount: number;
|
|
27
|
+
warningCount: number;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
proposal: {
|
|
31
|
+
kind: string | null;
|
|
32
|
+
note: string | null;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export type EvalHostPort = EvalHost & {
|
|
36
|
+
reviewIdentity: (id: string) => ReviewIdentity | null;
|
|
37
|
+
reviewPayload: (id: string) => Promise<ReviewPayload | null>;
|
|
38
|
+
loadEvalRemarkTracks?: () => Map<string, RemarkTrack>;
|
|
39
|
+
};
|
|
40
|
+
export declare function setEvalRemarkTracks(loader: () => Map<string, RemarkTrack>): void;
|
|
41
|
+
export declare function setEvalHost(next: EvalHost): void;
|
|
42
|
+
export declare function evalHost(): EvalHost;
|
|
43
|
+
export declare function requireEvalHost<K extends keyof EvalHost>(field: K): NonNullable<EvalHost[K]>;
|
|
44
|
+
export declare function evalRemarkTracks(): Map<string, RemarkTrack>;
|
|
45
|
+
export declare function evalRemarkSourceFingerprint(): string;
|
|
46
|
+
export declare const trackKey: (node: string, scenario: string) => string;
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
let services = {};
|
|
4
|
+
let remarks = () => new Map();
|
|
5
|
+
export function setEvalRemarkTracks(loader) {
|
|
6
|
+
remarks = loader;
|
|
7
|
+
}
|
|
8
|
+
export function setEvalHost(next) { services = { ...services, ...next }; }
|
|
9
|
+
export function evalHost() { return services; }
|
|
10
|
+
// CLI-backed commands must fail at the capability boundary, rather than letting an absent
|
|
11
|
+
// service turn into an unrelated TypeError or an empty result. Remark reads are the one
|
|
12
|
+
// deliberate standalone downgrade and stay on evalRemarkTracks() below.
|
|
13
|
+
export function requireEvalHost(field) {
|
|
14
|
+
const value = services[field];
|
|
15
|
+
if (value == null)
|
|
16
|
+
throw new Error(`spec-eval host is not configured: ${String(field)}`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function evalRemarkTracks() {
|
|
20
|
+
return remarks();
|
|
21
|
+
}
|
|
22
|
+
// The eval engine fingerprints the remark input even when no CLI host is installed. It deliberately hashes
|
|
23
|
+
// bytes, not issue semantics; the CLI host remains the sole owner of parsing and joining remark tracks.
|
|
24
|
+
export function evalRemarkSourceFingerprint() {
|
|
25
|
+
const root = process.env.SPEXCODE_ISSUES_DIR;
|
|
26
|
+
if (!root)
|
|
27
|
+
return '';
|
|
28
|
+
const files = [];
|
|
29
|
+
const walk = (dir) => {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const name of entries.sort()) {
|
|
38
|
+
const path = join(dir, name);
|
|
39
|
+
try {
|
|
40
|
+
if (statSync(path).isDirectory())
|
|
41
|
+
walk(path);
|
|
42
|
+
else
|
|
43
|
+
files.push(`${path}\0${readFileSync(path).toString('base64')}`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
files.push(`${path}\0<unreadable>`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(root);
|
|
51
|
+
return files.join('\0');
|
|
52
|
+
}
|
|
53
|
+
export const trackKey = (node, scenario) => `${node} · ${scenario}`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type HumanOk } from './sidecar.js';
|
|
2
|
+
export type OkResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
humanOk: HumanOk;
|
|
5
|
+
already: boolean;
|
|
6
|
+
landed: 'committed' | 'uncommitted';
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function fileHumanOk(nodeId: string, scenario: string, by: string): OkResult;
|
package/dist/humanok.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { repoRoot } from '@spexcode/spec-core';
|
|
3
|
+
import { requireEvalHost } from './host.js';
|
|
4
|
+
import { evalNodes, resolveEvalNode } from './scenarios.js';
|
|
5
|
+
import { readReadings, readSidecar, appendHumanOk, humanOkFor } from './sidecar.js';
|
|
6
|
+
export function fileHumanOk(nodeId, scenario, by) {
|
|
7
|
+
const root = repoRoot();
|
|
8
|
+
const res = resolveEvalNode(evalNodes(root), nodeId);
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
return { ok: false, error: res.error };
|
|
11
|
+
const node = res.node;
|
|
12
|
+
if (!node.scenarios.some((s) => s.name === scenario) &&
|
|
13
|
+
!readSidecar(node.sidecarPath).readings.some((r) => r.scenario === scenario))
|
|
14
|
+
return { ok: false, error: `'${node.id}' has no scenario '${scenario}'` };
|
|
15
|
+
const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario);
|
|
16
|
+
if (!forScenario.length)
|
|
17
|
+
return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective eval — nothing to ok` };
|
|
18
|
+
const latest = forScenario[forScenario.length - 1];
|
|
19
|
+
const existing = humanOkFor(readSidecar(node.sidecarPath).oks, scenario, latest.ts);
|
|
20
|
+
if (existing)
|
|
21
|
+
return { ok: true, humanOk: existing, already: true, landed: 'committed' };
|
|
22
|
+
const row = { kind: 'human-ok', scenario, okTs: latest.ts, okSha: latest.codeSha, by, ts: new Date().toISOString() };
|
|
23
|
+
appendHumanOk(node.sidecarPath, row);
|
|
24
|
+
const commit = requireEvalHost('commitTrunkData');
|
|
25
|
+
const landed = commit(relative(root, node.sidecarPath), `eval(${node.id}): human-ok '${scenario}' @ ${latest.ts} by ${by}`);
|
|
26
|
+
return { ok: true, humanOk: row, already: false, landed: landed === 'not-primary' ? 'uncommitted' : 'committed' };
|
|
27
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Reply = {
|
|
2
|
+
by: string;
|
|
3
|
+
at: string;
|
|
4
|
+
body: string;
|
|
5
|
+
rid?: string;
|
|
6
|
+
targetCodeSha?: string;
|
|
7
|
+
resolved?: boolean;
|
|
8
|
+
resolvedAt?: string;
|
|
9
|
+
resolvedBy?: string;
|
|
10
|
+
};
|
|
11
|
+
export type Issue = {
|
|
12
|
+
id: string;
|
|
13
|
+
store: string;
|
|
14
|
+
concern: string;
|
|
15
|
+
by: string;
|
|
16
|
+
status: string;
|
|
17
|
+
nodes: string[];
|
|
18
|
+
created: string;
|
|
19
|
+
body: string;
|
|
20
|
+
replies: Reply[];
|
|
21
|
+
evidence: string[];
|
|
22
|
+
labels: unknown[];
|
|
23
|
+
url?: string;
|
|
24
|
+
};
|
|
25
|
+
export type RemarkTrack = {
|
|
26
|
+
threadId: string;
|
|
27
|
+
node: string;
|
|
28
|
+
scenario: string;
|
|
29
|
+
thread: Issue;
|
|
30
|
+
remarks: Reply[];
|
|
31
|
+
};
|
package/dist/remarks.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ScenarioIndex = Map<string, Map<string, string[]>>;
|
|
2
|
+
export declare function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex>;
|
|
3
|
+
export declare function scenarioCacheStats(): {
|
|
4
|
+
heads: number;
|
|
5
|
+
roots: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scenario: string): string[];
|
|
8
|
+
export declare function primeScenarioBlocks(root: string, demands: readonly {
|
|
9
|
+
rev: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}[]): Promise<void>;
|
|
12
|
+
export declare function primeScenarioBlocksAt(root: string, revs: string[], path: string): Promise<void>;
|
|
13
|
+
export declare function scenarioBlocksAt(root: string, rev: string, path: string): Map<string, string> | null;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { batchBlobTexts, batchRevisionOids, git, gitA, gitTry, headSha } from '@spexcode/spec-core';
|
|
3
|
+
import { parseScenarios } from './scenarios.js';
|
|
4
|
+
import { rootSlots, touchRoot as touchRootLru } from '@spexcode/spec-core';
|
|
5
|
+
const RS = '\x1e';
|
|
6
|
+
function blockContent(src) {
|
|
7
|
+
const m = new Map();
|
|
8
|
+
for (const s of parseScenarios(src)) {
|
|
9
|
+
m.set(s.name, JSON.stringify({ d: s.description, e: s.expected }));
|
|
10
|
+
}
|
|
11
|
+
return m;
|
|
12
|
+
}
|
|
13
|
+
const ZERO = '0'.repeat(40);
|
|
14
|
+
const EMPTY = new Map();
|
|
15
|
+
const blockByOid = new Map();
|
|
16
|
+
async function fileChains(root, wanted) {
|
|
17
|
+
const chains = new Map();
|
|
18
|
+
const alias = new Map();
|
|
19
|
+
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
|
|
20
|
+
'--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*eval.md', '*yatsu.md']); // dead-words-ok: archived pathspec — immutable pre-rename history is read under its archived name
|
|
21
|
+
for (const rec of out.split(RS)) {
|
|
22
|
+
const nl = rec.indexOf('\n');
|
|
23
|
+
if (nl < 0)
|
|
24
|
+
continue;
|
|
25
|
+
const hash = rec.slice(0, nl);
|
|
26
|
+
if (!hash)
|
|
27
|
+
continue;
|
|
28
|
+
for (const line of rec.slice(nl + 1).split('\n')) {
|
|
29
|
+
if (line[0] !== ':')
|
|
30
|
+
continue; // `:<oldmode> <newmode> <oldoid> <newoid> <status>\t<path>[\t<path2>]`
|
|
31
|
+
const tab = line.indexOf('\t');
|
|
32
|
+
if (tab < 0)
|
|
33
|
+
continue;
|
|
34
|
+
const meta = line.slice(1, tab).split(' ');
|
|
35
|
+
const oid = meta[3], rename = meta[4][0] === 'R' || meta[4][0] === 'C';
|
|
36
|
+
const paths = line.slice(tab + 1).split('\t');
|
|
37
|
+
const to = rename ? paths[1] : paths[0];
|
|
38
|
+
let head = alias.get(to);
|
|
39
|
+
if (head === undefined) {
|
|
40
|
+
head = to;
|
|
41
|
+
alias.set(to, to);
|
|
42
|
+
}
|
|
43
|
+
let arr = chains.get(head);
|
|
44
|
+
if (!arr) {
|
|
45
|
+
arr = [];
|
|
46
|
+
chains.set(head, arr);
|
|
47
|
+
}
|
|
48
|
+
arr.push({ hash, oid });
|
|
49
|
+
if (rename && paths[0] !== to) {
|
|
50
|
+
alias.set(paths[0], head);
|
|
51
|
+
alias.delete(to);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const k of [...chains.keys()])
|
|
56
|
+
if (!wanted.has(k))
|
|
57
|
+
chains.delete(k);
|
|
58
|
+
return chains;
|
|
59
|
+
}
|
|
60
|
+
function scenarioCommits(chain) {
|
|
61
|
+
const commits = new Map();
|
|
62
|
+
const push = (name, hash) => { const a = commits.get(name); if (a)
|
|
63
|
+
a.push(hash);
|
|
64
|
+
else
|
|
65
|
+
commits.set(name, [hash]); };
|
|
66
|
+
const real = chain.filter((v) => v.oid !== ZERO);
|
|
67
|
+
for (let i = 0; i < real.length; i++) {
|
|
68
|
+
const cur = blockByOid.get(real[i].oid) ?? EMPTY;
|
|
69
|
+
const older = i + 1 < real.length ? (blockByOid.get(real[i + 1].oid) ?? EMPTY) : EMPTY;
|
|
70
|
+
for (const name of new Set([...cur.keys(), ...older.keys()])) {
|
|
71
|
+
if (cur.get(name) !== older.get(name))
|
|
72
|
+
push(name, real[i].hash);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return commits;
|
|
76
|
+
}
|
|
77
|
+
// read MANY blobs in ONE `git cat-file --batch` process (vs one `git show` per blob). Feeds the OIDs on
|
|
78
|
+
// stdin, parses the `<oid> <type> <size>\n<payload>\n` records byte-accurately (size is bytes; blobs are
|
|
79
|
+
// UTF-8). A `<oid> missing` line yields no entry. Env-stripped like git.ts's helpers (a stray GIT_DIR would
|
|
80
|
+
// misroute repo discovery); kept here beside its only caller — a general git-seam primitive if a second
|
|
81
|
+
// caller ever wants one.
|
|
82
|
+
function catFileBatch(root, oids) {
|
|
83
|
+
const out = new Map();
|
|
84
|
+
if (!oids.length)
|
|
85
|
+
return Promise.resolve(out);
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const env = { ...process.env };
|
|
88
|
+
delete env.GIT_DIR;
|
|
89
|
+
delete env.GIT_WORK_TREE;
|
|
90
|
+
delete env.GIT_INDEX_FILE;
|
|
91
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
92
|
+
const p = spawn('git', ['-C', root, 'cat-file', '--batch'], { env, timeout: Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000), killSignal: 'SIGKILL' });
|
|
93
|
+
const chunks = [];
|
|
94
|
+
p.stdout.on('data', (c) => chunks.push(c));
|
|
95
|
+
p.on('error', reject);
|
|
96
|
+
p.on('close', (_code, signal) => {
|
|
97
|
+
// a child that never exited was SIGKILLed at the timeout (a hung git must not pin this promise —
|
|
98
|
+
// same bound as git.ts's helpers); warn loudly and parse whatever arrived.
|
|
99
|
+
if (signal === 'SIGKILL')
|
|
100
|
+
console.warn(`spec-eval: git cat-file --batch killed after timeout — child never exited`);
|
|
101
|
+
const buf = Buffer.concat(chunks);
|
|
102
|
+
let i = 0;
|
|
103
|
+
while (i < buf.length) {
|
|
104
|
+
const nl = buf.indexOf(0x0a, i);
|
|
105
|
+
if (nl < 0)
|
|
106
|
+
break;
|
|
107
|
+
const header = buf.toString('utf8', i, nl);
|
|
108
|
+
i = nl + 1;
|
|
109
|
+
if (header.endsWith(' missing'))
|
|
110
|
+
continue; // unknown OID — no payload follows
|
|
111
|
+
const size = Number(header.slice(header.lastIndexOf(' ') + 1));
|
|
112
|
+
if (!Number.isFinite(size))
|
|
113
|
+
break;
|
|
114
|
+
out.set(header.slice(0, header.indexOf(' ')), buf.toString('utf8', i, i + size));
|
|
115
|
+
i += size + 1; // payload + its trailing newline
|
|
116
|
+
}
|
|
117
|
+
resolve(out);
|
|
118
|
+
});
|
|
119
|
+
p.stdin.on('error', () => { });
|
|
120
|
+
p.stdin.write(oids.join('\n') + '\n');
|
|
121
|
+
p.stdin.end();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function build(root, evalPaths) {
|
|
125
|
+
const chains = await fileChains(root, new Set(evalPaths));
|
|
126
|
+
const need = new Set();
|
|
127
|
+
for (const chain of chains.values())
|
|
128
|
+
for (const v of chain)
|
|
129
|
+
if (v.oid !== ZERO && !blockByOid.has(v.oid))
|
|
130
|
+
need.add(v.oid);
|
|
131
|
+
if (need.size) {
|
|
132
|
+
const blobs = await catFileBatch(root, [...need]);
|
|
133
|
+
for (const [oid, src] of blobs)
|
|
134
|
+
blockByOid.set(oid, blockContent(src));
|
|
135
|
+
}
|
|
136
|
+
const idx = new Map();
|
|
137
|
+
for (const p of evalPaths)
|
|
138
|
+
idx.set(p, scenarioCommits(chains.get(p) ?? []));
|
|
139
|
+
return idx;
|
|
140
|
+
}
|
|
141
|
+
const SLOTS = rootSlots(process.env.SPEXCODE_SCENARIO_CACHE_ROOTS, 16);
|
|
142
|
+
const cache = new Map();
|
|
143
|
+
const roots = new Map();
|
|
144
|
+
function touchRoot(root, head) {
|
|
145
|
+
touchRootLru(roots, cache, root, head, SLOTS);
|
|
146
|
+
}
|
|
147
|
+
export function scenarioIndex(root, evalPaths) {
|
|
148
|
+
let head;
|
|
149
|
+
try {
|
|
150
|
+
head = headSha(root);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return build(root, evalPaths);
|
|
154
|
+
}
|
|
155
|
+
touchRoot(root, head);
|
|
156
|
+
const hit = cache.get(head);
|
|
157
|
+
if (hit) {
|
|
158
|
+
cache.delete(head);
|
|
159
|
+
cache.set(head, hit);
|
|
160
|
+
return hit;
|
|
161
|
+
}
|
|
162
|
+
const p = build(root, evalPaths);
|
|
163
|
+
p.catch(() => cache.delete(head));
|
|
164
|
+
cache.set(head, p);
|
|
165
|
+
while (cache.size > SLOTS)
|
|
166
|
+
cache.delete(cache.keys().next().value);
|
|
167
|
+
return p;
|
|
168
|
+
}
|
|
169
|
+
export function scenarioCacheStats() {
|
|
170
|
+
return { heads: cache.size, roots: roots.size };
|
|
171
|
+
}
|
|
172
|
+
export function scenarioChangeCommits(idx, evalPath, scenario) {
|
|
173
|
+
return idx.get(evalPath)?.get(scenario) ?? [];
|
|
174
|
+
}
|
|
175
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
176
|
+
const oidMemo = new Map();
|
|
177
|
+
function oidAt(root, rev, path) {
|
|
178
|
+
const resolve = () => { try {
|
|
179
|
+
return git(['-C', root, 'rev-parse', `${rev}:${path}`]).trim();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return '';
|
|
183
|
+
} };
|
|
184
|
+
if (!FULL_SHA.test(rev))
|
|
185
|
+
return resolve();
|
|
186
|
+
const k = `${root}\x1f${rev}\x1f${path}`;
|
|
187
|
+
const hit = oidMemo.get(k);
|
|
188
|
+
if (hit !== undefined) {
|
|
189
|
+
oidMemo.delete(k);
|
|
190
|
+
oidMemo.set(k, hit);
|
|
191
|
+
return hit;
|
|
192
|
+
}
|
|
193
|
+
const v = resolve();
|
|
194
|
+
oidMemo.set(k, v);
|
|
195
|
+
if (oidMemo.size > 4096)
|
|
196
|
+
oidMemo.delete(oidMemo.keys().next().value);
|
|
197
|
+
return v;
|
|
198
|
+
}
|
|
199
|
+
// @@@ the memo holds SETTLED oids, so concurrent askers need a flight to join - primes run concurrently
|
|
200
|
+
// across the whole scope, and every one of them missed the memo while the first child was still running:
|
|
201
|
+
// measured on adopter-a's 415-node scope, 2695 `rev-parse` children resolved 1212 distinct (rev, path) pairs.
|
|
202
|
+
// `rev:path` under a full sha names an immutable object, so joining answers the same question; the LRU
|
|
203
|
+
// write still happens once, on settle, exactly as the sync path does it.
|
|
204
|
+
const oidFlight = new Map();
|
|
205
|
+
async function oidAtAsync(root, rev, path) {
|
|
206
|
+
if (!FULL_SHA.test(rev))
|
|
207
|
+
return (await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])).stdout.trim();
|
|
208
|
+
const k = `${root}\x1f${rev}\x1f${path}`;
|
|
209
|
+
const hit = oidMemo.get(k);
|
|
210
|
+
if (hit !== undefined) {
|
|
211
|
+
oidMemo.delete(k);
|
|
212
|
+
oidMemo.set(k, hit);
|
|
213
|
+
return hit;
|
|
214
|
+
}
|
|
215
|
+
const joined = oidFlight.get(k);
|
|
216
|
+
if (joined)
|
|
217
|
+
return joined;
|
|
218
|
+
const run = async () => {
|
|
219
|
+
const result = await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`]);
|
|
220
|
+
const oid = result.ok ? result.stdout.trim() : '';
|
|
221
|
+
oidMemo.set(k, oid);
|
|
222
|
+
if (oidMemo.size > 4096)
|
|
223
|
+
oidMemo.delete(oidMemo.keys().next().value);
|
|
224
|
+
return oid;
|
|
225
|
+
};
|
|
226
|
+
const flight = run().finally(() => { if (oidFlight.get(k) === flight)
|
|
227
|
+
oidFlight.delete(k); });
|
|
228
|
+
oidFlight.set(k, flight);
|
|
229
|
+
return flight;
|
|
230
|
+
}
|
|
231
|
+
// @@@ the PLURAL prime, mirroring the anchor probe's - a caller hands over every (rev, path) its whole read
|
|
232
|
+
// will ask about and TWO children answer them all: `cat-file --batch-check` for the object ids, then
|
|
233
|
+
// `cat-file --batch` for the blobs. The singular form below is one demand's worth of the same thing, kept
|
|
234
|
+
// for callers that genuinely learn their demand one at a time. Priming one at a time is what this replaces:
|
|
235
|
+
// on adopter-a's 415-node scope it forked 1212 `rev-parse` children plus a `cat-file` each, and the batch
|
|
236
|
+
// answers the identical set — same oids, same blocks, same verdicts, two children.
|
|
237
|
+
export async function primeScenarioBlocks(root, demands) {
|
|
238
|
+
const wanted = new Map();
|
|
239
|
+
for (const d of demands) {
|
|
240
|
+
if (!FULL_SHA.test(d.rev) || !d.path)
|
|
241
|
+
continue; // a symbolic rev is never memoized, so it can't ride a batch
|
|
242
|
+
const k = `${root}\x1f${d.rev}\x1f${d.path}`;
|
|
243
|
+
if (!oidMemo.has(k) && !wanted.has(k))
|
|
244
|
+
wanted.set(k, d);
|
|
245
|
+
}
|
|
246
|
+
if (!wanted.size)
|
|
247
|
+
return;
|
|
248
|
+
const keys = [...wanted.keys()];
|
|
249
|
+
const rows = [...wanted.values()];
|
|
250
|
+
const oids = await batchRevisionOids(root, rows.map((d) => `${d.rev}:${d.path}`));
|
|
251
|
+
oids.forEach((oid, i) => {
|
|
252
|
+
oidMemo.set(keys[i], oid ?? '');
|
|
253
|
+
if (oidMemo.size > 4096)
|
|
254
|
+
oidMemo.delete(oidMemo.keys().next().value);
|
|
255
|
+
});
|
|
256
|
+
const missing = [...new Set(oids.filter((o) => !!o && !blockByOid.has(o)))];
|
|
257
|
+
if (!missing.length)
|
|
258
|
+
return;
|
|
259
|
+
const texts = await batchBlobTexts(root, missing);
|
|
260
|
+
for (const [oid, src] of texts)
|
|
261
|
+
if (src)
|
|
262
|
+
blockByOid.set(oid, blockContent(src));
|
|
263
|
+
}
|
|
264
|
+
// the blob read needs the same flight as the oid lookup above it: an oid is content, so two primes that
|
|
265
|
+
// resolved the same one must not each read it.
|
|
266
|
+
const blockFlight = new Map();
|
|
267
|
+
export async function primeScenarioBlocksAt(root, revs, path) {
|
|
268
|
+
for (const rev of revs) {
|
|
269
|
+
const oid = await oidAtAsync(root, rev, path);
|
|
270
|
+
if (!oid || blockByOid.has(oid))
|
|
271
|
+
continue;
|
|
272
|
+
const joined = blockFlight.get(oid);
|
|
273
|
+
if (joined) {
|
|
274
|
+
await joined;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const run = async () => {
|
|
278
|
+
const src = await gitA(['-C', root, 'cat-file', 'blob', oid]); // dead-words-ok: git plumbing
|
|
279
|
+
if (src)
|
|
280
|
+
blockByOid.set(oid, blockContent(src));
|
|
281
|
+
};
|
|
282
|
+
const flight = run().finally(() => { if (blockFlight.get(oid) === flight)
|
|
283
|
+
blockFlight.delete(oid); });
|
|
284
|
+
blockFlight.set(oid, flight);
|
|
285
|
+
await flight;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export function scenarioBlocksAt(root, rev, path) {
|
|
289
|
+
const oid = oidAt(root, rev, path);
|
|
290
|
+
if (!oid)
|
|
291
|
+
return null;
|
|
292
|
+
const hit = blockByOid.get(oid);
|
|
293
|
+
if (hit)
|
|
294
|
+
return hit;
|
|
295
|
+
try {
|
|
296
|
+
const m = blockContent(git(['-C', root, 'cat-file', 'blob', oid])); // dead-words-ok: git plumbing — 'blob' is git's object type, not our vocabulary
|
|
297
|
+
blockByOid.set(oid, m);
|
|
298
|
+
return m;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type RelationEntry } from '@spexcode/spec-core';
|
|
2
|
+
export declare const EVAL_FILE = "eval.md";
|
|
3
|
+
export declare const SIDECAR_FILE = "evals.ndjson";
|
|
4
|
+
export type ScenarioTestReference = {
|
|
5
|
+
path: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
};
|
|
8
|
+
export type Scenario = {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
expected: string;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
test?: ScenarioTestReference;
|
|
14
|
+
code?: string[];
|
|
15
|
+
related?: string[];
|
|
16
|
+
};
|
|
17
|
+
export declare const SCENARIO_PROJECTION = "spex.eval.scenario-index";
|
|
18
|
+
export declare const SCENARIO_SCHEMA_VERSION = 1;
|
|
19
|
+
export type ScenarioSemanticRow = {
|
|
20
|
+
node: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
expected: string;
|
|
24
|
+
scenarioHash: string;
|
|
25
|
+
code: RelationEntry[];
|
|
26
|
+
related: RelationEntry[];
|
|
27
|
+
tags: string[];
|
|
28
|
+
};
|
|
29
|
+
export type ScenarioMeasurementRow = {
|
|
30
|
+
test: ScenarioTestReference | null;
|
|
31
|
+
};
|
|
32
|
+
export type ScenarioProjectionRow = {
|
|
33
|
+
semantic: ScenarioSemanticRow;
|
|
34
|
+
measurement: ScenarioMeasurementRow;
|
|
35
|
+
};
|
|
36
|
+
export type ScenarioProjectionNode = {
|
|
37
|
+
id: string;
|
|
38
|
+
code: RelationEntry[];
|
|
39
|
+
related: RelationEntry[];
|
|
40
|
+
};
|
|
41
|
+
export type ScenarioProjectionProvenance = {
|
|
42
|
+
head: string | null;
|
|
43
|
+
treeSha: string | null;
|
|
44
|
+
};
|
|
45
|
+
export type ScenarioProjection = {
|
|
46
|
+
projection: typeof SCENARIO_PROJECTION;
|
|
47
|
+
schemaVersion: typeof SCENARIO_SCHEMA_VERSION;
|
|
48
|
+
provenance: ScenarioProjectionProvenance;
|
|
49
|
+
nodes: ScenarioProjectionNode[];
|
|
50
|
+
semanticIndexHash: string;
|
|
51
|
+
fullIndexHash: string;
|
|
52
|
+
planningIndexHash: string;
|
|
53
|
+
rows: ScenarioProjectionRow[];
|
|
54
|
+
};
|
|
55
|
+
export type EvalNode = {
|
|
56
|
+
id: string;
|
|
57
|
+
dir: string;
|
|
58
|
+
evalPath: string;
|
|
59
|
+
sidecarPath: string;
|
|
60
|
+
scenarios: Scenario[];
|
|
61
|
+
evalSource?: string;
|
|
62
|
+
specSource?: string;
|
|
63
|
+
};
|
|
64
|
+
export declare function scenarioHash(s: Pick<Scenario, 'description' | 'expected'>): string;
|
|
65
|
+
export type ScenarioCodeAxis = {
|
|
66
|
+
entries: RelationEntry[];
|
|
67
|
+
paths: string[];
|
|
68
|
+
problems: string[];
|
|
69
|
+
};
|
|
70
|
+
export type ScenarioCodeAxisSource = readonly string[] | readonly RelationEntry[];
|
|
71
|
+
export declare function scenarioCodeAxis(scenarioCode: readonly string[] | undefined, nodeCode?: ScenarioCodeAxisSource): ScenarioCodeAxis;
|
|
72
|
+
export declare function parseScenarios(src: string): Scenario[];
|
|
73
|
+
export declare function scenarioProjection(nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource' | 'specSource'>[], provenance?: Partial<ScenarioProjectionProvenance>): ScenarioProjection;
|
|
74
|
+
export declare function validateScenarios(src: string, tagLibrary?: string[], pathRoot?: string): string[];
|
|
75
|
+
export type ScenarioMeasurementMetadataMutation = {
|
|
76
|
+
scenario: string;
|
|
77
|
+
insert: {
|
|
78
|
+
test: string | {
|
|
79
|
+
path: string;
|
|
80
|
+
name: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
} | {
|
|
84
|
+
scenario: string;
|
|
85
|
+
delete: 'test';
|
|
86
|
+
};
|
|
87
|
+
export declare function writeScenarioMeasurementMetadata(source: string, request: unknown): string;
|
|
88
|
+
export declare function evalNodes(root: string): EvalNode[];
|
|
89
|
+
export declare function evalNodesAt(root: string, tip: string): EvalNode[];
|
|
90
|
+
export declare function evalNodesAsync(root: string): Promise<EvalNode[]>;
|
|
91
|
+
export type EvalResolution<T> = {
|
|
92
|
+
ok: true;
|
|
93
|
+
node: T;
|
|
94
|
+
} | {
|
|
95
|
+
ok: false;
|
|
96
|
+
ambiguous: boolean;
|
|
97
|
+
error: string;
|
|
98
|
+
};
|
|
99
|
+
export declare function resolveEvalNode<T extends Pick<EvalNode, 'id' | 'dir'>>(nodes: T[], ref: string): EvalResolution<T>;
|