@monoes/monodesign 1.2.18 → 1.2.22

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/README.md +3 -3
  2. package/cli/engine/design-system-checks.mjs +381 -0
  3. package/cli/engine/design-system-parse.mjs +433 -0
  4. package/cli/engine/design-system.mjs +20 -736
  5. package/cli/engine/detect-antipatterns-browser.js +12 -1
  6. package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
  7. package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
  8. package/cli/engine/engines/regex/detect-text.mjs +11 -523
  9. package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
  10. package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
  11. package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
  12. package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
  13. package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
  14. package/cli/engine/registry/antipatterns-provider.mjs +60 -0
  15. package/cli/engine/registry/antipatterns-quality.mjs +218 -0
  16. package/cli/engine/registry/antipatterns-slop.mjs +226 -0
  17. package/cli/engine/registry/antipatterns.mjs +7 -494
  18. package/cli/engine/rules/checks-background.mjs +155 -0
  19. package/cli/engine/rules/checks-css-values.mjs +137 -0
  20. package/cli/engine/rules/checks-dom-effects.mjs +243 -0
  21. package/cli/engine/rules/checks-dom.mjs +148 -0
  22. package/cli/engine/rules/checks-element.mjs +286 -0
  23. package/cli/engine/rules/checks-html.mjs +204 -0
  24. package/cli/engine/rules/checks-overflow.mjs +263 -0
  25. package/cli/engine/rules/checks-page.mjs +465 -0
  26. package/cli/engine/rules/checks-pure.mjs +453 -0
  27. package/cli/engine/rules/checks-quality.mjs +447 -0
  28. package/cli/engine/rules/checks-stylesheet.mjs +324 -0
  29. package/cli/engine/rules/checks.mjs +98 -2939
  30. package/cli/lib/monodesign-config-consent.mjs +93 -0
  31. package/cli/lib/monodesign-config-core.mjs +37 -0
  32. package/cli/lib/monodesign-config-detection.mjs +133 -0
  33. package/cli/lib/monodesign-config-filter.mjs +198 -0
  34. package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
  35. package/cli/lib/monodesign-config.mjs +21 -623
  36. package/package.json +2 -2
  37. package/skill/scripts/context-fields.mjs +65 -0
  38. package/skill/scripts/context-resolve.mjs +425 -0
  39. package/skill/scripts/context-update.mjs +151 -0
  40. package/skill/scripts/context-utils.mjs +68 -0
  41. package/skill/scripts/context-workspace.mjs +245 -0
  42. package/skill/scripts/context.mjs +17 -891
  43. package/skill/scripts/hook-admin-config.mjs +152 -0
  44. package/skill/scripts/hook-admin-ignore.mjs +131 -0
  45. package/skill/scripts/hook-admin-manifest.mjs +292 -0
  46. package/skill/scripts/hook-admin.mjs +8 -541
  47. package/skill/scripts/hook-before-edit-shell.mjs +184 -0
  48. package/skill/scripts/hook-before-edit.mjs +9 -165
  49. package/skill/scripts/hook-lib-cache.mjs +145 -0
  50. package/skill/scripts/hook-lib-config.mjs +292 -0
  51. package/skill/scripts/hook-lib-detector.mjs +72 -0
  52. package/skill/scripts/hook-lib-events.mjs +182 -0
  53. package/skill/scripts/hook-lib-findings.mjs +201 -0
  54. package/skill/scripts/hook-lib-ignore.mjs +205 -0
  55. package/skill/scripts/hook-lib-render.mjs +273 -0
  56. package/skill/scripts/hook-lib-run.mjs +281 -0
  57. package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
  58. package/skill/scripts/hook-lib.mjs +71 -1747
  59. package/skill/scripts/lib/design-parser-colors.mjs +182 -0
  60. package/skill/scripts/lib/design-parser-extract.mjs +333 -0
  61. package/skill/scripts/lib/design-parser-sections.mjs +214 -0
  62. package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
  63. package/skill/scripts/lib/design-parser.mjs +10 -783
  64. package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
  65. package/skill/scripts/live/manual-apply-compact.mjs +122 -0
  66. package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
  67. package/skill/scripts/live/manual-apply-log.mjs +179 -0
  68. package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
  69. package/skill/scripts/live/manual-apply.mjs +65 -640
  70. package/skill/scripts/live/svelte-component-accept.mjs +248 -0
  71. package/skill/scripts/live/svelte-component-css.mjs +230 -0
  72. package/skill/scripts/live/svelte-component-markup.mjs +104 -0
  73. package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
  74. package/skill/scripts/live/svelte-component-session.mjs +158 -0
  75. package/skill/scripts/live/svelte-component.mjs +32 -792
  76. package/skill/scripts/live-accept-mutate.mjs +152 -0
  77. package/skill/scripts/live-accept-parse.mjs +370 -0
  78. package/skill/scripts/live-accept-scrub.mjs +59 -0
  79. package/skill/scripts/live-accept-search.mjs +58 -0
  80. package/skill/scripts/live-accept.mjs +15 -631
  81. package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
  82. package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
  83. package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
  84. package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
  85. package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
  86. package/skill/scripts/live-commit-manual-edits.mjs +24 -881
  87. package/skill/scripts/live-copy-edit-agent.mjs +16 -551
  88. package/skill/scripts/live-copy-edit-compact.mjs +137 -0
  89. package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
  90. package/skill/scripts/live-copy-edit-runners.mjs +297 -0
  91. package/skill/scripts/live-inject-csp.mjs +132 -0
  92. package/skill/scripts/live-inject-files.mjs +229 -0
  93. package/skill/scripts/live-inject-tag.mjs +114 -0
  94. package/skill/scripts/live-inject.mjs +13 -440
  95. package/skill/scripts/live-server-poll.mjs +232 -0
  96. package/skill/scripts/live-server-routes.mjs +449 -0
  97. package/skill/scripts/live-server-state.mjs +361 -0
  98. package/skill/scripts/live-server.mjs +10 -946
  99. package/skill/scripts/live-wrap-css.mjs +132 -0
  100. package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
  101. package/skill/scripts/live-wrap-search.mjs +247 -0
  102. package/skill/scripts/live-wrap.mjs +21 -484
  103. package/skill/scripts/palette-seeds.mjs +401 -0
  104. package/skill/scripts/palette.mjs +1 -395
