@ddtcorex/dsh-maestro-review 0.3.2 → 0.5.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/README.md +12 -0
- package/cordis.patch.yml +18 -0
- package/lib/ci-clone.d.ts +21 -0
- package/lib/ci-clone.d.ts.map +1 -0
- package/lib/ci-clone.js +35 -0
- package/lib/ci-clone.js.map +1 -0
- package/lib/ci-coexist.d.ts +9 -0
- package/lib/ci-coexist.d.ts.map +1 -0
- package/lib/ci-coexist.js +36 -0
- package/lib/ci-coexist.js.map +1 -0
- package/lib/config-store.d.ts +13 -6
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js.map +1 -1
- package/lib/events.d.ts +23 -0
- package/lib/events.d.ts.map +1 -1
- package/lib/gitlab-auth.d.ts +11 -0
- package/lib/gitlab-auth.d.ts.map +1 -0
- package/lib/gitlab-auth.js +15 -0
- package/lib/gitlab-auth.js.map +1 -0
- package/lib/gitlab-client.d.ts +57 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +120 -17
- package/lib/gitlab-client.js.map +1 -1
- package/lib/govard-audit-lint-tool.d.ts +81 -0
- package/lib/govard-audit-lint-tool.d.ts.map +1 -1
- package/lib/govard-audit-lint-tool.js +121 -36
- package/lib/govard-audit-lint-tool.js.map +1 -1
- package/lib/govard-tool.js +1 -1
- package/lib/govard-tool.js.map +1 -1
- package/lib/incremental.d.ts.map +1 -1
- package/lib/incremental.js +3 -2
- package/lib/incremental.js.map +1 -1
- package/lib/orchestrator.d.ts +137 -5
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +439 -70
- package/lib/orchestrator.js.map +1 -1
- package/lib/providers/ci-trigger.d.ts +43 -0
- package/lib/providers/ci-trigger.d.ts.map +1 -0
- package/lib/providers/ci-trigger.js +146 -0
- package/lib/providers/ci-trigger.js.map +1 -0
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js +2 -1
- package/lib/providers/gitlab.js.map +1 -1
- package/lib/review-findings-tool.d.ts +5 -0
- package/lib/review-findings-tool.d.ts.map +1 -1
- package/lib/review-findings-tool.js +3 -1
- package/lib/review-findings-tool.js.map +1 -1
- package/lib/review-intake.d.ts.map +1 -1
- package/lib/review-intake.js +5 -1
- package/lib/review-intake.js.map +1 -1
- package/lib/review-marker.d.ts +17 -0
- package/lib/review-marker.d.ts.map +1 -0
- package/lib/review-marker.js +23 -0
- package/lib/review-marker.js.map +1 -0
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.js +4 -8
- package/lib/review-signals.js.map +1 -1
- package/lib/settings-rpc.js +1 -1
- package/lib/settings-rpc.js.map +1 -1
- package/package.json +14 -9
- package/profiles/reviewer-ci/cordis.patch.yml +61 -0
- package/profiles/reviewer-ci/package.json +19 -0
- package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
- package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
- package/src/host/ci-clone.ts +54 -0
- package/src/host/ci-coexist.ts +43 -0
- package/src/host/config-store.ts +14 -1
- package/src/host/events.ts +25 -0
- package/src/host/gitlab-auth.ts +13 -0
- package/src/host/gitlab-client.ts +142 -17
- package/src/host/govard-audit-lint-tool.ts +144 -31
- package/src/host/govard-tool.ts +1 -1
- package/src/host/incremental.ts +3 -2
- package/src/host/orchestrator.ts +462 -50
- package/src/host/providers/ci-trigger.ts +164 -0
- package/src/host/providers/gitlab.ts +2 -1
- package/src/host/review-findings-tool.ts +9 -1
- package/src/host/review-intake.ts +5 -1
- package/src/host/review-marker.ts +25 -0
- package/src/host/review-signals.ts +4 -3
- package/src/host/settings-rpc.ts +1 -1
- package/templates/reviewer-project.gitlab-ci.yml +90 -0
- package/templates/source-project.gitlab-ci.yml +42 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import z from '@deepseek-ai/schemastery'
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
4
7
|
|
|
5
8
|
export const name = 'maestro-gitlab-client'
|
|
6
9
|
export const inject = ['tools']
|
|
@@ -27,6 +30,23 @@ interface GitlabDiff {
|
|
|
27
30
|
diff: string
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
export interface DiffFileEntry {
|
|
34
|
+
path: string
|
|
35
|
+
bytes: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Per-file inventory of the MR diff — small enough to return inline. Pure. */
|
|
39
|
+
export function diffFileList(diffs: GitlabDiff[]): DiffFileEntry[] {
|
|
40
|
+
return diffs.map((d) => ({ path: d.new_path, bytes: Buffer.byteLength(d.diff, 'utf8') }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Unified text for one file, or undefined when the path is not in the MR. Pure. */
|
|
44
|
+
export function selectFileDiff(diffs: GitlabDiff[], path: string): string | undefined {
|
|
45
|
+
const d = diffs.find((x) => x.new_path === path || x.old_path === path)
|
|
46
|
+
if (d === undefined) return undefined
|
|
47
|
+
return `--- ${d.old_path}\n+++ ${d.new_path}\n${d.diff}`
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
interface GitlabDiffRefs {
|
|
31
51
|
base_sha: string
|
|
32
52
|
start_sha: string
|
|
@@ -45,10 +65,74 @@ interface GitlabDiscussion {
|
|
|
45
65
|
notes: GitlabDiscussionNote[]
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
interface OwnThread {
|
|
69
|
+
discussionId: string
|
|
70
|
+
path: string
|
|
71
|
+
line: number | null
|
|
72
|
+
lastCommentBody: string
|
|
73
|
+
resolved: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the in-workspace spill path for the MR diff. The DSH runtime
|
|
78
|
+
* spills large tool outputs to /tmp/dsh-spill-*, which sits outside the
|
|
79
|
+
* agent's cwd — and Guard blocks the agent from reading it back
|
|
80
|
+
* ("path outside cwd", tickets g-dd0d1679/g-716cd436). Writing the diff
|
|
81
|
+
* inside the workspace root keeps it readable via maestro_read_file.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveDiffSpillPath(workspaceRoot: string, mrIid: number): string {
|
|
84
|
+
return join(resolve(workspaceRoot), '.maestro', `mr-${mrIid}.diff`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeDiffSpill(workspaceRoot: string, mrIid: number, text: string): { path: string; bytes: number } {
|
|
88
|
+
const abs = resolveDiffSpillPath(workspaceRoot, mrIid)
|
|
89
|
+
mkdirSync(join(resolve(workspaceRoot), '.maestro'), { recursive: true })
|
|
90
|
+
writeFileSync(abs, text, 'utf-8')
|
|
91
|
+
return { path: join('.maestro', `mr-${mrIid}.diff`), bytes: Buffer.byteLength(text, 'utf8') }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
95
|
+
function sessionCwd(e: unknown): string | undefined {
|
|
96
|
+
const cwd = (e as SC|undefined)?.agent?.session?.header?.cwd
|
|
97
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pick this bot's inline threads out of the MR discussions list, including
|
|
102
|
+
* resolved ones (flagged) so a same-SHA re-review can reply-update instead
|
|
103
|
+
* of posting duplicates. Pure for unit testing.
|
|
104
|
+
*/
|
|
105
|
+
export function selectOwnThreads(
|
|
106
|
+
discussions: GitlabDiscussion[],
|
|
107
|
+
botUsername: string,
|
|
108
|
+
): { threads: OwnThread[]; totalDiscussions: number } {
|
|
109
|
+
const threads = discussions
|
|
110
|
+
.filter(d => d.notes.length > 0 && d.notes[0].author.username === botUsername && d.notes[0].position !== undefined)
|
|
111
|
+
.map(d => ({
|
|
112
|
+
discussionId: d.id,
|
|
113
|
+
path: d.notes[0].position!.new_path,
|
|
114
|
+
line: d.notes[0].position!.new_line,
|
|
115
|
+
lastCommentBody: d.notes[d.notes.length - 1].body,
|
|
116
|
+
resolved: d.notes[0].resolved === true,
|
|
117
|
+
}))
|
|
118
|
+
return { threads, totalDiscussions: discussions.length }
|
|
119
|
+
}
|
|
120
|
+
|
|
48
121
|
export function apply(ctx: Context, config: Config): void {
|
|
49
122
|
const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`
|
|
50
|
-
const headers =
|
|
123
|
+
const headers = gitlabAuthHeaders(config.token)
|
|
51
124
|
let cachedDiffRefs: GitlabDiffRefs | undefined
|
|
125
|
+
let cachedDiffs: GitlabDiff[] | undefined
|
|
126
|
+
|
|
127
|
+
async function getDiffs(): Promise<GitlabDiff[]> {
|
|
128
|
+
if (cachedDiffs !== undefined) return cachedDiffs
|
|
129
|
+
const response = await fetch(`${apiBase}/diffs`, { headers })
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
132
|
+
}
|
|
133
|
+
cachedDiffs = (await response.json()) as GitlabDiff[]
|
|
134
|
+
return cachedDiffs
|
|
135
|
+
}
|
|
52
136
|
|
|
53
137
|
async function getDiffRefs(): Promise<GitlabDiffRefs> {
|
|
54
138
|
if (cachedDiffRefs !== undefined) return cachedDiffRefs
|
|
@@ -62,20 +146,41 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
62
146
|
|
|
63
147
|
ctx.tools.register(defineTool({
|
|
64
148
|
name: 'gitlab_get_mr_diff',
|
|
65
|
-
description: 'Fetch the current unified diff for this merge request.',
|
|
149
|
+
description: 'Fetch the current unified diff for this merge request. The full diff is written to path (inside your workspace, readable via maestro_read_file); bytes is its size and files lists per-file sizes. Prefer gitlab_get_file_diff per file you inspect — small inline results never spill. Do not look for anything under /tmp.',
|
|
66
150
|
parameters: {},
|
|
67
151
|
output: {
|
|
68
|
-
schema: { type: 'object', additionalProperties: false, properties: {
|
|
152
|
+
schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, bytes: { type: 'number', required: true }, files: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, bytes: { type: 'number', required: true } } } } } },
|
|
153
|
+
render: (_args, value) => [{ type: 'text', text: `diff written to ${value.path} (${value.bytes} bytes, ${value.files.length} files)` }],
|
|
154
|
+
},
|
|
155
|
+
async execute(_args, exec) {
|
|
156
|
+
const diffs = await getDiffs()
|
|
157
|
+
const text = diffs.map(d => `--- ${d.old_path}\n+++ ${d.new_path}\n${d.diff}`).join('\n\n')
|
|
158
|
+
const cwd = sessionCwd(exec)
|
|
159
|
+
// No session cwd (should not happen for review agents): fall back to
|
|
160
|
+
// the legacy inline text, which the runtime may spill to /tmp.
|
|
161
|
+
if (cwd === undefined) return { path: '', bytes: Buffer.byteLength(text, 'utf8'), files: diffFileList(diffs) }
|
|
162
|
+
const spilled = writeDiffSpill(cwd, config.mrIid, text)
|
|
163
|
+
return { ...spilled, files: diffFileList(diffs) }
|
|
164
|
+
},
|
|
165
|
+
}))
|
|
166
|
+
|
|
167
|
+
ctx.tools.register(defineTool({
|
|
168
|
+
name: 'gitlab_get_file_diff',
|
|
169
|
+
description: "Return one file's unified diff inline (small results never spill to /tmp). Use after gitlab_get_mr_diff to inspect files one by one.",
|
|
170
|
+
parameters: {
|
|
171
|
+
path: { type: 'string', required: true, description: 'New path of the file, as listed in files.' },
|
|
172
|
+
},
|
|
173
|
+
output: {
|
|
174
|
+
schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, text: { type: 'string', required: true } } },
|
|
69
175
|
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
70
176
|
},
|
|
71
|
-
async execute() {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
177
|
+
async execute(args) {
|
|
178
|
+
const diffs = await getDiffs()
|
|
179
|
+
const text = selectFileDiff(diffs, args.path)
|
|
180
|
+
if (text === undefined) {
|
|
181
|
+
throw new Error(`path not in this MR diff: ${args.path}`)
|
|
75
182
|
}
|
|
76
|
-
|
|
77
|
-
const text = diffs.map(d => `--- ${d.old_path}\n+++ ${d.new_path}\n${d.diff}`).join('\n\n')
|
|
78
|
-
return { text }
|
|
183
|
+
return { path: args.path, text }
|
|
79
184
|
},
|
|
80
185
|
}))
|
|
81
186
|
|
|
@@ -104,11 +209,34 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
104
209
|
|
|
105
210
|
ctx.tools.register(defineTool({
|
|
106
211
|
name: 'gitlab_list_own_review_threads',
|
|
107
|
-
description: 'List this merge request\'s
|
|
212
|
+
description: 'List this merge request\'s inline discussion threads previously created by this bot account (both unresolved and resolved, flagged), so you can reply instead of duplicating. Returns {threads, totalDiscussions}.',
|
|
108
213
|
parameters: {},
|
|
109
214
|
output: {
|
|
110
|
-
schema: {
|
|
111
|
-
|
|
215
|
+
schema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
additionalProperties: false,
|
|
218
|
+
properties: {
|
|
219
|
+
threads: {
|
|
220
|
+
type: 'array',
|
|
221
|
+
items: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
properties: {
|
|
225
|
+
discussionId: { type: 'string', required: true },
|
|
226
|
+
path: { type: 'string', required: true },
|
|
227
|
+
line: { type: 'number' },
|
|
228
|
+
lastCommentBody: { type: 'string', required: true },
|
|
229
|
+
resolved: { type: 'boolean', required: true },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
totalDiscussions: { type: 'number', required: true },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
render: (_args, value: { threads: OwnThread[]; totalDiscussions: number }) => [{
|
|
237
|
+
type: 'text',
|
|
238
|
+
text: `${value.totalDiscussions} discussion(s) on this MR, ${value.threads.length} own inline thread(s):\n${JSON.stringify(value.threads)}`,
|
|
239
|
+
}],
|
|
112
240
|
},
|
|
113
241
|
async execute() {
|
|
114
242
|
const response = await fetch(`${apiBase}/discussions`, { headers })
|
|
@@ -116,10 +244,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
116
244
|
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
117
245
|
}
|
|
118
246
|
const discussions = await response.json() as GitlabDiscussion[]
|
|
119
|
-
|
|
120
|
-
.filter(d => d.notes.length > 0 && d.notes[0].author.username === config.botUsername && !d.notes[0].resolved && d.notes[0].position !== undefined)
|
|
121
|
-
.map(d => ({ discussionId: d.id, path: d.notes[0].position!.new_path, line: d.notes[0].position!.new_line, lastCommentBody: d.notes[d.notes.length - 1].body }))
|
|
122
|
-
return { text: JSON.stringify(ownThreads) }
|
|
247
|
+
return selectOwnThreads(discussions, config.botUsername)
|
|
123
248
|
},
|
|
124
249
|
}))
|
|
125
250
|
|
|
@@ -7,7 +7,39 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
7
7
|
|
|
8
8
|
export const name='maestro-govard-audit-lint-tool'
|
|
9
9
|
export const inject=['tools']
|
|
10
|
-
export const Config:z<{rootPath?:string, timeoutMs?:number}> = z.object({rootPath:z.string(), timeoutMs:z.number()})
|
|
10
|
+
export const Config:z<{rootPath?:string, timeoutMs?:number, defaultBase?:string, allowXdebug?:boolean}> = z.object({rootPath:z.string(), timeoutMs:z.number(), defaultBase:z.string(), allowXdebug:z.boolean()})
|
|
11
|
+
|
|
12
|
+
export interface AuditLintOptions {
|
|
13
|
+
checks?: string[]
|
|
14
|
+
mode?: string
|
|
15
|
+
scope?: string
|
|
16
|
+
base?: string
|
|
17
|
+
phpVersions?: string[]
|
|
18
|
+
noLintResultCache?: boolean
|
|
19
|
+
timeout?: string
|
|
20
|
+
lintProvider?: string
|
|
21
|
+
allowXdebug?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Explicit call base wins, then the review-wired default (MR base_sha). */
|
|
25
|
+
export function resolveLintBase(argsBase: string | undefined, defaultBase: string | undefined): string | undefined {
|
|
26
|
+
return argsBase ?? defaultBase
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildAuditCliArgs(a: AuditLintOptions): string[] {
|
|
30
|
+
const checks = a.checks && a.checks.length ? a.checks.join(',') : 'lint'
|
|
31
|
+
const mode = a.mode ?? 'auto'
|
|
32
|
+
const timeout = a.timeout ?? 'auto'
|
|
33
|
+
const lintProvider = a.lintProvider ?? 'govard'
|
|
34
|
+
const cliArgs=['audit','run','--checks',checks,'--format','json','--mode',mode,'--timeout',timeout,'--lint-provider',lintProvider]
|
|
35
|
+
if(a.scope) cliArgs.push('--scope', a.scope)
|
|
36
|
+
const base = resolveLintBase(a.base, undefined)
|
|
37
|
+
if(base) cliArgs.push('--base', base)
|
|
38
|
+
if(a.phpVersions && a.phpVersions.length) cliArgs.push('--php', a.phpVersions.join(','))
|
|
39
|
+
if(a.noLintResultCache) cliArgs.push('--no-lint-result-cache')
|
|
40
|
+
if(a.allowXdebug) cliArgs.push('--allow-xdebug')
|
|
41
|
+
return cliArgs
|
|
42
|
+
}
|
|
11
43
|
|
|
12
44
|
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
13
45
|
function workspaceRootFor(c:string|undefined, e:unknown):string{
|
|
@@ -38,7 +70,98 @@ async function isInsideRoot(r:string,t:string):Promise<boolean>{
|
|
|
38
70
|
}
|
|
39
71
|
}
|
|
40
72
|
export function cleanJson(raw:string):string{
|
|
41
|
-
|
|
73
|
+
// pterm pads the level name: " ERROR audit run ..." (multi-space).
|
|
74
|
+
return raw.replace(/\n\s*ERROR\s+audit run.*$/s, '').replace(/\s{2,}ERROR\s+audit run.*$/s, '').trimEnd()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface LintViolationLike { path?: string; line?: number; rule?: string; message?: string }
|
|
78
|
+
|
|
79
|
+
interface LintResultLike {
|
|
80
|
+
ok: boolean
|
|
81
|
+
exitCode?: number
|
|
82
|
+
lint?: {
|
|
83
|
+
phpcs?: { violations?: LintViolationLike[] }
|
|
84
|
+
phpstan?: { errors?: LintViolationLike[] }
|
|
85
|
+
pubMediaGuard?: { violations?: LintViolationLike[] }
|
|
86
|
+
}
|
|
87
|
+
summary?: { findingCount?: number }
|
|
88
|
+
diagnostics?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One-line-plus render for the agent: a bare "audit lint failed" hides the
|
|
93
|
+
* violations the reviewer needs, so carry counts plus the top findings.
|
|
94
|
+
*/
|
|
95
|
+
export interface CollectedLintFindings {
|
|
96
|
+
phpcsViolations: Array<{ path?: string; line?: number; column?: number; rule?: string; message?: string; severity?: string }>
|
|
97
|
+
phpstanErrors: Array<{ path?: string; line?: number; message?: string }>
|
|
98
|
+
pubMediaViolations: unknown[]
|
|
99
|
+
compat: Array<{ tool?: string; path?: string; line?: number; rule?: string; message?: string }>
|
|
100
|
+
total: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collect findings across govard audit JSON shapes. The live envelope nests
|
|
105
|
+
* them at results[].evidence.php_results[].findings (older callers used a
|
|
106
|
+
* top-level findings array or evidence.php_results) — and non-phpcs/phpstan
|
|
107
|
+
* tools (e.g. M2-LINT-COMPAT internal errors) go to the compat bucket
|
|
108
|
+
* instead of being silently dropped from counts.
|
|
109
|
+
*/
|
|
110
|
+
export function collectLintFindings(parsed: unknown): CollectedLintFindings {
|
|
111
|
+
const p = (parsed ?? {}) as Record<string, any>
|
|
112
|
+
const lists: unknown[][] = []
|
|
113
|
+
if (Array.isArray(p.findings)) lists.push(p.findings)
|
|
114
|
+
const ev = p.evidence as Record<string, any> | undefined
|
|
115
|
+
if (Array.isArray(ev?.php_results)) lists.push(...ev.php_results.map((r: any) => r?.findings).filter(Array.isArray))
|
|
116
|
+
if (Array.isArray(p.php_results)) lists.push(...p.php_results.map((r: any) => r?.findings).filter(Array.isArray))
|
|
117
|
+
// Live shape (govard audit run): jobs[].evidence.php_results[].findings.
|
|
118
|
+
// Older shapes (results[], bare evidence) kept for backward compatibility.
|
|
119
|
+
for (const key of ['jobs', 'results'] as const) {
|
|
120
|
+
if (!Array.isArray(p[key])) continue
|
|
121
|
+
for (const r of p[key] as Array<Record<string, any>>) {
|
|
122
|
+
const e = r?.evidence as Record<string, any> | undefined
|
|
123
|
+
const pr = e?.php_results ?? r?.php_results
|
|
124
|
+
if (Array.isArray(pr)) lists.push(...pr.map((x: any) => x?.findings).filter(Array.isArray))
|
|
125
|
+
if (Array.isArray(r?.findings)) lists.push(r.findings)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const out: CollectedLintFindings = { phpcsViolations: [], phpstanErrors: [], pubMediaViolations: [], compat: [], total: 0 }
|
|
129
|
+
// DSH tool output must be lossless JSON: drop undefined fields, the
|
|
130
|
+
// runtime rejects values that do not survive a JSON round-trip.
|
|
131
|
+
const compact = (entry: Record<string, unknown>): Record<string, unknown> => {
|
|
132
|
+
const kept: Record<string, unknown> = {}
|
|
133
|
+
for (const [k, v] of Object.entries(entry)) if (v !== undefined) kept[k] = v
|
|
134
|
+
return kept
|
|
135
|
+
}
|
|
136
|
+
for (const list of lists) {
|
|
137
|
+
for (const f of list as Array<Record<string, any>>) {
|
|
138
|
+
if (f?.tool === 'phpstan') out.phpstanErrors.push(compact({ path: f.path, line: f.line, message: f.message }) as CollectedLintFindings['phpstanErrors'][number])
|
|
139
|
+
else if (f?.tool === 'phpcs') {
|
|
140
|
+
if (f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) out.pubMediaViolations.push(f)
|
|
141
|
+
else out.phpcsViolations.push(compact({ path: f.path, line: f.line, column: f.column, rule: f.rule, message: f.message, severity: f.severity }) as CollectedLintFindings['phpcsViolations'][number])
|
|
142
|
+
} else if (f && typeof f === 'object') {
|
|
143
|
+
out.compat.push(compact({ tool: f.tool, path: f.path, line: f.line, rule: f.rule, message: f.message }) as CollectedLintFindings['compat'][number])
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
out.total = out.phpcsViolations.length + out.phpstanErrors.length + out.pubMediaViolations.length + out.compat.length
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function lintResultText(v: LintResultLike): string {
|
|
152
|
+
if (v.ok) return 'audit lint passed'
|
|
153
|
+
const phpcs = v.lint?.phpcs?.violations ?? []
|
|
154
|
+
const phpstan = v.lint?.phpstan?.errors ?? []
|
|
155
|
+
const pubMedia = v.lint?.pubMediaGuard?.violations ?? []
|
|
156
|
+
const compat = (v.lint as Record<string, any> | undefined)?.compat?.findings ?? []
|
|
157
|
+
const total = v.summary?.findingCount ?? phpcs.length + phpstan.length + pubMedia.length + compat.length
|
|
158
|
+
const bits: string[] = [`audit lint failed — ${total} finding(s) (phpcs ${phpcs.length}, phpstan ${phpstan.length}, pubMedia ${pubMedia.length}, compat ${compat.length})`]
|
|
159
|
+
const top = [...phpcs.map(x => ({ ...x, tool: 'phpcs' })), ...phpstan.map(x => ({ ...x, tool: 'phpstan' })), ...pubMedia.map(x => ({ ...x, tool: 'pubMedia' })), ...compat.map((x: Record<string, any>) => ({ ...x, tool: x.tool ?? 'compat' }))].slice(0, 5)
|
|
160
|
+
for (const f of top) bits.push(`- [${f.tool}] ${f.path ?? (f.message ?? '?').toString().slice(0, 120)}${f.path !== undefined ? `:${f.line ?? '?'}` : ''}${f.rule !== undefined ? ` ${f.rule}` : ''}`)
|
|
161
|
+
if (v.exitCode !== undefined) bits.push(`exit ${v.exitCode}`)
|
|
162
|
+
const diag = (v.diagnostics ?? '').split('\n')[0]?.trim()
|
|
163
|
+
if (total === 0 && diag !== '' && diag !== undefined) bits.push(diag.slice(0, 200))
|
|
164
|
+
return bits.join('\n')
|
|
42
165
|
}
|
|
43
166
|
// Govard 1.67 auto timeout: 90s-30m framework-aware (15m floor for wordpress/magento2 → 22.5m auto)
|
|
44
167
|
// Keep kill timeout above the largest auto value so the outer watchdog doesn't cancel a valid auto run.
|
|
@@ -57,12 +180,16 @@ function run(cmd:string, args:string[], cwd:string, timeoutMs:number):Promise<{c
|
|
|
57
180
|
})
|
|
58
181
|
}
|
|
59
182
|
|
|
60
|
-
export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}={}):void{
|
|
183
|
+
export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number, defaultBase?:string, allowXdebug?:boolean}={}):void{
|
|
61
184
|
const configuredRoot=config.rootPath
|
|
62
185
|
const defaultTimeout=config.timeoutMs ?? DEFAULT_TIMEOUT
|
|
186
|
+
const defaultBase=config.defaultBase
|
|
187
|
+
// Review worktrees disable xdebug via .govard.local.yml, but govard's lint
|
|
188
|
+
// guard probes the base .govard.yml only — so the mount opts out explicitly.
|
|
189
|
+
const allowXdebug=config.allowXdebug ?? false
|
|
63
190
|
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
64
191
|
name:'govard_audit_lint',
|
|
65
|
-
description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text. Govard 1.67+ uses --timeout auto (framework-aware 90s-30m, 22.5m for wordpress/magento2) by default.',
|
|
192
|
+
description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text. Govard 1.67+ uses --timeout auto (framework-aware 90s-30m, 22.5m for wordpress/magento2) by default. scope "diff" requires a base ref: pass base, or rely on the wired defaultBase (MR base_sha) when present.',
|
|
66
193
|
parameters:{
|
|
67
194
|
worktreePath:{type:'string'},
|
|
68
195
|
checks:{type:'array', items:{type:'string'}},
|
|
@@ -93,7 +220,7 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
93
220
|
runId:{type:'string'},
|
|
94
221
|
}
|
|
95
222
|
},
|
|
96
|
-
render:(_a,v:
|
|
223
|
+
render:(_a,v:LintResultLike)=>[{type:'text', text: lintResultText(v)}],
|
|
97
224
|
},
|
|
98
225
|
async execute(args, exec){
|
|
99
226
|
const rawPath=(args as {worktreePath?:string}).worktreePath
|
|
@@ -104,20 +231,13 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
104
231
|
const timeoutMs=(args as {timeoutMs?:number}).timeoutMs ?? defaultTimeout
|
|
105
232
|
if(timeoutMs<5000 || timeoutMs>1_800_000) return {text:'timeoutMs out of range 5000-1800000 (5s-30m). Use --timeout auto for framework-aware estimation.', truncated:false} as never
|
|
106
233
|
|
|
107
|
-
const a = args as {checks?:string[]; mode?:string; scope?:string; base?:string; phpVersions?:string[]; noLintResultCache?:boolean; timeout?:string; lintProvider?:string}
|
|
108
|
-
const checks = a.checks && a.checks.length ? a.checks.join(',') : 'lint'
|
|
109
|
-
const mode = a.mode ?? 'auto'
|
|
234
|
+
const a = args as {checks?:string[]; mode?:string; scope?:string; base?:string; phpVersions?:string[]; noLintResultCache?:boolean; timeout?:string; lintProvider?:string; allowXdebug?:boolean}
|
|
110
235
|
const scope = a.scope
|
|
111
|
-
const base = a.base
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const cliArgs=['audit','run','--checks',checks,'--format','json','--mode',mode,'--timeout',timeout,'--lint-provider',lintProvider]
|
|
117
|
-
if(scope) cliArgs.push('--scope', scope)
|
|
118
|
-
if(base) cliArgs.push('--base', base)
|
|
119
|
-
if(phpVersionsArg && phpVersionsArg.length) cliArgs.push('--php', phpVersionsArg.join(','))
|
|
120
|
-
if(noLintResultCache) cliArgs.push('--no-lint-result-cache')
|
|
236
|
+
const base = resolveLintBase(a.base, defaultBase)
|
|
237
|
+
if (scope === 'diff' && base === undefined) {
|
|
238
|
+
return {text:'scope "diff" requires a base ref (govard --base): pass base explicitly (e.g. origin/master or the MR base_sha) or mount this tool with defaultBase.', truncated:false} as never
|
|
239
|
+
}
|
|
240
|
+
const cliArgs = buildAuditCliArgs({ ...a, base, allowXdebug: a.allowXdebug ?? allowXdebug })
|
|
121
241
|
const result=await run('govard', cliArgs, worktreePath, timeoutMs)
|
|
122
242
|
if(result.timedOut){
|
|
123
243
|
return {ok:false, exitCode: result.code ?? 124, timedOut:true, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'timeout', message:`timed out after ${timeoutMs}ms` }], diagnostics:result.stderr.slice(0,4000)} as never
|
|
@@ -139,20 +259,13 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
139
259
|
if(!parsed){
|
|
140
260
|
return {ok:false, exitCode: result.code ?? 1, timedOut:false, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'parse_error', message:'stdout not JSON'}], diagnostics:(cleaned+result.stderr).slice(0,4000)} as never
|
|
141
261
|
}
|
|
142
|
-
const
|
|
143
|
-
const phpcsViolations
|
|
144
|
-
const phpstanErrors
|
|
145
|
-
const pubMediaViolations
|
|
146
|
-
for(const f of findings){
|
|
147
|
-
if(f.tool==='phpstan') phpstanErrors.push({path:f.path, line:f.line, message:f.message})
|
|
148
|
-
else if(f.tool==='phpcs'){
|
|
149
|
-
if(f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) pubMediaViolations.push(f)
|
|
150
|
-
else phpcsViolations.push({path:f.path, line:f.line, column:f.column, rule:f.rule, message:f.message, severity:f.severity})
|
|
151
|
-
}
|
|
152
|
-
}
|
|
262
|
+
const collected = collectLintFindings(parsed)
|
|
263
|
+
const phpcsViolations = collected.phpcsViolations
|
|
264
|
+
const phpstanErrors = collected.phpstanErrors
|
|
265
|
+
const pubMediaViolations = collected.pubMediaViolations
|
|
153
266
|
const status=parsed.status ?? (result.code===0?'passed':'failed')
|
|
154
267
|
const phpVersions=parsed.php_versions ?? parsed.phpVersions ?? []
|
|
155
|
-
const findingCount=
|
|
268
|
+
const findingCount=collected.total
|
|
156
269
|
const sessionId = parsed.session_id ?? parsed.sessionId ?? undefined
|
|
157
270
|
const runId = parsed.run_id ?? parsed.runId ?? undefined
|
|
158
271
|
return {
|
|
@@ -164,7 +277,7 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
164
277
|
...(runId ? { runId: String(runId) } : {}),
|
|
165
278
|
rawJson: parsed as Record<string, unknown>,
|
|
166
279
|
summary:{status, phpVersions, matrixComplete:true, findingCount, truncated: findingCount>100},
|
|
167
|
-
lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}},
|
|
280
|
+
lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}, compat:{findings:collected.compat}},
|
|
168
281
|
errors:[],
|
|
169
282
|
diagnostics: result.stderr.slice(0,4000),
|
|
170
283
|
} as never
|
package/src/host/govard-tool.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
82
82
|
name: 'govard_shell',
|
|
83
83
|
description: 'Run one non-interactive command inside the Govard-managed container (e.g. a test suite) and return its output.',
|
|
84
84
|
parameters: {
|
|
85
|
-
command: { type: 'string', required: true, description: 'Shell command to run inside the container
|
|
85
|
+
command: { type: 'string', required: true, description: 'Shell command to run inside the container. PHPUnit in a Magento root: always scope it — "vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --filter <Module>" or "vendor/bin/phpunit --no-coverage --bootstrap dev/tests/unit/framework/bootstrap.php app/code/<Vendor>/<Module>/Test/Unit". The root ships no phpunit.xml so the bare binary prints usage and exits 1; never run the full suite bare (core fixture conflicts), and capture status via ${PIPESTATUS[0]} when piping through tail.' },
|
|
86
86
|
},
|
|
87
87
|
output: {
|
|
88
88
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
package/src/host/incremental.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
1
2
|
export interface CompareCommit {
|
|
2
3
|
short_id?: string
|
|
3
4
|
title?: string
|
|
@@ -57,7 +58,7 @@ export async function fetchMrDetailHeadSha(
|
|
|
57
58
|
try {
|
|
58
59
|
const response = await fetch(
|
|
59
60
|
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
60
|
-
{ headers:
|
|
61
|
+
{ headers: gitlabAuthHeaders(token) },
|
|
61
62
|
)
|
|
62
63
|
if (!response.ok) return undefined
|
|
63
64
|
const body = await response.json() as { diff_refs?: { head_sha?: string } }
|
|
@@ -74,7 +75,7 @@ export async function fetchCompare(
|
|
|
74
75
|
try {
|
|
75
76
|
const response = await fetch(
|
|
76
77
|
`${baseUrl}/api/v4/projects/${projectId}/repository/compare?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
|
77
|
-
{ headers:
|
|
78
|
+
{ headers: gitlabAuthHeaders(token) },
|
|
78
79
|
)
|
|
79
80
|
if (!response.ok) return undefined
|
|
80
81
|
return await response.json() as CompareResult
|