ts-axi 0.1.12 → 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.
package/README.md CHANGED
@@ -82,7 +82,8 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
82
82
  | `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
83
83
  | `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
84
84
  | `outline <file>` | Top-level declarations in source order, class members nested: `{line,kind,name,signature}` (`{at,kind,name,signature}` over several files). `--exports`, `--fields exported` |
85
- | `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set (direct only), a `via` count of calls through an interface or base member (`--via` lists them), test-file counts, `notes` (a signature change of a member that implements an interface member), `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Rows default to `{symbol,change,at,refs_outside}`; `--fields kind,exported,refs_total,via,files,tests` (or `all`) adds columns. Analyses the current checkout |
85
+ | `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set (direct only), a `via` count of calls through an interface or base member (`--via` lists them), test-file counts, `notes` (a signature change of a member that implements an interface member), `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Rows default to `{symbol,change,at,refs_outside}`; `--fields kind,exported,refs_total,via,files,tests` (or `all`) adds columns. `--size` adds a `size` block (files, hunks at zero context, lines added and removed, patch characters, exported signature changes, removed symbols) for routing a review by diff size. Analyses the current checkout |
86
+ | `anchor [<git-range> \| - \| --staged] <file:line[-end]>...` | Checks review-comment anchors: a line is commentable when the diff shows it (added or context; removed or context with `--side old`). Rows are `{anchor,ok,reason,symbol}`: a reason with the nearest hunk when not, and the enclosing Declaration (`Cart.total#method`) as a stable key for the finding. Git sources diff at `--context 3` (what Bitbucket and GitHub show); a stdin diff is used as given. `--fields in_diff,in_file,nearest,change,other_line` (or `all`). Exit 3 when any anchor is not ok, with the full output |
86
87
  | `config` | Show effective settings with their source; `config set <key> <value>` and `config unset <key>` write `.ts-axi.json` |
87
88
  | `setup <hooks\|status\|uninstall>` | Opt-in SessionStart hook for Claude Code, Codex and OpenCode that runs the home view at session start. `--project` for the current repository only |
88
89
 
@@ -149,6 +150,30 @@ help[4]:
149
150
  ...
