nothumanallowed 14.5.4 → 14.5.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "14.5.4",
3
+ "version": "14.5.6",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,8 @@
59
59
  "prebuild": "cd ../nha-ui && pnpm build"
60
60
  },
61
61
  "dependencies": {
62
+ "acorn": "^8.16.0",
63
+ "acorn-jsx": "^5.3.2",
62
64
  "imapflow": "^1.3.3",
63
65
  "mailparser": "^3.9.8",
64
66
  "ws": "^8.18.0"
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '14.5.4';
8
+ export const VERSION = '14.5.6';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -21,6 +21,8 @@ import { sendJSON, sendError, parseBody, sendSSE } from '../index.mjs';
21
21
  import { loadConfig } from '../../config.mjs';
22
22
  import { callLLM, callLLMStream, fixQwen3BPE } from '../../services/llm.mjs';
23
23
  import { NHA_DIR } from '../../constants.mjs';
24
+ import * as acorn from 'acorn';
25
+ import acornJsx from 'acorn-jsx';
24
26
 
25
27
  const execAsync = promisify(exec);
26
28
 
@@ -42,7 +44,10 @@ class SandboxManager {
42
44
  }
43
45
 
44
46
  isRunning() {
45
- return this._sandbox !== null && this._sandbox.proc && !this._sandbox.proc.killed;
47
+ if (!this._sandbox || !this._sandbox.proc) return false;
48
+ if (this._sandbox.proc.killed) { this._sandbox = null; return false; }
49
+ // Verify the process is actually alive (not zombie)
50
+ try { process.kill(this._sandbox.proc.pid, 0); return true; } catch { this._sandbox = null; return false; }
46
51
  }
47
52
 
48
53
  status() {
@@ -55,17 +60,43 @@ class SandboxManager {
55
60
  get port() { return this.isRunning() ? this._sandbox.port : null; }
56
61
 
57
62
  async stop() {
58
- if (!this.isRunning()) return;
59
- const { proc } = this._sandbox;
63
+ if (!this._sandbox) return;
64
+ const { proc, port } = this._sandbox;
60
65
  this._sandbox = null;
66
+
67
+ // 1. Kill the entire process group (parent + all children)
61
68
  try {
62
- proc.kill('SIGTERM');
63
- // Give it a grace period then SIGKILL
64
- await new Promise((resolve) => {
65
- const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} resolve(); }, 4000);
66
- proc.once('exit', () => { clearTimeout(t); resolve(); });
67
- });
69
+ if (proc.pid) {
70
+ // Kill the process group negative PID kills all processes in the group
71
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch {}
72
+ // Grace period, then SIGKILL the group
73
+ await new Promise((resolve) => {
74
+ const t = setTimeout(() => {
75
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch {}
76
+ try { proc.kill('SIGKILL'); } catch {}
77
+ resolve();
78
+ }, 1500);
79
+ proc.once('exit', () => { clearTimeout(t); resolve(); });
80
+ });
81
+ } else {
82
+ try { proc.kill('SIGKILL'); } catch {}
83
+ }
68
84
  } catch {}
85
+
86
+ // 2. Force-kill any orphan processes still holding the port
87
+ if (port) {
88
+ try {
89
+ if (process.platform === 'win32') {
90
+ await execAsync(`for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do taskkill /F /PID %a`, { timeout: 3000 });
91
+ } else {
92
+ const { stdout } = await execAsync(`lsof -ti:${port} 2>/dev/null || true`, { timeout: 2000 });
93
+ const pids = stdout.trim().split(/\s+/).filter(Boolean);
94
+ for (const pid of pids) { try { process.kill(parseInt(pid), 'SIGKILL'); } catch {} }
95
+ }
96
+ } catch {}
97
+ // Wait for OS to release the port
98
+ await new Promise((r) => setTimeout(r, 500));
99
+ }
69
100
  }
70
101
 