@@ -0,0 +1,292 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { extractPlatform, loadContext } from './context.mjs';
5
+ import { mergeIgnoreValues } from './hook-lib-ignore.mjs';
6
+ import { envProjectDir } from './hook-lib-events.mjs';
7
+ import { hasPathTraversal } from './hook-lib-scan-targets.mjs';
8
+
9
+ export const ENVELOPE_PREFIX = '[monodesign@1]';
10
+
11
+ export const ALLOWED_EXTS = new Set([
12
+ '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
13
+ '.css', '.scss', '.sass', '.less', '.ts', '.js',
14
+ ]);
15
+
16
+ export const ACK_EXTS = new Set([
17
+ '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
18
+ '.css', '.scss', '.sass', '.less',
19
+ ]);
20
+
21
+ // Hard-skip regex for sensitive files. Cannot be turned off via config.
22
+ // Match tokenized secret/credential filenames, not UI names such as
23
+ // CredentialForm.tsx, SecretPage.jsx, or secretary-dashboard.vue.
24
+ export const SENSITIVE_PATH = new RegExp([
25
+ String.raw`(?:^|[/\\])\.env(?:\.|$)`,
26
+ String.raw`(?:^|[/\\])\.git(?:[/\\]|$)`,
27
+ String.raw`(?:^|[/\\])id_rsa(?:$|[._-])[^/\\]*$`,
28
+ String.raw`(?:^|[/\\])[^/\\]*\.pem$`,
29
+ String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?(?:secret|secrets|credential|credentials)(?=[._-])[^/\\]*\.(?:json|ya?ml|toml|ini|conf|config|env|txt|key|cert|crt|pem|js|ts)$`,
30
+ ].join('|'), 'i');
31
+
32
+ // Hard-skip regex for generated, lock, minified, and build-output paths.
33
+ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;
34
+
35
+ // GENERATED_PATH checked against the path inside the project, so a project
36
+ // that lives under e.g. ~/.cache/ or /srv/build/ is still scanned. Paths
37
+ // outside the project root fall back to the absolute path.
38
+ export function isGeneratedPath(filePath, projectRoot) {
39
+ const rel = path.relative(path.resolve(projectRoot), path.resolve(filePath));
40
+ const inside = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
41
+ return GENERATED_PATH.test(inside ? path.sep + rel : filePath);
42
+ }
43
+
44
+ export const TRUTHY = /^(1|true|yes|on)$/i;
45
+
46
+ export const DEFAULT_CONFIG = Object.freeze({
47
+ enabled: true,
48
+ quiet: false,
49
+ auditLog: null,
50
+ designSystem: { enabled: true },
51
+ ignoreRules: [],
52
+ ignoreFiles: [],
53
+ ignoreValues: [],
54
+ extensions: [],
55
+ limits: { maxFindings: 5, maxChars: 8000 },
56
+ });
57
+
58
+ export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
59
+ '.monodesign/hook.cache.json',
60
+ '.monodesign/hook.pending.json',
61
+ '.monodesign/config.local.json',
62
+ ]);
63
+
64
+
65
+ export const EDIT_COUNT_THRESHOLD = 6;
66
+
67
+ export function truthy(value) {
68
+ return typeof value === 'string' && TRUTHY.test(value);
69
+ }
70
+
71
+ export function depthIsSet(value) {
72
+ if (value === undefined || value === null) return false;
73
+ const text = String(value).trim();
74
+ if (!text) return false;
75
+ if (TRUTHY.test(text)) return true;
76
+ return /^\d+$/.test(text) && Number(text) > 0;
77
+ }
78
+
79
+ export function safeReadJson(filePath) {
80
+ try {
81
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export function getConfigPath(cwd) {
88
+ return path.join(cwd, '.monodesign', 'config.json');
89
+ }
90
+
91
+ export function getLocalConfigPath(cwd) {
92
+ return path.join(cwd, '.monodesign', 'config.local.json');
93
+ }
94
+
95
+ export function getCachePath(cwd) {
96
+ return path.join(cwd, '.monodesign', 'hook.cache.json');
97
+ }
98
+
99
+ export function getPendingPath(cwd) {
100
+ return path.join(cwd, '.monodesign', 'hook.pending.json');
101
+ }
102
+
103
+ export function resolveProjectCwd(event, fallback = process.cwd()) {
104
+ return event?.cwd
105
+ || (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])
106
+ || envProjectDir(fallback)
107
+ || fallback;
108
+ }
109
+
110
+ function looksLikeProjectRoot(dir) {
111
+ return ['.git', 'package.json', '.monodesign'].some((marker) => {
112
+ try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
113
+ });
114
+ }
115
+
116
+ // Where `.monodesign/` (cache + config) lives for this event. Normally the
117
+ // session cwd, untouched. But when the agent was launched from an umbrella
118
+ // directory that is not itself a project (no .git, package.json, or
119
+ // .monodesign), key to the edited file's nearest project root instead, so a
120
+ // multi-project launch dir doesn't accumulate a shared cross-project cache
121
+ // (issue #305). Climbing stops at the home dir, falling back to the session
122
+ // cwd when no marker is found.
123
+ export function resolveCacheCwd(primaryFile, sessionCwd) {
124
+ const base = path.resolve(sessionCwd || process.cwd());
125
+ if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
126
+ if (looksLikeProjectRoot(base)) return base;
127
+ let dir;
128
+ try {
129
+ dir = path.dirname(path.resolve(primaryFile));
130
+ } catch {
131
+ return base;
132
+ }
133
+ const home = path.resolve(os.homedir());
134
+ while (true) {
135
+ if (dir === home) return base;
136
+ if (looksLikeProjectRoot(dir)) return dir;
137
+ const parent = path.dirname(dir);
138
+ if (parent === dir) return base;
139
+ dir = parent;
140
+ }
141
+ }
142
+
143
+ // The detector's rules are web rules (HTML/CSS shapes), but a React Native or
144
+ // Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
145
+ // .js), so without this gate every native screen edit would draw web-shaped
146
+ // findings that contradict the native platform references. PRODUCT.md's
147
+ // `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
148
+ // the scan entirely. Resolution goes through loadContext so the hook reads the
149
+ // same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
150
+ export function resolveProjectPlatform(cwd) {
151
+ try {
152
+ const ctx = loadContext(cwd);
153
+ return extractPlatform(ctx?.product);
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ export function isNativePlatform(platform) {
160
+ return platform === 'ios' || platform === 'android' || platform === 'adaptive';
161
+ }
162
+
163
+ export function readConfig(cwd) {
164
+ const config = cloneDefaultConfig();
165
+ // Hook runtime settings live under `hook`; detector filters live under
166
+ // `detector`. Back-compat: older configs stored detector filters in `hook`,
167
+ // so read those first and let canonical `detector` settings win.
168
+ for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
169
+ const raw = safeReadJson(filePath);
170
+ applyConfigSource(config, hookSection(raw));
171
+ applyDetectorConfigSource(config, detectorSection(raw));
172
+ }
173
+ return config;
174
+ }
175
+
176
+ // The hook settings subtree of a unified config.json / config.local.json.
177
+ function hookSection(raw) {
178
+ if (!raw || typeof raw !== 'object') return null;
179
+ return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
180
+ }
181
+
182
+ function detectorSection(raw) {
183
+ if (!raw || typeof raw !== 'object') return null;
184
+ return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
185
+ }
186
+
187
+ function numberOr(value, fallback) {
188
+ return Number.isFinite(value) && value > 0 ? value : fallback;
189
+ }
190
+
191
+ function cloneDefaultConfig() {
192
+ return {
193
+ ...DEFAULT_CONFIG,
194
+ ignoreRules: [],
195
+ ignoreFiles: [],
196
+ ignoreValues: [],
197
+ extensions: [],
198
+ designSystem: { ...DEFAULT_CONFIG.designSystem },
199
+ limits: { ...DEFAULT_CONFIG.limits },
200
+ };
201
+ }
202
+
203
+ function applyDetectorConfigSource(config, raw) {
204
+ if (!raw || typeof raw !== 'object') return config;
205
+ if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
206
+ config.designSystem = {
207
+ ...config.designSystem,
208
+ enabled: raw.designSystem.enabled !== false,
209
+ };
210
+ }
211
+ if (Array.isArray(raw.ignoreRules)) {
212
+ config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
213
+ }
214
+ if (Array.isArray(raw.ignoreFiles)) {
215
+ config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
216
+ }
217
+ if (Array.isArray(raw.ignoreValues)) {
218
+ config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
219
+ }
220
+ if (Array.isArray(raw.extensions)) {
221
+ config.extensions = mergeExtensions(config.extensions, raw.extensions);
222
+ }
223
+ return config;
224
+ }
225
+
226
+ // Extra scanned extensions from `detector.extensions` config. Entries are
227
+ // `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
228
+ // for server-side templates) or bare strings as shorthand. Extensions are
229
+ // matched against the end of the filename, not path.extname, so double
230
+ // extensions like `.blade.php` and `.html.erb` work (issue #316).
231
+ function normalizeExtensionEntries(entries) {
232
+ if (!Array.isArray(entries)) return [];
233
+ const out = [];
234
+ for (const entry of entries) {
235
+ const raw = typeof entry === 'string' ? entry : entry?.ext;
236
+ if (typeof raw !== 'string') continue;
237
+ let ext = raw.trim().toLowerCase();
238
+ if (!ext) continue;
239
+ if (!ext.startsWith('.')) ext = `.${ext}`;
240
+ const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
241
+ out.push({ ext, engine });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function mergeExtensions(existing, incoming) {
247
+ const map = new Map();
248
+ for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
249
+ for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
250
+ return Array.from(map.values());
251
+ }
252
+
253
+ export function matchConfiguredExtension(filePath, extensions) {
254
+ if (!Array.isArray(extensions) || extensions.length === 0) return null;
255
+ const name = path.basename(String(filePath || '')).toLowerCase();
256
+ if (!name) return null;
257
+ // The longest matching suffix wins, so `.blade.php` beats a broader `.php`
258
+ // entry regardless of config order.
259
+ let best = null;
260
+ for (const entry of normalizeExtensionEntries(extensions)) {
261
+ if (name.length > entry.ext.length && name.endsWith(entry.ext)
262
+ && (!best || entry.ext.length > best.ext.length)) {
263
+ best = entry;
264
+ }
265
+ }
266
+ return best;
267
+ }
268
+
269
+ function applyConfigSource(config, raw) {
270
+ if (!raw || typeof raw !== 'object') return config;
271
+ if (Object.hasOwn(raw, 'enabled')) {
272
+ config.enabled = raw.enabled !== false;
273
+ }
274
+ if (Object.hasOwn(raw, 'quiet')) {
275
+ config.quiet = raw.quiet === true;
276
+ }
277
+ if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
278
+ config.auditLog = raw.auditLog.trim();
279
+ }
280
+ applyDetectorConfigSource(config, raw);
281
+ if (raw.limits && typeof raw.limits === 'object') {
282
+ config.limits = {
283
+ maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
284
+ maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
285
+ };
286
+ }
287
+ return config;
288
+ }
289
+
290
+ export function uniqueStrings(values) {
291
+ return Array.from(new Set(values.map(String)));
292
+ }
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { pathToFileURL, fileURLToPath } from 'node:url';
5
+ import { readConfig } from './hook-lib-config.mjs';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ export function writeAuditLog(env, entry, cwd = process.cwd()) {
11
+ // The event's project root (entry.cwd) when present, else the passed cwd. Both
12
+ // config reads and relative log paths resolve against this, since the hook
13
+ // process cwd can differ from the project being edited.
14
+ const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
15
+ // Env wins; otherwise fall back to the unified config's hook.auditLog path.
16
+ let target = env?.MONODESIGN_HOOK_LOG;
17
+ if (!target || typeof target !== 'string') {
18
+ try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
19
+ }
20
+ if (!target || typeof target !== 'string') return false;
21
+ try {
22
+ let expanded;
23
+ if (target.startsWith('~/')) {
24
+ expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
25
+ } else if (path.isAbsolute(target)) {
26
+ expanded = target;
27
+ } else {
28
+ expanded = path.resolve(baseCwd, target);
29
+ }
30
+ fs.mkdirSync(path.dirname(expanded), { recursive: true });
31
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
32
+ fs.appendFileSync(expanded, line);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ // Packaged fallback: resolve the engine from an installed @monoes/monodesign.
40
+ function packagedDetectorPath() {
41
+ try {
42
+ return createRequire(import.meta.url).resolve('@monoes/monodesign/engine');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ const DETECTOR_CANDIDATES = [
49
+ path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
50
+ path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
51
+ path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
52
+ packagedDetectorPath(),
53
+ ].filter(Boolean);
54
+
55
+ let detectorCache = null;
56
+ export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
57
+ if (detectorCache) return detectorCache;
58
+ const found = candidates.find((c) => fs.existsSync(c));
59
+ if (!found) return null;
60
+ const mod = await import(pathToFileURL(found));
61
+ detectorCache = {
62
+ detectText: mod.detectText,
63
+ detectHtml: mod.detectHtml,
64
+ loadDesignSystemForCwd: mod.loadDesignSystemForCwd,
65
+ };
66
+ return detectorCache;
67
+ }
68
+
69
+ // For tests: allow injecting a detector implementation.
70
+ export function setDetectorForTesting(impl) {
71
+ detectorCache = impl;
72
+ }
@@ -0,0 +1,182 @@
1
+ import path from 'node:path';
2
+
3
+ // Codex `apply_patch` exposes the raw patch in `tool_input.command`, not
4
+ // `tool_input.file_path`. Claude Code may send both; parse the patch body
5
+ // so we can scan the file(s) the tool actually touched.
6
+ // https://developers.openai.com/codex/hooks#posttooluse
7
+ const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
8
+
9
+ export function parseApplyPatchPaths(command, projectCwd) {
10
+ if (!command || typeof command !== 'string') return [];
11
+ const out = [];
12
+ for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {
13
+ let p = (m[1] || '').trim();
14
+ if (!p) continue;
15
+ if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
16
+ out.push(p);
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export function resolveTargetFiles(event, projectCwd) {
22
+ const ti = event?.tool_input;
23
+ const out = [];
24
+ const add = (filePath) => {
25
+ if (typeof filePath !== 'string' || !filePath) return;
26
+ if (!out.includes(filePath)) out.push(filePath);
27
+ };
28
+
29
+ if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {
30
+ for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);
31
+ }
32
+ if (ti && typeof ti.file_path === 'string' && ti.file_path) {
33
+ add(ti.file_path);
34
+ }
35
+ // Cursor Write / StrReplace use `path`, not `file_path`.
36
+ if (ti && typeof ti.path === 'string' && ti.path) {
37
+ add(ti.path);
38
+ }
39
+ if (typeof event?.file_path === 'string' && event.file_path) {
40
+ add(event.file_path);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ export function resolveHarness(env = {}, event = null) {
46
+ const explicit = env?.MONODESIGN_HOOK_HARNESS;
47
+ if (explicit === 'cursor') return 'cursor';
48
+ if (explicit === 'github') return 'github';
49
+ if (explicit === 'claude' || explicit === 'codex') return 'claude';
50
+ // GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
51
+ // has no `tool_name`/`tool_input`. That shape is the discriminator.
52
+ if (event && typeof event === 'object'
53
+ && (typeof event.toolName === 'string' || event.toolArgs !== undefined)
54
+ && event.tool_name === undefined && event.tool_input === undefined) {
55
+ return 'github';
56
+ }
57
+ if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
58
+ return 'claude';
59
+ }
60
+
61
+ // GitHub Copilot's postToolUse payload is
62
+ // { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
63
+ // mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
64
+ // `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
65
+ // JSON *string* (double-encoded) carrying the file under `path`, e.g.
66
+ // "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
67
+ // while `apply_patch` sends a raw OpenAI-format patch string (handled below in
68
+ // normalizeGitHubEvent). The detector reads the file from disk after the tool
69
+ // ran, so only the path (not the proposed content) is needed here.
70
+ export function parseGitHubToolArgs(toolArgs) {
71
+ if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
72
+ if (typeof toolArgs === 'string' && toolArgs.trim()) {
73
+ try {
74
+ const parsed = JSON.parse(toolArgs);
75
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
76
+ } catch {
77
+ return {};
78
+ }
79
+ }
80
+ return {};
81
+ }
82
+
83
+ // Copilot's `apply_patch` tool (used by interactive sessions and the cloud
84
+ // agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
85
+ // *** Begin Patch
86
+ // *** Add File: /abs/app.css
87
+ // +body { ... }
88
+ // *** End Patch
89
+ // The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
90
+ // JSON string with the path under `path`. Both must map onto the internal shape.
91
+ const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
92
+
93
+ function looksLikeApplyPatch(rawArgs) {
94
+ if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
95
+ // Guard against an edit/create payload whose edited *content* happens to
96
+ // contain patch markers: that payload is a JSON object string, whereas a real
97
+ // apply_patch payload is a raw patch string that does not parse as JSON. Only
98
+ // treat non-JSON-object strings as apply_patch so edit events still get their
99
+ // `path` extracted.
100
+ try {
101
+ const parsed = JSON.parse(rawArgs);
102
+ if (parsed && typeof parsed === 'object') return false;
103
+ } catch { /* not JSON → genuine raw patch */ }
104
+ return true;
105
+ }
106
+
107
+ function applyPatchText(rawArgs) {
108
+ if (typeof rawArgs === 'string') {
109
+ if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
110
+ // Defensive: a future Copilot build might JSON-wrap the patch.
111
+ const parsed = parseGitHubToolArgs(rawArgs);
112
+ return parsed.patch || parsed.input || parsed.command || '';
113
+ }
114
+ if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
115
+ return rawArgs.patch || rawArgs.input || rawArgs.command || '';
116
+ }
117
+ return '';
118
+ }
119
+
120
+ function normalizeGitHubEvent(event, projectCwd) {
121
+ const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
122
+ const sessionId = event.sessionId || event.session_id || 'unknown';
123
+ const toolName = event.toolName || event.tool_name || null;
124
+ const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
125
+ const rawArgs = event.toolArgs;
126
+
127
+ let normalizedToolName = toolName;
128
+ if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
129
+ // resolveTargetFiles() reads the touched paths from tool_input.command when
130
+ // tool_name is 'apply_patch', so normalize the name even if a future build
131
+ // sends the patch under a different tool label.
132
+ const patch = applyPatchText(rawArgs);
133
+ if (patch) {
134
+ toolInput.command = patch;
135
+ normalizedToolName = 'apply_patch';
136
+ }
137
+ } else {
138
+ const args = parseGitHubToolArgs(rawArgs);
139
+ const filePath = args.path || args.file_path || args.filePath || args.target_file;
140
+ if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
141
+ }
142
+
143
+ return {
144
+ ...event,
145
+ cwd,
146
+ session_id: sessionId,
147
+ tool_name: normalizedToolName,
148
+ tool_input: toolInput,
149
+ };
150
+ }
151
+
152
+ export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
153
+ if (!event || typeof event !== 'object') return event;
154
+ if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
155
+ if (harness !== 'cursor') return event;
156
+
157
+ const cwd = event.cwd
158
+ || (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
159
+ || envProjectDir(projectCwd)
160
+ || projectCwd;
161
+ const sessionId = event.session_id || event.conversation_id || 'unknown';
162
+
163
+ const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
164
+ const filePath = ti.file_path || ti.path || event.file_path;
165
+ if (filePath) {
166
+ return {
167
+ ...event,
168
+ cwd,
169
+ session_id: sessionId,
170
+ tool_input: { ...ti, file_path: filePath },
171
+ };
172
+ }
173
+
174
+ return { ...event, cwd, session_id: sessionId };
175
+ }
176
+
177
+ export function envProjectDir(fallback) {
178
+ if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {
179
+ return process.env.CURSOR_PROJECT_DIR;
180
+ }
181
+ return fallback;
182
+ }