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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-scope rule-pack configuration, shared across the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* Lets a team tune which stack-aware rule packs the scanners apply via a preset
|
|
5
|
+
* plus explicit per-pack toggles — the configurable-rules capability DCM-style
|
|
6
|
+
* tools expose. Read here from the user's supabase session; edited from the
|
|
7
|
+
* desktop app (services/db/quality-rule-configs.ts). Stored in
|
|
8
|
+
* `quality_rule_configs`.
|
|
9
|
+
*/
|
|
10
|
+
import { logInfo } from '../../utils/logger.js';
|
|
11
|
+
import { detectProjectContext } from './detect-context.js';
|
|
12
|
+
import { selectRulePacks } from './rule-packs.js';
|
|
13
|
+
import { readScopedRow } from './scoped-read.js';
|
|
14
|
+
/**
|
|
15
|
+
* Apply a rule config to the packs detection selected. Pure + exported for
|
|
16
|
+
* testing.
|
|
17
|
+
*
|
|
18
|
+
* - no config → unchanged (default is 'recommended' = all detected packs).
|
|
19
|
+
* - 'minimal' → no packs (generic categories only).
|
|
20
|
+
* - otherwise → detected packs minus the explicitly disabled ones.
|
|
21
|
+
*/
|
|
22
|
+
export function applyRuleConfig(packs, config) {
|
|
23
|
+
if (!config) {
|
|
24
|
+
return packs;
|
|
25
|
+
}
|
|
26
|
+
if (config.preset === 'minimal') {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return packs.filter((p) => !config.disabledPacks.includes(p.id));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetch the rule config for a scope, or null if none is set / no session is
|
|
33
|
+
* available. Never throws — a read failure must not abort a scan (it just
|
|
34
|
+
* degrades to the default 'recommended' behaviour).
|
|
35
|
+
*/
|
|
36
|
+
export async function getRuleConfig(scope) {
|
|
37
|
+
const row = await readScopedRow('quality_rule_configs', 'preset, disabled_packs', scope);
|
|
38
|
+
if (!row) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
preset: row.preset ?? 'recommended',
|
|
43
|
+
disabledPacks: row.disabled_packs ?? [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Detect the repo's stack, apply the scope's rule config, and return the rule
|
|
48
|
+
* packs to inject — logging the detected stack and chosen packs. Shared by the
|
|
49
|
+
* pack-consuming phases (find-smells, find-architecture).
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveStackRulePacks(repoRoot, scope) {
|
|
52
|
+
const projectContext = detectProjectContext(repoRoot);
|
|
53
|
+
const ruleConfig = await getRuleConfig(scope);
|
|
54
|
+
const rulePacks = applyRuleConfig(selectRulePacks(projectContext), ruleConfig);
|
|
55
|
+
const detectedStack = [
|
|
56
|
+
...projectContext.languages,
|
|
57
|
+
...projectContext.frameworks,
|
|
58
|
+
];
|
|
59
|
+
const stackLabel = detectedStack.length
|
|
60
|
+
? detectedStack.join(', ')
|
|
61
|
+
: 'unknown';
|
|
62
|
+
const packLabel = rulePacks.length
|
|
63
|
+
? `; applying rule packs: ${rulePacks.map((p) => p.id).join(', ')}`
|
|
64
|
+
: '; no stack-specific rule packs matched';
|
|
65
|
+
logInfo(`Detected stack: ${stackLabel}${packLabel}`);
|
|
66
|
+
return rulePacks;
|
|
67
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack-specific rule packs for the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* The generic smell/bug/architecture categories in the base system prompts are
|
|
5
|
+
* language-neutral and always apply. A *rule pack* adds targeted, stack-aware
|
|
6
|
+
* guidance — e.g. Flutter widget anti-patterns, TypeScript type-safety escapes
|
|
7
|
+
* — that should only fire when the relevant language/framework is actually
|
|
8
|
+
* present in the repository.
|
|
9
|
+
*
|
|
10
|
+
* The design intentionally mirrors the quality-benchmark tool catalog:
|
|
11
|
+
* - tools gate on `applies_to: [<LanguageTag>]`, selected by
|
|
12
|
+
* `selectToolsForContext(languages)`.
|
|
13
|
+
* - rule packs gate on `applies_to: { languages?, frameworks?, files_present? }`,
|
|
14
|
+
* selected by `selectRulePacks(context)`.
|
|
15
|
+
*
|
|
16
|
+
* Adding support for a new language/framework is therefore a pure data change:
|
|
17
|
+
* append one `RulePack` here (and, eventually, more of them) — no phase code
|
|
18
|
+
* branches on a specific language anywhere.
|
|
19
|
+
*/
|
|
20
|
+
import type { ProjectContext } from './detect-context.js';
|
|
21
|
+
/**
|
|
22
|
+
* Which find-* phase a guidance block is written for. A pack only contributes
|
|
23
|
+
* to a phase it has guidance for, so the same pack can sharpen find-smells with
|
|
24
|
+
* local anti-patterns and find-architecture with structural ones without
|
|
25
|
+
* leaking smell guidance into the architecture audit (or vice versa).
|
|
26
|
+
*/
|
|
27
|
+
export type FindPhaseKind = 'smells' | 'architecture' | 'bugs';
|
|
28
|
+
export interface RulePack {
|
|
29
|
+
/** Stable id, shown in logs and used in tests. */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Human-readable label. */
|
|
32
|
+
label: string;
|
|
33
|
+
/**
|
|
34
|
+
* Gate conditions. A pack is selected when EVERY specified gate matches
|
|
35
|
+
* (AND across gates), where a gate matches if ANY of its values is present
|
|
36
|
+
* in the detected context (OR within a gate). At least one gate must be set.
|
|
37
|
+
*/
|
|
38
|
+
applies_to: {
|
|
39
|
+
languages?: string[];
|
|
40
|
+
frameworks?: string[];
|
|
41
|
+
files_present?: string[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Per-phase markdown guidance injected into the find-* system prompt when the
|
|
45
|
+
* pack is selected. Each block should enumerate concrete, file:line-citable
|
|
46
|
+
* anti-patterns that map onto that phase's existing categories (no new
|
|
47
|
+
* categories introduced). A pack may cover only some phases.
|
|
48
|
+
*/
|
|
49
|
+
guidance: Partial<Record<FindPhaseKind, string>>;
|
|
50
|
+
}
|
|
51
|
+
export declare const RULE_PACKS: readonly RulePack[];
|
|
52
|
+
/**
|
|
53
|
+
* Select the rule packs whose gates are satisfied by the detected context.
|
|
54
|
+
* Same AND-across-gates / OR-within-gate semantics as the quality-benchmark
|
|
55
|
+
* `selectToolsForContext`. Membership checks are case-insensitive.
|
|
56
|
+
*/
|
|
57
|
+
export declare function selectRulePacks(ctx: ProjectContext): RulePack[];
|
|
58
|
+
/**
|
|
59
|
+
* Render the guidance the selected packs provide *for a given phase* into a
|
|
60
|
+
* single markdown block for appending to that phase's system prompt. Packs
|
|
61
|
+
* without guidance for `kind` are skipped, so e.g. the TypeScript pack (smells
|
|
62
|
+
* only) contributes nothing to the architecture audit. Returns '' when nothing
|
|
63
|
+
* applies, so callers can concatenate unconditionally.
|
|
64
|
+
*/
|
|
65
|
+
export declare function renderRulePacks(packs: RulePack[], kind: FindPhaseKind): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack-specific rule packs for the find-* phases.
|
|
3
|
+
*
|
|
4
|
+
* The generic smell/bug/architecture categories in the base system prompts are
|
|
5
|
+
* language-neutral and always apply. A *rule pack* adds targeted, stack-aware
|
|
6
|
+
* guidance — e.g. Flutter widget anti-patterns, TypeScript type-safety escapes
|
|
7
|
+
* — that should only fire when the relevant language/framework is actually
|
|
8
|
+
* present in the repository.
|
|
9
|
+
*
|
|
10
|
+
* The design intentionally mirrors the quality-benchmark tool catalog:
|
|
11
|
+
* - tools gate on `applies_to: [<LanguageTag>]`, selected by
|
|
12
|
+
* `selectToolsForContext(languages)`.
|
|
13
|
+
* - rule packs gate on `applies_to: { languages?, frameworks?, files_present? }`,
|
|
14
|
+
* selected by `selectRulePacks(context)`.
|
|
15
|
+
*
|
|
16
|
+
* Adding support for a new language/framework is therefore a pure data change:
|
|
17
|
+
* append one `RulePack` here (and, eventually, more of them) — no phase code
|
|
18
|
+
* branches on a specific language anywhere.
|
|
19
|
+
*/
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Packs
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const flutter = {
|
|
24
|
+
id: 'flutter',
|
|
25
|
+
label: 'Flutter / Dart',
|
|
26
|
+
applies_to: { frameworks: ['flutter'] },
|
|
27
|
+
guidance: {
|
|
28
|
+
smells: `Flutter/Dart widget and performance smells (in addition to the generic categories):
|
|
29
|
+
- **Giant \`build()\` methods**: a build method spanning dozens of lines or many nested widgets — extract subtrees into named \`Widget\` classes or helper methods. (category: complexity)
|
|
30
|
+
- **Missing \`const\`**: widgets with compile-time-constant configuration that are not \`const\`, forcing needless rebuilds. (category: performance)
|
|
31
|
+
- **Deep widget nesting**: trees nested well beyond ~5 levels that should be decomposed into smaller widgets. (category: complexity)
|
|
32
|
+
- **Unbounded list rendering**: \`Column\`/\`ListView(children: [...])\` over a growing or large collection instead of \`ListView.builder\`/\`SliverList\`. (category: performance)
|
|
33
|
+
- **Work inside \`build()\`**: constructing controllers, futures, or expensive objects in \`build()\` instead of \`initState\`/memoization — they get recreated every rebuild. (category: performance)
|
|
34
|
+
- **Over-broad \`setState\`**: calling \`setState\` on a large widget where a smaller \`StatefulWidget\`, \`ValueListenableBuilder\`, or \`Selector\` would localise the rebuild. (category: performance)
|
|
35
|
+
- **Unkeyed dynamic lists**: reorderable/dynamic children without \`Key\`s, risking state mismatches. (category: readability)`,
|
|
36
|
+
architecture: `Flutter/Dart structural concerns (in addition to the generic categories):
|
|
37
|
+
- **Business logic in widgets**: data fetching, persistence, or domain rules embedded directly in \`Widget\`/\`State\` classes instead of a dedicated layer (controller/bloc/cubit/service). (concern: layering)
|
|
38
|
+
- **God widgets**: a single screen widget that builds UI, holds state, talks to the network, and formats data — split by responsibility. (concern: cohesion)
|
|
39
|
+
- **Cross-feature \`BuildContext\` reach-through**: features reaching into each other's widget state instead of going through a shared layer. (concern: coupling)`,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const typescript = {
|
|
43
|
+
id: 'typescript',
|
|
44
|
+
label: 'TypeScript',
|
|
45
|
+
applies_to: { languages: ['ts'] },
|
|
46
|
+
guidance: {
|
|
47
|
+
smells: `TypeScript type-safety smells (in addition to the generic categories):
|
|
48
|
+
- **Type escapes**: \`any\`/\`unknown\` casts that erase checking; prefer precise types or generics. (category: type_safety)
|
|
49
|
+
- **Suppressed errors**: \`@ts-ignore\`/\`@ts-expect-error\` hiding real type errors rather than fixing them. (category: type_safety)
|
|
50
|
+
- **Unsafe assertions**: \`as\` casts and non-null assertions (\`!\`) at trust boundaries instead of real narrowing/null checks. (category: type_safety)
|
|
51
|
+
- **Loose contracts**: \`object\`, open index signatures, or wide unions where a tighter interface/literal type is intended. (category: type_safety)
|
|
52
|
+
- **Derivable duplication**: enums/unions hand-maintained in parallel instead of derived (\`as const\` + \`keyof\`/\`typeof\`). (category: refactor)`,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const csharp = {
|
|
56
|
+
id: 'csharp',
|
|
57
|
+
label: 'C# / .NET',
|
|
58
|
+
applies_to: { languages: ['cs'] },
|
|
59
|
+
guidance: {
|
|
60
|
+
smells: `C#/.NET smells (in addition to the generic categories):
|
|
61
|
+
- **Async anti-patterns**: \`.Result\`/\`.Wait()\`/\`.GetAwaiter().GetResult()\` on tasks (sync-over-async deadlock risk); \`async void\` outside event handlers. (category: performance)
|
|
62
|
+
- **Multiple enumeration**: enumerating the same \`IEnumerable\` more than once (re-runs the query/LINQ); materialize with \`ToList()\`/\`ToArray()\` once. (category: performance)
|
|
63
|
+
- **Disposal**: \`IDisposable\` created without \`using\`/\`await using\` or an explicit \`Dispose\`. (category: type_safety)
|
|
64
|
+
- **Swallowed exceptions**: empty \`catch {}\` or \`catch (Exception)\` that hides failures. (category: refactor)
|
|
65
|
+
- **Nullable ignored**: suppressing nullable warnings with \`!\` (null-forgiving) at trust boundaries instead of real checks. (category: type_safety)
|
|
66
|
+
- **String building in loops**: \`+=\` string concatenation inside loops instead of \`StringBuilder\`. (category: performance)
|
|
67
|
+
- **Public mutable fields**: public fields where a property is intended. (category: readability)`,
|
|
68
|
+
architecture: `C#/.NET structural concerns (in addition to the generic categories):
|
|
69
|
+
- **Fat controllers**: business/data logic in ASP.NET controllers instead of a service/handler layer. (concern: layering)
|
|
70
|
+
- **Leaky persistence**: \`DbContext\`/EF queries used directly across layers instead of behind a repository/service boundary. (concern: layering)
|
|
71
|
+
- **Mutable static/singleton state**: static fields or singletons holding mutable shared state, complicating testing and concurrency. (concern: coupling)
|
|
72
|
+
- **Project reference cycles**: circular references between projects/assemblies. (concern: cyclic dependencies)`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Registry + selection
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export const RULE_PACKS = [
|
|
79
|
+
flutter,
|
|
80
|
+
typescript,
|
|
81
|
+
csharp,
|
|
82
|
+
];
|
|
83
|
+
/**
|
|
84
|
+
* Select the rule packs whose gates are satisfied by the detected context.
|
|
85
|
+
* Same AND-across-gates / OR-within-gate semantics as the quality-benchmark
|
|
86
|
+
* `selectToolsForContext`. Membership checks are case-insensitive.
|
|
87
|
+
*/
|
|
88
|
+
export function selectRulePacks(ctx) {
|
|
89
|
+
const langSet = new Set(ctx.languages.map((l) => l.toLowerCase()));
|
|
90
|
+
const fwSet = new Set(ctx.frameworks.map((f) => f.toLowerCase()));
|
|
91
|
+
const fileSet = new Set(ctx.files_present);
|
|
92
|
+
return RULE_PACKS.filter((pack) => {
|
|
93
|
+
const { languages, frameworks, files_present } = pack.applies_to;
|
|
94
|
+
const gates = [];
|
|
95
|
+
if (languages?.length) {
|
|
96
|
+
gates.push(languages.some((l) => langSet.has(l.toLowerCase())));
|
|
97
|
+
}
|
|
98
|
+
if (frameworks?.length) {
|
|
99
|
+
gates.push(frameworks.some((f) => fwSet.has(f.toLowerCase())));
|
|
100
|
+
}
|
|
101
|
+
if (files_present?.length) {
|
|
102
|
+
gates.push(files_present.some((f) => fileSet.has(f)));
|
|
103
|
+
}
|
|
104
|
+
// A pack with no gates is a misconfiguration — never auto-select it.
|
|
105
|
+
return gates.length > 0 && gates.every(Boolean);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Render the guidance the selected packs provide *for a given phase* into a
|
|
110
|
+
* single markdown block for appending to that phase's system prompt. Packs
|
|
111
|
+
* without guidance for `kind` are skipped, so e.g. the TypeScript pack (smells
|
|
112
|
+
* only) contributes nothing to the architecture audit. Returns '' when nothing
|
|
113
|
+
* applies, so callers can concatenate unconditionally.
|
|
114
|
+
*/
|
|
115
|
+
export function renderRulePacks(packs, kind) {
|
|
116
|
+
const sections = packs
|
|
117
|
+
.map((p) => ({ label: p.label, text: p.guidance[kind] }))
|
|
118
|
+
.filter((s) => Boolean(s.text))
|
|
119
|
+
.map((s) => `### ${s.label}\n${s.text}`);
|
|
120
|
+
if (sections.length === 0) {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
return `\n\n**Stack-specific checks** — the repository's detected stack triggers the following targeted checks. Apply them *in addition to* the generic categories above, using the same severity rubric and output format:\n\n${sections.join('\n\n')}`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper for reading a single scope-keyed row (product XOR repo) via the
|
|
3
|
+
* user's supabase session. Used by the find-* scan-config readers
|
|
4
|
+
* (getScanBaseline, getRuleConfig) so they don't each re-implement the
|
|
5
|
+
* session-guard + scoped query + never-throw error handling.
|
|
6
|
+
*/
|
|
7
|
+
import type { ScanScope } from './baseline.js';
|
|
8
|
+
/**
|
|
9
|
+
* Fetch one scope-keyed row, or null when there's no session / no row / a read
|
|
10
|
+
* error. Never throws — a read failure must not abort a scan.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readScopedRow<T>(table: string, columns: string, scope: ScanScope): Promise<T | null>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper for reading a single scope-keyed row (product XOR repo) via the
|
|
3
|
+
* user's supabase session. Used by the find-* scan-config readers
|
|
4
|
+
* (getScanBaseline, getRuleConfig) so they don't each re-implement the
|
|
5
|
+
* session-guard + scoped query + never-throw error handling.
|
|
6
|
+
*/
|
|
7
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
8
|
+
import { logWarning } from '../../utils/logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* Fetch one scope-keyed row, or null when there's no session / no row / a read
|
|
11
|
+
* error. Never throws — a read failure must not abort a scan.
|
|
12
|
+
*/
|
|
13
|
+
export async function readScopedRow(table, columns, scope) {
|
|
14
|
+
if (!hasSupabaseSession()) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const query = getSupabase().from(table).select(columns);
|
|
19
|
+
const scoped = scope.productId
|
|
20
|
+
? query.eq('product_id', scope.productId)
|
|
21
|
+
: query.eq('repository_id', scope.repoId);
|
|
22
|
+
const { data, error } = await scoped.maybeSingle();
|
|
23
|
+
if (error) {
|
|
24
|
+
logWarning(`Could not read ${table}: ${error.message}`);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return data ?? null;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
logWarning(`Could not read ${table}: ${err instanceof Error ? err.message : String(err)}`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { type SmellCategory } from './types.js';
|
|
11
11
|
export interface FindSmellsOptions {
|
|
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;
|
|
@@ -11,8 +11,11 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
11
11
|
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
|
+
import { resolveScanBase } from '../find-shared/baseline.js';
|
|
15
|
+
import { resolveCustomRules } from '../find-shared/custom-rules.js';
|
|
14
16
|
import { detectDefaultBranch, gitRevParse, isAncestor, listChangedPaths, } from '../find-shared/git.js';
|
|
15
|
-
import { createIssue, fetchOpenIssues, fetchProductBasics, } from '../find-shared/mcp.js';
|
|
17
|
+
import { createIssue, fetchOpenIssues, fetchOpenIssuesByRepo, fetchProductBasics, fetchRepositoryBasics, } from '../find-shared/mcp.js';
|
|
18
|
+
import { resolveStackRulePacks } from '../find-shared/rule-config.js';
|
|
16
19
|
import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
|
|
17
20
|
import { createFindSmellsSystemPrompt, createFindSmellsUserPrompt, } from './prompts.js';
|
|
18
21
|
import { acquireFindSmellsLock, loadFindSmellsState, updateFindSmellsState, } from './state.js';
|
|
@@ -31,31 +34,35 @@ const MAX_TURNS = 200;
|
|
|
31
34
|
*/
|
|
32
35
|
// eslint-disable-next-line complexity
|
|
33
36
|
export async function scanForSmells(options) {
|
|
34
|
-
const { productId, githubToken, owner, repo, full, maxFiles, categories, verbose, } = options;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const { productId, repoId, githubToken, owner, repo, full, maxFiles, categories, verbose, } = options;
|
|
38
|
+
// State/lock/workspace are keyed by an opaque scope id so product and repo
|
|
39
|
+
// scans never collide; repo keys are prefixed to namespace them clearly.
|
|
40
|
+
const scopeId = productId ?? `repo-${repoId}`;
|
|
41
|
+
const scopeLabel = productId ? `product ${productId}` : `repository ${repoId}`;
|
|
42
|
+
logInfo(`Starting smell scan for ${scopeLabel} (${owner}/${repo})`);
|
|
43
|
+
const lock = acquireFindSmellsLock(scopeId);
|
|
37
44
|
if (!lock) {
|
|
38
|
-
logWarning(`Another smell scan is already in progress for
|
|
45
|
+
logWarning(`Another smell scan is already in progress for ${scopeLabel}; skipping.`);
|
|
39
46
|
return {
|
|
40
47
|
status: 'error',
|
|
41
|
-
message: 'Another smell scan is already in progress for this
|
|
48
|
+
message: 'Another smell scan is already in progress for this scope',
|
|
42
49
|
};
|
|
43
50
|
}
|
|
44
51
|
let repoPath;
|
|
45
52
|
let scanSucceeded = false;
|
|
46
53
|
try {
|
|
47
|
-
updateFindSmellsState(
|
|
54
|
+
updateFindSmellsState(scopeId, {
|
|
48
55
|
lastAttemptedAt: new Date().toISOString(),
|
|
49
56
|
});
|
|
50
57
|
const workspaceRoot = ensureWorkspaceDir();
|
|
51
|
-
const repoKey = `${WORKSPACE_KEY}-${
|
|
58
|
+
const repoKey = `${WORKSPACE_KEY}-${scopeId}`;
|
|
52
59
|
({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, owner, repo, githubToken));
|
|
53
60
|
const branch = options.branch ?? detectDefaultBranch(repoPath);
|
|
54
61
|
logInfo(`Syncing ${owner}/${repo} to branch ${branch}`);
|
|
55
62
|
syncRepoToRef(repoPath, { branch }, githubToken);
|
|
56
63
|
const headSha = gitRevParse(repoPath, 'HEAD');
|
|
57
|
-
const state = loadFindSmellsState(
|
|
58
|
-
const baseSha =
|
|
64
|
+
const state = loadFindSmellsState(scopeId);
|
|
65
|
+
const baseSha = await resolveScanBase({ productId, repoId }, { full, lastScannedSha: state.lastScannedCommitSha });
|
|
59
66
|
let scope = 'full';
|
|
60
67
|
let changedPaths;
|
|
61
68
|
if (baseSha && baseSha !== headSha) {
|
|
@@ -66,7 +73,7 @@ export async function scanForSmells(options) {
|
|
|
66
73
|
logInfo(`Incremental scan: ${changedPaths.length} files changed since ${baseSha.slice(0, 8)}`);
|
|
67
74
|
if (changedPaths.length === 0) {
|
|
68
75
|
logSuccess('No code changes since last scan; nothing to do.');
|
|
69
|
-
updateFindSmellsState(
|
|
76
|
+
updateFindSmellsState(scopeId, {
|
|
70
77
|
lastScannedCommitSha: headSha,
|
|
71
78
|
lastScannedAt: new Date().toISOString(),
|
|
72
79
|
lastError: undefined,
|
|
@@ -90,7 +97,7 @@ export async function scanForSmells(options) {
|
|
|
90
97
|
// the sha didn't move, advance lastScannedAt + clear lastError so the
|
|
91
98
|
// state file accurately reflects when we last verified the repo.
|
|
92
99
|
logSuccess('HEAD unchanged since last scan; nothing to do.');
|
|
93
|
-
updateFindSmellsState(
|
|
100
|
+
updateFindSmellsState(scopeId, {
|
|
94
101
|
lastScannedAt: new Date().toISOString(),
|
|
95
102
|
lastError: undefined,
|
|
96
103
|
});
|
|
@@ -103,10 +110,19 @@ export async function scanForSmells(options) {
|
|
|
103
110
|
issuesCreated: 0,
|
|
104
111
|
};
|
|
105
112
|
}
|
|
106
|
-
const product =
|
|
107
|
-
|
|
113
|
+
const product = productId
|
|
114
|
+
? await fetchProductBasics(productId)
|
|
115
|
+
: await fetchRepositoryBasics(repoId);
|
|
116
|
+
const existingIssues = productId
|
|
117
|
+
? await fetchOpenIssues(productId)
|
|
118
|
+
: await fetchOpenIssuesByRepo(repoId);
|
|
108
119
|
logInfo(`Loaded ${existingIssues.length} existing issues for dedup context`);
|
|
109
|
-
const
|
|
120
|
+
const rulePacks = await resolveStackRulePacks(repoPath, {
|
|
121
|
+
productId,
|
|
122
|
+
repoId,
|
|
123
|
+
});
|
|
124
|
+
const customRules = await resolveCustomRules({ productId, repoId }, 'smells');
|
|
125
|
+
const systemPrompt = createFindSmellsSystemPrompt(rulePacks, customRules);
|
|
110
126
|
const userPrompt = createFindSmellsUserPrompt({
|
|
111
127
|
productName: product.name,
|
|
112
128
|
productDescription: product.description,
|
|
@@ -159,7 +175,7 @@ export async function scanForSmells(options) {
|
|
|
159
175
|
}
|
|
160
176
|
if (!scanResult) {
|
|
161
177
|
const msg = 'Audit failed: could not parse a scan_result from the agent';
|
|
162
|
-
updateFindSmellsState(
|
|
178
|
+
updateFindSmellsState(scopeId, { lastError: msg });
|
|
163
179
|
return {
|
|
164
180
|
status: 'error',
|
|
165
181
|
message: msg,
|
|
@@ -169,15 +185,17 @@ export async function scanForSmells(options) {
|
|
|
169
185
|
logInfo(`Audit produced ${smells.length} candidate smells. ${summary}`);
|
|
170
186
|
const deferredBugs = scanResult.deferred_to_bugs ?? [];
|
|
171
187
|
const deferredFeatures = scanResult.deferred_to_features ?? [];
|
|
188
|
+
// CLI suggestion argument: a productId positional, or the --repo-id flag.
|
|
189
|
+
const scopeArg = productId ?? `--repo-id ${repoId}`;
|
|
172
190
|
if (deferredBugs.length > 0) {
|
|
173
|
-
logInfo(`${deferredBugs.length} finding(s) deferred to find-bugs — run \`edsger find-bugs ${
|
|
191
|
+
logInfo(`${deferredBugs.length} finding(s) deferred to find-bugs — run \`edsger find-bugs ${scopeArg}\` to pick them up:`);
|
|
174
192
|
for (const d of deferredBugs) {
|
|
175
193
|
const loc = d.file ? ` (${d.file}${d.line ? `:${d.line}` : ''})` : '';
|
|
176
194
|
logInfo(` • ${d.title}${loc} — ${d.reason}`);
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
197
|
if (deferredFeatures.length > 0) {
|
|
180
|
-
logInfo(`${deferredFeatures.length} finding(s) deferred to find-features — run \`edsger find-features ${
|
|
198
|
+
logInfo(`${deferredFeatures.length} finding(s) deferred to find-features — run \`edsger find-features ${scopeArg}\` to pick them up:`);
|
|
181
199
|
for (const d of deferredFeatures) {
|
|
182
200
|
const loc = d.file ? ` (${d.file}${d.line ? `:${d.line}` : ''})` : '';
|
|
183
201
|
logInfo(` • ${d.title}${loc} — ${d.reason}`);
|
|
@@ -192,13 +210,13 @@ export async function scanForSmells(options) {
|
|
|
192
210
|
}
|
|
193
211
|
let created = 0;
|
|
194
212
|
for (const smell of filteredSmells) {
|
|
195
|
-
const issueId = await createIssueForSmell(productId, smell);
|
|
213
|
+
const issueId = await createIssueForSmell({ productId, repoId }, smell);
|
|
196
214
|
if (issueId) {
|
|
197
215
|
created++;
|
|
198
216
|
logSuccess(`Filed issue ${issueId}: ${smell.title}`);
|
|
199
217
|
}
|
|
200
218
|
}
|
|
201
|
-
updateFindSmellsState(
|
|
219
|
+
updateFindSmellsState(scopeId, {
|
|
202
220
|
lastScannedCommitSha: headSha,
|
|
203
221
|
lastScannedAt: new Date().toISOString(),
|
|
204
222
|
lastError: undefined,
|
|
@@ -219,7 +237,7 @@ export async function scanForSmells(options) {
|
|
|
219
237
|
catch (error) {
|
|
220
238
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
221
239
|
logError(`Smell scan failed: ${errorMessage}`);
|
|
222
|
-
updateFindSmellsState(
|
|
240
|
+
updateFindSmellsState(scopeId, { lastError: errorMessage });
|
|
223
241
|
return {
|
|
224
242
|
status: 'error',
|
|
225
243
|
message: `Smell scan failed: ${errorMessage}`,
|
|
@@ -232,11 +250,13 @@ export async function scanForSmells(options) {
|
|
|
232
250
|
lock.release();
|
|
233
251
|
}
|
|
234
252
|
}
|
|
235
|
-
async function createIssueForSmell(
|
|
253
|
+
async function createIssueForSmell(scope, smell) {
|
|
236
254
|
return createIssue({
|
|
237
|
-
productId,
|
|
255
|
+
productId: scope.productId,
|
|
256
|
+
repoId: scope.repoId,
|
|
238
257
|
title: smell.title,
|
|
239
258
|
description: formatIssueDescription(smell),
|
|
259
|
+
source: 'quality',
|
|
240
260
|
});
|
|
241
261
|
}
|
|
242
262
|
/**
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - find-smells (this phase) handles "the code would be better if changed":
|
|
10
10
|
* refactor candidates, dead code, perf cliffs, type-safety gaps, etc.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
import { type RulePack } from '../find-shared/rule-packs.js';
|
|
13
|
+
export declare function createFindSmellsSystemPrompt(packs?: RulePack[], customRules?: string): string;
|
|
13
14
|
export interface FindSmellsUserPromptParams {
|
|
14
15
|
productName: string;
|
|
15
16
|
productDescription?: string;
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - find-smells (this phase) handles "the code would be better if changed":
|
|
10
10
|
* refactor candidates, dead code, perf cliffs, type-safety gaps, etc.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
import { renderRulePacks } from '../find-shared/rule-packs.js';
|
|
13
|
+
export function createFindSmellsSystemPrompt(packs = [], customRules = '') {
|
|
13
14
|
return `You are a senior engineer auditing a codebase for **code smells** — concrete improvements the codebase would benefit from. You have read-only access via Read/Grep/Glob and may run shallow Bash queries (e.g. \`git log\`, \`wc -l\`) to navigate.
|
|
14
15
|
|
|
15
16
|
**What counts as a smell worth filing**:
|
|
@@ -18,9 +19,9 @@ export function createFindSmellsSystemPrompt() {
|
|
|
18
19
|
3. **Duplication**: copy-pasted blocks that should be a single helper
|
|
19
20
|
4. **Complexity**: functions / files that are too long or too deeply nested to reason about, cyclomatic-complexity hotspots
|
|
20
21
|
5. **Dead code**: unreferenced exports, unreachable branches, abandoned feature flags, commented-out blocks
|
|
21
|
-
6. **Type safety**:
|
|
22
|
+
6. **Type safety**: defeated or bypassed type checks — unsafe casts, suppressed type errors, missing null/undefined checks at trust boundaries, loose types that should be tightened (language-specific instances are listed under "Stack-specific checks" when applicable)
|
|
22
23
|
7. **Readability**: misleading names, missing or wrong comments, magic numbers that should be named constants
|
|
23
|
-
8. **Architecture**: cyclic deps, layering violations, modules that have grown into multiple responsibilities
|
|
24
|
+
8. **Architecture**: cyclic deps, layering violations, modules that have grown into multiple responsibilities${renderRulePacks(packs, 'smells')}${customRules}
|
|
24
25
|
|
|
25
26
|
**What does NOT count** (skip these — wrong tool):
|
|
26
27
|
- 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\` so the user knows to run that command.**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality gate evaluation for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Lets `edsger quality-benchmark --gate` enforce a per-scope pass/fail
|
|
5
|
+
* threshold against the report it just produced — the "fail the build" gate
|
|
6
|
+
* NDepend/DCM-style tools expose, usable directly in a user's own CI without
|
|
7
|
+
* any webhook. The gate (overall-score floor, critical-finding cap, per-
|
|
8
|
+
* dimension minimums) is stored in `quality_gates`, mirroring the desktop
|
|
9
|
+
* evaluator in desktop-app/.../services/db/quality-gate.ts.
|
|
10
|
+
*
|
|
11
|
+
* The evaluator is pure + exported for testing; the read is RLS-scoped via the
|
|
12
|
+
* user's supabase session and never throws (a missing gate = nothing to
|
|
13
|
+
* enforce).
|
|
14
|
+
*/
|
|
15
|
+
import type { ScanScope } from '../find-shared/baseline.js';
|
|
16
|
+
import type { Dimension, QualityReportPayload } from './types.js';
|
|
17
|
+
export interface QualityGate {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
/** Minimum overall score (0–100); null = unconstrained. */
|
|
20
|
+
min_overall_score: number | null;
|
|
21
|
+
/** Maximum critical-severity findings allowed; null = unconstrained. */
|
|
22
|
+
max_critical_findings: number | null;
|
|
23
|
+
/** Per-dimension minimum scores; absent dimensions are unconstrained. */
|
|
24
|
+
min_dimension_scores: Partial<Record<Dimension, number>>;
|
|
25
|
+
}
|
|
26
|
+
export interface GateViolation {
|
|
27
|
+
/** Axis that failed, e.g. "Overall score" or "security". */
|
|
28
|
+
label: string;
|
|
29
|
+
/** The threshold that was required. */
|
|
30
|
+
required: string;
|
|
31
|
+
/** The actual value from the report. */
|
|
32
|
+
actual: string;
|
|
33
|
+
}
|
|
34
|
+
export interface GateResult {
|
|
35
|
+
passed: boolean;
|
|
36
|
+
violations: GateViolation[];
|
|
37
|
+
}
|
|
38
|
+
/** Count critical-severity findings across every dimension's evidence. */
|
|
39
|
+
export declare function countCriticalFindings(report: QualityReportPayload): number;
|
|
40
|
+
/**
|
|
41
|
+
* Evaluate a report against a gate, returning every violated axis. A disabled
|
|
42
|
+
* gate passes vacuously. A report with no overall score is not failed on the
|
|
43
|
+
* overall-score axis (there is nothing to compare). Pure + exported for tests.
|
|
44
|
+
*/
|
|
45
|
+
export declare function evaluateGate(report: QualityReportPayload, gate: QualityGate): GateResult;
|
|
46
|
+
/**
|
|
47
|
+
* Fetch the gate configured for a scope, or null if none is set / no session.
|
|
48
|
+
* Never throws — a read failure degrades to "no gate" (nothing enforced).
|
|
49
|
+
*/
|
|
50
|
+
export declare function getQualityGate(scope: ScanScope): Promise<QualityGate | null>;
|