From 44951a9b209a9cb4163deb930e3f7c0e2630b1d3 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 16:28:41 -0400 Subject: [PATCH 1/8] Address Codex review feedback --- card/lib/analysis.js | 30 ++++++++++++++- index.html | 58 ++++++++++++++++++++--------- tests/fixtures/pascal-world/app.lpr | 4 +- tests/headless-analyze.test.mjs | 46 ++++++++++++++++++++++- tests/pascal-support.test.mjs | 26 ++++++++++++- 5 files changed, 141 insertions(+), 23 deletions(-) diff --git a/card/lib/analysis.js b/card/lib/analysis.js index 0e1b78b..6c9d3f4 100644 --- a/card/lib/analysis.js +++ b/card/lib/analysis.js @@ -2,6 +2,7 @@ 'use strict'; +const fs = require('fs'); const path = require('path'); const { loadAnalyzer, locateIndexHtml } = require('./analyzer.js'); @@ -16,9 +17,36 @@ function normalizeExcludeInput(exclude) { return exclude == null ? '' : String(exclude); } +function validateRepoRoot(repoRoot) { + let stats; + try { + stats = fs.statSync(repoRoot); + } catch (error) { + if (error && error.code === 'ENOENT') { + throw new Error('Analysis path does not exist: ' + repoRoot); + } + throw new Error( + 'Analysis path is not accessible: ' + repoRoot + + (error && error.code ? ' (' + error.code + ')' : '') + ); + } + if (!stats.isDirectory()) { + throw new Error('Analysis path is not a directory: ' + repoRoot); + } + try { + fs.accessSync(repoRoot, fs.constants.R_OK | fs.constants.X_OK); + } catch (error) { + throw new Error( + 'Analysis path is not readable: ' + repoRoot + + (error && error.code ? ' (' + error.code + ')' : '') + ); + } +} + async function analyze(options) { const opts = options || {}; const repoRoot = path.resolve(opts.repoRoot || process.cwd()); + validateRepoRoot(repoRoot); const actionDir = path.resolve(opts.actionDir || path.join(__dirname, '..')); const progress = typeof opts.progress === 'function' ? opts.progress : () => {}; const indexHtmlPath = opts.indexHtmlPath || locateIndexHtml(actionDir, repoRoot); @@ -59,4 +87,4 @@ async function analyze(options) { return { schemaVersion: HEADLESS_SCHEMA_VERSION, data, snapshot }; } -module.exports = { analyze, HEADLESS_SCHEMA_VERSION, normalizeExcludeInput }; +module.exports = { analyze, HEADLESS_SCHEMA_VERSION, normalizeExcludeInput, validateRepoRoot }; diff --git a/index.html b/index.html index 1efedb7..78d3b78 100644 --- a/index.html +++ b/index.html @@ -2204,7 +2204,9 @@ extractOtherLanguages:function(content,filename,addFn,extractCode){ var lines=content.split('\n'); var isPascal=Parser.isPascal(filename); - var pascalHasImplementation=isPascal&&/^\s*implementation\b/im.test(content); + var pascalContent=isPascal?Parser.stripPascalNonCode(content):''; + var pascalLines=isPascal?pascalContent.split('\n'):null; + var pascalHasImplementation=isPascal&&/^\s*implementation\b/im.test(pascalContent); var inPascalImplementation=!pascalHasImplementation; lines.forEach(function(line,idx){ @@ -2212,15 +2214,16 @@ var m; if(isPascal){ - if(/^\s*implementation\b/i.test(line)){ + var pascalLine=pascalLines[idx]; + if(/^\s*implementation\b/i.test(pascalLine)){ inPascalImplementation=true; return; } if(!inPascalImplementation)return; - if(!/^\s*(?:(?:class|static)\s+)?(?:procedure|function|constructor|destructor|operator)\b/i.test(line))return; - var signature=line; + if(!/^\s*(?:(?:class|static)\s+)?(?:procedure|function|constructor|destructor|operator)\b/i.test(pascalLine))return; + var signature=pascalLine; for(var si=idx+1;si=0?lower.split('.').pop():lower; + return wordSet.has(lower)||wordSet.has(base); + }); + } var index=fnIndex||Parser.buildFunctionNameIndex(fnNames); var out=[]; var seen=new Set(); @@ -2794,19 +2807,27 @@ countCandidateCalls:function(content,fnNames,options){ var calls=Object.create(null); var refs=Object.create(null); - var candidateSet=new Set(fnNames||[]); var source=String(content||''); var opts=options||{}; - fnNames.forEach(function(fn){calls[fn]=0;refs[fn]=0;}); + var canonicalNamesByToken=Object.create(null); + (fnNames||[]).forEach(function(fn){ + calls[fn]=0; + refs[fn]=0; + var token=opts.isPascal?fn.toLowerCase():fn; + if(!canonicalNamesByToken[token])canonicalNamesByToken[token]=[]; + canonicalNamesByToken[token].push(fn); + }); + var candidateSet=new Set(Object.keys(canonicalNamesByToken)); if(!source||!candidateSet.size)return calls; var tokenRe=/\b[a-zA-Z_$][\w$]*\b/g; var match; while((match=tokenRe.exec(source))!==null){ - var name=match[0]; - if(!candidateSet.has(name))continue; + var tokenName=opts.isPascal?match[0].toLowerCase():match[0]; + if(!candidateSet.has(tokenName))continue; + var matchedNames=canonicalNamesByToken[tokenName]; var start=match.index; - var end=start+name.length; + var end=start+match[0].length; var prev=start-1; while(prev>=0&&/\s/.test(source[prev]))prev--; var next=end; @@ -2821,13 +2842,13 @@ if(opts.isVBA&&/\b(Sub|Function)\s+$/i.test(prefix))isDefinition=true; if(opts.isPascal&&/\b(procedure|function|constructor|destructor|operator)\s+$/i.test(prefix))isDefinition=true; if(nextChar==='('&&!isDefinition){ - calls[name]++; + matchedNames.forEach(function(name){calls[name]++;}); }else if(opts.isPascal&&nextChar===';'&&!isDefinition&&/^(?:[A-Za-z_]\w*\.)*\s*$/.test(prefix.trim())){ - calls[name]++; + matchedNames.forEach(function(name){calls[name]++;}); }else if(!isDefinition&&'[,[:(={'.indexOf(prevChar)>=0&&' ,])};\n\r'.indexOf(nextChar)>=0){ - refs[name]++; + matchedNames.forEach(function(name){refs[name]++;}); }else if(opts.isPython&&prevChar==='@'&&!isDefinition){ - refs[name]++; + matchedNames.forEach(function(name){refs[name]++;}); } } @@ -2865,7 +2886,8 @@ // AST-based call detection - finds actual function calls and references findCalls:function(content,fnNames,definingFile,fnDefs,fnIndex){ - fnNames=Parser.candidateFunctionNames(content,fnNames,fnIndex); + var pascalFile=Parser.isPascal(definingFile); + fnNames=Parser.candidateFunctionNames(content,fnNames,fnIndex,{caseInsensitive:pascalFile}); var calls={}; var refs={}; // Functions used as callbacks/references without () if(!fnNames.length)return calls; @@ -2904,7 +2926,7 @@ var isPython=['py','pyw','pyi'].indexOf(ext)>=0; var isJS=['js','jsx','ts','tsx','mjs','cjs','vue','svelte'].indexOf(ext)>=0; var isVBA=['vba','bas','cls','xlsm','xlam'].indexOf(ext)>=0; - var isPascal=['pas','pp','dpr','dpk','lpr','inc'].indexOf(ext)>=0; + var isPascal=pascalFile; // Python: use tree-sitter real parser (WASM) for accurate AST-based detection if(isPython){ diff --git a/tests/fixtures/pascal-world/app.lpr b/tests/fixtures/pascal-world/app.lpr index de68d98..7d0f899 100644 --- a/tests/fixtures/pascal-world/app.lpr +++ b/tests/fixtures/pascal-world/app.lpr @@ -4,6 +4,6 @@ MathUtils; begin - WriteLn(DoubleValue(21)); - LogValue; + WriteLn(doublevalue(21)); + logvalue; end. diff --git a/tests/headless-analyze.test.mjs b/tests/headless-analyze.test.mjs index d1dc4d2..02c3c4c 100644 --- a/tests/headless-analyze.test.mjs +++ b/tests/headless-analyze.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -44,6 +44,50 @@ test('headless CLI keeps stdout machine-readable', async () => { assert.equal(result.data.stats.files, 6); }); +test('headless analyzer rejects missing paths and regular files', async (t) => { + const fixture = await mkdtemp(join(tmpdir(), 'codeflow-invalid-root-')); + t.after(() => rm(fixture, { recursive: true, force: true })); + const filePath = join(fixture, 'file.js'); + await writeFile(filePath, 'export function example() {}\n'); + + await assert.rejects( + analyze({ repoRoot: join(fixture, 'missing') }), + /Analysis path does not exist/ + ); + await assert.rejects(analyze({ repoRoot: filePath }), /Analysis path is not a directory/); +}); + +test('headless CLI reports invalid roots on stderr and exits unsuccessfully', async () => { + const missingPath = join( + tmpdir(), + 'codeflow-missing-analysis-root-' + process.pid + '-' + Date.now() + ); + await assert.rejects( + execFileAsync(process.execPath, [cliPath, '--path', missingPath]), + (error) => { + assert.equal(error.stdout, ''); + assert.match(error.stderr, /Analysis path does not exist/); + assert.notEqual(error.code, 0); + return true; + } + ); +}); + +test( + 'headless analyzer rejects unreadable directories', + { skip: process.platform === 'win32' || (process.getuid && process.getuid() === 0) }, + async (t) => { + const fixture = await mkdtemp(join(tmpdir(), 'codeflow-unreadable-root-')); + t.after(async () => { + await chmod(fixture, 0o700); + await rm(fixture, { recursive: true, force: true }); + }); + await chmod(fixture, 0o000); + + await assert.rejects(analyze({ repoRoot: fixture }), /Analysis path is not readable/); + } +); + test('headless argument parser accepts equals and repeated forms', () => { const parsed = parseArgs(['--path=' + fixtureRoot, '--exclude=dist/**', '--exclude', '*.min.js']); assert.equal(parsed.repoRoot, fixtureRoot); diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index 314470a..878922a 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -77,7 +77,31 @@ test('Pascal extraction uses implementation bodies and recognizes routines', asy ); }); -test('Pascal call graph follows uses units and ignores comments and strings', async () => { +test('Pascal extraction ignores routine-like declarations inside comments', () => { + const content = `program CommentedRoutines; + +{ +procedure CurlyGhost; +} + +(* +function ParenGhost: Integer; +*) + +procedure RealRoutine; +begin +end; + +begin + RealRoutine; +end. +`; + const functions = Parser.extract(content, 'CommentedRoutines.lpr'); + + assert.deepEqual(Array.from(functions, (fn) => fn.name), ['RealRoutine']); +}); + +test('Pascal call graph is case-insensitive, follows uses units, and ignores non-code', async () => { const data = await analyzePascalFixture(); const appConnections = data.connections .filter((connection) => connection.target === 'app.lpr') From 9168db4517bf13e07e8e31d28c4d9e417183c138 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 17:00:59 -0400 Subject: [PATCH 2/8] Resolve case-folded Pascal calls by imports --- index.html | 30 ++++++++++++++------ tests/pascal-support.test.mjs | 53 +++++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index 78d3b78..65dea67 100644 --- a/index.html +++ b/index.html @@ -2603,6 +2603,7 @@ buildFunctionDefinitionIndex:function(fnDefs){ var byName=Object.create(null); + var byPascalName=Object.create(null); var byKey=Object.create(null); (fnDefs||[]).forEach(function(fn){ if(!fn||typeof fn.name!=='string'||!fn.name)return; @@ -2612,8 +2613,13 @@ byKey[key]=fn; if(!byName[fn.name])byName[fn.name]=[]; byName[fn.name].push(fn); + if(Parser.isPascal(fn.file)){ + var pascalName=fn.name.toLowerCase(); + if(!byPascalName[pascalName])byPascalName[pascalName]=[]; + byPascalName[pascalName].push(fn); + } }); - return{byName:byName,byKey:byKey}; + return{byName:byName,byPascalName:byPascalName,byKey:byKey}; }, resolveCallGraphImportPath:function(importPath,fromFile,files){ @@ -2811,11 +2817,16 @@ var opts=options||{}; var canonicalNamesByToken=Object.create(null); (fnNames||[]).forEach(function(fn){ - calls[fn]=0; - refs[fn]=0; var token=opts.isPascal?fn.toLowerCase():fn; - if(!canonicalNamesByToken[token])canonicalNamesByToken[token]=[]; - canonicalNamesByToken[token].push(fn); + if(opts.isPascal){ + calls[token]=0; + refs[token]=0; + canonicalNamesByToken[token]=[token]; + }else{ + calls[fn]=0; + refs[fn]=0; + canonicalNamesByToken[token]=[fn]; + } }); var candidateSet=new Set(Object.keys(canonicalNamesByToken)); if(!source||!candidateSet.size)return calls; @@ -2878,7 +2889,7 @@ } } - fnNames.forEach(function(fn){ + Object.keys(calls).forEach(function(fn){ calls[fn]=Math.max(0,calls[fn]||0)+(refs[fn]||0); }); return calls; @@ -4857,7 +4868,10 @@ } function resolveCallDefinitions(fnName,file){ - var defs=fnDefIndex.byName[fnName]||[]; + var isPascalCall=Parser.isPascal(file.path); + var defs=isPascalCall + ?fnDefIndex.byPascalName[fnName.toLowerCase()]||[] + :fnDefIndex.byName[fnName]||[]; if(!defs.length)return[]; var sameFile=defs.filter(function(def){return def.file===file.path;}); var sameFileDef=firstDefinitionFromOneFile(sameFile); @@ -4902,7 +4916,7 @@ if(def.file===file.path){ stat.internal+=cnt; }else{ - conns.push({source:def.file,target:file.path,fn:fn,count:cnt,functionKey:def.key}); + conns.push({source:def.file,target:file.path,fn:def.name,count:cnt,functionKey:def.key}); var ex=stat.callers.get(file.path); if(ex)ex.count+=cnt; else stat.callers.set(file.path,{file:file.path,name:file.name,count:cnt}); diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index 878922a..8ab945e 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; -import { basename, dirname, join, relative } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; import vm from 'node:vm'; @@ -30,13 +30,18 @@ const { Parser, buildAnalysisData } = context; async function analyzePascalFixture() { const entries = await readdir(fixtureRoot, { withFileTypes: true }); - const analyzed = []; - const allFns = []; + const sources = {}; for (const entry of entries) { if (!entry.isFile() || !Parser.isIncluded(entry.name)) continue; - const fullPath = join(fixtureRoot, entry.name); - const filePath = relative(fixtureRoot, fullPath).replace(/\\/g, '/'); - const content = await readFile(fullPath, 'utf8'); + sources[entry.name] = await readFile(join(fixtureRoot, entry.name), 'utf8'); + } + return analyzePascalSources(sources); +} + +async function analyzePascalSources(sources) { + const analyzed = []; + const allFns = []; + for (const [filePath, content] of Object.entries(sources)) { const functions = Parser.extract(content, filePath); const layer = Parser.detectLayer(filePath); analyzed.push({ @@ -113,3 +118,39 @@ test('Pascal call graph is case-insensitive, follows uses units, and ignores non assert.equal(data.stats.files, 3); assert.equal(data.stats.functions, 3); }); + +test('Pascal case-folded calls resolve only to the imported unit', async () => { + const data = await analyzePascalSources({ + 'UnitA.pas': `unit UnitA; +interface +procedure Render; +implementation +procedure Render; +begin +end; +end. +`, + 'UnitB.pas': `unit UnitB; +interface +procedure render; +implementation +procedure render; +begin +end; +end. +`, + 'app.lpr': `program CaseFoldedCalls; +uses UnitA; +begin + RENDER; +end. +`, + }); + + const appConnections = data.connections.filter((connection) => connection.target === 'app.lpr'); + assert.deepEqual( + Array.from(appConnections, (connection) => connection.source + ':' + connection.fn), + ['UnitA.pas:Render'] + ); + assert.equal(data.connections.some((connection) => connection.source === 'UnitB.pas'), false); +}); From 1ff07065ffd01e3cbe19ed049c6235ebca61f574 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 17:10:46 -0400 Subject: [PATCH 3/8] Preserve Pascal unit qualifiers in call resolution --- index.html | 30 ++++++++++++++++++++++++++--- tests/pascal-support.test.mjs | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 65dea67..165da77 100644 --- a/index.html +++ b/index.html @@ -2762,7 +2762,9 @@ match[1].split(',').forEach(function(part){ var unitMatch=part.trim().match(/^([A-Za-z_][A-Za-z0-9_.]*)/); if(!unitMatch)return; - addTarget(Parser.resolveCallGraphImportPath(unitMatch[1],fromFile,files)); + var unitResolved=Parser.resolveCallGraphImportPath(unitMatch[1],fromFile,files); + addTarget(unitResolved); + addLocal(unitMatch[1].toLowerCase(),unitResolved); }); } } @@ -2847,6 +2849,17 @@ var prevChar=prev>=0?source[prev]:''; var lineStart=source.lastIndexOf('\n',start-1)+1; var prefix=source.slice(lineStart,start); + if(opts.isPascal){ + var qualifierMatch=prefix.match(/(?:^|[^A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\.\s*$/); + if(qualifierMatch){ + var qualifiedName=qualifierMatch[1].toLowerCase()+'.'+tokenName; + if(calls[qualifiedName]===undefined){ + calls[qualifiedName]=0; + refs[qualifiedName]=0; + } + matchedNames=[qualifiedName]; + } + } var isDefinition=false; if(/\b(function|class|def)\s*$/.test(prefix))isDefinition=true; if(opts.isPython&&/\b(async\s+def|def|class)\s*$/.test(prefix))isDefinition=true; @@ -4869,16 +4882,27 @@ function resolveCallDefinitions(fnName,file){ var isPascalCall=Parser.isPascal(file.path); + var pascalParts=isPascalCall?fnName.toLowerCase().split('.'):[]; + var pascalBaseName=isPascalCall?pascalParts.pop():fnName; + var pascalQualifier=isPascalCall?pascalParts.join('.'):''; var defs=isPascalCall - ?fnDefIndex.byPascalName[fnName.toLowerCase()]||[] + ?fnDefIndex.byPascalName[pascalBaseName]||[] :fnDefIndex.byName[fnName]||[]; if(!defs.length)return[]; + var imports=fileImportInfo[file.path]||{locals:Object.create(null),targets:new Set()}; + if(pascalQualifier){ + var qualifiedImports=(imports.locals&&imports.locals[pascalQualifier])||[]; + if(qualifiedImports.length){ + var qualifiedMatches=defs.filter(function(def){return qualifiedImports.indexOf(def.file)>=0;}); + var qualifiedDef=firstDefinitionFromOneFile(qualifiedMatches); + return qualifiedDef?[qualifiedDef]:[]; + } + } var sameFile=defs.filter(function(def){return def.file===file.path;}); var sameFileDef=firstDefinitionFromOneFile(sameFile); if(sameFileDef)return[sameFileDef]; if(defs.length===1)return[defs[0]]; - var imports=fileImportInfo[file.path]||{locals:Object.create(null),targets:new Set()}; var directImports=(imports.locals&&imports.locals[fnName])||[]; var matches=[]; if(directImports.length){ diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index 8ab945e..810a302 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -154,3 +154,39 @@ end. ); assert.equal(data.connections.some((connection) => connection.source === 'UnitB.pas'), false); }); + +test('Pascal unit-qualified calls select the named unit from case-folded definitions', async () => { + const data = await analyzePascalSources({ + 'UnitA.pas': `unit UnitA; +interface +procedure Render; +implementation +procedure Render; +begin +end; +end. +`, + 'UnitB.pas': `unit UnitB; +interface +procedure render; +implementation +procedure render; +begin +end; +end. +`, + 'app.lpr': `program QualifiedCaseFoldedCalls; +uses UnitA, UnitB; +begin + UnitA.RENDER; +end. +`, + }); + + const appConnections = data.connections.filter((connection) => connection.target === 'app.lpr'); + assert.deepEqual( + Array.from(appConnections, (connection) => connection.source + ':' + connection.fn), + ['UnitA.pas:Render'] + ); + assert.equal(data.connections.some((connection) => connection.source === 'UnitB.pas'), false); +}); From 8c3129b9e582a8047349359bfe333e048ac6ee76 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 17:20:29 -0400 Subject: [PATCH 4/8] Handle spaced Pascal unit qualifiers --- index.html | 38 ++++++++++++++++++++++++++--------- tests/pascal-support.test.mjs | 5 ++++- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 165da77..5825222 100644 --- a/index.html +++ b/index.html @@ -2833,6 +2833,26 @@ var candidateSet=new Set(Object.keys(canonicalNamesByToken)); if(!source||!candidateSet.size)return calls; + function pascalQualifierBefore(index){ + var cursor=index-1; + while(cursor>=0&&/\s/.test(source[cursor]))cursor--; + if(source[cursor]!=='.')return''; + cursor--; + var parts=[]; + while(cursor>=0){ + while(cursor>=0&&/\s/.test(source[cursor]))cursor--; + var end=cursor+1; + while(cursor>=0&&/[A-Za-z0-9_]/.test(source[cursor]))cursor--; + var part=source.slice(cursor+1,end); + if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))break; + parts.unshift(part); + while(cursor>=0&&/\s/.test(source[cursor]))cursor--; + if(source[cursor]!=='.')break; + cursor--; + } + return parts.join('.').toLowerCase(); + } + var tokenRe=/\b[a-zA-Z_$][\w$]*\b/g; var match; while((match=tokenRe.exec(source))!==null){ @@ -2849,16 +2869,14 @@ var prevChar=prev>=0?source[prev]:''; var lineStart=source.lastIndexOf('\n',start-1)+1; var prefix=source.slice(lineStart,start); - if(opts.isPascal){ - var qualifierMatch=prefix.match(/(?:^|[^A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\.\s*$/); - if(qualifierMatch){ - var qualifiedName=qualifierMatch[1].toLowerCase()+'.'+tokenName; - if(calls[qualifiedName]===undefined){ - calls[qualifiedName]=0; - refs[qualifiedName]=0; - } - matchedNames=[qualifiedName]; + var pascalQualifier=opts.isPascal?pascalQualifierBefore(start):''; + if(pascalQualifier){ + var qualifiedName=pascalQualifier+'.'+tokenName; + if(calls[qualifiedName]===undefined){ + calls[qualifiedName]=0; + refs[qualifiedName]=0; } + matchedNames=[qualifiedName]; } var isDefinition=false; if(/\b(function|class|def)\s*$/.test(prefix))isDefinition=true; @@ -2867,7 +2885,7 @@ if(opts.isPascal&&/\b(procedure|function|constructor|destructor|operator)\s+$/i.test(prefix))isDefinition=true; if(nextChar==='('&&!isDefinition){ matchedNames.forEach(function(name){calls[name]++;}); - }else if(opts.isPascal&&nextChar===';'&&!isDefinition&&/^(?:[A-Za-z_]\w*\.)*\s*$/.test(prefix.trim())){ + }else if(opts.isPascal&&nextChar===';'&&!isDefinition&&(pascalQualifier||/^(?:[A-Za-z_]\w*\.)*\s*$/.test(prefix.trim()))){ matchedNames.forEach(function(name){calls[name]++;}); }else if(!isDefinition&&'[,[:(={'.indexOf(prevChar)>=0&&' ,])};\n\r'.indexOf(nextChar)>=0){ matchedNames.forEach(function(name){refs[name]++;}); diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index 810a302..fc77a00 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -178,7 +178,9 @@ end. 'app.lpr': `program QualifiedCaseFoldedCalls; uses UnitA, UnitB; begin - UnitA.RENDER; + UnitA . RENDER; + UnitA. + RENDER; end. `, }); @@ -188,5 +190,6 @@ end. Array.from(appConnections, (connection) => connection.source + ':' + connection.fn), ['UnitA.pas:Render'] ); + assert.equal(appConnections[0].count, 2); assert.equal(data.connections.some((connection) => connection.source === 'UnitB.pas'), false); }); From 7c4678b54675ac866ef163764e9fdf843ca73c0c Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 17:38:17 -0400 Subject: [PATCH 5/8] Honor Pascal uses clause precedence --- index.html | 9 +++++++ tests/pascal-support.test.mjs | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/index.html b/index.html index 5825222..72960a8 100644 --- a/index.html +++ b/index.html @@ -4930,6 +4930,15 @@ } if(imports.targets&&typeof imports.targets.has==='function'){ + if(isPascalCall){ + var orderedTargets=Array.from(imports.targets); + for(var targetIndex=orderedTargets.length-1;targetIndex>=0;targetIndex--){ + matches=defs.filter(function(def){return def.file===orderedTargets[targetIndex];}); + var precedenceDef=firstDefinitionFromOneFile(matches); + if(precedenceDef)return[precedenceDef]; + } + return[]; + } matches=defs.filter(function(def){return imports.targets.has(def.file);}); var importedDef=firstDefinitionFromOneFile(matches); if(importedDef)return[importedDef]; diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index fc77a00..fe88983 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -193,3 +193,48 @@ end. assert.equal(appConnections[0].count, 2); assert.equal(data.connections.some((connection) => connection.source === 'UnitB.pas'), false); }); + +test('Pascal unqualified calls use the last matching unit in the uses clause', async () => { + const data = await analyzePascalSources({ + 'UnitA.pas': `unit UnitA; +interface +procedure Render; +implementation +procedure Render; +begin +end; +end. +`, + 'UnitB.pas': `unit UnitB; +interface +procedure render; +implementation +procedure render; +begin +end; +end. +`, + 'app-ab.lpr': `program UsesAThenB; +uses UnitA, UnitB; +begin + RENDER; +end. +`, + 'app-ba.lpr': `program UsesBThenA; +uses UnitB, UnitA; +begin + render; +end. +`, + }); + + const sourceByCaller = Object.fromEntries( + data.connections + .filter((connection) => connection.target.startsWith('app-')) + .map((connection) => [connection.target, connection.source]) + ); + assert.deepEqual(sourceByCaller, { + 'app-ab.lpr': 'UnitB.pas', + 'app-ba.lpr': 'UnitA.pas', + }); +}); From 785bb8b2ff39713c3e227c1a119ff7feb62bd287 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 17:49:27 -0400 Subject: [PATCH 6/8] Ignore qualified Pascal declarations in call counts --- index.html | 5 ++++- tests/pascal-support.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 72960a8..fd12b2d 100644 --- a/index.html +++ b/index.html @@ -2882,7 +2882,10 @@ if(/\b(function|class|def)\s*$/.test(prefix))isDefinition=true; if(opts.isPython&&/\b(async\s+def|def|class)\s*$/.test(prefix))isDefinition=true; if(opts.isVBA&&/\b(Sub|Function)\s+$/i.test(prefix))isDefinition=true; - if(opts.isPascal&&/\b(procedure|function|constructor|destructor|operator)\s+$/i.test(prefix))isDefinition=true; + if(opts.isPascal){ + var declarationContext=source.slice(Math.max(0,start-512),start); + if(/\b(?:procedure|function|constructor|destructor|operator)\s+(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)*$/i.test(declarationContext))isDefinition=true; + } if(nextChar==='('&&!isDefinition){ matchedNames.forEach(function(name){calls[name]++;}); }else if(opts.isPascal&&nextChar===';'&&!isDefinition&&(pascalQualifier||/^(?:[A-Za-z_]\w*\.)*\s*$/.test(prefix.trim()))){ diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index fe88983..46e7821 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -238,3 +238,26 @@ end. 'app-ba.lpr': 'UnitA.pas', }); }); + +test('Pascal qualified routine declarations are not counted as calls', async () => { + const data = await analyzePascalSources({ + 'Thing.pas': `unit Thing; +interface +type + TThing = class + procedure Render; + end; +implementation +procedure TThing.Render; +begin +end; +end. +`, + }); + + const renderStats = Object.values(data.fnStats).find((stats) => stats.name === 'Render'); + assert.ok(renderStats); + assert.equal(renderStats.internal, 0); + assert.equal(renderStats.external, 0); + assert.equal(renderStats.count, 0); +}); From 34aeffbc298d3d9fee3e2991aeaae4c00642e55c Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 17:54:51 -0400 Subject: [PATCH 7/8] Avoid misresolving Pascal member calls --- index.html | 3 +++ tests/pascal-support.test.mjs | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/index.html b/index.html index fd12b2d..d438b50 100644 --- a/index.html +++ b/index.html @@ -4911,6 +4911,7 @@ :fnDefIndex.byName[fnName]||[]; if(!defs.length)return[]; var imports=fileImportInfo[file.path]||{locals:Object.create(null),targets:new Set()}; + var hasUnresolvedPascalQualifier=false; if(pascalQualifier){ var qualifiedImports=(imports.locals&&imports.locals[pascalQualifier])||[]; if(qualifiedImports.length){ @@ -4918,10 +4919,12 @@ var qualifiedDef=firstDefinitionFromOneFile(qualifiedMatches); return qualifiedDef?[qualifiedDef]:[]; } + hasUnresolvedPascalQualifier=true; } var sameFile=defs.filter(function(def){return def.file===file.path;}); var sameFileDef=firstDefinitionFromOneFile(sameFile); if(sameFileDef)return[sameFileDef]; + if(hasUnresolvedPascalQualifier)return[]; if(defs.length===1)return[defs[0]]; var directImports=(imports.locals&&imports.locals[fnName])||[]; diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index 46e7821..84b1020 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -261,3 +261,40 @@ end. assert.equal(renderStats.external, 0); assert.equal(renderStats.count, 0); }); + +test('Pascal member calls do not use unit import precedence', async () => { + const data = await analyzePascalSources({ + 'UnitA.pas': `unit UnitA; +interface +type + TThing = class + procedure Render; + end; +implementation +procedure TThing.Render; +begin +end; +end. +`, + 'UnitB.pas': `unit UnitB; +interface +procedure render; +implementation +procedure render; +begin +end; +end. +`, + 'app.lpr': `program MemberCall; +uses UnitA, UnitB; +var + Obj: UnitA.TThing; +begin + Obj.Render; +end. +`, + }); + + const appConnections = data.connections.filter((connection) => connection.target === 'app.lpr'); + assert.deepEqual(Array.from(appConnections), []); +}); From 576963121978324c69a0fafd628bfcd3a8378a05 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Fri, 31 Jul 2026 18:03:38 -0400 Subject: [PATCH 8/8] Preserve Pascal expression receivers --- index.html | 2 +- tests/pascal-support.test.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index d438b50..f3cfeaf 100644 --- a/index.html +++ b/index.html @@ -2850,7 +2850,7 @@ if(source[cursor]!=='.')break; cursor--; } - return parts.join('.').toLowerCase(); + return parts.length?parts.join('.').toLowerCase():'@member'; } var tokenRe=/\b[a-zA-Z_$][\w$]*\b/g; diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs index 84b1020..232087e 100644 --- a/tests/pascal-support.test.mjs +++ b/tests/pascal-support.test.mjs @@ -289,8 +289,11 @@ end. uses UnitA, UnitB; var Obj: UnitA.TThing; + Items: array of UnitA.TThing; begin Obj.Render; + GetThing().Render(); + Items[0].Render(); end. `, });