@ddtcorex/dsh-maestro-review 0.1.0 → 0.1.1

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