@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,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
+ }
@@ -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
+ }