edsger 0.76.0 → 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/quality-benchmark/index.d.ts +5 -0
- package/dist/commands/quality-benchmark/index.js +28 -0
- package/dist/index.js +29 -0
- 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.js +13 -6
- 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.js +3 -1
- 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 +6 -0
- package/dist/phases/find-shared/mcp.js +2 -0
- 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.js +12 -5
- 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,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;
|
|
@@ -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
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,13 +36,11 @@ const MAX_TURNS = 200;
|
|
|
33
36
|
*/
|
|
34
37
|
// eslint-disable-next-line complexity
|
|
35
38
|
export async function scanForArchitecture(options) {
|
|
36
|
-
const { productId, repoId, githubToken, owner, repo, full, maxFiles, verbose } = options;
|
|
39
|
+
const { productId, repoId, githubToken, owner, repo, full, maxFiles, verbose, } = options;
|
|
37
40
|
// State/lock/workspace are keyed by an opaque scope id so product and repo
|
|
38
41
|
// scans never collide; repo keys are prefixed to namespace them clearly.
|
|
39
42
|
const scopeId = productId ?? `repo-${repoId}`;
|
|
40
|
-
const scopeLabel = productId
|
|
41
|
-
? `product ${productId}`
|
|
42
|
-
: `repository ${repoId}`;
|
|
43
|
+
const scopeLabel = productId ? `product ${productId}` : `repository ${repoId}`;
|
|
43
44
|
logInfo(`Starting architecture scan for ${scopeLabel} (${owner}/${repo})`);
|
|
44
45
|
const lock = acquireFindArchitectureLock(scopeId);
|
|
45
46
|
if (!lock) {
|
|
@@ -63,7 +64,7 @@ export async function scanForArchitecture(options) {
|
|
|
63
64
|
syncRepoToRef(repoPath, { branch }, githubToken);
|
|
64
65
|
const headSha = gitRevParse(repoPath, 'HEAD');
|
|
65
66
|
const state = loadFindArchitectureState(scopeId);
|
|
66
|
-
const baseSha =
|
|
67
|
+
const baseSha = await resolveScanBase({ productId, repoId }, { full, lastScannedSha: state.lastScannedCommitSha });
|
|
67
68
|
let scope = 'full';
|
|
68
69
|
let changedPaths;
|
|
69
70
|
if (baseSha && baseSha !== headSha) {
|
|
@@ -115,7 +116,12 @@ export async function scanForArchitecture(options) {
|
|
|
115
116
|
? await fetchOpenIssues(productId)
|
|
116
117
|
: await fetchOpenIssuesByRepo(repoId);
|
|
117
118
|
logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
|
|
118
|
-
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);
|
|
119
125
|
const userPrompt = createFindArchitectureUserPrompt({
|
|
120
126
|
productName: product.name,
|
|
121
127
|
productDescription: product.description,
|
|
@@ -241,6 +247,7 @@ async function createIssueForFinding(scope, finding) {
|
|
|
241
247
|
repoId: scope.repoId,
|
|
242
248
|
title: finding.title,
|
|
243
249
|
description: formatIssueDescription(finding),
|
|
250
|
+
source: 'quality',
|
|
244
251
|
});
|
|
245
252
|
}
|
|
246
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\`.**
|
|
@@ -7,6 +7,7 @@ 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
12
|
import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
|
|
12
13
|
import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
|
|
@@ -65,7 +66,7 @@ export async function scanForBugs(options) {
|
|
|
65
66
|
syncRepoToRef(repoPath, { branch }, githubToken);
|
|
66
67
|
const headSha = gitRevParse(repoPath, 'HEAD');
|
|
67
68
|
const state = loadFindBugsState(scopeId);
|
|
68
|
-
const baseSha =
|
|
69
|
+
const baseSha = await resolveScanBase({ productId, repoId }, { full, lastScannedSha: state.lastScannedCommitSha });
|
|
69
70
|
let scope = 'full';
|
|
70
71
|
let changedPaths;
|
|
71
72
|
if (baseSha && baseSha !== headSha) {
|
|
@@ -218,6 +219,7 @@ async function createIssueForBug(scope, bug) {
|
|
|
218
219
|
repoId: scope.repoId,
|
|
219
220
|
title: bug.title,
|
|
220
221
|
description: formatIssueDescription(bug),
|
|
222
|
+
source: 'quality',
|
|
221
223
|
});
|
|
222
224
|
}
|
|
223
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>;
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import { logInfo } from '../../utils/logger.js';
|
|
14
|
+
import { readScopedRow } from './scoped-read.js';
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the diff base for a scan, given the explicit `--full` flag, the
|
|
17
|
+
* machine-local last-scanned cursor, and the server baseline. Pure + exported
|
|
18
|
+
* for testing.
|
|
19
|
+
*
|
|
20
|
+
* - `--full` always wins → undefined (scan everything, ignore baseline).
|
|
21
|
+
* - otherwise prefer the local cursor (normal incremental), falling back to
|
|
22
|
+
* the baseline so a fresh checkout still suppresses the pre-existing backlog.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveScanBaseSha(opts) {
|
|
25
|
+
if (opts.full) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
// `||` (not `??`) so an empty-string cursor falls through to the baseline.
|
|
29
|
+
return opts.lastScannedSha || opts.baselineSha || undefined;
|
|
30
|
+
}
|
|
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 async function getScanBaseline(scope) {
|
|
37
|
+
const row = await readScopedRow('quality_scan_baselines', 'baseline_commit_sha', scope);
|
|
38
|
+
return row?.baseline_commit_sha ?? null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fetch the baseline and resolve the scan's diff base in one call, logging when
|
|
42
|
+
* the baseline floor is applied on a fresh checkout. Shared by every find-*
|
|
43
|
+
* phase so the baseline wiring lives in one place.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveScanBase(scope, opts) {
|
|
46
|
+
const baselineSha = await getScanBaseline(scope);
|
|
47
|
+
const baseSha = resolveScanBaseSha({
|
|
48
|
+
full: opts.full,
|
|
49
|
+
lastScannedSha: opts.lastScannedSha,
|
|
50
|
+
baselineSha,
|
|
51
|
+
});
|
|
52
|
+
if (!opts.full && !opts.lastScannedSha && baselineSha) {
|
|
53
|
+
logInfo(`No local scan history; using server baseline ${baselineSha.slice(0, 8)} — reporting only findings new since the baseline.`);
|
|
54
|
+
}
|
|
55
|
+
return baseSha;
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-scope user-authored custom rules, shared across the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* Where the built-in rule packs (rule-packs.ts) ship stack-specific guidance,
|
|
5
|
+
* custom rules let a team write their *own* checks in natural language — the
|
|
6
|
+
* configurable-rules / CQLinq capability NDepend-style tools expose, without
|
|
7
|
+
* the query language. Each enabled rule's guidance is appended to the relevant
|
|
8
|
+
* scanner's system prompt so the agent flags violations like any other finding.
|
|
9
|
+
*
|
|
10
|
+
* Read here from the user's supabase session; authored from the desktop app
|
|
11
|
+
* (services/db/quality-custom-rules.ts). Stored in `quality_custom_rules`.
|
|
12
|
+
*/
|
|
13
|
+
import type { ScanScope } from './baseline.js';
|
|
14
|
+
import type { FindPhaseKind } from './rule-packs.js';
|
|
15
|
+
/** Phases that consume injected custom-rule guidance today. */
|
|
16
|
+
export type CustomRulePhase = Extract<FindPhaseKind, 'smells' | 'architecture'>;
|
|
17
|
+
export interface CustomRule {
|
|
18
|
+
label: string;
|
|
19
|
+
guidance: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the enabled custom rules for a scope + phase, oldest first. Never
|
|
23
|
+
* throws — a read failure (or no session) must not abort a scan; it just means
|
|
24
|
+
* no custom rules are injected.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getCustomRules(scope: ScanScope, phase: CustomRulePhase): Promise<CustomRule[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Render custom rules into a markdown block for appending to a find-* system
|
|
29
|
+
* prompt. Returns '' when there are none, so callers can concatenate
|
|
30
|
+
* unconditionally (same contract as renderRulePacks). Pure + exported for
|
|
31
|
+
* testing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function renderCustomRules(rules: CustomRule[]): string;
|
|
34
|
+
/**
|
|
35
|
+
* Fetch + render the scope's custom rules for a phase in one call, logging how
|
|
36
|
+
* many were applied. Shared by the rule-consuming phases so the wiring lives in
|
|
37
|
+
* one place. Returns the markdown block ('' when none apply).
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveCustomRules(scope: ScanScope, phase: CustomRulePhase): Promise<string>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-scope user-authored custom rules, shared across the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* Where the built-in rule packs (rule-packs.ts) ship stack-specific guidance,
|
|
5
|
+
* custom rules let a team write their *own* checks in natural language — the
|
|
6
|
+
* configurable-rules / CQLinq capability NDepend-style tools expose, without
|
|
7
|
+
* the query language. Each enabled rule's guidance is appended to the relevant
|
|
8
|
+
* scanner's system prompt so the agent flags violations like any other finding.
|
|
9
|
+
*
|
|
10
|
+
* Read here from the user's supabase session; authored from the desktop app
|
|
11
|
+
* (services/db/quality-custom-rules.ts). Stored in `quality_custom_rules`.
|
|
12
|
+
*/
|
|
13
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
14
|
+
import { logInfo, logWarning } from '../../utils/logger.js';
|
|
15
|
+
/**
|
|
16
|
+
* Fetch the enabled custom rules for a scope + phase, oldest first. Never
|
|
17
|
+
* throws — a read failure (or no session) must not abort a scan; it just means
|
|
18
|
+
* no custom rules are injected.
|
|
19
|
+
*/
|
|
20
|
+
export async function getCustomRules(scope, phase) {
|
|
21
|
+
if (!hasSupabaseSession()) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const query = getSupabase()
|
|
26
|
+
.from('quality_custom_rules')
|
|
27
|
+
.select('label, guidance')
|
|
28
|
+
.eq('phase', phase)
|
|
29
|
+
.eq('enabled', true);
|
|
30
|
+
const scoped = scope.productId
|
|
31
|
+
? query.eq('product_id', scope.productId)
|
|
32
|
+
: query.eq('repository_id', scope.repoId);
|
|
33
|
+
const { data, error } = await scoped.order('created_at', {
|
|
34
|
+
ascending: true,
|
|
35
|
+
});
|
|
36
|
+
if (error) {
|
|
37
|
+
logWarning(`Could not read quality_custom_rules: ${error.message}`);
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return data ?? [];
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
logWarning(`Could not read quality_custom_rules: ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Render custom rules into a markdown block for appending to a find-* system
|
|
49
|
+
* prompt. Returns '' when there are none, so callers can concatenate
|
|
50
|
+
* unconditionally (same contract as renderRulePacks). Pure + exported for
|
|
51
|
+
* testing.
|
|
52
|
+
*/
|
|
53
|
+
export function renderCustomRules(rules) {
|
|
54
|
+
if (rules.length === 0) {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
const sections = rules
|
|
58
|
+
.map((r) => `### ${r.label}\n${r.guidance}`)
|
|
59
|
+
.join('\n\n');
|
|
60
|
+
return `\n\n**Custom rules** — this team has defined the following project-specific checks. Treat each as an additional category, applying the same severity rubric and output format as the generic categories above:\n\n${sections}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Fetch + render the scope's custom rules for a phase in one call, logging how
|
|
64
|
+
* many were applied. Shared by the rule-consuming phases so the wiring lives in
|
|
65
|
+
* one place. Returns the markdown block ('' when none apply).
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveCustomRules(scope, phase) {
|
|
68
|
+
const rules = await getCustomRules(scope, phase);
|
|
69
|
+
if (rules.length > 0) {
|
|
70
|
+
logInfo(`Applying ${rules.length} custom ${phase} rule(s): ${rules
|
|
71
|
+
.map((r) => r.label)
|
|
72
|
+
.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
return renderCustomRules(rules);
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic project-context detection, shared across the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* The find-* auditors are LLM-driven and already language-agnostic for the
|
|
5
|
+
* generic smell/bug/architecture categories — the agent reads whatever source
|
|
6
|
+
* is in the repo. This module adds the missing piece needed for *targeted*,
|
|
7
|
+
* stack-specific rules: a cheap, no-LLM detection of which languages and
|
|
8
|
+
* frameworks a repository actually contains, so the right rule packs (see
|
|
9
|
+
* `rule-packs.ts`) can be selected and injected into the system prompt.
|
|
10
|
+
*
|
|
11
|
+
* It mirrors the role that Phase 1 detection plays for `quality-benchmark`,
|
|
12
|
+
* but stays deterministic (manifest-file inspection only) so a scan never pays
|
|
13
|
+
* an extra model round-trip just to figure out the stack.
|
|
14
|
+
*
|
|
15
|
+
* Polyglot repos are first-class: a monorepo with `apps/mobile/pubspec.yaml`
|
|
16
|
+
* and `services/api/go.mod` resolves to `{ languages: ['dart','go'],
|
|
17
|
+
* frameworks: ['flutter'] }`, and every matching rule pack is selected.
|
|
18
|
+
*
|
|
19
|
+
* Detection is best-effort and must never throw — a malformed manifest simply
|
|
20
|
+
* contributes nothing.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Detected stack for a repository. Values are lowercased so callers can do
|
|
24
|
+
* case-insensitive set membership without re-normalising — same convention as
|
|
25
|
+
* `selectToolsForContext` in the quality-benchmark tool catalog.
|
|
26
|
+
*/
|
|
27
|
+
export interface ProjectContext {
|
|
28
|
+
/** Languages present, e.g. ['dart', 'go', 'ts']. */
|
|
29
|
+
languages: string[];
|
|
30
|
+
/** Frameworks present, e.g. ['flutter', 'react']. */
|
|
31
|
+
frameworks: string[];
|
|
32
|
+
/** Marker filenames that were found, e.g. ['pubspec.yaml', 'package.json']. */
|
|
33
|
+
files_present: string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Detect the languages and frameworks present in a repository checkout.
|
|
37
|
+
*
|
|
38
|
+
* @param repoRoot absolute path to the repo checkout (cwd of the auditor).
|
|
39
|
+
*/
|
|
40
|
+
export declare function detectProjectContext(repoRoot: string): ProjectContext;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic project-context detection, shared across the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* The find-* auditors are LLM-driven and already language-agnostic for the
|
|
5
|
+
* generic smell/bug/architecture categories — the agent reads whatever source
|
|
6
|
+
* is in the repo. This module adds the missing piece needed for *targeted*,
|
|
7
|
+
* stack-specific rules: a cheap, no-LLM detection of which languages and
|
|
8
|
+
* frameworks a repository actually contains, so the right rule packs (see
|
|
9
|
+
* `rule-packs.ts`) can be selected and injected into the system prompt.
|
|
10
|
+
*
|
|
11
|
+
* It mirrors the role that Phase 1 detection plays for `quality-benchmark`,
|
|
12
|
+
* but stays deterministic (manifest-file inspection only) so a scan never pays
|
|
13
|
+
* an extra model round-trip just to figure out the stack.
|
|
14
|
+
*
|
|
15
|
+
* Polyglot repos are first-class: a monorepo with `apps/mobile/pubspec.yaml`
|
|
16
|
+
* and `services/api/go.mod` resolves to `{ languages: ['dart','go'],
|
|
17
|
+
* frameworks: ['flutter'] }`, and every matching rule pack is selected.
|
|
18
|
+
*
|
|
19
|
+
* Detection is best-effort and must never throw — a malformed manifest simply
|
|
20
|
+
* contributes nothing.
|
|
21
|
+
*/
|
|
22
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
/** Directories never worth walking — vendored, generated, or VCS metadata. */
|
|
25
|
+
const SKIP_DIRS = new Set([
|
|
26
|
+
'node_modules',
|
|
27
|
+
'.git',
|
|
28
|
+
'dist',
|
|
29
|
+
'build',
|
|
30
|
+
'out',
|
|
31
|
+
'.next',
|
|
32
|
+
'.dart_tool',
|
|
33
|
+
'target',
|
|
34
|
+
'vendor',
|
|
35
|
+
'Pods',
|
|
36
|
+
'.gradle',
|
|
37
|
+
'__pycache__',
|
|
38
|
+
'.venv',
|
|
39
|
+
'venv',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* How deep to walk looking for manifest files. Root + a couple of levels
|
|
43
|
+
* catches the common monorepo layouts (`apps/<x>/`, `packages/<x>/`,
|
|
44
|
+
* `services/<x>/`) without descending into the whole tree.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_DEPTH = 3;
|
|
47
|
+
/**
|
|
48
|
+
* Walk the repo (bounded depth, skipping vendored dirs) and collect the
|
|
49
|
+
* absolute paths of every manifest file we know how to read. Defensive: any
|
|
50
|
+
* unreadable directory is skipped silently.
|
|
51
|
+
*/
|
|
52
|
+
function collectManifests(repoRoot) {
|
|
53
|
+
// marker filename → list of absolute paths where it was found
|
|
54
|
+
const found = new Map();
|
|
55
|
+
const record = (marker, absPath) => {
|
|
56
|
+
const list = found.get(marker) ?? [];
|
|
57
|
+
list.push(absPath);
|
|
58
|
+
found.set(marker, list);
|
|
59
|
+
};
|
|
60
|
+
const walk = (dir, depth) => {
|
|
61
|
+
if (depth > MAX_DEPTH) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const abs = join(dir, entry);
|
|
73
|
+
let isDir = false;
|
|
74
|
+
try {
|
|
75
|
+
isDir = statSync(abs).isDirectory();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (isDir) {
|
|
81
|
+
if (!SKIP_DIRS.has(entry) && !entry.startsWith('.')) {
|
|
82
|
+
walk(abs, depth + 1);
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// File: record exact-name and extension-based markers.
|
|
87
|
+
if (MARKER_FILES.has(entry)) {
|
|
88
|
+
record(entry, abs);
|
|
89
|
+
}
|
|
90
|
+
else if (entry.endsWith('.csproj')) {
|
|
91
|
+
record('*.csproj', abs);
|
|
92
|
+
}
|
|
93
|
+
else if (entry.endsWith('.gemspec')) {
|
|
94
|
+
record('*.gemspec', abs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
walk(repoRoot, 0);
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
/** Exact-name manifest files we look for. */
|
|
102
|
+
const MARKER_FILES = new Set([
|
|
103
|
+
'pubspec.yaml',
|
|
104
|
+
'package.json',
|
|
105
|
+
'tsconfig.json',
|
|
106
|
+
'go.mod',
|
|
107
|
+
'Cargo.toml',
|
|
108
|
+
'pyproject.toml',
|
|
109
|
+
'requirements.txt',
|
|
110
|
+
'setup.py',
|
|
111
|
+
'Pipfile',
|
|
112
|
+
'Gemfile',
|
|
113
|
+
'pom.xml',
|
|
114
|
+
'build.gradle',
|
|
115
|
+
'build.gradle.kts',
|
|
116
|
+
]);
|
|
117
|
+
function safeRead(paths) {
|
|
118
|
+
if (!paths || paths.length === 0) {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
let out = '';
|
|
122
|
+
for (const p of paths) {
|
|
123
|
+
try {
|
|
124
|
+
out += `\n${readFileSync(p, 'utf-8')}`;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// unreadable manifest contributes nothing
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/** Add every framework whose marker regex matches `haystack`. */
|
|
133
|
+
function addFrameworks(frameworks, haystack, markers) {
|
|
134
|
+
for (const [re, name] of markers) {
|
|
135
|
+
if (re.test(haystack)) {
|
|
136
|
+
frameworks.add(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function detectDart({ manifests, languages, frameworks }) {
|
|
141
|
+
if (!manifests.has('pubspec.yaml')) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
languages.add('dart');
|
|
145
|
+
const pubspec = safeRead(manifests.get('pubspec.yaml'));
|
|
146
|
+
// Flutter projects declare `flutter:` as a dependency / SDK constraint.
|
|
147
|
+
if (/\bflutter\s*:/.test(pubspec) || /sdk:\s*flutter/.test(pubspec)) {
|
|
148
|
+
frameworks.add('flutter');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const JS_FRAMEWORK_MARKERS = [
|
|
152
|
+
[/"react-native"\s*:/, 'react-native'],
|
|
153
|
+
[/"react"\s*:/, 'react'],
|
|
154
|
+
[/"next"\s*:/, 'next'],
|
|
155
|
+
[/"vue"\s*:/, 'vue'],
|
|
156
|
+
[/"@angular\/core"\s*:/, 'angular'],
|
|
157
|
+
[/"svelte"\s*:/, 'svelte'],
|
|
158
|
+
[/"express"\s*:/, 'express'],
|
|
159
|
+
[/"@nestjs\/core"\s*:/, 'nest'],
|
|
160
|
+
];
|
|
161
|
+
function detectJsTs({ manifests, languages, frameworks }) {
|
|
162
|
+
if (!manifests.has('package.json')) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
languages.add('js');
|
|
166
|
+
const pkg = safeRead(manifests.get('package.json'));
|
|
167
|
+
if (manifests.has('tsconfig.json') || /"typescript"\s*:/.test(pkg)) {
|
|
168
|
+
languages.add('ts');
|
|
169
|
+
}
|
|
170
|
+
addFrameworks(frameworks, pkg, JS_FRAMEWORK_MARKERS);
|
|
171
|
+
}
|
|
172
|
+
const PY_FRAMEWORK_MARKERS = [
|
|
173
|
+
[/\bdjango\b/i, 'django'],
|
|
174
|
+
[/\bfastapi\b/i, 'fastapi'],
|
|
175
|
+
[/\bflask\b/i, 'flask'],
|
|
176
|
+
];
|
|
177
|
+
function detectPython({ manifests, languages, frameworks }) {
|
|
178
|
+
const hasPy = manifests.has('pyproject.toml') ||
|
|
179
|
+
manifests.has('requirements.txt') ||
|
|
180
|
+
manifests.has('setup.py') ||
|
|
181
|
+
manifests.has('Pipfile');
|
|
182
|
+
if (!hasPy) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
languages.add('py');
|
|
186
|
+
const py = safeRead(manifests.get('pyproject.toml')) +
|
|
187
|
+
safeRead(manifests.get('requirements.txt')) +
|
|
188
|
+
safeRead(manifests.get('Pipfile'));
|
|
189
|
+
addFrameworks(frameworks, py, PY_FRAMEWORK_MARKERS);
|
|
190
|
+
}
|
|
191
|
+
function detectRuby({ manifests, languages, frameworks }) {
|
|
192
|
+
if (!manifests.has('Gemfile') && !manifests.has('*.gemspec')) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
languages.add('ruby');
|
|
196
|
+
if (/\brails\b/i.test(safeRead(manifests.get('Gemfile')))) {
|
|
197
|
+
frameworks.add('rails');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function detectJvm({ manifests, languages }) {
|
|
201
|
+
const hasJvm = manifests.has('pom.xml') ||
|
|
202
|
+
manifests.has('build.gradle') ||
|
|
203
|
+
manifests.has('build.gradle.kts');
|
|
204
|
+
if (!hasJvm) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
languages.add('java');
|
|
208
|
+
const gradle = safeRead(manifests.get('build.gradle')) +
|
|
209
|
+
safeRead(manifests.get('build.gradle.kts'));
|
|
210
|
+
if (manifests.has('build.gradle.kts') || /\bkotlin\b/i.test(gradle)) {
|
|
211
|
+
languages.add('kotlin');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Single-marker languages with no framework inference. */
|
|
215
|
+
const SIMPLE_LANGUAGES = [
|
|
216
|
+
['go.mod', 'go'],
|
|
217
|
+
['Cargo.toml', 'rust'],
|
|
218
|
+
['*.csproj', 'cs'],
|
|
219
|
+
];
|
|
220
|
+
/**
|
|
221
|
+
* Detect the languages and frameworks present in a repository checkout.
|
|
222
|
+
*
|
|
223
|
+
* @param repoRoot absolute path to the repo checkout (cwd of the auditor).
|
|
224
|
+
*/
|
|
225
|
+
export function detectProjectContext(repoRoot) {
|
|
226
|
+
const manifests = collectManifests(repoRoot);
|
|
227
|
+
const acc = {
|
|
228
|
+
manifests,
|
|
229
|
+
languages: new Set(),
|
|
230
|
+
frameworks: new Set(),
|
|
231
|
+
};
|
|
232
|
+
for (const [marker, lang] of SIMPLE_LANGUAGES) {
|
|
233
|
+
if (manifests.has(marker)) {
|
|
234
|
+
acc.languages.add(lang);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
detectDart(acc);
|
|
238
|
+
detectJsTs(acc);
|
|
239
|
+
detectPython(acc);
|
|
240
|
+
detectRuby(acc);
|
|
241
|
+
detectJvm(acc);
|
|
242
|
+
return {
|
|
243
|
+
languages: [...acc.languages],
|
|
244
|
+
frameworks: [...acc.frameworks],
|
|
245
|
+
files_present: [...manifests.keys()],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -38,6 +38,12 @@ export interface CreateIssueInput {
|
|
|
38
38
|
title: string;
|
|
39
39
|
/** Already-formatted markdown body. The caller decides the layout. */
|
|
40
40
|
description: string;
|
|
41
|
+
/**
|
|
42
|
+
* Origin tag written to `issues.source`. Omit to accept the DB default
|
|
43
|
+
* ('manual'). The find-* scanners pass 'quality' so automated findings can be
|
|
44
|
+
* told apart from hand-written and externally-synced issues.
|
|
45
|
+
*/
|
|
46
|
+
source?: string;
|
|
41
47
|
}
|
|
42
48
|
/**
|
|
43
49
|
* File a new issue via MCP. The issue is scoped to a product OR a single
|