edsger 0.75.1 → 0.77.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.
- package/dist/commands/api-docs/index.d.ts +18 -0
- package/dist/commands/api-docs/index.js +41 -0
- package/dist/commands/find-architecture/index.d.ts +3 -1
- package/dist/commands/find-architecture/index.js +14 -5
- package/dist/commands/find-bugs/index.d.ts +3 -1
- package/dist/commands/find-bugs/index.js +14 -5
- package/dist/commands/find-smells/index.d.ts +3 -1
- package/dist/commands/find-smells/index.js +14 -5
- package/dist/commands/quality-benchmark/index.d.ts +5 -0
- package/dist/commands/quality-benchmark/index.js +28 -0
- package/dist/index.js +38 -6
- package/dist/phases/api-docs/index.d.ts +47 -0
- package/dist/phases/api-docs/index.js +254 -0
- package/dist/phases/api-docs/mcp-server.d.ts +25 -0
- package/dist/phases/api-docs/mcp-server.js +82 -0
- package/dist/phases/api-docs/prompts.d.ts +16 -0
- package/dist/phases/api-docs/prompts.js +65 -0
- package/dist/phases/api-docs/types.d.ts +22 -0
- package/dist/phases/api-docs/types.js +10 -0
- package/dist/phases/find-architecture/index.d.ts +4 -1
- package/dist/phases/find-architecture/index.js +46 -26
- package/dist/phases/find-architecture/prompts.d.ts +2 -1
- package/dist/phases/find-architecture/prompts.js +3 -2
- package/dist/phases/find-bugs/index.d.ts +4 -1
- package/dist/phases/find-bugs/index.js +32 -19
- package/dist/phases/find-shared/baseline.d.ts +45 -0
- package/dist/phases/find-shared/baseline.js +56 -0
- package/dist/phases/find-shared/custom-rules.d.ts +39 -0
- package/dist/phases/find-shared/custom-rules.js +75 -0
- package/dist/phases/find-shared/detect-context.d.ts +40 -0
- package/dist/phases/find-shared/detect-context.js +247 -0
- package/dist/phases/find-shared/mcp.d.ts +24 -3
- package/dist/phases/find-shared/mcp.js +41 -4
- package/dist/phases/find-shared/rule-config.d.ts +37 -0
- package/dist/phases/find-shared/rule-config.js +67 -0
- package/dist/phases/find-shared/rule-packs.d.ts +65 -0
- package/dist/phases/find-shared/rule-packs.js +124 -0
- package/dist/phases/find-shared/scoped-read.d.ts +12 -0
- package/dist/phases/find-shared/scoped-read.js +33 -0
- package/dist/phases/find-smells/index.d.ts +4 -1
- package/dist/phases/find-smells/index.js +43 -23
- package/dist/phases/find-smells/prompts.d.ts +2 -1
- package/dist/phases/find-smells/prompts.js +4 -3
- package/dist/phases/quality-benchmark/gate.d.ts +50 -0
- package/dist/phases/quality-benchmark/gate.js +91 -0
- package/dist/phases/quality-benchmark/index.js +15 -1
- package/dist/phases/quality-benchmark/parsers.d.ts +23 -0
- package/dist/phases/quality-benchmark/parsers.js +210 -0
- package/dist/phases/quality-benchmark/rubric.md +37 -0
- package/dist/phases/quality-benchmark/tool-catalog.js +58 -1
- package/dist/phases/quality-benchmark/types.d.ts +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing a single api-docs tool:
|
|
3
|
+
*
|
|
4
|
+
* - submit_api_docs capture the generated API reference (Markdown + an
|
|
5
|
+
* optional OpenAPI document + summary + endpoint count)
|
|
6
|
+
*
|
|
7
|
+
* Unlike the recipes toolkit (which commits each mutation as it goes) the
|
|
8
|
+
* api-docs artifact is a single document, so the tool just captures into an
|
|
9
|
+
* in-memory buffer. The orchestrator persists the captured artifact and flips
|
|
10
|
+
* the run row to success once the agent's turn ends — a clean all-or-nothing
|
|
11
|
+
* write that can't leave a half-written reference behind.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { type ApiDocsArtifact } from './types.js';
|
|
15
|
+
export interface ApiDocsCaptureState {
|
|
16
|
+
captured: ApiDocsArtifact | null;
|
|
17
|
+
}
|
|
18
|
+
export declare function createApiDocsCaptureState(): ApiDocsCaptureState;
|
|
19
|
+
export declare function createSubmitApiDocsTool(capture: ApiDocsCaptureState): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
20
|
+
markdown: z.ZodString;
|
|
21
|
+
summary: z.ZodString;
|
|
22
|
+
endpoint_count: z.ZodNumber;
|
|
23
|
+
openapi_json: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}>;
|
|
25
|
+
export declare function createApiDocsMcpServer(capture: ApiDocsCaptureState): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing a single api-docs tool:
|
|
3
|
+
*
|
|
4
|
+
* - submit_api_docs capture the generated API reference (Markdown + an
|
|
5
|
+
* optional OpenAPI document + summary + endpoint count)
|
|
6
|
+
*
|
|
7
|
+
* Unlike the recipes toolkit (which commits each mutation as it goes) the
|
|
8
|
+
* api-docs artifact is a single document, so the tool just captures into an
|
|
9
|
+
* in-memory buffer. The orchestrator persists the captured artifact and flips
|
|
10
|
+
* the run row to success once the agent's turn ends — a clean all-or-nothing
|
|
11
|
+
* write that can't leave a half-written reference behind.
|
|
12
|
+
*/
|
|
13
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { API_DOCS_MARKDOWN_MAX, API_DOCS_SUMMARY_MAX, } from './types.js';
|
|
16
|
+
export function createApiDocsCaptureState() {
|
|
17
|
+
return { captured: null };
|
|
18
|
+
}
|
|
19
|
+
function textError(message) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: 'text', text: message }],
|
|
22
|
+
isError: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function textOk(message) {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: 'text', text: message }],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function createSubmitApiDocsTool(capture) {
|
|
31
|
+
return tool('submit_api_docs', 'Submit the final API reference for this repository. Call this exactly once, at the end, after you have explored the codebase. Provide a Markdown reference (always) and, when the repo exposes an HTTP API, a complete OpenAPI 3.x document as a JSON string.', {
|
|
32
|
+
markdown: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(API_DOCS_MARKDOWN_MAX)
|
|
36
|
+
.describe('The human-readable API reference in Markdown. Document each endpoint / exported interface: method + path (or signature), purpose, parameters, request/response shapes, auth, and an example where useful.'),
|
|
37
|
+
summary: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(API_DOCS_SUMMARY_MAX)
|
|
41
|
+
.describe('One-paragraph overview of the API surface (what it exposes and how it is consumed).'),
|
|
42
|
+
endpoint_count: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.min(0)
|
|
46
|
+
.describe('How many distinct endpoints / operations the reference documents (0 if the repo exposes no callable API surface).'),
|
|
47
|
+
openapi_json: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('A complete OpenAPI 3.x document serialized as a JSON string. Omit when the repository exposes no HTTP API (e.g. a library or CLI).'),
|
|
51
|
+
}, async (args) => {
|
|
52
|
+
let openapi = null;
|
|
53
|
+
if (args.openapi_json && args.openapi_json.trim()) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(args.openapi_json);
|
|
56
|
+
if (typeof parsed !== 'object' ||
|
|
57
|
+
parsed === null ||
|
|
58
|
+
Array.isArray(parsed)) {
|
|
59
|
+
return textError('openapi_json must be a JSON object (an OpenAPI document), not an array or scalar.');
|
|
60
|
+
}
|
|
61
|
+
openapi = parsed;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return textError(`openapi_json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
capture.captured = {
|
|
68
|
+
openapi,
|
|
69
|
+
markdown: args.markdown,
|
|
70
|
+
summary: args.summary.trim(),
|
|
71
|
+
endpointCount: args.endpoint_count,
|
|
72
|
+
};
|
|
73
|
+
return textOk(`API docs captured (${args.endpoint_count} endpoint${args.endpoint_count === 1 ? '' : 's'}${openapi ? ', with OpenAPI spec' : ''}). You can end your turn now.`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function createApiDocsMcpServer(capture) {
|
|
77
|
+
return createSdkMcpServer({
|
|
78
|
+
name: 'api-docs',
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
tools: [createSubmitApiDocsTool(capture)],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the api-docs phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned repository, work out what API surface it
|
|
5
|
+
* exposes (HTTP/REST routes, RPC handlers, edge functions, exported SDK
|
|
6
|
+
* functions, CLI commands — whatever the repo actually offers), and produce
|
|
7
|
+
* an API reference. Output is submitted via the single `submit_api_docs` MCP
|
|
8
|
+
* tool — no fenced JSON, no chat summary.
|
|
9
|
+
*/
|
|
10
|
+
export interface ApiDocsPromptContext {
|
|
11
|
+
repoName: string;
|
|
12
|
+
repoDescription?: string;
|
|
13
|
+
guidance?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function createApiDocsSystemPrompt(): string;
|
|
16
|
+
export declare function createApiDocsUserPrompt(ctx: ApiDocsPromptContext): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the api-docs phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned repository, work out what API surface it
|
|
5
|
+
* exposes (HTTP/REST routes, RPC handlers, edge functions, exported SDK
|
|
6
|
+
* functions, CLI commands — whatever the repo actually offers), and produce
|
|
7
|
+
* an API reference. Output is submitted via the single `submit_api_docs` MCP
|
|
8
|
+
* tool — no fenced JSON, no chat summary.
|
|
9
|
+
*/
|
|
10
|
+
export function createApiDocsSystemPrompt() {
|
|
11
|
+
return `You are a senior engineer writing the API reference for a codebase.
|
|
12
|
+
|
|
13
|
+
The current working directory is a fresh clone of the repository. Use Glob/Grep/Read (and Bash for git/log when helpful) to explore it before writing anything.
|
|
14
|
+
|
|
15
|
+
## Step 1 — Determine the API surface
|
|
16
|
+
|
|
17
|
+
Repositories expose their API in different ways. Figure out which apply here (a repo may have more than one):
|
|
18
|
+
- **HTTP / REST** — route definitions, controllers, request handlers (Express/Fastify/Next.js route handlers, Flask/FastAPI/Django, Spring, Go net/http, etc.).
|
|
19
|
+
- **Action-routed / RPC functions** — e.g. serverless / edge functions dispatched by an \`action\` field on the body, gRPC services, tRPC routers.
|
|
20
|
+
- **Library / SDK** — the public, exported functions/classes other code imports. Document the exported surface, not internal helpers.
|
|
21
|
+
- **CLI** — the commands and flags the tool exposes.
|
|
22
|
+
|
|
23
|
+
Do NOT assume it is REST. Read the code and document what is actually there.
|
|
24
|
+
|
|
25
|
+
## Step 2 — Produce two artifacts
|
|
26
|
+
|
|
27
|
+
1. **Markdown reference (always).** A clear, navigable reference. For each endpoint / operation / exported function document: its identifier (method + path, or signature), purpose, parameters (name, type, required, description), request body and response shapes, auth/permissions, and a short example where it helps. Group by resource/module. Note relative file paths so readers can jump to the source.
|
|
28
|
+
|
|
29
|
+
2. **OpenAPI 3.x document (only when the repo exposes an HTTP API).** A complete, spec-valid OpenAPI document covering the HTTP endpoints — paths, methods, parameters, requestBody/response schemas under \`components.schemas\`, and security schemes. Omit this entirely for pure libraries or CLIs (there is nothing meaningful to put in an OpenAPI \`paths\` object).
|
|
30
|
+
|
|
31
|
+
## Output protocol
|
|
32
|
+
|
|
33
|
+
Submit your result with the \`submit_api_docs\` MCP tool — call it EXACTLY ONCE at the end:
|
|
34
|
+
|
|
35
|
+
- \`markdown\` — the full Markdown reference.
|
|
36
|
+
- \`summary\` — one paragraph describing the API surface.
|
|
37
|
+
- \`endpoint_count\` — the number of distinct endpoints/operations you documented (0 for a non-callable surface).
|
|
38
|
+
- \`openapi_json\` — the OpenAPI document as a JSON string, or omit it when there is no HTTP API.
|
|
39
|
+
|
|
40
|
+
Do NOT paste the reference into chat. Do NOT wrap it in fenced code blocks in a message. Explore, then call the tool, then end your turn.
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- Ground everything in the actual source. Don't invent endpoints, parameters, or response fields. If something is ambiguous, describe what the code does and say so.
|
|
45
|
+
- Keep the Markdown focused on the externally-callable surface. Don't document private/internal helpers as if they were API.
|
|
46
|
+
- Prefer accuracy over completeness padding: a small repo gets a short, correct reference.`;
|
|
47
|
+
}
|
|
48
|
+
export function createApiDocsUserPrompt(ctx) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(`# Repository: ${ctx.repoName}`);
|
|
51
|
+
if (ctx.repoDescription) {
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('## Description');
|
|
54
|
+
lines.push(ctx.repoDescription);
|
|
55
|
+
}
|
|
56
|
+
if (ctx.guidance && ctx.guidance.trim()) {
|
|
57
|
+
lines.push('');
|
|
58
|
+
lines.push('## Reviewer guidance (focus or exclusions)');
|
|
59
|
+
lines.push(ctx.guidance.trim());
|
|
60
|
+
}
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('## Task');
|
|
63
|
+
lines.push('Explore the cloned repository, determine what API surface it exposes, and write its API reference. Then call `submit_api_docs` exactly once with the Markdown reference, a one-paragraph summary, the endpoint count, and (only if the repo exposes an HTTP API) the OpenAPI document as a JSON string. End your turn after submitting.');
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes the api-docs MCP tool accepts. The Zod schema lives in mcp-server.ts;
|
|
3
|
+
* these plain TS types are what the orchestrator / persistence helpers consume.
|
|
4
|
+
*
|
|
5
|
+
* Defensive caps mirror the DB CHECK constraints from
|
|
6
|
+
* 20260605000000_create_api_doc_runs.sql so the MCP tool can reject oversized
|
|
7
|
+
* output with an actionable message instead of a Postgres constraint violation.
|
|
8
|
+
*/
|
|
9
|
+
/** A spec-compliant OpenAPI document. Kept loose — the agent owns the shape. */
|
|
10
|
+
export type OpenApiSpec = Record<string, unknown>;
|
|
11
|
+
export interface ApiDocsArtifact {
|
|
12
|
+
/** Parsed OpenAPI document, or null when the repo exposes no HTTP API. */
|
|
13
|
+
openapi: OpenApiSpec | null;
|
|
14
|
+
/** Human-readable Markdown API reference (always produced). */
|
|
15
|
+
markdown: string;
|
|
16
|
+
/** One-paragraph overview for the tab header. */
|
|
17
|
+
summary: string;
|
|
18
|
+
/** Number of documented endpoints/operations (0 for non-HTTP surfaces). */
|
|
19
|
+
endpointCount: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const API_DOCS_MARKDOWN_MAX = 200000;
|
|
22
|
+
export declare const API_DOCS_SUMMARY_MAX = 1000;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes the api-docs MCP tool accepts. The Zod schema lives in mcp-server.ts;
|
|
3
|
+
* these plain TS types are what the orchestrator / persistence helpers consume.
|
|
4
|
+
*
|
|
5
|
+
* Defensive caps mirror the DB CHECK constraints from
|
|
6
|
+
* 20260605000000_create_api_doc_runs.sql so the MCP tool can reject oversized
|
|
7
|
+
* output with an actionable message instead of a Postgres constraint violation.
|
|
8
|
+
*/
|
|
9
|
+
export const API_DOCS_MARKDOWN_MAX = 200_000;
|
|
10
|
+
export const API_DOCS_SUMMARY_MAX = 1000;
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* phases share the same workspace + state pattern.
|
|
10
10
|
*/
|
|
11
11
|
export interface FindArchitectureOptions {
|
|
12
|
-
|
|
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,8 +12,11 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
12
12
|
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
|
+
import { resolveScanBase } from '../find-shared/baseline.js';
|
|
16
|
+
import { resolveCustomRules } from '../find-shared/custom-rules.js';
|
|
15
17
|
import { detectDefaultBranch, gitRevParse, isAncestor, listChangedPaths, } from '../find-shared/git.js';
|
|
16
|
-
import { createIssue, fetchOpenIssues, fetchProductBasics, } from '../find-shared/mcp.js';
|
|
18
|
+
import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
|
|
19
|
+
import { resolveStackRulePacks } from '../find-shared/rule-config.js';
|
|
17
20
|
import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
|
|
18
21
|
import { createFindArchitectureSystemPrompt, createFindArchitectureUserPrompt, } from './prompts.js';
|
|
19
22
|
import { acquireFindArchitectureLock, loadFindArchitectureState, updateFindArchitectureState, } from './state.js';
|
|
@@ -33,31 +36,35 @@ const MAX_TURNS = 200;
|
|
|
33
36
|
*/
|
|
34
37
|
// eslint-disable-next-line complexity
|
|
35
38
|
export async function scanForArchitecture(options) {
|
|
36
|
-
const { productId, githubToken, owner, repo, full, maxFiles, verbose } = options;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
const { productId, repoId, githubToken, owner, repo, full, maxFiles, verbose, } = options;
|
|
40
|
+
// State/lock/workspace are keyed by an opaque scope id so product and repo
|
|
41
|
+
// scans never collide; repo keys are prefixed to namespace them clearly.
|
|
42
|
+
const scopeId = productId ?? `repo-${repoId}`;
|
|
43
|
+
const scopeLabel = productId ? `product ${productId}` : `repository ${repoId}`;
|
|
44
|
+
logInfo(`Starting architecture scan for ${scopeLabel} (${owner}/${repo})`);
|
|
45
|
+
const lock = acquireFindArchitectureLock(scopeId);
|
|
39
46
|
if (!lock) {
|
|
40
|
-
logWarning(`Another architecture scan is already in progress for
|
|
47
|
+
logWarning(`Another architecture scan is already in progress for ${scopeLabel}; skipping.`);
|
|
41
48
|
return {
|
|
42
49
|
status: 'error',
|
|
43
|
-
message: 'Another architecture scan is already in progress for this
|
|
50
|
+
message: 'Another architecture scan is already in progress for this scope',
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
let repoPath;
|
|
47
54
|
let scanSucceeded = false;
|
|
48
55
|
try {
|
|
49
|
-
updateFindArchitectureState(
|
|
56
|
+
updateFindArchitectureState(scopeId, {
|
|
50
57
|
lastAttemptedAt: new Date().toISOString(),
|
|
51
58
|
});
|
|
52
59
|
const workspaceRoot = ensureWorkspaceDir();
|
|
53
|
-
const repoKey = `${WORKSPACE_KEY}-${
|
|
60
|
+
const repoKey = `${WORKSPACE_KEY}-${scopeId}`;
|
|
54
61
|
({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, owner, repo, githubToken));
|
|
55
62
|
const branch = options.branch ?? detectDefaultBranch(repoPath);
|
|
56
63
|
logInfo(`Syncing ${owner}/${repo} to branch ${branch}`);
|
|
57
64
|
syncRepoToRef(repoPath, { branch }, githubToken);
|
|
58
65
|
const headSha = gitRevParse(repoPath, 'HEAD');
|
|
59
|
-
const state = loadFindArchitectureState(
|
|
60
|
-
const baseSha =
|
|
66
|
+
const state = loadFindArchitectureState(scopeId);
|
|
67
|
+
const baseSha = await resolveScanBase({ productId, repoId }, { full, lastScannedSha: state.lastScannedCommitSha });
|
|
61
68
|
let scope = 'full';
|
|
62
69
|
let changedPaths;
|
|
63
70
|
if (baseSha && baseSha !== headSha) {
|
|
@@ -68,7 +75,7 @@ export async function scanForArchitecture(options) {
|
|
|
68
75
|
logInfo(`Incremental scan: ${changedPaths.length} files changed since ${baseSha.slice(0, 8)}`);
|
|
69
76
|
if (changedPaths.length === 0) {
|
|
70
77
|
logSuccess('No code changes since last scan; nothing to do.');
|
|
71
|
-
updateFindArchitectureState(
|
|
78
|
+
updateFindArchitectureState(scopeId, {
|
|
72
79
|
lastScannedCommitSha: headSha,
|
|
73
80
|
lastScannedAt: new Date().toISOString(),
|
|
74
81
|
lastError: undefined,
|
|
@@ -89,7 +96,7 @@ export async function scanForArchitecture(options) {
|
|
|
89
96
|
}
|
|
90
97
|
else if (baseSha === headSha) {
|
|
91
98
|
logSuccess('HEAD unchanged since last scan; nothing to do.');
|
|
92
|
-
updateFindArchitectureState(
|
|
99
|
+
updateFindArchitectureState(scopeId, {
|
|
93
100
|
lastScannedAt: new Date().toISOString(),
|
|
94
101
|
lastError: undefined,
|
|
95
102
|
});
|
|
@@ -102,10 +109,19 @@ export async function scanForArchitecture(options) {
|
|
|
102
109
|
issuesCreated: 0,
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
|
-
const product =
|
|
106
|
-
|
|
112
|
+
const product = productId
|
|
113
|
+
? await fetchProductBasics(productId)
|
|
114
|
+
: await fetchRepositoryBasics(repoId);
|
|
115
|
+
const existingIssues = productId
|
|
116
|
+
? await fetchOpenIssues(productId)
|
|
117
|
+
: await fetchOpenIssuesByRepo(repoId);
|
|
107
118
|
logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
|
|
108
|
-
const
|
|
119
|
+
const rulePacks = await resolveStackRulePacks(repoPath, {
|
|
120
|
+
productId,
|
|
121
|
+
repoId,
|
|
122
|
+
});
|
|
123
|
+
const customRules = await resolveCustomRules({ productId, repoId }, 'architecture');
|
|
124
|
+
const systemPrompt = createFindArchitectureSystemPrompt(rulePacks, customRules);
|
|
109
125
|
const userPrompt = createFindArchitectureUserPrompt({
|
|
110
126
|
productName: product.name,
|
|
111
127
|
productDescription: product.description,
|
|
@@ -157,7 +173,7 @@ export async function scanForArchitecture(options) {
|
|
|
157
173
|
}
|
|
158
174
|
if (!scanResult) {
|
|
159
175
|
const msg = 'Audit failed: could not parse a scan_result from the agent';
|
|
160
|
-
updateFindArchitectureState(
|
|
176
|
+
updateFindArchitectureState(scopeId, { lastError: msg });
|
|
161
177
|
return {
|
|
162
178
|
status: 'error',
|
|
163
179
|
message: msg,
|
|
@@ -168,18 +184,20 @@ export async function scanForArchitecture(options) {
|
|
|
168
184
|
const deferredBugs = scanResult.deferred_to_bugs ?? [];
|
|
169
185
|
const deferredFeatures = scanResult.deferred_to_features ?? [];
|
|
170
186
|
const deferredSmells = scanResult.deferred_to_smells ?? [];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
logDeferred(
|
|
187
|
+
// CLI suggestion argument: a productId positional, or the --repo-id flag.
|
|
188
|
+
const scopeArg = productId ?? `--repo-id ${repoId}`;
|
|
189
|
+
logDeferred(deferredBugs, 'find-bugs', scopeArg);
|
|
190
|
+
logDeferred(deferredFeatures, 'find-features', scopeArg);
|
|
191
|
+
logDeferred(deferredSmells, 'find-smells', scopeArg);
|
|
174
192
|
let created = 0;
|
|
175
193
|
for (const finding of findings) {
|
|
176
|
-
const issueId = await createIssueForFinding(productId, finding);
|
|
194
|
+
const issueId = await createIssueForFinding({ productId, repoId }, finding);
|
|
177
195
|
if (issueId) {
|
|
178
196
|
created++;
|
|
179
197
|
logSuccess(`Filed issue ${issueId}: ${finding.title}`);
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
|
-
updateFindArchitectureState(
|
|
200
|
+
updateFindArchitectureState(scopeId, {
|
|
183
201
|
lastScannedCommitSha: headSha,
|
|
184
202
|
lastScannedAt: new Date().toISOString(),
|
|
185
203
|
lastError: undefined,
|
|
@@ -200,7 +218,7 @@ export async function scanForArchitecture(options) {
|
|
|
200
218
|
catch (error) {
|
|
201
219
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
202
220
|
logError(`Architecture scan failed: ${errorMessage}`);
|
|
203
|
-
updateFindArchitectureState(
|
|
221
|
+
updateFindArchitectureState(scopeId, { lastError: errorMessage });
|
|
204
222
|
return {
|
|
205
223
|
status: 'error',
|
|
206
224
|
message: `Architecture scan failed: ${errorMessage}`,
|
|
@@ -213,21 +231,23 @@ export async function scanForArchitecture(options) {
|
|
|
213
231
|
lock.release();
|
|
214
232
|
}
|
|
215
233
|
}
|
|
216
|
-
function logDeferred(deferred, siblingPhase,
|
|
234
|
+
function logDeferred(deferred, siblingPhase, scopeArg) {
|
|
217
235
|
if (deferred.length === 0) {
|
|
218
236
|
return;
|
|
219
237
|
}
|
|
220
|
-
logInfo(`${deferred.length} finding(s) deferred to ${siblingPhase} — run \`edsger ${siblingPhase} ${
|
|
238
|
+
logInfo(`${deferred.length} finding(s) deferred to ${siblingPhase} — run \`edsger ${siblingPhase} ${scopeArg}\` to pick them up:`);
|
|
221
239
|
for (const d of deferred) {
|
|
222
240
|
const loc = d.file ? ` (${d.file}${d.line ? `:${d.line}` : ''})` : '';
|
|
223
241
|
logInfo(` • ${d.title}${loc} — ${d.reason}`);
|
|
224
242
|
}
|
|
225
243
|
}
|
|
226
|
-
async function createIssueForFinding(
|
|
244
|
+
async function createIssueForFinding(scope, finding) {
|
|
227
245
|
return createIssue({
|
|
228
|
-
productId,
|
|
246
|
+
productId: scope.productId,
|
|
247
|
+
repoId: scope.repoId,
|
|
229
248
|
title: finding.title,
|
|
230
249
|
description: formatIssueDescription(finding),
|
|
250
|
+
source: 'quality',
|
|
231
251
|
});
|
|
232
252
|
}
|
|
233
253
|
function formatIssueDescription(finding) {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* multiple files: module boundaries, layering, coupling, cycles, missing
|
|
13
13
|
* or duplicated abstractions, responsibility creep, cross-cutting concerns.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
import { type RulePack } from '../find-shared/rule-packs.js';
|
|
16
|
+
export declare function createFindArchitectureSystemPrompt(packs?: RulePack[], customRules?: string): string;
|
|
16
17
|
export interface FindArchitectureUserPromptParams {
|
|
17
18
|
productName: string;
|
|
18
19
|
productDescription?: string;
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* multiple files: module boundaries, layering, coupling, cycles, missing
|
|
13
13
|
* or duplicated abstractions, responsibility creep, cross-cutting concerns.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
import { renderRulePacks } from '../find-shared/rule-packs.js';
|
|
16
|
+
export function createFindArchitectureSystemPrompt(packs = [], customRules = '') {
|
|
16
17
|
return `You are a senior staff engineer auditing a codebase for **architectural problems and improvement opportunities**. You have read-only access via Read/Grep/Glob and may run shallow Bash queries (e.g. \`git log\`, \`wc -l\`, \`grep\`) to navigate.
|
|
17
18
|
|
|
18
19
|
**What counts as an architectural finding worth filing**:
|
|
@@ -26,7 +27,7 @@ export function createFindArchitectureSystemPrompt() {
|
|
|
26
27
|
8. **Responsibility creep**: a service / component that started focused and now does five things
|
|
27
28
|
9. **Cross-cutting concerns**: logging, auth, retries, error handling implemented inconsistently across modules
|
|
28
29
|
10. **Inconsistency**: same problem solved different ways in different parts of the codebase, drifting conventions
|
|
29
|
-
11. **Scalability shape**: data-flow / control-flow patterns that won't survive the next obvious axis of growth
|
|
30
|
+
11. **Scalability shape**: data-flow / control-flow patterns that won't survive the next obvious axis of growth${renderRulePacks(packs, 'architecture')}${customRules}
|
|
30
31
|
|
|
31
32
|
**What does NOT count** (skip these — wrong tool):
|
|
32
33
|
- Real bugs (security holes, logic errors, races, data corruption) — those belong in \`edsger find-bugs\`. **Don't drop them silently — list them in \`deferred_to_bugs\`.**
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* incremental, scoped to commits since the previous successful scan.
|
|
5
5
|
*/
|
|
6
6
|
export interface FindBugsOptions {
|
|
7
|
-
|
|
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;
|
|
@@ -7,8 +7,9 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
7
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
|
+
import { resolveScanBase } from '../find-shared/baseline.js';
|
|
10
11
|
import { detectDefaultBranch, gitRevParse, isAncestor, listChangedPaths, } from '../find-shared/git.js';
|
|
11
|
-
import { createIssue, fetchOpenIssues, fetchProductBasics, } from '../find-shared/mcp.js';
|
|
12
|
+
import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
|
|
12
13
|
import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
|
|
13
14
|
import { createFindBugsSystemPrompt, createFindBugsUserPrompt, } from './prompts.js';
|
|
14
15
|
import { acquireFindBugsLock, loadFindBugsState, updateFindBugsState, } from './state.js';
|
|
@@ -32,34 +33,40 @@ const MAX_TURNS = 200;
|
|
|
32
33
|
*/
|
|
33
34
|
// eslint-disable-next-line complexity
|
|
34
35
|
export async function scanForBugs(options) {
|
|
35
|
-
const { productId, githubToken, owner, repo, full, maxFiles, verbose } = options;
|
|
36
|
-
|
|
37
|
-
|
|
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 bug scan for ${scopeLabel} (${owner}/${repo})`);
|
|
44
|
+
const lock = acquireFindBugsLock(scopeId);
|
|
38
45
|
if (!lock) {
|
|
39
|
-
logWarning(`Another bug scan is already in progress for
|
|
46
|
+
logWarning(`Another bug scan is already in progress for ${scopeLabel}; skipping.`);
|
|
40
47
|
return {
|
|
41
48
|
status: 'error',
|
|
42
|
-
message: 'Another bug scan is already in progress for this
|
|
49
|
+
message: 'Another bug scan is already in progress for this scope',
|
|
43
50
|
};
|
|
44
51
|
}
|
|
45
52
|
let repoPath;
|
|
46
53
|
let scanSucceeded = false;
|
|
47
54
|
try {
|
|
48
|
-
updateFindBugsState(
|
|
55
|
+
updateFindBugsState(scopeId, {
|
|
49
56
|
lastAttemptedAt: new Date().toISOString(),
|
|
50
57
|
});
|
|
51
58
|
const workspaceRoot = ensureWorkspaceDir();
|
|
52
59
|
// Each run re-clones into a per-product directory and removes it on
|
|
53
60
|
// success. Incremental scope is recovered from the persisted state file
|
|
54
61
|
// (~/.edsger/find-bugs-state/<productId>.json), not from the workspace.
|
|
55
|
-
const repoKey = `${WORKSPACE_KEY}-${
|
|
62
|
+
const repoKey = `${WORKSPACE_KEY}-${scopeId}`;
|
|
56
63
|
({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, owner, repo, githubToken));
|
|
57
64
|
const branch = options.branch ?? detectDefaultBranch(repoPath);
|
|
58
65
|
logInfo(`Syncing ${owner}/${repo} to branch ${branch}`);
|
|
59
66
|
syncRepoToRef(repoPath, { branch }, githubToken);
|
|
60
67
|
const headSha = gitRevParse(repoPath, 'HEAD');
|
|
61
|
-
const state = loadFindBugsState(
|
|
62
|
-
const baseSha =
|
|
68
|
+
const state = loadFindBugsState(scopeId);
|
|
69
|
+
const baseSha = await resolveScanBase({ productId, repoId }, { full, lastScannedSha: state.lastScannedCommitSha });
|
|
63
70
|
let scope = 'full';
|
|
64
71
|
let changedPaths;
|
|
65
72
|
if (baseSha && baseSha !== headSha) {
|
|
@@ -70,7 +77,7 @@ export async function scanForBugs(options) {
|
|
|
70
77
|
logInfo(`Incremental scan: ${changedPaths.length} files changed since ${baseSha.slice(0, 8)}`);
|
|
71
78
|
if (changedPaths.length === 0) {
|
|
72
79
|
logSuccess('No code changes since last scan; nothing to do.');
|
|
73
|
-
updateFindBugsState(
|
|
80
|
+
updateFindBugsState(scopeId, {
|
|
74
81
|
lastScannedCommitSha: headSha,
|
|
75
82
|
lastScannedAt: new Date().toISOString(),
|
|
76
83
|
lastError: undefined,
|
|
@@ -100,8 +107,12 @@ export async function scanForBugs(options) {
|
|
|
100
107
|
issuesCreated: 0,
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
|
-
const product =
|
|
104
|
-
|
|
110
|
+
const product = productId
|
|
111
|
+
? await fetchProductBasics(productId)
|
|
112
|
+
: await fetchRepositoryBasics(repoId);
|
|
113
|
+
const existingIssues = productId
|
|
114
|
+
? await fetchOpenIssues(productId)
|
|
115
|
+
: await fetchOpenIssuesByRepo(repoId);
|
|
105
116
|
logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
|
|
106
117
|
const systemPrompt = createFindBugsSystemPrompt();
|
|
107
118
|
const userPrompt = createFindBugsUserPrompt({
|
|
@@ -155,7 +166,7 @@ export async function scanForBugs(options) {
|
|
|
155
166
|
}
|
|
156
167
|
if (!scanResult) {
|
|
157
168
|
const msg = 'Audit failed: could not parse a scan_result from the agent';
|
|
158
|
-
updateFindBugsState(
|
|
169
|
+
updateFindBugsState(scopeId, { lastError: msg });
|
|
159
170
|
return {
|
|
160
171
|
status: 'error',
|
|
161
172
|
message: msg,
|
|
@@ -165,13 +176,13 @@ export async function scanForBugs(options) {
|
|
|
165
176
|
logInfo(`Audit produced ${bugs.length} candidate bugs. ${summary}`);
|
|
166
177
|
let created = 0;
|
|
167
178
|
for (const bug of bugs) {
|
|
168
|
-
const issueId = await createIssueForBug(productId, bug);
|
|
179
|
+
const issueId = await createIssueForBug({ productId, repoId }, bug);
|
|
169
180
|
if (issueId) {
|
|
170
181
|
created++;
|
|
171
182
|
logSuccess(`Filed issue ${issueId}: ${bug.title}`);
|
|
172
183
|
}
|
|
173
184
|
}
|
|
174
|
-
updateFindBugsState(
|
|
185
|
+
updateFindBugsState(scopeId, {
|
|
175
186
|
lastScannedCommitSha: headSha,
|
|
176
187
|
lastScannedAt: new Date().toISOString(),
|
|
177
188
|
lastError: undefined,
|
|
@@ -189,7 +200,7 @@ export async function scanForBugs(options) {
|
|
|
189
200
|
catch (error) {
|
|
190
201
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
191
202
|
logError(`Bug scan failed: ${errorMessage}`);
|
|
192
|
-
updateFindBugsState(
|
|
203
|
+
updateFindBugsState(scopeId, { lastError: errorMessage });
|
|
193
204
|
return {
|
|
194
205
|
status: 'error',
|
|
195
206
|
message: `Bug scan failed: ${errorMessage}`,
|
|
@@ -202,11 +213,13 @@ export async function scanForBugs(options) {
|
|
|
202
213
|
lock.release();
|
|
203
214
|
}
|
|
204
215
|
}
|
|
205
|
-
async function createIssueForBug(
|
|
216
|
+
async function createIssueForBug(scope, bug) {
|
|
206
217
|
return createIssue({
|
|
207
|
-
productId,
|
|
218
|
+
productId: scope.productId,
|
|
219
|
+
repoId: scope.repoId,
|
|
208
220
|
title: bug.title,
|
|
209
221
|
description: formatIssueDescription(bug),
|
|
222
|
+
source: 'quality',
|
|
210
223
|
});
|
|
211
224
|
}
|
|
212
225
|
function formatIssueDescription(bug) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side scan baseline, shared across the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* A baseline pins a commit as the "everything before here is acknowledged"
|
|
5
|
+
* floor for a scope. On a fresh scan (no machine-local scan-state cursor) the
|
|
6
|
+
* find-* scanners use it as the diff base so only findings in code changed
|
|
7
|
+
* since the baseline are reported — the "only new" view DCM-style baselines
|
|
8
|
+
* provide — instead of re-filing the entire pre-existing backlog.
|
|
9
|
+
*
|
|
10
|
+
* Read here via the user's supabase session; written from the desktop app
|
|
11
|
+
* (services/db/quality-baselines.ts). Stored in `quality_scan_baselines`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScanScope {
|
|
14
|
+
productId?: string;
|
|
15
|
+
repoId?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the diff base for a scan, given the explicit `--full` flag, the
|
|
19
|
+
* machine-local last-scanned cursor, and the server baseline. Pure + exported
|
|
20
|
+
* for testing.
|
|
21
|
+
*
|
|
22
|
+
* - `--full` always wins → undefined (scan everything, ignore baseline).
|
|
23
|
+
* - otherwise prefer the local cursor (normal incremental), falling back to
|
|
24
|
+
* the baseline so a fresh checkout still suppresses the pre-existing backlog.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveScanBaseSha(opts: {
|
|
27
|
+
full?: boolean;
|
|
28
|
+
lastScannedSha?: string;
|
|
29
|
+
baselineSha?: string | null;
|
|
30
|
+
}): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Fetch the pinned baseline commit for a scope, or null if none is set / no
|
|
33
|
+
* session is available. Never throws — a baseline read failure must not abort
|
|
34
|
+
* a scan (it just degrades to a full scan).
|
|
35
|
+
*/
|
|
36
|
+
export declare function getScanBaseline(scope: ScanScope): Promise<string | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch the baseline and resolve the scan's diff base in one call, logging when
|
|
39
|
+
* the baseline floor is applied on a fresh checkout. Shared by every find-*
|
|
40
|
+
* phase so the baseline wiring lives in one place.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveScanBase(scope: ScanScope, opts: {
|
|
43
|
+
full?: boolean;
|
|
44
|
+
lastScannedSha?: string;
|
|
45
|
+
}): Promise<string | undefined>;
|