ucn 3.6.28 → 3.7.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.
@@ -12001,5 +12001,694 @@ module.exports = { process_data };
12001
12001
  });
12002
12002
  });
12003
12003
 
12004
+ // ============================================================================
12005
+ // HTML PARSING
12006
+ // ============================================================================
12007
+
12008
+ describe('HTML Parsing', () => {
12009
+ const { getParser, getLanguageModule } = require('../languages');
12010
+
12011
+ function getHtmlTools() {
12012
+ return {
12013
+ parser: getParser('html'),
12014
+ mod: getLanguageModule('html')
12015
+ };
12016
+ }
12017
+
12018
+ // -- Language detection --
12019
+
12020
+ it('detects HTML files', () => {
12021
+ assert.strictEqual(detectLanguage('file.html'), 'html');
12022
+ assert.strictEqual(detectLanguage('page.htm'), 'html');
12023
+ assert.strictEqual(detectLanguage('INDEX.HTML'), 'html');
12024
+ });
12025
+
12026
+ // -- Script extraction basics --
12027
+
12028
+ it('finds functions in a single script block', () => {
12029
+ const { parser, mod } = getHtmlTools();
12030
+ const html = '<html><body><script>\nfunction hello() { return 1; }\n</script></body></html>';
12031
+ const fns = mod.findFunctions(html, parser);
12032
+ assert.strictEqual(fns.length, 1);
12033
+ assert.strictEqual(fns[0].name, 'hello');
12034
+ });
12035
+
12036
+ it('finds functions from multiple script blocks', () => {
12037
+ const { parser, mod } = getHtmlTools();
12038
+ const html = `<script>
12039
+ function foo() {}
12040
+ </script>
12041
+ <div>content</div>
12042
+ <script>
12043
+ function bar() {}
12044
+ </script>`;
12045
+ const fns = mod.findFunctions(html, parser);
12046
+ const names = fns.map(f => f.name);
12047
+ assert.ok(names.includes('foo'), `Expected foo in ${names}`);
12048
+ assert.ok(names.includes('bar'), `Expected bar in ${names}`);
12049
+ });
12050
+
12051
+ it('returns empty results for HTML with no script tags', () => {
12052
+ const { parser, mod } = getHtmlTools();
12053
+ const html = '<html><body><h1>Hello</h1></body></html>';
12054
+ const result = mod.parse(html, parser);
12055
+ assert.strictEqual(result.functions.length, 0);
12056
+ assert.strictEqual(result.classes.length, 0);
12057
+ assert.strictEqual(result.language, 'html');
12058
+ });
12059
+
12060
+ it('returns empty results for empty script tag', () => {
12061
+ const { parser, mod } = getHtmlTools();
12062
+ const html = '<html><body><script></script></body></html>';
12063
+ const result = mod.parse(html, parser);
12064
+ assert.strictEqual(result.functions.length, 0);
12065
+ });
12066
+
12067
+ it('returns empty results when only external scripts present', () => {
12068
+ const { parser, mod } = getHtmlTools();
12069
+ const html = '<script src="app.js"></script>\n<script src="vendor.js"></script>';
12070
+ const result = mod.parse(html, parser);
12071
+ assert.strictEqual(result.functions.length, 0);
12072
+ });
12073
+
12074
+ // -- Type attribute filtering --
12075
+
12076
+ it('parses type="module" scripts', () => {
12077
+ const { parser, mod } = getHtmlTools();
12078
+ const html = '<script type="module">\nfunction modFn() {}\n</script>';
12079
+ const fns = mod.findFunctions(html, parser);
12080
+ assert.strictEqual(fns.length, 1);
12081
+ assert.strictEqual(fns[0].name, 'modFn');
12082
+ });
12083
+
12084
+ it('parses type="text/javascript" scripts', () => {
12085
+ const { parser, mod } = getHtmlTools();
12086
+ const html = '<script type="text/javascript">\nfunction textJsFn() {}\n</script>';
12087
+ const fns = mod.findFunctions(html, parser);
12088
+ assert.strictEqual(fns.length, 1);
12089
+ assert.strictEqual(fns[0].name, 'textJsFn');
12090
+ });
12091
+
12092
+ it('parses type="application/javascript" scripts', () => {
12093
+ const { parser, mod } = getHtmlTools();
12094
+ const html = '<script type="application/javascript">\nfunction appJsFn() {}\n</script>';
12095
+ const fns = mod.findFunctions(html, parser);
12096
+ assert.strictEqual(fns.length, 1);
12097
+ assert.strictEqual(fns[0].name, 'appJsFn');
12098
+ });
12099
+
12100
+ it('skips type="application/json" scripts', () => {
12101
+ const { parser, mod } = getHtmlTools();
12102
+ const html = '<script type="application/json">{"key": "value"}</script>\n<script>\nfunction realFn() {}\n</script>';
12103
+ const fns = mod.findFunctions(html, parser);
12104
+ assert.strictEqual(fns.length, 1);
12105
+ assert.strictEqual(fns[0].name, 'realFn');
12106
+ });
12107
+
12108
+ it('skips type="importmap" scripts', () => {
12109
+ const { parser, mod } = getHtmlTools();
12110
+ const html = '<script type="importmap">{"imports": {}}</script>';
12111
+ const result = mod.parse(html, parser);
12112
+ assert.strictEqual(result.functions.length, 0);
12113
+ });
12114
+
12115
+ it('parses scripts with no type attribute (default is JS)', () => {
12116
+ const { parser, mod } = getHtmlTools();
12117
+ const html = '<script>\nfunction defaultFn() {}\n</script>';
12118
+ const fns = mod.findFunctions(html, parser);
12119
+ assert.strictEqual(fns.length, 1);
12120
+ assert.strictEqual(fns[0].name, 'defaultFn');
12121
+ });
12122
+
12123
+ it('skips scripts with src attribute', () => {
12124
+ const { parser, mod } = getHtmlTools();
12125
+ const html = '<script src="app.js"></script>\n<script>\nfunction inlineFn() {}\n</script>';
12126
+ const fns = mod.findFunctions(html, parser);
12127
+ assert.strictEqual(fns.length, 1);
12128
+ assert.strictEqual(fns[0].name, 'inlineFn');
12129
+ });
12130
+
12131
+ // -- Line number accuracy --
12132
+
12133
+ it('reports correct line numbers for functions', () => {
12134
+ const { parser, mod } = getHtmlTools();
12135
+ const html = `<html>
12136
+ <head>
12137
+ <title>Test</title>
12138
+ </head>
12139
+ <body>
12140
+ <script>
12141
+ function atLine7() { return 1; }
12142
+ function atLine8() { return 2; }
12143
+ </script>
12144
+ </body>
12145
+ </html>`;
12146
+ const fns = mod.findFunctions(html, parser);
12147
+ const fn7 = fns.find(f => f.name === 'atLine7');
12148
+ const fn8 = fns.find(f => f.name === 'atLine8');
12149
+ assert.ok(fn7, 'atLine7 should be found');
12150
+ assert.ok(fn8, 'atLine8 should be found');
12151
+ assert.strictEqual(fn7.startLine, 7, `atLine7 should be on line 7, got ${fn7.startLine}`);
12152
+ assert.strictEqual(fn8.startLine, 8, `atLine8 should be on line 8, got ${fn8.startLine}`);
12153
+ });
12154
+
12155
+ it('reports correct line numbers across multiple script blocks with HTML gaps', () => {
12156
+ const { parser, mod } = getHtmlTools();
12157
+ const html = `<script>
12158
+ function first() {}
12159
+ </script>
12160
+ <div>gap line 4</div>
12161
+ <div>gap line 5</div>
12162
+ <script>
12163
+ function second() {}
12164
+ </script>`;
12165
+ const fns = mod.findFunctions(html, parser);
12166
+ const first = fns.find(f => f.name === 'first');
12167
+ const second = fns.find(f => f.name === 'second');
12168
+ assert.ok(first && second, 'Both functions should be found');
12169
+ assert.strictEqual(first.startLine, 2, `first should be at line 2, got ${first.startLine}`);
12170
+ assert.strictEqual(second.startLine, 7, `second should be at line 7, got ${second.startLine}`);
12171
+ });
12172
+
12173
+ it('reports correct line numbers for classes', () => {
12174
+ const { parser, mod } = getHtmlTools();
12175
+ const html = `<html>
12176
+ <body>
12177
+ <script>
12178
+ class MyApp {
12179
+ constructor() {}
12180
+ render() {}
12181
+ }
12182
+ </script>
12183
+ </body>
12184
+ </html>`;
12185
+ const classes = mod.findClasses(html, parser);
12186
+ assert.strictEqual(classes.length, 1);
12187
+ assert.strictEqual(classes[0].name, 'MyApp');
12188
+ assert.strictEqual(classes[0].startLine, 4, `MyApp should start at line 4, got ${classes[0].startLine}`);
12189
+ });
12190
+
12191
+ it('reports correct line numbers for state objects', () => {
12192
+ const { parser, mod } = getHtmlTools();
12193
+ const html = `<html>
12194
+ <head>
12195
+ <script>
12196
+ const CONFIG = { debug: true, version: '1.0' };
12197
+ </script>
12198
+ </head>
12199
+ </html>`;
12200
+ const states = mod.findStateObjects(html, parser);
12201
+ const config = states.find(s => s.name === 'CONFIG');
12202
+ assert.ok(config, 'CONFIG should be found');
12203
+ assert.strictEqual(config.startLine, 4, `CONFIG should be at line 4, got ${config.startLine}`);
12204
+ });
12205
+
12206
+ // -- Feature integration --
12207
+
12208
+ it('detects function calls between functions', () => {
12209
+ const { parser, mod } = getHtmlTools();
12210
+ const html = `<script>
12211
+ function initApp() { renderUI(); loadData(); }
12212
+ function renderUI() { console.log('render'); }
12213
+ function loadData() { fetch('/api'); }
12214
+ </script>`;
12215
+ const calls = mod.findCallsInCode(html, parser);
12216
+ const callNames = calls.map(c => c.name);
12217
+ assert.ok(callNames.includes('renderUI'), `Expected renderUI call, got: ${callNames}`);
12218
+ assert.ok(callNames.includes('loadData'), `Expected loadData call, got: ${callNames}`);
12219
+ });
12220
+
12221
+ it('detects cross-block function calls', () => {
12222
+ const { parser, mod } = getHtmlTools();
12223
+ const html = `<script>
12224
+ function initApp() { renderUI(); }
12225
+ </script>
12226
+ <div>separator</div>
12227
+ <script>
12228
+ function renderUI() { console.log('render'); }
12229
+ </script>`;
12230
+ const calls = mod.findCallsInCode(html, parser);
12231
+ const callNames = calls.map(c => c.name);
12232
+ assert.ok(callNames.includes('renderUI'), `Expected cross-block renderUI call, got: ${callNames}`);
12233
+ });
12234
+
12235
+ it('finds usages within script blocks', () => {
12236
+ const { parser, mod } = getHtmlTools();
12237
+ const html = `<script>
12238
+ const API_URL = '/api';
12239
+ function fetchData() { return fetch(API_URL); }
12240
+ </script>`;
12241
+ const usages = mod.findUsagesInCode(html, 'API_URL', parser);
12242
+ assert.ok(usages.length >= 1, `Expected at least 1 usage of API_URL, got ${usages.length}`);
12243
+ });
12244
+
12245
+ it('finds imports in type="module" scripts', () => {
12246
+ const { parser, mod } = getHtmlTools();
12247
+ const html = `<script type="module">
12248
+ import { createApp } from './app.js';
12249
+ createApp();
12250
+ </script>`;
12251
+ const imports = mod.findImportsInCode(html, parser);
12252
+ assert.ok(imports.length >= 1, `Expected imports, got ${imports.length}`);
12253
+ });
12254
+
12255
+ it('finds classes and state objects', () => {
12256
+ const { parser, mod } = getHtmlTools();
12257
+ const html = `<script>
12258
+ const SETTINGS = { theme: 'dark', lang: 'en' };
12259
+ class GameEngine {
12260
+ constructor() {}
12261
+ start() {}
12262
+ }
12263
+ </script>`;
12264
+ const result = mod.parse(html, parser);
12265
+ assert.ok(result.classes.length >= 1, 'Should find GameEngine class');
12266
+ assert.strictEqual(result.classes[0].name, 'GameEngine');
12267
+ const settings = result.stateObjects.find(s => s.name === 'SETTINGS');
12268
+ assert.ok(settings, 'Should find SETTINGS state object');
12269
+ });
12270
+
12271
+ // -- Edge cases --
12272
+
12273
+ it('handles script content on same line as script tag (column offset)', () => {
12274
+ const { parser, mod } = getHtmlTools();
12275
+ const html = '<div><script>function inline() { return 42; }</script></div>';
12276
+ const fns = mod.findFunctions(html, parser);
12277
+ assert.strictEqual(fns.length, 1);
12278
+ assert.strictEqual(fns[0].name, 'inline');
12279
+ });
12280
+
12281
+ it('handles mixed JS and non-JS script blocks in same file', () => {
12282
+ const { parser, mod } = getHtmlTools();
12283
+ const html = `<script type="application/json">{"not": "js"}</script>
12284
+ <script type="importmap">{"imports": {"a": "b"}}</script>
12285
+ <script>function realJS() {}</script>
12286
+ <script type="text/template"><div>{{template}}</div></script>`;
12287
+ const fns = mod.findFunctions(html, parser);
12288
+ assert.strictEqual(fns.length, 1);
12289
+ assert.strictEqual(fns[0].name, 'realJS');
12290
+ });
12291
+
12292
+ it('parse() returns language html', () => {
12293
+ const { parser, mod } = getHtmlTools();
12294
+ const html = '<script>var x = 1;</script>';
12295
+ const result = mod.parse(html, parser);
12296
+ assert.strictEqual(result.language, 'html');
12297
+ });
12298
+
12299
+ it('totalLines matches HTML file line count, not JS line count', () => {
12300
+ const { parser, mod } = getHtmlTools();
12301
+ const html = `<html>
12302
+ <head>
12303
+ <title>Page</title>
12304
+ </head>
12305
+ <body>
12306
+ <script>
12307
+ var x = 1;
12308
+ </script>
12309
+ </body>
12310
+ </html>`;
12311
+ const result = mod.parse(html, parser);
12312
+ assert.strictEqual(result.totalLines, 10, `Expected 10 lines, got ${result.totalLines}`);
12313
+ });
12314
+
12315
+ // -- Project integration --
12316
+
12317
+ it('indexes HTML files in project mode', () => {
12318
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-project-'));
12319
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), `<html>
12320
+ <body>
12321
+ <script>
12322
+ function initApp() { renderUI(); }
12323
+ function renderUI() { console.log('hello'); }
12324
+ const CONFIG = { debug: true };
12325
+ </script>
12326
+ </body>
12327
+ </html>`);
12328
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12329
+
12330
+ const { ProjectIndex } = require('../core/project');
12331
+ const index = new ProjectIndex(tmpDir);
12332
+ index.build(null, { quiet: true });
12333
+
12334
+ // Functions should be found
12335
+ const initDefs = index.find('initApp');
12336
+ assert.ok(initDefs.length > 0, 'initApp should be found in project index');
12337
+ assert.strictEqual(initDefs[0].startLine, 4, 'initApp should be at line 4');
12338
+
12339
+ // Callers should work
12340
+ const renderDefs = index.find('renderUI');
12341
+ assert.ok(renderDefs.length > 0, 'renderUI should be found');
12342
+ const callers = index.findCallers('renderUI');
12343
+ const callerNames = callers.map(c => c.callerName);
12344
+ assert.ok(callerNames.includes('initApp'), `initApp should call renderUI, got: ${callerNames}`);
12345
+
12346
+ // State objects should be found
12347
+ const configDefs = index.find('CONFIG');
12348
+ assert.ok(configDefs.length > 0, 'CONFIG should be found');
12349
+
12350
+ fs.rmSync(tmpDir, { recursive: true });
12351
+ });
12352
+
12353
+ it('extractCode works with HTML files', () => {
12354
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-extract-'));
12355
+ const htmlContent = `<html>
12356
+ <body>
12357
+ <script>
12358
+ function greet(name) {
12359
+ return 'Hello ' + name;
12360
+ }
12361
+ </script>
12362
+ </body>
12363
+ </html>`;
12364
+ fs.writeFileSync(path.join(tmpDir, 'page.html'), htmlContent);
12365
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
12366
+
12367
+ const { ProjectIndex } = require('../core/project');
12368
+ const index = new ProjectIndex(tmpDir);
12369
+ index.build(null, { quiet: true });
12370
+
12371
+ const defs = index.find('greet');
12372
+ assert.ok(defs.length > 0, 'greet should be found');
12373
+ const code = index.extractCode(defs[0]);
12374
+ assert.ok(code.includes('function greet'), `Extracted code should contain function: ${code}`);
12375
+ assert.ok(code.includes('Hello'), `Extracted code should contain body: ${code}`);
12376
+
12377
+ fs.rmSync(tmpDir, { recursive: true });
12378
+ });
12379
+
12380
+ // -- extractScriptBlocks and buildVirtualJSContent unit tests --
12381
+
12382
+ it('extractScriptBlocks returns correct block positions', () => {
12383
+ const { extractScriptBlocks } = require('../languages/html');
12384
+ const parser = getParser('html');
12385
+ const html = `<html>
12386
+ <body>
12387
+ <script>
12388
+ var x = 1;
12389
+ </script>
12390
+ </body>
12391
+ </html>`;
12392
+ const blocks = extractScriptBlocks(html, parser);
12393
+ assert.strictEqual(blocks.length, 1);
12394
+ assert.strictEqual(blocks[0].startRow, 2, `Block should start at row 2 (0-indexed, raw_text starts after <script> closing >), got ${blocks[0].startRow}`);
12395
+ assert.ok(blocks[0].text.includes('var x = 1'), `Block text should contain JS: ${blocks[0].text}`);
12396
+ });
12397
+
12398
+ it('buildVirtualJSContent preserves line positions', () => {
12399
+ const { buildVirtualJSContent } = require('../languages/html');
12400
+ const html = `line0
12401
+ line1
12402
+ <script>
12403
+ var x = 1;
12404
+ </script>
12405
+ line5`;
12406
+ const blocks = [{ text: '\nvar x = 1;\n', startRow: 2, startCol: 8 }];
12407
+ const virtual = buildVirtualJSContent(html, blocks);
12408
+ const lines = virtual.split('\n');
12409
+ assert.strictEqual(lines.length, 6, `Should have 6 lines, got ${lines.length}`);
12410
+ assert.strictEqual(lines[0], '', 'Line 0 should be empty (HTML)');
12411
+ assert.strictEqual(lines[1], '', 'Line 1 should be empty (HTML)');
12412
+ assert.strictEqual(lines[3].trim(), 'var x = 1;', 'Line 3 should have JS content');
12413
+ assert.strictEqual(lines[5], '', 'Line 5 should be empty (HTML)');
12414
+ });
12415
+
12416
+ // Bug fix tests
12417
+ // ─────────────────────────────────────────────────────────────
12418
+
12419
+ it('cleanHtmlScriptTags strips script tags from same-line scripts', () => {
12420
+ const { cleanHtmlScriptTags } = require('../core/parser');
12421
+
12422
+ // Same-line script: <script>function foo() { return 1; }</script>
12423
+ const lines1 = ['<script>function foo() { return 1; }</script>'];
12424
+ cleanHtmlScriptTags(lines1, 'html');
12425
+ assert.strictEqual(lines1[0], 'function foo() { return 1; }');
12426
+
12427
+ // Multi-line: only first/last lines affected
12428
+ const lines2 = [' <script type="module">', ' function bar() {', ' }', ' </script>'];
12429
+ cleanHtmlScriptTags(lines2, 'html');
12430
+ assert.strictEqual(lines2[0], ' ', 'First line should have only indentation');
12431
+ assert.strictEqual(lines2[1], ' function bar() {', 'Middle lines unchanged');
12432
+ assert.strictEqual(lines2[3], ' ', 'Last line should have only indentation');
12433
+
12434
+ // Non-HTML language: no changes
12435
+ const lines3 = ['<script>function foo() {}</script>'];
12436
+ cleanHtmlScriptTags(lines3, 'javascript');
12437
+ assert.strictEqual(lines3[0], '<script>function foo() {}</script>', 'Non-HTML should be unchanged');
12438
+
12439
+ // Uppercase SCRIPT tag
12440
+ const lines4 = ['<SCRIPT>function foo() {}</SCRIPT>'];
12441
+ cleanHtmlScriptTags(lines4, 'html');
12442
+ assert.strictEqual(lines4[0], 'function foo() {}');
12443
+ });
12444
+
12445
+ it('extractCode strips script tags for HTML files', () => {
12446
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-extract-'));
12447
+ const htmlFile = path.join(tmpDir, 'test.html');
12448
+ fs.writeFileSync(htmlFile, `<!DOCTYPE html>
12449
+ <html>
12450
+ <body>
12451
+ <script>function oneLiner() { return 42; }</script>
12452
+ <script>
12453
+ function multiLine() {
12454
+ return 99;
12455
+ }
12456
+ </script>
12457
+ </body>
12458
+ </html>`);
12459
+
12460
+ const { ProjectIndex } = require('../core/project');
12461
+ const index = new ProjectIndex(tmpDir);
12462
+ index.build();
12463
+
12464
+ // Find oneLiner and check its extracted code
12465
+ const oneLineDefs = index.find('oneLiner');
12466
+ assert.ok(oneLineDefs.length > 0, 'Should find oneLiner');
12467
+ const code = index.extractCode(oneLineDefs[0]);
12468
+ assert.ok(!code.includes('<script>'), 'extractCode should not include <script> tag');
12469
+ assert.ok(!code.includes('</script>'), 'extractCode should not include </script> tag');
12470
+ assert.ok(code.includes('function oneLiner'), 'extractCode should include function body');
12471
+
12472
+ // Multi-line function should not be affected
12473
+ const multiDefs = index.find('multiLine');
12474
+ assert.ok(multiDefs.length > 0, 'Should find multiLine');
12475
+ const multiCode = index.extractCode(multiDefs[0]);
12476
+ assert.ok(!multiCode.includes('<script>'), 'Multi-line extractCode should not include <script>');
12477
+ assert.ok(multiCode.includes('function multiLine'), 'Multi-line extractCode should include function body');
12478
+
12479
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12480
+ });
12481
+
12482
+ it('smart respects --file disambiguation', () => {
12483
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-smart-'));
12484
+ // Create two files with same function name
12485
+ fs.writeFileSync(path.join(tmpDir, 'a.html'), `<html><body>
12486
+ <script>
12487
+ function myFunc() { return 'from html'; }
12488
+ </script>
12489
+ </body></html>`);
12490
+ fs.writeFileSync(path.join(tmpDir, 'b.js'), `function myFunc() { return 'from js'; }\n`);
12491
+ // Need package.json for discovery
12492
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
12493
+
12494
+ const { ProjectIndex } = require('../core/project');
12495
+ const index = new ProjectIndex(tmpDir);
12496
+ index.build();
12497
+
12498
+ // Without --file, picks best scoring (b.js is not in tests/ so it should win)
12499
+ const result1 = index.smart('myFunc');
12500
+ assert.ok(result1, 'smart should find myFunc');
12501
+
12502
+ // With --file=a.html, should pick the HTML file
12503
+ const result2 = index.smart('myFunc', { file: 'a.html' });
12504
+ assert.ok(result2, 'smart with file filter should find myFunc');
12505
+ assert.ok(result2.target.file.endsWith('a.html'), `Should resolve to a.html, got ${result2.target.file}`);
12506
+
12507
+ // With --file=b.js, should pick the JS file
12508
+ const result3 = index.smart('myFunc', { file: 'b.js' });
12509
+ assert.ok(result3, 'smart with file filter should find myFunc in b.js');
12510
+ assert.ok(result3.target.file.endsWith('b.js'), `Should resolve to b.js, got ${result3.target.file}`);
12511
+
12512
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12513
+ });
12514
+
12515
+ it('deadcode buildUsageIndex parses HTML inline scripts', () => {
12516
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-deadcode-'));
12517
+ fs.writeFileSync(path.join(tmpDir, 'app.html'), `<html><body>
12518
+ <script>
12519
+ function helper() { return 42; }
12520
+ function main() { return helper(); }
12521
+ </script>
12522
+ </body></html>`);
12523
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
12524
+
12525
+ const { ProjectIndex } = require('../core/project');
12526
+ const index = new ProjectIndex(tmpDir);
12527
+ index.build();
12528
+
12529
+ // helper is called by main, so it should NOT be dead code
12530
+ const dead = index.deadcode({ includeExported: true });
12531
+ const deadNames = dead.map(d => d.name);
12532
+ assert.ok(!deadNames.includes('helper'), `helper should not be dead code (called by main), got: ${deadNames.join(', ')}`);
12533
+ // main has no callers, so it should be dead
12534
+ assert.ok(deadNames.includes('main'), 'main should be dead code (no callers)');
12535
+
12536
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12537
+ });
12538
+
12539
+ // ── HTML event handler tests (fix #90) ──────────────────────────────────
12540
+
12541
+ it('extractEventHandlerCalls extracts calls from onclick attributes', () => {
12542
+ const { extractEventHandlerCalls } = require('../languages/html');
12543
+ const parser = getParser('html');
12544
+ const html = '<button onclick="resetGame()">Click</button>';
12545
+ const calls = extractEventHandlerCalls(html, parser);
12546
+ assert.strictEqual(calls.length, 1);
12547
+ assert.strictEqual(calls[0].name, 'resetGame');
12548
+ assert.strictEqual(calls[0].line, 1);
12549
+ assert.strictEqual(calls[0].isMethod, false);
12550
+ assert.strictEqual(calls[0].isEventHandler, true);
12551
+ });
12552
+
12553
+ it('extractEventHandlerCalls handles multiple calls in one handler', () => {
12554
+ const { extractEventHandlerCalls } = require('../languages/html');
12555
+ const parser = getParser('html');
12556
+ const html = '<button onclick="validateForm(); submitData()">Go</button>';
12557
+ const calls = extractEventHandlerCalls(html, parser);
12558
+ assert.strictEqual(calls.length, 2);
12559
+ assert.strictEqual(calls[0].name, 'validateForm');
12560
+ assert.strictEqual(calls[1].name, 'submitData');
12561
+ });
12562
+
12563
+ it('extractEventHandlerCalls skips method calls on objects', () => {
12564
+ const { extractEventHandlerCalls } = require('../languages/html');
12565
+ const parser = getParser('html');
12566
+ const html = '<button onclick="event.stopPropagation(); selectCar(\'abc\')">Buy</button>';
12567
+ const calls = extractEventHandlerCalls(html, parser);
12568
+ const names = calls.map(c => c.name);
12569
+ assert.ok(!names.includes('stopPropagation'), 'should skip event.stopPropagation()');
12570
+ assert.ok(names.includes('selectCar'), 'should detect selectCar()');
12571
+ });
12572
+
12573
+ it('extractEventHandlerCalls skips JS keywords', () => {
12574
+ const { extractEventHandlerCalls } = require('../languages/html');
12575
+ const parser = getParser('html');
12576
+ const html = '<button onclick="if (confirm(\'sure?\')) deleteItem(id)">Del</button>';
12577
+ const calls = extractEventHandlerCalls(html, parser);
12578
+ const names = calls.map(c => c.name);
12579
+ assert.ok(!names.includes('if'), 'should skip keyword if');
12580
+ assert.ok(names.includes('confirm'));
12581
+ assert.ok(names.includes('deleteItem'));
12582
+ });
12583
+
12584
+ it('extractEventHandlerCalls works with various on* attributes', () => {
12585
+ const { extractEventHandlerCalls } = require('../languages/html');
12586
+ const parser = getParser('html');
12587
+ const html = `<input onchange="updateValue()" onfocus="highlight()" onblur="unhighlight()">`;
12588
+ const calls = extractEventHandlerCalls(html, parser);
12589
+ const names = calls.map(c => c.name);
12590
+ assert.ok(names.includes('updateValue'));
12591
+ assert.ok(names.includes('highlight'));
12592
+ assert.ok(names.includes('unhighlight'));
12593
+ });
12594
+
12595
+ it('extractEventHandlerCalls does not extract from script elements', () => {
12596
+ const { extractEventHandlerCalls } = require('../languages/html');
12597
+ const parser = getParser('html');
12598
+ const html = `<script>function foo() { bar(); }</script>
12599
+ <button onclick="foo()">Click</button>`;
12600
+ const calls = extractEventHandlerCalls(html, parser);
12601
+ // Should only find foo from onclick, not bar from <script>
12602
+ assert.strictEqual(calls.length, 1);
12603
+ assert.strictEqual(calls[0].name, 'foo');
12604
+ });
12605
+
12606
+ it('extractEventHandlerCalls reports correct line numbers', () => {
12607
+ const { extractEventHandlerCalls } = require('../languages/html');
12608
+ const parser = getParser('html');
12609
+ const html = `<html>
12610
+ <body>
12611
+ <div>text</div>
12612
+ <button onclick="doA()">A</button>
12613
+ <button onclick="doB()">B</button>
12614
+ </body>
12615
+ </html>`;
12616
+ const calls = extractEventHandlerCalls(html, parser);
12617
+ assert.strictEqual(calls.length, 2);
12618
+ assert.strictEqual(calls[0].name, 'doA');
12619
+ assert.strictEqual(calls[0].line, 4);
12620
+ assert.strictEqual(calls[1].name, 'doB');
12621
+ assert.strictEqual(calls[1].line, 5);
12622
+ });
12623
+
12624
+ it('findCallsInCode includes event handler calls for HTML', () => {
12625
+ const { parser, mod } = getHtmlTools();
12626
+ const html = `<button onclick="handleClick()">Click</button>
12627
+ <script>
12628
+ function handleClick() { doWork(); }
12629
+ function doWork() { return 42; }
12630
+ </script>`;
12631
+ const calls = mod.findCallsInCode(html, parser);
12632
+ const names = calls.map(c => c.name);
12633
+ assert.ok(names.includes('handleClick'), 'should find handleClick from onclick');
12634
+ assert.ok(names.includes('doWork'), 'should find doWork from script');
12635
+ });
12636
+
12637
+ it('deadcode does not report functions called from HTML event handlers', () => {
12638
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-onclick-'));
12639
+ fs.writeFileSync(path.join(tmpDir, 'page.html'), `<html><body>
12640
+ <button onclick="resetGame()">Reset</button>
12641
+ <button onclick="startGame('easy')">Start</button>
12642
+ <script>
12643
+ function resetGame() { init(); }
12644
+ function startGame(mode) { setup(mode); }
12645
+ function init() { return 1; }
12646
+ function setup(m) { return m; }
12647
+ function unusedFn() { return 0; }
12648
+ </script>
12649
+ </body></html>`);
12650
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
12651
+
12652
+ const { ProjectIndex } = require('../core/project');
12653
+ const index = new ProjectIndex(tmpDir);
12654
+ index.build();
12655
+
12656
+ const dead = index.deadcode({ includeExported: true });
12657
+ const deadNames = dead.map(d => d.name);
12658
+ // resetGame and startGame are called from onclick — NOT dead
12659
+ assert.ok(!deadNames.includes('resetGame'), 'resetGame should not be dead (called from onclick)');
12660
+ assert.ok(!deadNames.includes('startGame'), 'startGame should not be dead (called from onclick)');
12661
+ // init and setup are called from script — NOT dead
12662
+ assert.ok(!deadNames.includes('init'), 'init should not be dead (called from resetGame)');
12663
+ assert.ok(!deadNames.includes('setup'), 'setup should not be dead (called from startGame)');
12664
+ // unusedFn has no callers — dead
12665
+ assert.ok(deadNames.includes('unusedFn'), 'unusedFn should be dead');
12666
+
12667
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12668
+ });
12669
+
12670
+ it('findCallers detects callers from HTML event handlers', () => {
12671
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ucn-html-callers-'));
12672
+ fs.writeFileSync(path.join(tmpDir, 'page.html'), `<html><body>
12673
+ <button onclick="doStuff()">Go</button>
12674
+ <script>
12675
+ function doStuff() { return 42; }
12676
+ </script>
12677
+ </body></html>`);
12678
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name": "test"}');
12679
+
12680
+ const { ProjectIndex } = require('../core/project');
12681
+ const index = new ProjectIndex(tmpDir);
12682
+ index.build();
12683
+
12684
+ const callers = index.findCallers('doStuff');
12685
+ assert.strictEqual(callers.length, 1);
12686
+ assert.strictEqual(callers[0].line, 2);
12687
+ assert.ok(callers[0].content.includes('onclick="doStuff()"'));
12688
+
12689
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12690
+ });
12691
+ });
12692
+
12004
12693
  console.log('UCN v3 Test Suite');
12005
12694
  console.log('Run with: node --test test/parser.test.js');