150
151
  ```
151
152
 
153
+ ### Anchoring review comments
154
+
155
+ An inline comment only lands when the review tool shows its line in the diff. Check the anchors against the diff the tool renders before posting:
156
+
157
+ ```sh
158
+ bb-axi pr diff 42 --raw | ts-axi anchor - src/a.ts:12 src/b.ts:40-44
159
+ ```
160
+
161
+ ```
162
+ base: stdin diff
163
+ head: working tree
164
+ side: new
165
+ anchors[2]{anchor,ok,reason,symbol}:
166
+ "src/a.ts:12",true,"-",Cart.total#method
167
+ "src/b.ts:40-44",false,not in diff - nearest 51-58,formatPrice#function
168
+ invalid: 1
169
+ help[3]:
170
+ - "Move src/b.ts:40-44 into src/b.ts:51-58 or post it as a general comment"
171
+ - exit 3 (ANCHOR_INVALID) - 1 of 2 anchors cannot be commented; fix or drop them before posting
172
+ - "Run `<diff> | ts-axi anchor - src/b.ts:40-44 --fields all` for in_diff, in_file, nearest, change and other_line"
173
+ ```
174
+
175
+ The exit code is 3 when any anchor is not commentable, so a script can gate on it. `--side old` checks lines of the before file (comments on removed lines); a new-side anchor on a removed line says `removed line - use --side old`. With a stdin diff, `symbol` and `in_file` come from the working tree, so check out the PR head first; a mismatch is reported as a warning.
176
+
152
177
  ### Interfaces are not conflated
153
178
 
154
179
  `findReferences` in the compiler resolves a call like `parser.parse(raw)` (with `parser: FieldParser`) to every implementation of `parse`, and lists the other implementations' declarations as references. For a review that inflates the blast radius badly. ts-axi keeps the split:
@@ -0,0 +1,113 @@
1
+ import { usageError } from "./errors.js";
2
+ const ANCHOR = /^(.+):(\d+)(?:-(\d+))?$/;
3
+ const WITH_COLUMN = /^.+:\d+:\d+$/;
4
+ /** `path:line` or `path:start-end`; `path:line:col` counts too, so it gets a precise error. */
5
+ export function looksLikeAnchor(value) {
6
+ return value !== "-" && (ANCHOR.test(value) || WITH_COLUMN.test(value));
7
+ }
8
+ export function parseAnchor(raw) {
9
+ if (WITH_COLUMN.test(raw)) {
10
+ throw usageError(`anchor '${raw}' has a column - anchors are file:line or file:start-end`, [`Use ${raw.replace(/:\d+$/, "")}`]);
11
+ }
12
+ const m = ANCHOR.exec(raw);
13
+ if (!m)
14
+ throw usageError(`'${raw}' is not an anchor - expected file:line or file:start-end`);
15
+ const start = Number(m[2]);
16
+ const end = m[3] === undefined ? start : Number(m[3]);
17
+ if (start < 1)
18
+ throw usageError(`anchor '${raw}' - lines start at 1`);
19
+ if (end < start)
20
+ throw usageError(`anchor '${raw}' ends before it starts`, [`Use ${m[1]}:${end}-${start}`]);
21
+ return { raw, path: m[1].replace(/^\.\//, ""), start, end };
22
+ }
23
+ export function sideMap(file, side) {
24
+ const shown = new Map();
25
+ const hunks = new Map();
26
+ file.hunks.forEach((h, i) => {
27
+ const own = side === "new" ? h.added : h.removed;
28
+ for (const l of own)
29
+ shown.set(l.line, { hunk: i, kind: side === "new" ? "added" : "removed" });
30
+ for (const c of h.context) {
31
+ const [line, other] = side === "new" ? [c.newLine, c.oldLine] : [c.oldLine, c.newLine];
32
+ shown.set(line, { hunk: i, kind: "context", other });
33
+ }
34
+ const lines = [...own.map((l) => l.line), ...h.context.map((c) => (side === "new" ? c.newLine : c.oldLine))];
35
+ if (lines.length > 0)
36
+ hunks.set(i, [Math.min(...lines), Math.max(...lines)]);
37
+ });
38
+ return { shown, hunks };
39
+ }
40
+ /** Line count as an editor shows it: split on `\n` only, a trailing newline ends the last line. */
41
+ export function lineCount(text) {
42
+ if (text === "")
43
+ return 0;
44
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
45
+ }
46
+ export function formatSpan([a, b]) {
47
+ return a === b ? String(a) : `${a}-${b}`;
48
+ }
49
+ /** The closest range to [start, end]; ties go to the earlier one. */
50
+ export function nearestRange(ranges, start, end) {
51
+ let best;
52
+ let bestDistance = Infinity;
53
+ for (const r of ranges) {
54
+ const distance = end < r[0] ? r[0] - end : start > r[1] ? start - r[1] : 0;
55
+ if (distance < bestDistance) {
56
+ best = r;
57
+ bestDistance = distance;
58
+ }
59
+ }
60
+ return best;
61
+ }
62
+ /**
63
+ * Judge one anchor against its file's diff. `lines` = line count of the chosen side's text
64
+ * (undefined when unknown). Every line of a range must be shown, in a single hunk.
65
+ */
66
+ export function judge(anchor, file, side, lines) {
67
+ if (!file)
68
+ return { ok: false, reason: "file not in diff", inDiff: false, inFile: undefined };
69
+ if (file.binary)
70
+ return { ok: false, reason: "binary", inDiff: false, inFile: undefined };
71
+ const inFile = lines === undefined ? undefined : anchor.end <= lines;
72
+ if (side === "old" && file.status === "added")
73
+ return { ok: false, reason: "file added - use --side new", inDiff: false, inFile: false };
74
+ const map = sideMap(file, side);
75
+ const hits = [];
76
+ for (let n = anchor.start; n <= anchor.end; n++) {
77
+ const hit = map.shown.get(n);
78
+ if (hit)
79
+ hits.push(hit);
80
+ }
81
+ const span = anchor.end - anchor.start + 1;
82
+ const first = hits[0];
83
+ if (first && hits.length === span && hits.every((h) => h.hunk === first.hunk)) {
84
+ return { ok: inFile !== false, reason: inFile === false ? `past end of file (${lines} lines)` : "-", inDiff: true, inFile, ...(first.other !== undefined ? { otherLine: first.other } : {}) };
85
+ }
86
+ if (first) {
87
+ const nearest = formatSpan(map.hunks.get(first.hunk));
88
+ return { ok: false, reason: `crosses hunk boundary - hunk ${nearest}`, inDiff: false, inFile, nearest };
89
+ }
90
+ const otherSide = sideMap(file, side === "new" ? "old" : "new").shown.get(anchor.start);
91
+ if (side === "new" && otherSide?.kind === "removed")
92
+ return { ok: false, reason: "removed line - use --side old", inDiff: false, inFile };
93
+ if (side === "old" && otherSide?.kind === "added")
94
+ return { ok: false, reason: "added line - use --side new", inDiff: false, inFile };
95
+ if (side === "new" && file.status === "deleted")
96
+ return { ok: false, reason: "file deleted - use --side old", inDiff: false, inFile: false };
97
+ if (inFile === false)
98
+ return { ok: false, reason: `past end of file (${lines} lines)`, inDiff: false, inFile };
99
+ const near = nearestRange([...map.hunks.values()], anchor.start, anchor.end);
100
+ if (!near)
101
+ return { ok: false, reason: `not in diff - no ${side}-side lines in this file`, inDiff: false, inFile };
102
+ const nearest = formatSpan(near);
103
+ return { ok: false, reason: `not in diff - nearest ${nearest}`, inDiff: false, inFile, nearest };
104
+ }
105
+ /** Smallest Declaration spanning the whole range. */
106
+ export function enclosing(decls, start, end) {
107
+ let best;
108
+ for (const d of decls) {
109
+ if (d.line <= start && end <= d.endLine && (!best || d.endLine - d.line < best.endLine - best.line))
110
+ best = d;
111
+ }
112
+ return best;
113
+ }
package/dist/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runAxiCli } from "axi-sdk-js";
2
+ import { anchorCommand, ANCHOR_HELP } from "./commands/anchor.js";
2
3
  import { configCommand, CONFIG_HELP } from "./commands/config.js";
3
4
  import { defCommand, DEF_HELP } from "./commands/def.js";
4
5
  import { homeCommand } from "./commands/home.js";
@@ -13,8 +14,8 @@ import { out, renderErrorOutput, setHintRef, setJsonMode, toJson } from "./rende
13
14
  import { VERSION } from "./version.js";
14
15
  export const DESCRIPTION = "TypeScript/JavaScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (what a diff affects) from the TypeScript compiler. Prefer it over grep for 'who uses this'.";
15
16
  export const TOP_HELP = `usage: ts-axi [command] [args] [flags]
