@spexcode/spec-core 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/anchors.d.ts CHANGED
@@ -9,8 +9,8 @@ export type Unit = {
9
9
  export type Extractor = {
10
10
  id: string;
11
11
  claims(ext: string): boolean;
12
- ready(): true | string;
13
- extract(content: string, filename: string): Unit[];
12
+ ready(): true | string | Promise<true | string>;
13
+ extract(content: string, filename: string): Unit[] | Promise<Unit[]>;
14
14
  memoKey: (filename: string) => string;
15
15
  };
16
16
  export type CodeEntry = {
@@ -56,6 +56,16 @@ export type LangSpec = {
56
56
  export declare function heuristicExtractor(spec: LangSpec): Extractor;
57
57
  export declare const JS_LANG_R5B: LangSpec;
58
58
  export declare const PYTHON_LANG: LangSpec;
59
+ type TreeSitterNode = any;
60
+ export type TreeSitterLanguageRow = {
61
+ id: string;
62
+ extensions: string[];
63
+ grammar: string;
64
+ schema: string;
65
+ units(root: TreeSitterNode): Unit[];
66
+ };
67
+ export declare const TREE_SITTER_ROWS: TreeSitterLanguageRow[];
68
+ export declare function treeSitterExtractor(row: TreeSitterLanguageRow): Extractor;
59
69
  export declare function extractors(root: string): Extractor[];
60
70
  export declare function extractorFor(regs: Extractor[], ext: string): Extractor | null;
61
71
  export declare const extOf: (path: string) => string;
@@ -92,3 +102,4 @@ export type AnchorHitQuery = {
92
102
  export declare function anchorHitQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<AnchorHit[][]>;
93
103
  export declare function anchorHitExists(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<boolean[]>;
94
104
  export declare function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]>;
105
+ export {};
package/dist/anchors.js CHANGED
@@ -1,4 +1,4 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
2
  import { createRequire } from 'node:module';
3
3
  import { gitRequiredA, gitObjectFormat, isGitObjectId, batchRevisionOids, batchBlobTexts, combinedDiffOwnedChanges, driftPathWindow, readImmutableHunkFacts, persistImmutableHunkFacts, withEventLedgerBuild } from './git.js';
4
4
  const RS = '\x1e';
@@ -50,7 +50,9 @@ export function parseRelation(raws, relation) {
50
50
  }
51
51
  return { entries: order.map((p) => ({ path: p, selectors: byPath.get(p).selectors })), problems };
52
52
  }
53
- // ---- extractor: ts-ast (the designated extractor for the JS family) ----
53
+ // ---- legacy extractor: ts-ast ----
54
+ // Kept as a public compatibility helper only. The production language registry below routes every shipped
55
+ // language through Tree-sitter WASM and never selects this host-dependent path.
54
56
  // Parse-only via the HOST project's own typescript, so the parse matches what the project itself compiles
55
57
  // with. If it cannot resolve, ready() returns a loud unverified verdict and lint skips these anchors
56
58
  // without crashing (no bundled compiler, regex fallback, or fake pass for JS).
@@ -310,9 +312,7 @@ export function heuristicExtractor(spec) {
310
312
  },
311
313
  };
312
314
  }
313
- // The validated JS-family reference row (R5b: name precision 99.7% / recall 100% / range 98.9% on the
314
- // 41-file oracle) — NOT registered for JS (ts-ast is designated); kept as the engine's reference shape
315
- // and the benchmark's scoring subject.
315
+ // Historical JS-family reference row retained for compatibility callers; not a production registry row.
316
316
  export const JS_LANG_R5B = {
317
317
  id: 'heuristic-js',
318
318
  extensions: [...JS_EXTS],
@@ -330,9 +330,7 @@ export const JS_LANG_R5B = {
330
330
  },
331
331
  boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
332
332
  };
333
- // Python is a LangSpec DATA row over the same generic engine: declaration names come from patterns;
334
- // significant indentation supplies lexical qualification and ranges. It is intentionally structural,
335
- // not a Python runtime or full grammar (the user-facing boundary is documented by [[code-anchor]]).
333
+ // Historical Python row retained for compatibility callers; Tree-sitter owns shipped Python anchors.
336
334
  const PY_ID = String.raw `[\p{ID_Start}_][\p{ID_Continue}_]*`;
337
335
  export const PYTHON_LANG = {
338
336
  id: 'heuristic-python',
@@ -349,15 +347,313 @@ export const PYTHON_LANG = {
349
347
  indentScopes: { decorator: /^\s*@/ },
350
348
  boundary: /^\S/,
351
349
  };
350
+ let treeSitterModulePromise;
351
+ let treeSitterInitPromise;
352
+ const treeSitterLanguagePromises = new Map();
353
+ function treeSitterRuntimePath() {
354
+ const require = createRequire(import.meta.url);
355
+ return require.resolve('@vscode/tree-sitter-wasm');
356
+ }
357
+ async function treeSitterModule() {
358
+ if (!treeSitterModulePromise)
359
+ treeSitterModulePromise = import(treeSitterRuntimePath()).then((m) => m.default ?? m);
360
+ return treeSitterModulePromise;
361
+ }
362
+ async function treeSitterLanguage(row) {
363
+ const existing = treeSitterLanguagePromises.get(row.grammar);
364
+ if (existing)
365
+ return existing;
366
+ const pending = (async () => {
367
+ const mod = await treeSitterModule();
368
+ if (!treeSitterInitPromise) {
369
+ const runtime = dirname(treeSitterRuntimePath());
370
+ treeSitterInitPromise = mod.Parser.init({ locateFile: (file) => join(runtime, file) }).then(() => mod);
371
+ }
372
+ await treeSitterInitPromise;
373
+ return mod.Language.load(join(dirname(treeSitterRuntimePath()), `tree-sitter-${row.grammar}.wasm`));
374
+ })();
375
+ treeSitterLanguagePromises.set(row.grammar, pending);
376
+ return pending;
377
+ }
378
+ const nodeField = (node, field) => node?.childForFieldName?.(field) ?? null;
379
+ const nodeChildren = (node) => (node?.namedChildren ?? []).filter(Boolean);
380
+ const nodeAllChildren = (node) => (node?.children ?? []).filter(Boolean);
381
+ const nodeText = (node) => node?.text ?? '';
382
+ const treeHasSyntaxError = (root) => {
383
+ if (root?.hasError)
384
+ return true;
385
+ const pending = [root];
386
+ while (pending.length) {
387
+ const node = pending.pop();
388
+ if (node.isError || node.isMissing)
389
+ return true;
390
+ pending.push(...nodeAllChildren(node));
391
+ }
392
+ return false;
393
+ };
394
+ const nodeLineRange = (node, start = node.startPosition.row + 1, end = node.endPosition.row + 1) => ({ start, end });
395
+ const simpleName = (node) => nodeText(node).replace(/^\s+|\s+$/g, '');
396
+ const lastTypeName = (text) => {
397
+ const clean = text.replace(/^\s*\(/, '').replace(/\)\s*$/, '').replace(/^\s*[*&]+/, '');
398
+ const base = clean.split(/[.[\]<>\s]/, 1)[0];
399
+ return (base.split('.').pop() ?? base).replace(/[^\p{L}\p{N}_$]/gu, '');
400
+ };
401
+ function tsTreeUnits(root) {
402
+ const units = [];
403
+ const add = (name, kind, node, typeOnly = false, start) => {
404
+ if (!name)
405
+ return;
406
+ units.push({ name, kind, ...nodeLineRange(node, start), ...(typeOnly ? { typeOnly: true } : {}) });
407
+ };
408
+ const methodUnits = (className, body) => {
409
+ for (const member of nodeChildren(body)) {
410
+ if (member.type !== 'method_definition' || !nodeField(member, 'body'))
411
+ continue;
412
+ const name = nodeText(nodeField(member, 'name')) || nodeText(member.namedChildren?.[0]) || '(computed)';
413
+ add(`${className}.${name === 'constructor' ? 'constructor' : name}`, 'method', member);
414
+ }
415
+ };
416
+ for (const raw of nodeChildren(root)) {
417
+ const top = raw.type === 'export_statement' ? nodeChildren(raw) : [raw];
418
+ for (const node of top) {
419
+ if (node.type === 'function_declaration')
420
+ add(nodeText(nodeField(node, 'name')), 'function', node);
421
+ else if (node.type === 'class_declaration') {
422
+ const name = nodeText(nodeField(node, 'name'));
423
+ add(name, 'class', node);
424
+ methodUnits(name, nodeField(node, 'body'));
425
+ }
426
+ else if (node.type === 'lexical_declaration' || node.type === 'variable_declaration') {
427
+ for (const decl of nodeChildren(node).filter((child) => child.type === 'variable_declarator')) {
428
+ const name = nodeText(nodeField(decl, 'name'));
429
+ if (!/^[A-Za-z_$][\w$]*$/u.test(name))
430
+ continue;
431
+ const value = nodeField(decl, 'value');
432
+ add(name, value?.type === 'arrow_function' || value?.type === 'function' ? 'const-fn' : 'const-data', node);
433
+ }
434
+ }
435
+ else if (node.type === 'enum_declaration')
436
+ add(nodeText(nodeField(node, 'name')), 'enum', node);
437
+ else if (node.type === 'interface_declaration')
438
+ add(nodeText(nodeField(node, 'name')), 'interface', node, true);
439
+ else if (node.type === 'type_alias_declaration')
440
+ add(nodeText(nodeField(node, 'name')), 'type', node, true);
441
+ }
442
+ }
443
+ return units;
444
+ }
445
+ function pythonTreeUnits(root) {
446
+ const units = [];
447
+ const definition = (node) => node.type === 'decorated_definition'
448
+ ? nodeChildren(node).find((child) => child.type === 'function_definition' || child.type === 'class_definition') ?? null
449
+ : node;
450
+ const visitContainer = (container, scopes) => {
451
+ for (const child of nodeChildren(container)) {
452
+ const inner = definition(child);
453
+ if (!inner || (inner.type !== 'function_definition' && inner.type !== 'class_definition')) {
454
+ visitContainer(child, scopes);
455
+ continue;
456
+ }
457
+ const name = nodeText(nodeField(inner, 'name'));
458
+ const qualified = [...scopes.map((scope) => scope.name), name].filter(Boolean).join('.');
459
+ const wrapperStart = child.type === 'decorated_definition' ? child.startPosition.row + 1 : undefined;
460
+ const kind = inner.type === 'class_definition' ? 'class' : (scopes.at(-1)?.classLike ? 'method' : 'function');
461
+ units.push({ name: qualified, kind, ...nodeLineRange(inner, wrapperStart) });
462
+ const body = nodeField(inner, 'body');
463
+ if (body)
464
+ visitContainer(body, [...scopes, { name, classLike: inner.type === 'class_definition' }]);
465
+ }
466
+ };
467
+ visitContainer(root, []);
468
+ return units;
469
+ }
470
+ function goTreeUnits(root) {
471
+ const units = [];
472
+ for (const node of root.descendantsOfType?.(['const_declaration', 'function_declaration', 'method_declaration', 'type_declaration']) ?? []) {
473
+ if (node.type === 'const_declaration') {
474
+ for (const spec of nodeChildren(node).filter((child) => child.type === 'const_spec')) {
475
+ const name = nodeText(nodeField(spec, 'name')) || nodeText(nodeChildren(spec)[0]);
476
+ if (name)
477
+ units.push({ name, kind: 'const-data', ...nodeLineRange(spec) });
478
+ }
479
+ }
480
+ else if (node.type === 'function_declaration') {
481
+ units.push({ name: nodeText(nodeField(node, 'name')), kind: 'function', ...nodeLineRange(node) });
482
+ }
483
+ else if (node.type === 'method_declaration') {
484
+ const receiver = nodeField(node, 'receiver');
485
+ const parameter = nodeChildren(receiver).find((child) => child.type === 'parameter_declaration');
486
+ const receiverType = nodeField(parameter, 'type') ?? nodeChildren(parameter)[1];
487
+ const receiverName = lastTypeName(nodeText(receiverType));
488
+ const methodName = nodeText(nodeField(node, 'name'));
489
+ units.push({ name: receiverName ? `${receiverName}.${methodName}` : methodName, kind: 'method', ...nodeLineRange(node) });
490
+ }
491
+ else {
492
+ for (const spec of nodeChildren(node).filter((child) => child.type === 'type_spec')) {
493
+ const name = nodeText(nodeField(spec, 'name')) || nodeText(nodeChildren(spec)[0]);
494
+ const shape = nodeChildren(spec).find((child) => ['struct_type', 'interface_type'].includes(child.type));
495
+ const kind = shape?.type === 'struct_type' ? 'struct' : shape?.type === 'interface_type' ? 'interface' : 'type';
496
+ if (name)
497
+ units.push({ name, kind, ...nodeLineRange(spec) });
498
+ }
499
+ }
500
+ }
501
+ return units;
502
+ }
503
+ function rustImplName(node) {
504
+ const header = nodeText(node).split('{', 1)[0];
505
+ const forMatch = header.match(/\bfor\s+([A-Za-z_][\w:]*)/u);
506
+ if (forMatch)
507
+ return forMatch[1].split('::').pop() ?? forMatch[1];
508
+ const names = nodeChildren(node).filter((child) => child.type === 'type_identifier' || child.type === 'scoped_type_identifier');
509
+ return lastTypeName(nodeText(names[0]));
510
+ }
511
+ function rustTreeUnits(root) {
512
+ const units = [];
513
+ for (const node of root.descendantsOfType?.(['const_item', 'function_item', 'struct_item', 'enum_item', 'trait_item']) ?? []) {
514
+ const name = nodeText(nodeField(node, 'name')) || nodeText(nodeChildren(node).find((child) => child.type === 'type_identifier'));
515
+ if (!name)
516
+ continue;
517
+ const parent = node.parent;
518
+ let impl = parent;
519
+ while (impl && impl.type !== 'impl_item' && impl.type !== 'source_file')
520
+ impl = impl.parent;
521
+ const implName = impl?.type === 'impl_item' ? rustImplName(impl) : '';
522
+ const kind = node.type === 'function_item' ? (implName ? 'method' : 'function')
523
+ : node.type === 'const_item' ? 'const-data'
524
+ : node.type === 'struct_item' ? 'struct'
525
+ : node.type === 'enum_item' ? 'enum' : 'trait';
526
+ units.push({ name: implName && node.type === 'function_item' ? `${implName}.${name}` : name, kind, ...nodeLineRange(node) });
527
+ }
528
+ return units;
529
+ }
530
+ function javaTreeUnits(root) {
531
+ const units = [];
532
+ const visitClass = (node, scopes) => {
533
+ const name = nodeText(nodeField(node, 'name')) || nodeText(nodeChildren(node).find((child) => child.type === 'identifier'));
534
+ if (!name)
535
+ return;
536
+ const qualified = [...scopes, name].join('.');
537
+ const kind = node.type === 'class_declaration' ? 'class' : node.type === 'interface_declaration' ? 'interface' : 'enum';
538
+ units.push({ name: qualified, kind, ...nodeLineRange(node) });
539
+ const body = nodeField(node, 'body');
540
+ for (const child of nodeChildren(body)) {
541
+ if (child.type === 'field_declaration' && /\bfinal\b/u.test(nodeText(child))) {
542
+ for (const variable of nodeChildren(child).filter((part) => part.type === 'variable_declarator')) {
543
+ const field = nodeText(nodeField(variable, 'name')) || nodeText(nodeChildren(variable).find((part) => part.type === 'identifier'));
544
+ if (field)
545
+ units.push({ name: `${qualified}.${field}`, kind: 'const-data', ...nodeLineRange(child) });
546
+ }
547
+ }
548
+ else if (child.type === 'method_declaration' || child.type === 'constructor_declaration') {
549
+ const method = nodeText(nodeField(child, 'name')) || nodeText(nodeChildren(child).find((part) => part.type === 'identifier'));
550
+ if (method)
551
+ units.push({ name: `${qualified}.${child.type === 'constructor_declaration' ? 'constructor' : method}`, kind: 'method', ...nodeLineRange(child) });
552
+ }
553
+ else if (child.type.endsWith('_declaration') && ['class_declaration', 'interface_declaration', 'enum_declaration'].includes(child.type))
554
+ visitClass(child, [...scopes, name]);
555
+ }
556
+ };
557
+ const walk = (node) => {
558
+ for (const child of nodeChildren(node)) {
559
+ if (['class_declaration', 'interface_declaration', 'enum_declaration'].includes(child.type))
560
+ visitClass(child, []);
561
+ else
562
+ walk(child);
563
+ }
564
+ };
565
+ walk(root);
566
+ return units;
567
+ }
568
+ function rubyTreeUnits(root) {
569
+ const units = [];
570
+ const rubyName = (name) => name.replaceAll('::', '.');
571
+ const nameOf = (node) => rubyName(nodeText(nodeField(node, 'name')) || nodeText(nodeChildren(node).find((child) => ['constant', 'identifier'].includes(child.type))));
572
+ const visit = (container, scopes) => {
573
+ for (const child of nodeChildren(container)) {
574
+ if (child.type === 'class' || child.type === 'module') {
575
+ const name = nameOf(child);
576
+ if (!name)
577
+ continue;
578
+ const qualified = [...scopes.map((scope) => scope.name), name].join('.');
579
+ units.push({ name: qualified, kind: child.type === 'class' ? 'class' : 'module', ...nodeLineRange(child) });
580
+ visit(nodeField(child, 'body') ?? child, [...scopes, { name, kind: child.type }]);
581
+ }
582
+ else if (child.type === 'assignment' && nodeField(child, 'left')?.type === 'constant') {
583
+ const name = rubyName(nodeText(nodeField(child, 'left')));
584
+ if (name)
585
+ units.push({ name: [...scopes.map((scope) => scope.name), name].join('.'), kind: 'const-data', ...nodeLineRange(child) });
586
+ }
587
+ else if (child.type === 'method' || child.type === 'singleton_method') {
588
+ const name = nameOf(child);
589
+ if (!name)
590
+ continue;
591
+ const receiver = child.type === 'singleton_method' ? rubyName(nodeText(nodeField(child, 'object'))) : '';
592
+ const qualified = receiver && receiver !== 'self' ? `${receiver}.${name}` : [...scopes.map((scope) => scope.name), name].join('.');
593
+ units.push({ name: qualified, kind: child.type === 'singleton_method' || scopes.length ? 'method' : 'function', ...nodeLineRange(child) });
594
+ }
595
+ else
596
+ visit(child, scopes);
597
+ }
598
+ };
599
+ visit(root, []);
600
+ return units;
601
+ }
602
+ export const TREE_SITTER_ROWS = [
603
+ { id: 'tree-sitter-typescript', extensions: ['ts', 'mts', 'cts'], grammar: 'typescript', schema: 'ts-v1', units: tsTreeUnits },
604
+ { id: 'tree-sitter-tsx', extensions: ['tsx'], grammar: 'tsx', schema: 'tsx-v1', units: tsTreeUnits },
605
+ { id: 'tree-sitter-javascript', extensions: ['js', 'jsx', 'mjs', 'cjs'], grammar: 'javascript', schema: 'js-v1', units: tsTreeUnits },
606
+ { id: 'tree-sitter-python', extensions: ['py', 'pyi'], grammar: 'python', schema: 'python-v1', units: pythonTreeUnits },
607
+ { id: 'tree-sitter-go', extensions: ['go'], grammar: 'go', schema: 'go-v1', units: goTreeUnits },
608
+ { id: 'tree-sitter-rust', extensions: ['rs'], grammar: 'rust', schema: 'rust-v1', units: rustTreeUnits },
609
+ { id: 'tree-sitter-java', extensions: ['java'], grammar: 'java', schema: 'java-v1', units: javaTreeUnits },
610
+ { id: 'tree-sitter-ruby', extensions: ['rb'], grammar: 'ruby', schema: 'ruby-v1', units: rubyTreeUnits },
611
+ ];
612
+ export function treeSitterExtractor(row) {
613
+ let readiness;
614
+ const getLanguage = () => treeSitterLanguage(row);
615
+ return {
616
+ id: row.id,
617
+ claims: (ext) => row.extensions.includes(ext),
618
+ async ready() {
619
+ if (readiness !== undefined)
620
+ return readiness;
621
+ try {
622
+ await getLanguage();
623
+ readiness = true;
624
+ }
625
+ catch (error) {
626
+ readiness = `Tree-sitter extractor '${row.id}' cannot load grammar '${row.grammar}': ${error?.message ?? String(error)} — reinstall SpexCode or remove the #anchor`;
627
+ }
628
+ return readiness;
629
+ },
630
+ async extract(content, filename) {
631
+ const language = await getLanguage();
632
+ const mod = await treeSitterModule();
633
+ const parser = new mod.Parser().setLanguage(language);
634
+ let tree;
635
+ try {
636
+ tree = parser.parse(content);
637
+ if (!tree?.rootNode || treeHasSyntaxError(tree.rootNode))
638
+ throw new Error(`${filename} has Tree-sitter syntax errors`);
639
+ return row.units(tree.rootNode);
640
+ }
641
+ finally {
642
+ tree?.delete?.();
643
+ parser.delete?.();
644
+ }
645
+ },
646
+ memoKey(filename) { return JSON.stringify({ schema: 'tree-sitter-wasm-v1', row: row.id, grammar: row.grammar, rowSchema: row.schema, filename }); },
647
+ };
648
+ }
352
649
  // ---- registry: extension -> its ONE designated extractor ----
353
- // The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
354
- // heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
355
- // config — whatever the implementation needs rides inside its own factory, never in the registry.
650
+ // The registry is one Tree-sitter language adapter: every shipped extension is a grammar DATA row.
356
651
  export function extractors(root) {
357
- return [tsAstExtractor(root), ...[PYTHON_LANG].map(heuristicExtractor)];
652
+ void root;
653
+ return TREE_SITTER_ROWS.map(treeSitterExtractor);
358
654
  }
359
655
  // first claiming extractor IS the designation (the registry order defines it); null = no anchor support
360
- // for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
656
+ // for this language yet (lint ERRORS — the remedy is a Tree-sitter language row, or dropping the anchor).
361
657
  export function extractorFor(regs, ext) {
362
658
  return regs.find((x) => x.claims(ext)) ?? null;
363
659
  }
@@ -417,7 +713,7 @@ async function unitsAtFileRevision(commit, path, x, objectFormat, oid, text) {
417
713
  throw new Error(`git cat-file --batch omitted object ${oid} for ${commit}:${path}`);
418
714
  let result;
419
715
  try {
420
- result = { units: x.extract(text, path) };
716
+ result = { units: await x.extract(text, path) };
421
717
  }
422
718
  catch (e) {
423
719
  result = { unparseable: e?.message ?? String(e) };
@@ -626,7 +922,7 @@ async function runAnchorQueriesInLedger(root, queries, regs, stopAtFirstHit) {
626
922
  continue;
627
923
  const ref = revisions.get(key), oid = oidByRef.get(key) ?? null;
628
924
  const x = extractorFor(regs, extOf(ref.path));
629
- const ready = x?.ready();
925
+ const ready = x ? await x.ready() : undefined;
630
926
  if (!x || ready !== true) {
631
927
  units.set(key, { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) });
632
928
  continue;
package/dist/git.d.ts CHANGED
@@ -153,6 +153,10 @@ export type ReviewDiffFile = {
153
153
  additions: number;
154
154
  deletions: number;
155
155
  };
156
+ export declare function parseStatPath(token: string): {
157
+ from: string;
158
+ to: string;
159
+ };
156
160
  export declare function mergeBaseDiff(wtPath: string, mainRef?: string, headRef?: string): Promise<ReviewDiffFile[]>;
157
161
  export declare function mergeConflicts(wtPath: string, mainRef?: string, headRef?: string): Promise<boolean>;
158
162
  type WorktreeSpecDemand = {
package/dist/git.js CHANGED
@@ -230,15 +230,15 @@ export async function batchRevisionOids(root, revisions, options = {}) {
230
230
  });
231
231
  }
232
232
  export async function batchBlobTexts(root, oids) {
233
- const unique = [...new Set(oids.filter(Boolean))];
233
+ const objectIds = [...new Set(oids.filter(Boolean))];
234
234
  const files = new Map();
235
- if (!unique.length)
235
+ if (!objectIds.length)
236
236
  return files;
237
- for (const oid of unique)
237
+ for (const oid of objectIds)
238
238
  if (!isGitObjectId(root, oid))
239
239
  throw new Error(`invalid object id '${oid}'`);
240
- for (let cursor = 0; cursor < unique.length; cursor += BATCH_BLOB_CHUNK) {
241
- const chunk = unique.slice(cursor, cursor + BATCH_BLOB_CHUNK);
240
+ for (let cursor = 0; cursor < objectIds.length; cursor += BATCH_BLOB_CHUNK) {
241
+ const chunk = objectIds.slice(cursor, cursor + BATCH_BLOB_CHUNK);
242
242
  const out = await batchBuffer(['-C', root, 'cat-file', '--batch'], chunk.join('\n') + '\n', BATCH_BLOB_MAX_BUFFER);
243
243
  let offset = 0;
244
244
  for (const oid of chunk) {
@@ -1609,12 +1609,12 @@ function canonicalPathProjector(renamesByFrom, topology) {
1609
1609
  continue;
1610
1610
  }
1611
1611
  seen.add(candidate);
1612
- const unique = new Map();
1612
+ const applicableByIdentity = new Map();
1613
1613
  for (const rename of renamesByFrom.get(candidate) ?? []) {
1614
1614
  if (!precedes(rename.hash, event))
1615
- unique.set(`${rename.hash}\0${rename.to}`, rename);
1615
+ applicableByIdentity.set(`${rename.hash}\0${rename.to}`, rename);
1616
1616
  }
1617
- const applicable = [...unique.values()];
1617
+ const applicable = [...applicableByIdentity.values()];
1618
1618
  if (!applicable.length) {
1619
1619
  resolved.add(candidate);
1620
1620
  continue;
@@ -2531,7 +2531,7 @@ function parseNameStatus(out) {
2531
2531
  return rows;
2532
2532
  }
2533
2533
  const DIFF_STATUS = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' };
2534
- function parseStatPath(token) {
2534
+ export function parseStatPath(token) {
2535
2535
  const b = token.indexOf('{'), arrow = token.indexOf(' => ', b), close = token.indexOf('}', arrow);
2536
2536
  if (b >= 0 && arrow > b && close > arrow) {
2537
2537
  const pre = token.slice(0, b), post = token.slice(close + 1);
package/dist/layout.d.ts CHANGED
@@ -111,8 +111,10 @@ export type RawRecord = {
111
111
  createdAt: number;
112
112
  harness?: string;
113
113
  harness_session_id?: string;
114
+ runtime_start_token?: string;
114
115
  stopped?: boolean;
115
116
  archived?: boolean;
117
+ closed_at?: string;
116
118
  cold_proof?: string;
117
119
  adapter_recovery?: string;
118
120
  launcher?: string;
@@ -125,6 +127,15 @@ export type RawRecord = {
125
127
  runtime_revision?: string;
126
128
  runtime_metadata?: Record<string, string>;
127
129
  base?: string;
130
+ diff_comments?: Array<{
131
+ id: string;
132
+ file_path: string;
133
+ line_start: number;
134
+ line_end: number;
135
+ body: string;
136
+ diff_identity: string;
137
+ sent_at: string | null;
138
+ }>;
128
139
  launch_readiness_pending?: '' | RawLaunchReadinessPending;
129
140
  };
130
141
  export declare const SESSION_LIFECYCLES: readonly ["active", "idle", "awaiting", "parked", "error", "asking", "queued"];
@@ -139,6 +150,7 @@ export type RawLaunchReadinessOriginal = {
139
150
  note: string | null;
140
151
  stopped: boolean;
141
152
  archived: boolean;
153
+ closed_at?: string | null;
142
154
  cold_proof: string | null;
143
155
  adapter_recovery: string | null;
144
156
  };
package/dist/layout.js CHANGED
@@ -136,7 +136,7 @@ export function mainRoot(proj) {
136
136
  }
137
137
  // @@@ global per-session store - Fork A: NO SpexCode files live in the worktree any more, so the worktree's
138
138
  // spec/code tree is pristine (zero per-session pollution). Every per-session runtime artifact — the
139
- // structured record (session.json) AND the launcher products (prompt, launch, launch.sh) AND the recorded comms AND
139
+ // runtime envelope (runtime.json) AND the launcher products (prompt, launch, launch.sh) AND the recorded comms AND
140
140
  // the spec-discipline sentinels — lives in a per-USER GLOBAL store, keyed by the harness `session_id` so two
141
141
  // agents in one folder never clobber, and grouped PER PROJECT (mirroring Claude's ~/.claude/projects/<enc>/)
142
142
  // so the board enumerates ONE directory. This is the single seam that knows where the store sits; sessions.ts
@@ -169,7 +169,7 @@ export function treeSlotDir(wt) {
169
169
  // all keyed by session_id under <home>/projects/<enc>/sessions/.
170
170
  export function sessionsRoot() { return join(runtimeRoot(), 'sessions'); }
171
171
  export function sessionStoreDir(id) { return join(sessionsRoot(), id); }
172
- export function sessionRecordPath(id) { return join(sessionStoreDir(id), 'session.json'); }
172
+ export function sessionRecordPath(id) { return join(sessionStoreDir(id), 'runtime.json'); }
173
173
  export function sessionArtifactPath(id, name) { return join(sessionStoreDir(id), name); }
174
174
  export const SESSION_LIFECYCLES = ['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'];
175
175
  export const SESSION_PROPOSALS = ['merge', 'nothing', 'close'];
@@ -190,6 +190,7 @@ export function rawLaunchReadinessOriginal(raw) {
190
190
  || !(original.proposal === null || original.proposal === '' || isSessionProposal(original.proposal))
191
191
  || !(typeof original.note === 'string' || original.note === null)
192
192
  || typeof original.stopped !== 'boolean' || typeof original.archived !== 'boolean'
193
+ || !(typeof original.closed_at === 'string' || original.closed_at === null || original.closed_at === undefined)
193
194
  || !(typeof original.cold_proof === 'string' || original.cold_proof === null)
194
195
  || !(typeof original.adapter_recovery === 'string' || original.adapter_recovery === null)) {
195
196
  throw new Error(`session '${raw.session_id}' has an invalid launch_readiness_pending fence`);
@@ -281,6 +282,7 @@ export function projectPublicRecordEntry(id, entry) {
281
282
  note: original.note || null,
282
283
  stopped: original.stopped,
283
284
  archived: original.archived,
285
+ closed_at: original.closed_at ?? undefined,
284
286
  cold_proof: original.cold_proof ?? undefined,
285
287
  adapter_recovery: original.adapter_recovery ?? undefined,
286
288
  launch_readiness_pending: '',
@@ -1,2 +1,3 @@
1
+ export declare function isTrashWorktreePath(path: string): boolean;
1
2
  export declare function guardWorktree<T>(dir: string, fn: () => T | Promise<T>, degraded: () => T): Promise<T | null>;
2
3
  export declare function installProcessGuards(): void;
@@ -1,7 +1,15 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
2
3
  function describe(e) {
3
4
  return e instanceof Error ? e.message : String(e);
4
5
  }
6
+ // Delayed close deletion lives below this directory and is never a live worktree input.
7
+ export function isTrashWorktreePath(path) {
8
+ const normalized = resolve(path);
9
+ const parts = normalized.split(/[\\/]+/);
10
+ const worktrees = parts.lastIndexOf('.worktrees');
11
+ return worktrees >= 0 && parts[worktrees + 1] === '.trash';
12
+ }
5
13
  // run a per-worktree DETAIL read; on a throw, branch on whether the worktree DIRECTORY still exists.
6
14
  // dir gone → the worktree was genuinely removed mid-read → return null so the caller omits it.
7
15
  // dir present → a flaky detail read (ENOENT race on a sibling file, or a git index/ref lock under a
@@ -68,6 +68,7 @@ export const EVAL_FILTER_KIND: Readonly<{
68
68
  RESULT: "result";
69
69
  BLIND: "blind";
70
70
  UNMEASURED: "unmeasured";
71
+ DEFERRED: "deferred";
71
72
  DANGLING: "dangling";
72
73
  }>;
73
74
  export function evidenceList(reading: any): any;
@@ -11,6 +11,7 @@ export const EVAL_FILTER_KIND = Object.freeze({
11
11
  RESULT: 'result',
12
12
  BLIND: 'blind',
13
13
  UNMEASURED: 'unmeasured',
14
+ DEFERRED: 'deferred',
14
15
  DANGLING: 'dangling',
15
16
  });
16
17
  export const evidenceList = (reading) => reading?.evidence?.length ? reading.evidence
@@ -146,9 +147,11 @@ export function issueFilterModel(items, raw = {}, context = {}) {
146
147
  const evalIsResult = (entry) => entry.filterKind === EVAL_FILTER_KIND.RESULT;
147
148
  const verdictOf = (entry) => evalIsResult(entry)
148
149
  ? (entry.verdict?.status || 'unscored')
149
- : entry.filterKind === EVAL_FILTER_KIND.BLIND || entry.filterKind === EVAL_FILTER_KIND.UNMEASURED
150
- ? 'unmeasured'
151
- : 'unscored';
150
+ : entry.filterKind === EVAL_FILTER_KIND.DEFERRED
151
+ ? 'deferred'
152
+ : entry.filterKind === EVAL_FILTER_KIND.BLIND || entry.filterKind === EVAL_FILTER_KIND.UNMEASURED
153
+ ? 'unmeasured'
154
+ : 'unscored';
152
155
  const reviewStateOf = (entry) => (evalIsResult(entry) && entry.fresh && entry.humanOk ? 'reviewed' : 'current');
153
156
  // the ONE freshness axis of the Eval adapter — the facet's option values and the verdict sections' split
154
157
  // read it, so a chip and its Freshness menu can never disagree about what "stale" counts.
@@ -194,6 +197,7 @@ const EVAL_CONFIG = {
194
197
  { value: 'fail', label: 'reviewList.verdict.fail', split: true },
195
198
  { value: 'pass', label: 'reviewList.verdict.pass', split: true },
196
199
  { value: 'unmeasured', label: 'reviewList.verdict.unmeasured' },
200
+ { value: 'deferred', label: 'reviewList.verdict.deferred' },
197
201
  ],
198
202
  },
199
203
  facets: [
@@ -31,6 +31,7 @@ export const ISSUE_QUERY_DEFAULT: "is:issue state:open";
31
31
  export const EVAL_QUERY_DEFAULT: "is:eval";
32
32
  export function scopedEvalQuery(sessionId: any): any;
33
33
  export function nodeEvalQuery(nodeId: any): any;
34
+ export function nodeIssueQuery(nodeId: any): any;
34
35
  export function quoteValue(v: any): string;
35
36
  export function tokenize(text: any): ({
36
37
  ws: boolean;
@@ -8,6 +8,10 @@ export const EVAL_QUERY_DEFAULT = 'is:eval';
8
8
  export const scopedEvalQuery = (sessionId) => setToken(EVAL_QUERY_DEFAULT, 'scope', sessionId);
9
9
  // the aggregate score/count doors' address ([[eval-score-badge]]): the default view, node-filtered.
10
10
  export const nodeEvalQuery = (nodeId) => setToken(EVAL_QUERY_DEFAULT, 'node', nodeId);
11
+ // a node's OPEN issues ([[context-dock]]): the issue list's default view, node-filtered. Same shape and
12
+ // same reason as the eval twin — a surface that wants "this node's issues" asks for a text, never for a
13
+ // second filter path, so the panel and the list it links to are literally the same query.
14
+ export const nodeIssueQuery = (nodeId) => setToken(ISSUE_QUERY_DEFAULT, 'node', nodeId);
11
15
  const KEY_RE = /^([A-Za-z][A-Za-z0-9-]*):(.*)$/s;
12
16
  const unquote = (v) => (v.length >= 2 && v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v);
13
17
  export const quoteValue = (v) => (/\s/.test(String(v)) ? `"${v}"` : String(v));
package/dist/specs.d.ts CHANGED
@@ -9,6 +9,7 @@ export type SpecParts = {
9
9
  expandedSpec: string;
10
10
  };
11
11
  declare function parseParts(body: string): SpecParts | null;
12
+ export declare function bodyMentions(body: string): string[];
12
13
  export type DerivedStatus = 'pending' | 'active' | 'merged' | 'drift';
13
14
  export declare function deriveStatus(d: {
14
15
  version: number;
@@ -43,6 +44,7 @@ export declare function specContent(id: string): {
43
44
  body: string;
44
45
  parts: ReturnType<typeof parseParts>;
45
46
  } | null;
47
+ export declare function specDir(id: string): string | null;
46
48
  export type LoadSpecsOptions = {
47
49
  tip?: string;
48
50
  history?: HistoryIndex | null;
package/dist/specs.js CHANGED
@@ -68,6 +68,29 @@ function parseParts(body) {
68
68
  const t = (a) => a.join('\n').trim();
69
69
  return { rawSource: t(acc.rawSource), expandedSpec: t(acc.expandedSpec) };
70
70
  }
71
+ // the `[[id]]` reference grammar as a spec BODY writes it. Its consumer is the lint rule that rejects a
72
+ // dangling reference — the one place a `[[name]]` has to be judged against the node universe. It is a
73
+ // parser and not a projection: the loader once shipped the surviving ids as a `mentions` edge on every
74
+ // node, and that edge is gone, because a prose mention is a fact about the GRAPH and never was a fact
75
+ // about the node the reader has open ([[context-dock]]). Prose only: a fenced block or an inline
76
+ // `code span` is sample text (`[[node]]`, `[[<id>]]` placeholders live there), not a reference. Distinct,
77
+ // in first-appearance order; whether a name resolves to a real node is the caller's judgement.
78
+ const MENTION_RE = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/gu;
79
+ export function bodyMentions(body) {
80
+ const out = new Set();
81
+ let inFence = false;
82
+ for (const rawLine of body.split('\n')) {
83
+ if (/^\s*```/.test(rawLine)) {
84
+ inFence = !inFence;
85
+ continue;
86
+ }
87
+ if (inFence)
88
+ continue;
89
+ for (const m of rawLine.replace(/`[^`]*`/g, '').matchAll(MENTION_RE))
90
+ out.add(m[1]);
91
+ }
92
+ return [...out];
93
+ }
71
94
  export function deriveStatus(d) {
72
95
  if (d.fmStatus === 'pending' && !d.hasCode && d.drift === 0)
73
96
  return 'pending';
@@ -228,6 +251,13 @@ export function specContent(id) {
228
251
  const r = raws().find((x) => x.id === id);
229
252
  return r ? { body: r.body.trim(), parts: parseParts(r.body) } : null;
230
253
  }
254
+ // Where a node LIVES, repo-relative — its own folder, not its spec.md. A node's folder is the unit (the
255
+ // same rule the plugin instances are built on), so anything that wants to see what a node carries besides
256
+ // its body asks the spec tree's own reader rather than re-deriving a path from an id.
257
+ export function specDir(id) {
258
+ const r = raws().find((x) => x.id === id);
259
+ return r ? r.relPath.replace(/\/spec\.md$/, '') : null;
260
+ }
231
261
  export async function loadSpecs(root = ROOT, options = {}) {
232
262
  // The default pair shares one immutable-event snapshot; explicit sides let callers skip or supply either
233
263
  // projection. Every node below is then a pure in-memory lookup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-core",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "type": "module",
5
5
  "description": "SpexCode's dependency-minimal spec graph core.",
6
6
  "files": [
@@ -17,6 +17,9 @@
17
17
  "engines": {
18
18
  "node": ">=22"
19
19
  },
20
+ "dependencies": {
21
+ "@vscode/tree-sitter-wasm": "0.3.1"
22
+ },
20
23
  "publishConfig": {
21
24
  "access": "public"
22
25
  },