ts-axi 0.1.11 → 0.1.13

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.
@@ -3,7 +3,7 @@ import { sourceFs } from "../fs.js";
3
3
  import { basename, dirname, join, relative, sep } from "node:path";
4
4
  import ts from "typescript";
5
5
  import { bool, flag, intFlag, parseArgs, parseFields } from "../args.js";
6
- import { changedRanges, parseUnifiedDiff } from "../diff.js";
6
+ import { changedRanges, headRevOfRange, parseUnifiedDiff } from "../diff.js";
7
7
  import { AxiError, usageError } from "../errors.js";
8
8
  import { diffNameStatus, diffUnified, isGitRepo, resolveBaseRev, showFile, untrackedFiles } from "../git.js";
9
9
  import { allocateBudget, classifyFile, compareChanges, formatRanges, inRanges, localOnlyLine, matchKey, reverseApply, wordMatches, } from "../impact.js";
@@ -25,8 +25,8 @@ input:
25
25
  (none) = working tree vs HEAD (staged + unstaged + untracked); --staged = index vs HEAD
26
26
  <git-range> = any git diff range (main...HEAD, abc123, v1..v2); - = unified diff on stdin (needs the after side checked out)
27
27
  analysis is of the current checkout - for a range whose head is not the working tree, check that head out first
28
- flags[8]:
29
- --staged, --via (adds References through an interface or base member to refs[n] with a via column), --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), --fields <kind,exported,refs_total,via,files,tests|all> (extra changed[n] columns), -P <tsconfig>, --json
28
+ flags[9]:
29
+ --staged, --size (adds size{files,hunks,lines_added,lines_removed,patch_chars,exported_signature_changes,removed_symbols} - hunks are counted at zero context for a git source, as given for -; untracked files count as added),--via (adds References through an interface or base member to refs[n] with a via column), --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), --fields <kind,exported,refs_total,via,files,tests|all> (extra changed[n] columns), -P <tsconfig>, --json
30
30
  output:
31
31
  base, head, changes (count), changed[n]{symbol,change,at,refs_outside} by default; --fields adds columns in place, --fields all gives {symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
32
32
  change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
@@ -41,10 +41,11 @@ output:
41
41
  skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
42
42
  tests: 0 on an exported signature or body change = no Test file references it (columns from --fields exported,tests)
43
43
  scope = in a monorepo without -P the program is the touched files + two levels of importers (scope_partial: true when capped)
44
- examples[5]:
44
+ examples[6]:
45
45
  ts-axi impact
46
46
  ts-axi impact --staged
47
47
  ts-axi impact main...HEAD
48
+ ts-axi impact main...HEAD --size
48
49
  ts-axi impact HEAD~3 --depth 2 --grep
49
50
  bb-axi pr diff 42 --raw | ts-axi impact -
50
51
  `;
@@ -56,14 +57,24 @@ const SPECS = [
56
57
  { name: "--grep", boolean: true },
57
58
  { name: "--test-glob", repeatable: true },
58
59
  { name: "--fields" },
60
+ { name: "--size", boolean: true },
59
61
  { name: "-P" },
60
62
  ];
61
63
  // Opt-in columns of changed[n]; the default keeps symbol, change, at, refs_outside.
62
64
  const IMPACT_FIELDS = ["kind", "exported", "refs_total", "via", "files", "tests"];
63
65
  const OPT_IN = new Set(IMPACT_FIELDS);
64
- async function readStdin() {
66
+ function diffSize(parsed, text) {
67
+ return {
68
+ files: parsed.length,
69
+ hunks: parsed.reduce((n, f) => n + f.hunks.length, 0),
70
+ lines_added: parsed.reduce((n, f) => n + f.hunks.reduce((m, h) => m + h.added.length, 0), 0),
71
+ lines_removed: parsed.reduce((n, f) => n + f.hunks.reduce((m, h) => m + h.removed.length, 0), 0),
72
+ patch_chars: text.length,
73
+ };
74
+ }
75
+ export async function readStdin(command = "impact", example = "git diff -U0 | ts-axi impact -") {
65
76
  if (process.stdin.isTTY) {
66
- throw usageError("`impact -` reads a unified diff from stdin and nothing was piped", ["git diff -U0 | ts-axi impact -"]);
77
+ throw usageError(`\`${command} -\` reads a unified diff from stdin and nothing was piped`, [example]);
67
78
  }