16
- commands[8]:
17
- (none)=project overview, def, refs, callers, outline, impact, config, setup (opt-in session hooks)
17
+ commands[9]:
18
+ (none)=project overview, def, refs, callers, outline, impact, anchor (is a review comment's file:line in the diff), config, setup (opt-in session hooks)
18
19
  targets:
19
20
  name (save), member (User.save) or position (src/a.ts:42 or src/a.ts:42:7)
20
21
  flags[5]:
@@ -27,6 +28,7 @@ examples:
27
28
  ts-axi refs User.save --ref origin/main
28
29
  ts-axi outline src/models/user.ts
29
30
  ts-axi impact main...HEAD
31
+ bb-axi pr diff 42 --raw | ts-axi anchor - src/a.ts:12
30
32
  ts-axi refs --help
31
33
  `;
32
34
  const COMMAND_HELP = {
@@ -35,6 +37,7 @@ const COMMAND_HELP = {
35
37
  callers: CALLERS_HELP,
36
38
  outline: OUTLINE_HELP,
37
39
  impact: IMPACT_HELP,
40
+ anchor: ANCHOR_HELP,
38
41
  config: CONFIG_HELP,
39
42
  setup: SETUP_HELP,
40
43
  };
@@ -80,12 +83,13 @@ export async function main(options = {}) {
80
83
  callers: command("callers", callersCommand, ctx),
81
84
  outline: command("outline", outlineCommand, ctx),
82
85
  impact: command("impact", impactCommand, ctx),
86
+ anchor: command("anchor", anchorCommand, ctx),
83
87
  config: command("config", configCommand, ctx),
84
88
  setup: command("setup", (args) => setupCommand(args), ctx),
85
89
  },
86
90
  getCommandHelp: (name) => COMMAND_HELP[name],
87
91
  renderUnknownCommand: (name) => `${renderErrorOutput(`unknown command '${name}'`, "VALIDATION_ERROR", [
88
- "commands: def, refs, callers, outline, impact, config, setup (no command = project overview)",
92
+ "commands: def, refs, callers, outline, impact, anchor, config, setup (no command = project overview)",
89
93
  "Run `ts-axi --help` for examples",
90
94
  ])}\n`,
91
95
  formatError,
@@ -0,0 +1,250 @@
1
+ import { join, relative, sep } from "node:path";
2
+ import { enclosing, judge, lineCount, looksLikeAnchor, parseAnchor } from "../anchor.js";
3
+ import { bool, enumFlag, flag, intFlag, parseArgs, parseFields } from "../args.js";
4
+ import { changedRanges, headRevOfRange, parseUnifiedDiff } from "../diff.js";
5
+ import { ANCHOR_INVALID_EXIT, AxiError, usageError } from "../errors.js";
6
+ import { sourceFs } from "../fs.js";
7
+ import { diffUnified, isGitRepo, resolveBaseRev, showFile, untrackedFiles } from "../git.js";
8
+ import { classifyFile, liftTypeLevel, matchKey, reverseApply } from "../impact.js";
9
+ import { findRepoRoot, parseSource, SOURCE_EXTENSIONS } from "../project.js";
10
+ import { block, helpBlock, out } from "../render.js";
11
+ import { collectDeclarations } from "../symbols.js";
12
+ import { readStdin, shellWord } from "./impact.js";
13
+ const DEFAULT_CONTEXT = 3;
14
+ const MAX_CONTEXT = 1000;
15
+ const STDIN_EXAMPLE = "bb-axi pr diff 42 --raw | ts-axi anchor - src/a.ts:12";
16
+ export const ANCHOR_HELP = `usage: ts-axi anchor [<git-range> | - | --staged] <file:line[-end]>... [flags]
17
+ checks review-comment anchors: a line is commentable when the diff shows it (an added or context line; removed or context with --side old)
18
+ input:
19
+ (none) = working tree vs HEAD; --staged = index vs HEAD; <git-range> = any git diff range (main...HEAD)
20
+ - = unified diff on stdin, used as given - the path for PR review (${STDIN_EXAMPLE})
21
+ the diff source comes first; anchors are repo-relative file:line or file:start-end (every line of a range must be shown, in one hunk)
22
+ flags[5]:
23
+ --side new|old (default new; old = lines of the before file - comments on removed lines), --context <0-${MAX_CONTEXT}> (git sources only; default ${DEFAULT_CONTEXT} like Bitbucket and GitHub), --staged, --fields <in_diff,in_file,nearest,change,other_line|all>, --json
24
+ output:
25
+ base, head, side, anchors[n]{anchor,ok,reason,symbol}, invalid (count)
26
+ reason = - when ok; else not in diff - nearest <a-b> | past end of file (<n> lines) | removed line - use --side old | crosses hunk boundary - hunk <a-b> | file not in diff | binary
27
+ symbol = qualifiedName#kind of the innermost Declaration holding the line (a stable key for a finding); file_level outside any; - for non-TS/JS files
28
+ in_diff = the line is shown in a hunk; in_file = within the line count of that side; nearest = lines of the closest hunk on that side
29
+ change = impact class of that Symbol in this diff (signature, body, added, ...) or -; other_line = the same line on the other side (context lines) or -
30
+ exit codes:
31
+ 0 every anchor ok; 3 ANCHOR_INVALID = at least one is not (the full output still prints); 2 usage; 1 git
32
+ examples[4]:
33
+ ${STDIN_EXAMPLE} src/b.ts:40-44
34
+ ts-axi anchor main...HEAD src/a.ts:12
35
+ ts-axi anchor --staged src/a.ts:12 --fields all
36
+ ts-axi anchor main...HEAD src/a.ts:30 --side old
37
+ `;
38
+ const SPECS = [
39
+ { name: "--staged", boolean: true },
40
+ { name: "--side" },
41
+ { name: "--context" },
42
+ { name: "--fields" },
43
+ ];
44
+ const ANCHOR_FIELDS = ["in_diff", "in_file", "nearest", "change", "other_line"];
45
+ function isSourcePath(path) {
46
+ return SOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
47
+ }
48
+ /** Positionals -> diff source + anchors; the source is first when it is `-` or no file:line. */
49
+ function split(positionals, staged) {
50
+ const [first, ...rest] = positionals;
51
+ const source = first !== undefined && (first === "-" || !looksLikeAnchor(first)) ? first : undefined;
52
+ const anchors = source === undefined ? positionals : rest;
53
+ const stray = anchors.find((a) => !looksLikeAnchor(a));
54
+ if (stray !== undefined) {
55
+ const valid = anchors.filter(looksLikeAnchor);
56
+ throw usageError(`'${stray}' is not an anchor - expected file:line or file:start-end`, [
57
+ `the diff source goes first: \`ts-axi anchor ${shellWord(stray)} ${valid.map(shellWord).join(" ") || "src/a.ts:12"}\``,
58
+ ]);
59
+ }
60
+ if (staged && source !== undefined)
61
+ throw usageError("--staged cannot be combined with a range or -", [`Run \`ts-axi anchor --staged ${anchors.map(shellWord).join(" ")}\``]);
62
+ if (anchors.length === 0) {
63
+ throw usageError("no anchors given", [`Run \`ts-axi anchor ${source !== undefined ? `${shellWord(source)} ` : ""}src/a.ts:12\` (file:line or file:start-end)`]);
64
+ }
65
+ return { source, anchors };
66
+ }
67
+ export async function anchorCommand(args, ctx) {
68
+ const parsed = parseArgs(args, SPECS, "ts-axi anchor");
69
+ const staged = bool(parsed, "--staged");
70
+ const side = enumFlag(parsed, "--side", ["new", "old"], "new");
71
+ const extras = new Set(parseFields(flag(parsed, "--fields"), ANCHOR_FIELDS));
72
+ const { source: arg, anchors: rawAnchors } = split(parsed.positionals, staged);
73
+ if (arg === "-" && flag(parsed, "--context") !== undefined) {
74
+ throw usageError("--context applies to git diffs - a stdin diff is used as given", [`Run \`${STDIN_EXAMPLE}\``]);
75
+ }
76
+ const context = intFlag(parsed, "--context", DEFAULT_CONTEXT, { min: 0, max: MAX_CONTEXT });
77
+ const anchors = rawAnchors.map(parseAnchor);
78
+ const root = findRepoRoot(ctx.cwd);
79
+ let text;
80
+ let source;
81
+ if (arg === "-") {
82
+ text = ctx.stdin ?? (await readStdin("anchor", STDIN_EXAMPLE));
83
+ source = { kind: "stdin", base: "stdin diff", head: "working tree" };
84
+ }
85
+ else {
86
+ if (!(await isGitRepo(ctx.cwd))) {
87
+ throw new AxiError("not a git repository - anchor needs git or a diff on stdin", "GIT_ERROR", [`Run \`${STDIN_EXAMPLE}\``]);
88
+ }
89
+ text = await diffUnified(ctx.cwd, arg, staged, context);
90
+ const headRev = arg !== undefined ? headRevOfRange(arg) : undefined;
91
+ source = staged
92
+ ? { kind: "staged", base: "HEAD", head: "index" }
93
+ : arg === undefined
94
+ ? { kind: "worktree", base: "HEAD", head: "working tree" }
95
+ : { kind: "range", range: arg, base: await resolveBaseRev(ctx.cwd, arg), head: headRev ?? "working tree" };
96
+ }
97
+ const files = parseUnifiedDiff(text, { binary: true });
98
+ // Untracked files are wholly added in a working-tree diff (as in impact).
99
+ let untracked;
100
+ const findFile = async (path) => {
101
+ const hit = files.find((f) => (side === "old" ? (f.oldPath ?? f.path) === path : f.path === path)) ?? files.find((f) => f.path === path || f.oldPath === path);
102
+ if (hit || source.kind !== "worktree")
103
+ return hit;
104
+ untracked ??= new Set(await untrackedFiles(ctx.cwd));
105
+ if (!untracked.has(path))
106
+ return undefined;
107
+ const body = sourceFs().readFile(join(root, path)) ?? "";
108
+ const n = lineCount(body);
109
+ const added = Array.from({ length: n }, (_, i) => ({ line: i + 1, text: "" }));
110
+ const file = { path, oldPath: undefined, status: "added", hunks: n > 0 ? [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: n, removed: [], added, context: [] }] : [] };
111
+ files.push(file);
112
+ return file;
113
+ };
114
+ // An anchor relative to a subdirectory cwd, or carrying an a/ or b/ prefix, still finds its file.
115
+ const prefix = relative(root, ctx.cwd).split(sep).join("/");
116
+ const resolveFile = async (anchor) => {
117
+ const candidates = [anchor.path, ...(prefix && !prefix.startsWith("..") ? [`${prefix}/${anchor.path}`] : []), ...(/^[ab]\//.test(anchor.path) ? [anchor.path.slice(2)] : [])];
118
+ for (const c of candidates) {
119
+ const f = await findFile(c);
120
+ if (f)
121
+ return f;
122
+ }
123
+ return undefined;
124
+ };
125
+ // --- Both sides of a file, read once ---
126
+ const worktree = (path) => sourceFs().readFile(join(root, path)) ?? null;
127
+ const texts = new Map();
128
+ let mismatched = 0;
129
+ const textsOf = (f) => {
130
+ let entry = texts.get(f);
131
+ if (!entry) {
132
+ entry = (async () => {
133
+ if (f.binary)
134
+ return { after: undefined, before: undefined };
135
+ let after;
136
+ if (f.status === "deleted")
137
+ after = null;
138
+ else if (source.kind === "staged")
139
+ after = await showFile(ctx.cwd, "", f.path);
140
+ else if (source.kind === "range" && source.head !== "working tree")
141
+ after = await showFile(ctx.cwd, source.head, f.path);
142
+ else
143
+ after = worktree(f.path);
144
+ let before;
145
+ if (source.kind === "stdin") {
146
+ // The checkout must be the diff's after side; otherwise both texts are unknown. A deleted file's hunks hold all of it.
147
+ const reversed = reverseApply(after ?? "", f.hunks);
148
+ if (reversed === null) {
149
+ mismatched++;
150
+ after = undefined;
151
+ }
152
+ before = f.status === "added" ? null : reversed ?? undefined;
153
+ }
154
+ else if (f.status === "added")
155
+ before = null;
156
+ else
157
+ before = await showFile(ctx.cwd, source.base, f.oldPath ?? f.path);
158
+ return { after, before };
159
+ })();
160
+ texts.set(f, entry);
161
+ }
162
+ return entry;
163
+ };
164
+ const decls = new Map();
165
+ const declsOf = (path, sideText, key) => {
166
+ let list = decls.get(key);
167
+ if (!list) {
168
+ list = collectDeclarations(parseSource(path, sideText), path);
169
+ decls.set(key, list);
170
+ }
171
+ return list;
172
+ };
173
+ const classified = new Map();
174
+ const changesOf = (f, after, before) => {
175
+ let result = classified.get(f);
176
+ if (!result) {
177
+ const { after: a, before: b } = changedRanges(f);
178
+ result = classifyFile({ path: f.path, ...(f.oldPath ? { oldPath: f.oldPath } : {}), status: f.status, after: a, before: b, afterText: after, beforeText: before });
179
+ classified.set(f, result);
180
+ }
181
+ return result;
182
+ };
183
+ const rows = [];
184
+ for (const anchor of anchors) {
185
+ const file = await resolveFile(anchor);
186
+ const { after, before } = file ? await textsOf(file) : { after: undefined, before: undefined };
187
+ const sideText = side === "new" ? after : before;
188
+ const lines = typeof sideText === "string" ? lineCount(sideText) : sideText === null ? 0 : undefined;
189
+ const verdict = judge(anchor, file, side, lines);
190
+ let symbol = "-";
191
+ let change = "-";
192
+ const sidePath = file ? (side === "old" ? (file.oldPath ?? file.path) : file.path) : anchor.path;
193
+ if (file && typeof sideText === "string" && verdict.inFile !== false && isSourcePath(sidePath)) {
194
+ const list = declsOf(sidePath, sideText, `${side}:${sidePath}`);
195
+ const decl = enclosing(list, anchor.start, anchor.end);
196
+ symbol = decl ? matchKey(decl) : "file_level";
197
+ if (decl) {
198
+ const key = matchKey(liftTypeLevel(list, decl));
199
+ change = changesOf(file, after, before).changes.find((c) => c.key === key)?.change ?? "-";
200
+ }
201
+ }
202
+ rows.push({ anchor, verdict, symbol, change });
203
+ }
204
+ const invalid = rows.filter((r) => !r.verdict.ok);
205
+ if (invalid.length > 0)
206
+ process.exitCode = ANCHOR_INVALID_EXIT;
207
+ const sourceWord = arg === "-" ? "-" : arg !== undefined ? shellWord(arg) : staged ? "--staged" : "";
208
+ const again = (rest) => `${arg === "-" ? "<diff> | " : ""}${["ts-axi anchor", sourceWord, rest, ...(flag(parsed, "--context") !== undefined ? [`--context ${context}`] : [])].filter(Boolean).join(" ")}`;
209
+ const help = [];
210
+ for (const r of invalid.slice(0, 3)) {
211
+ if (r.verdict.reason.startsWith("removed line"))
212
+ help.push(`Run \`${again(`${r.anchor.raw} --side old`)}\` - ${r.anchor.raw} is a removed line`);
213
+ else if (r.verdict.reason.startsWith("added line"))
214
+ help.push(`Run \`${again(r.anchor.raw)}\` - ${r.anchor.raw} is an added line on the new side`);
215
+ else if (r.verdict.nearest)
216
+ help.push(`Move ${r.anchor.raw} into ${r.anchor.path}:${r.verdict.nearest} or post it as a general comment`);
217
+ }
218
+ if (invalid.length > 0) {
219
+ help.push(`exit 3 (ANCHOR_INVALID) - ${invalid.length} of ${rows.length} anchors cannot be commented; fix or drop them before posting`);
220
+ if (extras.size === 0)
221
+ help.push(`Run \`${again(`${invalid.map((r) => shellWord(r.anchor.raw)).join(" ")} --fields all`)}\` for in_diff, in_file, nearest, change and other_line`);
222
+ }
223
+ else {
224
+ help.push("Every anchor is commentable - symbol is a stable key for each finding across pushes");
225
+ }
226
+ if (mismatched > 0)
227
+ help.push("Check out the diff's head commit so symbol and in_file come from the code the diff describes");
228
+ return out(block({
229
+ ...(mismatched > 0 ? { warning: `${mismatched} files in the stdin diff do not match the working tree - symbol and in_file unknown there` } : {}),
230
+ base: source.base,
231
+ head: source.head,
232
+ ...(source.kind !== "stdin" ? { context } : {}),
233
+ side,
234
+ anchors: rows.map(({ anchor, verdict, symbol, change }) => {
235
+ const full = {
236
+ anchor: anchor.raw,
237
+ ok: verdict.ok,
238
+ reason: verdict.reason,
239
+ symbol,
240
+ in_diff: verdict.inDiff,
241
+ in_file: verdict.inFile ?? "-",
242
+ nearest: verdict.nearest ?? "-",
243
+ change,
244
+ other_line: verdict.otherLine ?? "-",
245
+ };
246
+ return Object.fromEntries(Object.entries(full).filter(([k]) => !ANCHOR_FIELDS.includes(k) || extras.has(k)));
247
+ }),
248
+ invalid: invalid.length,
249
+ }), helpBlock(help));
250
+ }
@@ -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));
@@ -470,7 +479,7 @@ function capped(key, rows) {
470
479
  };
