@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.
Files changed (83) hide show
  1. package/README.md +12 -0
  2. package/cordis.patch.yml +18 -0
  3. package/lib/ci-clone.d.ts +21 -0
  4. package/lib/ci-clone.d.ts.map +1 -0
  5. package/lib/ci-clone.js +35 -0
  6. package/lib/ci-clone.js.map +1 -0
  7. package/lib/ci-coexist.d.ts +9 -0
  8. package/lib/ci-coexist.d.ts.map +1 -0
  9. package/lib/ci-coexist.js +36 -0
  10. package/lib/ci-coexist.js.map +1 -0
  11. package/lib/config-store.d.ts +13 -6
  12. package/lib/config-store.d.ts.map +1 -1
  13. package/lib/config-store.js.map +1 -1
  14. package/lib/events.d.ts +23 -0
  15. package/lib/events.d.ts.map +1 -1
  16. package/lib/gitlab-auth.d.ts +11 -0
  17. package/lib/gitlab-auth.d.ts.map +1 -0
  18. package/lib/gitlab-auth.js +15 -0
  19. package/lib/gitlab-auth.js.map +1 -0
  20. package/lib/gitlab-client.d.ts +57 -0
  21. package/lib/gitlab-client.d.ts.map +1 -1
  22. package/lib/gitlab-client.js +120 -17
  23. package/lib/gitlab-client.js.map +1 -1
  24. package/lib/govard-audit-lint-tool.d.ts +81 -0
  25. package/lib/govard-audit-lint-tool.d.ts.map +1 -1
  26. package/lib/govard-audit-lint-tool.js +121 -36
  27. package/lib/govard-audit-lint-tool.js.map +1 -1
  28. package/lib/govard-tool.js +1 -1
  29. package/lib/govard-tool.js.map +1 -1
  30. package/lib/incremental.d.ts.map +1 -1
  31. package/lib/incremental.js +3 -2
  32. package/lib/incremental.js.map +1 -1
  33. package/lib/orchestrator.d.ts +137 -5
  34. package/lib/orchestrator.d.ts.map +1 -1
  35. package/lib/orchestrator.js +439 -70
  36. package/lib/orchestrator.js.map +1 -1
  37. package/lib/providers/ci-trigger.d.ts +43 -0
  38. package/lib/providers/ci-trigger.d.ts.map +1 -0
  39. package/lib/providers/ci-trigger.js +146 -0
  40. package/lib/providers/ci-trigger.js.map +1 -0
  41. package/lib/providers/gitlab.d.ts.map +1 -1
  42. package/lib/providers/gitlab.js +2 -1
  43. package/lib/providers/gitlab.js.map +1 -1
  44. package/lib/review-findings-tool.d.ts +5 -0
  45. package/lib/review-findings-tool.d.ts.map +1 -1
  46. package/lib/review-findings-tool.js +3 -1
  47. package/lib/review-findings-tool.js.map +1 -1
  48. package/lib/review-intake.d.ts.map +1 -1
  49. package/lib/review-intake.js +5 -1
  50. package/lib/review-intake.js.map +1 -1
  51. package/lib/review-marker.d.ts +17 -0
  52. package/lib/review-marker.d.ts.map +1 -0
  53. package/lib/review-marker.js +23 -0
  54. package/lib/review-marker.js.map +1 -0
  55. package/lib/review-signals.d.ts.map +1 -1
  56. package/lib/review-signals.js +4 -8
  57. package/lib/review-signals.js.map +1 -1
  58. package/lib/settings-rpc.js +1 -1
  59. package/lib/settings-rpc.js.map +1 -1
  60. package/package.json +14 -9
  61. package/profiles/reviewer-ci/cordis.patch.yml +61 -0
  62. package/profiles/reviewer-ci/package.json +19 -0
  63. package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
  64. package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
  65. package/src/host/ci-clone.ts +54 -0
  66. package/src/host/ci-coexist.ts +43 -0
  67. package/src/host/config-store.ts +14 -1
  68. package/src/host/events.ts +25 -0
  69. package/src/host/gitlab-auth.ts +13 -0
  70. package/src/host/gitlab-client.ts +142 -17
  71. package/src/host/govard-audit-lint-tool.ts +144 -31
  72. package/src/host/govard-tool.ts +1 -1
  73. package/src/host/incremental.ts +3 -2
  74. package/src/host/orchestrator.ts +462 -50
  75. package/src/host/providers/ci-trigger.ts +164 -0
  76. package/src/host/providers/gitlab.ts +2 -1
  77. package/src/host/review-findings-tool.ts +9 -1
  78. package/src/host/review-intake.ts +5 -1
  79. package/src/host/review-marker.ts +25 -0
  80. package/src/host/review-signals.ts +4 -3
  81. package/src/host/settings-rpc.ts +1 -1
  82. package/templates/reviewer-project.gitlab-ci.yml +90 -0
  83. 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 = { 'PRIVATE-TOKEN': config.token }
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: { text: { type: 'string', required: true } } },
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 response = await fetch(`${apiBase}/diffs`, { headers })
73
- if (!response.ok) {
74
- throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
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
- const diffs = await response.json() as GitlabDiff[]
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 unresolved inline discussion threads previously created by this bot account, so you can reply instead of duplicating.',
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: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
111
- render: (_args, value) => [{ type: 'text', text: value.text }],
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
- const ownThreads = discussions
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
- return raw.replace(/\n\s*ERROR audit run.*$/s, '').replace(/\s{2,}ERROR audit run.*$/s, '').trimEnd()
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:{ok:boolean})=>[{type:'text', text: v.ok? 'audit lint passed':'audit lint failed'}],
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
- const phpVersionsArg = a.phpVersions
113
- const noLintResultCache = a.noLintResultCache
114
- const timeout = a.timeout ?? 'auto'
115
- const lintProvider = a.lintProvider ?? 'govard'
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 findings:Array<any>=parsed.findings ?? parsed.evidence?.php_results?.flatMap((r:any)=>r.findings) ?? []
143
- const phpcsViolations:any[]=[]
144
- const phpstanErrors:any[]=[]
145
- const pubMediaViolations:any[]=[]
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=findings.length
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
@@ -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, e.g. "vendor/bin/phpunit".' },
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 } } },
@@ -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: { 'PRIVATE-TOKEN': token } },
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: { 'PRIVATE-TOKEN': token } },
78
+ { headers: gitlabAuthHeaders(token) },
78
79
  )
79
80
  if (!response.ok) return undefined
80
81
  return await response.json() as CompareResult