68
79
  const chunks = [];
69
80
  for await (const chunk of process.stdin)
@@ -79,16 +90,6 @@ function readText(abs) {
79
90
  return null;
80
91
  }
81
92
  }
82
- /** Right side of a range; undefined when git diffs it against the working tree (`abc123`). */
83
- function headOfRange(range) {
84
- const triple = range.indexOf("...");
85
- if (triple > 0)
86
- return range.slice(triple + 3) || "HEAD";
87
- const double = range.indexOf("..");
88
- if (double > 0)
89
- return range.slice(double + 2) || "HEAD";
90
- return undefined;
91
- }
92
93
  async function gitInput(cwd, root, range, staged) {
93
94
  if (!(await isGitRepo(cwd))) {
94
95
  throw new AxiError("not a git repository - impact needs git or a diff on stdin", "GIT_ERROR", [
@@ -97,8 +98,9 @@ async function gitInput(cwd, root, range, staged) {
97
98
  }
98
99
  const [diffText, names] = await Promise.all([diffUnified(cwd, range, staged), diffNameStatus(cwd, range, staged)]);
99
100
  const parsed = parseUnifiedDiff(diffText);
101
+ const size = diffSize(parsed, diffText);
100
102
  const base = staged ? "HEAD" : await resolveBaseRev(cwd, range);
101
- const headRev = staged ? "" : range !== undefined ? headOfRange(range) : undefined;
103
+ const headRev = staged ? "" : range !== undefined ? headRevOfRange(range) : undefined;
102
104
  const files = [];
103
105
  for (const diff of parsed) {
104
106
  const { after, before } = changedRanges(diff);
@@ -113,16 +115,23 @@ async function gitInput(cwd, root, range, staged) {
113
115
  continue;
114
116
  const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
115
117
  files.push({ path, status: "added", after: lines > 0 ? [[1, lines]] : [], before: [], afterText: text, beforeText: null });
118
+ // Untracked files count as added, one hunk each; they are not in patch_chars.
119
+ size.files++;
120
+ size.hunks += lines > 0 ? 1 : 0;
121
+ size.lines_added += lines;
116
122
  }
117
123
  }
118
124
  const seen = new Set(parsed.map((d) => d.path));
125
+ const noText = names.filter((n) => !seen.has(n.path) && n.status !== "deleted").map((n) => n.path);
126
+ size.files += noText.length;
119
127
  return {
120
128
  base,
121
129
  head: staged ? "index" : headRev ?? "working tree",
122
130
  ...(headRev !== undefined ? { headRev } : {}),
123
131
  files,
124
- noText: names.filter((n) => !seen.has(n.path) && n.status !== "deleted").map((n) => n.path),
132
+ noText,
125
133
  missing: [],
134
+ size,
126
135
  };
127
136
  }
128
137
  function stdinInput(text, root) {
@@ -139,7 +148,7 @@ function stdinInput(text, root) {
139
148
  const beforeText = diff.status === "added" ? null : reverseApply(afterText ?? "", diff.hunks) ?? undefined;
140
149
  files.push({ path: diff.path, ...(diff.oldPath ? { oldPath: diff.oldPath } : {}), status: diff.status, after, before, afterText, beforeText });
141
150
  }
142
- return { base: "stdin diff", head: "working tree", files, noText: [], missing };
151
+ return { base: "stdin diff", head: "working tree", files, noText: [], missing, size: diffSize(parseUnifiedDiff(text, { binary: true }), text) };
143
152
  }
144
153
  function isSourcePath(path) {
145
154
  return SOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
@@ -270,6 +279,27 @@ export function textualMatches(opened, semantic, names, known, skipLines, change
270
279
  }
271
280
  return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
272
281
  }
282
+ /** Whole-word matches of `name` in the Project files, one row per line: the fallback when no Declaration matches. */
283
+ export function textualScan(opened, name) {
284
+ const rows = [];
285
+ sourceFs().prefetch(opened.index.files);
286
+ for (const abs of opened.index.files) {
287
+ const text = opened.index.text(abs);
288
+ if (text === undefined || !text.includes(name))
289
+ continue;
290
+ let last = 0;
291
+ for (const m of wordMatches(text, name)) {
292
+ if (m.line === last)
293
+ continue;
294
+ last = m.line;
295
+ rows.push({ file: opened.index.rel(abs), line: m.line, context: contextOf(lineOf(text, m.line), m.col) });
296
+ }
297
+ }
298
+ return rows.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
299
+ }
300
+ export function textualScanHint(label, matches) {
301
+ return `Run \`ts-axi refs ${label} --grep\` for ${matches} textual matches`;
302
+ }
273
303
  /** Whether a whole-word count of the name says anything: not for router-called or by-name-injected members (`injected`). */
274
304
  export function isTextualCandidate(decl) {
275
305
  return decl?.injected !== true;
@@ -297,9 +327,66 @@ export function isUntyped(opened, decl, file) {
297
327
  return false;
298
328
  return decl?.nameless === true || opened.index.project.implicit || (file !== undefined && /\.[mc]?jsx?$/.test(file));
299
329
  }
300
- /** A non-exported, non-member, identifier-named Declaration: only its own file can name it. */
301
- export function isPlainLocal(decl) {
302
- return decl !== undefined && !decl.exported && !decl.nameless && decl.parentName === undefined;
330
+ /** The identifier starting at `pos`. */
331
+ function identifierAt(node, pos, sf) {
332
+ if (ts.isIdentifier(node) && node.getStart(sf) === pos)
333
+ return node;
334
+ return ts.forEachChild(node, (child) => (child.pos <= pos && pos < child.end ? identifierAt(child, pos, sf) : undefined));
335
+ }
336
+ /** Names a CommonJS file hands out (`module.exports = { a, b: c }`, `exports.x = d`); undefined when not CommonJS. */
337
+ function commonJsExports(sf) {
338
+ const names = new Set();
339
+ let cjs = /\brequire\s*\(/.test(sf.text);
340
+ // Values only: identifiers used inside exported function bodies do not leave the file.
341
+ const collect = (expr) => {
342
+ let e = expr;
343
+ while (ts.isParenthesizedExpression(e))
344
+ e = e.expression;
345
+ if (ts.isIdentifier(e))
346
+ names.add(e.text);
347
+ else if (ts.isObjectLiteralExpression(e)) {
348
+ for (const p of e.properties) {
349
+ if (ts.isShorthandPropertyAssignment(p))
350
+ names.add(p.name.text);
351
+ else if (ts.isPropertyAssignment(p))
352
+ collect(p.initializer);
353
+ else if (ts.isSpreadAssignment(p))
354
+ collect(p.expression);
355
+ }
356
+ }
357
+ };
358
+ for (const st of sf.statements) {
359
+ if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression) || st.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
360
+ continue;
361
+ const left = st.expression.left.getText(sf).replace(/\s+/g, "");
362
+ if (!/^(module\.exports|exports)(\.|$)/.test(left))
363
+ continue;
364
+ cjs = true;
365
+ collect(st.expression.right);
366
+ }
367
+ return cjs ? names : undefined;
368
+ }
369
+ /**
370
+ * A Declaration only its own file can name: a parameter or function-scoped binding, or a non-exported top-level
371
+ * binding of an ES module, or of a CommonJS file whose exports never mention it. Members, object properties,
372
+ * string registrations and script globals are not.
373
+ */
374
+ export function isPlainLocal(index, decl) {
375
+ if (decl === undefined || decl.exported || decl.nameless || decl.parentName !== undefined)
376
+ return false;
377
+ const sf = index.sourceFile(index.abs(decl.file));
378
+ const id = sf && identifierAt(sf, decl.namePos, sf);
379
+ if (!sf || !id)
380
+ return false;
381
+ const owner = id.parent;
382
+ if (ts.isPropertyAssignment(owner) || ts.isShorthandPropertyAssignment(owner) || ts.isMethodDeclaration(owner) || ts.isPropertyAccessExpression(owner))
383
+ return false;
384
+ for (let n = owner.parent; !ts.isSourceFile(n); n = n.parent)
385
+ if (ts.isFunctionLike(n))
386
+ return true;
387
+ if (ts.isExternalModule(sf))
388
+ return true;
389
+ return commonJsExports(sf)?.has(decl.name) === false;
303
390
  }
304
391
  /**
305
392
  * Whole-word occurrences of `name` in Project files outside `changeSet`; lines declaring a `name` excluded.
@@ -392,7 +479,7 @@ function capped(key, rows) {
392
479
  };
393
480
  }
394
481
  /** Quotes a value for a pasteable command. */
395
- function shellWord(value) {
482
+ export function shellWord(value) {
396
483
  return /^[\w@%+=:,.\/-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
397
484
  }
398
485
  /** The same `impact` invocation with every given flag replayed, the one `extra` sets replaced by it. */
@@ -515,12 +602,22 @@ export async function impactCommand(args, ctx) {
515
602
  }
516
603
  }
517
604
  const total = declChanges.length + fileLevel.length;
605
+ const exportedOf = (c) => (c.after ?? c.before)?.exported ?? false;
518
606
  const header = {
519
607
  ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
520
608
  ...projectFields(opened),
521
609
  base: input.base,
522
610
  head: input.head,
523
611
  changes: total,
612
+ ...(bool(parsed, "--size")
613
+ ? {
614
+ size: {
615
+ ...input.size,
616
+ exported_signature_changes: declChanges.filter((c) => c.change === "signature" && exportedOf(c)).length,
617
+ removed_symbols: declChanges.filter((c) => c.change === "removed").length,
618
+ },
619
+ }
620
+ : {}),
524
621
  };
525
622
  const skipTemplates = (used) => {
526
623
  for (const f of templateInputs)
@@ -592,7 +689,7 @@ export async function impactCommand(args, ctx) {
592
689
  for (const row of rows) {
593
690
  if (!row.live || !isUntyped(opened, row.live, row.live.file))
594
691
  continue;
595
- const localTo = isPlainLocal(row.live) ? new Map([[row.live.name, opened.index.abs(row.live.file)]]) : undefined;
692
+ const localTo = isPlainLocal(opened.index, row.live) ? new Map([[row.live.name, opened.index.abs(row.live.file)]]) : undefined;
596
693
  const needle = textualNeedle(row.live);
597
694
  if (!needle)
598
695
  continue;
@@ -667,7 +764,7 @@ export async function impactCommand(args, ctx) {
667
764
  if (!textNames.has(needle.text))
668
765
  textNames.set(needle.text, needle);
669
766
  const abs = opened.index.abs(decl.file);
670
- if (!isPlainLocal(decl) || (localTo.has(name) && localTo.get(name) !== abs))
767
+ if (!isPlainLocal(opened.index, decl) || (localTo.has(name) && localTo.get(name) !== abs))
671
768
  wide.add(name);
672
769
  else
673
770
  localTo.set(name, abs);
@@ -1,7 +1,7 @@
1
1
  import { relative, sep } from "node:path";
2
2
  import { bool, expectPositionals, flag, intFlag, parseArgs } from "../args.js";
3
3
  import { importerClosure } from "../importers.js";
4
- import { isPlainLocal, isTextualCandidate, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches } from "./impact.js";
4
+ import { isPlainLocal, isTextualCandidate, isUntyped, textualCount, textualNeedle, textualHint, textualLine, textualMatches, textualScan, textualScanHint } from "./impact.js";
5
5
  import { findOutsideProject, nearestTsconfig } from "../project.js";
6
6
  import { viaLine } from "../references.js";
7
7
  import { block, helpBlock, out } from "../render.js";
@@ -10,7 +10,7 @@ import { loadTemplates, templatesLine, templateTarget, templateUsages } from "..
10
10
  import { byDepthThenPath } from "../walk.js";
11
11
  import { detectWorkspace, isMultiProject } from "../workspace.js";
12
12
  import { isTestFile } from "../settings.js";
13
- import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
13
+ import { isFunctionLike, kindFromScriptElement, parseTarget, registrationOf } from "../symbols.js";
14
14
  import { contextOf, declarationAt, filterSuffix, lineOf, MATCH_ROWS, matchRows, narrowHints, open, positionStartDir, projectFields, readFilters, useRef, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, } from "./common.js";
15
15
  const DEFAULT_LIMIT = 50;
16
16
  const MAX_LIMIT = 500;
@@ -23,8 +23,8 @@ const VIA_DOC = `via_interface: n (Iface.member) = References through an interfa
23
23
  refs_total: 0 under -P in a workspace lists found_outside_project[n]{file,line} = importers outside that Project
24
24
  scope = in a monorepo without -P the program is the declaring file + its importers (scope_partial: true when the importer scan was capped)
25
25
  tsconfig: x (n referenced configs) = a references-only tsconfig expanded to the configs it references
26
- textual: n matches in m files = whole-word count of the name in Project files (JavaScript declarations and implicit projects; a state counts its full dotted name as a whole token, templates included; not for a state's resolve keys and hooks - injected by name, not imported); 'n matches in this file' for a non-exported local; above refs_total = uses the checker cannot resolve - see refs --grep
27
- templates: n usages in m files = template uses (.html .htm .ejs .hbs .njk .pug) of a directive or component (<wap-select / wap-select / data-wap-select), its bindings (fallback-display-property= on that tag), a controller (ng-controller) or a filter (| money); refs --grep lists them in unresolved`;
26
+ textual: n matches in m files = whole-word count of the name in Project files (JavaScript declarations and implicit projects; a state counts its full dotted name as a whole token, templates included; not for a state's resolve keys and hooks - injected by name, not imported); 'n matches in this file' only for a real local (a parameter, a binding inside a function or IIFE, a non-exported top-level binding of an ES module or of a CommonJS file whose module.exports never hands it out) - members, object properties and script globals count Project-wide; above refs_total = uses the checker cannot resolve - see refs --grep
27
+ templates: n usages in m files = template uses (.html .htm .ejs .hbs .njk .pug) of a directive or component (<wap-select / wap-select / data-wap-select), its bindings (fallback-display-property= on that tag), a controller (ng-controller) or a filter (| money) - also for a function registered by identifier (.directive('fancyBox', fancyBox)), under the registered name; refs --grep lists them in unresolved`;
28
28
  export const REFS_HELP = `usage: ts-axi refs <name | Class.member | path:line[:col]> [flags]
29
29
  flags[13]:
30
30
  ${SHARED_FLAGS}
@@ -34,7 +34,9 @@ flags[13]:
34
34
  output:
35
35
  symbol, kind, declared_at, refs_total, files, tests, refs[n]{file,line,context} (Declarations excluded), elapsed_ms
36
36
  ${VIA_DOC}
37
- note = the name is registered by a string ('Name' in .controller('Name', fn)) - its uses are textual; add --grep
37
+ note = the name is registered by a string ('Name' in .controller('Name', fn), or fn a same-file function named by identifier) - its uses are textual; add --grep
38
+ matches: 0 = no Declaration of the name; help counts its whole-word matches in Project files, and --grep lists them in unresolved with note 'no declaration found - textual matches only'
39
+ path:line = the declaration that starts the line (a function before its parameters, a property before its value's parameters; siblings like 'const a = 1, b = 2' list identifiers - add :col)
38
40
  an ambiguous name lists matches[n] (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms; nothing is analysed
39
41
  examples[4]:
40
42
  ts-axi refs User.save
@@ -111,8 +113,23 @@ async function run(args, ctx, invoked) {
111
113
  `Run \`ts-axi ${name} ${outside[0].file}:${outside[0].nameLine}\` to use the nearest tsconfig of that file`,
112
114
  ]));
113
115
  }
116
+ // No Declaration, but the name may still be written: an untyped property set up elsewhere.
117
+ const text = f.fuzzy ? [] : textualScan(opened, target.member ?? target.name);
118
+ if (show.grep) {
119
+ return out(block({
120
+ ...warning,
121
+ matches: 0,
122
+ note: "no declaration found - textual matches only",
123
+ unresolved: text.length === 0 ? 0 : text.slice(0, limit),
124
+ ...(text.length > limit ? { unresolved_total: text.length } : {}),
125
+ }), helpBlock([
126
+ ...(text.length > limit ? [`Showing ${limit} of ${text.length}; run \`ts-axi refs ${label} --grep --limit ${Math.min(MAX_LIMIT, text.length)}\` for more`] : []),
127
+ `Run \`ts-axi def ${label} --fuzzy\` for substring matches`,
128
+ ]));
129
+ }
114
130
  const suggested = suggestions(opened, target);
115
131
  return out(block({ ...warning, matches: 0, ...(suggested.length > 0 ? { did_you_mean: suggested } : {}) }), helpBlock([
132
+ ...(text.length > 0 ? [textualScanHint(label, text.length)] : []),
116
133
  ...suggested.slice(0, 2).map((s) => `Run \`ts-axi ${name} ${s}\``),
117
134
  ...(f.fuzzy ? [] : [`Run \`ts-axi def ${label} --fuzzy\` for substring matches`]),
118
135
  ]));
@@ -143,15 +160,17 @@ async function run(args, ctx, invoked) {
143
160
  }
144
161
  const { semantic, scope } = built;
145
162
  const def = semantic.definitionAt(resolved.absFile, resolved.pos);
146
- const decl = resolved.decl ??
163
+ const found = resolved.decl ??
147
164
  (def && def.line > 0 ? declarationAt(opened.index, def.absFile, def.pos, kindFromScriptElement(def.kind)) : undefined);
165
+ // A function registered by identifier is its registration: templates, note and textual use the registered name.
166
+ const decl = (found && registrationOf(found, opened.index.declarations(opened.index.abs(found.file)))) ?? found;
148
167
  const kind = decl?.kind ?? kindFromScriptElement(def?.kind ?? semantic.quickInfo(resolved.absFile, resolved.pos)?.kind ?? "");
149
168
  const symbol = decl?.qualifiedName ?? def?.name ?? label;
150
169
  const declaredAt = decl ? `${decl.file}:${decl.nameLine}` : def && def.line > 0 ? `${def.file}:${def.line}` : "(outside the Project)";
151
170
  const header = { ...warning, ...projectFields(opened), symbol, kind, declared_at: declaredAt };
152
171
  const explicit = Boolean(project || opened.settings.project);
153
172
  const declaredFile = decl?.file ?? (def && def.line > 0 ? def.file : undefined);
154
- const localFile = isPlainLocal(decl) ? opened.index.abs(decl.file) : undefined;
173
+ const localFile = isPlainLocal(opened.index, decl) ? opened.index.abs(decl.file) : undefined;
155
174
  const needle = textualNeedle(decl ?? symbol, kind);
156
175
  const textual = declaredFile !== undefined && needle && isUntyped(opened, decl, declaredFile) ? textualCount(opened, needle, new Map(), localFile) : undefined;
157
176
  const tplTarget = decl ? templateTarget(decl, opened.index.declarations(opened.index.abs(decl.file))) : undefined;
@@ -249,7 +268,7 @@ function unresolvedOf(opened, semantic, o, known) {
249
268
  const needle = isTextualCandidate(o.decl) ? textualNeedle(o.decl ?? o.symbol, o.kind) : undefined;
250
269
  if (!needle)
251
270
  return [];
252
- return textualMatches(opened, semantic, [needle], new Set(known.map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map(), isPlainLocal(o.decl) ? new Map([[o.decl.name, opened.index.abs(o.decl.file)]]) : new Map());
271
+ return textualMatches(opened, semantic, [needle], new Set(known.map((r) => `${r.file}:${r.line}`)), new Set(o.decl ? [`${o.decl.file}:${o.decl.line}`, `${o.decl.file}:${o.decl.nameLine}`] : []), new Map(), isPlainLocal(opened.index, o.decl) ? new Map([[o.decl.name, opened.index.abs(o.decl.file)]]) : new Map());
253
272
  }
254
273
  /**
255
274
  * `textual` field and, when the name occurs more often than refs_total and some matches sit on no
package/dist/src/diff.js CHANGED
@@ -40,8 +40,8 @@ function parseGitHeader(rest) {
40
40
  const m = /^"?a\/(.+?)"? "?b\/(.+?)"?$/.exec(rest);
41
41
  return m ? [m[1], m[2]] : [undefined, undefined];
42
42
  }
43
- function finish(draft) {
44
- if (draft.binary)
43
+ function finish(draft, keepBinary) {
44
+ if (draft.binary && !keepBinary)
45
45
  return undefined;
46
46
  const isNew = draft.newMode || draft.minus === null;
47
47
  const isDeleted = draft.deletedMode || draft.plus === null;
@@ -62,13 +62,15 @@ function finish(draft) {
62
62
  oldPath: status === "renamed" ? oldP : undefined,
63
63
  status,
64
64
  hunks: draft.hunks,
65
+ ...(draft.binary ? { binary: true } : {}),
65
66
  };
66
67
  }
67
68
  /**
68
- * Parse git-style unified diff text. Binary files are skipped; files with no
69
- * hunks (pure renames, mode changes) are kept with `hunks: []`.
69
+ * Parse git-style unified diff text. Binary files are skipped unless `binary` is set; files
70
+ * with no hunks (pure renames, mode changes) are kept with `hunks: []`. Splits on `\n` only,
71
+ * so `\r`, U+2028 or U+2029 inside a line never shift the numbering.
70
72
  */
71
- export function parseUnifiedDiff(text) {
73
+ export function parseUnifiedDiff(text, options = {}) {
72
74
  const files = [];
73
75
  let draft;
74
76
  let hunk;
@@ -78,7 +80,7 @@ export function parseUnifiedDiff(text) {
78
80
  let newNo = 0;
79
81
  const flush = () => {
80
82
  if (draft) {
81
- const file = finish(draft);
83
+ const file = finish(draft, options.binary === true);
82
84
  if (file)
83
85
  files.push(file);
84
86
  }
@@ -104,6 +106,7 @@ export function parseUnifiedDiff(text) {
104
106
  continue;
105
107
  }
106
108
  if (tag === " " || line === "") {
109
+ hunk.context.push({ oldLine: oldNo, newLine: newNo });
107
110
  oldNo++;
108
111
  newNo++;
109
112
  oldLeft--;
@@ -130,6 +133,7 @@ export function parseUnifiedDiff(text) {
130
133
  newLines: h[4] === undefined ? 1 : Number(h[4]),
131
134
  removed: [],
132
135
  added: [],
136
+ context: [],
133
137
  };
134
138
  draft.hunks.push(hunk);
135
139
  oldLeft = hunk.oldLines;
@@ -211,3 +215,13 @@ export function baseRevOfRange(range) {
211
215
  return range;
212
216
  return range.slice(0, index) || "HEAD";
213
217
  }
218
+ /** Right side of a range; undefined when git diffs it against the working tree (`abc123`). */
219
+ export function headRevOfRange(range) {
220
+ const triple = range.indexOf("...");
221
+ if (triple > 0)
222
+ return range.slice(triple + 3) || "HEAD";
223
+ const double = range.indexOf("..");
224
+ if (double > 0)
225
+ return range.slice(double + 2) || "HEAD";
226
+ return undefined;
227
+ }
@@ -1,5 +1,7 @@
1
1
  import { AxiError, exitCodeForError as sdkExitCode } from "axi-sdk-js";
2
2
  export { AxiError };
3
+ /** `anchor` exit code when an anchor is not commentable; set by the handler, the output still prints. */
4
+ export const ANCHOR_INVALID_EXIT = 3;
3
5
  /** SDK mapping: 2 for usage errors, 1 otherwise (`GIT_ERROR` included). */
4
6
  export function exitCodeForError(error) {
5
7
  return sdkExitCode(error);
package/dist/src/git.js CHANGED
@@ -155,10 +155,10 @@ export async function untrackedFiles(cwd) {
155
155
  const stdout = await runOrThrow(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--full-name", ":/"]);
156
156
  return stdout.split("\0").filter((p) => p !== "");
157
157
  }
158
- /** Raw unified diff with zero context lines; same target rules as `diffNameStatus`. */
159
- export async function diffUnified(cwd, range, staged) {
158
+ /** Raw unified diff with `context` lines around each change (default 0); same target rules as `diffNameStatus`. */
159
+ export async function diffUnified(cwd, range, staged, context = 0) {
160
160
  const target = await diffTarget(cwd, range, staged);
161
- return runOrThrow(cwd, ["diff", "-U0", "--no-color", "--no-ext-diff", "-M", ...target, "--"]);
161
+ return runOrThrow(cwd, ["diff", `-U${context}`, "--no-color", "--no-ext-diff", "-M", ...target, "--"]);
162
162
  }
163
163
  /** File content at `rev`, or `null` when the path does not exist there. */
164
164
  export async function showFile(cwd, rev, path) {
package/dist/src/skill.js CHANGED
@@ -3,7 +3,8 @@ import { DESCRIPTION } from "./cli.js";
3
3
  // outcome-focused so it fires on code-navigation and review intents.
4
4
  export const SKILL_DESCRIPTION = "Navigate TypeScript and JavaScript code and measure change impact through the ts-axi CLI - find where a symbol is declared, " +
5
5
  "list its references and callers, outline a file, and see which other code a diff affects. Use before reading many files to find " +
6
- "callers or usages of a function, class or type; use `ts-axi impact` when reviewing a diff, a pull request or your own uncommitted changes.";
6
+ "callers or usages of a function, class or type; use `ts-axi impact` when reviewing a diff, a pull request or your own uncommitted changes, " +
7
+ "and `ts-axi anchor` to check that review-comment file:line anchors are in the diff before posting them.";
7
8
  // Hard cap so a regeneration cannot re-inflate the stub with CLI-owned
8
9
  // guidance. The home view and --help output are the source of truth.
9
10
  export const MAX_SKILL_MARKDOWN_CHARS = 2000;