Skip to content

Commit 9e86526

Browse files
committed
refactor: address PR review comments (BYK + BugBot round 7)
Code changes: - bin.ts: Static import for disableDbTracing (db/index.ts is lightweight now that telemetry import is lazy) - introspect.ts: Widen isRouteMap/isCommand to accept `unknown` so completions.ts can reuse them - completions.ts: Remove duplicate type guards (use introspect.ts); extractFlagEntries uses for..of; extractSubcommands uses filter.map; shellVarName uses whitelist (/[^a-zA-Z0-9_]/); bash/zsh standalone command completion at position 2; bash __varname uses same whitelist - complete.ts: Merge matchOrgSlugs into completeOrgSlugs with suffix param (no identity fn); eliminate [...nameMap.keys()] intermediate; export command sets for testing - completions.property.test.ts: Add drift-detection tests verifying hardcoded ORG_PROJECT_COMMANDS / ORG_ONLY_COMMANDS stay in sync with the actual route tree
1 parent 20f0186 commit 9e86526

File tree

5 files changed

+182
-97
lines changed

5 files changed

+182
-97
lines changed

src/bin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* the full CLI with telemetry, middleware, and error recovery.
77
*/
88

9+
import { disableDbTracing } from "./lib/db/index.js";
10+
911
// Exit cleanly when downstream pipe consumer closes (e.g., `sentry issue list | head`).
1012
// EPIPE (errno -32) is normal Unix behavior — not an error. Node.js/Bun ignore SIGPIPE
1113
// at the process level, so pipe write failures surface as async 'error' events on the
@@ -28,7 +30,6 @@ process.stderr.on("error", handleStreamError);
2830
* completion engine and SQLite cache modules.
2931
*/
3032
async function runCompletion(completionArgs: string[]): Promise<void> {
31-
const { disableDbTracing } = await import("./lib/db/index.js");
3233
disableDbTracing();
3334
const { handleComplete } = await import("./lib/complete.js");
3435
await handleComplete(completionArgs);

src/lib/complete.ts

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,16 @@ export async function handleComplete(args: string[]): Promise<void> {
6363
}
6464
}
6565

