@ngockhoale/ukit 2.2.7 → 2.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.8 - 2026-08-28
6
+
7
+ UKit already picked the right files to open, but the step *after* that — actually entering the
8
+ file — was unoptimised: the agent read the whole thing to find the one function it needed. This
9
+ release adds a signature outline so it can jump straight to a line instead.
10
+
11
+ The outline is a map for **locating** code, not a substitute for reading it. Any code that is
12
+ about to be changed must still be read; the saving comes from narrower reads, not skipped ones.
13
+ That rule is stated in the shipped `CLAUDE.md` / `AGENTS.md` guidance next to the feature itself.
14
+
15
+ ### Added
16
+
17
+ - **Indexed symbols now carry `line` and `signature`.** `query-index` and `resolve-context` print
18
+ an `outline:` block (`line: signature`) for the top suspect files, so an agent can go straight
19
+ to `Read(file, offset=<line>)`. On this repo, locating the top-3 suspects for a representative
20
+ query costs 2,082 bytes / 25 lines instead of 131,702 bytes / 4,064 lines of whole-file reads.
21
+ On by default; `--no-outline` turns it off. Capped at the top 3 files and 12 symbols each.
22
+ - **`getFileOutline()`** in the index core, returning line-sorted, name-deduped rows. Missing
23
+ file, missing artifact, or a stale schema all resolve to `[]`, so callers can render
24
+ unconditionally.
25
+ - **First behavioural parity test for the hand-maintained index mirror**
26
+ (`tests/index/indexCoreParity.test.js`). `templates/.claude/ukit/index/lib/index-core.mjs`
27
+ duplicates `src/index/*` by hand and was flagged in `MIRROR_PAIRS` but never actually compared;
28
+ drift could only be caught by reading both files. It is now asserted.
29
+ - **Both harnesses covered.** omp runs the same Node helpers and reads `AGENTS.md`, so the
30
+ guidance ships identically in `templates/CLAUDE.md` and `templates/AGENTS.md` (byte-identical
31
+ section bodies, enforced by `tests/consistency/contextDocsParity.test.js`). No separate omp
32
+ code path was needed.
33
+
34
+ ### Fixed
35
+
36
+ - **`.vue` line numbers would have been silently wrong.** `extractScriptContent` joined the
37
+ `<script>` blocks together, so a symbol in the second block reported a line number offset by the
38
+ whole `<template>` above it. Script regions are now masked in place, preserving byte offsets, so
39
+ reported lines match the file on disk.
40
+
41
+ ### Changed
42
+
43
+ - `INDEX_SCHEMA_VERSION` 6 → 7. Existing `.cache/index/` artifacts are invalidated and rebuilt
44
+ automatically on first use; no migration step and no user action.
45
+
5
46
  ## 2.2.7 - 2026-08-28
6
47
 
7
48
  Follow-up to 2.2.6: that release only made the wrong-runtime failure message *explain* the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.7",
