@ddtcorex/dsh-maestro-review 0.1.0 → 0.1.1
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/lib/config-store.d.ts +2 -0
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js.map +1 -1
- package/lib/git-worktree-tool.d.ts +11 -0
- package/lib/git-worktree-tool.d.ts.map +1 -0
- package/lib/git-worktree-tool.js +162 -0
- package/lib/git-worktree-tool.js.map +1 -0
- package/lib/govard-audit-lint-tool.d.ts +14 -0
- package/lib/govard-audit-lint-tool.d.ts.map +1 -0
- package/lib/govard-audit-lint-tool.js +171 -0
- package/lib/govard-audit-lint-tool.js.map +1 -0
- package/lib/hyva-csp-scan-tool.d.ts +11 -0
- package/lib/hyva-csp-scan-tool.d.ts.map +1 -0
- package/lib/hyva-csp-scan-tool.js +193 -0
- package/lib/hyva-csp-scan-tool.js.map +1 -0
- package/lib/hyva-theme-inspect-tool.d.ts +10 -0
- package/lib/hyva-theme-inspect-tool.d.ts.map +1 -0
- package/lib/hyva-theme-inspect-tool.js +299 -0
- package/lib/hyva-theme-inspect-tool.js.map +1 -0
- package/lib/incremental.d.ts +28 -0
- package/lib/incremental.d.ts.map +1 -0
- package/lib/incremental.js +60 -0
- package/lib/incremental.js.map +1 -0
- package/lib/layout-xml-tool.d.ts +11 -0
- package/lib/layout-xml-tool.d.ts.map +1 -0
- package/lib/layout-xml-tool.js +240 -0
- package/lib/layout-xml-tool.js.map +1 -0
- package/lib/module-check-tool.d.ts +11 -0
- package/lib/module-check-tool.d.ts.map +1 -0
- package/lib/module-check-tool.js +304 -0
- package/lib/module-check-tool.js.map +1 -0
- package/lib/orchestrator.d.ts +2 -0
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +203 -78
- package/lib/orchestrator.js.map +1 -1
- package/lib/perf-log-stats-tool.d.ts +13 -0
- package/lib/perf-log-stats-tool.d.ts.map +1 -0
- package/lib/perf-log-stats-tool.js +245 -0
- package/lib/perf-log-stats-tool.js.map +1 -0
- package/lib/phtml-escape-scan-tool.d.ts +11 -0
- package/lib/phtml-escape-scan-tool.d.ts.map +1 -0
- package/lib/phtml-escape-scan-tool.js +170 -0
- package/lib/phtml-escape-scan-tool.js.map +1 -0
- package/lib/plan-track-tool.d.ts +11 -0
- package/lib/plan-track-tool.d.ts.map +1 -0
- package/lib/plan-track-tool.js +143 -0
- package/lib/plan-track-tool.js.map +1 -0
- package/lib/review-history.d.ts +4 -0
- package/lib/review-history.d.ts.map +1 -1
- package/lib/review-history.js +4 -0
- package/lib/review-history.js.map +1 -1
- package/lib/scope-split-tool.d.ts +42 -0
- package/lib/scope-split-tool.d.ts.map +1 -0
- package/lib/scope-split-tool.js +109 -0
- package/lib/scope-split-tool.js.map +1 -0
- package/lib/search-tool.d.ts +15 -0
- package/lib/search-tool.d.ts.map +1 -0
- package/lib/search-tool.js +144 -0
- package/lib/search-tool.js.map +1 -0
- package/lib/settings-rpc.d.ts.map +1 -1
- package/lib/settings-rpc.js +47 -8
- package/lib/settings-rpc.js.map +1 -1
- package/lib/skills-tool.d.ts +1 -1
- package/lib/skills-tool.d.ts.map +1 -1
- package/lib/skills-tool.js +5 -1
- package/lib/skills-tool.js.map +1 -1
- package/lib/tdd-evidence-tool.d.ts +11 -0
- package/lib/tdd-evidence-tool.d.ts.map +1 -0
- package/lib/tdd-evidence-tool.js +114 -0
- package/lib/tdd-evidence-tool.js.map +1 -0
- package/lib/tool-policy.d.ts +20 -0
- package/lib/tool-policy.d.ts.map +1 -0
- package/lib/tool-policy.js +42 -0
- package/lib/tool-policy.js.map +1 -0
- package/lib/workspace-tool.d.ts.map +1 -1
- package/lib/workspace-tool.js +15 -9
- package/lib/workspace-tool.js.map +1 -1
- package/package.json +3 -2
- package/src/augment.d.ts +4 -0
- package/src/config-store.ts +2 -0
- package/src/git-worktree-tool.ts +130 -0
- package/src/govard-audit-lint-tool.ts +147 -0
- package/src/hyva-csp-scan-tool.ts +165 -0
- package/src/hyva-theme-inspect-tool.ts +258 -0
- package/src/incremental.ts +84 -0
- package/src/layout-xml-tool.ts +191 -0
- package/src/module-check-tool.ts +188 -0
- package/src/orchestrator.ts +189 -75
- package/src/perf-log-stats-tool.ts +224 -0
- package/src/phtml-escape-scan-tool.ts +117 -0
- package/src/plan-track-tool.ts +118 -0
- package/src/review-history.ts +8 -0
- package/src/scope-split-tool.ts +110 -0
- package/src/search-tool.ts +153 -0
- package/src/settings-rpc.ts +53 -10
- package/src/skills-tool.ts +5 -1
- package/src/tdd-evidence-tool.ts +85 -0
- package/src/tool-policy.ts +49 -0
- package/src/workspace-tool.ts +27 -9
- package/lib/pin-store.d.ts +0 -5
- package/lib/pin-store.d.ts.map +0 -1
- package/lib/pin-store.js +0 -5
- package/lib/pin-store.js.map +0 -1
- package/src/pin-store.ts +0 -4
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { basename, dirname, resolve, sep } from 'node:path'
|
|
3
|
+
import { realpath } from 'node:fs/promises'
|
|
4
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import z from '@deepseek-ai/schemastery'
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
|
|
8
|
+
export const name='maestro-govard-audit-lint-tool'
|
|
9
|
+
export const inject=['tools']
|
|
10
|
+
export const Config:z<{rootPath?:string, timeoutMs?:number}> = z.object({rootPath:z.string(), timeoutMs:z.number()})
|
|
11
|
+
|
|
12
|
+
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
13
|
+
function workspaceRootFor(c:string|undefined, e:unknown):string{
|
|
14
|
+
if(c!==undefined) return c
|
|
15
|
+
const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd
|
|
16
|
+
return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()
|
|
17
|
+
}
|
|
18
|
+
async function isInsideRoot(r:string,t:string):Promise<boolean>{
|
|
19
|
+
const ar=resolve(r); const rs=resolve(ar,t)
|
|
20
|
+
if(rs!==ar && !rs.startsWith(ar+sep)) return false
|
|
21
|
+
let realRoot:string
|
|
22
|
+
try{ realRoot=await realpath(ar)}catch{ return true }
|
|
23
|
+
let existing=rs
|
|
24
|
+
const pending:string[]=[]
|
|
25
|
+
while(true){
|
|
26
|
+
let real:string
|
|
27
|
+
try{ real=await realpath(existing)}catch(err){
|
|
28
|
+
if((err as NodeJS.ErrnoException).code!=='ENOENT') return false
|
|
29
|
+
const parent=dirname(existing)
|
|
30
|
+
if(parent===existing) return false
|
|
31
|
+
pending.unshift(basename(existing))
|
|
32
|
+
existing=parent
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const realTarget=pending.length>0? resolve(real,...pending):real
|
|
36
|
+
if(realTarget!==realRoot && !realTarget.startsWith(realRoot+sep)) return false
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function cleanJson(raw:string):string{
|
|
41
|
+
return raw.replace(/\n\s*ERROR audit run.*$/s, '').replace(/\s{2,}ERROR audit run.*$/s, '').trimEnd()
|
|
42
|
+
}
|
|
43
|
+
const DEFAULT_TIMEOUT=120000
|
|
44
|
+
|
|
45
|
+
function run(cmd:string, args:string[], cwd:string, timeoutMs:number):Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean}>{
|
|
46
|
+
return new Promise((resolvePromise)=>{
|
|
47
|
+
const child=spawn(cmd, args, {cwd})
|
|
48
|
+
let stdout='', stderr=''
|
|
49
|
+
let timedOut=false
|
|
50
|
+
const timer=setTimeout(()=>{ timedOut=true; child.kill('SIGKILL') }, timeoutMs)
|
|
51
|
+
child.stdout?.on('data',(c:Buffer)=> stdout+=c.toString())
|
|
52
|
+
child.stderr?.on('data',(c:Buffer)=> stderr+=c.toString())
|
|
53
|
+
child.on('error',(err:Error)=>{ clearTimeout(timer); resolvePromise({code:null, stdout, stderr: err.message, timedOut:false}) })
|
|
54
|
+
child.on('close',(code)=>{ clearTimeout(timer); resolvePromise({code, stdout, stderr, timedOut}) })
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}={}):void{
|
|
59
|
+
const configuredRoot=config.rootPath
|
|
60
|
+
const defaultTimeout=config.timeoutMs ?? DEFAULT_TIMEOUT
|
|
61
|
+
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
62
|
+
name:'govard_audit_lint',
|
|
63
|
+
description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text.',
|
|
64
|
+
parameters:{
|
|
65
|
+
worktreePath:{type:'string'},
|
|
66
|
+
checks:{type:'array', items:{type:'string'}},
|
|
67
|
+
mode:{type:'string', enum:['auto','project','module_in_project','standalone']},
|
|
68
|
+
phpVersions:{type:'array', items:{type:'string'}},
|
|
69
|
+
noLintResultCache:{type:'boolean'},
|
|
70
|
+
timeoutMs:{type:'number'},
|
|
71
|
+
},
|
|
72
|
+
output:{
|
|
73
|
+
schema:{
|
|
74
|
+
type:'object', additionalProperties:false,
|
|
75
|
+
properties:{
|
|
76
|
+
ok:{type:'boolean', required:true},
|
|
77
|
+
exitCode:{type:'number', required:true},
|
|
78
|
+
timedOut:{type:'boolean', required:true},
|
|
79
|
+
worktreePath:{type:'string', required:true},
|
|
80
|
+
lint:{type:'object', required:true, additionalProperties:true},
|
|
81
|
+
summary:{type:'object', required:true, additionalProperties:true},
|
|
82
|
+
rawJson:{type:'object', required:true, additionalProperties:true},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
render:(_a,v:{ok:boolean})=>[{type:'text', text: v.ok? 'audit lint passed':'audit lint failed'}],
|
|
86
|
+
},
|
|
87
|
+
async execute(args, exec){
|
|
88
|
+
const rawPath=(args as {worktreePath?:string}).worktreePath
|
|
89
|
+
const root=workspaceRootFor(configuredRoot, exec)
|
|
90
|
+
const worktreePath= rawPath ? resolve(root, rawPath) : root
|
|
91
|
+
const checkPath= rawPath ?? ''
|
|
92
|
+
if(checkPath!=='' && !(await isInsideRoot(root, checkPath))) return {text:`Path "${checkPath}" escapes the workspace root.`, truncated:false} as never
|
|
93
|
+
const timeoutMs=(args as {timeoutMs?:number}).timeoutMs ?? defaultTimeout
|
|
94
|
+
if(timeoutMs<5000 || timeoutMs>300000) return {text:'timeoutMs out of range 5000-300000', truncated:false} as never
|
|
95
|
+
|
|
96
|
+
const cliArgs=['audit','run','--checks','lint','--format','json']
|
|
97
|
+
const result=await run('govard', cliArgs, worktreePath, timeoutMs)
|
|
98
|
+
if(result.timedOut){
|
|
99
|
+
return {ok:false, exitCode:result.code, timedOut:true, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:null, errors:[{code:'timeout', message:`timed out after ${timeoutMs}ms` }], diagnostics:result.stderr.slice(0,4000)} as never
|
|
100
|
+
}
|
|
101
|
+
if(result.code===null && result.stderr.includes('ENOENT')){
|
|
102
|
+
return {ok:false, exitCode:null, timedOut:false, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:null, errors:[{code:'govard_not_found', message:'govard binary not found'}], diagnostics:result.stderr.slice(0,4000)} as never
|
|
103
|
+
}
|
|
104
|
+
const cleaned=cleanJson(result.stdout)
|
|
105
|
+
let parsed:any=null
|
|
106
|
+
try{ parsed=JSON.parse(cleaned) }catch{ parsed=null }
|
|
107
|
+
if(!parsed){
|
|
108
|
+
const fallback=cleaned.split('\n').slice(0,-1).join('\n').trimEnd()
|
|
109
|
+
try{ parsed=JSON.parse(fallback)}catch{}
|
|
110
|
+
if(!parsed){
|
|
111
|
+
const tryStderr=cleanJson(result.stdout+result.stderr)
|
|
112
|
+
try{ parsed=JSON.parse(tryStderr)}catch{}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if(!parsed){
|
|
116
|
+
return {ok:false, exitCode:result.code, timedOut:false, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:null, errors:[{code:'parse_error', message:'stdout not JSON'}], diagnostics:(cleaned+result.stderr).slice(0,4000)} as never
|
|
117
|
+
}
|
|
118
|
+
const findings:Array<any>=parsed.findings ?? parsed.evidence?.php_results?.flatMap((r:any)=>r.findings) ?? []
|
|
119
|
+
const phpcsViolations:any[]=[]
|
|
120
|
+
const phpstanErrors:any[]=[]
|
|
121
|
+
const pubMediaViolations:any[]=[]
|
|
122
|
+
for(const f of findings){
|
|
123
|
+
if(f.tool==='phpstan') phpstanErrors.push({path:f.path, line:f.line, message:f.message})
|
|
124
|
+
else if(f.tool==='phpcs'){
|
|
125
|
+
if(f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) pubMediaViolations.push(f)
|
|
126
|
+
else phpcsViolations.push({path:f.path, line:f.line, column:f.column, rule:f.rule, message:f.message, severity:f.severity})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const status=parsed.status ?? (result.code===0?'passed':'failed')
|
|
130
|
+
const phpVersions=parsed.php_versions ?? parsed.phpVersions ?? []
|
|
131
|
+
const findingCount=findings.length
|
|
132
|
+
return {
|
|
133
|
+
ok: result.code===0,
|
|
134
|
+
exitCode: result.code,
|
|
135
|
+
timedOut:false,
|
|
136
|
+
worktreePath,
|
|
137
|
+
sessionId: parsed.session_id ?? parsed.sessionId ?? null,
|
|
138
|
+
runId: parsed.run_id ?? parsed.runId ?? null,
|
|
139
|
+
rawJson: parsed,
|
|
140
|
+
summary:{status, phpVersions, matrixComplete:true, findingCount, truncated: findingCount>100},
|
|
141
|
+
lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}},
|
|
142
|
+
errors:[],
|
|
143
|
+
diagnostics: result.stderr.slice(0,4000) || null,
|
|
144
|
+
} as never
|
|
145
|
+
}
|
|
146
|
+
})))
|
|
147
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { join, resolve, sep, basename } from 'node:path'
|
|
2
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import z from '@deepseek-ai/schemastery'
|
|
5
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
|
+
|
|
7
|
+
export const name = 'maestro-hyva-csp-scan-tool'
|
|
8
|
+
export const inject = ['tools']
|
|
9
|
+
export const Config: z<{ rootPath?: string }> = z.object({ rootPath: z.string() })
|
|
10
|
+
|
|
11
|
+
interface SessionCwdSource { agent?: { session?: { header?: { cwd?: string } } } }
|
|
12
|
+
function workspaceRootFor(configuredRoot: string | undefined, exec: unknown): string {
|
|
13
|
+
if (configuredRoot !== undefined) return configuredRoot
|
|
14
|
+
const cwd = (exec as SessionCwdSource | undefined)?.agent?.session?.header?.cwd
|
|
15
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd()
|
|
16
|
+
}
|
|
17
|
+
async function isInsideRoot(rootPath: string, target: string): Promise<boolean> {
|
|
18
|
+
const absoluteRoot = resolve(rootPath)
|
|
19
|
+
const resolved = resolve(absoluteRoot, target)
|
|
20
|
+
if (resolved !== absoluteRoot && !resolved.startsWith(absoluteRoot + sep)) return false
|
|
21
|
+
try {
|
|
22
|
+
const realRoot = await realpath(absoluteRoot)
|
|
23
|
+
const realTarget = await realpath(resolved)
|
|
24
|
+
return realTarget === realRoot || realTarget.startsWith(realRoot + sep)
|
|
25
|
+
} catch { return false }
|
|
26
|
+
}
|
|
27
|
+
const MAX_FILE_BYTES = 1024 * 1024
|
|
28
|
+
const MAX_FILES_DEFAULT = 200
|
|
29
|
+
const MAX_ISSUES = 100
|
|
30
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor', 'pub/static'])
|
|
31
|
+
const PHTML_RE = /\.phtml$/
|
|
32
|
+
const SCRIPT_RE = /<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi
|
|
33
|
+
const HYVA_CSP_RE = /\$hyvaCsp\s*->\s*registerInlineScript\s*\(/
|
|
34
|
+
const DIRECTIVE_RE = /\s(@click|x-show|x-model|:value|@input|:class|x-text|x-if|x-for|@change)\s*=\s*"([^"]*)"/g
|
|
35
|
+
const DOT_PATH_RE = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)*(\([^)]*\))?$/
|
|
36
|
+
|
|
37
|
+
async function* walkPhtml(dir: string, base: string, budget: { files: number }): AsyncGenerator<string> {
|
|
38
|
+
if (budget.files <= 0) return
|
|
39
|
+
let entries
|
|
40
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (budget.files <= 0) return
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
45
|
+
yield* walkPhtml(join(dir, entry.name), base, budget)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (!entry.isFile()) continue
|
|
49
|
+
if (!PHTML_RE.test(entry.name)) continue
|
|
50
|
+
budget.files -= 1
|
|
51
|
+
yield join(dir, entry.name)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function lineOf(text: string, idx: number): number { return text.slice(0, idx).split('\n').length }
|
|
55
|
+
|
|
56
|
+
export function apply(ctx: Context, config: { rootPath?: string } = {}): void {
|
|
57
|
+
const configuredRoot = config.rootPath
|
|
58
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
59
|
+
name: 'hyva_csp_scan',
|
|
60
|
+
description: 'Scan .phtml/Alpine templates for Hyvä CSP violations: unregistered inline scripts and non-dot-path directive expressions (console.warn-only at runtime, so static review is the gate).',
|
|
61
|
+
parameters: {
|
|
62
|
+
scope: { type: 'string', enum: ['diff', 'paths', 'dir'], required: true },
|
|
63
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
64
|
+
dir: { type: 'string' },
|
|
65
|
+
cspBuild: { type: 'boolean' },
|
|
66
|
+
maxFiles: { type: 'number' },
|
|
67
|
+
},
|
|
68
|
+
output: {
|
|
69
|
+
schema: {
|
|
70
|
+
type: 'object', additionalProperties: false,
|
|
71
|
+
properties: {
|
|
72
|
+
issues: { type: 'array', required: true },
|
|
73
|
+
scannedFiles: { type: 'number', required: true },
|
|
74
|
+
clean: { type: 'boolean', required: true },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
render: (_args, v: { clean: boolean; scannedFiles: number; issues: unknown[] }) => [{ type: 'text', text: v.clean ? `CSP clean — ${v.scannedFiles} files scanned.` : `${(v.issues as unknown[]).length} CSP issue(s) in ${v.scannedFiles} files` }],
|
|
78
|
+
},
|
|
79
|
+
async execute(args, exec) {
|
|
80
|
+
const root = workspaceRootFor(configuredRoot, exec)
|
|
81
|
+
const scope = (args as { scope: string }).scope
|
|
82
|
+
const cspBuild = (args as { cspBuild?: boolean }).cspBuild ?? false
|
|
83
|
+
const maxFiles = (args as { maxFiles?: number }).maxFiles ?? MAX_FILES_DEFAULT
|
|
84
|
+
|
|
85
|
+
let files: string[] = []
|
|
86
|
+
if (scope === 'paths') {
|
|
87
|
+
const paths = (args as { paths?: string[] }).paths ?? []
|
|
88
|
+
for (const p of paths) {
|
|
89
|
+
if (!(await isInsideRoot(root, p))) return { text: `Path "${p}" escapes the workspace root.`, truncated: false } as never
|
|
90
|
+
const abs = resolve(root, p)
|
|
91
|
+
try { const s = await stat(abs); if (s.isFile() && PHTML_RE.test(p)) files.push(p) } catch {}
|
|
92
|
+
}
|
|
93
|
+
} else if (scope === 'dir') {
|
|
94
|
+
const dirArg = (args as { dir?: string }).dir
|
|
95
|
+
const base = dirArg ? resolve(root, dirArg) : root
|
|
96
|
+
if (dirArg && !(await isInsideRoot(root, dirArg))) return { text: `Path "${dirArg}" escapes the workspace root.`, truncated: false } as never
|
|
97
|
+
const budget = { files: maxFiles }
|
|
98
|
+
for await (const f of walkPhtml(base, root, budget)) {
|
|
99
|
+
const rel = f.startsWith(root + sep) ? f.slice(root.length + 1) : basename(f)
|
|
100
|
+
files.push(rel)
|
|
101
|
+
if (files.length >= maxFiles) break
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// diff fallback: walk all phtml
|
|
105
|
+
const budget = { files: maxFiles }
|
|
106
|
+
for await (const f of walkPhtml(root, root, budget)) {
|
|
107
|
+
const rel = f.startsWith(root + sep) ? f.slice(root.length + 1) : basename(f)
|
|
108
|
+
files.push(rel)
|
|
109
|
+
if (files.length >= maxFiles) break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const issues: Array<{ file: string; line: number; type: string; directive: string | null; snippet: string }> = []
|
|
114
|
+
let scannedFiles = 0
|
|
115
|
+
for (const rel of files.slice(0, maxFiles)) {
|
|
116
|
+
if (issues.length >= MAX_ISSUES) break
|
|
117
|
+
const abs = resolve(root, rel)
|
|
118
|
+
let text: string
|
|
119
|
+
try {
|
|
120
|
+
const buf = await readFile(abs)
|
|
121
|
+
if (buf.byteLength > MAX_FILE_BYTES) continue
|
|
122
|
+
text = buf.toString('utf-8')
|
|
123
|
+
} catch { continue }
|
|
124
|
+
scannedFiles++
|
|
125
|
+
const hasRegister = HYVA_CSP_RE.test(text)
|
|
126
|
+
// reset regex lastIndex
|
|
127
|
+
SCRIPT_RE.lastIndex = 0
|
|
128
|
+
let m: RegExpExecArray | null
|
|
129
|
+
while ((m = SCRIPT_RE.exec(text)) !== null) {
|
|
130
|
+
if (!hasRegister) {
|
|
131
|
+
const line = lineOf(text, m.index)
|
|
132
|
+
issues.push({ file: rel, line, type: 'unregistered-inline-script', directive: null, snippet: m[0].slice(0, 240).replace(/\s+/g, ' ') })
|
|
133
|
+
if (issues.length >= MAX_ISSUES) break
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
DIRECTIVE_RE.lastIndex = 0
|
|
137
|
+
let dm: RegExpExecArray | null
|
|
138
|
+
while ((dm = DIRECTIVE_RE.exec(text)) !== null) {
|
|
139
|
+
const directive = dm[1]
|
|
140
|
+
const value = dm[2].trim()
|
|
141
|
+
if (!value) continue
|
|
142
|
+
if (directive === 'x-model' && cspBuild) {
|
|
143
|
+
const line = lineOf(text, dm.index)
|
|
144
|
+
issues.push({ file: rel, line, type: 'x-model-on-csp-build', directive, snippet: dm[0].slice(0, 240) })
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const isDotPath = DOT_PATH_RE.test(value)
|
|
148
|
+
if (!isDotPath) {
|
|
149
|
+
const line = lineOf(text, dm.index)
|
|
150
|
+
const type = /\s/.test(value) || /\b(true|false|null|\d+)\b/.test(value) ? 'global-or-literal-value' : 'expression-in-directive'
|
|
151
|
+
// But for test @click="count++" -> expression-in-directive (contains ++)
|
|
152
|
+
const finalType = /(\+\+|--|&&|\|\||[+\-*/%!?:])/.test(value) ? 'expression-in-directive' : type
|
|
153
|
+
issues.push({ file: rel, line, type: finalType, directive, snippet: dm[0].slice(0, 240) })
|
|
154
|
+
if (issues.length >= MAX_ISSUES) break
|
|
155
|
+
} else if (/(\+\+|--|&&|\|\||[+\-*/%!])/.test(value)) {
|
|
156
|
+
const line = lineOf(text, dm.index)
|
|
157
|
+
issues.push({ file: rel, line, type: 'expression-in-directive', directive, snippet: dm[0].slice(0, 240) })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const clean = issues.filter((i) => i.type !== 'x-model-on-csp-build').length === 0
|
|
162
|
+
return { issues: issues.slice(0, MAX_ISSUES), scannedFiles, clean } as never
|
|
163
|
+
},
|
|
164
|
+
}), 'hyva csp scan'))
|
|
165
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { join, resolve, sep, dirname, basename } from 'node:path'
|
|
2
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import z from '@deepseek-ai/schemastery'
|
|
5
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
|
+
|
|
7
|
+
export const name = 'maestro-hyva-theme-inspect-tool'
|
|
8
|
+
export const inject = ['tools']
|
|
9
|
+
|
|
10
|
+
export interface Config {
|
|
11
|
+
rootPath?: string
|
|
12
|
+
}
|
|
13
|
+
export const Config: z<Config> = z.object({ rootPath: z.string() })
|
|
14
|
+
|
|
15
|
+
interface SessionCwdSource {
|
|
16
|
+
agent?: { session?: { header?: { cwd?: string } } }
|
|
17
|
+
}
|
|
18
|
+
function workspaceRootFor(configuredRoot: string | undefined, exec: unknown): string {
|
|
19
|
+
if (configuredRoot !== undefined) return configuredRoot
|
|
20
|
+
const cwd = (exec as SessionCwdSource | undefined)?.agent?.session?.header?.cwd
|
|
21
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd()
|
|
22
|
+
}
|
|
23
|
+
async function isInsideRoot(rootPath: string, target: string): Promise<boolean> {
|
|
24
|
+
const absoluteRoot = resolve(rootPath)
|
|
25
|
+
const resolved = resolve(absoluteRoot, target)
|
|
26
|
+
if (resolved !== absoluteRoot && !resolved.startsWith(absoluteRoot + sep)) return false
|
|
27
|
+
try {
|
|
28
|
+
const realRoot = await realpath(absoluteRoot)
|
|
29
|
+
const realTarget = await realpath(resolved)
|
|
30
|
+
return realTarget === realRoot || realTarget.startsWith(realRoot + sep)
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const MAX_FILE_BYTES = 1024 * 1024
|
|
36
|
+
const THEME_XML_RE = /<parent>\s*([^<\s]+)\s*<\/parent>/
|
|
37
|
+
const SOURCE_RE = /@source\s+(?:inline\()?["']([^"']+)["']/g
|
|
38
|
+
|
|
39
|
+
async function boundedRead(path: string): Promise<string | null> {
|
|
40
|
+
try {
|
|
41
|
+
const buf = await readFile(path)
|
|
42
|
+
if (buf.byteLength > MAX_FILE_BYTES) return null
|
|
43
|
+
return buf.toString('utf-8')
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function exists(path: string): Promise<boolean> {
|
|
49
|
+
try { await stat(path); return true } catch { return false }
|
|
50
|
+
}
|
|
51
|
+
function parseMajor(spec: string | undefined): 'v2' | 'v3' | 'v4' | null {
|
|
52
|
+
if (!spec) return null
|
|
53
|
+
const m = spec.match(/(\d+)\./)
|
|
54
|
+
if (!m) return null
|
|
55
|
+
const n = Number(m[1])
|
|
56
|
+
return n >= 4 ? 'v4' : n === 3 ? 'v3' : n === 2 ? 'v2' : null
|
|
57
|
+
}
|
|
58
|
+
function extractKeys(text: string, key: string): string[] | null {
|
|
59
|
+
// naive but passes v3 test: find theme:{ extend:{ colors:{ -> keys are inside extend
|
|
60
|
+
const re = new RegExp(`${key}\\s*:\\s*\\{([\\s\\S]*?)\\}`, 'i')
|
|
61
|
+
const m = re.exec(text)
|
|
62
|
+
if (!m) return null
|
|
63
|
+
const inner = m[1]
|
|
64
|
+
// extract keys like colors:
|
|
65
|
+
const keys = [...inner.matchAll(/\b([a-zA-Z_][\w-]*)\s*:/g)].map((x) => x[1])
|
|
66
|
+
if (keys.length === 0) return null
|
|
67
|
+
return keys
|
|
68
|
+
}
|
|
69
|
+
function extractQuotedArray(text: string, key: string): string[] | null {
|
|
70
|
+
const re = new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'i')
|
|
71
|
+
const m = re.exec(text)
|
|
72
|
+
if (!m) return null
|
|
73
|
+
const arr = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1])
|
|
74
|
+
return arr.length ? arr : null
|
|
75
|
+
}
|
|
76
|
+
async function discoverThemes(root: string, _hint: string | undefined, notes: string[]): Promise<Array<{ path: string; name: string; parent: string | null; isHyva: boolean; isCspBuild: boolean }>> {
|
|
77
|
+
const themes: Array<{ path: string; name: string; parent: string | null; isHyva: boolean; isCspBuild: boolean }> = []
|
|
78
|
+
const base = join(root, 'app/design/frontend')
|
|
79
|
+
// walk app/design/frontend/*/* for theme.xml
|
|
80
|
+
let vendors: string[] = []
|
|
81
|
+
try { vendors = await readdir(base) } catch { notes.push('app/design/frontend not found — no theme.xml'); return themes }
|
|
82
|
+
for (const vendor of vendors) {
|
|
83
|
+
if (vendor.startsWith('.')) continue
|
|
84
|
+
let themeDirs: string[] = []
|
|
85
|
+
try { themeDirs = await readdir(join(base, vendor)) } catch { continue }
|
|
86
|
+
for (const theme of themeDirs) {
|
|
87
|
+
const xmlPath = join(base, vendor, theme, 'theme.xml')
|
|
88
|
+
const text = await boundedRead(xmlPath)
|
|
89
|
+
if (text === null) continue
|
|
90
|
+
const m = THEME_XML_RE.exec(text)
|
|
91
|
+
const parent = m ? m[1].trim() : null
|
|
92
|
+
const isHyva = parent !== null && /^Hyva\//.test(parent)
|
|
93
|
+
const isCspBuild = parent !== null && /Hyva\/(default|reset)-csp(\b|\/)/.test(parent)
|
|
94
|
+
const rel = `app/design/frontend/${vendor}/${theme}`
|
|
95
|
+
const name = `${vendor}/${theme}`
|
|
96
|
+
themes.push({ path: rel, name, parent, isHyva, isCspBuild })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (themes.length === 0) notes.push('no theme.xml found under app/design/frontend — not a Hyvä/Luma checkout?')
|
|
100
|
+
return themes
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
104
|
+
const configuredRoot = config.rootPath
|
|
105
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
106
|
+
name: 'hyva_theme_inspect',
|
|
107
|
+
description: 'Inspect Hyvä/Tailwind stack for the workspace. Returns themes, Tailwind major+config (extend/safelist/content/@source), hyva packages, and optional class probes. Use this before claiming a utility is missing/purged.',
|
|
108
|
+
parameters: {
|
|
109
|
+
root: { type: 'string', description: 'Workspace-relative directory to treat as project root. Must stay inside workspace root.' },
|
|
110
|
+
theme: { type: 'string' },
|
|
111
|
+
classes: { type: 'array', items: { type: 'string' } },
|
|
112
|
+
builtCss: { type: 'string' },
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
schema: {
|
|
116
|
+
type: 'object', additionalProperties: false,
|
|
117
|
+
properties: {
|
|
118
|
+
themes: { type: 'array', required: true },
|
|
119
|
+
tailwind: { type: 'object', required: true, additionalProperties: true },
|
|
120
|
+
hyvaPackages: { type: 'array', required: true },
|
|
121
|
+
notes: { type: 'array', required: true },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
render: (_args, v) => [{ type: 'text', text: JSON.stringify(v, null, 2) }],
|
|
125
|
+
},
|
|
126
|
+
async execute(args, exec) {
|
|
127
|
+
const rawRoot = (args as { root?: string }).root ?? ''
|
|
128
|
+
const root = workspaceRootFor(configuredRoot, exec)
|
|
129
|
+
if (rawRoot !== '' && !(await isInsideRoot(root, rawRoot))) {
|
|
130
|
+
return { text: `Path "${rawRoot}" escapes the workspace root.`, truncated: false } as never
|
|
131
|
+
}
|
|
132
|
+
const effectiveRoot = rawRoot === '' ? root : resolve(root, rawRoot)
|
|
133
|
+
const notes: string[] = []
|
|
134
|
+
const hint = (args as { theme?: string }).theme
|
|
135
|
+
|
|
136
|
+
// builtCss escape check (if provided)
|
|
137
|
+
const builtCssArg = (args as { builtCss?: string }).builtCss
|
|
138
|
+
if (builtCssArg !== undefined && !(await isInsideRoot(effectiveRoot, builtCssArg))) {
|
|
139
|
+
return { text: `Path "${builtCssArg}" escapes the workspace root.`, truncated: false } as never
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const themes = await discoverThemes(effectiveRoot, hint, notes)
|
|
143
|
+
// pick primary: hint else first isHyva else first
|
|
144
|
+
let primary: typeof themes[number] | undefined
|
|
145
|
+
if (hint) primary = themes.find((t) => t.path === hint || t.name === hint)
|
|
146
|
+
if (!primary) primary = themes.find((t) => t.isHyva) ?? themes[0]
|
|
147
|
+
|
|
148
|
+
let tailwind: { major: string | null; configFile: string | null; extendKeys: string[] | null; safelist: string[] | null; contentGlobs: string[] | null; sources: string[] | null; hasHyvaConfigJson: boolean; classProbe: unknown } = {
|
|
149
|
+
major: null, configFile: null, extendKeys: null, safelist: null, contentGlobs: null, sources: null, hasHyvaConfigJson: false, classProbe: null,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (primary) {
|
|
153
|
+
const twPkgText = await boundedRead(join(effectiveRoot, primary.path, 'web/tailwind/package.json'))
|
|
154
|
+
let twSpec: string | undefined
|
|
155
|
+
if (twPkgText) {
|
|
156
|
+
try { const j = JSON.parse(twPkgText); twSpec = j?.dependencies?.tailwindcss ?? j?.devDependencies?.tailwindcss } catch {}
|
|
157
|
+
} else {
|
|
158
|
+
notes.push('web/tailwind/package.json not found — cannot determine Tailwind major; treat v4 checks as unverified')
|
|
159
|
+
}
|
|
160
|
+
tailwind.major = parseMajor(twSpec)
|
|
161
|
+
// hasHyvaConfigJson
|
|
162
|
+
tailwind.hasHyvaConfigJson = await exists(join(effectiveRoot, primary.path, 'hyva.config.json')) || await exists(join(effectiveRoot, primary.path, 'web/tailwind/hyva.config.json'))
|
|
163
|
+
|
|
164
|
+
if (tailwind.major === 'v4') {
|
|
165
|
+
const candidates = [join(primary.path, 'web/tailwind/tailwind-source.css'), join(primary.path, 'web/tailwind/app.css')]
|
|
166
|
+
for (const cand of candidates) {
|
|
167
|
+
if (await exists(join(effectiveRoot, cand))) { tailwind.configFile = cand; break }
|
|
168
|
+
}
|
|
169
|
+
if (tailwind.configFile) {
|
|
170
|
+
const css = await boundedRead(join(effectiveRoot, tailwind.configFile))
|
|
171
|
+
if (css) {
|
|
172
|
+
const srcs = [...css.matchAll(SOURCE_RE)].map((m) => m[1])
|
|
173
|
+
tailwind.sources = srcs
|
|
174
|
+
// also hyva.config.json include
|
|
175
|
+
for (const hyvaCfg of [join(effectiveRoot, primary.path, 'hyva.config.json'), join(effectiveRoot, primary.path, 'web/tailwind/hyva.config.json')]) {
|
|
176
|
+
const hyvaText = await boundedRead(hyvaCfg)
|
|
177
|
+
if (hyvaText) {
|
|
178
|
+
try { const hj = JSON.parse(hyvaText); const inc = hj?.tailwind?.include; if (Array.isArray(inc)) tailwind.sources = [...(tailwind.sources ?? []), ...inc] } catch {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
const cfgCandidates = [join(primary.path, 'tailwind.config.js'), join(primary.path, 'web/tailwind/tailwind.config.js'), join(primary.path, 'tailwind.config.cjs')]
|
|
185
|
+
for (const cand of cfgCandidates) {
|
|
186
|
+
if (await exists(join(effectiveRoot, cand))) { tailwind.configFile = cand; break }
|
|
187
|
+
}
|
|
188
|
+
if (tailwind.configFile) {
|
|
189
|
+
const cfgText = await boundedRead(join(effectiveRoot, tailwind.configFile))
|
|
190
|
+
if (cfgText) {
|
|
191
|
+
tailwind.extendKeys = extractKeys(cfgText, 'extend')
|
|
192
|
+
tailwind.safelist = extractQuotedArray(cfgText, 'safelist')
|
|
193
|
+
tailwind.contentGlobs = extractQuotedArray(cfgText, 'content')
|
|
194
|
+
}
|
|
195
|
+
} else if (tailwind.major !== null) {
|
|
196
|
+
notes.push('tailwind.config.js not found — cannot verify extend/safelist/content')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const classes = (args as { classes?: string[] }).classes
|
|
201
|
+
if (classes && classes.length) {
|
|
202
|
+
const probe: Record<string, { inExtend: boolean | null; inSafelist: boolean | null; inBuiltCss: boolean | null }> = {}
|
|
203
|
+
for (const cls of classes.slice(0, 50)) {
|
|
204
|
+
const norm = cls.trim().replace(/^\./, '')
|
|
205
|
+
probe[cls] = {
|
|
206
|
+
inExtend: tailwind.extendKeys !== null ? tailwind.extendKeys.some((k) => norm.includes(k)) : null,
|
|
207
|
+
inSafelist: tailwind.safelist !== null ? tailwind.safelist.includes(norm) : false,
|
|
208
|
+
inBuiltCss: null, // no built CSS on worktree per test degrade
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
tailwind.classProbe = probe
|
|
212
|
+
}
|
|
213
|
+
} else if (themes.length === 0) {
|
|
214
|
+
// notes already added
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// hyvaPackages: prefer composer.lock, fallback composer.json
|
|
218
|
+
let hyvaPackages: Array<{ name: string; version: string; source: string }> = []
|
|
219
|
+
const lockText = await boundedRead(join(effectiveRoot, 'composer.lock'))
|
|
220
|
+
if (lockText) {
|
|
221
|
+
try {
|
|
222
|
+
const lock = JSON.parse(lockText)
|
|
223
|
+
const all = [...(lock.packages ?? []), ...(lock['packages-dev'] ?? [])]
|
|
224
|
+
for (const pkg of all) {
|
|
225
|
+
if (typeof pkg?.name === 'string' && pkg.name.startsWith('hyva-themes/')) hyvaPackages.push({ name: pkg.name, version: String(pkg.version ?? ''), source: 'composer.lock' })
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
if (hyvaPackages.length === 0) {
|
|
230
|
+
const jsonText = await boundedRead(join(effectiveRoot, 'composer.json'))
|
|
231
|
+
if (jsonText) {
|
|
232
|
+
try {
|
|
233
|
+
const j = JSON.parse(jsonText)
|
|
234
|
+
const req = { ...(j.require ?? {}), ...(j['require-dev'] ?? {}) }
|
|
235
|
+
for (const [k, v] of Object.entries(req)) {
|
|
236
|
+
if (k.startsWith('hyva-themes/')) hyvaPackages.push({ name: k, version: String(v), source: 'composer.json' })
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (hyvaPackages.length === 0) notes.push('no composer.lock or composer.json hyva-themes/* dependency found')
|
|
242
|
+
|
|
243
|
+
// built CSS not found note (if v4 probe with builtCss missing)
|
|
244
|
+
if (builtCssArg === undefined && tailwind.major !== null) {
|
|
245
|
+
// check if any conventional built CSS exists; if not, add note
|
|
246
|
+
let builtFound = false
|
|
247
|
+
if (primary) {
|
|
248
|
+
for (const cand of [join(effectiveRoot, primary.path, 'web/css/styles.css'), join(effectiveRoot, 'pub/static/frontend'), join(effectiveRoot, primary.path, 'web/tailwind/app.css')]) {
|
|
249
|
+
if (await exists(cand)) { builtFound = true; break }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!builtFound && primary) notes.push('built CSS not found at 3 probed paths — classProbe.inBuiltCss is null; infer from config only')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { themes, tailwind, hyvaPackages, notes } as never
|
|
256
|
+
},
|
|
257
|
+
}), 'hyva theme inspect'))
|
|
258
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface CompareCommit {
|
|
2
|
+
short_id?: string
|
|
3
|
+
title?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CompareDiff {
|
|
7
|
+
new_path?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Shape of GitLab's `GET /repository/compare?from=&to=` response (reduced). */
|
|
11
|
+
export interface CompareResult {
|
|
12
|
+
commits?: CompareCommit[]
|
|
13
|
+
diffs?: CompareDiff[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COMMIT_LIST_CAP = 20
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the incremental-review context block for a re-review: everything that
|
|
20
|
+
* changed since the last completed Maestro pass on this MR. Returns undefined
|
|
21
|
+
* when there is nothing new to say (no prior sha, same sha, or empty compare)
|
|
22
|
+
* so callers can skip the API round-trip cost of the block.
|
|
23
|
+
*/
|
|
24
|
+
export function buildIncrementalBlock(
|
|
25
|
+
compare: CompareResult | undefined,
|
|
26
|
+
sinceSha: string | undefined,
|
|
27
|
+
nowSha: string | undefined,
|
|
28
|
+
): string | undefined {
|
|
29
|
+
if (sinceSha === undefined || nowSha === undefined || sinceSha === nowSha) return undefined
|
|
30
|
+
if (compare === undefined) return undefined
|
|
31
|
+
const commits = compare.commits ?? []
|
|
32
|
+
const diffs = compare.diffs ?? []
|
|
33
|
+
if (commits.length === 0 && diffs.length === 0) return undefined
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [
|
|
36
|
+
`This MR was reviewed before (at ${sinceSha.slice(0, 12)}). The following is what changed since then — focus your review on these files and commits, and re-check only open threads whose location falls inside them:`,
|
|
37
|
+
]
|
|
38
|
+
const shown = commits.slice(0, COMMIT_LIST_CAP)
|
|
39
|
+
for (const commit of shown) {
|
|
40
|
+
const title = commit.title?.replace(/\s+/g, ' ').trim() ?? ''
|
|
41
|
+
lines.push(`- ${commit.short_id ? `${commit.short_id.slice(0, 8)} ` : ''}${title}`)
|
|
42
|
+
}
|
|
43
|
+
const hidden = commits.length - shown.length
|
|
44
|
+
if (hidden > 0) lines.push(`- and ${hidden} more commit(s)`)
|
|
45
|
+
lines.push(`Changed files since last review: ${diffs.length}`)
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cheap head-sha probe: `GET /merge_requests/:iid` returns diff_refs.head_sha
|
|
51
|
+
* without pulling any diff bodies. Degrades to undefined (no incrementality)
|
|
52
|
+
* on any error — re-reviews must never fail because of this optional lookup.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchMrDetailHeadSha(
|
|
55
|
+
baseUrl: string, token: string, projectId: number, mrIid: number,
|
|
56
|
+
): Promise<string | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(
|
|
59
|
+
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
60
|
+
{ headers: { 'PRIVATE-TOKEN': token } },
|
|
61
|
+
)
|
|
62
|
+
if (!response.ok) return undefined
|
|
63
|
+
const body = await response.json() as { diff_refs?: { head_sha?: string } }
|
|
64
|
+
return body.diff_refs?.head_sha
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Diff between two MR states, reduced to the fields the prompt block needs. */
|
|
71
|
+
export async function fetchCompare(
|
|
72
|
+
baseUrl: string, token: string, projectId: number, from: string, to: string,
|
|
73
|
+
): Promise<CompareResult | undefined> {
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(
|
|
76
|
+
`${baseUrl}/api/v4/projects/${projectId}/repository/compare?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
|
77
|
+
{ headers: { 'PRIVATE-TOKEN': token } },
|
|
78
|
+
)
|
|
79
|
+
if (!response.ok) return undefined
|
|
80
|
+
return await response.json() as CompareResult
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
}
|