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..f3cfeaf 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 +2815,52 @@ 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){ + var token=opts.isPascal?fn.toLowerCase():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; + 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.length?parts.join('.').toLowerCase():'@member'; + } + 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; @@ -2815,19 +2869,31 @@ var prevChar=prev>=0?source[prev]:''; var lineStart=source.lastIndexOf('\n',start-1)+1; var prefix=source.slice(lineStart,start); + 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; 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){ - 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(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){ - refs[name]++; + matchedNames.forEach(function(name){refs[name]++;}); }else if(opts.isPython&&prevChar==='@'&&!isDefinition){ - refs[name]++; + matchedNames.forEach(function(name){refs[name]++;}); } } @@ -2857,7 +2923,7 @@ } } - fnNames.forEach(function(fn){ + Object.keys(calls).forEach(function(fn){ calls[fn]=Math.max(0,calls[fn]||0)+(refs[fn]||0); }); return calls; @@ -2865,7 +2931,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 +2971,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){ @@ -4835,14 +4902,31 @@ } function resolveCallDefinitions(fnName,file){ - var defs=fnDefIndex.byName[fnName]||[]; + 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[pascalBaseName]||[] + :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){ + var qualifiedMatches=defs.filter(function(def){return qualifiedImports.indexOf(def.file)>=0;}); + 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 imports=fileImportInfo[file.path]||{locals:Object.create(null),targets:new Set()}; var directImports=(imports.locals&&imports.locals[fnName])||[]; var matches=[]; if(directImports.length){ @@ -4852,6 +4936,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]; @@ -4880,7 +4973,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/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..232087e 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({ @@ -77,7 +82,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') @@ -89,3 +118,186 @@ test('Pascal call graph follows uses units and ignores comments and strings', as 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); +}); + +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; + 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(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', + }); +}); + +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); +}); + +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; + Items: array of UnitA.TThing; +begin + Obj.Render; + GetThing().Render(); + Items[0].Render(); +end. +`, + }); + + const appConnections = data.connections.filter((connection) => connection.target === 'app.lpr'); + assert.deepEqual(Array.from(appConnections), []); +});