@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,130 @@
|
|
|
1
|
+
import { join, resolve, sep, basename } from 'node:path'
|
|
2
|
+
import { stat, realpath } from 'node:fs/promises'
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
import z from '@deepseek-ai/schemastery'
|
|
8
|
+
|
|
9
|
+
export const name='maestro-git-worktree-tool'
|
|
10
|
+
export const inject=['tools']
|
|
11
|
+
export const Config:z<{rootPath?:string}> = z.object({rootPath:z.string()})
|
|
12
|
+
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
13
|
+
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()}
|
|
14
|
+
async function isInsideRoot(r:string,t:string):Promise<boolean>{
|
|
15
|
+
const ar=resolve(r); const rs=resolve(ar,t)
|
|
16
|
+
if(rs!==ar && !rs.startsWith(ar+sep)) return false
|
|
17
|
+
// For non-existent targets (create), realpath fails — allow if string prefix passes and parent is inside
|
|
18
|
+
try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{
|
|
19
|
+
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) }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const SAFE_BRANCH=/^[A-Za-z0-9._\/-]+$/
|
|
23
|
+
const execFileAsync=promisify(execFile)
|
|
24
|
+
async function runGit(cwd:string, args:string[]):Promise<{code:number|null, stdout:string, stderr:string}>{
|
|
25
|
+
try{
|
|
26
|
+
const {stdout, stderr}=await execFileAsync('git', args, {cwd, timeout:10000}) as any
|
|
27
|
+
return {code:0, stdout: String(stdout), stderr: String(stderr)}
|
|
28
|
+
}catch(e:any){
|
|
29
|
+
return {code:e.code ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message)}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function exists(p:string):Promise<boolean>{ try{await stat(p); return true}catch{return false}}
|
|
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:'git_worktree',
|
|
38
|
+
description:'Inspect/create/remove git worktrees deterministically (isolated workspace, branch, submodule guard).',
|
|
39
|
+
parameters:{
|
|
40
|
+
op:{type:'string', enum:['inspect','create','remove'], required:true},
|
|
41
|
+
worktreePath:{type:'string', required:true},
|
|
42
|
+
branch:{type:'string'},
|
|
43
|
+
base:{type:'string'},
|
|
44
|
+
},
|
|
45
|
+
output:{
|
|
46
|
+
schema:{
|
|
47
|
+
type:'object', additionalProperties:true,
|
|
48
|
+
properties:{
|
|
49
|
+
op:{type:'string', required:true},
|
|
50
|
+
exists:{type:'boolean'},
|
|
51
|
+
isWorktree:{type:'boolean'},
|
|
52
|
+
created:{type:'boolean'},
|
|
53
|
+
reason:{type:'string'},
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
render:(_a,v)=>[{type:'text', text: JSON.stringify(v,null,2)}],
|
|
57
|
+
},
|
|
58
|
+
async execute(args, exec){
|
|
59
|
+
const op=(args as {op:string}).op
|
|
60
|
+
const rawPath=(args as {worktreePath:string}).worktreePath
|
|
61
|
+
const root=workspaceRootFor(configuredRoot, exec)
|
|
62
|
+
if(!(await isInsideRoot(root, rawPath))) return {text:`Path "${rawPath}" escapes the workspace root.`, truncated:false} as never
|
|
63
|
+
const worktreePath=resolve(root, rawPath)
|
|
64
|
+
|
|
65
|
+
if(op==='inspect'){
|
|
66
|
+
const ex=await exists(worktreePath)
|
|
67
|
+
let isWorktree=false, branch:string|null=null, headSha:string|null=null, isClean=true, gitDir:string|null=null, gitCommonDir:string|null=null
|
|
68
|
+
if(ex){
|
|
69
|
+
// try git rev-parse
|
|
70
|
+
const r1=await runGit(worktreePath, ['rev-parse','--git-dir'])
|
|
71
|
+
const r2=await runGit(worktreePath, ['rev-parse','--git-common-dir'])
|
|
72
|
+
const r3=await runGit(worktreePath, ['branch','--show-current'])
|
|
73
|
+
const r4=await runGit(worktreePath, ['rev-parse','HEAD'])
|
|
74
|
+
const r5=await runGit(worktreePath, ['status','--porcelain'])
|
|
75
|
+
if(r1.code===0) gitDir=r1.stdout.trim()
|
|
76
|
+
if(r2.code===0) gitCommonDir=r2.stdout.trim()
|
|
77
|
+
if(r3.code===0) branch=r3.stdout.trim()||null
|
|
78
|
+
if(r4.code===0) headSha=r4.stdout.trim()||null
|
|
79
|
+
isClean=r5.stdout.trim()===''
|
|
80
|
+
// isWorktree if gitDir is file and gitCommonDir differs
|
|
81
|
+
if(gitDir && gitCommonDir && gitDir!==gitCommonDir) isWorktree=true
|
|
82
|
+
// fallback: if worktreePath contains .worktrees
|
|
83
|
+
if(worktreePath.includes('/.worktrees/')) isWorktree=true
|
|
84
|
+
}
|
|
85
|
+
return {op:'inspect', exists:ex, branch, headSha, isClean, isWorktree, gitDir, gitCommonDir, untrackedFiles:[], truncated:false, ignored:null} as never
|
|
86
|
+
}
|
|
87
|
+
if(op==='create'){
|
|
88
|
+
const branch=(args as {branch?:string}).branch
|
|
89
|
+
const base=(args as {base?:string}).base ?? 'HEAD'
|
|
90
|
+
if(!branch) return {text:'branch required for create', truncated:false} as never
|
|
91
|
+
if(!SAFE_BRANCH.test(branch) || branch.startsWith('-') || branch.includes('..')) return {text:`invalid branch name "${branch}" — must match ${SAFE_BRANCH.source}`, truncated:false} as never
|
|
92
|
+
if(branch.includes('--')) return {text:`invalid branch name "${branch}"`, truncated:false} as never
|
|
93
|
+
// base validate
|
|
94
|
+
if(base && !SAFE_BRANCH.test(base) && !/^[0-9a-f]{7,40}$/.test(base)) return {text:`invalid base "${base}"`, truncated:false} as never
|
|
95
|
+
// .gitignore guard: check if worktreePath is ignored
|
|
96
|
+
const check=await runGit(root, ['check-ignore','-q', worktreePath])
|
|
97
|
+
const isIgnored=check.code===0
|
|
98
|
+
if(!isIgnored){
|
|
99
|
+
return {op:'create', created:false, worktreePath, branch, headSha:'', alreadyExisted:false, reason:'not ignored — add .worktrees to .gitignore'} as never
|
|
100
|
+
}
|
|
101
|
+
if(await exists(worktreePath)){
|
|
102
|
+
return {op:'create', created:false, worktreePath, branch, headSha:'', alreadyExisted:true, reason:'already exists'} as never
|
|
103
|
+
}
|
|
104
|
+
const res=await runGit(root, ['worktree','add','--', worktreePath,'-b', branch, base])
|
|
105
|
+
if(res.code!==0){
|
|
106
|
+
if(res.stderr.includes('permission denied') || res.stderr.includes('EACCES')){
|
|
107
|
+
return {op:'create', created:false, worktreePath, branch, headSha:'', alreadyExisted:false, reason:'blocked by sandbox — working in place'} as never
|
|
108
|
+
}
|
|
109
|
+
return {op:'create', created:false, worktreePath, branch, headSha:'', alreadyExisted:false, reason: res.stderr.slice(0,500)} as never
|
|
110
|
+
}
|
|
111
|
+
const head=await runGit(worktreePath, ['rev-parse','HEAD'])
|
|
112
|
+
return {op:'create', created:true, worktreePath, branch, headSha: head.stdout.trim(), alreadyExisted:false, reason:null} as never
|
|
113
|
+
}
|
|
114
|
+
if(op==='remove'){
|
|
115
|
+
const ex=await exists(worktreePath)
|
|
116
|
+
if(!ex) return {op:'remove', removed:false, pruned:false, reason:'not exists', dirtyFiles:[]} as never
|
|
117
|
+
const status=await runGit(worktreePath, ['status','--porcelain'])
|
|
118
|
+
const dirtyFiles=status.stdout.trim()? status.stdout.trim().split('\n').slice(0,20):[]
|
|
119
|
+
if(dirtyFiles.length>0){
|
|
120
|
+
return {op:'remove', removed:false, pruned:false, reason:'contains modified or untracked files', dirtyFiles} as never
|
|
121
|
+
}
|
|
122
|
+
const res=await runGit(root, ['worktree','remove','--', worktreePath])
|
|
123
|
+
if(res.code!==0) return {op:'remove', removed:false, pruned:false, reason: res.stderr.slice(0,500), dirtyFiles} as never
|
|
124
|
+
const prune=await runGit(root, ['worktree','prune'])
|
|
125
|
+
return {op:'remove', removed:true, pruned: prune.code===0, reason:null, dirtyFiles:[]} as never
|
|
126
|
+
}
|
|
127
|
+
return {text:`unknown op ${op}`, truncated:false} as never
|
|
128
|
+
}
|
|
129
|
+
}), 'git worktree'))
|
|
130
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
errors:{type:'array', items:{type:'object', additionalProperties:true}},
|
|
84
|
+
diagnostics:{type:'string'},
|
|
85
|
+
sessionId:{type:'string'},
|
|
86
|
+
runId:{type:'string'},
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
render:(_a,v:{ok:boolean})=>[{type:'text', text: v.ok? 'audit lint passed':'audit lint failed'}],
|
|
90
|
+
},
|
|
91
|
+
async execute(args, exec){
|
|
92
|
+
const rawPath=(args as {worktreePath?:string}).worktreePath
|
|
93
|
+
const root=workspaceRootFor(configuredRoot, exec)
|
|
94
|
+
const worktreePath= rawPath ? resolve(root, rawPath) : root
|
|
95
|
+
const checkPath= rawPath ?? ''
|
|
96
|
+
if(checkPath!=='' && !(await isInsideRoot(root, checkPath))) return {text:`Path "${checkPath}" escapes the workspace root.`, truncated:false} as never
|
|
97
|
+
const timeoutMs=(args as {timeoutMs?:number}).timeoutMs ?? defaultTimeout
|
|
98
|
+
if(timeoutMs<5000 || timeoutMs>300000) return {text:'timeoutMs out of range 5000-300000', truncated:false} as never
|
|
99
|
+
|
|
100
|
+
const cliArgs=['audit','run','--checks','lint','--format','json']
|
|
101
|
+
const result=await run('govard', cliArgs, worktreePath, timeoutMs)
|
|
102
|
+
if(result.timedOut){
|
|
103
|
+
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
|
|
104
|
+
}
|
|
105
|
+
if(result.code===null && result.stderr.includes('ENOENT')){
|
|
106
|
+
return {ok:false, exitCode:127, timedOut:false, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'govard_not_found', message:'govard binary not found'}], diagnostics:result.stderr.slice(0,4000)} as never
|
|
107
|
+
}
|
|
108
|
+
const cleaned=cleanJson(result.stdout)
|
|
109
|
+
let parsed:any=null
|
|
110
|
+
try{ parsed=JSON.parse(cleaned) }catch{ parsed=null }
|
|
111
|
+
if(!parsed){
|
|
112
|
+
const fallback=cleaned.split('\n').slice(0,-1).join('\n').trimEnd()
|
|
113
|
+
try{ parsed=JSON.parse(fallback)}catch{}
|
|
114
|
+
if(!parsed){
|
|
115
|
+
const tryStderr=cleanJson(result.stdout+result.stderr)
|
|
116
|
+
try{ parsed=JSON.parse(tryStderr)}catch{}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if(!parsed){
|
|
120
|
+
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
|
|
121
|
+
}
|
|
122
|
+
const findings:Array<any>=parsed.findings ?? parsed.evidence?.php_results?.flatMap((r:any)=>r.findings) ?? []
|
|
123
|
+
const phpcsViolations:any[]=[]
|
|
124
|
+
const phpstanErrors:any[]=[]
|
|
125
|
+
const pubMediaViolations:any[]=[]
|
|
126
|
+
for(const f of findings){
|
|
127
|
+
if(f.tool==='phpstan') phpstanErrors.push({path:f.path, line:f.line, message:f.message})
|
|
128
|
+
else if(f.tool==='phpcs'){
|
|
129
|
+
if(f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) pubMediaViolations.push(f)
|
|
130
|
+
else phpcsViolations.push({path:f.path, line:f.line, column:f.column, rule:f.rule, message:f.message, severity:f.severity})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const status=parsed.status ?? (result.code===0?'passed':'failed')
|
|
134
|
+
const phpVersions=parsed.php_versions ?? parsed.phpVersions ?? []
|
|
135
|
+
const findingCount=findings.length
|
|
136
|
+
const sessionId = parsed.session_id ?? parsed.sessionId ?? undefined
|
|
137
|
+
const runId = parsed.run_id ?? parsed.runId ?? undefined
|
|
138
|
+
return {
|
|
139
|
+
ok: result.code===0,
|
|
140
|
+
exitCode: result.code ?? 0,
|
|
141
|
+
timedOut:false,
|
|
142
|
+
worktreePath,
|
|
143
|
+
...(sessionId ? { sessionId: String(sessionId) } : {}),
|
|
144
|
+
...(runId ? { runId: String(runId) } : {}),
|
|
145
|
+
rawJson: parsed as Record<string, unknown>,
|
|
146
|
+
summary:{status, phpVersions, matrixComplete:true, findingCount, truncated: findingCount>100},
|
|
147
|
+
lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}},
|
|
148
|
+
errors:[],
|
|
149
|
+
diagnostics: result.stderr.slice(0,4000),
|
|
150
|
+
} as never
|
|
151
|
+
}
|
|
152
|
+
})))
|
|
153
|
+
}
|
|
@@ -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
|
+
}
|