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.
package/README.md CHANGED
@@ -78,11 +78,12 @@ The hook runs `ts-axi` with no arguments: it reads only the file tree and git st
78
78
  | Command | What it does |
79
79
  | --- | --- |
80
80
  | `ts-axi` | Home view: project root, tsconfig used (or implicit), source file count, test-file glob and match counts, git branch and dirty count, next steps. Under a second, exit 0 everywhere |
81
- | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` |
81
+ | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` and, when the name still occurs in the code, the `refs <name> --grep` that lists those textual matches |
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:
@@ -180,7 +205,7 @@ decls[4]{line,kind,name,exported,signature}:
180
205
  17,function,cartBadge,true,directive cartBadge()
181
206
  ```
182
207
 
183
- An edit inside `CartCtrl` is reported by `impact` as `CartCtrl body`, not as a file-level change. Names registered by a string have no identifier the checker can follow; `refs` says so and offers `--grep`. ui-router and ngRoute states are Declarations of kind `state` (`states.cart`, with callbacks as `states.cart.resolve.items`), so an edit inside a `resolve` names that callback; state objects in exported arrays (`` module.exports = [{ name: `${base}.init`, url, resolve }] ``) count too, their template-literal names filled from same-file consts. Directives, components, their `scope`/`bindings` keys, controllers and filters also get `templates: <n> usages in <m> files` (kebab-case elements and attributes, `ng-controller`, `| filter` in `.html`/`.htm`/`.ejs`/`.hbs`/`.njk`/`.pug`), a `templates` column in `impact`, and a changed template that uses a changed directive or binding is listed under `template_changes` instead of `skipped`.
208
+ An edit inside `CartCtrl` is reported by `impact` as `CartCtrl body`, not as a file-level change. Names registered by a string have no identifier the checker can follow; `refs` says so and offers `--grep`. A registration that names a same-file function (`.directive('fancyBox', fancyBox)`, `['$uibModal', fancyBox]`) links the two, so `refs` on the function reports the registration's template usages too. Function-valued members of an object a function returns (`return { canReadItem: async () => ... }`) are Declarations (`makeItemDeps.canReadItem`), so `def canReadItem` finds them; their `textual:` count covers the whole project, while a real local (a parameter, an IIFE helper, a CommonJS binding no `module.exports` hands out) says `in this file`. ui-router and ngRoute states are Declarations of kind `state` (`states.cart`, with callbacks as `states.cart.resolve.items`), so an edit inside a `resolve` names that callback; state objects in exported arrays (`` module.exports = [{ name: `${base}.init`, url, resolve }] ``) count too, their template-literal names filled from same-file consts. Directives, components, their `scope`/`bindings` keys, controllers and filters also get `templates: <n> usages in <m> files` (kebab-case elements and attributes, `ng-controller`, `| filter` in `.html`/`.htm`/`.ejs`/`.hbs`/`.njk`/`.pug`), a `templates` column in `impact`, and a changed template that uses a changed directive or binding is listed under `template_changes` instead of `skipped`.
184
209
 
185
210
  ## Monorepos
186
211
 
@@ -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
+ }
@@ -217,9 +217,27 @@ function identifiers(node, sf, outIds) {
217
217
  }
218
218
  ts.forEachChild(node, (child) => identifiers(child, sf, outIds));
219
219
  }
220
+ function isFunctionValue(expr) {
221
+ let e = expr;
222
+ while (ts.isParenthesizedExpression(e))
223
+ e = e.expression;
224
+ return ts.isArrowFunction(e) || ts.isFunctionExpression(e);
225
+ }
226
+ /** Declares a name: a Declaration kind, a parameter, a binding element, or a function-valued property. */
220
227
  function isDeclarationName(id) {
221
228
  const parent = id.parent;
222
- return parent !== undefined && parent.name === id && (kindOf(parent) !== undefined || ts.isParameter(parent) || ts.isBindingElement(parent));
229
+ if (parent === undefined || parent.name !== id)
230
+ return false;
231
+ if (ts.isPropertyAssignment(parent))
232
+ return isFunctionValue(parent.initializer);
233
+ return kindOf(parent) !== undefined || ts.isParameter(parent) || ts.isBindingElement(parent);
234
+ }
235
+ /** The node a declaration name owns, for nesting: `this.x = fn` → the whole assignment. */
236
+ function declarationNode(id) {
237
+ const parent = id.parent;
238
+ if (ts.isPropertyAccessExpression(parent) && parent.name === id && ts.isBinaryExpression(parent.parent) && parent.parent.left === parent)
239
+ return parent.parent;
240
+ return parent;
223
241
  }
224
242
  /** The identifier a `path:line[:col]` target points at (see PLAN.md Targets). */
225
243
  export function resolvePosition(index, cwd, target) {
@@ -252,33 +270,41 @@ export function resolvePosition(index, cwd, target) {
252
270
  return hit ? pick(hit) : { kind: "none", abs, file, reason: `no identifier at ${file}:${target.line}:${target.col}` };
253
271
  }
254
272
  // A string-registered name has no identifier: its line stands for it.
255
- const nameless = decls.find((d) => d.nameless && d.nameLine === target.line);
273
+ // The registration line too: `.directive('x', fancyBox)` has its name far from the function.
274
+ const nameless = decls.find((d) => d.nameless && d.nameLine === target.line) ?? decls.find((d) => d.nameless && d.line === target.line);
256
275
  if (nameless)
257
276
  return { kind: "decl", decl: nameless, abs };
258
277
  if (onLine.length === 0)
259
278
  return { kind: "none", abs, file, reason: `no identifier on ${file}:${target.line}` };
279
+ const ambiguous = (ids) => {
280
+ const distinct = new Map();
281
+ for (const id of ids)
282
+ if (!distinct.has(id.text))
283
+ distinct.set(id.text, id);
284
+ if (distinct.size === 1)
285
+ return pick(ids[0]);
286
+ return {
287
+ kind: "ambiguous",
288
+ abs,
289
+ file,
290
+ candidates: [...distinct.values()].map((id) => ({ name: id.text, line: target.line, col: id.getStart(sf) - lineStart + 1 })),
291
+ };
292
+ };
260
293
  const constructorDecl = decls.find((d) => d.nameLine === target.line && d.name === "constructor");
261
- const declared = onLine.find((id) => declFor(id)) ?? onLine.find(isDeclarationName);
262
- if (declared)
263
- return pick(declared);
294
+ // The declaration that starts the line: names nested in another one's declaration (its parameters, its members) rank after it.
295
+ const declaring = onLine.filter((id) => declFor(id) !== undefined || isDeclarationName(id));
296
+ const owned = (inner, outer) => {
297
+ const node = declarationNode(outer);
298
+ const at = inner.getStart(sf);
299
+ return node !== declarationNode(inner) && node.getStart(sf) <= at && at < node.end;
300
+ };
301
+ const outer = declaring.filter((id) => !(constructorDecl && ts.isParameter(id.parent) && ts.isConstructorDeclaration(id.parent.parent)) &&
302
+ !declaring.some((other) => other !== id && owned(id, other)));
303
+ if (outer.length > 0)
304
+ return ambiguous(outer);
264
305
  if (constructorDecl)
265
306
  return { kind: "decl", decl: constructorDecl, abs };
266
- const distinct = new Map();
267
- for (const id of onLine)
268
- if (!distinct.has(id.text))
269
- distinct.set(id.text, id);
270
- if (distinct.size === 1)
271
- return pick(onLine[0]);
272
- return {
273
- kind: "ambiguous",
274
- abs,
275
- file,
276
- candidates: [...distinct.values()].map((id) => ({
277
- name: id.text,
278
- line: target.line,
279
- col: id.getStart(sf) - lineStart + 1,
280
- })),
281
- };
307
+ return ambiguous(onLine);
282
308
  }
283
309
  /** A Declaration for an arbitrary declaration name position (locals, parameters) not in the index. */
284
310
  export function declarationAt(index, abs, pos, fallbackKind) {
@@ -1,5 +1,6 @@
1
1
  import { bool, expectPositionals, flag, parseArgs } from "../args.js";
2
2
  import { semanticFor } from "../scope.js";
3
+ import { textualScan, textualScanHint } from "./impact.js";
3
4
  import { block, fenced, helpBlock, out } from "../render.js";
4
5
  import { isFunctionLike, kindFromScriptElement, parseTarget } from "../symbols.js";
5
6
  import { BODY_LINES, declarationAt, filterSuffix, MATCH_ROWS, matchRows, narrowHints, numberedLines, open, positionStartDir, resolveNameTarget, resolvePosition, suggestions, TARGET_FLAGS, targetLabel, readFilters, useRef, } from "./common.js";
@@ -9,7 +10,7 @@ flags[8]:
9
10
  output:
10
11
  1 match = file, line, kind, exported, signature + body fenced with file line numbers
11
12
  several = matches[n]{at,kind,signature} (at = path:line) (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
12
- none = matches: 0 + did_you_mean
13
+ none = matches: 0 + did_you_mean; when the name still occurs in Project files, help gives the refs --grep that lists those textual matches
13
14
  examples[5]:
14
15
  ts-axi def User
15
16
  ts-axi def User.save
@@ -78,7 +79,8 @@ export async function defCommand(args, ctx) {
78
79
  return detail(opened, groups[0].decls, full, label);
79
80
  if (groups.length === 0) {
80
81
  const suggested = suggestions(opened, target);
81
- const help = suggested.slice(0, 2).map((s) => `Run \`ts-axi def ${s}\``);
82
+ const text = f.fuzzy ? 0 : textualScan(opened, target.member ?? target.name).length;
83
+ const help = [...(text > 0 ? [textualScanHint(label, text)] : []), ...suggested.slice(0, 2).map((s) => `Run \`ts-axi def ${s}\``)];
82
84
  if (!f.fuzzy)
83
85
  help.push(`Run \`ts-axi def ${label} --fuzzy\` for substring matches`);
84
86
  if (f.kind || f.in.length > 0 || f.exported)