simt-dev 0.3.1 → 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/CHANGELOG.md +157 -0
- package/README.md +271 -62
- package/package.json +6 -2
- package/rules/core.md +1 -1
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +28 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +88 -54
- package/src/bench/tasks.mjs +38 -0
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +368 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +200 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +72 -15
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/distill.mjs +36 -0
- package/src/commands/help.mjs +62 -3
- package/src/commands/index.mjs +29 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +42 -7
- package/src/commands/repoindex.mjs +4 -1
- package/src/commands/review.mjs +65 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/skills.mjs +5 -50
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +15 -0
- package/src/commands/tests.mjs +2 -49
- package/src/commands/why.mjs +23 -25
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/distill/distill.mjs +246 -0
- package/src/distill/engine.mjs +187 -0
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/index/gate.mjs +1 -13
- package/src/index/govern.mjs +23 -11
- package/src/index/map.mjs +251 -17
- package/src/index/persist.mjs +74 -7
- package/src/index/store.mjs +222 -8
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +36 -1
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +269 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/lock.mjs +39 -7
- package/src/projection/rules.mjs +32 -8
- package/src/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- package/src/toolwords.mjs +6 -0
package/src/index/store.mjs
CHANGED
|
@@ -9,12 +9,16 @@ import { stat } from 'node:fs/promises';
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { readManifest } from '../projection/manifest.mjs';
|
|
12
|
+
import { readLocalConfig } from '../local/config.mjs';
|
|
13
|
+
import { localEmbed } from '../local/client.mjs';
|
|
12
14
|
import { extract, resolveImports } from './extract.mjs';
|
|
13
15
|
import {
|
|
14
16
|
JSONL_FILE,
|
|
15
17
|
SCHEMA_V,
|
|
16
18
|
SQLITE_FILE,
|
|
17
19
|
countSqlite,
|
|
20
|
+
decodeVectorBlob,
|
|
21
|
+
encodeVectorBlob,
|
|
18
22
|
readJsonl,
|
|
19
23
|
readSchema,
|
|
20
24
|
readSqliteRows,
|
|
@@ -22,7 +26,7 @@ import {
|
|
|
22
26
|
writeSqlite,
|
|
23
27
|
} from './persist.mjs';
|
|
24
28
|
|
|
25
|
-
export { JSONL_FILE, SQLITE_FILE };
|
|
29
|
+
export { JSONL_FILE, SQLITE_FILE, decodeVectorBlob, encodeVectorBlob };
|
|
26
30
|
|
|
27
31
|
const MARKERS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'];
|
|
28
32
|
|
|
@@ -45,11 +49,64 @@ async function probeBackend() {
|
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
export function ftsKindOf(filePath, lang = '') {
|
|
53
|
+
if (filePath.startsWith('docs/decisions/')) return 'decisions';
|
|
54
|
+
if (filePath.startsWith('docs/lessons/')) return 'lessons';
|
|
55
|
+
if (filePath.includes('skills') && (filePath.endsWith('.md') || filePath.endsWith('.json'))) return 'skills';
|
|
56
|
+
if (lang) return 'code';
|
|
57
|
+
return 'docs';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildFtsBody(filePath, symbolsForPath = [], text = null) {
|
|
61
|
+
const tokens = [];
|
|
62
|
+
for (const part of String(filePath ?? '').toLowerCase().split(/[^a-z0-9]+/)) {
|
|
63
|
+
if (part.length >= 2) tokens.push(part);
|
|
64
|
+
}
|
|
65
|
+
for (const s of symbolsForPath) {
|
|
66
|
+
if (s?.name) tokens.push(s.name, s.name);
|
|
67
|
+
if (s?.sig) tokens.push(s.sig);
|
|
68
|
+
}
|
|
69
|
+
if (typeof text === 'string' && text.length > 0) {
|
|
70
|
+
const head = text.slice(0, 24000);
|
|
71
|
+
const seen = new Map();
|
|
72
|
+
for (const m of head.matchAll(/[A-Za-z][A-Za-z0-9_-]{2,}/g)) {
|
|
73
|
+
const w = m[0].toLowerCase();
|
|
74
|
+
const c = (seen.get(w) ?? 0) + 1;
|
|
75
|
+
if (c <= 6) {
|
|
76
|
+
seen.set(w, c);
|
|
77
|
+
tokens.push(w);
|
|
78
|
+
}
|
|
79
|
+
if (seen.size >= 600) break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return tokens.join(' ');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const FTS_STOP = new Set([
|
|
86
|
+
'this', 'that', 'with', 'from', 'have', 'been', 'into', 'only', 'when', 'what',
|
|
87
|
+
'which', 'their', 'about', 'after', 'before', 'where', 'there', 'these', 'those',
|
|
88
|
+
'would', 'could', 'should', 'every', 'other', 'than', 'then', 'them', 'they',
|
|
89
|
+
'your', 'will', 'does', 'file', 'files', 'test', 'tests', 'line', 'lines', 'code',
|
|
90
|
+
'also', 'just', 'more', 'some', 'such', 'each', 'same', 'over', 'used', 'using',
|
|
91
|
+
'the', 'and', 'for', 'not', 'are', 'was', 'were', 'has', 'had', 'but', 'can', 'one', 'two',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
export function ftsQueryTerms(task) {
|
|
95
|
+
return [
|
|
96
|
+
...new Set(
|
|
97
|
+
String(task ?? '')
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.split(/[^a-z0-9]+/)
|
|
100
|
+
.filter((w) => w.length >= 3 && !FTS_STOP.has(w)),
|
|
101
|
+
),
|
|
102
|
+
].slice(0, 48);
|
|
103
|
+
}
|
|
104
|
+
|
|
48
105
|
/**
|
|
49
106
|
* Rebuild the index from the git index. Unchanged blobs are left as they are.
|
|
50
107
|
*
|
|
51
108
|
* @param {string} root
|
|
52
|
-
* @param {{backend?: 'sqlite'|'jsonl'}} [opts]
|
|
109
|
+
* @param {{backend?: 'sqlite'|'jsonl', embed?: boolean, allowRemote?: boolean, fetchImpl?: typeof fetch}} [opts]
|
|
53
110
|
*/
|
|
54
111
|
export async function reindex(root, opts = {}) {
|
|
55
112
|
const started = Date.now();
|
|
@@ -91,16 +148,25 @@ export async function reindex(root, opts = {}) {
|
|
|
91
148
|
: await runExtract(entry.path, text, lang, adapter.module);
|
|
92
149
|
symbols.push(...pulled.symbols);
|
|
93
150
|
edges.push(...(pulled.edges ?? resolveImports(entry.path, pulled.imports, paths, lang)));
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
151
|
+
const body = buildFtsBody(entry.path, pulled.symbols, text);
|
|
152
|
+
if (body.length > 0) {
|
|
153
|
+
fts.push({ kind: ftsKindOf(entry.path, lang), ref: entry.path, body });
|
|
154
|
+
}
|
|
96
155
|
}
|
|
97
156
|
const removed = previous.filter((row) => !paths.has(row.path)).map((row) => row.path);
|
|
157
|
+
const embedBuild = await buildEmbeddings(root, listed, fts, {
|
|
158
|
+
backend,
|
|
159
|
+
force,
|
|
160
|
+
embed: opts.embed === true,
|
|
161
|
+
allowRemote: opts.allowRemote === true,
|
|
162
|
+
fetchImpl: opts.fetchImpl,
|
|
163
|
+
});
|
|
98
164
|
let stored;
|
|
99
165
|
if (backend === 'sqlite') {
|
|
100
|
-
await writeSqlite(root, next, removed, symbols, edges, fts);
|
|
166
|
+
await writeSqlite(root, next, removed, symbols, edges, fts, embedBuild.rows);
|
|
101
167
|
stored = await countSqlite(root);
|
|
102
168
|
} else {
|
|
103
|
-
stored = await writeJsonl(root, previous, next, removed, symbols, edges, fts, force);
|
|
169
|
+
stored = await writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embedBuild.rows);
|
|
104
170
|
}
|
|
105
171
|
return {
|
|
106
172
|
backend,
|
|
@@ -109,11 +175,67 @@ export async function reindex(root, opts = {}) {
|
|
|
109
175
|
removed: removed.length,
|
|
110
176
|
symbols: stored.symbols,
|
|
111
177
|
edges: stored.edges,
|
|
178
|
+
embeddings: stored.embeddings ?? 0,
|
|
179
|
+
embedState: embedBuild.state,
|
|
112
180
|
parsers: adapter.state,
|
|
113
181
|
ms: Date.now() - started,
|
|
114
182
|
};
|
|
115
183
|
}
|
|
116
184
|
|
|
185
|
+
async function buildEmbeddings(root, listed, changedFts, { backend, force, embed, allowRemote, fetchImpl }) {
|
|
186
|
+
if (!embed) return { state: 'off', rows: [] };
|
|
187
|
+
const localCfg = await readLocalConfig(root, { allowRemote });
|
|
188
|
+
if (!localCfg.embed) return { state: 'off', rows: [] };
|
|
189
|
+
if (localCfg.embed.blocked) return { state: 'refused-remote', rows: [] };
|
|
190
|
+
|
|
191
|
+
const existingEmbeds = force ? [] : await readEmbeddings(root, { backend });
|
|
192
|
+
const embedByPath = new Map(existingEmbeds.map((row) => [row.path, row]));
|
|
193
|
+
const ftsByPath = new Map(changedFts.map((row) => [row.ref, row.body]));
|
|
194
|
+
const missingEntries = [];
|
|
195
|
+
for (const entry of listed) {
|
|
196
|
+
const prev = embedByPath.get(entry.path);
|
|
197
|
+
if (prev && prev.blob === entry.blob && prev.model === localCfg.embed.model && prev.vec.length > 0) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
missingEntries.push(entry);
|
|
201
|
+
}
|
|
202
|
+
if (missingEntries.length === 0) return { state: 'cached', rows: [] };
|
|
203
|
+
|
|
204
|
+
// Ensure FTS summaries are available for any entry that did not change in this pass.
|
|
205
|
+
const needFts = missingEntries.some((entry) => !ftsByPath.has(entry.path));
|
|
206
|
+
if (needFts) {
|
|
207
|
+
const storedFts = await readFts(root, { backend });
|
|
208
|
+
for (const row of storedFts) {
|
|
209
|
+
if (!ftsByPath.has(row.ref)) ftsByPath.set(row.ref, row.body);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const rows = [];
|
|
214
|
+
const batchSize = 32;
|
|
215
|
+
for (let i = 0; i < missingEntries.length; i += batchSize) {
|
|
216
|
+
const slice = missingEntries.slice(i, i + batchSize);
|
|
217
|
+
const inputs = slice.map((entry) => {
|
|
218
|
+
const summary = ftsByPath.get(entry.path) ?? entry.path;
|
|
219
|
+
return `${entry.path}\n${summary}`.slice(0, 4096);
|
|
220
|
+
});
|
|
221
|
+
const res = await localEmbed(localCfg.embed, { input: inputs, allowRemote, fetchImpl });
|
|
222
|
+
if (!res.ok || res.embeddings.length !== slice.length) {
|
|
223
|
+
return { state: 'failed', rows };
|
|
224
|
+
}
|
|
225
|
+
for (let j = 0; j < slice.length; j += 1) {
|
|
226
|
+
const vec = res.embeddings[j];
|
|
227
|
+
rows.push({
|
|
228
|
+
path: slice[j].path,
|
|
229
|
+
blob: slice[j].blob,
|
|
230
|
+
model: res.model ?? localCfg.embed.model,
|
|
231
|
+
dims: vec.length,
|
|
232
|
+
vec: encodeVectorBlob(vec),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { state: 'embedded', rows };
|
|
237
|
+
}
|
|
238
|
+
|
|
117
239
|
/** File rows, sorted by path. The same blobs read back as the same rows. */
|
|
118
240
|
export async function readFiles(root, opts = {}) {
|
|
119
241
|
const backend = opts.backend ?? await probeBackend();
|
|
@@ -135,7 +257,99 @@ export async function readEdges(root, opts = {}) {
|
|
|
135
257
|
return readJsonl(root, 'edges');
|
|
136
258
|
}
|
|
137
259
|
|
|
138
|
-
|
|
260
|
+
/** FTS rows currently indexed. */
|
|
261
|
+
export async function readFts(root, opts = {}) {
|
|
262
|
+
const backend = opts.backend ?? await probeBackend();
|
|
263
|
+
if (backend === 'sqlite') return readSqliteRows(root, 'select kind, ref, body from fts order by ref, kind');
|
|
264
|
+
return readJsonl(root, 'fts');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Stored vector embeddings, decoded as Float32Array per path. */
|
|
268
|
+
export async function readEmbeddings(root, opts = {}) {
|
|
269
|
+
const backend = opts.backend ?? await probeBackend();
|
|
270
|
+
if (backend === 'sqlite') {
|
|
271
|
+
const raw = await readSqliteRows(root, 'select path, blob, model, dims, vec from embeddings order by path');
|
|
272
|
+
return raw.map((row) => {
|
|
273
|
+
const vec = decodeVectorBlob(row.vec);
|
|
274
|
+
return {
|
|
275
|
+
path: row.path,
|
|
276
|
+
blob: row.blob ?? '',
|
|
277
|
+
model: row.model ?? '',
|
|
278
|
+
dims: Number.isInteger(row.dims) ? row.dims : vec.length,
|
|
279
|
+
vec,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return readJsonl(root, 'embeddings');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Rank indexed documents against a task string using BM25.
|
|
288
|
+
*
|
|
289
|
+
* Uses SQLite FTS5 `bm25(fts)` when the sqlite backend is active on disk, and
|
|
290
|
+
* falls back to an identical Okapi BM25 computation over `fts` rows otherwise.
|
|
291
|
+
*/
|
|
292
|
+
export async function searchFtsBm25(root, task, opts = {}) {
|
|
293
|
+
const terms = ftsQueryTerms(task);
|
|
294
|
+
if (terms.length === 0) return [];
|
|
295
|
+
const backend = opts.backend ?? await probeBackend();
|
|
296
|
+
if (backend === 'sqlite') {
|
|
297
|
+
const matchExpr = terms.map((t) => `"${t}"`).join(' OR ');
|
|
298
|
+
const rows = await readSqliteRows(
|
|
299
|
+
root,
|
|
300
|
+
'select kind, ref as path, -bm25(fts) as score from fts where fts match ? order by score desc, path asc',
|
|
301
|
+
[matchExpr],
|
|
302
|
+
);
|
|
303
|
+
if (rows.length > 0) {
|
|
304
|
+
return rows.map((row) => ({ kind: row.kind, path: row.path, score: Number(row.score) || 0 }));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const ftsRows = opts.fts ?? await readFts(root, { backend });
|
|
308
|
+
return scoreFtsBm25(ftsRows, task);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Deterministic Okapi BM25 ranking over FTS rows (`{ kind, ref, body }`).
|
|
313
|
+
*/
|
|
314
|
+
export function scoreFtsBm25(ftsRows, task) {
|
|
315
|
+
const terms = ftsQueryTerms(task);
|
|
316
|
+
if (terms.length === 0 || !Array.isArray(ftsRows) || ftsRows.length === 0) return [];
|
|
317
|
+
const k1 = 1.2;
|
|
318
|
+
const b = 0.75;
|
|
319
|
+
const N = ftsRows.length;
|
|
320
|
+
const docs = ftsRows.map((row) => {
|
|
321
|
+
const path = row.ref ?? row.path ?? '';
|
|
322
|
+
const words = `${row.kind ?? ''} ${path} ${row.body ?? ''}`
|
|
323
|
+
.toLowerCase()
|
|
324
|
+
.split(/[^a-z0-9_]+/)
|
|
325
|
+
.filter(Boolean);
|
|
326
|
+
const tf = new Map();
|
|
327
|
+
for (const w of words) tf.set(w, (tf.get(w) ?? 0) + 1);
|
|
328
|
+
return { kind: row.kind ?? 'code', path, len: words.length, tf };
|
|
329
|
+
});
|
|
330
|
+
const avgdl = docs.reduce((sum, d) => sum + d.len, 0) / N || 1;
|
|
331
|
+
const df = new Map();
|
|
332
|
+
for (const term of terms) {
|
|
333
|
+
let n = 0;
|
|
334
|
+
for (const d of docs) if (d.tf.has(term)) n += 1;
|
|
335
|
+
df.set(term, n);
|
|
336
|
+
}
|
|
337
|
+
const out = [];
|
|
338
|
+
for (const d of docs) {
|
|
339
|
+
let score = 0;
|
|
340
|
+
for (const term of terms) {
|
|
341
|
+
const f = d.tf.get(term) ?? 0;
|
|
342
|
+
if (f === 0) continue;
|
|
343
|
+
const n = df.get(term) ?? 0;
|
|
344
|
+
const idf = Math.max(1e-6, Math.log((N - n + 0.5) / (n + 0.5)));
|
|
345
|
+
score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (d.len / avgdl))));
|
|
346
|
+
}
|
|
347
|
+
if (score > 0) out.push({ kind: d.kind, path: d.path, score });
|
|
348
|
+
}
|
|
349
|
+
return out.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function langOf(filePath) {
|
|
139
353
|
const base = filePath.slice(filePath.lastIndexOf('/') + 1);
|
|
140
354
|
const dot = base.lastIndexOf('.');
|
|
141
355
|
if (dot <= 0) return '';
|
|
@@ -263,7 +477,7 @@ function normalise(filePath, got) {
|
|
|
263
477
|
return { symbols, imports: Array.isArray(got.imports) ? got.imports : [], edges };
|
|
264
478
|
}
|
|
265
479
|
|
|
266
|
-
async function blobTexts(root, blobs) {
|
|
480
|
+
export async function blobTexts(root, blobs) {
|
|
267
481
|
const unique = [...new Set(blobs)];
|
|
268
482
|
const texts = new Map();
|
|
269
483
|
if (unique.length === 0) return texts;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Rule text variant parsing, validation, and deterministic projection folding
|
|
2
|
+
// (plan 0003 R35).
|
|
3
|
+
//
|
|
4
|
+
// A rule line or list item may carry `<!-- simt-variants: <slot> <spec> -->`.
|
|
5
|
+
// When unlocked, projection uses the baseline variant (`a`, or the line's own
|
|
6
|
+
// prose) and strips the marker so `simt-` annotations never reach a reader.
|
|
7
|
+
// When `simt-dev.lock.json` records a locked winner for `<ruleId>.<slot>`,
|
|
8
|
+
// projection deterministically renders that winning variant.
|
|
9
|
+
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const VARIANTS_MARKER = 'simt-variants';
|
|
14
|
+
const LOCK_FILE_NAME = 'simt-dev.lock.json';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read the locked `variants` map from `simt-dev.lock.json` in `root`, or `{}` when absent.
|
|
18
|
+
*/
|
|
19
|
+
export async function readLockedVariants(root) {
|
|
20
|
+
if (!root) return {};
|
|
21
|
+
try {
|
|
22
|
+
const raw = await readFile(join(root, LOCK_FILE_NAME), 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (
|
|
25
|
+
parsed &&
|
|
26
|
+
typeof parsed === 'object' &&
|
|
27
|
+
parsed.variants &&
|
|
28
|
+
typeof parsed.variants === 'object' &&
|
|
29
|
+
!Array.isArray(parsed.variants)
|
|
30
|
+
) {
|
|
31
|
+
return parsed.variants;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Missing or unreadable lockfile falls back to baseline variants.
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MARKER = /<!--\s*simt-variants:\s*([\s\S]*?)\s*-->/g;
|
|
40
|
+
const MARKER_AT = /\s*<!--\s*simt-variants:\s*[\s\S]*?-->/g;
|
|
41
|
+
const HAS_MARKER = /<!--\s*simt-variants:/;
|
|
42
|
+
const ITEM_START = /^(\s*)((?:[-*]|\d+\.)\s+)/;
|
|
43
|
+
const SLOT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
44
|
+
const VARIANT_KEY_RE = /^[A-Za-z0-9_-]+$/;
|
|
45
|
+
|
|
46
|
+
function fenced(unit) {
|
|
47
|
+
return /^\s*(```|~~~)/.test(unit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function markersIn(unit) {
|
|
51
|
+
return [...unit.matchAll(MARKER)].map((m) => m[1]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripMarkers(unit) {
|
|
55
|
+
return unit.replace(MARKER_AT, '').replace(/[ \t]+$/gm, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lines of a body, fences kept whole, list items split from their neighbours. */
|
|
59
|
+
function units(body) {
|
|
60
|
+
const lines = String(body ?? '').split('\n');
|
|
61
|
+
const out = [];
|
|
62
|
+
let inFence = false;
|
|
63
|
+
let current = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (current.length > 0) out.push(current.join('\n'));
|
|
66
|
+
current = [];
|
|
67
|
+
};
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
70
|
+
if (!inFence) flush();
|
|
71
|
+
current.push(line);
|
|
72
|
+
inFence = !inFence;
|
|
73
|
+
if (!inFence) flush();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (inFence) {
|
|
77
|
+
current.push(line);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (line.trim() === '') {
|
|
81
|
+
flush();
|
|
82
|
+
out.push(line);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ITEM_START.test(line) && current.length > 0) flush();
|
|
86
|
+
current.push(line);
|
|
87
|
+
}
|
|
88
|
+
flush();
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripItemPrefix(text) {
|
|
93
|
+
const m = ITEM_START.exec(text);
|
|
94
|
+
if (!m) return { indent: '', bullet: '', content: text.trim() };
|
|
95
|
+
return {
|
|
96
|
+
indent: m[1],
|
|
97
|
+
bullet: m[2],
|
|
98
|
+
content: text.slice(m[0].length).trim(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function qualifySlotId(ruleId, rawSlot) {
|
|
103
|
+
const slot = String(rawSlot ?? '').trim();
|
|
104
|
+
if (!slot) return { slotId: '', shortId: '' };
|
|
105
|
+
const prefix = ruleId ? `${ruleId}.` : '';
|
|
106
|
+
if (prefix && slot.startsWith(prefix)) {
|
|
107
|
+
return { slotId: slot, shortId: slot.slice(prefix.length) };
|
|
108
|
+
}
|
|
109
|
+
if (slot.includes('.')) {
|
|
110
|
+
const dot = slot.indexOf('.');
|
|
111
|
+
return { slotId: slot, shortId: slot.slice(dot + 1) };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
slotId: prefix ? `${prefix}${slot}` : slot,
|
|
115
|
+
shortId: slot,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseKvPairs(rawSpec) {
|
|
120
|
+
const map = {};
|
|
121
|
+
const pairRe = /([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/g;
|
|
122
|
+
let matched = 0;
|
|
123
|
+
for (const m of rawSpec.matchAll(pairRe)) {
|
|
124
|
+
const key = m[1];
|
|
125
|
+
const val = (m[2] ?? m[3] ?? '').replace(/\\(["'\\])/g, '$1').trim();
|
|
126
|
+
map[key] = val;
|
|
127
|
+
matched += 1;
|
|
128
|
+
}
|
|
129
|
+
if (matched === 0) return null;
|
|
130
|
+
const leftover = rawSpec.replace(pairRe, '').replace(/[|\s,]+/g, '').trim();
|
|
131
|
+
if (leftover !== '') return null;
|
|
132
|
+
return map;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse one `simt-variants` marker expression together with the stripped unit text.
|
|
137
|
+
*/
|
|
138
|
+
export function parseVariantSpec(expr, ruleId = '', strippedUnit = '') {
|
|
139
|
+
const raw = String(expr ?? '').trim();
|
|
140
|
+
if (!raw) {
|
|
141
|
+
return { error: `${VARIANTS_MARKER} is empty; name a slot and its variants` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let rawSlot = '';
|
|
145
|
+
let rawMap = null;
|
|
146
|
+
|
|
147
|
+
if (raw.startsWith('{')) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { error: `${VARIANTS_MARKER} JSON is invalid (${error.message})` };
|
|
153
|
+
}
|
|
154
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
155
|
+
return { error: `${VARIANTS_MARKER} must be a JSON object` };
|
|
156
|
+
}
|
|
157
|
+
const keys = Object.keys(parsed);
|
|
158
|
+
if (
|
|
159
|
+
keys.length === 1 &&
|
|
160
|
+
parsed[keys[0]] &&
|
|
161
|
+
typeof parsed[keys[0]] === 'object' &&
|
|
162
|
+
!Array.isArray(parsed[keys[0]])
|
|
163
|
+
) {
|
|
164
|
+
rawSlot = keys[0];
|
|
165
|
+
rawMap = parsed[keys[0]];
|
|
166
|
+
} else if (typeof parsed.id === 'string' || typeof parsed.slot === 'string') {
|
|
167
|
+
rawSlot = String(parsed.id ?? parsed.slot);
|
|
168
|
+
rawMap = {};
|
|
169
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
170
|
+
if (k === 'id' || k === 'slot') continue;
|
|
171
|
+
rawMap[k] = v;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
return { error: `${VARIANTS_MARKER} JSON object must name a slot id` };
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
const firstSpace = raw.search(/\s/);
|
|
178
|
+
if (firstSpace === -1) {
|
|
179
|
+
return { error: `${VARIANTS_MARKER} "${raw}" names a slot but no variants` };
|
|
180
|
+
}
|
|
181
|
+
rawSlot = raw.slice(0, firstSpace).trim();
|
|
182
|
+
const rest = raw.slice(firstSpace + 1).trim();
|
|
183
|
+
if (rest.startsWith('{')) {
|
|
184
|
+
try {
|
|
185
|
+
rawMap = JSON.parse(rest);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return { error: `${VARIANTS_MARKER} "${rawSlot}" has invalid JSON (${error.message})` };
|
|
188
|
+
}
|
|
189
|
+
if (!rawMap || typeof rawMap !== 'object' || Array.isArray(rawMap)) {
|
|
190
|
+
return { error: `${VARIANTS_MARKER} "${rawSlot}" must map variant keys to strings` };
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
rawMap = parseKvPairs(rest);
|
|
194
|
+
if (!rawMap) {
|
|
195
|
+
return {
|
|
196
|
+
error: `${VARIANTS_MARKER} "${rawSlot}" must specify variants as JSON or key="text" pairs`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { slotId, shortId } = qualifySlotId(ruleId, rawSlot);
|
|
203
|
+
if (!slotId || !shortId || !SLOT_ID_RE.test(slotId) || !SLOT_ID_RE.test(shortId)) {
|
|
204
|
+
return { error: `${VARIANTS_MARKER} has invalid slot id "${rawSlot}"` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const { indent, bullet, content: baseProse } = stripItemPrefix(strippedUnit);
|
|
208
|
+
const variants = {};
|
|
209
|
+
|
|
210
|
+
if (baseProse !== '' && rawMap.a === undefined) {
|
|
211
|
+
variants.a = baseProse;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const [k, v] of Object.entries(rawMap)) {
|
|
215
|
+
if (!VARIANT_KEY_RE.test(k)) {
|
|
216
|
+
return { error: `${VARIANTS_MARKER} "${slotId}" has invalid variant key "${k}"` };
|
|
217
|
+
}
|
|
218
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
219
|
+
return { error: `${VARIANTS_MARKER} "${slotId}" variant "${k}" must be a non-empty string` };
|
|
220
|
+
}
|
|
221
|
+
const { content } = stripItemPrefix(v.trim());
|
|
222
|
+
variants[k] = content;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const variantKeys = Object.keys(variants);
|
|
226
|
+
if (variantKeys.length < 2) {
|
|
227
|
+
return {
|
|
228
|
+
error: `${VARIANTS_MARKER} "${slotId}" needs at least 2 variants (found ${variantKeys.length})`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const baseline = variants.a !== undefined ? 'a' : variantKeys[0];
|
|
233
|
+
return {
|
|
234
|
+
slotId,
|
|
235
|
+
shortId,
|
|
236
|
+
ruleId: ruleId || (slotId.includes('.') ? slotId.slice(0, slotId.indexOf('.')) : ''),
|
|
237
|
+
baseline,
|
|
238
|
+
variants,
|
|
239
|
+
indent,
|
|
240
|
+
bullet,
|
|
241
|
+
error: null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Report the first defect in a rule body's `simt-variants` markers, or null.
|
|
247
|
+
*/
|
|
248
|
+
export function variantsDefect(body, ruleId = '') {
|
|
249
|
+
const seenSlots = new Set();
|
|
250
|
+
for (const unit of units(body)) {
|
|
251
|
+
if (fenced(unit)) continue;
|
|
252
|
+
const found = markersIn(unit);
|
|
253
|
+
if (found.length === 0) continue;
|
|
254
|
+
if (found.length > 1) {
|
|
255
|
+
return `carries more than one ${VARIANTS_MARKER} marker on one item; one item is one variant slot`;
|
|
256
|
+
}
|
|
257
|
+
const stripped = stripMarkers(unit);
|
|
258
|
+
const parsed = parseVariantSpec(found[0], ruleId, stripped);
|
|
259
|
+
if (parsed.error) return parsed.error;
|
|
260
|
+
if (seenSlots.has(parsed.slotId)) {
|
|
261
|
+
return `declares duplicate ${VARIANTS_MARKER} slot "${parsed.slotId}"`;
|
|
262
|
+
}
|
|
263
|
+
seenSlots.add(parsed.slotId);
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract all declared variant slots from a rule (`{id, source, body}`).
|
|
270
|
+
*/
|
|
271
|
+
export function extractVariantsFromRule(rule) {
|
|
272
|
+
const body = String(rule?.body ?? '');
|
|
273
|
+
const ruleId = String(rule?.id ?? '');
|
|
274
|
+
if (!HAS_MARKER.test(body) || variantsDefect(body, ruleId) !== null) return [];
|
|
275
|
+
const slots = [];
|
|
276
|
+
for (const unit of units(body)) {
|
|
277
|
+
if (fenced(unit)) continue;
|
|
278
|
+
const found = markersIn(unit);
|
|
279
|
+
if (found.length !== 1) continue;
|
|
280
|
+
const stripped = stripMarkers(unit);
|
|
281
|
+
const parsed = parseVariantSpec(found[0], ruleId, stripped);
|
|
282
|
+
if (parsed.error) continue;
|
|
283
|
+
slots.push({
|
|
284
|
+
slotId: parsed.slotId,
|
|
285
|
+
shortId: parsed.shortId,
|
|
286
|
+
ruleId: parsed.ruleId,
|
|
287
|
+
source: rule?.source ?? null,
|
|
288
|
+
baseline: parsed.baseline,
|
|
289
|
+
variants: parsed.variants,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return slots;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function lookupLockedWinner(lockedVariants, slotId, shortId) {
|
|
296
|
+
if (!lockedVariants || typeof lockedVariants !== 'object') return null;
|
|
297
|
+
const entry = lockedVariants[slotId] ?? (shortId ? lockedVariants[shortId] : undefined);
|
|
298
|
+
if (typeof entry === 'string') return { winner: entry, entry: { winner: entry } };
|
|
299
|
+
if (entry && typeof entry === 'object') {
|
|
300
|
+
const w = entry.winner ?? entry.variant;
|
|
301
|
+
if (typeof w === 'string' && w !== '') return { winner: w, entry };
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve every `simt-variants` slot in `body` using `lockedVariants` (or the baseline
|
|
308
|
+
* variant when unlocked) and strip the marker comments.
|
|
309
|
+
*
|
|
310
|
+
* When `body` carries no `simt-variants` marker, returns `body` by identity so
|
|
311
|
+
* untouched rules do not move a byte.
|
|
312
|
+
*
|
|
313
|
+
* @returns {{body: string, selected: {slotId: string, winner: string, baseline: string, locked: boolean, entry: object|null}[], changed: boolean}}
|
|
314
|
+
*/
|
|
315
|
+
export function foldVariants(body, ruleId = '', lockedVariants = null) {
|
|
316
|
+
if (!HAS_MARKER.test(body) || variantsDefect(body, ruleId) !== null) {
|
|
317
|
+
return { body, selected: [], changed: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const selected = [];
|
|
321
|
+
const kept = [];
|
|
322
|
+
|
|
323
|
+
for (const unit of units(body)) {
|
|
324
|
+
const found = fenced(unit) ? [] : markersIn(unit);
|
|
325
|
+
if (found.length === 0) {
|
|
326
|
+
kept.push(unit);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const stripped = stripMarkers(unit);
|
|
330
|
+
const parsed = parseVariantSpec(found[0], ruleId, stripped);
|
|
331
|
+
if (parsed.error) {
|
|
332
|
+
kept.push(stripped);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const locked = lookupLockedWinner(lockedVariants, parsed.slotId, parsed.shortId);
|
|
337
|
+
const hasLockedKey = locked !== null && Object.hasOwn(parsed.variants, locked.winner);
|
|
338
|
+
const chosenKey = hasLockedKey ? locked.winner : parsed.baseline;
|
|
339
|
+
const chosenText = parsed.variants[chosenKey];
|
|
340
|
+
|
|
341
|
+
kept.push(`${parsed.indent}${parsed.bullet}${chosenText}`);
|
|
342
|
+
selected.push({
|
|
343
|
+
slotId: parsed.slotId,
|
|
344
|
+
winner: chosenKey,
|
|
345
|
+
baseline: parsed.baseline,
|
|
346
|
+
locked: hasLockedKey,
|
|
347
|
+
entry: hasLockedKey ? locked.entry : null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const next = kept.join('\n');
|
|
352
|
+
return { body: next, selected, changed: next !== body };
|
|
353
|
+
}
|