3
+ "version": "2.2.8",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * outline-savings.mjs — Signature Outline Layer (S8 measurement).
4
+ *
5
+ * Reports actual whole-file vs outline byte/line counts for this repo's top-3 suspects on a
6
+ * representative query, so the CHANGELOG can quote a measured number instead of a guess.
7
+ *
8
+ * Usage:
9
+ * node scripts/bench/outline-savings.mjs ["<query>"] [--root <dir>] [--limit 3]
10
+ */
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ import { buildCodeIndex } from '../../src/index/buildIndex.js';
16
+ import { queryCodeIndex, getFileOutline } from '../../src/index/queryIndex.js';
17
+
18
+ const DEFAULT_QUERY = 'resolveContext';
19
+
20
+ async function main() {
21
+ const args = process.argv.slice(2);
22
+ const rootDir = readFlagValue(args, '--root') ?? process.cwd();
23
+ const limit = Number.parseInt(readFlagValue(args, '--limit') ?? '3', 10);
24
+ const query = collectPositionalArgs(args, ['--root', '--limit']) || DEFAULT_QUERY;
25
+
26
+ await buildCodeIndex({ rootDir });
27
+ const results = await queryCodeIndex({ rootDir, query, limit });
28
+
29
+ if (results.length === 0) {
30
+ console.log(`[bench:outline-savings] no matches for query "${query}"`);
31
+ return;
32
+ }
33
+
34
+ let wholeFileBytes = 0;
35
+ let wholeFileLines = 0;
36
+ let outlineBytes = 0;
37
+ let outlineLines = 0;
38
+
39
+ for (const item of results) {
40
+ const absolutePath = path.join(rootDir, item.filePath);
41
+ const content = await fs.readFile(absolutePath, 'utf8');
42
+ wholeFileBytes += Buffer.byteLength(content, 'utf8');
43
+ wholeFileLines += content.split('\n').length;
44
+
45
+ const outline = await getFileOutline({ rootDir, filePath: item.filePath, limit: Infinity });
46
+ const rendered = renderOutline(outline);
47
+ outlineBytes += Buffer.byteLength(rendered, 'utf8');
48
+ outlineLines += rendered.length === 0 ? 0 : rendered.split('\n').length;
49
+ }
50
+
51
+ console.log(`[bench:outline-savings] query: "${query}", top-${results.length} suspects: ${results.map((r) => r.filePath).join(', ')}`);
52
+ console.log(`whole-file read of top-${results.length} suspects : ${wholeFileBytes} bytes / ${wholeFileLines} lines`);
53
+ console.log(`outline of top-${results.length} suspects : ${outlineBytes} bytes / ${outlineLines} lines`);
54
+ }
55
+
56
+ function renderOutline(outline) {
57
+ if (outline.length === 0) {
58
+ return '';
59
+ }
60
+ const labels = outline.map((item) => String(item.line));
61
+ const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
62
+ const lines = [' outline:'];
63
+ for (const item of outline) {
64
+ lines.push(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
65
+ }
66
+ return lines.join('\n');
67
+ }
68
+
69
+ function readFlagValue(argv, flag) {
70
+ const exact = argv.indexOf(flag);
71
+ if (exact >= 0 && argv[exact + 1]) return argv[exact + 1];
72
+ const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
73
+ return withEquals ? withEquals.slice(flag.length + 1) : null;
74
+ }
75
+
76
+ function collectPositionalArgs(argv, flagsWithValues = []) {
77
+ return argv
78
+ .filter((arg, index) => !isFlagOrValue(argv, index, flagsWithValues))
79
+ .join(' ')
80
+ .trim();
81
+ }
82
+
83
+ function isFlagOrValue(argv, index, flagsWithValues = []) {
84
+ const arg = argv[index];
85
+ if (!arg.startsWith('--')) {
86
+ const prev = argv[index - 1];
87
+ return Boolean(prev && flagsWithValues.includes(prev));
88
+ }
89
+ return true;
90
+ }
91
+
92
+ await main();
@@ -1,8 +1,12 @@
1
- const QUERY_FLAG_DEFINITIONS = new Map([['--limit', { requiresValue: true }]]);
1
+ const QUERY_FLAG_DEFINITIONS = new Map([
2
+ ['--limit', { requiresValue: true }],
3
+ ['--no-outline', { requiresValue: false }],
4
+ ]);
2
5
  const TRIAGE_FLAG_DEFINITIONS = new Map();
3
6
  const CONTEXT_FLAG_DEFINITIONS = new Map([
4
7
  ['--target', { requiresValue: true }],
5
8
  ['--type', { requiresValue: true }],
9
+ ['--no-outline', { requiresValue: false }],
6
10
  ]);
7
11
  const VERIFY_FLAG_DEFINITIONS = new Map([
8
12
  ['--target', { requiresValue: true }],
@@ -7,7 +7,7 @@ import {
7
7
  import { resolveContext } from '../../index/resolveContext.js';
8
8
  import { deriveVerificationPlan } from '../../index/verificationPlan.js';
9
9
  import { deriveTaskRoute } from '../../index/taskRouting.js';
10
- import { queryCodeIndex } from '../../index/queryIndex.js';
10
+ import { queryCodeIndex, getFileOutline } from '../../index/queryIndex.js';
11
11
  import { triageBug } from '../../bug/triageBug.js';
12
12
  import { installIndexRefreshHooks, removeIndexRefreshHooks } from '../../index/gitHooks.js';
13
13
  import {
@@ -25,6 +25,8 @@ const CONTEXT_REASON_LIMIT = 2;
25
25
  const VERIFY_COMMAND_DISPLAY_LIMIT = 2;
26
26
  const VERIFY_REASON_DISPLAY_LIMIT = 2;
27
27
  const VERIFY_NOTE_DISPLAY_LIMIT = 1;
28
+ const OUTLINE_TOP_FILES_LIMIT = 3;
29
+ const OUTLINE_SYMBOLS_LIMIT = 12;
28
30
 
29
31
  export async function runIndexTools({ projectRoot, argv = [] }) {
30
32
  const [subcommandRaw, ...rest] = argv;
@@ -68,6 +70,7 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
68
70
  return;
69
71
  }
70
72
 
73
+ const showOutline = !flags.get('--no-outline');
71
74
  console.log(`[UKit] Index query top ${results.length} for: ${query}`);
72
75
  for (const [idx, item] of results.entries()) {
73
76
  console.log(`${idx + 1}. ${item.filePath} (score=${item.score})`);
@@ -77,6 +80,9 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
77
80
  if ((item.reasons ?? []).length > 0) {
78
81
  console.log(` reasons: ${item.reasons.join(', ')}`);
79
82
  }
83
+ if (showOutline && idx < OUTLINE_TOP_FILES_LIMIT) {
84
+ await printOutlineForFile(projectRoot, item.filePath);
85
+ }
80
86
  }
81
87
  return;
82
88
  }
@@ -118,10 +124,16 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
118
124
  taskType,
119
125
  });
120
126
 
127
+ const showOutline = !flags.get('--no-outline');
121
128
  console.log('[UKit] Suggested context:');
122
129
  console.log(`taskType: ${result.taskType}`);
123
130
  console.log(`budget: ${result.contextBudget.minFiles}-${result.contextBudget.maxFiles} files`);
124
131
  printContextGroup('primaryTargets', result.primaryTargets, result.explanations?.primaryTargets);
132
+ if (showOutline) {
133
+ for (const filePath of (result.primaryTargets ?? []).slice(0, OUTLINE_TOP_FILES_LIMIT)) {
134
+ await printOutlineForFile(projectRoot, filePath);
135
+ }
136
+ }
125
137
  printContextGroup('sharedAbstractions', result.sharedAbstractions, result.explanations?.sharedAbstractions);
126
138
  printContextGroup('analogFiles', result.analogFiles, result.explanations?.analogFiles);
127
139
  printContextGroup('relatedTests', result.relatedTests, result.explanations?.relatedTests);
@@ -286,6 +298,29 @@ async function refreshIndexIfStale(rootDir) {
286
298
  return true;
287
299
  }
288
300
 
301
+ async function printOutlineForFile(rootDir, filePath) {
302
+ const outline = await getFileOutline({ rootDir, filePath, limit: Infinity });
303
+ if (outline.length === 0) {
304
+ return;
305
+ }
306
+
307
+ const shown = outline.slice(0, OUTLINE_SYMBOLS_LIMIT);
308
+ const remaining = outline.length - shown.length;
309
+ const labels = shown.map((item) => String(item.line));
310
+ if (remaining > 0) {
311
+ labels.push(`+${remaining}`);
312
+ }
313
+ const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
314
+
315
+ console.log(' outline:');
316
+ for (const item of shown) {
317
+ console.log(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
318
+ }
319
+ if (remaining > 0) {
320
+ console.log(` ${`+${remaining}`.padStart(labelWidth)} more symbols`);
321
+ }
322
+ }
323
+
289
324
  function printContextGroup(label, filePaths = [], explanations = []) {
290
325
  console.log(`${label}: ${formatCompactDisplayList(filePaths)}`);
291
326
 
@@ -607,36 +607,117 @@ function findScriptClose(content, startIndex) {
607
607
  return -1;
608
608
  }
609
609
 
610
- function extractScriptContent(filePath, content) {
610
+ export function extractScriptContent(filePath, content) {
611
611
  if (!filePath.endsWith('.vue')) {
612
612
  return content;
613
613
  }
614
614
 
615
- const scripts = [];
615
+ // Mask instead of extract: the returned string is the same length as `content`,
616
+ // with every character outside a <script> body replaced by a space (newlines kept).
617
+ // That keeps offsets mapped 1:1 to the original file, so line numbers computed
618
+ // against this string are correct for the file on disk. Extracting and `join('\n')`ing
619
+ // the bodies (the old approach) destroys that positional relationship.
620
+ const masked = content.replace(/[^\n]/g, ' ').split('');
621
+ let foundAny = false;
616
622
  const startTagPattern = /<script[^>]*>/gi;
617
623
  for (const match of content.matchAll(startTagPattern)) {
618
624
  const bodyStart = match.index + match[0].length;
619
625
  const bodyEnd = findScriptClose(content, bodyStart);
620
626
  if (bodyEnd !== -1) {
621
- scripts.push(content.slice(bodyStart, bodyEnd));
627
+ foundAny = true;
628
+ for (let i = bodyStart; i < bodyEnd; i += 1) {
629
+ masked[i] = content[i];
630
+ }
622
631
  }
623
632
  }
624
- if (scripts.length === 0) return '';
625
- return scripts.join('\n');
633
+ if (!foundAny) return '';
634
+ return masked.join('');
626
635
  }
627
636
 
628
637
 
629
- function extractSymbols(filePath, content) {
638
+ // Precompute the start offset of every line once per file, so `line` for a match
639
+ // can be found with a binary search instead of `content.slice(0, i).split('\n').length`
640
+ // (which is O(n) per symbol, O(n^2) across a large file).
641
+ function computeLineStarts(content) {
642
+ const starts = [0];
643
+ for (let index = 0; index < content.length; index += 1) {
644
+ if (content[index] === '\n') {
645
+ starts.push(index + 1);
646
+ }
647
+ }
648
+ return starts;
649
+ }
650
+
651
+ function lineNumberForIndex(lineStarts, index) {
652
+ let low = 0;
653
+ let high = lineStarts.length - 1;
654
+ while (low < high) {
655
+ const mid = Math.ceil((low + high) / 2);
656
+ if (lineStarts[mid] <= index) {
657
+ low = mid;
658
+ } else {
659
+ high = mid - 1;
660
+ }
661
+ }
662
+ return low + 1;
663
+ }
664
+
665
+ // Display string only, not a parse: scan forward from the start of the matched line to the
666
+ // `)` that closes the parameter list, or to end-of-line when the line opens no paren. Depth
667
+ // is tracked rather than stopping at the first `)`, otherwise a default-valued parameter
668
+ // truncates mid-expression (`function f({ root = process.cwd() } = {})` would cut at the
669
+ // `)` of `process.cwd()`). The scan is capped at 400 chars so a malformed file cannot walk
670
+ // the whole buffer.
671
+ function computeSignature(content, lineStartIndex) {
672
+ const boundedEnd = Math.min(content.length, lineStartIndex + 400);
673
+ let sliceEnd = boundedEnd;
674
+ let depth = 0;
675
+ let sawOpenParen = false;
676
+ for (let index = lineStartIndex; index < boundedEnd; index += 1) {
677
+ const char = content[index];
678
+ if (char === '(') {
679
+ depth += 1;
680
+ sawOpenParen = true;
681
+ } else if (char === ')') {
682
+ depth -= 1;
683
+ if (sawOpenParen && depth <= 0) {
684
+ sliceEnd = index + 1;
685
+ break;
686
+ }
687
+ } else if (char === '\n' && !sawOpenParen) {
688
+ sliceEnd = index;
689
+ break;
690
+ }
691
+ }
692
+
693
+ // Keep a trailing `=>` so an arrow function stays distinguishable from an ordinary
694
+ // parenthesised expression. getFileOutline relies on that to filter outline noise.
695
+ const arrowMatch = /^\s*=>/.exec(content.slice(sliceEnd, Math.min(content.length, sliceEnd + 8)));
696
+ if (arrowMatch) {
697
+ sliceEnd += arrowMatch[0].length;
698
+ }
699
+
700
+ const collapsed = content.slice(lineStartIndex, sliceEnd).replace(/\s+/g, ' ').trim();
701
+ if (collapsed.length <= 200) {
702
+ return collapsed;
703
+ }
704
+ return `${collapsed.slice(0, 199)}…`;
705
+ }
706
+
707
+ export function extractSymbols(filePath, content) {
630
708
  const symbols = [];
631
- const addSymbol = (name, type) => {
709
+ const lineStarts = computeLineStarts(content);
710
+ const addSymbol = (name, type, matchIndex = null) => {
632
711
  if (!name) {
633
712
  return;
634
713
  }
635
- symbols.push({
636
- name,
637
- type,
638
- filePath,
639
- });
714
+ if (matchIndex === null) {
715
+ symbols.push({ name, type, filePath, line: null, signature: null });
716
+ return;
717
+ }
718
+ const line = lineNumberForIndex(lineStarts, matchIndex);
719
+ const signature = computeSignature(content, lineStarts[line - 1]);
720
+ symbols.push({ name, type, filePath, line, signature });
640
721
  };
641
722
  const addMatches = (regex, type, groupIndex = 1) => {
642
723
  for (const match of content.matchAll(regex)) {
@@ -644,7 +725,7 @@ function extractSymbols(filePath, content) {
644
725
  if (!symbolName) {
645
726
  continue;
646
727
  }
647
- addSymbol(symbolName, type);
728
+ addSymbol(symbolName, type, match.index);
648
729
  }
649
730
  };
650
731
 
@@ -671,7 +752,7 @@ function extractSymbols(filePath, content) {
671
752
  if (!aliasMatch) {
672
753
  continue;
673
754
  }
674
- addSymbol(aliasMatch[2] ?? aliasMatch[1], 'named-export');
755
+ addSymbol(aliasMatch[2] ?? aliasMatch[1], 'named-export', match.index);
675
756
  }
676
757
  }
677
758
 
@@ -13,7 +13,7 @@ export const INDEX_ARTIFACTS = {
13
13
  analogs: 'analogs.json',
14
14
  };
15
15
 
16
- export const INDEX_SCHEMA_VERSION = 6;
16
+ export const INDEX_SCHEMA_VERSION = 7;
17
17
 
18
18
  export function getIndexDir(rootDir) {
19
19
  return path.join(rootDir, '.cache', 'index');
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, isLikelyTestFilePath } from './paths.js';
4
+ import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath } from './paths.js';
5
5
  import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
6
6
  import { buildSearchDescriptor } from './languageTools.js';
7
7
 
@@ -130,6 +130,62 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
130
130
  return queryPromise;
131
131
  }
132
132
 
133
+ // The `callable` symbol regex matches any `const x = (`, so it also captures plain
134
+ // parenthesised expressions such as `const rows = (items ?? [])`. Harmless for fuzzy
135
+ // ranking, misleading in an outline that is read as a map of the file's API. Those rows
136
+ // stay in symbols.json and are dropped here instead.
137
+ function isFunctionLikeSignature(signature) {
138
+ const normalized = String(signature ?? '');
139
+ // computeSignature only appends `=>` directly after a balanced parameter list, so a
140
+ // trailing arrow is a reliable marker and body text that merely contains one is not.
141
+ if (normalized.endsWith('=>')) {
142
+ return true;
143
+ }
144
+ // `function`/`class` must belong to the declaration head, not to body text the bounded
145
+ // scan happened to include.
146
+ return /\b(?:function|class)\b/.test(normalized.split('(')[0]);
147
+ }
148
+
149
+ // Reads symbols.json through the existing readArtifact cache (no new file reads, no new
150
+ // cache). Never throws: an absent file, a missing artifact, or a stale schema all resolve
151
+ // to [] so callers can render an outline block unconditionally.
152
+ export async function getFileOutline({ rootDir = process.cwd(), filePath, limit = 12 } = {}) {
153
+ if (!filePath) {
154
+ return Object.freeze([]);
155
+ }
156
+
157
+ const absoluteRoot = path.resolve(rootDir);
158
+ const symbolsArtifact = await readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols);
159
+ const safeSymbolsArtifact = ensureArtifact(symbolsArtifact);
160
+ if (safeSymbolsArtifact.schemaVersion !== INDEX_SCHEMA_VERSION) {
161
+ return Object.freeze([]);
162
+ }
163
+
164
+ const rows = (safeSymbolsArtifact.items ?? [])
165
+ .filter((item) => item?.filePath === filePath && item?.line !== null && item?.line !== undefined)
166
+ .filter((item) => item?.type !== 'callable' || isFunctionLikeSignature(item?.signature))
167
+ .sort((a, b) => a.line - b.line);
168
+
169
+ const seenNames = new Set();
170
+ const deduped = [];
171
+ for (const item of rows) {
172
+ if (seenNames.has(item.name)) {
173
+ continue;
174
+ }
175
+ seenNames.add(item.name);
176
+ deduped.push(item);
177
+ }
178
+
179
+ return Object.freeze(
180
+ deduped.slice(0, limit).map((item) => Object.freeze({
181
+ line: item.line,
182
+ name: item.name,
183
+ type: item.type,
184
+ signature: item.signature ?? null,
185
+ })),
186
+ );
187
+ }
188
+
133
189
  async function readArtifact(rootDir, artifactName) {
134
190
  const artifactPath = getArtifactPath(path.resolve(rootDir), artifactName);
135
191
 
@@ -28,7 +28,7 @@ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '
28
28
  const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
29
29
  const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
30
30
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
31
- const INDEX_SCHEMA_VERSION = 6;
31
+ export const INDEX_SCHEMA_VERSION = 7;
32
32
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
33
33
  const INDEX_PARSE_BATCH_SIZE = 8;
34
34
  const MAX_IMPORTER_HOPS = 2;
@@ -395,6 +395,62 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
395
395
 
396
396
  // ── Query Index ──
397
397
 
398
+ // The `callable` symbol regex matches any `const x = (`, so it also captures plain
399
+ // parenthesised expressions such as `const rows = (items ?? [])`. Harmless for fuzzy
400
+ // ranking, misleading in an outline that is read as a map of the file's API. Those rows
401
+ // stay in symbols.json and are dropped here instead.
402
+ function isFunctionLikeSignature(signature) {
403
+ const normalized = String(signature ?? '');
404
+ // computeSignature only appends `=>` directly after a balanced parameter list, so a
405
+ // trailing arrow is a reliable marker and body text that merely contains one is not.
406
+ if (normalized.endsWith('=>')) {
407
+ return true;
408
+ }
409
+ // `function`/`class` must belong to the declaration head, not to body text the bounded
410
+ // scan happened to include.
411
+ return /\b(?:function|class)\b/.test(normalized.split('(')[0]);
412
+ }
413
+
414
+ // Reads symbols.json through the existing readArtifact cache (no new file reads, no new
415
+ // cache). Never throws: an absent file, a missing artifact, or a stale schema all resolve
416
+ // to [] so callers can render an outline block unconditionally.
417
+ export async function getFileOutline({ rootDir = process.cwd(), filePath, limit = 12 } = {}) {
418
+ if (!filePath) {
419
+ return Object.freeze([]);
420
+ }
421
+
422
+ const absoluteRoot = path.resolve(rootDir);
423
+ const symbolsArtifact = await readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols);
424
+ const safeSymbolsArtifact = ensureArtifact(symbolsArtifact);
425
+ if (safeSymbolsArtifact.schemaVersion !== INDEX_SCHEMA_VERSION) {
426
+ return Object.freeze([]);
427
+ }
428
+
429
+ const rows = (safeSymbolsArtifact.items ?? [])
430
+ .filter((item) => item?.filePath === filePath && item?.line !== null && item?.line !== undefined)
431
+ .filter((item) => item?.type !== 'callable' || isFunctionLikeSignature(item?.signature))
432
+ .sort((a, b) => a.line - b.line);
433
+
434
+ const seenNames = new Set();
435
+ const deduped = [];
436
+ for (const item of rows) {
437
+ if (seenNames.has(item.name)) {
438
+ continue;
439
+ }
440
+ seenNames.add(item.name);
441
+ deduped.push(item);
442
+ }
443
+
444
+ return Object.freeze(
445
+ deduped.slice(0, limit).map((item) => Object.freeze({
446
+ line: item.line,
447
+ name: item.name,
448
+ type: item.type,
449
+ signature: item.signature ?? null,
450
+ })),
451
+ );
452
+ }
453
+
398
454
  export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5 } = {}) {
399
455
  if (!query || !query.trim()) return [];
400
456
  if (limit <= 0) return Object.freeze([]);
@@ -1588,24 +1644,169 @@ async function collectFiles(scanRoots) {
1588
1644
  return result;
1589
1645
  }
1590
1646
 
1591
- function extractScriptContent(filePath, content) {
1592
- if (!filePath.endsWith('.vue')) return content;
1593
- const allMatches = [...content.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)];
1594
- if (allMatches.length === 0) return '';
1595
- return allMatches.map((m) => m[1]).join('\n');
1647
+ function findScriptClose(content, startIndex) {
1648
+ const lower = content.toLowerCase();
1649
+ let state = 'normal';
1650
+ let escaped = false;
1651
+ let templateExpressionDepth = 0;
1652
+
1653
+ for (let index = startIndex; index < content.length; index += 1) {
1654
+ const char = content[index];
1655
+ const next = content[index + 1];
1656
+
1657
+ if (state === 'line-comment') {
1658
+ if (char === '\n') state = 'normal';
1659
+ continue;
1660
+ }
1661
+ if (state === 'block-comment') {
1662
+ if (char === '*' && next === '/') {
1663
+ index += 1;
1664
+ state = 'normal';
1665
+ }
1666
+ continue;
1667
+ }
1668
+ if (state === 'single' || state === 'double' || state === 'template') {
1669
+ if (escaped) {
1670
+ escaped = false;
1671
+ continue;
1672
+ }
1673
+ if (char === '\\') {
1674
+ escaped = true;
1675
+ continue;
1676
+ }
1677
+ if (state === 'single' && char === "'") state = 'normal';
1678
+ else if (state === 'double' && char === '"') state = 'normal';
1679
+ else if (state === 'template') {
1680
+ if (char === '`' && templateExpressionDepth === 0) state = 'normal';
1681
+ else if (char === '{' && content[index - 1] === '$') templateExpressionDepth += 1;
1682
+ else if (char === '}' && templateExpressionDepth > 0) templateExpressionDepth -= 1;
1683
+ }
1684
+ continue;
1685
+ }
1686
+
1687
+ if (lower.startsWith('</script>', index)) return index;
1688
+ if (char === '/' && next === '/') {
1689
+ state = 'line-comment';
1690
+ index += 1;
1691
+ } else if (char === '/' && next === '*') {
1692
+ state = 'block-comment';
1693
+ index += 1;
1694
+ } else if (char === "'") state = 'single';
1695
+ else if (char === '"') state = 'double';
1696
+ else if (char === '`') state = 'template';
1697
+ }
1698
+
1699
+ return -1;
1596
1700
  }
1597
1701
 
1598
- function extractSymbols(filePath, content) {
1702
+ export function extractScriptContent(filePath, content) {
1703
+ if (!filePath.endsWith('.vue')) {
1704
+ return content;
1705
+ }
1706
+
1707
+ // Mask instead of extract: same length as `content`, non-script chars replaced by a
1708
+ // space (newlines kept), so offsets stay 1:1 with the file on disk. See buildIndex.js.
1709
+ const masked = content.replace(/[^\n]/g, ' ').split('');
1710
+ let foundAny = false;
1711
+ const startTagPattern = /<script[^>]*>/gi;
1712
+ for (const match of content.matchAll(startTagPattern)) {
1713
+ const bodyStart = match.index + match[0].length;
1714
+ const bodyEnd = findScriptClose(content, bodyStart);
1715
+ if (bodyEnd !== -1) {
1716
+ foundAny = true;
1717
+ for (let i = bodyStart; i < bodyEnd; i += 1) {
1718
+ masked[i] = content[i];
1719
+ }
1720
+ }
1721
+ }
1722
+ if (!foundAny) return '';
1723
+ return masked.join('');
1724
+ }
1725
+
1726
+ function computeLineStarts(content) {
1727
+ const starts = [0];
1728
+ for (let index = 0; index < content.length; index += 1) {
1729
+ if (content[index] === '\n') {
1730
+ starts.push(index + 1);
1731
+ }
1732
+ }
1733
+ return starts;
1734
+ }
1735
+
1736
+ function lineNumberForIndex(lineStarts, index) {
1737
+ let low = 0;
1738
+ let high = lineStarts.length - 1;
1739
+ while (low < high) {
1740
+ const mid = Math.ceil((low + high) / 2);
1741
+ if (lineStarts[mid] <= index) {
1742
+ low = mid;
1743
+ } else {
1744
+ high = mid - 1;
1745
+ }
1746
+ }
1747
+ return low + 1;
1748
+ }
1749
+
1750
+ // Display string only, not a parse: scan forward from the start of the matched line to the
1751
+ // `)` that closes the parameter list, or to end-of-line when the line opens no paren. Depth
1752
+ // is tracked rather than stopping at the first `)`, otherwise a default-valued parameter
1753
+ // truncates mid-expression (`function f({ root = process.cwd() } = {})` would cut at the
1754
+ // `)` of `process.cwd()`). The scan is capped at 400 chars so a malformed file cannot walk
1755
+ // the whole buffer.
1756
+ function computeSignature(content, lineStartIndex) {
1757
+ const boundedEnd = Math.min(content.length, lineStartIndex + 400);
1758
+ let sliceEnd = boundedEnd;
1759
+ let depth = 0;
1760
+ let sawOpenParen = false;
1761
+ for (let index = lineStartIndex; index < boundedEnd; index += 1) {
1762
+ const char = content[index];
1763
+ if (char === '(') {
1764
+ depth += 1;
1765
+ sawOpenParen = true;
1766
+ } else if (char === ')') {
1767
+ depth -= 1;
1768
+ if (sawOpenParen && depth <= 0) {
1769
+ sliceEnd = index + 1;
1770
+ break;
1771
+ }
1772
+ } else if (char === '\n' && !sawOpenParen) {
1773
+ sliceEnd = index;
1774
+ break;
1775
+ }
1776
+ }
1777
+
1778
+ // Keep a trailing `=>` so an arrow function stays distinguishable from an ordinary
1779
+ // parenthesised expression. getFileOutline relies on that to filter outline noise.
1780
+ const arrowMatch = /^\s*=>/.exec(content.slice(sliceEnd, Math.min(content.length, sliceEnd + 8)));
1781
+ if (arrowMatch) {
1782
+ sliceEnd += arrowMatch[0].length;
1783
+ }
1784
+
1785
+ const collapsed = content.slice(lineStartIndex, sliceEnd).replace(/\s+/g, ' ').trim();
1786
+ if (collapsed.length <= 200) {
1787
+ return collapsed;
1788
+ }
1789
+ return `${collapsed.slice(0, 199)}…`;
1790
+ }
1791
+
1792
+ export function extractSymbols(filePath, content) {
1599
1793
  const out = [];
1600
- const addSymbol = (name, type) => {
1794
+ const lineStarts = computeLineStarts(content);
1795
+ const addSymbol = (name, type, matchIndex = null) => {
1601
1796
  if (!name) return;
1602
- out.push({ name, type, filePath });
1797
+ if (matchIndex === null) {
1798
+ out.push({ name, type, filePath, line: null, signature: null });
1799
+ return;
1800
+ }
1801
+ const line = lineNumberForIndex(lineStarts, matchIndex);
1802
+ const signature = computeSignature(content, lineStarts[line - 1]);
1803
+ out.push({ name, type, filePath, line, signature });
1603
1804
  };
1604
1805
  const addMatches = (regex, type, idx = 1) => {
1605
1806
  for (const match of content.matchAll(regex)) {
1606
1807
  const name = match[idx];
1607
1808
  if (!name) continue;
1608
- addSymbol(name, type);
1809
+ addSymbol(name, type, match.index);
1609
1810
  }
1610
1811
  };
1611
1812
 
@@ -1628,7 +1829,7 @@ function extractSymbols(filePath, content) {
1628
1829
  for (const part of (match[1] ?? '').split(',').map((x) => x.trim()).filter(Boolean)) {
1629
1830
  const parsed = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
1630
1831
  if (!parsed) continue;
1631
- addSymbol(parsed[2] ?? parsed[1], 'named-export');
1832
+ addSymbol(parsed[2] ?? parsed[1], 'named-export', match.index);
1632
1833
  }
1633
1834
  }
1634
1835
 
@@ -11,14 +11,19 @@ import {
11
11
  const {
12
12
  queryCodeIndex,
13
13
  buildCodeIndex,
14
+ getFileOutline,
14
15
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
15
16
  getIndexArtifactGeneratedAt,
16
17
  } = indexCore;
17
18
 
19
+ const OUTLINE_TOP_FILES_LIMIT = 3;
20
+ const OUTLINE_SYMBOLS_LIMIT = 12;
21
+
18
22
  const args = process.argv.slice(2);
19
23
  const rootDir = getRootDir(args);
20
24
  const limitArg = readFlagValue(args, '--limit');
21
25
  const limit = Number.parseInt(limitArg ?? '5', 10);
26
+ const showOutline = !args.includes('--no-outline');
22
27
  const query = collectPositionalArgs(args, ['--root', '--limit']);
23
28
 
24
29
  if (!query) {
@@ -32,6 +37,7 @@ if (!query) {
32
37
  indexGeneratedAtMs,
33
38
  query,
34
39
  limit: effectiveLimit,
40
+ showOutline,
35
41
  });
36
42
  const cached = await readRecentCacheEntry(cachePath, requestKey, {
37
43
  maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
@@ -66,10 +72,39 @@ if (!query) {
66
72
  if ((item.reasons ?? []).length > 0) {
67
73
  console.log(` reasons: ${item.reasons.join(', ')}`);
68
74
  }
75
+ if (showOutline && idx < OUTLINE_TOP_FILES_LIMIT) {
76
+ await printOutlineForFile(rootDir, item.filePath);
77
+ }
69
78
  }
70
79
  }
71
80
  }
72
81
 
82
+ async function printOutlineForFile(root, filePath) {
83
+ if (typeof getFileOutline !== 'function') {
84
+ return;
85
+ }
86
+ const outline = await getFileOutline({ rootDir: root, filePath, limit: Infinity });
87
+ if (outline.length === 0) {
88
+ return;
89
+ }
90
+
91
+ const shown = outline.slice(0, OUTLINE_SYMBOLS_LIMIT);
92
+ const remaining = outline.length - shown.length;
93
+ const labels = shown.map((item) => String(item.line));
94
+ if (remaining > 0) {
95
+ labels.push(`+${remaining}`);
96
+ }
97
+ const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
98
+
99
+ console.log(' outline:');
100
+ for (const item of shown) {
101
+ console.log(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
102
+ }
103
+ if (remaining > 0) {
104
+ console.log(` ${`+${remaining}`.padStart(labelWidth)} more symbols`);
105
+ }
106
+ }
107
+
73
108
  async function ensureFreshIndex({ rootDir, logPrefix }) {
74
109
  const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
75
110
  const stale = lastRefreshMs === null
@@ -102,11 +137,13 @@ function buildQueryRequestKey({
102
137
  indexGeneratedAtMs = null,
103
138
  query = '',
104
139
  limit = 5,
140
+ showOutline = true,
105
141
  } = {}) {
106
- return buildCompactMachineKey('query-v1', {
142
+ return buildCompactMachineKey('query-v2', {
107
143
  indexGeneratedAtMs,
108
144
  query: String(query || '').trim(),
109
145
  limit: Number(limit),
146
+ showOutline: Boolean(showOutline),
110
147
  });
111
148
  }
112
149
 
@@ -11,16 +11,20 @@ import {
11
11
  const {
12
12
  resolveContext,
13
13
  buildCodeIndex,
14
+ getFileOutline,
14
15
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
15
16
  getIndexArtifactGeneratedAt,
16
17
  } = indexCore;
17
18
  const CONTEXT_DISPLAY_LIMIT = 2;
18
19
  const CONTEXT_REASON_LIMIT = 2;
20
+ const OUTLINE_TOP_FILES_LIMIT = 3;
21
+ const OUTLINE_SYMBOLS_LIMIT = 12;
19
22
 
20
23
  const args = process.argv.slice(2);
21
24
  const rootDir = getRootDir(args);
22
25
  const targetFile = readFlagValue(args, '--target');
23
26
  const taskType = readFlagValue(args, '--type');
27
+ const showOutline = !args.includes('--no-outline');
24
28
  const intent = collectPositionalArgs(args, ['--root', '--target', '--type']);
25
29
  const effectiveIntent = deriveHelperIntent({ intent, targetFile });
26
30
 
@@ -57,7 +61,7 @@ if (!intent && !targetFile) {
57
61
  });
58
62
  }
59
63
 
60
- printContextResult(result);
64
+ await printContextResult(result, { rootDir, showOutline });
61
65
  }
62
66
 
63
67
  async function ensureFreshIndex({ rootDir, logPrefix }) {
@@ -217,17 +221,48 @@ function unique(values = []) {
217
221
  return [...new Set(values.filter(Boolean))];
218
222
  }
219
223
 
220
- function printContextResult(result) {
224
+ async function printContextResult(result, { rootDir, showOutline } = {}) {
221
225
  console.log('[ukit:context]');
222
226
  console.log(`taskType: ${result.taskType}`);
223
227
  console.log(`budget: ${result.contextBudget.minFiles}-${result.contextBudget.maxFiles} files`);
224
228
  printContextGroup('primaryTargets', result.primaryTargets, result.explanations?.primaryTargets);
229
+ if (showOutline) {
230
+ for (const filePath of (result.primaryTargets ?? []).slice(0, OUTLINE_TOP_FILES_LIMIT)) {
231
+ await printOutlineForFile(rootDir, filePath);
232
+ }
233
+ }
225
234
  printContextGroup('sharedAbstractions', result.sharedAbstractions, result.explanations?.sharedAbstractions);
226
235
  printContextGroup('analogFiles', result.analogFiles, result.explanations?.analogFiles);
227
236
  printContextGroup('relatedTests', result.relatedTests, result.explanations?.relatedTests);
228
237
  printContextGroup('styleFiles', result.styleFiles, result.explanations?.styleFiles);
229
238
  }
230
239
 
240
+ async function printOutlineForFile(root, filePath) {
241
+ if (typeof getFileOutline !== 'function') {
242
+ return;
243
+ }
244
+ const outline = await getFileOutline({ rootDir: root, filePath, limit: Infinity });
245
+ if (outline.length === 0) {
246
+ return;
247
+ }
248
+
249
+ const shown = outline.slice(0, OUTLINE_SYMBOLS_LIMIT);
250
+ const remaining = outline.length - shown.length;
251
+ const labels = shown.map((item) => String(item.line));
252
+ if (remaining > 0) {
253
+ labels.push(`+${remaining}`);
254
+ }
255
+ const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
256
+
257
+ console.log(' outline:');
258
+ for (const item of shown) {
259
+ console.log(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
260
+ }
261
+ if (remaining > 0) {
262
+ console.log(` ${`+${remaining}`.padStart(labelWidth)} more symbols`);
263
+ }
264
+ }
265
+
231
266
  function printContextGroup(label, filePaths = [], explanations = []) {
232
267
  console.log(`${label}: ${formatCompactDisplayList(filePaths)}`);
233
268
 
@@ -37,6 +37,11 @@ For any task that needs code context:
37
37
  3. For bug signatures:
38
38
  - `node .claude/ukit/index/triage.mjs "<error signature>"`
39
39
  4. Open only the **top 1-3 suspect files first**, then widen if needed.
40
+ `query-index` and `resolve-context` print an `outline:` block (`line: signature`) for the
41
+ top suspects. Use it to jump straight to the relevant region with
42
+ `Read(file, offset=<line>)` instead of reading the whole file.
43
+ The outline locates code; it does not describe behaviour. **Any code you are about to
44
+ change must still be read.**
40
45
  5. For analog/reuse patterns, check if `resolve-context` returns related existing patterns.
41
46
 
42
47
  For clearly non-code specialist lanes (docs-only, status, task queue), skip the source-code index.
@@ -37,6 +37,11 @@ For any task that needs code context:
37
37
  3. For bug signatures:
38
38
  - `node .claude/ukit/index/triage.mjs "<error signature>"`
39
39
  4. Open only the **top 1-3 suspect files first**, then widen if needed.
40
+ `query-index` and `resolve-context` print an `outline:` block (`line: signature`) for the
41
+ top suspects. Use it to jump straight to the relevant region with
42
+ `Read(file, offset=<line>)` instead of reading the whole file.
43
+ The outline locates code; it does not describe behaviour. **Any code you are about to
44
+ change must still be read.**
40
45
  5. For analog/reuse patterns, check if `resolve-context` returns related existing patterns.
41
46
 
42
47
  For clearly non-code specialist lanes (docs-only, status, task queue), skip the source-code index.