@ddtcorex/dsh-maestro-review 0.1.0 → 0.1.2
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/events.d.ts.map +1 -1
- package/lib/events.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/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js.map +1 -1
- package/lib/gitlab-webhook.d.ts.map +1 -1
- package/lib/gitlab-webhook.js.map +1 -1
- 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 +177 -0
- package/lib/govard-audit-lint-tool.js.map +1 -0
- package/lib/govard-tool.d.ts.map +1 -1
- package/lib/govard-tool.js.map +1 -1
- 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/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- 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/notify.d.ts.map +1 -1
- package/lib/notify.js.map +1 -1
- package/lib/orchestrator.d.ts +2 -0
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +220 -88
- 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/providers/github.stub.d.ts.map +1 -1
- package/lib/providers/github.stub.js.map +1 -1
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js.map +1 -1
- package/lib/providers/interface.d.ts.map +1 -1
- package/lib/providers/interface.js.map +1 -1
- package/lib/review-findings-tool.d.ts.map +1 -1
- package/lib/review-findings-tool.js.map +1 -1
- package/lib/review-history.d.ts +4 -0
- package/lib/review-history.d.ts.map +1 -1
- package/lib/review-history.js +23 -1
- package/lib/review-history.js.map +1 -1
- package/lib/review-intake.d.ts.map +1 -1
- package/lib/review-intake.js.map +1 -1
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.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/secure-compare.d.ts.map +1 -1
- package/lib/secure-compare.js.map +1 -1
- 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 +4 -4
- package/src/{augment.d.ts → host/augment.d.ts} +5 -2
- package/src/{config-store.ts → host/config-store.ts} +2 -0
- package/src/host/git-worktree-tool.ts +130 -0
- package/src/host/govard-audit-lint-tool.ts +153 -0
- package/src/host/hyva-csp-scan-tool.ts +165 -0
- package/src/host/hyva-theme-inspect-tool.ts +258 -0
- package/src/host/incremental.ts +84 -0
- package/src/host/layout-xml-tool.ts +191 -0
- package/src/host/module-check-tool.ts +188 -0
- package/src/{orchestrator.ts → host/orchestrator.ts} +205 -84
- package/src/host/perf-log-stats-tool.ts +224 -0
- package/src/host/phtml-escape-scan-tool.ts +117 -0
- package/src/host/plan-track-tool.ts +118 -0
- package/src/{review-history.ts → host/review-history.ts} +27 -1
- package/src/host/scope-split-tool.ts +110 -0
- package/src/host/search-tool.ts +153 -0
- package/src/{settings-rpc.ts → host/settings-rpc.ts} +54 -11
- package/src/{skills-tool.ts → host/skills-tool.ts} +5 -1
- package/src/host/tdd-evidence-tool.ts +85 -0
- package/src/host/tool-policy.ts +49 -0
- package/src/{workspace-tool.ts → host/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
- /package/src/{events.ts → host/events.ts} +0 -0
- /package/src/{gitlab-client.ts → host/gitlab-client.ts} +0 -0
- /package/src/{gitlab-webhook.ts → host/gitlab-webhook.ts} +0 -0
- /package/src/{govard-tool.ts → host/govard-tool.ts} +0 -0
- /package/src/{index.ts → host/index.ts} +0 -0
- /package/src/{notify.ts → host/notify.ts} +0 -0
- /package/src/{providers → host/providers}/github.stub.ts +0 -0
- /package/src/{providers → host/providers}/gitlab.ts +0 -0
- /package/src/{providers → host/providers}/interface.ts +0 -0
- /package/src/{review-findings-tool.ts → host/review-findings-tool.ts} +0 -0
- /package/src/{review-intake.ts → host/review-intake.ts} +0 -0
- /package/src/{review-signals.ts → host/review-signals.ts} +0 -0
- /package/src/{secure-compare.ts → host/secure-compare.ts} +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
|
+
import z from '@deepseek-ai/schemastery'
|
|
7
|
+
|
|
8
|
+
export const name='maestro-perf-log-stats-tool'
|
|
9
|
+
export const inject=['tools']
|
|
10
|
+
export const Config:z<{rootPath?:string}> = z.object({rootPath:z.string()})
|
|
11
|
+
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
12
|
+
function workspaceRootFor(c:string|undefined,e:unknown):string{ if(c!==undefined) return c; const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd; return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()}
|
|
13
|
+
async function isInsideRoot(r:string,t:string):Promise<boolean>{
|
|
14
|
+
const ar=resolve(r); const rs=resolve(ar,t)
|
|
15
|
+
if(rs!==ar && !rs.startsWith(ar+sep)) return false
|
|
16
|
+
let realRoot:string
|
|
17
|
+
try{ realRoot=await realpath(ar) }catch{
|
|
18
|
+
// if root does not exist yet (e.g. .worktrees not created), fallback to lexical check only
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
let existing=rs
|
|
22
|
+
const pending:string[]=[]
|
|
23
|
+
while(true){
|
|
24
|
+
let real:string
|
|
25
|
+
try{ real=await realpath(existing) }catch(err){
|
|
26
|
+
if((err as NodeJS.ErrnoException).code!=='ENOENT') return false
|
|
27
|
+
const parent=dirname(existing)
|
|
28
|
+
if(parent===existing) return false
|
|
29
|
+
pending.unshift(basename(existing))
|
|
30
|
+
existing=parent
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const realTarget=pending.length>0 ? resolve(real, ...pending) : real
|
|
34
|
+
if(realTarget!==realRoot && !realTarget.startsWith(realRoot+sep)) return false
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export const MAX_FILE_BYTES=2*1024*1024
|
|
39
|
+
|
|
40
|
+
export function cleanJson(raw:string):string{
|
|
41
|
+
// strip trailing ERROR line outside JSON (large audit 2.8MB case, sed '$d' before jq)
|
|
42
|
+
// Handles both "\n ERROR audit run ..." and " ERROR audit run ..." suffix
|
|
43
|
+
return raw.replace(/\n\s*ERROR audit run.*$/s, '').replace(/\s{2,}ERROR audit run.*$/s, '').trimEnd()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function govardShCat(worktreePath:string, relPath:string):Promise<{text:string, bytes:number, truncated:boolean}|null>{
|
|
47
|
+
// streaming cat var/debug/db.log server-side bounded 2MiB via govard sh
|
|
48
|
+
// returns null on govard not found or not a govard project, caller falls back to fs
|
|
49
|
+
return new Promise((res)=>{
|
|
50
|
+
const child=spawn('govard', ['sh','-c', `head -c ${MAX_FILE_BYTES} "${relPath.replace(/"/g,'\\"')}" 2>/dev/null`], {cwd: worktreePath})
|
|
51
|
+
let out=''
|
|
52
|
+
let err=''
|
|
53
|
+
child.stdout?.on('data',(c:Buffer)=> out+=c.toString())
|
|
54
|
+
child.stderr?.on('data',(c:Buffer)=> err+=c.toString())
|
|
55
|
+
child.on('error',()=> res(null))
|
|
56
|
+
child.on('close', async (code)=>{
|
|
57
|
+
const combined=out+err
|
|
58
|
+
if(code!==0){
|
|
59
|
+
// govard not found, no container, or file missing -> fallback to fs
|
|
60
|
+
res(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if(combined.includes('Error response from daemon') || combined.includes('No such container')){
|
|
64
|
+
res(null)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
// Try to get full file size for truncated flag via fs stat (best effort)
|
|
68
|
+
try{
|
|
69
|
+
const abs=resolve(worktreePath, relPath)
|
|
70
|
+
const s=await stat(abs)
|
|
71
|
+
const truncated=s.size>MAX_FILE_BYTES
|
|
72
|
+
const text=truncated ? out.slice(0, MAX_FILE_BYTES) : out
|
|
73
|
+
res({text, bytes:s.size, truncated})
|
|
74
|
+
}catch{
|
|
75
|
+
// if stat fails (file missing or container-only file), infer from output length
|
|
76
|
+
// but if file missing, out is empty and code was 0? head on missing returns 1, already handled
|
|
77
|
+
// so infer truncated from size
|
|
78
|
+
const truncated=Buffer.byteLength(out,'utf-8')>=MAX_FILE_BYTES
|
|
79
|
+
// if out empty and code 0, file empty -> exists true with empty text; but we already filtered code !=0 for missing
|
|
80
|
+
// For safety, if out empty and we are here, treat as exists with empty
|
|
81
|
+
res({text:out.slice(0,MAX_FILE_BYTES), bytes:Buffer.byteLength(out,'utf-8'), truncated})
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function boundedRead(p:string, worktreePath?:string, relPath?:string):Promise<{exists:boolean, text:string|null, bytes:number, truncated:boolean}>{
|
|
88
|
+
// Prefer govard sh streaming when worktreePath+relPath supplied and govard available
|
|
89
|
+
if(worktreePath && relPath){
|
|
90
|
+
const via=await govardShCat(worktreePath, relPath)
|
|
91
|
+
if(via) return {exists:true, text:via.text, bytes:via.bytes, truncated:via.truncated}
|
|
92
|
+
}
|
|
93
|
+
try{
|
|
94
|
+
const s=await stat(p)
|
|
95
|
+
if(s.size>MAX_FILE_BYTES){
|
|
96
|
+
const b=await readFile(p)
|
|
97
|
+
// bounded 2MiB streaming server-side cat equivalent: slice to MAX
|
|
98
|
+
return {exists:true, text:b.toString('utf-8').slice(0,MAX_FILE_BYTES), bytes:s.size, truncated:true}
|
|
99
|
+
}
|
|
100
|
+
const b=await readFile(p)
|
|
101
|
+
return {exists:true, text:b.toString('utf-8'), bytes:b.byteLength, truncated:false}
|
|
102
|
+
}catch{ return {exists:false, text:null, bytes:0, truncated:false} }
|
|
103
|
+
}
|
|
104
|
+
function normalizeShape(sql:string):string{
|
|
105
|
+
return sql.replace(/'[^']*'/g, '?').replace(/"[^"]*"/g,'?').replace(/\b\d+\b/g,'?').replace(/\s+/g,' ').trim().slice(0,200)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function apply(ctx:Context, config:{rootPath?:string}={}):void{
|
|
109
|
+
const configuredRoot=config.rootPath
|
|
110
|
+
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
111
|
+
name:'maestro_perf_log_stats',
|
|
112
|
+
description:'Parse Magento query log and profiler CSV for slow queries, n+1, cache flush storms, crawler overload.',
|
|
113
|
+
parameters:{
|
|
114
|
+
worktreePath:{type:'string'},
|
|
115
|
+
queryLogPath:{type:'string'},
|
|
116
|
+
profilerCsvPath:{type:'string'},
|
|
117
|
+
systemLogPath:{type:'string'},
|
|
118
|
+
debugLogPath:{type:'string'},
|
|
119
|
+
topN:{type:'number'},
|
|
120
|
+
repeatThreshold:{type:'number'},
|
|
121
|
+
timeThresholdMs:{type:'number'},
|
|
122
|
+
},
|
|
123
|
+
output:{
|
|
124
|
+
schema:{
|
|
125
|
+
type:'object', additionalProperties:false,
|
|
126
|
+
properties:{
|
|
127
|
+
ok:{type:'boolean', required:true},
|
|
128
|
+
slowQueries:{type:'array', required:true},
|
|
129
|
+
nPlusOneCandidates:{type:'array', required:true},
|
|
130
|
+
warnings:{type:'array', required:true},
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
render:(_a,v:{warnings:string[]})=>[{type:'text', text: v.warnings.join('\n') || 'perf stats parsed'}],
|
|
134
|
+
},
|
|
135
|
+
async execute(args, exec){
|
|
136
|
+
const rawWorktree=(args as {worktreePath?:string}).worktreePath
|
|
137
|
+
const root=workspaceRootFor(configuredRoot, exec)
|
|
138
|
+
if(rawWorktree!==undefined && !(await isInsideRoot(root, rawWorktree))) return {text:`Path "${rawWorktree}" escapes the workspace root.`, truncated:false} as never
|
|
139
|
+
const worktreePath = rawWorktree ? resolve(root, rawWorktree) : root
|
|
140
|
+
const queryLogPath=(args as {queryLogPath?:string}).queryLogPath ?? 'var/debug/db.log'
|
|
141
|
+
const profilerCsvPath=(args as {profilerCsvPath?:string}).profilerCsvPath ?? 'artifacts/profiler/profile.csv'
|
|
142
|
+
const topN=(args as {topN?:number}).topN ?? 10
|
|
143
|
+
const repeatThreshold=(args as {repeatThreshold?:number}).repeatThreshold ?? 5
|
|
144
|
+
const timeThresholdMs=(args as {timeThresholdMs?:number}).timeThresholdMs ?? 1000
|
|
145
|
+
if(rawWorktree!==undefined && !(await isInsideRoot(root, worktreePath))){ /* already checked */ }
|
|
146
|
+
|
|
147
|
+
const qLogAbs=resolve(worktreePath, queryLogPath)
|
|
148
|
+
if(!(await isInsideRoot(worktreePath, queryLogPath)) && queryLogPath!== 'var/debug/db.log') return {text:`Path "${queryLogPath}" escapes the workspace root.`, truncated:false} as never
|
|
149
|
+
|
|
150
|
+
const warnings:string[]=[]
|
|
151
|
+
const qRead=await boundedRead(qLogAbs, worktreePath, queryLogPath)
|
|
152
|
+
const pRead=await boundedRead(resolve(worktreePath, profilerCsvPath), worktreePath, profilerCsvPath)
|
|
153
|
+
|
|
154
|
+
const inputs={
|
|
155
|
+
queryLog:{path:queryLogPath, exists:qRead.exists, bytes:qRead.bytes, truncated:qRead.truncated},
|
|
156
|
+
profilerCsv:{path:profilerCsvPath, exists:pRead.exists, bytes:pRead.bytes, truncated:pRead.truncated},
|
|
157
|
+
systemLog:{path:'var/log/system.log', exists:false, bytes:0, truncated:false},
|
|
158
|
+
debugLog:{path:'var/log/debug.log', exists:false, bytes:0, truncated:false},
|
|
159
|
+
}
|
|
160
|
+
if(!qRead.exists) warnings.push('query log not found — run dev:query-log:enable')
|
|
161
|
+
if(!pRead.exists) warnings.push('profiler CSV not found')
|
|
162
|
+
if(qRead.exists && qRead.text && qRead.text.length===0) warnings.push('query log empty')
|
|
163
|
+
if(qRead.truncated) warnings.push(`query log truncated to ${MAX_FILE_BYTES} bytes (bounded 2MiB streaming)`)
|
|
164
|
+
if(pRead.truncated) warnings.push(`profiler CSV truncated to ${MAX_FILE_BYTES} bytes (bounded 2MiB streaming)`)
|
|
165
|
+
// parse db.log
|
|
166
|
+
const shapeMap=new Map<string,{count:number, totalMs:number, maxMs:number, example:string, callers:string[]}>()
|
|
167
|
+
if(qRead.text){
|
|
168
|
+
const blocks=qRead.text.split(/##\s*\d+\s*##\s*QUERY/g)
|
|
169
|
+
for(const block of blocks){
|
|
170
|
+
if(!block.trim()) continue
|
|
171
|
+
const sqlMatch=/SQL:\s*([^\n]+)/.exec(block)
|
|
172
|
+
const timeMatch=/TIME:\s*([0-9.]+)/.exec(block)
|
|
173
|
+
const traceMatch=/TRACE:\s*([\s\S]*?)(?:\n##|$)/.exec(block) // capture trace
|
|
174
|
+
const sql=sqlMatch?sqlMatch[1].trim():null
|
|
175
|
+
const timeSec=timeMatch? parseFloat(timeMatch[1]):0
|
|
176
|
+
const trace=traceMatch? traceMatch[1] : block
|
|
177
|
+
const isFrontend = /App\\Http|App\/Http|Http\.php/.test(trace)
|
|
178
|
+
if(!sql) continue
|
|
179
|
+
if(!isFrontend) continue
|
|
180
|
+
const ms=timeSec*1000
|
|
181
|
+
const shape=normalizeShape(sql)
|
|
182
|
+
let entry=shapeMap.get(shape)
|
|
183
|
+
if(!entry){ entry={count:0, totalMs:0, maxMs:0, example:sql, callers:[]}; shapeMap.set(shape, entry)}
|
|
184
|
+
entry.count++
|
|
185
|
+
entry.totalMs+=ms
|
|
186
|
+
entry.maxMs=Math.max(entry.maxMs, ms)
|
|
187
|
+
// extract caller
|
|
188
|
+
const callerLine=(trace.split('\n').find(l=>l.includes('app/code'))||'').trim().slice(0,120)
|
|
189
|
+
if(callerLine && !entry.callers.includes(callerLine)) entry.callers.push(callerLine)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const slowQueries:Array<{query:string, shape:string, count:number, avgMs:number, maxMs:number, exampleCallerStack:string}>=[]
|
|
193
|
+
const nPlusOneCandidates:Array<{shape:string, count:number, model:string|null, callerStack:string}>=[]
|
|
194
|
+
for(const [shape, v] of shapeMap.entries()){
|
|
195
|
+
const avg=v.totalMs/v.count
|
|
196
|
+
if(v.maxMs>=timeThresholdMs || avg>=timeThresholdMs){
|
|
197
|
+
slowQueries.push({query:shape, shape, count:v.count, avgMs:Math.round(avg), maxMs:Math.round(v.maxMs), exampleCallerStack:v.callers.join(' | ')})
|
|
198
|
+
}
|
|
199
|
+
if(v.count>=repeatThreshold){
|
|
200
|
+
// infer model from caller
|
|
201
|
+
const modelMatch=v.callers.join(' ').match(/app\/code\/[^\/]+\/[^\/]+\/Model\/(\w+)/)
|
|
202
|
+
const model=modelMatch?modelMatch[1]:null
|
|
203
|
+
nPlusOneCandidates.push({shape, count:v.count, model, callerStack:v.callers.join(' | ')})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
slowQueries.sort((a,b)=>b.maxMs-a.maxMs)
|
|
207
|
+
nPlusOneCandidates.sort((a,b)=>b.count-a.count)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
ok:true,
|
|
211
|
+
worktreePath,
|
|
212
|
+
inputs,
|
|
213
|
+
slowQueries: slowQueries.slice(0, topN),
|
|
214
|
+
nPlusOneCandidates: nPlusOneCandidates.slice(0, topN),
|
|
215
|
+
cacheFlushStorm:{eventsPerMin:0, rawEvents:[], windowMin:5},
|
|
216
|
+
crawlerOverload:false,
|
|
217
|
+
crawlerEvidence:{hitsPerMin:0, topUA:null, topIP:null},
|
|
218
|
+
queryShapes: Array.from(shapeMap.entries()).map(([shape,c])=>({shape, count:c.count})),
|
|
219
|
+
topTimers: [],
|
|
220
|
+
warnings,
|
|
221
|
+
} as never
|
|
222
|
+
}
|
|
223
|
+
}), 'perf log stats'))
|
|
224
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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-phtml-escape-scan-tool'
|
|
8
|
+
export const inject=['tools']
|
|
9
|
+
export const Config:z<{rootPath?:string}>=z.object({rootPath:z.string()})
|
|
10
|
+
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
11
|
+
function workspaceRootFor(c:string|undefined,e:unknown):string{ if(c!==undefined) return c; const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd; return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()}
|
|
12
|
+
async function isInsideRoot(r:string,t:string):Promise<boolean>{ const ar=resolve(r); const rs=resolve(ar,t); if(rs!==ar && !rs.startsWith(ar+sep)) return false; try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{return false}}
|
|
13
|
+
const MAX_FILE_BYTES=1024*1024
|
|
14
|
+
const MAX_PHTML_FILES=200
|
|
15
|
+
const MAX_FINDINGS=200
|
|
16
|
+
const SKIP_DIRS=new Set(['.git','node_modules','vendor','pub/static'])
|
|
17
|
+
async function exists(p:string):Promise<boolean>{ try{await stat(p); return true}catch{return false}}
|
|
18
|
+
async function* walkPhtml(dir:string, budget:{files:number}):AsyncGenerator<string>{
|
|
19
|
+
if(budget.files<=0) return
|
|
20
|
+
let entries; try{entries=await readdir(dir,{withFileTypes:true})}catch{return}
|
|
21
|
+
for(const e of entries){
|
|
22
|
+
if(budget.files<=0) return
|
|
23
|
+
if(e.isDirectory()){ if(SKIP_DIRS.has(e.name)) continue; yield* walkPhtml(join(dir,e.name), budget); continue }
|
|
24
|
+
if(!e.isFile()) continue
|
|
25
|
+
if(!e.name.endsWith('.phtml')) continue
|
|
26
|
+
budget.files-=1; yield join(dir,e.name)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isSafeLine(line:string):boolean{
|
|
30
|
+
return /\$escaper\s*->\s*escape/.test(line) || /\$block\s*->\s*escape/.test(line) || /->\s*get\w*Html\s*\(/.test(line) || /escapeHtmlAttr/.test(line) && /title=/.test(line) ? true : false
|
|
31
|
+
// second case handled more precisely below
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function apply(ctx:Context, config:{rootPath?:string}={}):void{
|
|
35
|
+
const configuredRoot=config.rootPath
|
|
36
|
+
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
37
|
+
name:'phtml_escape_scan',
|
|
38
|
+
description:'Scan .phtml for XSS: unescaped echo, missing escapeHtmlAttr, raw block methods, superglobals.',
|
|
39
|
+
parameters:{
|
|
40
|
+
scope:{type:'string', enum:['diff','paths'], required:true},
|
|
41
|
+
paths:{type:'array', items:{type:'string'}},
|
|
42
|
+
},
|
|
43
|
+
output:{
|
|
44
|
+
schema:{
|
|
45
|
+
type:'object', additionalProperties:false,
|
|
46
|
+
properties:{
|
|
47
|
+
findings:{type:'array', required:true},
|
|
48
|
+
scannedFiles:{type:'number', required:true},
|
|
49
|
+
skippedFiles:{type:'number', required:true},
|
|
50
|
+
truncated:{type:'boolean', required:true},
|
|
51
|
+
summary:{type:'object', required:true, additionalProperties:true},
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
render:(_a,v:{findings:unknown[]})=>[{type:'text', text:`${(v.findings as unknown[]).length} findings`}],
|
|
55
|
+
},
|
|
56
|
+
async execute(args, exec){
|
|
57
|
+
const root=workspaceRootFor(configuredRoot, exec)
|
|
58
|
+
const scope=(args as {scope:string}).scope
|
|
59
|
+
const paths=(args as {paths?:string[]}).paths ?? []
|
|
60
|
+
for(const p of paths){ if(!(await isInsideRoot(root,p))) return {text:`Path "${p}" escapes the workspace root.`, truncated:false} as never }
|
|
61
|
+
let files:string[]=[]
|
|
62
|
+
if(scope==='paths'){
|
|
63
|
+
for(const p of paths){
|
|
64
|
+
if(!p.endsWith('.phtml')) continue
|
|
65
|
+
const abs=resolve(root,p); if(await exists(abs)) files.push(p)
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// diff: walk all phtml bounded
|
|
69
|
+
const budget={files:MAX_PHTML_FILES}
|
|
70
|
+
for await(const f of walkPhtml(root, budget)){
|
|
71
|
+
const rel=f.startsWith(root+sep)? f.slice(root.length+1): basename(f)
|
|
72
|
+
files.push(rel); if(files.length>=MAX_PHTML_FILES) break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const findings:Array<{file:string,line:number,type:string,snippet:string,confidence:string,rule:string,escaperHint:string}>=[]
|
|
76
|
+
let scannedFiles=0, skippedFiles=0
|
|
77
|
+
for(const rel of files.slice(0,MAX_PHTML_FILES)){
|
|
78
|
+
if(findings.length>=MAX_FINDINGS) break
|
|
79
|
+
const abs=resolve(root,rel)
|
|
80
|
+
let text:string
|
|
81
|
+
try{ const b=await readFile(abs); if(b.byteLength>MAX_FILE_BYTES){skippedFiles++; continue} text=b.toString('utf-8') }catch{skippedFiles++; continue}
|
|
82
|
+
scannedFiles++
|
|
83
|
+
const lines=text.split('\n')
|
|
84
|
+
lines.forEach((line, idx)=>{
|
|
85
|
+
if(findings.length>=MAX_FINDINGS) return
|
|
86
|
+
const snippet=line.trim().slice(0,240)
|
|
87
|
+
const lineNo=idx+1
|
|
88
|
+
// superglobal always flagged
|
|
89
|
+
if(/\$_GET|\$_POST|\$_REQUEST|\$_COOKIE/.test(line)){
|
|
90
|
+
findings.push({file:rel,line:lineNo,type:'direct-superglobal',snippet,confidence:'high',rule:'M2-SEC-superglobal',escaperHint:'validate/sanitize input, do not echo raw'})
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
// check <?= ... ?> echo
|
|
94
|
+
const echoRe=/<\?=\s*([^?]*?)\s*\?>/g
|
|
95
|
+
let m:RegExpExecArray|null
|
|
96
|
+
while((m=echoRe.exec(line))!==null){
|
|
97
|
+
const inner=m[1]
|
|
98
|
+
// safe if contains escaper
|
|
99
|
+
if(/\$escaper\s*->\s*escape/.test(inner) || /\$block\s*->\s*escape/.test(inner) || /->\s*get\w*Html\s*\(/.test(inner)) continue
|
|
100
|
+
// attr check: if line has title=" <?= ... ?> and not escapeHtmlAttr -> unescaped-attr
|
|
101
|
+
if(/title\s*=\s*"/.test(line) && !/escapeHtmlAttr/.test(inner)){
|
|
102
|
+
findings.push({file:rel,line:lineNo,type:'unescaped-attr',snippet,confidence:'high',rule:'M2-SEC-attr',escaperHint:'$escaper->escapeHtmlAttr() for attributes'})
|
|
103
|
+
} else if(/^\s*\$[a-zA-Z_][\w$]*\s*$/.test(inner.trim()) || /\$\w+/.test(inner)){
|
|
104
|
+
// bare variable echo
|
|
105
|
+
// if this was already handled as attr, don't double count
|
|
106
|
+
if(findings.some(f=>f.file===rel && f.line===lineNo && f.type==='unescaped-attr')) continue
|
|
107
|
+
findings.push({file:rel,line:lineNo,type:'unescaped-echo',snippet,confidence:'high',rule:'M2-SEC-echo',escaperHint:'$escaper->escapeHtml() or $block->escapeHtml()'})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
const truncated=files.length>=MAX_PHTML_FILES || findings.length>=MAX_FINDINGS
|
|
113
|
+
const summary={high:findings.filter(f=>f.confidence==='high').length, medium:findings.filter(f=>f.confidence==='medium').length, low:findings.filter(f=>f.confidence==='low').length, filesWithFindings: new Set(findings.map(f=>f.file)).size}
|
|
114
|
+
return {findings:findings.slice(0,MAX_FINDINGS), scannedFiles, skippedFiles, truncated, summary} as never
|
|
115
|
+
},
|
|
116
|
+
}), 'phtml escape'))
|
|
117
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { join, resolve, sep } from 'node:path'
|
|
2
|
+
import { readFile, writeFile, mkdir, realpath } from 'node:fs/promises'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import z from '@deepseek-ai/schemastery'
|
|
6
|
+
|
|
7
|
+
export const name='maestro-plan-track-tool'
|
|
8
|
+
export const inject=['tools']
|
|
9
|
+
export const Config:z<{rootPath?:string}> = z.object({rootPath:z.string()})
|
|
10
|
+
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
11
|
+
function workspaceRootFor(c:string|undefined,e:unknown):string{ if(c!==undefined) return c; const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd; return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()}
|
|
12
|
+
async function isInsideRoot(r:string,t:string):Promise<boolean>{
|
|
13
|
+
const ar=resolve(r); const rs=resolve(ar,t)
|
|
14
|
+
if(rs!==ar && !rs.startsWith(ar+sep)) return false
|
|
15
|
+
try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{
|
|
16
|
+
try{ const rr=await realpath(ar); const parent=resolve(rs,'..'); const rp=await realpath(parent); return rp===rr||rp.startsWith(rr+sep)}catch{ return rs===ar || rs.startsWith(ar+sep) }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Task={id:string, title:string, status:'pending'|'in_progress'|'done'|'blocked', line:number}
|
|
21
|
+
function parseTasks(md:string):Task[]{
|
|
22
|
+
const lines=md.split('\n')
|
|
23
|
+
const tasks:Task[]=[]
|
|
24
|
+
for(let i=0;i<lines.length;i++){
|
|
25
|
+
const m=/^###\s+(Task\s*\d+):?\s*(.*)/.exec(lines[i].trim())
|
|
26
|
+
if(!m) continue
|
|
27
|
+
const id=m[1].trim()
|
|
28
|
+
let title=m[2].trim()
|
|
29
|
+
let status:Task['status']='pending'
|
|
30
|
+
if(title.includes('in_progress')) status='in_progress'
|
|
31
|
+
else if(title.toLowerCase().includes('done') || title.includes('✅')) status='done'
|
|
32
|
+
else if(title.toLowerCase().includes('blocked')) status='blocked'
|
|
33
|
+
for(let j=i+1;j<Math.min(i+4, lines.length);j++){
|
|
34
|
+
if(lines[j].toLowerCase().includes('in_progress')) status='in_progress'
|
|
35
|
+
if(lines[j].toLowerCase().includes('status: done')) status='done'
|
|
36
|
+
}
|
|
37
|
+
tasks.push({id, title, status, line:i})
|
|
38
|
+
}
|
|
39
|
+
return tasks
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function apply(ctx:Context, config:{rootPath?:string}={}):void{
|
|
43
|
+
const configuredRoot=config.rootPath
|
|
44
|
+
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
45
|
+
name:'maestro_plan_track',
|
|
46
|
+
description:'Track cross-repo plan: init/update/complete tasks with dtodo sync and oneTaskOneCommit guard.',
|
|
47
|
+
parameters:{
|
|
48
|
+
op:{type:'string', enum:['init','update','complete'], required:true},
|
|
49
|
+
planPath:{type:'string', required:true},
|
|
50
|
+
taskId:{type:'string'},
|
|
51
|
+
status:{type:'string', enum:['pending','in_progress','done','blocked']},
|
|
52
|
+
note:{type:'string'},
|
|
53
|
+
},
|
|
54
|
+
output:{
|
|
55
|
+
schema:{
|
|
56
|
+
type:'object', additionalProperties:true,
|
|
57
|
+
properties:{
|
|
58
|
+
ok:{type:'boolean', required:true},
|
|
59
|
+
plan:{type:'object', required:true, additionalProperties:true},
|
|
60
|
+
discipline:{type:'object', additionalProperties:true},
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
render:(_a,v:{ok:boolean})=>[{type:'text', text: v.ok? 'ok' : 'violation'}],
|
|
64
|
+
},
|
|
65
|
+
async execute(args, exec){
|
|
66
|
+
const op=(args as {op:string}).op
|
|
67
|
+
const rawPath=(args as {planPath:string}).planPath
|
|
68
|
+
const root=workspaceRootFor(configuredRoot, exec)
|
|
69
|
+
if(!(await isInsideRoot(root, rawPath))) return {text:`Path "${rawPath}" escapes the workspace root.`, truncated:false} as never
|
|
70
|
+
const planPath=resolve(root, rawPath)
|
|
71
|
+
let md=''
|
|
72
|
+
try{ md=await readFile(planPath,'utf-8') }catch{
|
|
73
|
+
if(op==='init') return {text:`plan not found ${rawPath}`, truncated:false} as never
|
|
74
|
+
md=''
|
|
75
|
+
}
|
|
76
|
+
const tasks=parseTasks(md)
|
|
77
|
+
const lines=md.split('\n')
|
|
78
|
+
|
|
79
|
+
if(op==='init'){
|
|
80
|
+
const nextTask=tasks.find(t=>t.status==='pending' || t.status==='in_progress') || tasks[0] || null
|
|
81
|
+
const dtodoPayload=tasks.map(t=>({content:`${t.id}: ${t.title}`, status: t.status}))
|
|
82
|
+
return {ok:true, plan:{path: rawPath, tasks, nextTask, dtodoPayload}, discipline:{violation:null}} as never
|
|
83
|
+
}
|
|
84
|
+
if(op==='update'){
|
|
85
|
+
const taskId=(args as {taskId?:string}).taskId
|
|
86
|
+
const status=(args as {status?:string}).status as Task['status'] | undefined
|
|
87
|
+
if(!taskId) return {text:'taskId required', truncated:false} as never
|
|
88
|
+
const idx=tasks.findIndex(t=>t.id===taskId)
|
|
89
|
+
if(idx<0) return {text:`task ${taskId} not found`, truncated:false} as never
|
|
90
|
+
if(status==='in_progress'){
|
|
91
|
+
const other=tasks.find(t=>t.id!==taskId && t.status==='in_progress')
|
|
92
|
+
if(other){
|
|
93
|
+
return {ok:false, plan:{path: rawPath, tasks, nextTask: tasks.find(t=>t.status==='in_progress')||null, dtodoPayload:[]}, discipline:{violation:'oneTaskOneCommit: only one task may be in_progress at a time'}} as never
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const lineIdx=tasks[idx].line
|
|
97
|
+
if(status){
|
|
98
|
+
const orig=lines[lineIdx]
|
|
99
|
+
if(orig.includes('—')) lines[lineIdx]=orig.replace(/—.*$/, `— ${status}`)
|
|
100
|
+
else lines[lineIdx]=`${orig} — ${status}`
|
|
101
|
+
await mkdir(join(planPath,'..'),{recursive:true})
|
|
102
|
+
await writeFile(planPath, lines.join('\n'), 'utf-8')
|
|
103
|
+
tasks[idx].status=status
|
|
104
|
+
}
|
|
105
|
+
const nextTask=tasks.find(t=>t.status==='in_progress') || tasks.find(t=>t.status==='pending') || null
|
|
106
|
+
return {ok:true, plan:{path: rawPath, tasks, nextTask, dtodoPayload: tasks.map(t=>({content:`${t.id}: ${t.title}`, status:t.status}))}, discipline:{violation:null}} as never
|
|
107
|
+
}
|
|
108
|
+
if(op==='complete'){
|
|
109
|
+
const pending=tasks.filter(t=>t.status!=='done')
|
|
110
|
+
if(pending.length>0){
|
|
111
|
+
return {ok:false, plan:{path: rawPath, tasks, nextTask: pending[0], dtodoPayload:[]}, discipline:{violation:`pending tasks remain: ${pending.map(p=>p.id).join(',')}`}} as never
|
|
112
|
+
}
|
|
113
|
+
return {ok:true, plan:{path: rawPath, tasks, nextTask:null, dtodoPayload:[]}, discipline:{violation:null}} as never
|
|
114
|
+
}
|
|
115
|
+
return {text:`unknown op ${op}`, truncated:false} as never
|
|
116
|
+
}
|
|
117
|
+
}), 'plan track'))
|
|
118
|
+
}
|
|
@@ -16,9 +16,12 @@ export interface ReviewHistoryEntry {
|
|
|
16
16
|
finishedAt?: number
|
|
17
17
|
summary?: string
|
|
18
18
|
error?: string
|
|
19
|
+
/** MR head_sha at review start — baseline for incremental re-reviews (H6). */
|
|
20
|
+
headSha?: string
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const HISTORY_CAP = 100
|
|
24
|
+
const STALE_RUNNING_MS = 2 * 60 * 60_000 // 2h — host restart can leave running without finish
|
|
22
25
|
|
|
23
26
|
export function historyPath(dshHome?: string): string {
|
|
24
27
|
const home = dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
@@ -45,9 +48,24 @@ async function writeAll(entries: ReviewHistoryEntry[], dshHome?: string): Promis
|
|
|
45
48
|
await chmod(path, 0o600)
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
function failStaleRunning(entries: ReviewHistoryEntry[]): boolean {
|
|
52
|
+
let mutated = false
|
|
53
|
+
const now = Date.now()
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.status === 'running' && now - e.startedAt > STALE_RUNNING_MS) {
|
|
56
|
+
e.status = 'failed'
|
|
57
|
+
e.error = e.error ?? 'auto-failed: stale running review (host restart or crash)'
|
|
58
|
+
e.finishedAt = e.finishedAt ?? now
|
|
59
|
+
mutated = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return mutated
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
/** Prepend a running entry; the oldest entries fall off past `HISTORY_CAP`. */
|
|
49
66
|
export async function recordReviewStart(entry: Omit<ReviewHistoryEntry, 'status'>, dshHome?: string): Promise<void> {
|
|
50
67
|
const all = await readAll(dshHome)
|
|
68
|
+
if (failStaleRunning(all)) await writeAll(all, dshHome)
|
|
51
69
|
all.unshift({ ...entry, status: 'running' })
|
|
52
70
|
await writeAll(all.slice(0, HISTORY_CAP), dshHome)
|
|
53
71
|
}
|
|
@@ -76,6 +94,12 @@ export async function hasCompletedReview(projectId: number, mrIid: number, dshHo
|
|
|
76
94
|
entry.projectId === projectId && entry.mrIid === mrIid && entry.status === 'completed')
|
|
77
95
|
}
|
|
78
96
|
|
|
97
|
+
/** Newest completed entry for this MR — carries headSha for H6 incrementality. */
|
|
98
|
+
export async function lastCompletedReview(projectId: number, mrIid: number, dshHome?: string): Promise<ReviewHistoryEntry | undefined> {
|
|
99
|
+
return (await readAll(dshHome)).find(entry =>
|
|
100
|
+
entry.projectId === projectId && entry.mrIid === mrIid && entry.status === 'completed' && typeof entry.headSha === 'string')
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
/**
|
|
80
104
|
* Drop history entries and failed-report files older than `retentionDays`.
|
|
81
105
|
* `0` disables pruning entirely. Maestro records only — DSH session
|
|
@@ -83,9 +107,11 @@ export async function hasCompletedReview(projectId: number, mrIid: number, dshHo
|
|
|
83
107
|
*/
|
|
84
108
|
export async function pruneHistory(retentionDays: number, dshHome?: string): Promise<void> {
|
|
85
109
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return
|
|
110
|
+
const all = await readAll(dshHome)
|
|
111
|
+
failStaleRunning(all)
|
|
86
112
|
const cutoff = Date.now() - retentionDays * DAY_MS
|
|
87
113
|
const kept: ReviewHistoryEntry[] = []
|
|
88
|
-
for (const entry of
|
|
114
|
+
for (const entry of all) {
|
|
89
115
|
if ((entry.finishedAt ?? entry.startedAt) < cutoff) continue
|
|
90
116
|
kept.push(entry)
|
|
91
117
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import z from '@deepseek-ai/schemastery'
|
|
4
|
+
|
|
5
|
+
export const name='maestro-scope-split-tool'
|
|
6
|
+
export const inject=['tools']
|
|
7
|
+
export const Config:z<{rootPath?:string}>=z.object({rootPath:z.string()})
|
|
8
|
+
|
|
9
|
+
const QUICK_EXTS=new Set(['.phtml','.html','.js','.ts','.less','.css','.xml','.csv','.json'])
|
|
10
|
+
const DEEP_EXTS=new Set(['.php'])
|
|
11
|
+
const QUICK_PATH_HINTS=/^(view\/frontend|view\/adminhtml|i18n)\//
|
|
12
|
+
const DEEP_PATH_HINTS=/(Plugin\/|Observer\/|Controller\/|Setup\/|Model\/|etc\/(di|acl|webapi)\.xml|composer\.json|registration\.php)/
|
|
13
|
+
|
|
14
|
+
function extOf(f:string):string{ const i=f.lastIndexOf('.'); return i>=0? f.slice(i).toLowerCase(): ''}
|
|
15
|
+
|
|
16
|
+
export interface SplitInput{ files:string[], addedLinesPerFile?:Record<string,number>, baseRef?:string}
|
|
17
|
+
export interface SplitResult{
|
|
18
|
+
split:{quick:{files:string[], reason:string}, deep:{files:string[], reason:string}},
|
|
19
|
+
reason:string,
|
|
20
|
+
estimatedSavingsTokens:number,
|
|
21
|
+
estimatedSavingsPercent:number,
|
|
22
|
+
manifest:{totalFiles:number, totalAddedLines:number, riskBreakdown:Array<{risk:string,count:number,examples:string[]}>},
|
|
23
|
+
warnings:string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function splitScope(diffStats: SplitInput, mode:'quick'|'deep'): SplitResult{
|
|
27
|
+
const warnings:string[]=[]
|
|
28
|
+
const quick:string[]=[]
|
|
29
|
+
const deep:string[]=[]
|
|
30
|
+
if(mode==='quick'){
|
|
31
|
+
for(const f of diffStats.files) quick.push(f)
|
|
32
|
+
} else {
|
|
33
|
+
for(const f of diffStats.files){
|
|
34
|
+
const ext=extOf(f)
|
|
35
|
+
if(DEEP_PATH_HINTS.test(f)){ deep.push(f); continue }
|
|
36
|
+
if(QUICK_PATH_HINTS.test(f)){ quick.push(f); continue }
|
|
37
|
+
if(ext==='.phtml' || ext==='.html' || ext==='.js' || ext==='.ts' || ext==='.less' || ext==='.css' || ext==='.csv' || ext==='.json'){
|
|
38
|
+
quick.push(f); continue
|
|
39
|
+
}
|
|
40
|
+
if(ext==='.xml'){
|
|
41
|
+
// layout xml is quick, other xml deep? Treat layout as quick
|
|
42
|
+
if(f.includes('/layout/') || f.includes('view/')) quick.push(f)
|
|
43
|
+
else deep.push(f)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if(DEEP_EXTS.has(ext) || ext==='.php'){ deep.push(f); continue }
|
|
47
|
+
// unknown ext
|
|
48
|
+
deep.push(f)
|
|
49
|
+
warnings.push(`unknown extension ${ext||'(none)'} for ${f} → deep (fail-safe)`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const estimatedSavingsTokens=quick.length*1400
|
|
53
|
+
const totalFiles=diffStats.files.length
|
|
54
|
+
const totalAddedLines=diffStats.addedLinesPerFile? Object.values(diffStats.addedLinesPerFile).reduce((a,b)=>a+b,0): 0
|
|
55
|
+
const totalTokensIfAllDeep=totalFiles*1800
|
|
56
|
+
const estimatedSavingsPercent= totalTokensIfAllDeep>0 ? Math.round(estimatedSavingsTokens/totalTokensIfAllDeep*100) : 0
|
|
57
|
+
const reason = `split ${quick.length} quick (phtml/js/less/xml) vs ${deep.length} deep (php/di.xml/Plugin) → saves ~${estimatedSavingsTokens} tokens (${estimatedSavingsPercent}%)`
|
|
58
|
+
const manifest={
|
|
59
|
+
totalFiles,
|
|
60
|
+
totalAddedLines,
|
|
61
|
+
riskBreakdown:[
|
|
62
|
+
{risk:'quick', count:quick.length, examples:quick.slice(0,3)},
|
|
63
|
+
{risk:'deep', count:deep.length, examples:deep.slice(0,3)},
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
split:{
|
|
68
|
+
quick:{files:quick, reason:'quick: templates, frontend assets'},
|
|
69
|
+
deep:{files:deep, reason:'deep: php, di.xml, Plugin/Observer'},
|
|
70
|
+
},
|
|
71
|
+
reason,
|
|
72
|
+
estimatedSavingsTokens,
|
|
73
|
+
estimatedSavingsPercent,
|
|
74
|
+
manifest,
|
|
75
|
+
warnings,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function apply(ctx:Context, _config:{rootPath?:string}={}):void{
|
|
80
|
+
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
81
|
+
name:'maestro_review_scope_split',
|
|
82
|
+
description:'Deterministic QA split for MR diff: quick (templates) vs deep (php/di.xml/Plugin) with token savings estimate.',
|
|
83
|
+
parameters:{
|
|
84
|
+
diffStats:{type:'object', required:true, additionalProperties:false, properties:{files:{type:'array', items:{type:'string'}, required:true}, addedLinesPerFile:{type:'object', additionalProperties:true}, baseRef:{type:'string'}}},
|
|
85
|
+
mode:{type:'string', enum:['quick','deep'], required:true},
|
|
86
|
+
quickExtensions:{type:'array', items:{type:'string'}},
|
|
87
|
+
deepExtensions:{type:'array', items:{type:'string'}},
|
|
88
|
+
maxDeepFiles:{type:'number'},
|
|
89
|
+
},
|
|
90
|
+
output:{
|
|
91
|
+
schema:{
|
|
92
|
+
type:'object', additionalProperties:false,
|
|
93
|
+
properties:{
|
|
94
|
+
split:{type:'object', required:true, additionalProperties:true},
|
|
95
|
+
reason:{type:'string', required:true},
|
|
96
|
+
estimatedSavingsTokens:{type:'number', required:true},
|
|
97
|
+
estimatedSavingsPercent:{type:'number', required:true},
|
|
98
|
+
manifest:{type:'object', required:true, additionalProperties:true},
|
|
99
|
+
warnings:{type:'array', required:true},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
render:(_a,v:{reason:string})=>[{type:'text', text:v.reason}],
|
|
103
|
+
},
|
|
104
|
+
async execute(args){
|
|
105
|
+
const diffStats=(args as {diffStats:SplitInput}).diffStats
|
|
106
|
+
const mode=(args as {mode:'quick'|'deep'}).mode
|
|
107
|
+
return splitScope(diffStats, mode) as never
|
|
108
|
+
}
|
|
109
|
+
}), 'scope split'))
|
|
110
|
+
}
|