471
480
  }
472
481
  /** Quotes a value for a pasteable command. */
473
- function shellWord(value) {
482
+ export function shellWord(value) {
474
483
  return /^[\w@%+=:,.\/-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
475
484
  }
476
485
  /** The same `impact` invocation with every given flag replayed, the one `extra` sets replaced by it. */
@@ -593,12 +602,22 @@ export async function impactCommand(args, ctx) {
593
602
  }
594
603
  }
595
604
  const total = declChanges.length + fileLevel.length;
605
+ const exportedOf = (c) => (c.after ?? c.before)?.exported ?? false;
596
606
  const header = {
597
607
  ...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
598
608
  ...projectFields(opened),
599
609
  base: input.base,
600
610
  head: input.head,
601
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
+ : {}),
602
621
  };
603
622
  const skipTemplates = (used) => {
604
623
  for (const f of templateInputs)
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "AXI-compliant TypeScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (blast radius) with token-efficient TOON output",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ts-axi
3
- description: "Navigate TypeScript and JavaScript code and measure change impact through the ts-axi CLI - find where a symbol is declared, list its references and callers, outline a file, and see which other code a diff affects. Use before reading many files to find 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."
3
+ description: "Navigate TypeScript and JavaScript code and measure change impact through the ts-axi CLI - find where a symbol is declared, list its references and callers, outline a file, and see which other code a diff affects. Use before reading many files to find 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, and `ts-axi anchor` to check that review-comment file:line anchors are in the diff before posting them."
4
4
  user-invocable: false
5
5
  ---
6
6