71
102
  /**
@@ -155,7 +186,7 @@ class SandboxManager {
155
186
  NODE_ENV: 'development',
156
187
  NHA_SANDBOX: '1',
157
188
  },
158
- detached: false,
189
+ detached: true,
159
190
  stdio: ['ignore', 'pipe', 'pipe'],
160
191
  });
161
192
 
@@ -947,7 +978,7 @@ RULES:
947
978
  const proc = spawn('node', [patchedEntry], {
948
979
  cwd: projectDir,
949
980
  env: { ...process.env, PORT: String(port), NODE_ENV: 'development', NHA_SANDBOX: '1' },
950
- detached: false, stdio: ['ignore', 'pipe', 'pipe'],
981
+ detached: true, stdio: ['ignore', 'pipe', 'pipe'],
951
982
  });
952
983
  sandbox._sandbox = { proc, port, projectName, startedAt: new Date(), healthy: false };
953
984
  let sandboxStderr = '';
@@ -1089,7 +1120,7 @@ RULES:
1089
1120
  const proc = spawn('node', [patchedEntry], {
1090
1121
  cwd: projectDir,
1091
1122
  env: { ...process.env, PORT: String(port), NODE_ENV: 'development', NHA_SANDBOX: '1' },
1092
- detached: false,
1123
+ detached: true,
1093
1124
  stdio: ['ignore', 'pipe', 'pipe'],
1094
1125
  });
1095
1126
  sandbox._sandbox = { proc, port, projectName, startedAt: new Date(), healthy: false };
@@ -1889,6 +1920,386 @@ export function register(router) {
1889
1920
  });
1890
1921
 
1891
1922
  // ── Diagnostics (lint) — returns errors/warnings for a file ───────────────
1923
+ // ── Advanced Linter — AST-based diagnostics (acorn + scope analysis) ─────────
1924
+
1925
+ const JsxParser = acorn.Parser.extend(acornJsx());
1926
+ const JS_BUILTINS = new Set([
1927
+ 'undefined','NaN','Infinity','globalThis','eval','isFinite','isNaN','parseFloat','parseInt',
1928
+ 'decodeURI','decodeURIComponent','encodeURI','encodeURIComponent',
1929
+ 'Array','ArrayBuffer','BigInt','BigInt64Array','BigUint64Array','Boolean','DataView','Date',
1930
+ 'Error','EvalError','FinalizationRegistry','Float32Array','Float64Array','Function',
1931
+ 'Int8Array','Int16Array','Int32Array','JSON','Map','Math','Number','Object','Promise',
1932
+ 'Proxy','RangeError','ReferenceError','Reflect','RegExp','Set','SharedArrayBuffer',
1933
+ 'String','Symbol','SyntaxError','TypeError','URIError','Uint8Array','Uint8ClampedArray',
1934
+ 'Uint16Array','Uint32Array','WeakMap','WeakRef','WeakSet',
1935
+ 'console','setTimeout','setInterval','clearTimeout','clearInterval','queueMicrotask',
1936
+ 'atob','btoa','fetch','structuredClone','performance','crypto','navigator','location',
1937
+ 'window','document','self','global','process','require','module','exports','__dirname','__filename',
1938
+ 'Buffer','URL','URLSearchParams','TextEncoder','TextDecoder','AbortController','AbortSignal',
1939
+ 'Event','EventTarget','CustomEvent','FormData','Headers','Request','Response',
1940
+ 'ReadableStream','WritableStream','TransformStream','Blob','File','FileReader',
1941
+ 'WebSocket','Worker','SharedWorker','BroadcastChannel','MessageChannel','MessagePort',
1942
+ 'Intl','alert','confirm','prompt','requestAnimationFrame','cancelAnimationFrame',
1943
+ 'MutationObserver','ResizeObserver','IntersectionObserver','PerformanceObserver',
1944
+ 'HTMLElement','Element','Node','NodeList','DocumentFragment',
1945
+ 'localStorage','sessionStorage','history','screen','CSS','CSSStyleSheet',
1946
+ 'XMLHttpRequest','Image','Audio','Video','MediaSource','SourceBuffer',
1947
+ 'Map','Set','WeakMap','WeakSet','Proxy','Reflect',
1948
+ 'arguments','this','super','import','export',
1949
+ ]);
1950
+ const REACT_GLOBALS = new Set([
1951
+ 'React','useState','useEffect','useRef','useCallback','useMemo','useContext',
1952
+ 'useReducer','useLayoutEffect','useImperativeHandle','useDebugValue','useTransition',
1953
+ 'useDeferredValue','useId','useSyncExternalStore','useInsertionEffect',
1954
+ 'createContext','createRef','forwardRef','lazy','memo','startTransition',
1955
+ 'Component','PureComponent','Fragment','StrictMode','Suspense','Profiler',
1956
+ 'createElement','cloneElement','isValidElement','Children',
1957
+ 'jsx','jsxs','jsxDEV',
1958
+ ]);
1959
+ const NODE_MODULES = new Set([
1960
+ 'fs','path','os','http','https','url','util','stream','events','crypto','child_process',
1961
+ 'net','dgram','dns','tls','zlib','readline','cluster','worker_threads','perf_hooks',
1962
+ 'assert','buffer','querystring','string_decoder','timers','v8','vm','inspector',
1963
+ ]);
1964
+
1965
+ function lintJS(content, relPath, projectName) {
1966
+ const diagnostics = [];
1967
+ const ext = relPath.split('.').pop()?.toLowerCase();
1968
+ const isJsx = ext === 'jsx' || ext === 'tsx';
1969
+
1970
+ // 1. Parse with acorn (real AST)
1971
+ let ast;
1972
+ try {
1973
+ ast = JsxParser.parse(content, {
1974
+ ecmaVersion: 'latest',
1975
+ sourceType: 'module',
1976
+ locations: true,
1977
+ allowImportExportEverywhere: true,
1978
+ allowReturnOutsideFunction: true,
1979
+ allowHashBang: true,
1980
+ });
1981
+ } catch (e) {
1982
+ diagnostics.push({
1983
+ from: { line: e.loc?.line || 1, col: e.loc?.column || 0 },
1984
+ severity: 'error',
1985
+ message: e.message.replace(/\(\d+:\d+\)$/, '').trim(),
1986
+ });
1987
+ return diagnostics;
1988
+ }
1989
+
1990
+ // 2. Scope analysis — collect declarations and references
1991
+ const declared = new Set();
1992
+ const imported = new Set();
1993
+ const importSources = [];
1994
+ const references = []; // { name, loc }
1995
+ const exportedNames = new Set();
1996
+
1997
+ function walkNode(node, scope) {
1998
+ if (!node || typeof node !== 'object') return;
1999
+ if (Array.isArray(node)) { node.forEach(n => walkNode(n, scope)); return; }
2000
+ if (!node.type) return;
2001
+
2002
+ const localScope = new Set(scope);
2003
+
2004
+ switch (node.type) {
2005
+ case 'VariableDeclaration':
2006
+ for (const decl of node.declarations) {
2007
+ collectPattern(decl.id, localScope);
2008
+ if (decl.init) walkNode(decl.init, localScope);
2009
+ }
2010
+ // Walk rest of body with the declared vars
2011
+ return;
2012
+
2013
+ case 'FunctionDeclaration':
2014
+ if (node.id) localScope.add(node.id.name);
2015
+ declared.add(node.id?.name);
2016
+ const fnScope = new Set(localScope);
2017
+ for (const p of node.params) collectPattern(p, fnScope);
2018
+ walkNode(node.body, fnScope);
2019
+ return;
2020
+
2021
+ case 'FunctionExpression':
2022
+ case 'ArrowFunctionExpression': {
2023
+ const arrowScope = new Set(localScope);
2024
+ if (node.id) arrowScope.add(node.id.name);
2025
+ for (const p of node.params) collectPattern(p, arrowScope);
2026
+ walkNode(node.body, arrowScope);
2027
+ return;
2028
+ }
2029
+
2030
+ case 'ClassDeclaration':
2031
+ case 'ClassExpression':
2032
+ if (node.id) { localScope.add(node.id.name); declared.add(node.id.name); }
2033
+ if (node.superClass) walkNode(node.superClass, localScope);
2034
+ walkNode(node.body, localScope);
2035
+ return;
2036
+
2037
+ case 'ImportDeclaration':
2038
+ for (const spec of node.specifiers) {
2039
+ imported.add(spec.local.name);
2040
+ localScope.add(spec.local.name);
2041
+ }
2042
+ importSources.push({ source: node.source.value, loc: node.loc });
2043
+ return;
2044
+
2045
+ case 'ExportNamedDeclaration':
2046
+ if (node.declaration) walkNode(node.declaration, localScope);
2047
+ if (node.specifiers) for (const s of node.specifiers) exportedNames.add(s.exported.name || s.exported.value);
2048
+ return;
2049
+
2050
+ case 'ExportDefaultDeclaration':
2051
+ walkNode(node.declaration, localScope);
2052
+ return;
2053
+
2054
+ case 'Identifier':
2055
+ if (!localScope.has(node.name) && !declared.has(node.name) && !imported.has(node.name)) {
2056
+ references.push({ name: node.name, loc: node.loc });
2057
+ }
2058
+ return;
2059
+
2060
+ case 'MemberExpression':
2061
+ walkNode(node.object, localScope);
2062
+ // Don't walk computed property as reference
2063
+ if (node.computed) walkNode(node.property, localScope);
2064
+ return;
2065
+
2066
+ case 'Property':
2067
+ case 'MethodDefinition':
2068
+ // Don't treat keys as references
2069
+ if (node.computed) walkNode(node.key, localScope);
2070
+ walkNode(node.value, localScope);
2071
+ return;
2072
+
2073
+ case 'CatchClause':
2074
+ const catchScope = new Set(localScope);
2075
+ if (node.param) collectPattern(node.param, catchScope);
2076
+ walkNode(node.body, catchScope);
2077
+ return;
2078
+
2079
+ case 'ForInStatement':
2080
+ case 'ForOfStatement': {
2081
+ const forScope = new Set(localScope);
2082
+ if (node.left.type === 'VariableDeclaration') {
2083
+ for (const d of node.left.declarations) collectPattern(d.id, forScope);
2084
+ } else { walkNode(node.left, forScope); }
2085
+ walkNode(node.right, forScope);
2086
+ walkNode(node.body, forScope);
2087
+ return;
2088
+ }
2089
+
2090
+ case 'ForStatement': {
2091
+ const forScope2 = new Set(localScope);
2092
+ if (node.init?.type === 'VariableDeclaration') {
2093
+ for (const d of node.init.declarations) collectPattern(d.id, forScope2);
2094
+ } else if (node.init) { walkNode(node.init, forScope2); }
2095
+ if (node.test) walkNode(node.test, forScope2);
2096
+ if (node.update) walkNode(node.update, forScope2);
2097
+ walkNode(node.body, forScope2);
2098
+ return;
2099
+ }
2100
+
2101
+ case 'BlockStatement':
2102
+ case 'Program': {
2103
+ const blockScope = new Set(localScope);
2104
+ // Pre-scan for hoisted declarations
2105
+ if (node.body) {
2106
+ for (const stmt of node.body) {
2107
+ if (stmt.type === 'FunctionDeclaration' && stmt.id) blockScope.add(stmt.id.name);
2108
+ if (stmt.type === 'VariableDeclaration') {
2109
+ for (const d of stmt.declarations) collectPattern(d.id, blockScope);
2110
+ }
2111
+ if (stmt.type === 'ClassDeclaration' && stmt.id) blockScope.add(stmt.id.name);
2112
+ if (stmt.type === 'ImportDeclaration') {
2113
+ for (const s of stmt.specifiers) { blockScope.add(s.local.name); imported.add(s.local.name); }
2114
+ }
2115
+ }
2116
+ }
2117
+ if (node.body) for (const stmt of node.body) walkNode(stmt, blockScope);
2118
+ return;
2119
+ }
2120
+
2121
+ case 'LabeledStatement':
2122
+ walkNode(node.body, localScope);
2123
+ return;
2124
+
2125
+ case 'JSXIdentifier':
2126
+ // JSX component names (capitalized) are references
2127
+ if (/^[A-Z]/.test(node.name) && !localScope.has(node.name) && !declared.has(node.name) && !imported.has(node.name)) {
2128
+ references.push({ name: node.name, loc: node.loc });
2129
+ }
2130
+ return;
2131
+
2132
+ case 'JSXMemberExpression':
2133
+ walkNode(node.object, localScope);
2134
+ return;
2135
+ }
2136
+
2137
+ // Generic walk for other node types
2138
+ for (const key of Object.keys(node)) {
2139
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'type' || key === 'raw' || key === 'value' || key === 'name' || key === 'operator' || key === 'prefix' || key === 'sourceType') continue;
2140
+ const val = node[key];
2141
+ if (val && typeof val === 'object') walkNode(val, localScope);
2142
+ }
2143
+ }
2144
+
2145
+ function collectPattern(pattern, scope) {
2146
+ if (!pattern) return;
2147
+ if (pattern.type === 'Identifier') { scope.add(pattern.name); declared.add(pattern.name); }
2148
+ else if (pattern.type === 'ObjectPattern') { for (const p of pattern.properties) collectPattern(p.value || p.argument, scope); }
2149
+ else if (pattern.type === 'ArrayPattern') { for (const e of pattern.elements) if (e) collectPattern(e, scope); }
2150
+ else if (pattern.type === 'RestElement') collectPattern(pattern.argument, scope);
2151
+ else if (pattern.type === 'AssignmentPattern') collectPattern(pattern.left, scope);
2152
+ }
2153
+
2154
+ walkNode(ast, new Set());
2155
+
2156
+ // 3. Report undefined references (excluding builtins and React globals)
2157
+ const allKnown = new Set([...declared, ...imported, ...JS_BUILTINS]);
2158
+ if (isJsx || content.includes('from \'react\'') || content.includes('from "react"')) {
2159
+ for (const g of REACT_GLOBALS) allKnown.add(g);
2160
+ }
2161
+ for (const ref of references) {
2162
+ if (allKnown.has(ref.name)) continue;
2163
+ // Skip single-letter vars (often from minified/short code)
2164
+ if (ref.name.length === 1) continue;
2165
+ // Skip common DOM event handler names
2166
+ if (/^on[A-Z]/.test(ref.name)) continue;
2167
+ diagnostics.push({
2168
+ from: { line: ref.loc.start.line, col: ref.loc.start.column },
2169
+ severity: 'warning',
2170
+ message: `'${ref.name}' is not defined`,
2171
+ });
2172
+ }
2173
+
2174
+ // 4. Check import sources — verify local files exist
2175
+ for (const imp of importSources) {
2176
+ const src = imp.source;
2177
+ if (src.startsWith('.') || src.startsWith('/')) {
2178
+ const dir = path.dirname(relPath);
2179
+ const candidates = [
2180
+ path.join(dir, src),
2181
+ path.join(dir, src + '.js'),
2182
+ path.join(dir, src + '.mjs'),
2183
+ path.join(dir, src + '.jsx'),
2184
+ path.join(dir, src + '/index.js'),
2185
+ path.join(dir, src + '/index.mjs'),
2186
+ ].map(p => p.replace(/\\/g, '/'));
2187
+ const found = candidates.some(c => ProjectStore.readFile(projectName, c) !== null);
2188
+ if (!found) {
2189
+ diagnostics.push({
2190
+ from: { line: imp.loc.start.line, col: imp.loc.start.column },
2191
+ severity: 'error',
2192
+ message: `Cannot resolve import '${src}'`,
2193
+ });
2194
+ }
2195
+ }
2196
+ }
2197
+
2198
+ return diagnostics;
2199
+ }
2200
+
2201
+ function lintCSS(content) {
2202
+ const diagnostics = [];
2203
+ const lines = content.split('\n');
2204
+
2205
+ // Brace balance per-line tracking
2206
+ let depth = 0;
2207
+ for (let i = 0; i < lines.length; i++) {
2208
+ const line = lines[i];
2209
+ for (const ch of line) {
2210
+ if (ch === '{') depth++;
2211
+ if (ch === '}') depth--;
2212
+ }
2213
+ if (depth < 0) {
2214
+ diagnostics.push({ from: { line: i + 1, col: 0 }, severity: 'error', message: 'Unexpected closing brace }' });
2215
+ depth = 0;
2216
+ }
2217
+ }
2218
+ if (depth > 0) {
2219
+ diagnostics.push({ from: { line: lines.length, col: 0 }, severity: 'error', message: `${depth} unclosed brace(s) {` });
2220
+ }
2221
+
2222
+ // Check for common CSS errors
2223
+ for (let i = 0; i < lines.length; i++) {
2224
+ const line = lines[i].trim();
2225
+ // Empty property value
2226
+ if (/^[a-z-]+:\s*;/i.test(line)) {
2227
+ diagnostics.push({ from: { line: i + 1, col: 0 }, severity: 'warning', message: 'Empty property value' });
2228
+ }
2229
+ // Duplicate semicolons
2230
+ if (/;;/.test(line) && !line.startsWith('//') && !line.startsWith('/*')) {
2231
+ diagnostics.push({ from: { line: i + 1, col: line.indexOf(';;') }, severity: 'warning', message: 'Duplicate semicolon' });
2232
+ }
2233
+ // Missing semicolon (property line without ; that isn't a selector/comment/brace)
2234
+ if (/^[a-z-]+\s*:/.test(line) && !line.endsWith(';') && !line.endsWith('{') && !line.endsWith('}') && !line.endsWith(',') && !line.startsWith('//') && !line.startsWith('/*') && !line.startsWith('*')) {
2235
+ diagnostics.push({ from: { line: i + 1, col: line.length }, severity: 'warning', message: 'Missing semicolon' });
2236
+ }
2237
+ }
2238
+
2239
+ return diagnostics;
2240
+ }
2241
+
2242
+ function lintHTML(content, relPath, projectName) {
2243
+ const diagnostics = [];
2244
+ const lines = content.split('\n');
2245
+
2246
+ // Tag balance check
2247
+ const voidTags = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']);
2248
+ const tagStack = [];
2249
+ const tagRegex = /<\/?([a-z][a-z0-9]*)\b[^>]*\/?>/gi;
2250
+ let match;
2251
+ while ((match = tagRegex.exec(content)) !== null) {
2252
+ const full = match[0];
2253
+ const tagName = match[1].toLowerCase();
2254
+ if (voidTags.has(tagName) || full.endsWith('/>')) continue;
2255
+ const lineNum = content.slice(0, match.index).split('\n').length;
2256
+
2257
+ if (full.startsWith('</')) {
2258
+ // Closing tag
2259
+ if (tagStack.length === 0) {
2260
+ diagnostics.push({ from: { line: lineNum, col: 0 }, severity: 'error', message: `Unexpected closing tag </${tagName}>` });
2261
+ } else {
2262
+ const last = tagStack[tagStack.length - 1];
2263
+ if (last.name === tagName) {
2264
+ tagStack.pop();
2265
+ } else {
2266
+ diagnostics.push({ from: { line: lineNum, col: 0 }, severity: 'error', message: `Mismatched tag: expected </${last.name}> but found </${tagName}>` });
2267
+ }
2268
+ }
2269
+ } else {
2270
+ tagStack.push({ name: tagName, line: lineNum });
2271
+ }
2272
+ }
2273
+ // Report unclosed tags (max 5)
2274
+ for (const unclosed of tagStack.slice(-5)) {
2275
+ diagnostics.push({ from: { line: unclosed.line, col: 0 }, severity: 'warning', message: `Unclosed tag <${unclosed.name}>` });
2276
+ }
2277
+
2278
+ // Check src/href references to local files
2279
+ const refRegex = /(?:src|href)=["']([^"']*?\.(?:js|css|mjs|png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|eot))["']/gi;
2280
+ let refMatch;
2281
+ while ((refMatch = refRegex.exec(content)) !== null) {
2282
+ const ref = refMatch[1];
2283
+ if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:') || ref.startsWith('#') || ref.startsWith('mailto:')) continue;
2284
+ const htmlDir = path.dirname(relPath);
2285
+ const refPath = ref.startsWith('/') ? ref.slice(1) : path.join(htmlDir, ref).replace(/\\/g, '/');
2286
+ const publicRef = refPath.startsWith('public/') ? refPath : `public/${refPath}`;
2287
+ const fileExists = ProjectStore.readFile(projectName, refPath) !== null
2288
+ || ProjectStore.readFile(projectName, publicRef) !== null
2289
+ || ProjectStore.readFile(projectName, ref) !== null;
2290
+ if (!fileExists) {
2291
+ const lineNum = content.slice(0, refMatch.index).split('\n').length;
2292
+ diagnostics.push({
2293
+ from: { line: lineNum, col: 0 },
2294
+ severity: 'error',
2295
+ message: `Referenced file not found: ${ref}`,
2296
+ });
2297
+ }
2298
+ }
2299
+
2300
+ return diagnostics;
2301
+ }
2302
+
1892
2303
  router.post('/api/studio/webcraft/lint', async (req, res) => {
1893
2304
  try {
1894
2305
  const { projectName, path: relPath } = await parseBody(req);
@@ -1896,76 +2307,36 @@ export function register(router) {
1896
2307
  const content = ProjectStore.readFile(projectName, relPath);
1897
2308
  if (content === null) return sendJSON(res, 200, { diagnostics: [] });
1898
2309
 
1899
- const diagnostics = [];
1900
- const ext = relPath.split('.').pop()?.toLowerCase();
2310
+ const ext = (relPath.split('.').pop() || '').toLowerCase();
2311
+ let diagnostics = [];
1901
2312
 
1902
- if (ext === 'js' || ext === 'mjs' || ext === 'jsx') {
1903
- try { new Function(content); } catch (e) {
1904
- const match = e.message.match(/^(.*?)$/m);
1905
- const lineMatch = e.message.match(/:(\d+):(\d+)/);
1906
- diagnostics.push({
1907
- from: lineMatch ? { line: parseInt(lineMatch[1]), col: parseInt(lineMatch[2]) } : { line: 1, col: 0 },
1908
- severity: 'error',
1909
- message: match?.[1] || e.message,
1910
- });
1911
- }
2313
+ // JavaScript / JSX full AST analysis
2314
+ if (['js', 'mjs', 'jsx', 'cjs'].includes(ext)) {
2315
+ diagnostics = lintJS(content, relPath, projectName);
1912
2316
  }
1913
2317
 
2318
+ // JSON — parse errors with precise location
1914
2319
  if (ext === 'json') {
1915
2320
  try { JSON.parse(content); } catch (e) {
1916
- const posMatch = e.message.match(/position (\d+)/);
2321
+ const posMatch = e.message.match(/position (\d+)/i);
1917
2322
  const pos = posMatch ? parseInt(posMatch[1]) : 0;
1918
- const lines = content.slice(0, pos).split('\n');
2323
+ const before = content.slice(0, pos).split('\n');
1919
2324
  diagnostics.push({
1920
- from: { line: lines.length, col: (lines[lines.length - 1] || '').length },
2325
+ from: { line: before.length, col: (before[before.length - 1] || '').length },
1921
2326
  severity: 'error',
1922
2327
  message: e.message,
1923
2328
  });
1924
2329
  }
1925
2330
  }
1926
2331
 
2332
+ // CSS — brace balance + property validation
1927
2333
  if (ext === 'css') {
1928
- const opens = (content.match(/\{/g) || []).length;
1929
- const closes = (content.match(/\}/g) || []).length;
1930
- if (opens !== closes) {
1931
- diagnostics.push({
1932
- from: { line: content.split('\n').length, col: 0 },
1933
- severity: 'warning',
1934
- message: `Unbalanced braces: ${opens} open, ${closes} close`,
1935
- });
1936
- }
2334
+ diagnostics = lintCSS(content);
1937
2335
  }
1938
2336
 
2337
+ // HTML/HTM — tag balance + reference validation
1939
2338
  if (ext === 'html' || ext === 'htm') {
1940
- if (!content.includes('</html>')) {
1941
- diagnostics.push({
1942
- from: { line: content.split('\n').length, col: 0 },
1943
- severity: 'warning',
1944
- message: 'Missing </html> closing tag',
1945
- });
1946
- }
1947
- // Check references to local files
1948
- const refRegex = /(?:src|href)=["']([^"']*?\.(?:js|css|mjs))["']/gi;
1949
- let refMatch;
1950
- const lines = content.split('\n');
1951
- while ((refMatch = refRegex.exec(content)) !== null) {
1952
- const ref = refMatch[1];
1953
- if (ref.startsWith('http') || ref.startsWith('//') || ref.startsWith('data:')) continue;
1954
- const htmlDir = path.dirname(relPath);
1955
- const refPath = ref.startsWith('/') ? ref.slice(1) : path.join(htmlDir, ref).replace(/\\/g, '/');
1956
- const publicRef = refPath.startsWith('public/') ? refPath : `public/${refPath}`;
1957
- const fileExists = ProjectStore.readFile(projectName, refPath) !== null
1958
- || ProjectStore.readFile(projectName, publicRef) !== null
1959
- || ProjectStore.readFile(projectName, ref) !== null;
1960
- if (!fileExists) {
1961
- const lineNum = content.slice(0, refMatch.index).split('\n').length;
1962
- diagnostics.push({
1963
- from: { line: lineNum, col: 0 },
1964
- severity: 'error',
1965
- message: `Referenced file not found: ${ref}`,
1966
- });
1967
- }
1968
- }
2339
+ diagnostics = lintHTML(content, relPath, projectName);
1969
2340
  }
1970
2341
 
1971
2342
  sendJSON(res, 200, { diagnostics });
@@ -2006,6 +2377,14 @@ export function register(router) {
2006
2377
  } catch (e) { sendError(res, 500, e.message); }
2007
2378
  });
2008
2379
 
2380
+ // ── Sandbox stop (beacon — for beforeunload) ───────────────────────────────
2381
+ router.post('/api/studio/webcraft/sandbox/stop-beacon', async (_req, res) => {
2382
+ try {
2383
+ await sandbox.stop();
2384
+ sendJSON(res, 200, { ok: true });
2385
+ } catch (e) { sendError(res, 500, e.message); }
2386
+ });
2387
+
2009
2388
  // ── Sandbox status ────────────────────────────────────────────────────────
2010
2389
  router.get('/api/studio/webcraft/sandbox/status', (_req, res) => {
2011
2390
  sendJSON(res, 200, sandbox.status());