edsger 0.75.1 → 0.76.0

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.
@@ -5,9 +5,11 @@
5
5
  * and files each new finding as an issue.
6
6
  */
7
7
  export interface FindArchitectureCliOptions {
8
+ /** Repo-only mode: scan a single `repositories` row with no product. */
9
+ repoId?: string;
8
10
  full?: boolean;
9
11
  branch?: string;
10
12
  maxFiles?: number;
11
13
  verbose?: boolean;
12
14
  }
13
- export declare function runFindArchitecture(productId: string, options: FindArchitectureCliOptions): Promise<void>;
15
+ export declare function runFindArchitecture(productId: string | undefined, options: FindArchitectureCliOptions): Promise<void>;
@@ -4,22 +4,31 @@
4
4
  * coupling, cycles, missing/duplicated abstractions, responsibility creep)
5
5
  * and files each new finding as an issue.
6
6
  */
7
- import { getGitHubConfigByProduct } from '../../api/github.js';
7
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
8
8
  import { scanForArchitecture } from '../../phases/find-architecture/index.js';
9
9
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
10
10
  export async function runFindArchitecture(productId, options) {
11
- const { full, branch, maxFiles, verbose } = options;
12
- logInfo(`Starting architecture scan for product ${productId}`);
13
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
11
+ const { repoId, full, branch, maxFiles, verbose } = options;
12
+ if (!productId && !repoId) {
13
+ throw new Error('Provide a productId or --repo-id for find-architecture');
14
+ }
15
+ const scopeLabel = productId
16
+ ? `product ${productId}`
17
+ : `repository ${repoId}`;
18
+ logInfo(`Starting architecture scan for ${scopeLabel}`);
19
+ const githubConfig = productId
20
+ ? await getGitHubConfigByProduct(productId, verbose)
21
+ : await getGitHubConfigByRepository(repoId, verbose);
14
22
  if (!githubConfig.configured ||
15
23
  !githubConfig.token ||
16
24
  !githubConfig.owner ||
17
25
  !githubConfig.repo) {
18
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
26
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
19
27
  process.exit(1);
20
28
  }
21
29
  const result = await scanForArchitecture({
22
30
  productId,
31
+ repoId,
23
32
  githubToken: githubConfig.token,
24
33
  owner: githubConfig.owner,
25
34
  repo: githubConfig.repo,
@@ -3,9 +3,11 @@
3
3
  * Audits the product's repository for bugs and files each new finding as an issue.
4
4
  */
5
5
  export interface FindBugsCliOptions {
6
+ /** Repo-only mode: scan a single `repositories` row with no product. */
7
+ repoId?: string;
6
8
  full?: boolean;
7
9
  branch?: string;
8
10
  maxFiles?: number;
9
11
  verbose?: boolean;
10
12
  }
11
- export declare function runFindBugs(productId: string, options: FindBugsCliOptions): Promise<void>;
13
+ export declare function runFindBugs(productId: string | undefined, options: FindBugsCliOptions): Promise<void>;
@@ -2,22 +2,31 @@
2
2
  * CLI command: edsger find-bugs <productId>
3
3
  * Audits the product's repository for bugs and files each new finding as an issue.
4
4
  */
5
- import { getGitHubConfigByProduct } from '../../api/github.js';
5
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
6
6
  import { scanForBugs } from '../../phases/find-bugs/index.js';
7
7
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
8
8
  export async function runFindBugs(productId, options) {
9
- const { full, branch, maxFiles, verbose } = options;
10
- logInfo(`Starting bug scan for product ${productId}`);
11
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
9
+ const { repoId, full, branch, maxFiles, verbose } = options;
10
+ if (!productId && !repoId) {
11
+ throw new Error('Provide a productId or --repo-id for find-bugs');
12
+ }
13
+ const scopeLabel = productId
14
+ ? `product ${productId}`
15
+ : `repository ${repoId}`;
16
+ logInfo(`Starting bug scan for ${scopeLabel}`);
17
+ const githubConfig = productId
18
+ ? await getGitHubConfigByProduct(productId, verbose)
19
+ : await getGitHubConfigByRepository(repoId, verbose);
12
20
  if (!githubConfig.configured ||
13
21
  !githubConfig.token ||
14
22
  !githubConfig.owner ||
15
23
  !githubConfig.repo) {
16
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
24
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
17
25
  process.exit(1);
18
26
  }
19
27
  const result = await scanForBugs({
20
28
  productId,
29
+ repoId,
21
30
  githubToken: githubConfig.token,
22
31
  owner: githubConfig.owner,
23
32
  repo: githubConfig.repo,
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { type SmellCategory } from '../../phases/find-smells/types.js';
7
7
  export interface FindSmellsCliOptions {
8
+ /** Repo-only mode: scan a single `repositories` row with no product. */
9
+ repoId?: string;
8
10
  full?: boolean;
9
11
  branch?: string;
10
12
  maxFiles?: number;
@@ -18,4 +20,4 @@ export interface FindSmellsCliOptions {
18
20
  * matches user intuition for `--categories=`.
19
21
  */
20
22
  export declare function parseCategoriesOption(raw: string): SmellCategory[] | undefined;
21
- export declare function runFindSmells(productId: string, options: FindSmellsCliOptions): Promise<void>;
23
+ export declare function runFindSmells(productId: string | undefined, options: FindSmellsCliOptions): Promise<void>;
@@ -3,7 +3,7 @@
3
3
  * Audits the product's repository for code smells (refactor candidates, perf
4
4
  * cliffs, dead code, etc.) and files each new finding as an issue.
5
5
  */
6
- import { getGitHubConfigByProduct } from '../../api/github.js';
6
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
7
7
  import { scanForSmells } from '../../phases/find-smells/index.js';
8
8
  import { isSmellCategory, SMELL_CATEGORIES, } from '../../phases/find-smells/types.js';
9
9
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
@@ -31,18 +31,27 @@ export function parseCategoriesOption(raw) {
31
31
  return Array.from(new Set(tokens));
32
32
  }
33
33
  export async function runFindSmells(productId, options) {
34
- const { full, branch, maxFiles, categories, verbose } = options;
35
- logInfo(`Starting smell scan for product ${productId}`);
36
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
34
+ const { repoId, full, branch, maxFiles, categories, verbose } = options;
35
+ if (!productId && !repoId) {
36
+ throw new Error('Provide a productId or --repo-id for find-smells');
37
+ }
38
+ const scopeLabel = productId
39
+ ? `product ${productId}`
40
+ : `repository ${repoId}`;
41
+ logInfo(`Starting smell scan for ${scopeLabel}`);
42
+ const githubConfig = productId
43
+ ? await getGitHubConfigByProduct(productId, verbose)
44
+ : await getGitHubConfigByRepository(repoId, verbose);
37
45
  if (!githubConfig.configured ||
38
46
  !githubConfig.token ||
39
47
  !githubConfig.owner ||
40
48
  !githubConfig.repo) {
41
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
49
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
42
50
  process.exit(1);
43
51
  }
44
52
  const result = await scanForSmells({
45
53
  productId,
54
+ repoId,
46
55
  githubToken: githubConfig.token,
47
56
  owner: githubConfig.owner,
48
57
  repo: githubConfig.repo,
package/dist/index.js CHANGED
@@ -802,8 +802,9 @@ program
802
802
  // Subcommand: edsger find-bugs <productId>
803
803
  // ============================================================
804
804
  program
805
- .command('find-bugs <productId>')
806
- .description("AI-audit a product's repository for bugs and file each new finding as an issue")
805
+ .command('find-bugs [productId]')
806
+ .description("AI-audit a product's (or a standalone repository's) repo for bugs and file each new finding as an issue")
807
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
807
808
  .option('--full', 'Force a full scan even if previous-scan state exists')
808
809
  .option('--branch <name>', 'Branch to scan (defaults to repo default branch)')
809
810
  .option('--max-files <n>', 'Upper bound on files the auditor may Read (default 200)', (value) => {
@@ -1030,8 +1031,9 @@ program
1030
1031
  // Subcommand: edsger find-smells <productId>
1031
1032
  // ============================================================
1032
1033
  program
1033
- .command('find-smells <productId>')
1034
- .description("AI-audit a product's repository for code smells (refactor candidates, perf cliffs, dead code, type-safety gaps) and file each new finding as an issue")
1034
+ .command('find-smells [productId]')
1035
+ .description("AI-audit a product's (or a standalone repository's) repo for code smells (refactor candidates, perf cliffs, dead code, type-safety gaps) and file each new finding as an issue")
1036
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
1035
1037
  .option('--full', 'Force a full scan even if previous-scan state exists')
1036
1038
  .option('--branch <name>', 'Branch to scan (defaults to repo default branch)')
1037
1039
  .option('--max-files <n>', `Upper bound on files the auditor may Read (default ${FIND_SMELLS_DEFAULT_MAX_FILES})`, (value) => {
@@ -1055,8 +1057,9 @@ program
1055
1057
  // Subcommand: edsger find-architecture <productId>
1056
1058
  // ============================================================
1057
1059
  program
1058
- .command('find-architecture <productId>')
1059
- .description("AI-audit a product's repository for architectural concerns (layering, coupling, cycles, missing/duplicated abstractions, responsibility creep) and file each new finding as an issue")
1060
+ .command('find-architecture [productId]')
1061
+ .description("AI-audit a product's (or a standalone repository's) repo for architectural concerns (layering, coupling, cycles, missing/duplicated abstractions, responsibility creep) and file each new finding as an issue")
1062
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
1060
1063
  .option('--full', 'Force a full scan even if previous-scan state exists')
1061
1064
  .option('--branch <name>', 'Branch to scan (defaults to repo default branch)')
1062
1065
  .option('--max-files <n>', `Upper bound on files the auditor may Read (default ${FIND_ARCHITECTURE_DEFAULT_MAX_FILES})`, (value) => {
@@ -9,7 +9,10 @@
9
9
  * phases share the same workspace + state pattern.
10
10
  */
11
11
  export interface FindArchitectureOptions {
12
- productId: string;
12
+ /** Product scope. Provide this OR repoId (repo-scoped scan). */
13
+ productId?: string;
14
+ /** Repository scope: scan a bare `repositories` row with no product. */
15
+ repoId?: string;
13
16
  githubToken: string;
14
17
  owner: string;
15
18
  repo: string;
@@ -13,7 +13,7 @@ import { DEFAULT_MODEL } from '../../constants.js';
13
13
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
14
14
  import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, syncRepoToRef, } from '../../workspace/workspace-manager.js';
15
15
  import { detectDefaultBranch, gitRevParse, isAncestor, listChangedPaths, } from '../find-shared/git.js';
16
- import { createIssue, fetchOpenIssues, fetchProductBasics, } from '../find-shared/mcp.js';
16
+ import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
17
17
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
18
18
  import { createFindArchitectureSystemPrompt, createFindArchitectureUserPrompt, } from './prompts.js';
19
19
  import { acquireFindArchitectureLock, loadFindArchitectureState, updateFindArchitectureState, } from './state.js';
@@ -33,30 +33,36 @@ const MAX_TURNS = 200;
33
33
  */
34
34
  // eslint-disable-next-line complexity
35
35
  export async function scanForArchitecture(options) {
36
- const { productId, githubToken, owner, repo, full, maxFiles, verbose } = options;
37
- logInfo(`Starting architecture scan for product ${productId} (${owner}/${repo})`);
38
- const lock = acquireFindArchitectureLock(productId);
36
+ const { productId, repoId, githubToken, owner, repo, full, maxFiles, verbose } = options;
37
+ // State/lock/workspace are keyed by an opaque scope id so product and repo
38
+ // scans never collide; repo keys are prefixed to namespace them clearly.
39
+ const scopeId = productId ?? `repo-${repoId}`;
40
+ const scopeLabel = productId
41
+ ? `product ${productId}`
42
+ : `repository ${repoId}`;
43
+ logInfo(`Starting architecture scan for ${scopeLabel} (${owner}/${repo})`);
44
+ const lock = acquireFindArchitectureLock(scopeId);
39
45
  if (!lock) {
40
- logWarning(`Another architecture scan is already in progress for product ${productId}; skipping.`);
46
+ logWarning(`Another architecture scan is already in progress for ${scopeLabel}; skipping.`);
41
47
  return {
42
48
  status: 'error',
43
- message: 'Another architecture scan is already in progress for this product',
49
+ message: 'Another architecture scan is already in progress for this scope',
44
50
  };
45
51
  }
46
52
  let repoPath;
47
53
  let scanSucceeded = false;
48
54
  try {
49
- updateFindArchitectureState(productId, {
55
+ updateFindArchitectureState(scopeId, {
50
56
  lastAttemptedAt: new Date().toISOString(),
51
57
  });
52
58
  const workspaceRoot = ensureWorkspaceDir();
53
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
59
+ const repoKey = `${WORKSPACE_KEY}-${scopeId}`;
54
60
  ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, owner, repo, githubToken));
55
61
  const branch = options.branch ?? detectDefaultBranch(repoPath);
56
62
  logInfo(`Syncing ${owner}/${repo} to branch ${branch}`);
57
63
  syncRepoToRef(repoPath, { branch }, githubToken);
58
64
  const headSha = gitRevParse(repoPath, 'HEAD');
59
- const state = loadFindArchitectureState(productId);
65
+ const state = loadFindArchitectureState(scopeId);
60
66
  const baseSha = full ? undefined : state.lastScannedCommitSha;
61
67
  let scope = 'full';
62
68
  let changedPaths;
@@ -68,7 +74,7 @@ export async function scanForArchitecture(options) {
68
74
  logInfo(`Incremental scan: ${changedPaths.length} files changed since ${baseSha.slice(0, 8)}`);
69
75
  if (changedPaths.length === 0) {
70
76
  logSuccess('No code changes since last scan; nothing to do.');
71
- updateFindArchitectureState(productId, {
77
+ updateFindArchitectureState(scopeId, {
72
78
  lastScannedCommitSha: headSha,
73
79
  lastScannedAt: new Date().toISOString(),
74
80
  lastError: undefined,
@@ -89,7 +95,7 @@ export async function scanForArchitecture(options) {
89
95
  }
90
96
  else if (baseSha === headSha) {
91
97
  logSuccess('HEAD unchanged since last scan; nothing to do.');
92
- updateFindArchitectureState(productId, {
98
+ updateFindArchitectureState(scopeId, {
93
99
  lastScannedAt: new Date().toISOString(),
94
100
  lastError: undefined,
95
101
  });
@@ -102,8 +108,12 @@ export async function scanForArchitecture(options) {
102
108
  issuesCreated: 0,
103
109
  };
104
110
  }
105
- const product = await fetchProductBasics(productId);
106
- const existingIssues = await fetchOpenIssues(productId);
111
+ const product = productId
112
+ ? await fetchProductBasics(productId)
113
+ : await fetchRepositoryBasics(repoId);
114
+ const existingIssues = productId
115
+ ? await fetchOpenIssues(productId)
116
+ : await fetchOpenIssuesByRepo(repoId);
107
117
  logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
108
118
  const systemPrompt = createFindArchitectureSystemPrompt();
109
119
  const userPrompt = createFindArchitectureUserPrompt({
@@ -157,7 +167,7 @@ export async function scanForArchitecture(options) {
157
167
  }
158
168
  if (!scanResult) {
159
169
  const msg = 'Audit failed: could not parse a scan_result from the agent';
160
- updateFindArchitectureState(productId, { lastError: msg });
170
+ updateFindArchitectureState(scopeId, { lastError: msg });
161
171
  return {
162
172
  status: 'error',
163
173
  message: msg,
@@ -168,18 +178,20 @@ export async function scanForArchitecture(options) {
168
178
  const deferredBugs = scanResult.deferred_to_bugs ?? [];
169
179
  const deferredFeatures = scanResult.deferred_to_features ?? [];
170
180
  const deferredSmells = scanResult.deferred_to_smells ?? [];
171
- logDeferred(deferredBugs, 'find-bugs', productId);
172
- logDeferred(deferredFeatures, 'find-features', productId);
173
- logDeferred(deferredSmells, 'find-smells', productId);
181
+ // CLI suggestion argument: a productId positional, or the --repo-id flag.
182
+ const scopeArg = productId ?? `--repo-id ${repoId}`;
183
+ logDeferred(deferredBugs, 'find-bugs', scopeArg);
184
+ logDeferred(deferredFeatures, 'find-features', scopeArg);
185
+ logDeferred(deferredSmells, 'find-smells', scopeArg);
174
186
  let created = 0;
175
187
  for (const finding of findings) {
176
- const issueId = await createIssueForFinding(productId, finding);
188
+ const issueId = await createIssueForFinding({ productId, repoId }, finding);
177
189
  if (issueId) {
178
190
  created++;
179
191
  logSuccess(`Filed issue ${issueId}: ${finding.title}`);
180
192
  }
181
193
  }
182
- updateFindArchitectureState(productId, {
194
+ updateFindArchitectureState(scopeId, {
183
195
  lastScannedCommitSha: headSha,
184
196
  lastScannedAt: new Date().toISOString(),
185
197
  lastError: undefined,
@@ -200,7 +212,7 @@ export async function scanForArchitecture(options) {
200
212
  catch (error) {
201
213
  const errorMessage = error instanceof Error ? error.message : String(error);
202
214
  logError(`Architecture scan failed: ${errorMessage}`);
203
- updateFindArchitectureState(productId, { lastError: errorMessage });
215
+ updateFindArchitectureState(scopeId, { lastError: errorMessage });
204
216
  return {
205
217
  status: 'error',
206
218
  message: `Architecture scan failed: ${errorMessage}`,
@@ -213,19 +225,20 @@ export async function scanForArchitecture(options) {
213
225
  lock.release();
214
226
  }
215
227
  }
216
- function logDeferred(deferred, siblingPhase, productId) {
228
+ function logDeferred(deferred, siblingPhase, scopeArg) {
217
229
  if (deferred.length === 0) {
218
230
  return;
219
231
  }
220
- logInfo(`${deferred.length} finding(s) deferred to ${siblingPhase} — run \`edsger ${siblingPhase} ${productId}\` to pick them up:`);
232
+ logInfo(`${deferred.length} finding(s) deferred to ${siblingPhase} — run \`edsger ${siblingPhase} ${scopeArg}\` to pick them up:`);
221
233
  for (const d of deferred) {
222
234
  const loc = d.file ? ` (${d.file}${d.line ? `:${d.line}` : ''})` : '';
223
235
  logInfo(` • ${d.title}${loc} — ${d.reason}`);
224
236
  }
225
237
  }
226
- async function createIssueForFinding(productId, finding) {
238
+ async function createIssueForFinding(scope, finding) {
227
239
  return createIssue({
228
- productId,
240
+ productId: scope.productId,
241
+ repoId: scope.repoId,
229
242
  title: finding.title,
230
243
  description: formatIssueDescription(finding),
231
244
  });
@@ -4,7 +4,10 @@
4
4
  * incremental, scoped to commits since the previous successful scan.
5
5
  */
6
6
  export interface FindBugsOptions {
7
- productId: string;
7
+ /** Product scope. Provide this OR repoId (repo-scoped scan). */
8
+ productId?: string;
9
+ /** Repository scope: scan a bare `repositories` row with no product. */
10
+ repoId?: string;
8
11
  githubToken: string;
9
12
  owner: string;
10
13
  repo: string;
@@ -8,7 +8,7 @@ import { DEFAULT_MODEL } from '../../constants.js';
8
8
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
9
9
  import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, syncRepoToRef, } from '../../workspace/workspace-manager.js';
10
10
  import { detectDefaultBranch, gitRevParse, isAncestor, listChangedPaths, } from '../find-shared/git.js';
11
- import { createIssue, fetchOpenIssues, fetchProductBasics, } from '../find-shared/mcp.js';
11
+ import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
12
12
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
13
13
  import { createFindBugsSystemPrompt, createFindBugsUserPrompt, } from './prompts.js';
14
14
  import { acquireFindBugsLock, loadFindBugsState, updateFindBugsState, } from './state.js';
@@ -32,33 +32,39 @@ const MAX_TURNS = 200;
32
32
  */
33
33
  // eslint-disable-next-line complexity
34
34
  export async function scanForBugs(options) {
35
- const { productId, githubToken, owner, repo, full, maxFiles, verbose } = options;
36
- logInfo(`Starting bug scan for product ${productId} (${owner}/${repo})`);
37
- const lock = acquireFindBugsLock(productId);
35
+ const { productId, repoId, githubToken, owner, repo, full, maxFiles, verbose } = options;
36
+ // State/lock/workspace are keyed by an opaque scope id so product and repo
37
+ // scans never collide; repo keys are prefixed to namespace them clearly.
38
+ const scopeId = productId ?? `repo-${repoId}`;
39
+ const scopeLabel = productId
40
+ ? `product ${productId}`
41
+ : `repository ${repoId}`;
42
+ logInfo(`Starting bug scan for ${scopeLabel} (${owner}/${repo})`);
43
+ const lock = acquireFindBugsLock(scopeId);
38
44
  if (!lock) {
39
- logWarning(`Another bug scan is already in progress for product ${productId}; skipping.`);
45
+ logWarning(`Another bug scan is already in progress for ${scopeLabel}; skipping.`);
40
46
  return {
41
47
  status: 'error',
42
- message: 'Another bug scan is already in progress for this product',
48
+ message: 'Another bug scan is already in progress for this scope',
43
49
  };
44
50
  }
45
51
  let repoPath;
46
52
  let scanSucceeded = false;
47
53
  try {
48
- updateFindBugsState(productId, {
54
+ updateFindBugsState(scopeId, {
49
55
  lastAttemptedAt: new Date().toISOString(),
50
56
  });
51
57
  const workspaceRoot = ensureWorkspaceDir();
52
58
  // Each run re-clones into a per-product directory and removes it on
53
59
  // success. Incremental scope is recovered from the persisted state file
54
60
  // (~/.edsger/find-bugs-state/<productId>.json), not from the workspace.
55
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
61
+ const repoKey = `${WORKSPACE_KEY}-${scopeId}`;
56
62
  ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, owner, repo, githubToken));
57
63
  const branch = options.branch ?? detectDefaultBranch(repoPath);
58
64
  logInfo(`Syncing ${owner}/${repo} to branch ${branch}`);
59
65
  syncRepoToRef(repoPath, { branch }, githubToken);
60
66
  const headSha = gitRevParse(repoPath, 'HEAD');
61
- const state = loadFindBugsState(productId);
67
+ const state = loadFindBugsState(scopeId);
62
68
  const baseSha = full ? undefined : state.lastScannedCommitSha;
63
69
  let scope = 'full';
64
70
  let changedPaths;
@@ -70,7 +76,7 @@ export async function scanForBugs(options) {
70
76
  logInfo(`Incremental scan: ${changedPaths.length} files changed since ${baseSha.slice(0, 8)}`);
71
77
  if (changedPaths.length === 0) {
72
78
  logSuccess('No code changes since last scan; nothing to do.');
73
- updateFindBugsState(productId, {
79
+ updateFindBugsState(scopeId, {
74
80
  lastScannedCommitSha: headSha,
75
81
  lastScannedAt: new Date().toISOString(),
76
82
  lastError: undefined,
@@ -100,8 +106,12 @@ export async function scanForBugs(options) {
100
106
  issuesCreated: 0,
101
107
  };
102
108
  }
103
- const product = await fetchProductBasics(productId);
104
- const existingIssues = await fetchOpenIssues(productId);
109
+ const product = productId
110
+ ? await fetchProductBasics(productId)
111
+ : await fetchRepositoryBasics(repoId);
112
+ const existingIssues = productId
113
+ ? await fetchOpenIssues(productId)
114
+ : await fetchOpenIssuesByRepo(repoId);
105
115
  logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
106
116
  const systemPrompt = createFindBugsSystemPrompt();
107
117
  const userPrompt = createFindBugsUserPrompt({
@@ -155,7 +165,7 @@ export async function scanForBugs(options) {
155
165
  }
156
166
  if (!scanResult) {
157
167
  const msg = 'Audit failed: could not parse a scan_result from the agent';
158
- updateFindBugsState(productId, { lastError: msg });
168
+ updateFindBugsState(scopeId, { lastError: msg });
159
169
  return {
160
170
  status: 'error',
161
171
  message: msg,
@@ -165,13 +175,13 @@ export async function scanForBugs(options) {
165
175
  logInfo(`Audit produced ${bugs.length} candidate bugs. ${summary}`);
166
176
  let created = 0;
167
177
  for (const bug of bugs) {
168
- const issueId = await createIssueForBug(productId, bug);
178
+ const issueId = await createIssueForBug({ productId, repoId }, bug);
169
179
  if (issueId) {
170
180
  created++;
171
181
  logSuccess(`Filed issue ${issueId}: ${bug.title}`);
172
182
  }
173
183
  }
174
- updateFindBugsState(productId, {
184
+ updateFindBugsState(scopeId, {
175
185
  lastScannedCommitSha: headSha,
176
186
  lastScannedAt: new Date().toISOString(),
177
187
  lastError: undefined,
@@ -189,7 +199,7 @@ export async function scanForBugs(options) {
189
199
  catch (error) {
190
200
  const errorMessage = error instanceof Error ? error.message : String(error);
191
201
  logError(`Bug scan failed: ${errorMessage}`);
192
- updateFindBugsState(productId, { lastError: errorMessage });
202
+ updateFindBugsState(scopeId, { lastError: errorMessage });
193
203
  return {
194
204
  status: 'error',
195
205
  message: `Bug scan failed: ${errorMessage}`,
@@ -202,9 +212,10 @@ export async function scanForBugs(options) {
202
212
  lock.release();
203
213
  }
204
214
  }
205
- async function createIssueForBug(productId, bug) {
215
+ async function createIssueForBug(scope, bug) {
206
216
  return createIssue({
207
- productId,
217
+ productId: scope.productId,
218
+ repoId: scope.repoId,
208
219
  title: bug.title,
209
220
  description: formatIssueDescription(bug),
210
221
  });
@@ -12,22 +12,37 @@ export interface ProductBasics {
12
12
  description?: string;
13
13
  }
14
14
  export declare function fetchProductBasics(productId: string): Promise<ProductBasics>;
15
+ /**
16
+ * Build prompt "product basics" from a bare repository row (repo-scoped scans
17
+ * have no product to read). Falls back to the repo id as the display name.
18
+ */
19
+ export declare function fetchRepositoryBasics(repoId: string): Promise<ProductBasics>;
15
20
  /**
16
21
  * Fetch the product's open issues for dedup context. Filters out terminal
17
22
  * statuses (shipped/archived/closed/...) since those can't conflict with new
18
23
  * findings the agent might surface.
19
24
  */
20
25
  export declare function fetchOpenIssues(productId: string): Promise<IssueInfo[]>;
26
+ /**
27
+ * Same as `fetchOpenIssues` but scoped to a single repository (repo-scoped
28
+ * scans). Used for dedup context when filing findings against a bare repo.
29
+ */
30
+ export declare function fetchOpenIssuesByRepo(repoId: string): Promise<IssueInfo[]>;
21
31
  export declare function isTerminalStatus(status: string): boolean;
22
32
  export interface CreateIssueInput {
23
- productId: string;
33
+ /** Product scope. Provide this OR repoId (repo-scoped scans). */
34
+ productId?: string;
35
+ /** Repository scope. Provide this OR productId. */
36
+ repoId?: string;
24
37
  /** Issue title for `name`. */
25
38
  title: string;
26
39
  /** Already-formatted markdown body. The caller decides the layout. */
27
40
  description: string;
28
41
  }
29
42
  /**
30
- * File a new issue via MCP. Returns the new issue id, or null if MCP returned
31
- * an error / unexpected shape (already logged).
43
+ * File a new issue via MCP. The issue is scoped to a product OR a single
44
+ * repository exactly one of `productId` / `repoId` should be set. Returns the
45
+ * new issue id, or null if MCP returned an error / unexpected shape (already
46
+ * logged).
32
47
  */
33
48
  export declare function createIssue(input: CreateIssueInput): Promise<string | null>;
@@ -6,6 +6,7 @@
6
6
  * Centralising the calls means a schema change in MCP only needs touching one
7
7
  * place, and the per-phase orchestrators stay focused on their own logic.
8
8
  */
9
+ import { getRepositoryBasics } from '../../api/github.js';
9
10
  import { callMcpEndpoint } from '../../api/mcp-client.js';
10
11
  import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
11
12
  import { logError, logWarning } from '../../utils/logger.js';
@@ -25,6 +26,17 @@ export async function fetchProductBasics(productId) {
25
26
  return { name: productId };
26
27
  }
27
28
  }
29
+ /**
30
+ * Build prompt "product basics" from a bare repository row (repo-scoped scans
31
+ * have no product to read). Falls back to the repo id as the display name.
32
+ */
33
+ export async function fetchRepositoryBasics(repoId) {
34
+ const basics = await getRepositoryBasics(repoId).catch(() => null);
35
+ return {
36
+ name: basics?.fullName || repoId,
37
+ description: basics?.description ?? undefined,
38
+ };
39
+ }
28
40
  /**
29
41
  * Fetch the product's open issues for dedup context. Filters out terminal
30
42
  * statuses (shipped/archived/closed/...) since those can't conflict with new
@@ -43,6 +55,23 @@ export async function fetchOpenIssues(productId) {
43
55
  return [];
44
56
  }
45
57
  }
58
+ /**
59
+ * Same as `fetchOpenIssues` but scoped to a single repository (repo-scoped
60
+ * scans). Used for dedup context when filing findings against a bare repo.
61
+ */
62
+ export async function fetchOpenIssuesByRepo(repoId) {
63
+ try {
64
+ const result = (await callMcpEndpoint('issues/list', {
65
+ repository_id: repoId,
66
+ }));
67
+ const all = result.issues || [];
68
+ return all.filter((i) => !isTerminalStatus(i.status));
69
+ }
70
+ catch (error) {
71
+ logWarning(`Could not load existing issues for dedup: ${error instanceof Error ? error.message : String(error)}`);
72
+ return [];
73
+ }
74
+ }
46
75
  export function isTerminalStatus(status) {
47
76
  return (status === 'shipped' ||
48
77
  status === 'archived' ||
@@ -51,17 +80,22 @@ export function isTerminalStatus(status) {
51
80
  status === 'completed');
52
81
  }
53
82
  /**
54
- * File a new issue via MCP. Returns the new issue id, or null if MCP returned
55
- * an error / unexpected shape (already logged).
83
+ * File a new issue via MCP. The issue is scoped to a product OR a single
84
+ * repository exactly one of `productId` / `repoId` should be set. Returns the
85
+ * new issue id, or null if MCP returned an error / unexpected shape (already
86
+ * logged).
56
87
  */
57
88
  export async function createIssue(input) {
89
+ const productId = input.productId ?? null;
90
+ const repositoryId = input.repoId ?? null;
58
91
  try {
59
92
  if (hasSupabaseSession()) {
60
93
  try {
61
94
  const { data, error } = await getSupabase()
62
95
  .from('issues')
63
96
  .insert({
64
- product_id: input.productId,
97
+ product_id: productId,
98
+ repository_id: repositoryId,
65
99
  name: input.title,
66
100
  description: input.description,
67
101
  })
@@ -77,7 +111,8 @@ export async function createIssue(input) {
77
111
  }
78
112
  }
79
113
  const result = (await callMcpEndpoint('issues/create', {
80
- product_id: input.productId,
114
+ ...(productId ? { product_id: productId } : {}),
115
+ ...(repositoryId ? { repository_id: repositoryId } : {}),
81
116
  name: input.title,
82
117
  description: input.description,
83
118
  }));
@@ -9,7 +9,10 @@
9
9
  */
10
10
  import { type SmellCategory } from './types.js';
11
11
  export interface FindSmellsOptions {
12
- productId: string;
12
+ /** Product scope. Provide this OR repoId (repo-scoped scan). */
13
+ productId?: string;
14
+ /** Repository scope: scan a bare `repositories` row with no product. */
15
+ repoId?: string;
13
16
  githubToken: string;
14
17
  owner: string;
15
18
  repo: string;
@@ -12,7 +12,7 @@ import { DEFAULT_MODEL } from '../../constants.js';
12
12
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
13
13
  import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, syncRepoToRef, } from '../../workspace/workspace-manager.js';
14
14
  import { detectDefaultBranch, gitRevParse, isAncestor, listChangedPaths, } from '../find-shared/git.js';
15
- import { createIssue, fetchOpenIssues, fetchProductBasics, } from '../find-shared/mcp.js';
15
+ import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
16
16
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
17
17
  import { createFindSmellsSystemPrompt, createFindSmellsUserPrompt, } from './prompts.js';
18
18
  import { acquireFindSmellsLock, loadFindSmellsState, updateFindSmellsState, } from './state.js';
@@ -31,30 +31,36 @@ const MAX_TURNS = 200;
31
31
  */
32
32
  // eslint-disable-next-line complexity
33
33
  export async function scanForSmells(options) {
34
- const { productId, githubToken, owner, repo, full, maxFiles, categories, verbose, } = options;
35
- logInfo(`Starting smell scan for product ${productId} (${owner}/${repo})`);
36
- const lock = acquireFindSmellsLock(productId);
34
+ const { productId, repoId, githubToken, owner, repo, full, maxFiles, categories, verbose, } = options;
35
+ // State/lock/workspace are keyed by an opaque scope id so product and repo
36
+ // scans never collide; repo keys are prefixed to namespace them clearly.
37
+ const scopeId = productId ?? `repo-${repoId}`;
38
+ const scopeLabel = productId
39
+ ? `product ${productId}`
40
+ : `repository ${repoId}`;
41
+ logInfo(`Starting smell scan for ${scopeLabel} (${owner}/${repo})`);
42
+ const lock = acquireFindSmellsLock(scopeId);
37
43
  if (!lock) {
38
- logWarning(`Another smell scan is already in progress for product ${productId}; skipping.`);
44
+ logWarning(`Another smell scan is already in progress for ${scopeLabel}; skipping.`);
39
45
  return {
40
46
  status: 'error',
41
- message: 'Another smell scan is already in progress for this product',
47
+ message: 'Another smell scan is already in progress for this scope',
42
48
  };
43
49
  }
44
50
  let repoPath;
45
51
  let scanSucceeded = false;
46
52
  try {
47
- updateFindSmellsState(productId, {
53
+ updateFindSmellsState(scopeId, {
48
54
  lastAttemptedAt: new Date().toISOString(),
49
55
  });
50
56
  const workspaceRoot = ensureWorkspaceDir();
51
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
57
+ const repoKey = `${WORKSPACE_KEY}-${scopeId}`;
52
58
  ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, owner, repo, githubToken));
53
59
  const branch = options.branch ?? detectDefaultBranch(repoPath);
54
60
  logInfo(`Syncing ${owner}/${repo} to branch ${branch}`);
55
61
  syncRepoToRef(repoPath, { branch }, githubToken);
56
62
  const headSha = gitRevParse(repoPath, 'HEAD');
57
- const state = loadFindSmellsState(productId);
63
+ const state = loadFindSmellsState(scopeId);
58
64
  const baseSha = full ? undefined : state.lastScannedCommitSha;
59
65
  let scope = 'full';
60
66
  let changedPaths;
@@ -66,7 +72,7 @@ export async function scanForSmells(options) {
66
72
  logInfo(`Incremental scan: ${changedPaths.length} files changed since ${baseSha.slice(0, 8)}`);
67
73
  if (changedPaths.length === 0) {
68
74
  logSuccess('No code changes since last scan; nothing to do.');
69
- updateFindSmellsState(productId, {
75
+ updateFindSmellsState(scopeId, {
70
76
  lastScannedCommitSha: headSha,
71
77
  lastScannedAt: new Date().toISOString(),
72
78
  lastError: undefined,
@@ -90,7 +96,7 @@ export async function scanForSmells(options) {
90
96
  // the sha didn't move, advance lastScannedAt + clear lastError so the
91
97
  // state file accurately reflects when we last verified the repo.
92
98
  logSuccess('HEAD unchanged since last scan; nothing to do.');
93
- updateFindSmellsState(productId, {
99
+ updateFindSmellsState(scopeId, {
94
100
  lastScannedAt: new Date().toISOString(),
95
101
  lastError: undefined,
96
102
  });
@@ -103,8 +109,12 @@ export async function scanForSmells(options) {
103
109
  issuesCreated: 0,
104
110
  };
105
111
  }
106
- const product = await fetchProductBasics(productId);
107
- const existingIssues = await fetchOpenIssues(productId);
112
+ const product = productId
113
+ ? await fetchProductBasics(productId)
114
+ : await fetchRepositoryBasics(repoId);
115
+ const existingIssues = productId
116
+ ? await fetchOpenIssues(productId)
117
+ : await fetchOpenIssuesByRepo(repoId);
108
118
  logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
109
119
  const systemPrompt = createFindSmellsSystemPrompt();
110
120
  const userPrompt = createFindSmellsUserPrompt({
@@ -159,7 +169,7 @@ export async function scanForSmells(options) {
159
169
  }
160
170
  if (!scanResult) {
161
171
  const msg = 'Audit failed: could not parse a scan_result from the agent';
162
- updateFindSmellsState(productId, { lastError: msg });
172
+ updateFindSmellsState(scopeId, { lastError: msg });
163
173
  return {
164
174
  status: 'error',
165
175
  message: msg,
@@ -169,15 +179,17 @@ export async function scanForSmells(options) {
169
179
  logInfo(`Audit produced ${smells.length} candidate smells. ${summary}`);
170
180
  const deferredBugs = scanResult.deferred_to_bugs ?? [];
171
181
  const deferredFeatures = scanResult.deferred_to_features ?? [];
182
+ // CLI suggestion argument: a productId positional, or the --repo-id flag.
183
+ const scopeArg = productId ?? `--repo-id ${repoId}`;
172
184
  if (deferredBugs.length > 0) {
173
- logInfo(`${deferredBugs.length} finding(s) deferred to find-bugs — run \`edsger find-bugs ${productId}\` to pick them up:`);
185
+ logInfo(`${deferredBugs.length} finding(s) deferred to find-bugs — run \`edsger find-bugs ${scopeArg}\` to pick them up:`);
174
186
  for (const d of deferredBugs) {
175
187
  const loc = d.file ? ` (${d.file}${d.line ? `:${d.line}` : ''})` : '';
176
188
  logInfo(` • ${d.title}${loc} — ${d.reason}`);
177
189
  }
178
190
  }
179
191
  if (deferredFeatures.length > 0) {
180
- logInfo(`${deferredFeatures.length} finding(s) deferred to find-features — run \`edsger find-features ${productId}\` to pick them up:`);
192
+ logInfo(`${deferredFeatures.length} finding(s) deferred to find-features — run \`edsger find-features ${scopeArg}\` to pick them up:`);
181
193
  for (const d of deferredFeatures) {
182
194
  const loc = d.file ? ` (${d.file}${d.line ? `:${d.line}` : ''})` : '';
183
195
  logInfo(` • ${d.title}${loc} — ${d.reason}`);
@@ -192,13 +204,13 @@ export async function scanForSmells(options) {
192
204
  }
193
205
  let created = 0;
194
206
  for (const smell of filteredSmells) {
195
- const issueId = await createIssueForSmell(productId, smell);
207
+ const issueId = await createIssueForSmell({ productId, repoId }, smell);
196
208
  if (issueId) {
197
209
  created++;
198
210
  logSuccess(`Filed issue ${issueId}: ${smell.title}`);
199
211
  }
200
212
  }
201
- updateFindSmellsState(productId, {
213
+ updateFindSmellsState(scopeId, {
202
214
  lastScannedCommitSha: headSha,
203
215
  lastScannedAt: new Date().toISOString(),
204
216
  lastError: undefined,
@@ -219,7 +231,7 @@ export async function scanForSmells(options) {
219
231
  catch (error) {
220
232
  const errorMessage = error instanceof Error ? error.message : String(error);
221
233
  logError(`Smell scan failed: ${errorMessage}`);
222
- updateFindSmellsState(productId, { lastError: errorMessage });
234
+ updateFindSmellsState(scopeId, { lastError: errorMessage });
223
235
  return {
224
236
  status: 'error',
225
237
  message: `Smell scan failed: ${errorMessage}`,
@@ -232,9 +244,10 @@ export async function scanForSmells(options) {
232
244
  lock.release();
233
245
  }
234
246
  }
235
- async function createIssueForSmell(productId, smell) {
247
+ async function createIssueForSmell(scope, smell) {
236
248
  return createIssue({
237
- productId,
249
+ productId: scope.productId,
250
+ repoId: scope.repoId,
238
251
  title: smell.title,
239
252
  description: formatIssueDescription(smell),
240
253
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.75.1",
3
+ "version": "0.76.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"