@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.
Files changed (143) hide show
  1. package/lib/config-store.d.ts +2 -0
  2. package/lib/config-store.d.ts.map +1 -1
  3. package/lib/config-store.js.map +1 -1
  4. package/lib/events.d.ts.map +1 -1
  5. package/lib/events.js.map +1 -1
  6. package/lib/git-worktree-tool.d.ts +11 -0
  7. package/lib/git-worktree-tool.d.ts.map +1 -0
  8. package/lib/git-worktree-tool.js +162 -0
  9. package/lib/git-worktree-tool.js.map +1 -0
  10. package/lib/gitlab-client.d.ts.map +1 -1
  11. package/lib/gitlab-client.js.map +1 -1
  12. package/lib/gitlab-webhook.d.ts.map +1 -1
  13. package/lib/gitlab-webhook.js.map +1 -1
  14. package/lib/govard-audit-lint-tool.d.ts +14 -0
  15. package/lib/govard-audit-lint-tool.d.ts.map +1 -0
  16. package/lib/govard-audit-lint-tool.js +177 -0
  17. package/lib/govard-audit-lint-tool.js.map +1 -0
  18. package/lib/govard-tool.d.ts.map +1 -1
  19. package/lib/govard-tool.js.map +1 -1
  20. package/lib/hyva-csp-scan-tool.d.ts +11 -0
  21. package/lib/hyva-csp-scan-tool.d.ts.map +1 -0
  22. package/lib/hyva-csp-scan-tool.js +193 -0
  23. package/lib/hyva-csp-scan-tool.js.map +1 -0
  24. package/lib/hyva-theme-inspect-tool.d.ts +10 -0
  25. package/lib/hyva-theme-inspect-tool.d.ts.map +1 -0
  26. package/lib/hyva-theme-inspect-tool.js +299 -0
  27. package/lib/hyva-theme-inspect-tool.js.map +1 -0
  28. package/lib/incremental.d.ts +28 -0
  29. package/lib/incremental.d.ts.map +1 -0
  30. package/lib/incremental.js +60 -0
  31. package/lib/incremental.js.map +1 -0
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js.map +1 -1
  34. package/lib/layout-xml-tool.d.ts +11 -0
  35. package/lib/layout-xml-tool.d.ts.map +1 -0
  36. package/lib/layout-xml-tool.js +240 -0
  37. package/lib/layout-xml-tool.js.map +1 -0
  38. package/lib/module-check-tool.d.ts +11 -0
  39. package/lib/module-check-tool.d.ts.map +1 -0
  40. package/lib/module-check-tool.js +304 -0
  41. package/lib/module-check-tool.js.map +1 -0
  42. package/lib/notify.d.ts.map +1 -1
  43. package/lib/notify.js.map +1 -1
  44. package/lib/orchestrator.d.ts +2 -0
  45. package/lib/orchestrator.d.ts.map +1 -1
  46. package/lib/orchestrator.js +220 -88
  47. package/lib/orchestrator.js.map +1 -1
  48. package/lib/perf-log-stats-tool.d.ts +13 -0
  49. package/lib/perf-log-stats-tool.d.ts.map +1 -0
  50. package/lib/perf-log-stats-tool.js +245 -0
  51. package/lib/perf-log-stats-tool.js.map +1 -0
  52. package/lib/phtml-escape-scan-tool.d.ts +11 -0
  53. package/lib/phtml-escape-scan-tool.d.ts.map +1 -0
  54. package/lib/phtml-escape-scan-tool.js +170 -0
  55. package/lib/phtml-escape-scan-tool.js.map +1 -0
  56. package/lib/plan-track-tool.d.ts +11 -0
  57. package/lib/plan-track-tool.d.ts.map +1 -0
  58. package/lib/plan-track-tool.js +143 -0
  59. package/lib/plan-track-tool.js.map +1 -0
  60. package/lib/providers/github.stub.d.ts.map +1 -1
  61. package/lib/providers/github.stub.js.map +1 -1
  62. package/lib/providers/gitlab.d.ts.map +1 -1
  63. package/lib/providers/gitlab.js.map +1 -1
  64. package/lib/providers/interface.d.ts.map +1 -1
  65. package/lib/providers/interface.js.map +1 -1
  66. package/lib/review-findings-tool.d.ts.map +1 -1
  67. package/lib/review-findings-tool.js.map +1 -1
  68. package/lib/review-history.d.ts +4 -0
  69. package/lib/review-history.d.ts.map +1 -1
  70. package/lib/review-history.js +23 -1
  71. package/lib/review-history.js.map +1 -1
  72. package/lib/review-intake.d.ts.map +1 -1
  73. package/lib/review-intake.js.map +1 -1
  74. package/lib/review-signals.d.ts.map +1 -1
  75. package/lib/review-signals.js.map +1 -1
  76. package/lib/scope-split-tool.d.ts +42 -0
  77. package/lib/scope-split-tool.d.ts.map +1 -0
  78. package/lib/scope-split-tool.js +109 -0
  79. package/lib/scope-split-tool.js.map +1 -0
  80. package/lib/search-tool.d.ts +15 -0
  81. package/lib/search-tool.d.ts.map +1 -0
  82. package/lib/search-tool.js +144 -0
  83. package/lib/search-tool.js.map +1 -0
  84. package/lib/secure-compare.d.ts.map +1 -1
  85. package/lib/secure-compare.js.map +1 -1
  86. package/lib/settings-rpc.d.ts.map +1 -1
  87. package/lib/settings-rpc.js +47 -8
  88. package/lib/settings-rpc.js.map +1 -1
  89. package/lib/skills-tool.d.ts +1 -1
  90. package/lib/skills-tool.d.ts.map +1 -1
  91. package/lib/skills-tool.js +5 -1
  92. package/lib/skills-tool.js.map +1 -1
  93. package/lib/tdd-evidence-tool.d.ts +11 -0
  94. package/lib/tdd-evidence-tool.d.ts.map +1 -0
  95. package/lib/tdd-evidence-tool.js +114 -0
  96. package/lib/tdd-evidence-tool.js.map +1 -0
  97. package/lib/tool-policy.d.ts +20 -0
  98. package/lib/tool-policy.d.ts.map +1 -0
  99. package/lib/tool-policy.js +42 -0
  100. package/lib/tool-policy.js.map +1 -0
  101. package/lib/workspace-tool.d.ts.map +1 -1
  102. package/lib/workspace-tool.js +15 -9
  103. package/lib/workspace-tool.js.map +1 -1
  104. package/package.json +4 -4
  105. package/src/{augment.d.ts → host/augment.d.ts} +5 -2
  106. package/src/{config-store.ts → host/config-store.ts} +2 -0
  107. package/src/host/git-worktree-tool.ts +130 -0
  108. package/src/host/govard-audit-lint-tool.ts +153 -0
  109. package/src/host/hyva-csp-scan-tool.ts +165 -0
  110. package/src/host/hyva-theme-inspect-tool.ts +258 -0
  111. package/src/host/incremental.ts +84 -0
  112. package/src/host/layout-xml-tool.ts +191 -0
  113. package/src/host/module-check-tool.ts +188 -0
  114. package/src/{orchestrator.ts → host/orchestrator.ts} +205 -84
  115. package/src/host/perf-log-stats-tool.ts +224 -0
  116. package/src/host/phtml-escape-scan-tool.ts +117 -0
  117. package/src/host/plan-track-tool.ts +118 -0
  118. package/src/{review-history.ts → host/review-history.ts} +27 -1
  119. package/src/host/scope-split-tool.ts +110 -0
  120. package/src/host/search-tool.ts +153 -0
  121. package/src/{settings-rpc.ts → host/settings-rpc.ts} +54 -11
  122. package/src/{skills-tool.ts → host/skills-tool.ts} +5 -1
  123. package/src/host/tdd-evidence-tool.ts +85 -0
  124. package/src/host/tool-policy.ts +49 -0
  125. package/src/{workspace-tool.ts → host/workspace-tool.ts} +27 -9
  126. package/lib/pin-store.d.ts +0 -5
  127. package/lib/pin-store.d.ts.map +0 -1
  128. package/lib/pin-store.js +0 -5
  129. package/lib/pin-store.js.map +0 -1
  130. package/src/pin-store.ts +0 -4
  131. /package/src/{events.ts → host/events.ts} +0 -0
  132. /package/src/{gitlab-client.ts → host/gitlab-client.ts} +0 -0
  133. /package/src/{gitlab-webhook.ts → host/gitlab-webhook.ts} +0 -0
  134. /package/src/{govard-tool.ts → host/govard-tool.ts} +0 -0
  135. /package/src/{index.ts → host/index.ts} +0 -0
  136. /package/src/{notify.ts → host/notify.ts} +0 -0
  137. /package/src/{providers → host/providers}/github.stub.ts +0 -0
  138. /package/src/{providers → host/providers}/gitlab.ts +0 -0
  139. /package/src/{providers → host/providers}/interface.ts +0 -0
  140. /package/src/{review-findings-tool.ts → host/review-findings-tool.ts} +0 -0
  141. /package/src/{review-intake.ts → host/review-intake.ts} +0 -0
  142. /package/src/{review-signals.ts → host/review-signals.ts} +0 -0
  143. /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
+ }