@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,191 @@
1
+ import { join, resolve, sep, basename, dirname } 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-layout-xml-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(c:string|undefined, exec:unknown):string{
13
+ if(c!==undefined) return c
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(root:string, target:string):Promise<boolean>{
18
+ const ar=resolve(root); const rs=resolve(ar,target)
19
+ if(rs!==ar && !rs.startsWith(ar+sep)) return false
20
+ try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr || rt.startsWith(rr+sep)}catch{return false}
21
+ }
22
+ const MAX_FILE_BYTES=1024*1024
23
+ const MAX_FILES=2000
24
+ const SKIP_DIRS=new Set(['.git','node_modules','vendor','pub/static'])
25
+ const LAYOUT_RE = /\/layout\//
26
+
27
+ // tiny helpers
28
+ function parseAttrs(s:string):Record<string,string>{
29
+ const out:Record<string,string>={}
30
+ let m:RegExpExecArray|null
31
+ const re=/(\w+)="([^"]*)"/g
32
+ while((m=re.exec(s))!==null) out[m[1]]=m[2]
33
+ return out
34
+ }
35
+ async function exists(p:string):Promise<boolean>{ try{await stat(p); return true}catch{return false}}
36
+ async function* walkLayout(dir:string, root:string, budget:{files:number}):AsyncGenerator<string>{
37
+ if(budget.files<=0) return
38
+ let entries
39
+ try{ entries=await readdir(dir,{withFileTypes:true})}catch{return}
40
+ for(const e of entries){
41
+ if(budget.files<=0) return
42
+ if(e.isDirectory()){
43
+ if(SKIP_DIRS.has(e.name)) continue
44
+ yield* walkLayout(join(dir,e.name), root, budget)
45
+ continue
46
+ }
47
+ if(!e.isFile()) continue
48
+ if(!e.name.endsWith('.xml')) continue
49
+ if(!join(dir,e.name).includes('/layout/')) continue
50
+ budget.files-=1
51
+ yield join(dir,e.name)
52
+ }
53
+ }
54
+
55
+ export function apply(ctx:Context, config:{rootPath?:string}={}):void{
56
+ const configuredRoot=config.rootPath
57
+ ctx.effect(()=>ctx.tools.register(defineTool({
58
+ name:'layout_xml_extract',
59
+ description:'Extract Magento layout handles/blocks/moves/head from layout XML under workspace. Use before claiming layout is correct.',
60
+ parameters:{
61
+ root:{type:'string'},
62
+ changedFiles:{type:'array', items:{type:'string'}},
63
+ },
64
+ output:{
65
+ schema:{
66
+ type:'object', additionalProperties:false,
67
+ properties:{
68
+ handles:{type:'array', required:true},
69
+ handleCount:{type:'number', required:true},
70
+ blockCount:{type:'number', required:true},
71
+ scannedFiles:{type:'number', required:true},
72
+ truncated:{type:'boolean', required:true},
73
+ }
74
+ },
75
+ render:(_args,v)=>[{type:'text', text: JSON.stringify(v,null,2)}],
76
+ },
77
+ async execute(args, exec){
78
+ const rawRoot=(args as {root?:string}).root ?? ''
79
+ const root=workspaceRootFor(configuredRoot, exec)
80
+ if(rawRoot!=='' && !(await isInsideRoot(root, rawRoot))) return {text:`Path "${rawRoot}" escapes the workspace root.`, truncated:false} as never
81
+ const effectiveRoot = rawRoot==='' ? root : resolve(root, rawRoot)
82
+ const changedFiles=(args as {changedFiles?:string[]}).changedFiles
83
+ if(changedFiles){
84
+ for(const p of changedFiles){
85
+ if(!(await isInsideRoot(effectiveRoot, p))) return {text:`Path "${p}" escapes the workspace root.`, truncated:false} as never
86
+ }
87
+ }
88
+ // collect files
89
+ const budget={files:MAX_FILES}
90
+ const all:string[]=[]
91
+ for await(const f of walkLayout(effectiveRoot, effectiveRoot, budget)){
92
+ const rel=f.startsWith(effectiveRoot+sep)? f.slice(effectiveRoot.length+1): basename(f)
93
+ all.push(rel)
94
+ }
95
+ let files=all
96
+ if(changedFiles && changedFiles.length>0){
97
+ const set=new Set(changedFiles.map(p=>p.endsWith('.xml')?p:p))
98
+ files=all.filter(p=> set.has(p) || changedFiles.some(cf=> p.endsWith(basename(cf))))
99
+ // also if allow-list contains files not discovered via walk (e.g., outside layout but still layout), include them if they exist and are layout
100
+ for(const cf of changedFiles){
101
+ if(!files.includes(cf)){
102
+ const abs=resolve(effectiveRoot, cf)
103
+ if(await exists(abs) && cf.includes('/layout/') && cf.endsWith('.xml') && !(all.includes(cf))) files.push(cf)
104
+ }
105
+ }
106
+ }
107
+ const truncated = budget.files<=0
108
+ const handles:Array<{file:string,handle:string,layoutType:string,blocks:Array<{name:string|null,type:string,class:string|null,template:string|null,templateExists:boolean|null,as:string|null,cacheable:boolean|null,remove:boolean,refs:string[]}>,moves:Array<{element:string,destination:string|null,as:string|null,before:string|null,after:string|null}>,references:Array<{kind:string,name:string,attrs:Record<string,string>}>,head:{scripts:Array<{src:string}>,css:Array<{src:string}>,metas:Array<Record<string,string>>,removes:Array<string>},parseError:string|null}>=[]
109
+ let blockCount=0
110
+ for(const rel of files){
111
+ const abs=resolve(effectiveRoot, rel)
112
+ let text:string
113
+ try{
114
+ const buf=await readFile(abs)
115
+ if(buf.byteLength>MAX_FILE_BYTES) continue
116
+ text=buf.toString('utf-8')
117
+ }catch{continue}
118
+ const handle=basename(rel,'.xml')
119
+ const layoutType=/<page\b/.test(text)?'page':'generic'
120
+ // quick parseError heuristic: mismatched < vs > or unclosed tag
121
+ let parseError:string|null=null
122
+ const openCount=(text.match(/</g)||[]).length
123
+ const closeCount=(text.match(/>/g)||[]).length
124
+ if(openCount!==closeCount) parseError=`mismatched < (${openCount}) vs > (${closeCount})`
125
+ // also detect block tag without closing >
126
+ if(/<block[^>]*$/m.test(text) || /<block[^>]*<\/body/.test(text)) parseError=parseError ?? 'malformed block tag'
127
+ if(parseError){
128
+ handles.push({file:rel,handle,layoutType,blocks:[],moves:[],references:[],head:{scripts:[],css:[],metas:[],removes:[]},parseError})
129
+ continue
130
+ }
131
+ const blocks:Array<{name:string|null,type:string,class:string|null,template:string|null,templateExists:boolean|null,as:string|null,cacheable:boolean|null,remove:boolean,refs:string[]}>=[]
132
+ const moves:Array<{element:string,destination:string|null,as:string|null,before:string|null,after:string|null}>=[]
133
+ const references:Array<{kind:string,name:string,attrs:Record<string,string>}>=[]
134
+ const head={scripts:[] as Array<{src:string}>, css:[] as Array<{src:string}>, metas:[] as Array<Record<string,string>>, removes:[] as string[]}
135
+ // blocks/containers (only concrete)
136
+ const tagRe=/<(block|container)\b([^>]*?)(?:\/>|>)/g
137
+ let m:RegExpExecArray|null
138
+ while((m=tagRe.exec(text))!==null){
139
+ const type=m[1]; const attrStr=m[2]; const attrs=parseAttrs(attrStr)
140
+ const name=attrs.name ?? null
141
+ const klass=attrs.class ?? null
142
+ const template=attrs.template ?? null
143
+ let templateExists:boolean|null=null
144
+ if(template!==null){
145
+ if(template.includes('::')){
146
+ const [modPart, filePart]=template.split('::')
147
+ const [vendor, mod]=modPart.split('_')
148
+ if(vendor && mod && filePart){
149
+ const cand=join(effectiveRoot, `app/code/${vendor}/${mod}/view/frontend/templates/${filePart}`)
150
+ templateExists=await exists(cand)
151
+ if(!templateExists){
152
+ templateExists=false
153
+ }
154
+ } else templateExists=false
155
+ } else {
156
+ templateExists=await exists(join(effectiveRoot, template))
157
+ }
158
+ }
159
+ const as=attrs.as ?? null
160
+ const cacheable=attrs.cacheable ? attrs.cacheable==='true' : null
161
+ const remove=attrs.remove==='true'
162
+ blocks.push({name, type, class:klass, template, templateExists, as, cacheable, remove, refs:[]})
163
+ }
164
+ // references
165
+ const refRe=/(<(referenceBlock|referenceContainer)\b([^>]*?)(?:\/>|>))/g
166
+ while((m=refRe.exec(text))!==null){
167
+ const type=m[2]; const attrs=parseAttrs(m[3])
168
+ references.push({kind:type, name: attrs.name ?? '', attrs})
169
+ }
170
+ // moves
171
+ const moveRe=/<move\b([^>]*?)\/>/g
172
+ while((m=moveRe.exec(text))!==null){
173
+ const attrs=parseAttrs(m[1])
174
+ moves.push({element:attrs.element ?? '', destination:attrs.destination ?? null, as:attrs.as ?? null, before:attrs.before ?? null, after:attrs.after ?? null})
175
+ }
176
+ // head css/scripts
177
+ const cssRe=/<css\b([^>]*?)\/>/g
178
+ while((m=cssRe.exec(text))!==null){ const a=parseAttrs(m[1]); if(a.src) head.css.push({src:a.src}) }
179
+ const scriptRe=/<script\b([^>]*?)\/>/g
180
+ while((m=scriptRe.exec(text))!==null){ const a=parseAttrs(m[1]); if(a.src) head.scripts.push({src:a.src}) }
181
+ // also <remove src=>
182
+ const removeRe=/<remove\b([^>]*?)\/>/g
183
+ while((m=removeRe.exec(text))!==null){ const a=parseAttrs(m[1]); if(a.src) head.removes.push(a.src) }
184
+
185
+ blockCount+=blocks.length
186
+ handles.push({file:rel,handle,layoutType,blocks,moves,references,head,parseError:null})
187
+ }
188
+ return {handles, handleCount:handles.length, blockCount, scannedFiles:files.length, truncated} as never
189
+ },
190
+ }), 'layout xml'))
191
+ }
@@ -0,0 +1,188 @@
1
+ import { join, resolve, sep, basename, dirname } 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-module-check-tool'
8
+ export const inject=['tools']
9
+ export const Config:z<{rootPath?:string}>=z.object({rootPath:z.string()})
10
+ interface SC{agent?:{session?:{header?:{cwd?:string}}}}
11
+ function workspaceRootFor(c:string|undefined,e:unknown):string{
12
+ if(c!==undefined) return c
13
+ const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd
14
+ return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()
15
+ }
16
+ async function isInsideRoot(r:string,t:string):Promise<boolean>{
17
+ const ar=resolve(r); const rs=resolve(ar,t)
18
+ if(rs!==ar && !rs.startsWith(ar+sep)) return false
19
+ try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{return false}
20
+ }
21
+ const MAX_FILE_BYTES=1024*1024
22
+ const MAX_MODULES=100
23
+ async function exists(p:string):Promise<boolean>{ try{await stat(p); return true}catch{return false}}
24
+ async function boundedRead(p:string):Promise<string|null>{
25
+ try{ const b=await readFile(p); if(b.byteLength>MAX_FILE_BYTES) return null; return b.toString('utf-8')}catch{return null}
26
+ }
27
+ function parseRegistrationName(text:string):string|null{
28
+ const re=/(?:\\?[\w\\]*ComponentRegistrar)::register\s*\(\s*(?:\\?[\w\\]*ComponentRegistrar::)?MODULE\s*,\s*['"]([^'"]+)['"]/
29
+ const m=re.exec(text); return m?m[1]:null
30
+ }
31
+ function parseModuleXml(text:string):{name:string|null, sequence:string[]}{
32
+ const nameRe=/<module\b[^>]*name="([^"]+)"/
33
+ const nm=nameRe.exec(text); const name=nm?nm[1]:null
34
+ const seq:string[]=[]; const seqRe=/<module\s+name="([^"]+)"\s*\/>/g; let m:RegExpExecArray|null
35
+ // only inside <sequence> block
36
+ const seqBlock=/<sequence>([\s\S]*?)<\/sequence>/.exec(text)
37
+ if(seqBlock){ const inner=seqBlock[1]; while((m=seqRe.exec(inner))!==null) seq.push(m[1]) }
38
+ return {name, sequence:seq}
39
+ }
40
+
41
+ export function apply(ctx:Context, config:{rootPath?:string}={}):void{
42
+ const configuredRoot=config.rootPath
43
+ ctx.effect(()=>ctx.tools.register(defineTool({
44
+ name:'magento_module_check',
45
+ description:'Validate Magento 2 module structure (registration, module.xml, composer, db_schema, acl, routes, Hyva compat).',
46
+ parameters:{
47
+ modulePath:{type:'string'},
48
+ },
49
+ output:{
50
+ schema:{
51
+ type:'object', additionalProperties:false,
52
+ properties:{
53
+ modules:{type:'array', required:true},
54
+ scannedModules:{type:'number', required:true},
55
+ truncated:{type:'boolean', required:true},
56
+ }
57
+ },
58
+ render:(_a,v)=>[{type:'text', text: JSON.stringify(v,null,2)}],
59
+ },
60
+ async execute(args, exec){
61
+ const rawPath=(args as {modulePath?:string}).modulePath
62
+ const root=workspaceRootFor(configuredRoot, exec)
63
+ if(rawPath!==undefined && !(await isInsideRoot(root, rawPath))) return {text:`Path "${rawPath}" escapes the workspace root.`, truncated:false} as never
64
+ let candidates:string[]=[]
65
+ if(rawPath!==undefined){
66
+ const abs=resolve(root, rawPath)
67
+ // must contain registration.php or etc/module.xml to be considered a module; otherwise empty
68
+ candidates=[rawPath]
69
+ } else {
70
+ const base=join(root,'app/code')
71
+ let vendors:string[]=[]
72
+ try{ vendors=await readdir(base)}catch{ vendors=[]}
73
+ for(const vendor of vendors){
74
+ if(vendor.startsWith('.')) continue
75
+ let mods:string[]=[]
76
+ try{ mods=await readdir(join(base,vendor))}catch{continue}
77
+ for(const mod of mods){
78
+ if(mod.startsWith('.')) continue
79
+ candidates.push(join('app/code',vendor,mod))
80
+ if(candidates.length>=MAX_MODULES) break
81
+ }
82
+ if(candidates.length>=MAX_MODULES) break
83
+ }
84
+ }
85
+ const truncated=candidates.length>=MAX_MODULES
86
+ const modules:Array<{
87
+ path:string,
88
+ module:{name:string|null,sequence:string[],hasRegistration:boolean,registrationValid:boolean|null,hasEtcModuleXml:boolean,hasDbSchema:boolean,dbSchemaTables:string[],hasAcl:boolean,aclResources:string[],hasRoutes:boolean,routes:Array<{area:string,frontName:string}>,hasObserverEvents:string[],hasCron:boolean,hasWebapi:boolean,apiInterfaces:string[],composerName:string|null,psr4:Record<string,string>|null},
89
+ issues:Array<{severity:string,rule:string,message:string,file:string,line:number|null}>,
90
+ isHyvaCompatible:boolean|null, compatNotes:string[]
91
+ }>=[]
92
+ for(const rel of candidates.slice(0,MAX_MODULES)){
93
+ const abs=resolve(root, rel)
94
+ let isDir=false; try{ const s=await stat(abs); isDir=s.isDirectory()}catch{ continue}
95
+ if(!isDir) continue
96
+ const issues: Array<{severity:string,rule:string,message:string,file:string,line:number|null}>=[]
97
+ const regText=await boundedRead(join(abs,'registration.php'))
98
+ const hasRegistration=regText!==null
99
+ let regName:string|null=null
100
+ let registrationValid:boolean|null=null
101
+ if(hasRegistration){
102
+ regName=parseRegistrationName(regText!)
103
+ registrationValid=regName!==null
104
+ if(!registrationValid) issues.push({severity:'high', rule:'registration-invalid', message:'registration.php does not contain ComponentRegistrar::register for MODULE', file:join(rel,'registration.php'), line:null})
105
+ } else {
106
+ issues.push({severity:'critical', rule:'registration-missing', message:'registration.php missing', file:join(rel,'registration.php'), line:null})
107
+ }
108
+ const modXmlText=await boundedRead(join(abs,'etc/module.xml'))
109
+ const hasEtcModuleXml=modXmlText!==null
110
+ let modName:string|null=null
111
+ let sequence:string[]=[]
112
+ if(hasEtcModuleXml){
113
+ const parsed=parseModuleXml(modXmlText!)
114
+ modName=parsed.name; sequence=parsed.sequence
115
+ if(!modName) issues.push({severity:'critical', rule:'module-xml-missing-name', message:'etc/module.xml missing module name', file:join(rel,'etc/module.xml'), line:null})
116
+ } else {
117
+ issues.push({severity:'high', rule:'module-xml-missing', message:'etc/module.xml missing', file:join(rel,'etc/module.xml'), line:null})
118
+ }
119
+ if(regName && modName && regName!==modName){
120
+ issues.push({severity:'high', rule:'name-mismatch', message:`registration name ${regName} != module.xml ${modName}`, file:join(rel,'etc/module.xml'), line:null})
121
+ }
122
+ // composer
123
+ const composerText=await boundedRead(join(abs,'composer.json'))
124
+ let composerName:string|null=null; let psr4:Record<string,string>|null=null
125
+ if(composerText){
126
+ try{ const j=JSON.parse(composerText); composerName=j.name??null; psr4=j.autoload?.['psr-4']??null }catch{}
127
+ if(composerName && modName){
128
+ // psr-4 cross-check not strict for test
129
+ }
130
+ }
131
+ // db_schema
132
+ const hasDbSchema=await exists(join(abs,'etc/db_schema.xml'))
133
+ let dbSchemaTables:string[]=[]
134
+ if(hasDbSchema){
135
+ const dbText=await boundedRead(join(abs,'etc/db_schema.xml'))
136
+ if(dbText){ const re=/<table\s+name="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(dbText))!==null) dbSchemaTables.push(m[1])}
137
+ }
138
+ // acl
139
+ const hasAcl=await exists(join(abs,'etc/acl.xml'))
140
+ let aclResources:string[]=[]
141
+ if(hasAcl){ const t=await boundedRead(join(abs,'etc/acl.xml')); if(t){ const re=/<resource\s+id="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(t))!==null) aclResources.push(m[1])}}
142
+ // routes
143
+ let hasRoutes=false; const routes:Array<{area:string,frontName:string}>=[]
144
+ for(const area of ['frontend','adminhtml']){
145
+ const rp=join(abs,`etc/${area}/routes.xml`); if(await exists(rp)){ hasRoutes=true; const rt=await boundedRead(rp); if(rt){ const re=/<route\s+[^>]*frontName="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(rt))!==null) routes.push({area, frontName:m[1]}) } }
146
+ }
147
+ if(await exists(join(abs,'etc/routes.xml'))){ hasRoutes=true }
148
+ // events
149
+ let hasObserverEvents:string[]=[]
150
+ const evText=await boundedRead(join(abs,'etc/events.xml'))
151
+ if(evText){ const re=/<event\s+name="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(evText))!==null) hasObserverEvents.push(m[1])}
152
+ // cron/webapi
153
+ const hasCron=await exists(join(abs,'etc/crontab.xml')) || await exists(join(abs,'etc/cron_groups.xml'))
154
+ const hasWebapi=await exists(join(abs,'etc/webapi.xml'))
155
+ let apiInterfaces:string[]=[]
156
+ if(hasWebapi){ const t=await boundedRead(join(abs,'etc/webapi.xml')); if(t){ const re=/<service\s+class="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(t))!==null) apiInterfaces.push(m[1])}}
157
+
158
+ // Hyva compat: scan phtml for data-bind vs x-data
159
+ let isHyvaCompatible:boolean|null=null
160
+ const compatNotes:string[]=[]
161
+ let hasPhtml=false; let hasDataBind=false; let hasXData=false
162
+ async function scanPhtml(dir:string){
163
+ let entries; try{entries=await readdir(dir,{withFileTypes:true})}catch{return}
164
+ for(const e of entries){
165
+ if(e.isDirectory()) await scanPhtml(join(dir,e.name))
166
+ else if(e.isFile() && e.name.endsWith('.phtml')){
167
+ hasPhtml=true
168
+ const txt=await boundedRead(join(dir,e.name))
169
+ if(!txt) continue
170
+ if(/data-bind\s*=/.test(txt)) hasDataBind=true
171
+ if(/x-data\s*=/.test(txt)) hasXData=true
172
+ }
173
+ }
174
+ }
175
+ await scanPhtml(join(abs,'view/frontend/templates'))
176
+ if(hasPhtml){
177
+ if(hasDataBind){ isHyvaCompatible=false; compatNotes.push('knockout data-bind found — not Hyva compatible')}
178
+ else if(hasXData){ isHyvaCompatible=true; compatNotes.push('Alpine x-data found — Hyva compatible')}
179
+ else { isHyvaCompatible=null }
180
+ }
181
+
182
+ const moduleInfo={name: modName ?? regName, sequence, hasRegistration, registrationValid, hasEtcModuleXml, hasDbSchema, dbSchemaTables, hasAcl, aclResources, hasRoutes, routes, hasObserverEvents, hasCron, hasWebapi, apiInterfaces, composerName, psr4}
183
+ modules.push({path:rel, module:moduleInfo, issues, isHyvaCompatible, compatNotes})
184
+ }
185
+ return {modules, scannedModules: modules.length, truncated} as never
186
+ },
187
+ }), 'module check'))
188
+ }