66-
/** Commands that accept org/project positional args. */
67-
const ORG_PROJECT_COMMANDS = new Set([
66+
/**
67+
* Commands that accept org/project positional args.
68+
*
69+
* Hardcoded for fast-path performance — cannot derive from the route map
70+
* at runtime because `app.ts` imports `@sentry/bun`. A property test in
71+
* `completions.property.test.ts` verifies this set stays in sync.
72+
*
73+
* @internal Exported for testing only.
74+
*/
75+
export const ORG_PROJECT_COMMANDS = new Set([
6876
"issue list",
6977
"issue view",
7078
"issue explain",
@@ -84,8 +92,12 @@ const ORG_PROJECT_COMMANDS = new Set([
8492
"dashboard list",
8593
]);
8694

87-
/** Commands that accept only an org slug (no project). */
88-
const ORG_ONLY_COMMANDS = new Set([
95+
/**
96+
* Commands that accept only an org slug (no project).
97+
*
98+
* @internal Exported for testing only.
99+
*/
100+
export const ORG_ONLY_COMMANDS = new Set([
89101
"org view",
90102
"team list",
91103
"repo list",
@@ -125,18 +137,18 @@ export async function getCompletions(
125137
}
126138

127139
/**
128-
* Fuzzy-match cached org slugs and build completions.
140+
* Complete organization slugs with fuzzy matching.
129141
*
130-
* Shared by both `completeOrgSlugs` (bare slugs) and the slash variant
131-
* (slugs with trailing `/`). Avoids duplicating the cache query, fuzzy
132-
* match, and name lookup logic.
142+
* Queries the org_regions cache for all known org slugs and matches
143+
* them against the partial input.
133144
*
134145
* @param partial - Partial org slug to match
135-
* @param formatValue - Transform a matched slug into the completion value
146+
* @param suffix - Appended to each slug (e.g., "/" for org/project mode)
147+
* @returns Completions with org names as descriptions
136148
*/
137-
async function matchOrgSlugs(
149+
export async function completeOrgSlugs(
138150
partial: string,
139-
formatValue: (slug: string) => string
151+
suffix = ""
140152
): Promise<Completion[]> {
141153
const orgs = await getCachedOrganizations();
142154
if (orgs.length === 0) {
@@ -147,26 +159,12 @@ async function matchOrgSlugs(
147159
const matched = fuzzyMatch(partial, slugs);
148160

149161
const nameMap = new Map(orgs.map((o) => [o.slug, o.name]));
150-
151162
return matched.map((slug) => ({
152-
value: formatValue(slug),
163+
value: `${slug}${suffix}`,
153164
description: nameMap.get(slug),
154165
}));
155166
}
156167

157-
/**
158-
* Complete organization slugs with fuzzy matching.
159-
*
160-
* Queries the org_regions cache for all known org slugs and matches
161-
* them against the partial input.
162-
*
163-
* @param partial - Partial org slug to match
164-
* @returns Completions with org names as descriptions
165-
*/
166-
export function completeOrgSlugs(partial: string): Promise<Completion[]> {
167-
return matchOrgSlugs(partial, (slug) => slug);
168-
}
169-
170168
/**
171169
* Complete the `org/project` positional pattern with fuzzy matching.
172170
*
@@ -218,7 +216,7 @@ export async function completeOrgSlashProject(
218216
* `sentry/` so they can continue typing the project name.
219217
*/
220218
function completeOrgSlugsWithSlash(partial: string): Promise<Completion[]> {
221-
return matchOrgSlugs(partial, (slug) => `${slug}/`);
219+
return completeOrgSlugs(partial, "/");
222220
}
223221

224222
/**

src/lib/completions.ts

Lines changed: 51 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import { existsSync, mkdirSync } from "node:fs";
1616
import { dirname, join } from "node:path";
1717
import { routes } from "../app.js";
18-
import type { FlagDef } from "./introspect.js";
18+
import { type FlagDef, isCommand, isRouteMap } from "./introspect.js";
1919
import type { ShellType } from "./shell.js";
2020

2121
/** Where completions are installed */
@@ -26,43 +26,6 @@ export type CompletionLocation = {
2626
created: boolean;
2727
};
2828

29-
/**
30-
* Check if a routing target is a route map (has subcommands).
31-
* Works with untyped Stricli route targets (avoids generic type constraints).
32-
*/
33-
function isRouteMap(target: unknown): target is {
34-
getAllEntries: () => readonly {
35-
name: { original: string };
36-
target: unknown;
37-
hidden: boolean;
38-
}[];
39-
brief: string;
40-
} {
41-
return (
42-
typeof target === "object" &&
43-
target !== null &&
44-
typeof (target as Record<string, unknown>).getAllEntries === "function"
45-
);
46-
}
47-
48-
/**
49-
* Check if a routing target is a command (has parameters with flags).
50-
* Works with untyped Stricli route targets.
51-
*/
52-
function isCommandTarget(target: unknown): target is {
53-
brief: string;
54-
parameters: {
55-
flags?: Record<string, FlagDef>;
56-
};
57-
} {
58-
return (
59-
typeof target === "object" &&
60-
target !== null &&
61-
"parameters" in target &&
62-
!("getAllEntries" in target)
63-
);
64-
}
65-
6629
/** Flag metadata for shell completion */
6730
type FlagEntry = {
6831
/** Flag name without `--` prefix (e.g., "limit") */
@@ -108,27 +71,29 @@ function extractFlagEntries(
10871
return [];
10972
}
11073

111-
return Object.entries(flags)
112-
.filter(([, def]) => !def.hidden)
113-
.map(([name, def]) => {
114-
const entry: FlagEntry = { name, brief: def.brief ?? "" };
115-
// Extract enum values if available
116-
if (def.kind === "enum" && "values" in def) {
117-
const enumDef = def as FlagDef & { values?: readonly string[] };
118-
if (enumDef.values) {
119-
entry.enumValues = [...enumDef.values];
120-
}
74+
const entries: FlagEntry[] = [];
75+
for (const [name, def] of Object.entries(flags)) {
76+
if (def.hidden) {
77+
continue;
78+
}
79+
const entry: FlagEntry = { name, brief: def.brief ?? "" };
80+
if (def.kind === "enum" && "values" in def) {
81+
const enumDef = def as FlagDef & { values?: readonly string[] };
82+
if (enumDef.values) {
83+
entry.enumValues = [...enumDef.values];
12184
}
122-
return entry;
123-
});
85+
}
86+
entries.push(entry);
87+
}
88+
return entries;
12489
}
12590

12691
/**
12792
* Build a CommandEntry from a route target with its name.
12893
* Extracts flags if the target is a command.
12994
*/
13095
function buildCommandEntry(name: string, target: unknown): CommandEntry {
131-
const flags = isCommandTarget(target)
96+
const flags = isCommand(target)
13297
? extractFlagEntries(target.parameters.flags)
13398
: [];
13499
return {
@@ -148,13 +113,10 @@ function extractSubcommands(routeMap: {
148113
hidden: boolean;
149114
}[];
150115
}): CommandEntry[] {
151-
const subcommands: CommandEntry[] = [];
152-
for (const sub of routeMap.getAllEntries()) {
153-
if (!sub.hidden) {
154-
subcommands.push(buildCommandEntry(sub.name.original, sub.target));
155-
}
156-
}
157-
return subcommands;
116+
return routeMap
117+
.getAllEntries()
118+
.filter((sub) => !sub.hidden)
119+
.map((sub) => buildCommandEntry(sub.name.original, sub.target));
158120
}
159121

160122
/**
@@ -191,10 +153,14 @@ export function extractCommandTree(): CommandTree {
191153

192154
/**
193155
* Sanitize a string for use in a shell variable name.
194-
* Replaces hyphens and dots with underscores.
156+
*
157+
* Uses a whitelist approach — replaces any character that is not a valid
158+
* shell identifier character (`[a-zA-Z0-9_]`) with an underscore.
159+
* Input comes from Stricli route/flag names (alphanumeric + hyphen),
160+
* but the whitelist guards against unexpected characters.
195161
*/
196162
function shellVarName(s: string): string {
197-
return s.replace(/[-. ]/g, "_");
163+
return s.replace(/[^a-zA-Z0-9_]/g, "_");
198164
}
199165

200166
/** Collected flag and enum variable declarations for bash scripts. */
@@ -314,8 +280,8 @@ export function generateBashCompletion(binaryName: string): string {
314280
# Auto-generated from command definitions
315281
316282
# Sanitize a string for use as a bash variable name suffix.
317-
# Matches shellVarName() in completions.ts: replaces hyphens, dots, spaces.
318-
__${binaryName}_varname() { echo "\${1//[-. ]/_}"; }
283+
# Mirrors shellVarName() in completions.tsreplaces non-identifier chars.
284+
__${binaryName}_varname() { echo "\${1//[^a-zA-Z0-9_]/_}"; }
319285
320286
_${binaryName}_completions() {
321287
local cur prev words cword
@@ -333,6 +299,25 @@ ${enumVarLines.join("\n")}
333299
2)
334300
case "\${prev}" in
335301
${caseBranches}
302+
*)
303+
# Standalone command: check for flags at position 2
304+
if [[ "\${cur}" == --* ]]; then
305+
local standalone_var="$(__${binaryName}_varname "\${prev}")_flags"
306+
if [[ -n "\${!standalone_var+x}" ]]; then
307+
COMPREPLY=($(compgen -W "\${!standalone_var}" -- "\${cur}"))
308+
fi
309+
else
310+
# Dynamic value completion for standalone commands
311+
local IFS=$'\\n'
312+
COMPREPLY=($(${binaryName} __complete "\${COMP_WORDS[@]:1}" 2>/dev/null))
313+
if [[ \${#COMPREPLY[@]} -gt 0 ]]; then
314+
local i
315+
for i in "\${!COMPREPLY[@]}"; do
316+
COMPREPLY[$i]="\${COMPREPLY[$i]%%$'\\t'*}"
317+
done
318+
fi
319+
fi
320+
;;
336321
esac
337322
;;
338323
*)
@@ -430,8 +415,11 @@ ${subArrays}
430415
subcommand)
431416
case "$line[1]" in
432417
${caseBranches}
418+
*)
419+
# Standalone command — delegate to dynamic completion
420+
;;
433421
esac
434-
;;
422+
;;&
435423
args)
436424
# Dynamic completion for positional args (org slugs, project names)
437425
# In the args state, $line[1] and $line[2] hold the parsed command

src/lib/introspect.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,28 @@ export type ResolvedPath =
111111

112112
/**
113113
* Check if a routing target is a RouteMap (has subcommands).
114-
* RouteMap has getAllEntries(), Command does not.
114+
*
115+
* Accepts `unknown` so callers (e.g., completions.ts) can use it on raw
116+
* Stricli route entries without first narrowing to {@link RouteTarget}.
115117
*/
116-
export function isRouteMap(target: RouteTarget): target is RouteMap {
117-
return "getAllEntries" in target;
118+
export function isRouteMap(target: unknown): target is RouteMap {
119+
return (
120+
typeof target === "object" && target !== null && "getAllEntries" in target
121+
);
118122
}
119123

120124
/**
121125
* Check if a routing target is a Command (has parameters).
122-
* Commands have parameters but no getAllEntries.
126+
*
127+
* Accepts `unknown` for the same reason as {@link isRouteMap}.
123128
*/
124-
export function isCommand(target: RouteTarget): target is Command {
125-
return "parameters" in target && !("getAllEntries" in target);
129+
export function isCommand(target: unknown): target is Command {
130+
return (
131+
typeof target === "object" &&
132+
target !== null &&
133+
"parameters" in target &&
134+
!("getAllEntries" in target)
135+
);
126136
}
127137

128138
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)