diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/degradation.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/degradation.json.gz new file mode 100644 index 00000000..501cdbbc Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/degradation.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/latest.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/latest.json.gz new file mode 100644 index 00000000..c3c2dcb0 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/latest.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/snapshots/2026-02-04.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/snapshots/2026-02-04.json.gz new file mode 100644 index 00000000..c3c2dcb0 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/audit/snapshots/2026-02-04.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/backup-manifest.json b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/backup-manifest.json new file mode 100644 index 00000000..ac987c35 --- /dev/null +++ b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/backup-manifest.json @@ -0,0 +1,32 @@ +{ + "id": "a032d236", + "driftVersion": "0.9.48", + "schemaVersion": "2.0.0", + "createdAt": "2026-02-04T01:23:24.161Z", + "reason": "pre_destructive_operation", + "sizeBytes": 39629544, + "checksum": "78f61599bbfa03839566c8c30b02a4830f7e5f1c9d5aef3e2211a6e82168af40", + "originalPath": "/Users/geoffreyfernald/drift/drift/.drift", + "projectName": "drift", + "compressed": true, + "files": [ + "audit/degradation.json", + "audit/latest.json", + "audit/snapshots/2026-02-04.json", + "config.json", + "dna/styling.json", + "drift.db", + "drift.db-shm", + "drift.db-wal", + "error-handling/analysis.json", + "lake/callgraph/callgraph.db", + "lake/callgraph/callgraph.db-shm", + "lake/callgraph/callgraph.db-wal", + "manifest.json", + "memory/cortex.db", + "module-coupling/graph.json", + "source-of-truth.json", + "test-topology/summary.json", + "views/status.json" + ] +} \ No newline at end of file diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/config.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/config.json.gz new file mode 100644 index 00000000..e848dc79 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/config.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/dna/styling.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/dna/styling.json.gz new file mode 100644 index 00000000..5d82c831 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/dna/styling.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db new file mode 100644 index 00000000..50db9a09 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db-shm b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db-shm new file mode 100644 index 00000000..2fd0de1f Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db-shm differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db-wal b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db-wal new file mode 100644 index 00000000..0619dc39 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/drift.db-wal differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/error-handling/analysis.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/error-handling/analysis.json.gz new file mode 100644 index 00000000..8663c36f Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/error-handling/analysis.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db new file mode 100644 index 00000000..84344b66 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db-shm b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db-shm new file mode 100644 index 00000000..fe9ac284 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db-shm differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db-wal b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/lake/callgraph/callgraph.db-wal new file mode 100644 index 00000000..e69de29b diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/manifest.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/manifest.json.gz new file mode 100644 index 00000000..b3e33c9e Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/manifest.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/memory/cortex.db b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/memory/cortex.db new file mode 100644 index 00000000..a076ff1e Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/memory/cortex.db differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/module-coupling/graph.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/module-coupling/graph.json.gz new file mode 100644 index 00000000..31e80428 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/module-coupling/graph.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/source-of-truth.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/source-of-truth.json.gz new file mode 100644 index 00000000..f33dbfcc Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/source-of-truth.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/test-topology/summary.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/test-topology/summary.json.gz new file mode 100644 index 00000000..8a5f7d64 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/test-topology/summary.json.gz differ diff --git a/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/views/status.json.gz b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/views/status.json.gz new file mode 100644 index 00000000..15a49177 Binary files /dev/null and b/.drift-backups/backup-2026-02-04T01-23-24-082Z-pre_destructive_operation/views/status.json.gz differ diff --git a/.drift-backups/index.json b/.drift-backups/index.json index 969e3ab2..58cd32a8 100644 --- a/.drift-backups/index.json +++ b/.drift-backups/index.json @@ -541,6 +541,38 @@ "views/security-summary.json", "views/status.json" ] + }, + { + "id": "a032d236", + "driftVersion": "0.9.48", + "schemaVersion": "2.0.0", + "createdAt": "2026-02-04T01:23:24.161Z", + "reason": "pre_destructive_operation", + "sizeBytes": 39629544, + "checksum": "78f61599bbfa03839566c8c30b02a4830f7e5f1c9d5aef3e2211a6e82168af40", + "originalPath": "/Users/geoffreyfernald/drift/drift/.drift", + "projectName": "drift", + "compressed": true, + "files": [ + "audit/degradation.json", + "audit/latest.json", + "audit/snapshots/2026-02-04.json", + "config.json", + "dna/styling.json", + "drift.db", + "drift.db-shm", + "drift.db-wal", + "error-handling/analysis.json", + "lake/callgraph/callgraph.db", + "lake/callgraph/callgraph.db-shm", + "lake/callgraph/callgraph.db-wal", + "manifest.json", + "memory/cortex.db", + "module-coupling/graph.json", + "source-of-truth.json", + "test-topology/summary.json", + "views/status.json" + ] } ] } \ No newline at end of file diff --git a/drift/.driftignore b/drift/.driftignore new file mode 100644 index 00000000..86319c44 --- /dev/null +++ b/drift/.driftignore @@ -0,0 +1,220 @@ +node_modules/** +.git/** +.svn/** +.hg/** +.drift/** +dist/** +build/** +out/** +output/** +coverage/** +.next/** +.nuxt/** +.output/** +.vercel/** +.netlify/** +.npm/** +.yarn/** +.pnpm-store/** +.turbo/** +.nx/** +.angular/** +.parcel-cache/** +bower_components/** +__pycache__/** +.venv/** +venv/** +env/** +.eggs/** +*.egg-info/** +.tox/** +.nox/** +.mypy_cache/** +.pytest_cache/** +.ruff_cache/** +.pytype/** +htmlcov/** +.hypothesis/** +*.pyc +*.pyo +*.pyd +.Python +pip-wheel-metadata/** +bin/** +obj/** +packages/** +.vs/** +TestResults/** +BenchmarkDotNet.Artifacts/** +*.dll +*.exe +*.pdb +*.nupkg +*.snupkg +*.msi +*.msix +wwwroot/lib/** +publish/** +artifacts/** +target/** +.gradle/** +.m2/** +.mvn/** +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* +vendor/** +*.exe +*.test +*.out +target/** +*.rlib +*.rmeta +Cargo.lock +cmake-build-*/** +CMakeFiles/** +CMakeCache.txt +.ccache/** +conan/** +*.o +*.obj +*.a +*.lib +*.so +*.so.* +*.dylib +*.dll +*.exe +*.out +*.app +*.dSYM/** +*.gcno +*.gcda +*.gcov +.bundle/** +vendor/bundle/** +*.gem +vendor/** +storage/framework/** +bootstrap/cache/** +*.phar +_build/** +deps/** +.elixir_ls/** +*.beam +erl_crash.dump +.build/** +DerivedData/** +Pods/** +Carthage/** +*.xcworkspace/** +*.xcodeproj/** +*.playground/** +*.ipa +*.dSYM/** +.gradle/** +.cxx/** +*.apk +*.aab +*.ap_ +local.properties +.idea/** +.vscode/** +.vs/** +.eclipse/** +.settings/** +*.swp +*.swo +*~ +.project +.classpath +*.iml +.DS_Store +Thumbs.db +desktop.ini +*.lnk +*.log +logs/** +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +tmp/** +temp/** +.cache/** +.temp/** +.tmp/** +*.zip +*.rar +*.7z +*.tar +*.tar.gz +*.tgz +*.tar.bz2 +*.tar.xz +*.gz +*.bz2 +*.xz +*.sqlite +*.sqlite3 +*.db +*.mdb +*.accdb +*.mp3 +*.mp4 +*.avi +*.mov +*.wmv +*.flv +*.wav +*.ogg +*.pdf +*.doc +*.docx +*.xls +*.xlsx +*.ppt +*.pptx +*.psd +*.ai +*.sketch +*.fig +*.min.js +*.min.css +*.bundle.js +*.bundle.css +*.chunk.js +*.chunk.css +*.map +*.js.map +*.css.map +package-lock.json +yarn.lock +pnpm-lock.yaml +composer.lock +Gemfile.lock +poetry.lock +Pipfile.lock +go.sum +_site/** +.docusaurus/** +.vitepress/** +docs/_build/** +site/** +javadoc/** +apidoc/** +.terraform/** +*.tfstate +*.tfstate.* +.docker/** +.kube/** +.serverless/** +.aws-sam/** +fixtures/**/*.json +__fixtures__/** +test-data/** +testdata/** \ No newline at end of file diff --git a/package.json b/package.json index fde2c26e..cab5b9d6 100644 --- a/package.json +++ b/package.json @@ -46,16 +46,17 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "eslint": "^9.0.0", "eslint-plugin-import": "^2.29.0", "husky": "^8.0.3", "prettier": "^3.1.0", + "tsx": "^4.7.0", "turbo": "^1.11.0", "typescript": "^5.3.0", "typescript-eslint": "^8.0.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" }, "packageManager": "pnpm@8.10.0", "engines": { diff --git a/packages/ai/package.json b/packages/ai/package.json index f2497c8d..23331211 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -30,9 +30,9 @@ "driftdetect-core": "^0.9.28" }, "devDependencies": { - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" } } diff --git a/packages/ci/package.json b/packages/ci/package.json index dc300361..d4a6e32f 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -38,10 +38,10 @@ "simple-git": "^3.30.0" }, "devDependencies": { - "@types/node": "^20.19.30", + "@types/node": "^25.2.0", "tsx": "^4.7.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" }, "engines": { "node": ">=18" diff --git a/packages/cibench/package.json b/packages/cibench/package.json index 7bc955ac..448a5e28 100644 --- a/packages/cibench/package.json +++ b/packages/cibench/package.json @@ -34,9 +34,9 @@ "glob": "^10.3.10" }, "devDependencies": { - "@types/node": "^20.11.0", + "@types/node": "^25.2.0", "typescript": "^5.3.3", - "vitest": "^1.2.0" + "vitest": "^4.0.18" }, "engines": { "node": ">=18.0.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index e2bb8b8f..9dfdf825 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -79,10 +79,10 @@ }, "devDependencies": { "@types/cli-progress": "^3.11.6", - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "fast-check": "^3.15.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" } } diff --git a/packages/cli/src/bin/drift.ts b/packages/cli/src/bin/drift.ts index d5493db0..b4696998 100644 --- a/packages/cli/src/bin/drift.ts +++ b/packages/cli/src/bin/drift.ts @@ -58,6 +58,7 @@ import { setupCommand, backupCommand, importCommand, + stCommand, } from '../commands/index.js'; import { VERSION } from '../index.js'; @@ -167,6 +168,9 @@ function createProgram(): Command { // Database Import (Phase 3) program.addCommand(importCommand); + // IEC 61131-3 Structured Text Analysis (Industrial Automation) + program.addCommand(stCommand); + // Add help examples program.addHelpText( 'after', @@ -288,6 +292,16 @@ Examples: $ drift backup restore Restore from a backup $ drift backup delete Delete a backup (requires typing DELETE) $ drift backup info Show backup details + $ drift st status IEC 61131-3 project overview + $ drift st docstrings Extract PLC documentation + $ drift st state-machines Find CASE-based state machines + $ drift st safety Analyze safety interlocks + $ drift st tribal Extract tribal knowledge + $ drift st blocks List all POUs + $ drift st variables Extract all variables + $ drift st io-map I/O address mappings + $ drift st ai-context Generate AI migration context + $ drift st export report.json Export full analysis Documentation: https://github.com/drift/drift diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index cfad5a62..1405baed 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -95,4 +95,7 @@ export { setupCommand } from './setup.js'; export { backupCommand } from './backup.js'; // Database Import (Phase 3) -export { importCommand } from './import.js'; \ No newline at end of file +export { importCommand } from './import.js'; + +// IEC 61131-3 Structured Text Analysis (Industrial Automation) +export { stCommand } from './st.js'; \ No newline at end of file diff --git a/packages/cli/src/commands/scan.ts b/packages/cli/src/commands/scan.ts index 36427cf9..8e15ea4e 100644 --- a/packages/cli/src/commands/scan.ts +++ b/packages/cli/src/commands/scan.ts @@ -47,7 +47,7 @@ import { import { createBoundaryScanner, type BoundaryScanResult } from '../services/boundary-scanner.js'; import { createContractScanner } from '../services/contract-scanner.js'; -import { createScannerService, type ProjectContext, type AggregatedPattern, type AggregatedViolation } from '../services/scanner-service.js'; +import { createScannerService, type AggregatedPattern, type AggregatedViolation } from '../services/scanner-service.js'; import { createSpinner, status } from '../ui/spinner.js'; import { createPatternsTable, type PatternRow } from '../ui/table.js'; @@ -782,19 +782,20 @@ async function scanSingleProject(rootDir: string, options: ScanCommandOptions, q try { await scannerService.initialize(); - const counts = scannerService.getDetectorCounts(); + const counts = await scannerService.getDetectorCounts(); + const totalAvailable = Object.values(counts).reduce((sum, n) => sum + n, 0); const workerInfo = scannerService.isUsingWorkerThreads() ? chalk.green(` [${scannerService.getWorkerThreadCount()} worker threads]`) : chalk.yellow(' [single-threaded]'); - initSpinner.succeed(`Loaded ${scannerService.getDetectorCount()} detectors (${counts.total} available)${workerInfo}`); + initSpinner.succeed(`Loaded ${scannerService.getDetectorCount()} detectors (${totalAvailable} available)${workerInfo}`); } catch (error) { initSpinner.fail('Failed to load detectors'); console.error(chalk.red((error as Error).message)); process.exit(1); } - // Create project context - const projectContext: ProjectContext = { + // Create project context for scanner service + const scannerProjectContext = { rootDir, files, config: {}, @@ -811,7 +812,7 @@ async function scanSingleProject(rootDir: string, options: ScanCommandOptions, q try { healthMonitor.start(); const scanResults = await healthMonitor.withTimeout( - scannerService.scanFiles(files, projectContext) + scannerService.scanFiles(files, scannerProjectContext) ); const duration = ((Date.now() - startTime) / 1000).toFixed(2); diff --git a/packages/cli/src/commands/st.ts b/packages/cli/src/commands/st.ts new file mode 100644 index 00000000..487646bd --- /dev/null +++ b/packages/cli/src/commands/st.ts @@ -0,0 +1,1562 @@ +/** + * ST Command - drift st + * + * Analyze IEC 61131-3 Structured Text code: docstrings, state machines, safety interlocks, tribal knowledge. + * + * This is the CLI-first implementation. MCP is a thin wrapper around this. + * Following architecture doc Part 11: CLI-First Design + * + * @requirements IEC 61131-3 Code Factory + */ + +import chalk from 'chalk'; +import { Command } from 'commander'; +import { IEC61131Analyzer } from 'driftdetect-core/iec61131'; + +import { createSpinner } from '../ui/spinner.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface STOptions { + format?: 'text' | 'json'; + verbose?: boolean; + limit?: string; + includeRaw?: boolean; + strict?: boolean; + target?: string; +} + +// ============================================================================ +// Main Command +// ============================================================================ + +/** + * Create the ST command with all subcommands + */ +function createSTCommand(): Command { + const st = new Command('st') + .description('Analyze IEC 61131-3 Structured Text code'); + + // drift st status + st + .command('status [path]') + .description('Show ST project analysis summary') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .action(statusAction); + + // drift st docstrings + st + .command('docstrings [path]') + .description('Extract documentation from ST files (PhD primary request)') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .option('-l, --limit ', 'Limit results', '50') + .option('--include-raw', 'Include raw docstring text') + .action(docstringsAction); + + // drift st blocks + st + .command('blocks [path]') + .description('List all POUs (PROGRAM, FUNCTION_BLOCK, FUNCTION)') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .option('-l, --limit ', 'Limit results', '50') + .action(blocksAction); + + // drift st state-machines + st + .command('state-machines [path]') + .description('Detect CASE-based state machines') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .option('-l, --limit ', 'Limit results', '20') + .action(stateMachinesAction); + + // drift st safety + st + .command('safety [path]') + .description('Analyze safety interlocks and bypasses (CRITICAL)') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .option('--strict', 'Exit with error if bypasses detected') + .action(safetyAction); + + // drift st tribal + st + .command('tribal [path]') + .description('Extract tribal knowledge (warnings, workarounds, gotchas)') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .option('-l, --limit ', 'Limit results', '50') + .action(tribalAction); + + // drift st variables + st + .command('variables [path]') + .description('Extract all variables with types and comments') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .option('-l, --limit ', 'Limit results', '100') + .action(variablesAction); + + // drift st io-map + st + .command('io-map [path]') + .description('Extract I/O address mappings') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .action(ioMapAction); + + // drift st diagram + st + .command('diagram [path]') + .description('Generate state machine diagrams (Mermaid)') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .action(diagramAction); + + // drift st migration + st + .command('migration [path]') + .description('Calculate migration readiness scores') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .action(migrationAction); + + // drift st ai-context + st + .command('ai-context [path]') + .description('Generate AI context package for migration') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-t, --target ', 'Target language: python, rust, typescript, csharp, cpp, go, java', 'python') + .option('--max-tokens ', 'Maximum tokens for context', '50000') + .action(aiContextAction); + + // drift st call-graph + st + .command('call-graph [path]') + .description('Build and display call graph') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .action(callGraphAction); + + // drift st all + st + .command('all [path]') + .description('Run full analysis pipeline') + .option('-f, --format ', 'Output format: text, json', 'text') + .option('-v, --verbose', 'Enable verbose output') + .action(allAction); + + return st; +} + +// Export as stCommand for consistency with other commands +export const stCommand = createSTCommand(); + +// ============================================================================ +// Action Implementations +// ============================================================================ + +/** + * Status subcommand - Project overview + */ +async function statusAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Analyzing ST project...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.status(); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ­ IEC 61131-3 Project Status')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Project info + console.log(chalk.bold('Project')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Path: ${chalk.cyan(result.project.path)}`); + console.log(` Name: ${chalk.cyan(result.project.name)}`); + if (result.project.vendor) { + console.log(` Vendor: ${chalk.cyan(result.project.vendor)}`); + } + if (result.project.plcType) { + console.log(` PLC Type: ${chalk.cyan(result.project.plcType)}`); + } + console.log(); + + // Files + console.log(chalk.bold('Files')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.files.total)}`); + console.log(` Lines: ${chalk.cyan(result.files.totalLines.toLocaleString())}`); + for (const [ext, count] of Object.entries(result.files.byExtension)) { + console.log(` ${ext}: ${chalk.cyan(count)}`); + } + console.log(); + + // Analysis + console.log(chalk.bold('Analysis')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` POUs: ${chalk.cyan(result.analysis.pous)}`); + console.log(` State Machines: ${chalk.cyan(result.analysis.stateMachines)}`); + console.log(` Safety Interlocks: ${chalk.cyan(result.analysis.safetyInterlocks)}`); + console.log(` Tribal Knowledge: ${chalk.cyan(result.analysis.tribalKnowledge)}`); + console.log(` Docstrings: ${chalk.cyan(result.analysis.docstrings)}`); + console.log(); + + // Health + console.log(chalk.bold('Health')); + console.log(chalk.gray('─'.repeat(40))); + const healthColor = result.health.score >= 70 ? chalk.green : + result.health.score >= 40 ? chalk.yellow : chalk.red; + console.log(` Score: ${healthColor(`${result.health.score}/100`)}`); + if (result.health.issues.length > 0) { + for (const issue of result.health.issues) { + console.log(chalk.yellow(` ⚠ ${issue}`)); + } + } + console.log(); + + // Next steps + console.log(chalk.gray('─'.repeat(60))); + console.log(chalk.bold('šŸ“Œ Next Steps:')); + console.log(chalk.gray(` • drift st docstrings ${chalk.white('Extract documentation')}`)); + console.log(chalk.gray(` • drift st state-machines ${chalk.white('Detect state machines')}`)); + console.log(chalk.gray(` • drift st safety ${chalk.white('Analyze safety interlocks')}`)); + console.log(chalk.gray(` • drift st tribal ${chalk.white('Extract tribal knowledge')}`)); + console.log(chalk.gray(` • drift st blocks ${chalk.white('List all POUs')}`)); + console.log(chalk.gray(` • drift st all ${chalk.white('Run full analysis')}`)); + console.log(); + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Docstrings subcommand - Extract documentation (PhD's primary request) + */ +async function docstringsAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + const limit = parseInt(options.limit ?? '50', 10); + const includeRaw = options.includeRaw ?? false; + + const spinner = isTextFormat ? createSpinner('Extracting docstrings...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.docstrings(undefined, { includeRaw, limit }); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify({ + total: result.summary.total, + byBlock: result.summary.byBlock, + withParams: result.summary.withParams, + withHistory: result.summary.withHistory, + withWarnings: result.summary.withWarnings, + averageQuality: Math.round(result.summary.averageQuality), + docstrings: result.docstrings, + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ“š Extracted Docstrings')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.summary.total)}`); + console.log(` With Parameters: ${chalk.cyan(result.summary.withParams)}`); + console.log(` With History: ${chalk.cyan(result.summary.withHistory)}`); + console.log(` With Warnings: ${chalk.yellow(result.summary.withWarnings)}`); + console.log(` Average Quality: ${getQualityColor(result.summary.averageQuality)}`); + console.log(); + + // By block type + if (Object.keys(result.summary.byBlock).length > 0) { + console.log(chalk.bold('By Block Type')); + console.log(chalk.gray('─'.repeat(40))); + for (const [block, count] of Object.entries(result.summary.byBlock)) { + console.log(` ${block}: ${chalk.cyan(count)}`); + } + console.log(); + } + + // Docstrings + if (result.docstrings.length > 0) { + console.log(chalk.bold('Docstrings')); + console.log(chalk.gray('─'.repeat(40))); + + for (const doc of result.docstrings) { + const blockInfo = doc.associatedBlock ? chalk.cyan(doc.associatedBlock) : chalk.gray('standalone'); + console.log(` šŸ“„ ${blockInfo} ${chalk.gray(`(${doc.file}:${doc.location.line})`)}`); + console.log(` ${chalk.white(doc.summary || doc.description?.slice(0, 80) || 'No summary')}`); + + if (doc.params.length > 0 && options.verbose) { + console.log(chalk.gray(` Params: ${doc.params.map((p: { name: string }) => p.name).join(', ')}`)); + } + if (doc.warnings.length > 0) { + console.log(chalk.yellow(` ⚠ ${doc.warnings.length} warning(s)`)); + } + console.log(); + } + + if (result.summary.total > result.docstrings.length) { + console.log(chalk.gray(` ... and ${result.summary.total - result.docstrings.length} more (use --limit to see more)`)); + console.log(); + } + } else { + console.log(chalk.gray('No docstrings found')); + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Blocks subcommand - List all POUs + */ +async function blocksAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + const limit = parseInt(options.limit ?? '50', 10); + + const spinner = isTextFormat ? createSpinner('Listing POUs...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.blocks(undefined, { limit }); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('🧱 Program Organization Units (POUs)')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.summary.total)}`); + for (const [type, count] of Object.entries(result.summary.byType)) { + const icon = type === 'PROGRAM' ? 'šŸ“‹' : type === 'FUNCTION_BLOCK' ? '🧩' : 'ʒ'; + console.log(` ${icon} ${type}: ${chalk.cyan(count)}`); + } + console.log(); + + // Blocks + if (result.blocks.length > 0) { + console.log(chalk.bold('Blocks')); + console.log(chalk.gray('─'.repeat(40))); + + // Group by type + const byType = new Map(); + for (const block of result.blocks) { + const existing = byType.get(block.type) ?? []; + existing.push(block); + byType.set(block.type, existing); + } + + for (const [type, blocks] of byType) { + const icon = type === 'PROGRAM' ? 'šŸ“‹' : type === 'FUNCTION_BLOCK' ? '🧩' : 'ʒ'; + console.log(); + console.log(chalk.bold(` ${icon} ${type}`)); + + for (const block of blocks.slice(0, 20)) { + console.log(` ${chalk.cyan(block.name)} ${chalk.gray(`(${block.file}:${block.line})`)}`); + } + + if (blocks.length > 20) { + console.log(chalk.gray(` ... and ${blocks.length - 20} more`)); + } + } + console.log(); + } else { + console.log(chalk.gray('No POUs found')); + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * State Machines subcommand - Detect CASE-based state machines + */ +async function stateMachinesAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + const limit = parseInt(options.limit ?? '20', 10); + + const spinner = isTextFormat ? createSpinner('Detecting state machines...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.stateMachines(undefined, { limit }); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify({ + total: result.summary.total, + totalStates: result.summary.totalStates, + byVariable: result.summary.byVariable, + withDeadlocks: result.summary.withDeadlocks, + withGaps: result.summary.withGaps, + machines: result.stateMachines.map(sm => ({ + name: sm.name, + file: sm.file, + variable: sm.stateVariable, + states: sm.states, + transitions: sm.transitions, + verification: sm.verification, + diagram: sm.visualizations.mermaid, + })), + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ”„ State Machines')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.summary.total)}`); + console.log(` Total States: ${chalk.cyan(result.summary.totalStates)}`); + if (result.summary.withDeadlocks > 0) { + console.log(` With Deadlocks: ${chalk.red(result.summary.withDeadlocks)}`); + } + if (result.summary.withGaps > 0) { + console.log(` With Gaps: ${chalk.yellow(result.summary.withGaps)}`); + } + console.log(); + + // State machines + if (result.stateMachines.length > 0) { + for (const sm of result.stateMachines) { + console.log(chalk.bold(` šŸ”„ ${sm.name}`)); + console.log(chalk.gray(` File: ${sm.file}`)); + console.log(chalk.gray(` Variable: ${sm.stateVariable}`)); + console.log(` States: ${chalk.cyan(sm.states.length)}`); + console.log(` Transitions: ${chalk.cyan(sm.transitions.length)}`); + + // Verification warnings + if (sm.verification.hasDeadlocks) { + console.log(chalk.red(` ⚠ Has deadlocks`)); + } + if (sm.verification.hasGaps) { + console.log(chalk.yellow(` ⚠ Has gaps in state values`)); + } + if (sm.verification.unreachableStates.length > 0) { + console.log(chalk.yellow(` ⚠ Unreachable states: ${sm.verification.unreachableStates.join(', ')}`)); + } + + // States list + if (options.verbose) { + console.log(chalk.gray(' States:')); + for (const state of sm.states) { + const initial = state.isInitial ? chalk.green(' (initial)') : ''; + const final = state.isFinal ? chalk.blue(' (final)') : ''; + console.log(chalk.gray(` ${state.value}: ${state.name || 'unnamed'}${initial}${final}`)); + } + } + + // Mermaid diagram + console.log(); + console.log(chalk.gray(' Diagram (Mermaid):')); + console.log(chalk.gray(' ```mermaid')); + for (const line of sm.visualizations.mermaid.split('\n')) { + console.log(chalk.gray(` ${line}`)); + } + console.log(chalk.gray(' ```')); + console.log(); + } + } else { + console.log(chalk.gray('No state machines found')); + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Safety subcommand - Analyze safety interlocks and bypasses (CRITICAL) + */ +async function safetyAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Analyzing safety interlocks...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.safety(); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify({ + interlocks: result.interlocks, + bypasses: result.bypasses, + criticalWarnings: result.criticalWarnings, + summary: result.summary, + }, null, 2)); + + // Exit with error if strict mode and bypasses found + if (options.strict && result.bypasses.length > 0) { + process.exit(1); + } + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ›”ļø Safety Analysis')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total Interlocks: ${chalk.cyan(result.summary.totalInterlocks)}`); + for (const [type, count] of Object.entries(result.summary.byType)) { + if (count > 0) { + const color = type === 'bypass' ? chalk.red : + type === 'estop' ? chalk.yellow : chalk.green; + console.log(` ${type}: ${color(count)}`); + } + } + console.log(); + + // CRITICAL: Bypasses + if (result.bypasses.length > 0) { + console.log(chalk.red.bold('🚨 CRITICAL: SAFETY BYPASSES DETECTED')); + console.log(chalk.red('─'.repeat(40))); + + for (const bypass of result.bypasses) { + console.log(chalk.red(` ⚠ ${bypass.name}`)); + console.log(chalk.red(` File: ${bypass.location.file}:${bypass.location.line}`)); + console.log(chalk.red(` Severity: ${bypass.severity}`)); + if (bypass.condition) { + console.log(chalk.red(` Condition: ${bypass.condition}`)); + } + console.log(); + } + + console.log(chalk.red.bold(' ⚠ REVIEW WITH SAFETY ENGINEER BEFORE MIGRATION')); + console.log(); + } + + // Critical warnings + if (result.criticalWarnings.length > 0) { + console.log(chalk.yellow.bold('⚠ Critical Warnings')); + console.log(chalk.yellow('─'.repeat(40))); + + for (const warning of result.criticalWarnings) { + const severityColor = warning.severity === 'critical' ? chalk.red : + warning.severity === 'high' ? chalk.yellow : chalk.white; + console.log(` ${severityColor(`[${warning.severity}]`)} ${warning.message}`); + console.log(chalk.gray(` ${warning.location.file}:${warning.location.line}`)); + } + console.log(); + } + + // Interlocks + if (result.interlocks.length > 0) { + console.log(chalk.bold('Interlocks')); + console.log(chalk.gray('─'.repeat(40))); + + // Group by type + const byType = new Map(); + for (const il of result.interlocks) { + const existing = byType.get(il.type) ?? []; + existing.push(il); + byType.set(il.type, existing); + } + + for (const [type, interlocks] of byType) { + const icon = type === 'estop' ? 'šŸ›‘' : + type === 'permissive' ? 'āœ…' : + type === 'safety-relay' ? '⚔' : 'šŸ”’'; + console.log(); + console.log(chalk.bold(` ${icon} ${type.toUpperCase()}`)); + + for (const il of interlocks.slice(0, 10)) { + console.log(` ${chalk.cyan(il.name)} ${chalk.gray(`(${il.location.file}:${il.location.line})`)}`); + if (options.verbose) { + console.log(chalk.gray(` Confidence: ${il.confidence}`)); + } + } + + if (interlocks.length > 10) { + console.log(chalk.gray(` ... and ${interlocks.length - 10} more`)); + } + } + console.log(); + } + + // Exit with error if strict mode and bypasses found + if (options.strict && result.bypasses.length > 0) { + console.log(chalk.red('Exiting with error due to --strict flag and bypasses detected')); + process.exit(1); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Tribal subcommand - Extract tribal knowledge + */ +async function tribalAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + const limit = parseInt(options.limit ?? '50', 10); + + const spinner = isTextFormat ? createSpinner('Extracting tribal knowledge...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.tribalKnowledge(undefined, { limit }); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify({ + total: result.summary.total, + byType: result.summary.byType, + byImportance: result.summary.byImportance, + criticalCount: result.summary.criticalCount, + items: result.items, + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('🧠 Tribal Knowledge')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.summary.total)}`); + console.log(` Critical: ${result.summary.criticalCount > 0 ? chalk.red(result.summary.criticalCount) : chalk.green('0')}`); + console.log(); + + // By type + if (Object.keys(result.summary.byType).length > 0) { + console.log(chalk.bold('By Type')); + console.log(chalk.gray('─'.repeat(40))); + for (const [type, count] of Object.entries(result.summary.byType)) { + const icon = getTribalIcon(type); + console.log(` ${icon} ${type}: ${chalk.cyan(count)}`); + } + console.log(); + } + + // By importance + console.log(chalk.bold('By Importance')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Critical: ${chalk.red(result.summary.byImportance.critical)}`); + console.log(` High: ${chalk.yellow(result.summary.byImportance.high)}`); + console.log(` Medium: ${chalk.white(result.summary.byImportance.medium)}`); + console.log(` Low: ${chalk.gray(result.summary.byImportance.low)}`); + console.log(); + + // Items + if (result.items.length > 0) { + console.log(chalk.bold('Knowledge Items')); + console.log(chalk.gray('─'.repeat(40))); + + for (const item of result.items) { + const icon = getTribalIcon(item.type); + const importanceColor = item.importance === 'critical' ? chalk.red : + item.importance === 'high' ? chalk.yellow : + item.importance === 'medium' ? chalk.white : chalk.gray; + + console.log(` ${icon} ${importanceColor(`[${item.importance}]`)} ${item.type}`); + console.log(` ${chalk.white(item.content.slice(0, 100))}${item.content.length > 100 ? '...' : ''}`); + console.log(chalk.gray(` ${item.file}:${item.location.line}`)); + console.log(); + } + + if (result.summary.total > result.items.length) { + console.log(chalk.gray(` ... and ${result.summary.total - result.items.length} more (use --limit to see more)`)); + console.log(); + } + } else { + console.log(chalk.gray('No tribal knowledge found')); + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Variables subcommand - Extract all variables + */ +async function variablesAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + const limit = parseInt(options.limit ?? '100', 10); + + const spinner = isTextFormat ? createSpinner('Extracting variables...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.variables(undefined, { limit }); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify({ + total: result.summary.total, + bySection: result.summary.bySection, + withComments: result.summary.withComments, + withIOAddress: result.summary.withIOAddress, + safetyCritical: result.summary.safetyCritical, + variables: result.variables, + ioMappings: result.ioMappings, + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ“Š Variables')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.summary.total)}`); + console.log(` With Comments: ${chalk.cyan(result.summary.withComments)}`); + console.log(` With I/O Address: ${chalk.cyan(result.summary.withIOAddress)}`); + console.log(` Safety Critical: ${result.summary.safetyCritical > 0 ? chalk.yellow(result.summary.safetyCritical) : chalk.green('0')}`); + console.log(); + + // By section + if (Object.keys(result.summary.bySection).length > 0) { + console.log(chalk.bold('By Section')); + console.log(chalk.gray('─'.repeat(40))); + for (const [section, count] of Object.entries(result.summary.bySection)) { + const icon = getSectionIcon(section); + console.log(` ${icon} ${section}: ${chalk.cyan(count)}`); + } + console.log(); + } + + // Variables (verbose only) + if (options.verbose && result.variables.length > 0) { + console.log(chalk.bold('Variables')); + console.log(chalk.gray('─'.repeat(40))); + + // Group by section + const bySection = new Map(); + for (const v of result.variables) { + const existing = bySection.get(v.section) ?? []; + existing.push(v); + bySection.set(v.section, existing); + } + + for (const [section, vars] of bySection) { + console.log(); + console.log(chalk.bold(` ${section}`)); + + for (const v of vars.slice(0, 15)) { + const safetyIcon = v.isSafetyCritical ? chalk.yellow('⚠') : ' '; + const ioInfo = v.ioAddress ? chalk.magenta(` AT ${v.ioAddress}`) : ''; + console.log(` ${safetyIcon} ${chalk.cyan(v.name)}: ${v.dataType}${ioInfo}`); + if (v.comment) { + console.log(chalk.gray(` // ${v.comment}`)); + } + } + + if (vars.length > 15) { + console.log(chalk.gray(` ... and ${vars.length - 15} more`)); + } + } + console.log(); + } + + // I/O Mappings + if (result.ioMappings.length > 0) { + console.log(chalk.bold('I/O Mappings')); + console.log(chalk.gray('─'.repeat(40))); + + for (const io of result.ioMappings.slice(0, 20)) { + const dirIcon = io.isInput ? chalk.green('→') : chalk.blue('←'); + console.log(` ${dirIcon} ${chalk.magenta(io.address)} ${chalk.cyan(io.variableName || 'unnamed')}`); + if (io.description) { + console.log(chalk.gray(` // ${io.description}`)); + } + } + + if (result.ioMappings.length > 20) { + console.log(chalk.gray(` ... and ${result.ioMappings.length - 20} more`)); + } + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * IO Map subcommand - Extract I/O address mappings + */ +async function ioMapAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Extracting I/O mappings...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.variables(); + + spinner?.stop(); + + const ioMappings = result.ioMappings; + const inputs = ioMappings.filter(io => io.isInput); + const outputs = ioMappings.filter(io => !io.isInput); + + if (format === 'json') { + console.log(JSON.stringify({ + total: ioMappings.length, + inputs: inputs.length, + outputs: outputs.length, + mappings: ioMappings, + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ“ I/O Address Mappings')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(ioMappings.length)}`); + console.log(` Inputs: ${chalk.green(inputs.length)}`); + console.log(` Outputs: ${chalk.blue(outputs.length)}`); + console.log(); + + // Inputs + if (inputs.length > 0) { + console.log(chalk.bold('Inputs')); + console.log(chalk.gray('─'.repeat(40))); + + for (const io of inputs) { + console.log(` ${chalk.green('→')} ${chalk.magenta(io.address.padEnd(12))} ${chalk.cyan(io.variableName || 'unnamed')}`); + if (io.description) { + console.log(chalk.gray(` // ${io.description}`)); + } + } + console.log(); + } + + // Outputs + if (outputs.length > 0) { + console.log(chalk.bold('Outputs')); + console.log(chalk.gray('─'.repeat(40))); + + for (const io of outputs) { + console.log(` ${chalk.blue('←')} ${chalk.magenta(io.address.padEnd(12))} ${chalk.cyan(io.variableName || 'unnamed')}`); + if (io.description) { + console.log(chalk.gray(` // ${io.description}`)); + } + } + console.log(); + } + + if (ioMappings.length === 0) { + console.log(chalk.gray('No I/O mappings found')); + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Diagram subcommand - Generate state machine diagrams + */ +async function diagramAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Generating diagrams...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.stateMachines(); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify({ + total: result.stateMachines.length, + diagrams: result.stateMachines.map(sm => ({ + name: sm.name, + file: sm.file, + mermaid: sm.visualizations.mermaid, + ascii: sm.visualizations.ascii, + })), + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ“Š State Machine Diagrams')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + if (result.stateMachines.length === 0) { + console.log(chalk.gray('No state machines found')); + console.log(); + return; + } + + for (const sm of result.stateMachines) { + console.log(chalk.bold(`šŸ”„ ${sm.name}`)); + console.log(chalk.gray(` File: ${sm.file}`)); + console.log(chalk.gray(` States: ${sm.states.length}, Transitions: ${sm.transitions.length}`)); + console.log(); + + // Mermaid diagram + console.log(chalk.gray(' Mermaid:')); + console.log(' ```mermaid'); + for (const line of sm.visualizations.mermaid.split('\n')) { + console.log(` ${line}`); + } + console.log(' ```'); + console.log(); + + // ASCII diagram (if verbose) + if (options.verbose && sm.visualizations.ascii) { + console.log(chalk.gray(' ASCII:')); + for (const line of sm.visualizations.ascii.split('\n')) { + console.log(` ${line}`); + } + console.log(); + } + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * All subcommand - Run full analysis pipeline + */ +async function allAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Running full analysis...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.fullAnalysis(); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Text output - comprehensive summary + console.log(); + console.log(chalk.bold('šŸ­ Full IEC 61131-3 Analysis')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Project Status + console.log(chalk.bold('šŸ“‹ Project Status')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Files: ${chalk.cyan(result.status.files.total)}`); + console.log(` Lines: ${chalk.cyan(result.status.files.totalLines.toLocaleString())}`); + console.log(` POUs: ${chalk.cyan(result.status.analysis.pous)}`); + console.log(` Health: ${getHealthColor(result.status.health.score)}`); + console.log(); + + // Docstrings + console.log(chalk.bold('šŸ“š Documentation')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Docstrings: ${chalk.cyan(result.docstrings.summary.total)}`); + console.log(` With Params: ${chalk.cyan(result.docstrings.summary.withParams)}`); + console.log(` Avg Quality: ${getQualityColor(result.docstrings.summary.averageQuality)}`); + console.log(); + + // State Machines + console.log(chalk.bold('šŸ”„ State Machines')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.stateMachines.summary.total)}`); + console.log(` States: ${chalk.cyan(result.stateMachines.summary.totalStates)}`); + if (result.stateMachines.summary.withDeadlocks > 0) { + console.log(` With Deadlocks: ${chalk.red(result.stateMachines.summary.withDeadlocks)}`); + } + console.log(); + + // Safety + console.log(chalk.bold('šŸ›”ļø Safety')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Interlocks: ${chalk.cyan(result.safety.summary.totalInterlocks)}`); + if (result.safety.bypasses.length > 0) { + console.log(chalk.red(` ⚠ BYPASSES: ${result.safety.bypasses.length}`)); + } else { + console.log(` Bypasses: ${chalk.green('0')}`); + } + if (result.safety.criticalWarnings.length > 0) { + console.log(chalk.yellow(` Warnings: ${result.safety.criticalWarnings.length}`)); + } + console.log(); + + // Tribal Knowledge + console.log(chalk.bold('🧠 Tribal Knowledge')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.tribalKnowledge.summary.total)}`); + console.log(` Critical: ${result.tribalKnowledge.summary.criticalCount > 0 ? chalk.red(result.tribalKnowledge.summary.criticalCount) : chalk.green('0')}`); + console.log(); + + // Variables + console.log(chalk.bold('šŸ“Š Variables')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(result.variables.summary.total)}`); + console.log(` I/O Mapped: ${chalk.cyan(result.variables.summary.withIOAddress)}`); + console.log(` Safety Critical: ${result.variables.summary.safetyCritical > 0 ? chalk.yellow(result.variables.summary.safetyCritical) : chalk.green('0')}`); + console.log(); + + // Critical issues + if (result.safety.bypasses.length > 0 || result.safety.criticalWarnings.length > 0) { + console.log(chalk.red.bold('🚨 CRITICAL ISSUES')); + console.log(chalk.red('─'.repeat(40))); + + for (const bypass of result.safety.bypasses) { + console.log(chalk.red(` ⚠ BYPASS: ${bypass.name} (${bypass.location.file}:${bypass.location.line})`)); + } + + for (const warning of result.safety.criticalWarnings.filter(w => w.severity === 'critical')) { + console.log(chalk.red(` ⚠ ${warning.message}`)); + } + console.log(); + } + + // Next steps + console.log(chalk.gray('─'.repeat(60))); + console.log(chalk.bold('šŸ“Œ Detailed Commands:')); + console.log(chalk.gray(` • drift st docstrings ${chalk.white('View all documentation')}`)); + console.log(chalk.gray(` • drift st state-machines ${chalk.white('View state machine diagrams')}`)); + console.log(chalk.gray(` • drift st safety ${chalk.white('Detailed safety analysis')}`)); + console.log(chalk.gray(` • drift st tribal ${chalk.white('View tribal knowledge')}`)); + console.log(); + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Handle errors consistently + */ +function handleError(error: unknown, format: string): void { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (format === 'json') { + console.log(JSON.stringify({ error: true, message })); + } else { + console.log(chalk.red(`\nāŒ Error: ${message}`)); + } +} + +/** + * Migration subcommand - Calculate migration readiness scores + */ +async function migrationAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Calculating migration readiness...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.migrationReadiness(); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ“Š Migration Readiness Report')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Overall score + const gradeColor = result.overallGrade === 'A' ? chalk.green : + result.overallGrade === 'B' ? chalk.cyan : + result.overallGrade === 'C' ? chalk.yellow : + result.overallGrade === 'D' ? chalk.magenta : chalk.red; + + console.log(chalk.bold('Overall Score')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Grade: ${gradeColor(result.overallGrade)}`); + console.log(` Score: ${gradeColor(`${Math.round(result.overallScore)}/100`)}`); + console.log(); + + // Risks + if (result.risks.length > 0) { + console.log(chalk.bold('Risks')); + console.log(chalk.gray('─'.repeat(40))); + for (const risk of result.risks) { + const severityColor = risk.severity === 'critical' ? chalk.red : + risk.severity === 'high' ? chalk.yellow : chalk.white; + console.log(` ${severityColor(`[${risk.severity}]`)} ${risk.description}`); + console.log(chalk.gray(` Mitigation: ${risk.mitigation}`)); + } + console.log(); + } + + // Migration order + if (result.migrationOrder.length > 0) { + console.log(chalk.bold('Recommended Migration Order')); + console.log(chalk.gray('─'.repeat(40))); + for (const item of result.migrationOrder.slice(0, 10)) { + const hasBlockers = result.pouScores.find(s => s.pouId === item.pouId)?.blockers.length ?? 0; + const icon = hasBlockers > 0 ? chalk.red('⚠') : chalk.green('āœ“'); + console.log(` ${item.order}. ${icon} ${chalk.cyan(item.pouName)} - ${item.estimatedEffort}`); + console.log(chalk.gray(` ${item.reason}`)); + } + if (result.migrationOrder.length > 10) { + console.log(chalk.gray(` ... and ${result.migrationOrder.length - 10} more`)); + } + console.log(); + } + + // POU scores (verbose) + if (options.verbose && result.pouScores.length > 0) { + console.log(chalk.bold('POU Scores')); + console.log(chalk.gray('─'.repeat(40))); + for (const score of result.pouScores) { + const scoreColor = score.overallScore >= 80 ? chalk.green : + score.overallScore >= 60 ? chalk.yellow : chalk.red; + console.log(` ${chalk.cyan(score.pouName)} (${score.pouType})`); + console.log(` Score: ${scoreColor(`${Math.round(score.overallScore)}/100`)} Grade: ${score.grade}`); + console.log(chalk.gray(` Doc: ${Math.round(score.dimensionScores.documentation)} | Safety: ${Math.round(score.dimensionScores.safety)} | Complexity: ${Math.round(score.dimensionScores.complexity)}`)); + if (score.blockers.length > 0) { + console.log(chalk.red(` ⚠ ${score.blockers.length} blocker(s)`)); + } + console.log(); + } + } + + // Effort estimate + console.log(chalk.bold('Effort Estimate')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Total: ${chalk.cyan(`${result.estimatedEffort.totalHours} hours`)}`); + console.log(` Confidence: ${chalk.cyan(`${Math.round(result.estimatedEffort.confidence * 100)}%`)}`); + console.log(); + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * AI Context subcommand - Generate AI context package + */ +async function aiContextAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + const targetLanguage = (options.target ?? 'python') as 'python' | 'rust' | 'typescript' | 'csharp' | 'cpp' | 'go' | 'java'; + + const spinner = isTextFormat ? createSpinner(`Generating AI context for ${targetLanguage}...`) : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.generateAIContext(targetLanguage); + + spinner?.stop(); + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Text output - summary + console.log(); + console.log(chalk.bold('šŸ¤– AI Context Package')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Project info + console.log(chalk.bold('Project')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Name: ${chalk.cyan(result.project.name)}`); + console.log(` Target Language: ${chalk.cyan(result.targetLanguage)}`); + console.log(` POUs: ${chalk.cyan(result.project.totalPOUs)}`); + console.log(` Lines: ${chalk.cyan(result.project.totalLines)}`); + console.log(); + + // Type mappings + console.log(chalk.bold('Type Mappings')); + console.log(chalk.gray('─'.repeat(40))); + const mappings = Object.entries(result.types.plcToTarget).slice(0, 10); + for (const [plc, target] of mappings) { + console.log(` ${chalk.yellow(plc)} → ${chalk.green(target)}`); + } + if (Object.keys(result.types.plcToTarget).length > 10) { + console.log(chalk.gray(` ... and ${Object.keys(result.types.plcToTarget).length - 10} more`)); + } + console.log(); + + // Safety context + if (result.safety.interlocks.length > 0) { + console.log(chalk.bold('Safety Context')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Interlocks: ${chalk.yellow(result.safety.interlocks.length)}`); + console.log(` Must Preserve: ${chalk.yellow(result.safety.mustPreserve.length)} items`); + console.log(); + } + + // POUs + console.log(chalk.bold('POU Contexts')); + console.log(chalk.gray('─'.repeat(40))); + for (const pou of result.pous.slice(0, 5)) { + console.log(` ${chalk.cyan(pou.pouName)} (${pou.pouType})`); + console.log(chalk.gray(` ${pou.purpose.slice(0, 60)}${pou.purpose.length > 60 ? '...' : ''}`)); + console.log(chalk.gray(` Inputs: ${pou.interface.inputs.length}, Outputs: ${pou.interface.outputs.length}`)); + if (pou.safety.isSafetyCritical) { + console.log(chalk.yellow(` ⚠ Safety Critical`)); + } + } + if (result.pous.length > 5) { + console.log(chalk.gray(` ... and ${result.pous.length - 5} more`)); + } + console.log(); + + // Verification requirements + if (result.verificationRequirements.length > 0) { + console.log(chalk.bold('Verification Requirements')); + console.log(chalk.gray('─'.repeat(40))); + for (const req of result.verificationRequirements) { + console.log(` [${req.category}] ${req.requirement}`); + } + console.log(); + } + + // Usage hint + console.log(chalk.gray('─'.repeat(60))); + console.log(chalk.bold('šŸ’” Usage:')); + console.log(chalk.gray(` Use --format json to get the full context package for AI consumption`)); + console.log(chalk.gray(` Example: drift st ai-context --format json --target rust > context.json`)); + console.log(); + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Call Graph subcommand - Build and display call graph + */ +async function callGraphAction(targetPath: string | undefined, options: STOptions): Promise { + const rootDir = targetPath ?? process.cwd(); + const format = options.format ?? 'text'; + const isTextFormat = format === 'text'; + + const spinner = isTextFormat ? createSpinner('Building call graph...') : null; + spinner?.start(); + + try { + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(rootDir); + const result = await analyzer.buildCallGraph(); + + spinner?.stop(); + + const nodes = Array.from(result.nodes.values()); + const edges = result.edges; + + if (format === 'json') { + console.log(JSON.stringify({ + nodes: nodes.map(n => ({ + id: n.id, + name: n.name, + type: n.type, + file: n.file, + line: n.line, + inputs: n.inputs.length, + outputs: n.outputs.length, + })), + edges: edges.map(e => ({ + from: e.callerId, + to: e.calleeName, + type: e.callType, + line: e.location.line, + })), + summary: { + totalNodes: nodes.length, + totalEdges: edges.length, + }, + }, null, 2)); + return; + } + + // Text output + console.log(); + console.log(chalk.bold('šŸ”— Call Graph')); + console.log(chalk.gray('═'.repeat(60))); + console.log(); + + // Summary + console.log(chalk.bold('Summary')); + console.log(chalk.gray('─'.repeat(40))); + console.log(` Nodes (POUs): ${chalk.cyan(nodes.length)}`); + console.log(` Edges (Calls): ${chalk.cyan(edges.length)}`); + console.log(); + + // Nodes by type + const byType: Record = {}; + for (const node of nodes) { + byType[node.type] = (byType[node.type] || 0) + 1; + } + console.log(chalk.bold('By Type')); + console.log(chalk.gray('─'.repeat(40))); + for (const [type, count] of Object.entries(byType)) { + const icon = type === 'PROGRAM' ? 'šŸ“‹' : type === 'FUNCTION_BLOCK' ? '🧩' : 'ʒ'; + console.log(` ${icon} ${type}: ${chalk.cyan(count)}`); + } + console.log(); + + // Most called + const callCounts: Record = {}; + for (const edge of edges) { + callCounts[edge.calleeName] = (callCounts[edge.calleeName] || 0) + 1; + } + const mostCalled = Object.entries(callCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + + if (mostCalled.length > 0) { + console.log(chalk.bold('Most Called')); + console.log(chalk.gray('─'.repeat(40))); + for (const [name, count] of mostCalled) { + console.log(` ${chalk.cyan(name)}: ${count} call(s)`); + } + console.log(); + } + + // Entry points + const calledNodes = new Set(edges.map(e => e.calleeName.toLowerCase())); + const entryPoints = nodes.filter(n => !calledNodes.has(n.name.toLowerCase())); + + if (entryPoints.length > 0) { + console.log(chalk.bold('Entry Points (not called by others)')); + console.log(chalk.gray('─'.repeat(40))); + for (const ep of entryPoints.slice(0, 10)) { + console.log(` ${chalk.green('→')} ${chalk.cyan(ep.name)} (${ep.type})`); + } + if (entryPoints.length > 10) { + console.log(chalk.gray(` ... and ${entryPoints.length - 10} more`)); + } + console.log(); + } + + // Mermaid diagram (verbose) + if (options.verbose && nodes.length > 0 && nodes.length <= 20) { + console.log(chalk.bold('Mermaid Diagram')); + console.log(chalk.gray('─'.repeat(40))); + console.log('```mermaid'); + console.log('graph TD'); + for (const node of nodes) { + const shape = node.type === 'PROGRAM' ? `[${node.name}]` : + node.type === 'FUNCTION_BLOCK' ? `[[${node.name}]]` : + `(${node.name})`; + console.log(` ${node.name}${shape}`); + } + const seenEdges = new Set(); + for (const edge of edges) { + const callerName = edge.callerId.split(':').pop() || edge.callerId; + const edgeKey = `${callerName}->${edge.calleeName}`; + if (!seenEdges.has(edgeKey)) { + seenEdges.add(edgeKey); + console.log(` ${callerName}-->${edge.calleeName}`); + } + } + console.log('```'); + console.log(); + } + + } catch (error) { + spinner?.stop(); + handleError(error, format); + } +} + +/** + * Get color for quality score + */ +function getQualityColor(score: number): string { + if (score >= 80) return chalk.green(`${Math.round(score)}%`); + if (score >= 50) return chalk.yellow(`${Math.round(score)}%`); + return chalk.red(`${Math.round(score)}%`); +} + +/** + * Get color for health score + */ +function getHealthColor(score: number): string { + if (score >= 70) return chalk.green(`${score}/100`); + if (score >= 40) return chalk.yellow(`${score}/100`); + return chalk.red(`${score}/100`); +} + +/** + * Get icon for tribal knowledge type + */ +function getTribalIcon(type: string): string { + const icons: Record = { + 'warning': 'āš ļø', + 'danger': '🚨', + 'caution': '⚔', + 'note': 'šŸ“', + 'todo': 'šŸ“‹', + 'fixme': 'šŸ”§', + 'hack': 'šŸ”Ø', + 'workaround': 'šŸ”„', + 'history': 'šŸ“…', + 'author': 'šŸ‘¤', + 'magic-number': 'šŸ”¢', + 'dead-code': 'šŸ’€', + 'timing': 'ā±ļø', + 'calibration': 'šŸŽÆ', + 'undocumented': 'ā“', + }; + return icons[type] ?? 'šŸ“Œ'; +} + +/** + * Get icon for variable section + */ +function getSectionIcon(section: string): string { + const icons: Record = { + 'VAR_INPUT': '→', + 'VAR_OUTPUT': '←', + 'VAR_IN_OUT': '↔', + 'VAR': '•', + 'VAR_GLOBAL': '🌐', + 'VAR_TEMP': 'ā³', + 'VAR_CONSTANT': 'šŸ”’', + 'VAR_EXTERNAL': 'šŸ”—', + }; + return icons[section] ?? '•'; +} diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index 190a4097..27dad5bf 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -18,7 +18,8 @@ import chalk from 'chalk'; import { Command } from 'commander'; import { getDefaultIgnoreDirectories } from 'driftdetect-core'; import { createPatternStore, type PatternStoreInterface } from 'driftdetect-core/storage'; -import { createAllDetectorsArray, type BaseDetector } from 'driftdetect-detectors'; +import { createAllDetectorsArray } from 'driftdetect-detectors'; +import type { BaseDetector } from 'driftdetect-detectors'; import type { Pattern, PatternCategory } from 'driftdetect-core'; @@ -474,11 +475,11 @@ function mergePatternIntoStore( if (existingPattern) { // Phase 2: Smart merge - update existing pattern // Remove old locations from this file, add new ones - const otherFileLocations = existingPattern.locations.filter(loc => loc.file !== file); + const otherFileLocations = existingPattern.locations.filter((loc: { file: string }) => loc.file !== file); const mergedLocations = [...otherFileLocations, ...detected.locations].slice(0, 100); // Same for outliers - filter to only include required fields - const otherFileOutliers = existingPattern.outliers.filter(o => o.file !== file); + const otherFileOutliers = existingPattern.outliers.filter((o: { file: string }) => o.file !== file); const mergedOutliers = [ ...otherFileOutliers, ...newOutliers, @@ -548,12 +549,12 @@ function removeFileFromStore(store: PatternStoreInterface, file: string): void { const allPatterns = store.getAll(); for (const pattern of allPatterns) { - const hasLocationsInFile = pattern.locations.some(loc => loc.file === file); - const hasOutliersInFile = pattern.outliers.some(o => o.file === file); + const hasLocationsInFile = pattern.locations.some((loc: { file: string }) => loc.file === file); + const hasOutliersInFile = pattern.outliers.some((o: { file: string }) => o.file === file); if (hasLocationsInFile || hasOutliersInFile) { - const newLocations = pattern.locations.filter(loc => loc.file !== file); - const newOutliers = pattern.outliers.filter(o => o.file !== file); + const newLocations = pattern.locations.filter((loc: { file: string }) => loc.file !== file); + const newOutliers = pattern.outliers.filter((o: { file: string }) => o.file !== file); if (newLocations.length === 0 && newOutliers.length === 0) { // Pattern has no more locations - delete it diff --git a/packages/cli/src/commands/where.ts b/packages/cli/src/commands/where.ts index 0a0af21f..71519d64 100644 --- a/packages/cli/src/commands/where.ts +++ b/packages/cli/src/commands/where.ts @@ -63,11 +63,11 @@ export const whereCommand = new Command('where') console.log(chalk.yellow(`No patterns found matching "${pattern}"`)); // Show available categories - const categories = new Set(allPatternsResult.items.map(p => p.category)); + const categories = new Set(allPatternsResult.items.map((p: { category: string }) => p.category)); if (categories.size > 0) { console.log(chalk.dim('\nAvailable categories:')); for (const cat of categories) { - const count = allPatternsResult.items.filter(p => p.category === cat).length; + const count = allPatternsResult.items.filter((p: { category: string }) => p.category === cat).length; console.log(chalk.dim(` ${cat}: ${count} patterns`)); } } @@ -96,7 +96,7 @@ export const whereCommand = new Command('where') const fullPattern = await service.getPattern(summary.id); if (!fullPattern) {continue;} - const locations = fullPattern.locations.slice(0, limit).map(loc => ({ + const locations = fullPattern.locations.slice(0, limit).map((loc) => ({ file: loc.file, hash: '', range: { start: loc.line, end: loc.endLine ?? loc.line }, diff --git a/packages/cli/src/commands/wrappers.ts b/packages/cli/src/commands/wrappers.ts index bb5b8dc2..805db4ee 100644 --- a/packages/cli/src/commands/wrappers.ts +++ b/packages/cli/src/commands/wrappers.ts @@ -19,12 +19,100 @@ import { isNativeAvailable, analyzeWrappersWithFallback, } from 'driftdetect-core'; -import { - createWrapperScanner, - type WrapperScanResult, - type WrapperCluster, - type WrapperFunction, -} from 'driftdetect-core/wrappers'; + +// ============================================================================= +// Local Type Definitions (to avoid subpath import issues) +// ============================================================================= + +type WrapperCategory = + | 'state-management' + | 'data-fetching' + | 'side-effects' + | 'authentication' + | 'authorization' + | 'validation' + | 'dependency-injection' + | 'middleware' + | 'testing' + | 'logging' + | 'caching' + | 'error-handling' + | 'async-utilities' + | 'form-handling' + | 'routing' + | 'factory' + | 'decorator' + | 'utility' + | 'other'; + +interface WrapperFunction { + name: string; + qualifiedName: string; + file: string; + line: number; + language: 'typescript' | 'python' | 'java' | 'csharp' | 'php' | 'rust' | 'cpp'; + directPrimitives: string[]; + transitivePrimitives: string[]; + primitiveSignature: string[]; + depth: number; + callsWrappers: string[]; + calledBy: string[]; + isFactory: boolean; + isHigherOrder: boolean; + isDecorator: boolean; + isAsync: boolean; +} + +interface WrapperCluster { + id: string; + name: string; + category: WrapperCategory; + description: string; + confidence: number; + primitiveSignature: string[]; + wrappers: WrapperFunction[]; + avgDepth: number; + maxDepth: number; + totalUsages: number; + fileSpread: number; + suggestedNames: string[]; +} + +interface FrameworkInfo { + name: string; + primitiveCount: number; +} + +interface WrapperScanResult { + analysis: { + clusters: WrapperCluster[]; + wrappers: WrapperFunction[]; + frameworks: FrameworkInfo[]; + primitives: unknown[]; + factories: unknown[]; + decoratorWrappers: unknown[]; + asyncWrappers: unknown[]; + summary: { + totalWrappers: number; + totalClusters: number; + avgDepth: number; + maxDepth: number; + mostWrappedPrimitive: string; + mostUsedWrapper: string; + wrappersByLanguage: Record; + wrappersByCategory: Record; + }; + }; + stats: { + totalFiles: number; + totalFunctions: number; + totalCalls: number; + totalImports: number; + byLanguage: Record; + }; + duration: number; + errors: string[]; +} // ============================================================================= // Command Definition @@ -44,10 +132,10 @@ export const wrappersCommand = new Command('wrappers') const rootDir = path.resolve(options.dir); const verbose = options.verbose || false; const jsonOutput = options.json || false; - const includeTests = options.includeTests || false; + // Note: includeTests and maxDepth options are parsed but not yet used + // They are reserved for future implementation const minConfidence = parseFloat(options.minConfidence); const minClusterSize = parseInt(options.minClusterSize, 10); - const maxDepth = parseInt(options.maxDepth, 10); const categoryFilter = options.category; if (!jsonOutput) { @@ -206,37 +294,13 @@ export const wrappersCommand = new Command('wrappers') return; } catch (nativeError) { if (verbose) { - console.log(chalk.gray(`Native analyzer failed, using TypeScript fallback: ${(nativeError as Error).message}\n`)); + console.log(chalk.gray(`Native analyzer failed: ${(nativeError as Error).message}\n`)); } - // Fall through to TypeScript implementation + throw nativeError; } - } - - // TypeScript fallback - const scanner = createWrapperScanner({ - rootDir, - includeTestFiles: includeTests, - verbose, - }); - - const result = await scanner.scan({ - minConfidence, - minClusterSize, - maxDepth, - includeTestFiles: includeTests, - }); - - // Filter by category if specified - if (categoryFilter) { - result.analysis.clusters = result.analysis.clusters.filter( - (c: WrapperCluster) => c.category === categoryFilter - ); - } - - if (jsonOutput) { - console.log(JSON.stringify(result, null, 2)); } else { - printResults(result, verbose); + // Native analyzer not available + throw new Error('Native wrapper analyzer not available. Please ensure the Rust native module is built.'); } } catch (error) { if (jsonOutput) { diff --git a/packages/cli/src/services/pattern-service-factory.ts b/packages/cli/src/services/pattern-service-factory.ts index 861f33ef..f544ebd9 100644 --- a/packages/cli/src/services/pattern-service-factory.ts +++ b/packages/cli/src/services/pattern-service-factory.ts @@ -14,13 +14,11 @@ import { PatternStore, createPatternServiceFromStore, - type IPatternService, -} from 'driftdetect-core'; -import { createPatternStore, getStorageInfo, + type IPatternService, type PatternStoreInterface, -} from 'driftdetect-core/storage'; +} from 'driftdetect-core'; /** * Create a PatternService for CLI commands. diff --git a/packages/cli/src/services/scanner-service.ts b/packages/cli/src/services/scanner-service.ts index 0fc60d85..5cafb961 100644 --- a/packages/cli/src/services/scanner-service.ts +++ b/packages/cli/src/services/scanner-service.ts @@ -5,1392 +5,19 @@ * high-value architectural patterns and violations. * * Now uses Piscina worker threads for parallel CPU-bound processing. - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - type Language, - type PatternMatch, - ManifestStore, - hashContent, - type SemanticLocation, - type SemanticType, - type ManifestPattern, - type Manifest, - type PatternMatchResult, - OutlierDetector, -} from 'driftdetect-core'; -import { - createAllDetectorsArray, - getDetectorCounts, - type BaseDetector, - type DetectionContext, -} from 'driftdetect-detectors'; - -import type { - DetectorWorkerTask, - DetectorWorkerResult, - WorkerPatternMatch, -} from '../workers/detector-worker.js'; - - -// Get the directory of this module for worker path resolution -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Piscina types (dynamic import) -interface Piscina { - run(task: unknown): Promise; - destroy(): Promise; - readonly threads: unknown[]; - readonly queueSize: number; - readonly completed: number; -} - -type PiscinaConstructor = new (options: { - filename: string; - minThreads?: number; - maxThreads?: number; - idleTimeout?: number; -}) => Piscina; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * Project-wide context for detection - */ -export interface ProjectContext { - rootDir: string; - files: string[]; - config: Record; -} - -/** - * Scanner service configuration - */ -export interface ScannerServiceConfig { - rootDir: string; - verbose?: boolean; - categories?: string[]; - /** Only run critical/high-value detectors */ - criticalOnly?: boolean; - /** Enable manifest generation */ - generateManifest?: boolean; - /** Only scan changed files (incremental) */ - incremental?: boolean; - /** Use worker threads for parallel processing */ - useWorkerThreads?: boolean; - /** Number of worker threads (default: CPU cores - 1) */ - workerThreads?: number; -} - - -/** - * Aggregated pattern match across files * - * Enhanced to preserve all metadata from detectors including: - * - Full location range (endLine/endColumn) - * - Outlier information per location - * - Matched text for context - */ -export interface AggregatedPattern { - patternId: string; - detectorId: string; - category: string; - subcategory: string; - name: string; - description: string; - locations: Array<{ - file: string; - line: number; - column: number; - /** End line for full range highlighting */ - endLine?: number; - /** End column for full range highlighting */ - endColumn?: number; - /** Whether this specific location is an outlier */ - isOutlier?: boolean; - /** Reason for outlier classification */ - outlierReason?: string; - /** The actual text that was matched */ - matchedText?: string; - /** Per-location confidence score */ - confidence?: number; - }>; - confidence: number; - occurrences: number; - /** Total number of outliers across all locations */ - outlierCount: number; - /** Custom metadata from detectors */ - metadata?: Record; -} - -/** - * Aggregated violation across files - */ -export interface AggregatedViolation { - patternId: string; - detectorId: string; - category: string; - severity: 'error' | 'warning' | 'info' | 'hint'; - file: string; - line: number; - column: number; - message: string; - explanation?: string | undefined; - suggestedFix?: string | undefined; -} - -/** - * Scan result for a single file - */ -export interface FileScanResult { - file: string; - patterns: Array<{ - patternId: string; - detectorId: string; - confidence: number; - location: { - file: string; - line: number; - column: number; - endLine?: number; - endColumn?: number; - }; - isOutlier?: boolean; - outlierReason?: string; - matchedText?: string; - metadata?: Record; - }>; - violations: AggregatedViolation[]; - duration: number; - error?: string | undefined; -} - - -/** - * Overall scan results - */ -export interface ScanResults { - files: FileScanResult[]; - patterns: AggregatedPattern[]; - violations: AggregatedViolation[]; - totalPatterns: number; - totalViolations: number; - totalFiles: number; - duration: number; - errors: string[]; - detectorStats: { - total: number; - ran: number; - skipped: number; - }; - /** Manifest with semantic locations (if generateManifest is true) */ - manifest?: Manifest | undefined; - /** Worker thread stats (if useWorkerThreads is true) */ - workerStats?: { - threadsUsed: number; - tasksCompleted: number; - } | undefined; -} - -// ============================================================================ -// Location Deduplication -// ============================================================================ - -function locationKey(loc: { file: string; line: number; column: number }): string { - return `${loc.file}:${loc.line}:${loc.column}`; -} - -function semanticLocationKey(loc: SemanticLocation): string { - return `${loc.file}:${loc.range.start}:${loc.range.end}:${loc.name}`; -} - -function addUniqueLocation( - locations: T[], - location: T, - seenKeys?: Set -): boolean { - const key = locationKey(location); - const seen = seenKeys || new Set(locations.map(locationKey)); - if (seen.has(key)) { - return false; - } - seen.add(key); - locations.push(location); - return true; -} - -function addUniqueSemanticLocation( - locations: SemanticLocation[], - location: SemanticLocation, - seenKeys?: Set -): boolean { - const key = semanticLocationKey(location); - const seen = seenKeys || new Set(locations.map(semanticLocationKey)); - if (seen.has(key)) { - return false; - } - seen.add(key); - locations.push(location); - return true; -} - - -// ============================================================================ -// Language Detection -// ============================================================================ - -const EXTENSION_TO_LANGUAGE: Record = { - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - py: 'python', - pyw: 'python', - cs: 'csharp', - java: 'java', - php: 'php', - css: 'css', - scss: 'css', - sass: 'css', - less: 'css', - json: 'json', - yaml: 'yaml', - yml: 'yaml', - md: 'markdown', - mdx: 'markdown', -}; - -function getLanguage(filePath: string): Language | null { - const ext = path.extname(filePath).slice(1).toLowerCase(); - return EXTENSION_TO_LANGUAGE[ext] || null; -} - -function isDetectorApplicable(detector: BaseDetector, language: Language | null): boolean { - if (!language) {return false;} - const info = detector.getInfo(); - return info.supportedLanguages.includes(language); -} - -// ============================================================================ -// Critical Detectors -// ============================================================================ - -const CRITICAL_DETECTOR_IDS = new Set([ - 'security/sql-injection', - 'security/xss-prevention', - 'security/secret-management', - 'security/input-sanitization', - 'security/csrf-protection', - 'auth/middleware-usage', - 'auth/token-handling', - 'api/route-structure', - 'api/error-format', - 'api/response-envelope', - 'data-access/n-plus-one', - 'data-access/query-patterns', - 'structural/circular-deps', - 'structural/module-boundaries', - 'errors/exception-hierarchy', - 'errors/try-catch-placement', - 'logging/pii-redaction', -]); - - -// ============================================================================ -// Scanner Service -// ============================================================================ - -/** - * Scanner Service - * - * Orchestrates pattern detection across files using real detectors. - * Supports both single-threaded and multi-threaded (Piscina) modes. - */ -export class ScannerService { - private config: ScannerServiceConfig; - private detectors: BaseDetector[] = []; - private initialized = false; - private manifestStore: ManifestStore | null = null; - private pool: Piscina | null = null; - private PiscinaClass: PiscinaConstructor | null = null; - - constructor(config: ScannerServiceConfig) { - this.config = { - ...config, - // Default to using worker threads - useWorkerThreads: config.useWorkerThreads ?? true, - workerThreads: config.workerThreads ?? Math.max(1, os.cpus().length - 1), - }; - if (config.generateManifest) { - this.manifestStore = new ManifestStore(config.rootDir); - } - } - - /** - * Initialize the scanner service - */ - async initialize(): Promise { - if (this.initialized) {return;} - - // Create all detectors (needed for counts even in worker mode) - this.detectors = await createAllDetectorsArray(); - - // Filter by category if specified - if (this.config.categories && this.config.categories.length > 0) { - const categories = new Set(this.config.categories); - this.detectors = this.detectors.filter(d => { - const info = d.getInfo(); - return categories.has(info.category); - }); - } - - // Filter to critical only if specified - if (this.config.criticalOnly) { - this.detectors = this.detectors.filter(d => CRITICAL_DETECTOR_IDS.has(d.id)); - } - - // Initialize worker pool if using threads - if (this.config.useWorkerThreads) { - try { - const piscinaModule = await import('piscina'); - // Piscina exports as named export, not default - this.PiscinaClass = (piscinaModule.Piscina || piscinaModule.default) as unknown as PiscinaConstructor; - - if (!this.PiscinaClass || typeof this.PiscinaClass !== 'function') { - throw new Error(`Piscina class not found. Module exports: ${Object.keys(piscinaModule).join(', ')}`); - } - - // Worker path - compiled JS in dist - const workerPath = path.join(__dirname, '..', 'workers', 'detector-worker.js'); - - if (this.config.verbose) { - console.log(` Worker path: ${workerPath}`); - } - - this.pool = new this.PiscinaClass({ - filename: workerPath, - minThreads: this.config.workerThreads!, // Keep all workers alive - maxThreads: this.config.workerThreads!, - idleTimeout: 60000, // 60s idle timeout - }); - - if (this.config.verbose) { - console.log(` Worker pool initialized with ${this.config.workerThreads} threads`); - } - - // Warm up all workers in parallel - this loads detectors once per worker - await this.warmupWorkers(); - - } catch (error) { - // Fall back to single-threaded mode - console.log(` Worker threads unavailable, using single-threaded mode: ${(error as Error).message}`); - if (this.config.verbose) { - console.log(` Full error: ${(error as Error).stack}`); - } - this.config.useWorkerThreads = false; - } - } - - // Load existing manifest for incremental scanning - if (this.manifestStore) { - await this.manifestStore.load(); - } - - this.initialized = true; - } - - /** - * Warm up all worker threads by preloading detectors - * This runs warmup tasks in parallel so all workers load detectors simultaneously - */ - private async warmupWorkers(): Promise { - if (!this.pool) {return;} - - const numWorkers = this.config.workerThreads!; - const warmupTasks = Array(numWorkers).fill(null).map(() => ({ - type: 'warmup' as const, - categories: this.config.categories, - criticalOnly: this.config.criticalOnly, - })); - - if (this.config.verbose) { - console.log(` Warming up ${numWorkers} workers...`); - } - - const startTime = Date.now(); - - // Run warmup tasks in parallel - each worker gets one task - await Promise.all( - warmupTasks.map(task => this.pool!.run(task)) - ); - - const duration = Date.now() - startTime; - - if (this.config.verbose) { - console.log(` Workers warmed up in ${duration}ms`); - } - } - - /** - * Get detector count - */ - getDetectorCount(): number { - return this.detectors.length; - } - - /** - * Get detector counts by category - */ - getDetectorCounts() { - return getDetectorCounts(); - } - - /** - * Check if worker threads are enabled and initialized - */ - isUsingWorkerThreads(): boolean { - return this.config.useWorkerThreads === true && this.pool !== null; - } - - /** - * Get worker thread count - */ - getWorkerThreadCount(): number { - return this.config.workerThreads ?? 0; - } - - /** - * Scan files for patterns - */ - async scanFiles(files: string[], projectContext: ProjectContext): Promise { - if (this.config.useWorkerThreads && this.pool) { - return this.scanFilesWithWorkers(files, projectContext); - } - return this.scanFilesSingleThreaded(files, projectContext); - } - - /** - * Scan files using worker threads (parallel) - */ - private async scanFilesWithWorkers( - files: string[], - projectContext: ProjectContext - ): Promise { - const startTime = Date.now(); - const errors: string[] = []; - - // Filter to changed files if incremental - let filesToScan = files; - if (this.config.incremental && this.manifestStore) { - const fullPaths = files.map(f => path.join(this.config.rootDir, f)); - const changedPaths = await this.manifestStore.getChangedFiles(fullPaths); - filesToScan = changedPaths.map(f => path.relative(this.config.rootDir, f)); - - for (const file of filesToScan) { - this.manifestStore.clearFilePatterns(path.join(this.config.rootDir, file)); - } - } - - // Create tasks for worker pool - const tasks: DetectorWorkerTask[] = filesToScan.map(file => ({ - file, - rootDir: this.config.rootDir, - projectFiles: projectContext.files, - projectConfig: projectContext.config, - categories: this.config.categories, - criticalOnly: this.config.criticalOnly, - })); - - // Run all tasks in parallel - const results = await Promise.all( - tasks.map(async (task): Promise => { - try { - return await this.pool!.run(task); - } catch (error) { - return { - file: task.file, - language: null, - patterns: [], - violations: [], - detectorsRan: 0, - detectorsSkipped: 0, - duration: 0, - error: error instanceof Error ? error.message : String(error), - }; - } - }) - ); - - // Aggregate results - return this.aggregateWorkerResults(results, files, startTime, errors); - } - - - /** - * Aggregate results from worker threads - */ - private async aggregateWorkerResults( - results: DetectorWorkerResult[], - allFiles: string[], - startTime: number, - errors: string[] - ): Promise { - const fileResults: FileScanResult[] = []; - const patternMap = new Map(); - const allViolations: AggregatedViolation[] = []; - const manifestPatternMap = new Map(); - - let totalDetectorsRan = 0; - let totalDetectorsSkipped = 0; - - for (const result of results) { - if (result.error) { - errors.push(`Failed to scan ${result.file}: ${result.error}`); - } - - totalDetectorsRan += result.detectorsRan; - totalDetectorsSkipped += result.detectorsSkipped; - - // Convert to FileScanResult - PRESERVE ALL METADATA - const filePatterns: FileScanResult['patterns'] = result.patterns.map(p => { - const loc: FileScanResult['patterns'][0]['location'] = { - file: p.location.file, - line: p.location.line, - column: p.location.column, - }; - if (p.location.endLine !== undefined) {loc.endLine = p.location.endLine;} - if (p.location.endColumn !== undefined) {loc.endColumn = p.location.endColumn;} - - const pattern: FileScanResult['patterns'][0] = { - patternId: p.patternId, - detectorId: p.detectorId, - confidence: p.confidence, - location: loc, - }; - if (p.isOutlier !== undefined) {pattern.isOutlier = p.isOutlier;} - if (p.outlierReason !== undefined) {pattern.outlierReason = p.outlierReason;} - if (p.matchedText !== undefined) {pattern.matchedText = p.matchedText;} - if (p.metadata !== undefined) {pattern.metadata = p.metadata;} - return pattern; - }); - - fileResults.push({ - file: result.file, - patterns: filePatterns, - violations: result.violations, - duration: result.duration, - error: result.error, - }); - - // Aggregate patterns - PRESERVE ALL METADATA - for (const match of result.patterns) { - const key = match.patternId; - const existing = patternMap.get(key); - - // Create enhanced location with all metadata - const enhancedLocation: AggregatedPattern['locations'][0] = { - file: match.location.file, - line: match.location.line, - column: match.location.column, - }; - if (match.location.endLine !== undefined) {enhancedLocation.endLine = match.location.endLine;} - if (match.location.endColumn !== undefined) {enhancedLocation.endColumn = match.location.endColumn;} - if (match.isOutlier !== undefined) {enhancedLocation.isOutlier = match.isOutlier;} - if (match.outlierReason !== undefined) {enhancedLocation.outlierReason = match.outlierReason;} - if (match.matchedText !== undefined) {enhancedLocation.matchedText = match.matchedText;} - enhancedLocation.confidence = match.confidence; - - if (existing) { - addUniqueLocation(existing.locations, enhancedLocation); - existing.occurrences++; - existing.confidence = Math.max(existing.confidence, match.confidence); - // Track outlier count - if (match.isOutlier) { - existing.outlierCount = (existing.outlierCount || 0) + 1; - } - // Merge metadata - if (match.metadata) { - existing.metadata = { ...existing.metadata, ...match.metadata }; - } - } else { - const newPattern: AggregatedPattern = { - patternId: match.patternId, - detectorId: match.detectorId, - category: match.category, - subcategory: match.subcategory, - name: match.detectorName, - description: match.detectorDescription, - locations: [enhancedLocation], - confidence: match.confidence, - occurrences: 1, - outlierCount: match.isOutlier ? 1 : 0, - }; - if (match.metadata !== undefined) {newPattern.metadata = match.metadata;} - patternMap.set(key, newPattern); - } - - // Build manifest pattern - if (this.config.generateManifest && result.language) { - await this.addToManifest(manifestPatternMap, match, result); - } - } - - // Aggregate violations - for (const violation of result.violations) { - allViolations.push(violation); - } - } - - // Convert pattern map to array - const patterns = Array.from(patternMap.values()); - - // ======================================================================== - // STATISTICAL OUTLIER DETECTION (Gap 2 Fix) - // Run outlier detection on aggregated patterns to identify statistical - // deviations that weren't caught by individual detectors - // ======================================================================== - const outlierDetector = new OutlierDetector({ - sensitivity: 0.5, - minSampleSize: 5, // Need at least 5 samples for statistical analysis - }); - - for (const pattern of patterns) { - // Skip patterns with too few locations for statistical analysis - if (pattern.locations.length < 5) {continue;} - - // Convert locations to PatternMatchResult format for outlier detection - // Handle exactOptionalPropertyTypes by only including defined values - const matchResults: PatternMatchResult[] = pattern.locations.map((loc) => { - const result: PatternMatchResult = { - patternId: pattern.patternId, - location: { - file: loc.file, - line: loc.line, - column: loc.column, - }, - confidence: loc.confidence ?? pattern.confidence, - isOutlier: loc.isOutlier ?? false, - matchType: 'ast' as const, - timestamp: new Date(), - }; - // Only add optional properties if they have values - if (loc.endLine !== undefined) {result.location.endLine = loc.endLine;} - if (loc.endColumn !== undefined) {result.location.endColumn = loc.endColumn;} - if (loc.matchedText !== undefined) {result.matchedText = loc.matchedText;} - if (loc.outlierReason !== undefined) {result.outlierReason = loc.outlierReason;} - return result; - }); - - // Run statistical outlier detection - const outlierResult = outlierDetector.detect(matchResults, pattern.patternId); - - // Update pattern locations with newly detected outliers - for (const outlier of outlierResult.outliers) { - // Find the matching location - const locIndex = pattern.locations.findIndex( - loc => loc.file === outlier.location.file && - loc.line === outlier.location.line && - loc.column === outlier.location.column - ); - - if (locIndex !== -1) { - const loc = pattern.locations[locIndex]; - // Only mark as outlier if not already marked by detector - if (!loc?.isOutlier) { - if (loc) { - loc.isOutlier = true; - loc.outlierReason = outlier.reason; - } - pattern.outlierCount++; - } - } - } - - // Update manifest pattern with outliers if generating manifest - if (this.config.generateManifest) { - const manifestKey = `${pattern.category}/${pattern.subcategory}/${pattern.patternId}`; - const manifestPattern = manifestPatternMap.get(manifestKey); - - if (manifestPattern && outlierResult.outliers.length > 0) { - // Add outlier locations to manifest pattern - for (const outlier of outlierResult.outliers) { - // Create semantic location for outlier - const outlierSemanticLoc: SemanticLocation = { - file: outlier.location.file, - hash: '', // Will be populated if we have the content - range: { - start: outlier.location.line, - end: outlier.location.endLine ?? outlier.location.line, - }, - type: 'block', - name: `outlier-${outlier.location.line}`, - confidence: 1 - outlier.deviationScore, // Lower confidence for higher deviation - metadata: { - reason: outlier.reason, - deviationScore: outlier.deviationScore, - deviationType: outlier.deviationType, - significance: outlier.significance, - expected: outlier.expected, - actual: outlier.actual, - suggestedFix: outlier.suggestedFix, - }, - }; - - // Check if this outlier location already exists - const exists = manifestPattern.outliers.some( - o => o.file === outlierSemanticLoc.file && - o.range.start === outlierSemanticLoc.range.start - ); - - if (!exists) { - manifestPattern.outliers.push(outlierSemanticLoc); - } - } - } - } - } - - // Build and save manifest - let manifest: Manifest | undefined; - if (this.config.generateManifest && this.manifestStore) { - const manifestPatterns = Array.from(manifestPatternMap.values()); - this.manifestStore.updatePatterns(manifestPatterns); - await this.manifestStore.save(); - manifest = await this.manifestStore.get(); - } - - return { - files: fileResults, - patterns, - violations: allViolations, - totalPatterns: patterns.reduce((sum, p) => sum + p.occurrences, 0), - totalViolations: allViolations.length, - totalFiles: allFiles.length, - duration: Date.now() - startTime, - errors, - detectorStats: { - total: this.detectors.length, - ran: totalDetectorsRan, - skipped: totalDetectorsSkipped, - }, - workerStats: this.pool ? { - threadsUsed: (this.pool.threads).length, - tasksCompleted: this.pool.completed, - } : undefined, - manifest, - }; - } - - - /** - * Add pattern match to manifest - */ - private async addToManifest( - manifestPatternMap: Map, - match: WorkerPatternMatch, - result: DetectorWorkerResult - ): Promise { - try { - const filePath = path.join(this.config.rootDir, result.file); - const content = await fs.readFile(filePath, 'utf-8'); - const contentHash = hashContent(content); - const language = result.language as Language; - - const semanticLoc = this.createSemanticLocation( - match.location, - content, - contentHash, - language - ); - - const manifestKey = `${match.category}/${match.subcategory}/${match.patternId}`; - const existingManifest = manifestPatternMap.get(manifestKey); - - if (existingManifest) { - addUniqueSemanticLocation(existingManifest.locations, semanticLoc); - existingManifest.confidence = Math.max(existingManifest.confidence, match.confidence); - existingManifest.lastSeen = new Date().toISOString(); - } else { - manifestPatternMap.set(manifestKey, { - id: manifestKey, - name: match.detectorName, - category: match.category as any, - subcategory: match.subcategory, - status: 'discovered', - confidence: match.confidence, - locations: [semanticLoc], - outliers: [], - description: match.detectorDescription, - firstSeen: new Date().toISOString(), - lastSeen: new Date().toISOString(), - }); - } - } catch { - // Ignore manifest errors - } - } - - /** - * Scan files single-threaded (fallback) - */ - private async scanFilesSingleThreaded( - files: string[], - projectContext: ProjectContext - ): Promise { - const startTime = Date.now(); - const fileResults: FileScanResult[] = []; - const errors: string[] = []; - const patternMap = new Map(); - const allViolations: AggregatedViolation[] = []; - const manifestPatternMap = new Map(); - - let detectorsRan = 0; - let detectorsSkipped = 0; - - // Filter to changed files if incremental - let filesToScan = files; - if (this.config.incremental && this.manifestStore) { - const fullPaths = files.map(f => path.join(this.config.rootDir, f)); - const changedPaths = await this.manifestStore.getChangedFiles(fullPaths); - filesToScan = changedPaths.map(f => path.relative(this.config.rootDir, f)); - - for (const file of filesToScan) { - this.manifestStore.clearFilePatterns(path.join(this.config.rootDir, file)); - } - } - - for (const file of filesToScan) { - const fileStart = Date.now(); - const filePath = path.join(this.config.rootDir, file); - const language = getLanguage(file); - - if (!language) { - fileResults.push({ - file, - patterns: [], - violations: [], - duration: Date.now() - fileStart, - }); - continue; - } - - try { - const content = await fs.readFile(filePath, 'utf-8'); - const contentHash = hashContent(content); - const filePatterns: FileScanResult['patterns'] = []; - const fileViolations: AggregatedViolation[] = []; - - const context: DetectionContext = { - file, - content, - language, - ast: null, - imports: [], - exports: [], - extension: path.extname(file), - isTestFile: /\.(test|spec)\.[jt]sx?$/.test(file) || file.includes('__tests__'), - isTypeDefinition: file.endsWith('.d.ts'), - projectContext: { - rootDir: projectContext.rootDir, - files: projectContext.files, - config: projectContext.config, - }, - }; - - for (const detector of this.detectors) { - if (!isDetectorApplicable(detector, language)) { - detectorsSkipped++; - continue; - } - - detectorsRan++; - - try { - const result = await detector.detect(context); - const info = detector.getInfo(); - - for (const match of result.patterns) { - // Cast to extended type to safely access optional fields - // Note: PatternMatch has isOutlier, but extended fields may not exist - const extendedMatch = match as PatternMatch & { - outlierReason?: string; - matchedText?: string; - metadata?: Record; - }; - - // Create enhanced location with all metadata - const enhancedLocation: FileScanResult['patterns'][0]['location'] = { - file: match.location.file, - line: match.location.line, - column: match.location.column, - }; - if (match.location.endLine !== undefined) {enhancedLocation.endLine = match.location.endLine;} - if (match.location.endColumn !== undefined) {enhancedLocation.endColumn = match.location.endColumn;} - - // Build file pattern - const filePattern: FileScanResult['patterns'][0] = { - patternId: match.patternId, - detectorId: detector.id, - confidence: match.confidence, - location: enhancedLocation, - }; - if (match.isOutlier !== undefined) {filePattern.isOutlier = match.isOutlier;} - if (extendedMatch.outlierReason !== undefined) {filePattern.outlierReason = extendedMatch.outlierReason;} - if (extendedMatch.matchedText !== undefined) {filePattern.matchedText = extendedMatch.matchedText;} - const metadata = extendedMatch.metadata ?? result.metadata?.custom; - if (metadata !== undefined) {filePattern.metadata = metadata;} - filePatterns.push(filePattern); - - const key = match.patternId; - const existing = patternMap.get(key); - - // Create aggregated location with all metadata - const aggLocation: AggregatedPattern['locations'][0] = { - file: match.location.file, - line: match.location.line, - column: match.location.column, - confidence: match.confidence, - }; - if (match.location.endLine !== undefined) {aggLocation.endLine = match.location.endLine;} - if (match.location.endColumn !== undefined) {aggLocation.endColumn = match.location.endColumn;} - if (match.isOutlier !== undefined) {aggLocation.isOutlier = match.isOutlier;} - if (extendedMatch.outlierReason !== undefined) {aggLocation.outlierReason = extendedMatch.outlierReason;} - if (extendedMatch.matchedText !== undefined) {aggLocation.matchedText = extendedMatch.matchedText;} - - if (existing) { - addUniqueLocation(existing.locations, aggLocation); - existing.occurrences++; - existing.confidence = Math.max(existing.confidence, match.confidence); - if (match.isOutlier) { - existing.outlierCount = (existing.outlierCount || 0) + 1; - } - if (extendedMatch.metadata || result.metadata?.custom) { - existing.metadata = { ...existing.metadata, ...(extendedMatch.metadata ?? result.metadata?.custom) }; - } - } else { - const newPattern: AggregatedPattern = { - patternId: match.patternId, - detectorId: detector.id, - category: info.category, - subcategory: info.subcategory, - name: info.name, - description: info.description, - locations: [aggLocation], - confidence: match.confidence, - occurrences: 1, - outlierCount: match.isOutlier ? 1 : 0, - }; - const patternMetadata = extendedMatch.metadata ?? result.metadata?.custom; - if (patternMetadata !== undefined) {newPattern.metadata = patternMetadata;} - patternMap.set(key, newPattern); - } - - if (this.config.generateManifest) { - const semanticLoc = this.createSemanticLocation( - match.location, - content, - contentHash, - language - ); - - const manifestKey = `${info.category}/${info.subcategory}/${match.patternId}`; - const existingManifest = manifestPatternMap.get(manifestKey); - - if (existingManifest) { - addUniqueSemanticLocation(existingManifest.locations, semanticLoc); - existingManifest.confidence = Math.max(existingManifest.confidence, match.confidence); - existingManifest.lastSeen = new Date().toISOString(); - } else { - manifestPatternMap.set(manifestKey, { - id: manifestKey, - name: info.name, - category: info.category as any, - subcategory: info.subcategory, - status: 'discovered', - confidence: match.confidence, - locations: [semanticLoc], - outliers: [], - description: info.description, - firstSeen: new Date().toISOString(), - lastSeen: new Date().toISOString(), - }); - } - } - } - - // Process violations (same as before) - let violationsToProcess = result.violations; - if (violationsToProcess.length === 0 && result.metadata?.custom) { - const customData = result.metadata.custom; - const customViolations = customData['violations'] as Array<{ - type?: string; - file: string; - line: number; - column: number; - endLine?: number; - endColumn?: number; - value?: string; - issue?: string; - message?: string; - suggestedFix?: string; - severity?: string; - }> | undefined; - - if (customViolations && Array.isArray(customViolations)) { - violationsToProcess = customViolations.map(cv => { - const v: typeof result.violations[0] = { - id: `${detector.id}-${cv.file}-${cv.line}-${cv.column}`, - patternId: detector.id, - severity: (cv.severity as 'error' | 'warning' | 'info' | 'hint') || 'warning', - file: cv.file, - range: { - start: { line: cv.line - 1, character: cv.column - 1 }, - end: { line: (cv.endLine || cv.line) - 1, character: (cv.endColumn || cv.column) - 1 }, - }, - message: cv.issue || cv.message || cv.type || 'Pattern violation detected', - expected: cv.suggestedFix || 'Follow established patterns', - actual: cv.value || 'Non-conforming code', - aiExplainAvailable: true, - aiFixAvailable: !!cv.suggestedFix, - firstSeen: new Date(), - occurrences: 1, - }; - if (cv.type) { - v.explanation = `Violation type: ${cv.type}`; - } - return v; - }); - } - } - - for (const violation of violationsToProcess) { - const aggViolation: AggregatedViolation = { - patternId: violation.patternId, - detectorId: detector.id, - category: info.category, - severity: violation.severity, - file: violation.file, - line: violation.range.start.line + 1, - column: violation.range.start.character + 1, - message: violation.message, - suggestedFix: violation.expected, - }; - if (violation.explanation) { - aggViolation.explanation = violation.explanation; - } - fileViolations.push(aggViolation); - allViolations.push(aggViolation); - } - } catch (detectorError) { - if (this.config.verbose) { - errors.push(`Detector ${detector.id} failed on ${file}: ${(detectorError as Error).message}`); - } - } - } - - fileResults.push({ - file, - patterns: filePatterns, - violations: fileViolations, - duration: Date.now() - fileStart, - }); - } catch (e) { - const error = `Failed to scan ${file}: ${e instanceof Error ? e.message : e}`; - errors.push(error); - fileResults.push({ - file, - patterns: [], - violations: [], - duration: Date.now() - fileStart, - error, - }); - } - } - - const patterns = Array.from(patternMap.values()); - - // ======================================================================== - // STATISTICAL OUTLIER DETECTION (Gap 2 Fix - Single-threaded path) - // Run outlier detection on aggregated patterns to identify statistical - // deviations that weren't caught by individual detectors - // ======================================================================== - const outlierDetectorST = new OutlierDetector({ - sensitivity: 0.5, - minSampleSize: 5, // Need at least 5 samples for statistical analysis - }); - - for (const pattern of patterns) { - // Skip patterns with too few locations for statistical analysis - if (pattern.locations.length < 5) {continue;} - - // Convert locations to PatternMatchResult format for outlier detection - // Handle exactOptionalPropertyTypes by only including defined values - const matchResults: PatternMatchResult[] = pattern.locations.map((loc) => { - const result: PatternMatchResult = { - patternId: pattern.patternId, - location: { - file: loc.file, - line: loc.line, - column: loc.column, - }, - confidence: loc.confidence ?? pattern.confidence, - isOutlier: loc.isOutlier ?? false, - matchType: 'ast' as const, - timestamp: new Date(), - }; - // Only add optional properties if they have values - if (loc.endLine !== undefined) {result.location.endLine = loc.endLine;} - if (loc.endColumn !== undefined) {result.location.endColumn = loc.endColumn;} - if (loc.matchedText !== undefined) {result.matchedText = loc.matchedText;} - if (loc.outlierReason !== undefined) {result.outlierReason = loc.outlierReason;} - return result; - }); - - // Run statistical outlier detection - const outlierResult = outlierDetectorST.detect(matchResults, pattern.patternId); - - // Update pattern locations with newly detected outliers - for (const outlier of outlierResult.outliers) { - // Find the matching location - const locIndex = pattern.locations.findIndex( - loc => loc.file === outlier.location.file && - loc.line === outlier.location.line && - loc.column === outlier.location.column - ); - - if (locIndex !== -1) { - const loc = pattern.locations[locIndex]; - // Only mark as outlier if not already marked by detector - if (!loc?.isOutlier) { - if (loc) { - loc.isOutlier = true; - loc.outlierReason = outlier.reason; - } - pattern.outlierCount++; - } - } - } - - // Update manifest pattern with outliers if generating manifest - if (this.config.generateManifest) { - const manifestKey = `${pattern.category}/${pattern.subcategory}/${pattern.patternId}`; - const manifestPattern = manifestPatternMap.get(manifestKey); - - if (manifestPattern && outlierResult.outliers.length > 0) { - // Add outlier locations to manifest pattern - for (const outlier of outlierResult.outliers) { - // Create semantic location for outlier - const outlierSemanticLoc: SemanticLocation = { - file: outlier.location.file, - hash: '', // Will be populated if we have the content - range: { - start: outlier.location.line, - end: outlier.location.endLine ?? outlier.location.line, - }, - type: 'block', - name: `outlier-${outlier.location.line}`, - confidence: 1 - outlier.deviationScore, // Lower confidence for higher deviation - metadata: { - reason: outlier.reason, - deviationScore: outlier.deviationScore, - deviationType: outlier.deviationType, - significance: outlier.significance, - expected: outlier.expected, - actual: outlier.actual, - suggestedFix: outlier.suggestedFix, - }, - }; - - // Check if this outlier location already exists - const exists = manifestPattern.outliers.some( - o => o.file === outlierSemanticLoc.file && - o.range.start === outlierSemanticLoc.range.start - ); - - if (!exists) { - manifestPattern.outliers.push(outlierSemanticLoc); - } - } - } - } - } - - let manifest: Manifest | undefined; - if (this.config.generateManifest && this.manifestStore) { - const manifestPatterns = Array.from(manifestPatternMap.values()); - this.manifestStore.updatePatterns(manifestPatterns); - await this.manifestStore.save(); - manifest = await this.manifestStore.get(); - } - - return { - files: fileResults, - patterns, - violations: allViolations, - totalPatterns: patterns.reduce((sum, p) => sum + p.occurrences, 0), - totalViolations: allViolations.length, - totalFiles: files.length, - duration: Date.now() - startTime, - errors, - detectorStats: { - total: this.detectors.length, - ran: detectorsRan, - skipped: detectorsSkipped, - }, - manifest, - }; - } - - - /** - * Create a semantic location from a basic location - */ - private createSemanticLocation( - location: { file: string; line: number; column: number }, - content: string, - hash: string, - language: Language, - name?: string - ): SemanticLocation { - const lines = content.split('\n'); - const lineContent = lines[location.line - 1] || ''; - const semanticInfo = this.extractSemanticInfo(lineContent, language); - - const result: SemanticLocation = { - file: location.file, - hash, - range: { - start: location.line, - end: location.line, - }, - type: semanticInfo.type, - name: name || semanticInfo.name || `line-${location.line}`, - confidence: 0.9, - snippet: lineContent.trim().substring(0, 100), - language, - }; - - if (semanticInfo.signature) { - result.signature = semanticInfo.signature; - } - - return result; - } - - /** - * Extract semantic information from a line of code - */ - private extractSemanticInfo(line: string, language: Language): { - type: SemanticType; - name?: string; - signature?: string; - } { - const trimmed = line.trim(); - - if (language === 'typescript' || language === 'javascript') { - const classMatch = trimmed.match(/^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/); - if (classMatch?.[1]) { - return { type: 'class', name: classMatch[1], signature: trimmed.substring(0, 80) }; - } - - const funcMatch = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/); - if (funcMatch?.[1]) { - return { type: 'function', name: funcMatch[1], signature: trimmed.substring(0, 80) }; - } - - const arrowMatch = trimmed.match(/^(?:export\s+)?const\s+(\w+)\s*=/); - if (arrowMatch?.[1]) { - return { type: 'function', name: arrowMatch[1], signature: trimmed.substring(0, 80) }; - } - - const interfaceMatch = trimmed.match(/^(?:export\s+)?interface\s+(\w+)/); - if (interfaceMatch?.[1]) { - return { type: 'interface', name: interfaceMatch[1], signature: trimmed.substring(0, 80) }; - } - - const typeMatch = trimmed.match(/^(?:export\s+)?type\s+(\w+)/); - if (typeMatch?.[1]) { - return { type: 'type', name: typeMatch[1], signature: trimmed.substring(0, 80) }; - } - } - - if (language === 'python') { - const classMatch = trimmed.match(/^class\s+(\w+)/); - if (classMatch?.[1]) { - return { type: 'class', name: classMatch[1], signature: trimmed.substring(0, 80) }; - } - - const defMatch = trimmed.match(/^(?:async\s+)?def\s+(\w+)/); - if (defMatch?.[1]) { - return { type: 'function', name: defMatch[1], signature: trimmed.substring(0, 80) }; - } - - const decoratorMatch = trimmed.match(/^@(\w+)/); - if (decoratorMatch?.[1]) { - return { type: 'decorator', name: decoratorMatch[1], signature: trimmed }; - } - } - - if (language === 'php') { - const classMatch = trimmed.match(/^(?:abstract\s+|final\s+)?class\s+(\w+)/); - if (classMatch?.[1]) { - return { type: 'class', name: classMatch[1], signature: trimmed.substring(0, 80) }; - } - - const interfaceMatch = trimmed.match(/^interface\s+(\w+)/); - if (interfaceMatch?.[1]) { - return { type: 'interface', name: interfaceMatch[1], signature: trimmed.substring(0, 80) }; - } - - const traitMatch = trimmed.match(/^trait\s+(\w+)/); - if (traitMatch?.[1]) { - return { type: 'class', name: traitMatch[1], signature: trimmed.substring(0, 80) }; - } - - const funcMatch = trimmed.match(/^(?:public|protected|private)?\s*(?:static\s+)?function\s+(\w+)/); - if (funcMatch?.[1]) { - return { type: 'function', name: funcMatch[1], signature: trimmed.substring(0, 80) }; - } - - const attrMatch = trimmed.match(/^#\[(\w+)/); - if (attrMatch?.[1]) { - return { type: 'decorator', name: attrMatch[1], signature: trimmed }; - } - } - - return { type: 'block' }; - } - - /** - * Get the manifest store - */ - getManifestStore(): ManifestStore | null { - return this.manifestStore; - } - - /** - * Destroy the worker pool - */ - async destroy(): Promise { - if (this.pool) { - await this.pool.destroy(); - this.pool = null; - } - } -} - -/** - * Create a scanner service - */ -export function createScannerService(config: ScannerServiceConfig): ScannerService { - return new ScannerService(config); -} + * NOTE: This is a re-export from driftdetect-core for backward compatibility. + * The actual implementation lives in driftdetect-core/services. + */ + +// Re-export everything from core's scanner service +export { + ScannerService, + createScannerService, + type ScannerServiceConfig, + type ProjectContext, + type AggregatedPattern, + type AggregatedViolation, + type FileScanResult, + type ScanResults, +} from 'driftdetect-core'; diff --git a/packages/cli/src/workers/detector-worker.ts b/packages/cli/src/workers/detector-worker.ts index adf3764f..8733e570 100644 --- a/packages/cli/src/workers/detector-worker.ts +++ b/packages/cli/src/workers/detector-worker.ts @@ -1,511 +1,19 @@ /** * Detector Worker - Worker thread for running pattern detectors * - * This worker runs in a separate thread and handles: - * - Loading and running detectors on file content - * - Tree-sitter AST parsing - * - Pattern matching and violation detection - * - * @requirements 2.6 - Parallel file processing with worker threads - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -import { - createAllDetectorsArray, - type BaseDetector, - type DetectionContext, -} from 'driftdetect-detectors'; - -import type { Language, PatternMatch } from 'driftdetect-core'; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * Warmup task - preloads detectors without processing files - */ -export interface WarmupTask { - type: 'warmup'; - categories?: string[] | undefined; - criticalOnly?: boolean | undefined; -} - -/** - * Warmup result - */ -export interface WarmupResult { - type: 'warmup'; - detectorsLoaded: number; - duration: number; -} - -/** - * Task input for detector worker - */ -export interface DetectorWorkerTask { - /** Task type - 'scan' for file processing, 'warmup' for preloading */ - type?: 'scan' | undefined; - - /** Relative path to the file */ - file: string; - - /** Root directory */ - rootDir: string; - - /** Project files list (for context) */ - projectFiles: string[]; - - /** Project config */ - projectConfig: Record; - - /** Detector IDs to run (empty = all) */ - detectorIds?: string[] | undefined; - - /** Categories to filter by */ - categories?: string[] | undefined; - - /** Only run critical detectors */ - criticalOnly?: boolean | undefined; -} - -/** - * Pattern match from detector - * - * Enhanced to preserve all metadata from detectors including: - * - Full location range (endLine/endColumn) - * - Outlier information (isOutlier, outlierReason) - * - Matched text for context - * - Custom detector metadata - */ -export interface WorkerPatternMatch { - patternId: string; - detectorId: string; - detectorName: string; - detectorDescription: string; - category: string; - subcategory: string; - confidence: number; - location: { - file: string; - line: number; - column: number; - /** End line for full range highlighting */ - endLine?: number; - /** End column for full range highlighting */ - endColumn?: number; - }; - /** Whether this match deviates from the established pattern */ - isOutlier?: boolean; - /** Explanation for why this is an outlier */ - outlierReason?: string; - /** The actual text that was matched */ - matchedText?: string; - /** Custom metadata from the detector (auth types, route info, etc.) */ - metadata?: Record; -} - -/** - * Violation from detector - */ -export interface WorkerViolation { - patternId: string; - detectorId: string; - category: string; - severity: 'error' | 'warning' | 'info' | 'hint'; - file: string; - line: number; - column: number; - message: string; - explanation?: string | undefined; - suggestedFix?: string | undefined; -} - -/** - * Result from detector worker - */ -export interface DetectorWorkerResult { - /** File that was processed */ - file: string; - - /** Detected language */ - language: string | null; - - /** Pattern matches found */ - patterns: WorkerPatternMatch[]; - - /** Violations found */ - violations: WorkerViolation[]; - - /** Number of detectors that ran */ - detectorsRan: number; - - /** Number of detectors skipped */ - detectorsSkipped: number; - - /** Processing duration in milliseconds */ - duration: number; - - /** Error message if processing failed */ - error?: string | undefined; -} - -// ============================================================================ -// Constants -// ============================================================================ - -const EXTENSION_TO_LANGUAGE: Record = { - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - py: 'python', - pyw: 'python', - cs: 'csharp', - java: 'java', - php: 'php', - css: 'css', - scss: 'css', - sass: 'css', - less: 'css', - json: 'json', - yaml: 'yaml', - yml: 'yaml', - md: 'markdown', - mdx: 'markdown', -}; - -const CRITICAL_DETECTOR_IDS = new Set([ - 'security/sql-injection', - 'security/xss-prevention', - 'security/secret-management', - 'security/input-sanitization', - 'security/csrf-protection', - 'auth/middleware-usage', - 'auth/token-handling', - 'api/route-structure', - 'api/error-format', - 'api/response-envelope', - 'data-access/n-plus-one', - 'data-access/query-patterns', - 'structural/circular-deps', - 'structural/module-boundaries', - 'errors/exception-hierarchy', - 'errors/try-catch-placement', - 'logging/pii-redaction', -]); - -// ============================================================================ -// Helpers -// ============================================================================ - -// ============================================================================ -// Worker State (cached per worker thread) -// ============================================================================ - -let cachedDetectors: BaseDetector[] | null = null; -let detectorsLoading: Promise | null = null; - -/** - * Load detectors (cached per worker) - */ -async function loadDetectors(): Promise { - if (cachedDetectors) { - return cachedDetectors; - } - - if (detectorsLoading) { - return detectorsLoading; - } - - detectorsLoading = createAllDetectorsArray().then(detectors => { - cachedDetectors = detectors; - return detectors; - }); - - return detectorsLoading; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function getLanguage(filePath: string): Language | null { - const ext = path.extname(filePath).slice(1).toLowerCase(); - return EXTENSION_TO_LANGUAGE[ext] || null; -} - -function isDetectorApplicable(detector: BaseDetector, language: Language | null): boolean { - if (!language) {return false;} - const info = detector.getInfo(); - return info.supportedLanguages.includes(language); -} - -function filterDetectors( - detectors: BaseDetector[], - task: DetectorWorkerTask -): BaseDetector[] { - let filtered = detectors; - - // Filter by specific IDs - if (task.detectorIds && task.detectorIds.length > 0) { - const ids = new Set(task.detectorIds); - filtered = filtered.filter(d => ids.has(d.id)); - } - - // Filter by categories - if (task.categories && task.categories.length > 0) { - const categories = new Set(task.categories); - filtered = filtered.filter(d => { - const info = d.getInfo(); - return categories.has(info.category); - }); - } - - // Filter to critical only - if (task.criticalOnly) { - filtered = filtered.filter(d => CRITICAL_DETECTOR_IDS.has(d.id)); - } - - return filtered; -} - -// ============================================================================ -// Main Worker Function -// ============================================================================ - -/** - * Handle warmup task - preload detectors without processing files - */ -async function handleWarmup(task: WarmupTask): Promise { - const startTime = Date.now(); - - // Force load detectors - const detectors = await loadDetectors(); - - // Apply filters to cache the filtered set too - let count = detectors.length; - if (task.categories && task.categories.length > 0) { - const categories = new Set(task.categories); - count = detectors.filter(d => categories.has(d.getInfo().category)).length; - } - if (task.criticalOnly) { - count = detectors.filter(d => CRITICAL_DETECTOR_IDS.has(d.id)).length; - } - - return { - type: 'warmup', - detectorsLoaded: count, - duration: Date.now() - startTime, - }; -} - -/** - * Process a single file with detectors - * - * This is the main export that Piscina will call for each task. - */ -export default async function processFile(task: DetectorWorkerTask | WarmupTask): Promise { - // Handle warmup task - if ('type' in task && task.type === 'warmup') { - return handleWarmup(task); - } - - // Regular file processing - const scanTask = task; - const startTime = Date.now(); - const language = getLanguage(scanTask.file); - - // Skip files we can't detect language for - if (!language) { - return { - file: scanTask.file, - language: null, - patterns: [], - violations: [], - detectorsRan: 0, - detectorsSkipped: 0, - duration: Date.now() - startTime, - }; - } - - try { - // Load detectors (cached) - const allDetectors = await loadDetectors(); - const detectors = filterDetectors(allDetectors, scanTask); - - // Read file content - const filePath = path.join(scanTask.rootDir, scanTask.file); - const content = await fs.readFile(filePath, 'utf-8'); - - // Create detection context - const context: DetectionContext = { - file: scanTask.file, - content, - language, - ast: null, - imports: [], - exports: [], - extension: path.extname(scanTask.file), - isTestFile: /\.(test|spec)\.[jt]sx?$/.test(scanTask.file) || scanTask.file.includes('__tests__'), - isTypeDefinition: scanTask.file.endsWith('.d.ts'), - projectContext: { - rootDir: scanTask.rootDir, - files: scanTask.projectFiles, - config: scanTask.projectConfig, - }, - }; - - const patterns: WorkerPatternMatch[] = []; - const violations: WorkerViolation[] = []; - let detectorsRan = 0; - let detectorsSkipped = 0; - - // Run applicable detectors - for (const detector of detectors) { - if (!isDetectorApplicable(detector, language)) { - detectorsSkipped++; - continue; - } - - detectorsRan++; - - try { - const result = await detector.detect(context); - const info = detector.getInfo(); - - // Process patterns - PRESERVE ALL METADATA - // Note: Detectors output PatternMatch which has isOutlier but not all extended fields - // We safely access extended fields if they exist (some detectors may provide them) - for (const match of result.patterns) { - const extendedMatch = match as PatternMatch & { - outlierReason?: string; - matchedText?: string; - metadata?: Record; - }; - - // Build location object, only including defined values - const location: WorkerPatternMatch['location'] = { - file: match.location.file, - line: match.location.line, - column: match.location.column, - }; - if (match.location.endLine !== undefined) {location.endLine = match.location.endLine;} - if (match.location.endColumn !== undefined) {location.endColumn = match.location.endColumn;} - - // Build pattern match, only including defined values - const patternMatch: WorkerPatternMatch = { - patternId: match.patternId, - detectorId: detector.id, - detectorName: info.name, - detectorDescription: info.description, - category: info.category, - subcategory: info.subcategory, - confidence: match.confidence, - location, - isOutlier: match.isOutlier, - }; - if (extendedMatch.outlierReason !== undefined) {patternMatch.outlierReason = extendedMatch.outlierReason;} - if (extendedMatch.matchedText !== undefined) {patternMatch.matchedText = extendedMatch.matchedText;} - const metadata = extendedMatch.metadata ?? result.metadata?.custom; - if (metadata !== undefined) {patternMatch.metadata = metadata;} - - patterns.push(patternMatch); - } - - // Process violations - let violationsToProcess = result.violations; - - // Check for violations in custom metadata - if (violationsToProcess.length === 0 && result.metadata?.custom) { - const customData = result.metadata.custom; - const customViolations = customData['violations'] as Array<{ - type?: string; - file: string; - line: number; - column: number; - endLine?: number; - endColumn?: number; - value?: string; - issue?: string; - message?: string; - suggestedFix?: string; - severity?: string; - }> | undefined; - - if (customViolations && Array.isArray(customViolations)) { - violationsToProcess = customViolations.map(cv => { - const v: typeof result.violations[0] = { - id: `${detector.id}-${cv.file}-${cv.line}-${cv.column}`, - patternId: detector.id, - severity: (cv.severity as 'error' | 'warning' | 'info' | 'hint') || 'warning', - file: cv.file, - range: { - start: { line: cv.line - 1, character: cv.column - 1 }, - end: { line: (cv.endLine || cv.line) - 1, character: (cv.endColumn || cv.column) - 1 }, - }, - message: cv.issue || cv.message || cv.type || 'Pattern violation detected', - expected: cv.suggestedFix || 'Follow established patterns', - actual: cv.value || 'Non-conforming code', - aiExplainAvailable: true, - aiFixAvailable: !!cv.suggestedFix, - firstSeen: new Date(), - occurrences: 1, - }; - if (cv.type) { - v.explanation = `Violation type: ${cv.type}`; - } - return v; - }); - } - } - - for (const violation of violationsToProcess) { - const wv: WorkerViolation = { - patternId: violation.patternId, - detectorId: detector.id, - category: info.category, - severity: violation.severity, - file: violation.file, - line: violation.range.start.line + 1, - column: violation.range.start.character + 1, - message: violation.message, - suggestedFix: violation.expected, - }; - if (violation.explanation) { - wv.explanation = violation.explanation; - } - violations.push(wv); - } - } catch (detectorError) { - // Log but don't fail the whole file - // Errors will be aggregated in the main thread - } - } - - return { - file: scanTask.file, - language, - patterns, - violations, - detectorsRan, - detectorsSkipped, - duration: Date.now() - startTime, - }; - } catch (error) { - return { - file: scanTask.file, - language, - patterns: [], - violations: [], - detectorsRan: 0, - detectorsSkipped: 0, - duration: Date.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }; - } -} + * NOTE: This is a re-export from driftdetect-core for backward compatibility. + * The actual implementation lives in driftdetect-core/services. + */ + +// Re-export types from core +export type { + DetectorWorkerTask, + DetectorWorkerResult, + WorkerPatternMatch, + WorkerViolation, + WarmupTask, + WarmupResult, +} from 'driftdetect-core'; + +// The actual worker implementation is in core +// This file exists for backward compatibility with existing imports diff --git a/packages/core/package.json b/packages/core/package.json index 1a634ae8..0efd9fd4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,13 +39,17 @@ "./wrappers": { "types": "./dist/wrappers/index.d.ts", "import": "./dist/wrappers/index.js" + }, + "./iec61131": { + "types": "./dist/iec61131/index.d.ts", + "import": "./dist/iec61131/index.js" } }, "files": [ "dist" ], "scripts": { - "build": "tsc && cp src/storage/schema.sql dist/storage/schema.sql", + "build": "tsc && mkdir -p dist/storage && cp src/storage/schema.sql dist/storage/schema.sql", "clean": "rm -rf dist", "dev": "tsc --watch", "lint": "eslint src --ext .ts", @@ -60,7 +64,6 @@ }, "dependencies": { "better-sqlite3": "^11.0.0", - "driftdetect-detectors": "workspace:0.9.47", "ignore": "^5.3.1", "minimatch": "^9.0.3", "piscina": "^5.1.4", @@ -83,10 +86,18 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.11", "@types/minimatch": "^5.1.2", - "@types/node": "^20.19.30", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "fast-check": "^3.15.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" + }, + "peerDependencies": { + "driftdetect-detectors": "workspace:*" + }, + "peerDependenciesMeta": { + "driftdetect-detectors": { + "optional": true + } } } diff --git a/packages/core/src/call-graph/analysis/graph-builder.ts b/packages/core/src/call-graph/analysis/graph-builder.ts index 67c231ef..5913e938 100644 --- a/packages/core/src/call-graph/analysis/graph-builder.ts +++ b/packages/core/src/call-graph/analysis/graph-builder.ts @@ -825,6 +825,7 @@ export class GraphBuilder { go: 0, rust: 0, cpp: 0, + 'structured-text': 0, }; for (const [, func] of this.functions) { diff --git a/packages/core/src/call-graph/extractors/index.ts b/packages/core/src/call-graph/extractors/index.ts index 73e72368..fae82d50 100644 --- a/packages/core/src/call-graph/extractors/index.ts +++ b/packages/core/src/call-graph/extractors/index.ts @@ -20,6 +20,7 @@ export { JavaCallGraphExtractor } from './java-extractor.js'; export { PhpCallGraphExtractor } from './php-extractor.js'; export { GoCallGraphExtractor, createGoExtractor } from './go-extractor.js'; export { RustCallGraphExtractor, createRustExtractor } from './rust-extractor.js'; +export { STCallGraphExtractor } from './st-extractor.js'; // Hybrid Extractors (tree-sitter + regex fallback) export { HybridExtractorBase, type HybridExtractionResult } from './hybrid-extractor-base.js'; diff --git a/packages/core/src/call-graph/extractors/st-extractor.ts b/packages/core/src/call-graph/extractors/st-extractor.ts new file mode 100644 index 00000000..8ec69021 --- /dev/null +++ b/packages/core/src/call-graph/extractors/st-extractor.ts @@ -0,0 +1,345 @@ +/** + * Structured Text Call Graph Extractor + * + * Single responsibility: Extract functions, calls, and data access from IEC 61131-3 ST code + */ + +import { BaseCallGraphExtractor } from './base-extractor.js'; + +import type { + CallGraphLanguage, + FileExtractionResult, + FunctionExtraction, + CallExtraction, + ClassExtraction, + ParameterInfo, +} from '../types.js'; + +/** + * IEC 61131-3 Structured Text call graph extractor + */ +export class STCallGraphExtractor extends BaseCallGraphExtractor { + readonly language: CallGraphLanguage = 'structured-text'; + readonly extensions: string[] = ['.st', '.stx', '.scl', '.pou', '.exp']; + + extract(source: string, filePath: string): FileExtractionResult { + const result = this.createEmptyResult(filePath); + result.language = this.language; + + try { + // Extract blocks (PROGRAM, FUNCTION_BLOCK, FUNCTION) + const blocks = this.extractBlocks(source); + result.functions = blocks.functions; + result.classes = blocks.classes; + + // Extract calls within each block + result.calls = this.extractCalls(source); + + // Extract FB instances as "imports" (dependencies) + result.imports = this.extractFBInstances(source); + + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + } + + return result; + } + + private extractBlocks(source: string): { + functions: FunctionExtraction[]; + classes: ClassExtraction[]; + } { + const functions: FunctionExtraction[] = []; + const classes: ClassExtraction[] = []; + + // Extract PROGRAMs + let match; + const programPattern = /PROGRAM\s+(\w+)/gi; + while ((match = programPattern.exec(source)) !== null) { + const name = match[1]!; + const startLine = this.getLineNumber(source, match.index); + const endLine = this.findEndLine(source, match.index, 'END_PROGRAM'); + const params = this.extractParameters(source, match.index); + + functions.push(this.createFunction({ + name, + qualifiedName: name, + startLine, + endLine, + parameters: params, + isExported: true, + isMethod: false, + })); + } + + // Extract FUNCTION_BLOCKs (these are like classes) + const fbPattern = /FUNCTION_BLOCK\s+(\w+)(?:\s+EXTENDS\s+(\w+))?/gi; + while ((match = fbPattern.exec(source)) !== null) { + const name = match[1]!; + const baseClass = match[2]; + const startLine = this.getLineNumber(source, match.index); + const endLine = this.findEndLine(source, match.index, 'END_FUNCTION_BLOCK'); + const methods = this.extractMethods(source, match.index); + + classes.push(this.createClass({ + name, + startLine, + endLine, + baseClasses: baseClass ? [baseClass] : [], + methods: methods.map(m => m.name), + isExported: true, + })); + + // Add methods as functions + functions.push(...methods.map(m => this.createFunction({ + name: m.name, + qualifiedName: `${name}.${m.name}`, + startLine: m.startLine, + endLine: m.endLine, + parameters: m.parameters, + isMethod: true, + className: name, + }))); + } + + // Extract FUNCTIONs + const funcPattern = /FUNCTION\s+(\w+)\s*:\s*(\w+)/gi; + while ((match = funcPattern.exec(source)) !== null) { + const name = match[1]!; + const returnType = match[2]; + const startLine = this.getLineNumber(source, match.index); + const endLine = this.findEndLine(source, match.index, 'END_FUNCTION'); + const params = this.extractParameters(source, match.index); + + functions.push(this.createFunction({ + name, + qualifiedName: name, + startLine, + endLine, + parameters: params, + returnType, + isExported: true, + isMethod: false, + })); + } + + return { functions, classes }; + } + + private extractCalls(source: string): CallExtraction[] { + const calls: CallExtraction[] = []; + const lines = source.split('\n'); + + // Track FB instances for method calls + const fbInstances = new Map(); + const instancePattern = /(\w+)\s*:\s*(\w+)\s*;/g; + let match; + while ((match = instancePattern.exec(source)) !== null) { + fbInstances.set(match[1]!, match[2]!); + } + + // Find function calls + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const lineNum = i + 1; + + // Skip comments + if (line.trim().startsWith('//') || line.trim().startsWith('(*')) continue; + + // Assignment with function call: result := FunctionName(...) + const assignCallPattern = /(\w+)\s*:=\s*(\w+)\s*\(/g; + while ((match = assignCallPattern.exec(line)) !== null) { + const calleeName = match[2]!; + // Skip built-in operators and keywords + if (!this.isBuiltIn(calleeName)) { + calls.push(this.createCall({ + calleeName, + line: lineNum, + column: match.index, + isMethodCall: false, + })); + } + } + + // FB instance call: fbInstance(IN:=...) or fbInstance.Method(...) + const fbCallPattern = /(\w+)(?:\.(\w+))?\s*\(/g; + while ((match = fbCallPattern.exec(line)) !== null) { + const instanceOrFunc = match[1]!; + const method = match[2]; + + if (fbInstances.has(instanceOrFunc)) { + // This is an FB instance call + const fbType = fbInstances.get(instanceOrFunc)!; + calls.push(this.createCall({ + calleeName: method || fbType, + receiver: instanceOrFunc, + fullExpression: method ? `${instanceOrFunc}.${method}` : instanceOrFunc, + line: lineNum, + column: match.index, + isMethodCall: true, + })); + } else if (!this.isBuiltIn(instanceOrFunc) && !method) { + // Direct function call + calls.push(this.createCall({ + calleeName: instanceOrFunc, + line: lineNum, + column: match.index, + isMethodCall: false, + })); + } + } + + // Timer/Counter calls + const timerPattern = /(TON|TOF|TP|CTU|CTD|CTUD|R_TRIG|F_TRIG)\s*\(/gi; + while ((match = timerPattern.exec(line)) !== null) { + calls.push(this.createCall({ + calleeName: match[1]!.toUpperCase(), + line: lineNum, + column: match.index, + isConstructorCall: true, + })); + } + } + + return calls; + } + + private extractFBInstances(source: string): Array<{ + source: string; + names: Array<{ imported: string; local: string; isDefault: boolean; isNamespace: boolean }>; + line: number; + isTypeOnly: boolean; + }> { + const imports: Array<{ + source: string; + names: Array<{ imported: string; local: string; isDefault: boolean; isNamespace: boolean }>; + line: number; + isTypeOnly: boolean; + }> = []; + + // FB instance declarations act like imports + const instancePattern = /(\w+)\s*:\s*(\w+)\s*;/g; + let match; + while ((match = instancePattern.exec(source)) !== null) { + const instanceName = match[1]!; + const fbType = match[2]!; + const line = this.getLineNumber(source, match.index); + + // Skip built-in types + if (!this.isBuiltInType(fbType)) { + imports.push(this.createImport({ + source: fbType, + names: [{ imported: fbType, local: instanceName, isDefault: false, isNamespace: false }], + line, + })); + } + } + + return imports; + } + + private extractParameters(source: string, blockStart: number): ParameterInfo[] { + const params: ParameterInfo[] = []; + const afterBlock = source.slice(blockStart); + + // Find VAR_INPUT section + const varInputMatch = afterBlock.match(/VAR_INPUT\s*\n([\s\S]*?)END_VAR/i); + if (varInputMatch) { + const varSection = varInputMatch[1]!; + const varPattern = /(\w+)\s*:\s*(\w+)(?:\s*:=\s*([^;]+))?/g; + let match; + while ((match = varPattern.exec(varSection)) !== null) { + params.push(this.parseParameter( + match[1]!, + match[2], + !!match[3] + )); + } + } + + return params; + } + + private extractMethods(source: string, fbStart: number): Array<{ + name: string; + startLine: number; + endLine: number; + parameters: ParameterInfo[]; + }> { + const methods: Array<{ + name: string; + startLine: number; + endLine: number; + parameters: ParameterInfo[]; + }> = []; + + const fbBody = source.slice(fbStart); + const methodPattern = /METHOD\s+(\w+)(?:\s*:\s*(\w+))?/gi; + let match; + + while ((match = methodPattern.exec(fbBody)) !== null) { + const name = match[1]!; + const startLine = this.getLineNumber(source, fbStart + match.index); + const endLine = this.findEndLine(source, fbStart + match.index, 'END_METHOD'); + const params = this.extractParameters(source, fbStart + match.index); + + methods.push({ name, startLine, endLine, parameters: params }); + } + + return methods; + } + + private getLineNumber(source: string, index: number): number { + return source.slice(0, index).split('\n').length; + } + + private findEndLine(source: string, startIndex: number, endKeyword: string): number { + const afterStart = source.slice(startIndex); + const endMatch = afterStart.match(new RegExp(endKeyword, 'i')); + if (endMatch) { + return this.getLineNumber(source, startIndex + endMatch.index! + endMatch[0].length); + } + return this.getLineNumber(source, source.length); + } + + private isBuiltIn(name: string): boolean { + const builtIns = new Set([ + // Operators + 'AND', 'OR', 'XOR', 'NOT', 'MOD', + // Type conversions + 'INT_TO_REAL', 'REAL_TO_INT', 'BOOL_TO_INT', 'INT_TO_BOOL', + 'DINT_TO_REAL', 'REAL_TO_DINT', 'TIME_TO_DINT', 'DINT_TO_TIME', + // Math functions + 'ABS', 'SQRT', 'LN', 'LOG', 'EXP', 'SIN', 'COS', 'TAN', 'ASIN', 'ACOS', 'ATAN', + 'MIN', 'MAX', 'LIMIT', 'SEL', 'MUX', + // String functions + 'LEN', 'LEFT', 'RIGHT', 'MID', 'CONCAT', 'INSERT', 'DELETE', 'REPLACE', 'FIND', + // Bit operations + 'SHL', 'SHR', 'ROL', 'ROR', + // Keywords + 'IF', 'THEN', 'ELSE', 'ELSIF', 'END_IF', + 'CASE', 'OF', 'END_CASE', + 'FOR', 'TO', 'BY', 'DO', 'END_FOR', + 'WHILE', 'END_WHILE', + 'REPEAT', 'UNTIL', 'END_REPEAT', + 'TRUE', 'FALSE', + ]); + return builtIns.has(name.toUpperCase()); + } + + private isBuiltInType(name: string): boolean { + const builtInTypes = new Set([ + 'BOOL', 'BYTE', 'WORD', 'DWORD', 'LWORD', + 'SINT', 'INT', 'DINT', 'LINT', + 'USINT', 'UINT', 'UDINT', 'ULINT', + 'REAL', 'LREAL', + 'TIME', 'DATE', 'TIME_OF_DAY', 'DATE_AND_TIME', 'TOD', 'DT', + 'STRING', 'WSTRING', + 'ARRAY', 'STRUCT', + // Standard FBs (these are built-in) + 'TON', 'TOF', 'TP', 'CTU', 'CTD', 'CTUD', 'R_TRIG', 'F_TRIG', + 'SR', 'RS', + ]); + return builtInTypes.has(name.toUpperCase()); + } +} diff --git a/packages/core/src/call-graph/store/call-graph-store.ts b/packages/core/src/call-graph/store/call-graph-store.ts index e7a2c4df..a3817c4e 100644 --- a/packages/core/src/call-graph/store/call-graph-store.ts +++ b/packages/core/src/call-graph/store/call-graph-store.ts @@ -268,6 +268,7 @@ export class CallGraphStore { go: 0, rust: 0, cpp: 0, + 'structured-text': 0, }, }, _sqliteAvailable: true, // Internal flag to indicate SQLite mode @@ -428,6 +429,7 @@ export class CallGraphStore { go: 0, rust: 0, cpp: 0, + 'structured-text': 0, }, }, }; diff --git a/packages/core/src/call-graph/types.ts b/packages/core/src/call-graph/types.ts index 3421954d..06d5c8e9 100644 --- a/packages/core/src/call-graph/types.ts +++ b/packages/core/src/call-graph/types.ts @@ -14,7 +14,7 @@ import type { DataAccessPoint, SensitiveField } from '../boundaries/types.js'; /** * Supported languages for call graph extraction */ -export type CallGraphLanguage = 'python' | 'typescript' | 'javascript' | 'java' | 'csharp' | 'php' | 'go' | 'rust' | 'cpp'; +export type CallGraphLanguage = 'python' | 'typescript' | 'javascript' | 'java' | 'csharp' | 'php' | 'go' | 'rust' | 'cpp' | 'structured-text'; /** * A function/method definition in the codebase diff --git a/packages/core/src/call-graph/unified-provider.ts b/packages/core/src/call-graph/unified-provider.ts index 761e9f5b..b997816f 100644 --- a/packages/core/src/call-graph/unified-provider.ts +++ b/packages/core/src/call-graph/unified-provider.ts @@ -294,6 +294,7 @@ export class UnifiedCallGraphProvider { go: 0, rust: 0, cpp: 0, + 'structured-text': 0, }; return { diff --git a/packages/core/src/iec61131/__tests__/ai-context.test.ts b/packages/core/src/iec61131/__tests__/ai-context.test.ts new file mode 100644 index 00000000..6813ce3d --- /dev/null +++ b/packages/core/src/iec61131/__tests__/ai-context.test.ts @@ -0,0 +1,304 @@ +/** + * AI Context Generator Tests + */ + +import { describe, it, expect } from 'vitest'; +import { AIContextGenerator, createAIContextGenerator } from '../analyzers/ai-context.js'; +import type { STPOU, SafetyAnalysisResult } from '../types.js'; +import type { DocstringExtractionResult, StateMachineExtractionResult, TribalKnowledgeExtractionResult } from '../extractors/index.js'; + +describe('AIContextGenerator', () => { + const createMockPOU = (overrides: Partial = {}): STPOU => ({ + id: 'test-pou-1', + type: 'FUNCTION_BLOCK', + name: 'FB_Test', + qualifiedName: 'FB_Test', + location: { file: 'test.st', line: 1, column: 1 }, + documentation: null, + variables: [ + { + id: 'var-1', + name: 'bInput', + dataType: 'BOOL', + section: 'VAR_INPUT', + initialValue: null, + comment: 'Input signal', + isArray: false, + arrayBounds: null, + isSafetyCritical: false, + ioAddress: null, + location: { file: 'test.st', line: 5, column: 1 }, + pouId: 'test-pou-1', + }, + { + id: 'var-2', + name: 'bOutput', + dataType: 'BOOL', + section: 'VAR_OUTPUT', + initialValue: null, + comment: 'Output signal', + isArray: false, + arrayBounds: null, + isSafetyCritical: false, + ioAddress: null, + location: { file: 'test.st', line: 6, column: 1 }, + pouId: 'test-pou-1', + }, + ], + extends: null, + implements: [], + methods: [], + bodyStartLine: 10, + bodyEndLine: 50, + vendorAttributes: {}, + ...overrides, + }); + + const createMockDocstrings = (): DocstringExtractionResult => ({ + docstrings: [], + summary: { + total: 0, + byBlock: {}, + withParams: 0, + withHistory: 0, + withWarnings: 0, + averageQuality: 0, + }, + }); + + const createMockStateMachines = (): StateMachineExtractionResult => ({ + stateMachines: [], + summary: { + total: 0, + totalStates: 0, + byVariable: {}, + withDeadlocks: 0, + withGaps: 0, + }, + }); + + const createMockSafety = (): SafetyAnalysisResult => ({ + interlocks: [], + bypasses: [], + criticalWarnings: [], + summary: { + totalInterlocks: 0, + byType: { + 'interlock': 0, + 'permissive': 0, + 'estop': 0, + 'safety-relay': 0, + 'safety-device': 0, + 'bypass': 0, + }, + bypassCount: 0, + criticalWarningCount: 0, + }, + }); + + const createMockTribalKnowledge = (): TribalKnowledgeExtractionResult => ({ + items: [], + summary: { + total: 0, + byType: {}, + byImportance: { + critical: 0, + high: 0, + medium: 0, + low: 0, + }, + criticalCount: 0, + }, + }); + + describe('factory function', () => { + it('should create generator', () => { + const generator = createAIContextGenerator(); + expect(generator).toBeInstanceOf(AIContextGenerator); + }); + }); + + describe('generateContext', () => { + it('should generate context for Python target', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety(), + createMockTribalKnowledge(), + 'python' + ); + + expect(result.version).toBe('1.0.0'); + expect(result.targetLanguage).toBe('python'); + expect(result.pous).toHaveLength(1); + expect(result.types.plcToTarget['BOOL']).toBe('bool'); + expect(result.types.plcToTarget['INT']).toBe('int'); + expect(result.types.plcToTarget['REAL']).toBe('float'); + }); + + it('should generate context for Rust target', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety(), + createMockTribalKnowledge(), + 'rust' + ); + + expect(result.targetLanguage).toBe('rust'); + expect(result.types.plcToTarget['BOOL']).toBe('bool'); + expect(result.types.plcToTarget['INT']).toBe('i16'); + expect(result.types.plcToTarget['DINT']).toBe('i32'); + }); + + it('should generate context for TypeScript target', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety(), + createMockTribalKnowledge(), + 'typescript' + ); + + expect(result.targetLanguage).toBe('typescript'); + expect(result.types.plcToTarget['BOOL']).toBe('boolean'); + expect(result.types.plcToTarget['STRING']).toBe('string'); + }); + + it('should include POU interface information', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety(), + createMockTribalKnowledge(), + 'python' + ); + + const pouContext = result.pous[0]!; + expect(pouContext.pouName).toBe('FB_Test'); + expect(pouContext.interface.inputs).toHaveLength(1); + expect(pouContext.interface.outputs).toHaveLength(1); + expect(pouContext.interface.inputs[0]!.name).toBe('bInput'); + }); + + it('should include translation guide', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety(), + createMockTribalKnowledge(), + 'python' + ); + + expect(result.translationGuide).toBeDefined(); + expect(result.translationGuide.targetLanguage).toBe('python'); + expect(result.translationGuide.typeMapping).toBeDefined(); + expect(result.translationGuide.patternMapping.length).toBeGreaterThan(0); + }); + + it('should include project info when provided', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety(), + createMockTribalKnowledge(), + 'python', + { name: 'TestProject', vendor: 'siemens-tia', plcType: 'S7-1500' } + ); + + expect(result.project.name).toBe('TestProject'); + expect(result.project.vendor).toBe('siemens-tia'); + expect(result.project.plcType).toBe('S7-1500'); + }); + + it('should include safety context', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const safetyWithInterlocks: SafetyAnalysisResult = { + ...createMockSafety(), + interlocks: [{ + id: 'il-1', + name: 'bIL_DoorClosed', + type: 'interlock', + location: { file: 'test.st', line: 20, column: 1 }, + pouId: null, + isBypassed: false, + bypassCondition: null, + confidence: 0.95, + severity: 'high', + relatedInterlocks: [], + }], + }; + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + safetyWithInterlocks, + createMockTribalKnowledge(), + 'python' + ); + + expect(result.safety.interlocks).toHaveLength(1); + expect(result.safety.mustPreserve.length).toBeGreaterThan(0); + }); + + it('should generate verification requirements', () => { + const generator = new AIContextGenerator(); + const pou = createMockPOU(); + + const safetyWithInterlocks: SafetyAnalysisResult = { + ...createMockSafety(), + interlocks: [{ + id: 'il-1', + name: 'bIL_Test', + type: 'interlock', + location: { file: 'test.st', line: 20, column: 1 }, + pouId: null, + isBypassed: false, + bypassCondition: null, + confidence: 0.95, + severity: 'high', + relatedInterlocks: [], + }], + }; + + const result = generator.generateContext( + [pou], + createMockDocstrings(), + createMockStateMachines(), + safetyWithInterlocks, + createMockTribalKnowledge(), + 'python' + ); + + expect(result.verificationRequirements.length).toBeGreaterThan(0); + expect(result.verificationRequirements.some(r => r.category === 'safety')).toBe(true); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/analyzer.test.ts b/packages/core/src/iec61131/__tests__/analyzer.test.ts new file mode 100644 index 00000000..c3f75035 --- /dev/null +++ b/packages/core/src/iec61131/__tests__/analyzer.test.ts @@ -0,0 +1,496 @@ +/** + * IEC 61131-3 Analyzer Integration Tests + * + * Tests the main analyzer that orchestrates all extractors. + * Ensures the full pipeline works correctly. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { IEC61131Analyzer, createAnalyzer } from '../analyzer.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +describe('IEC61131Analyzer', () => { + let analyzer: IEC61131Analyzer; + let tempDir: string; + + beforeEach(async () => { + analyzer = new IEC61131Analyzer(); + + // Create temp directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iec61131-test-')); + }); + + afterEach(() => { + // Clean up temp directory + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + function writeTestFile(filename: string, content: string): string { + const filePath = path.join(tempDir, filename); + fs.writeFileSync(filePath, content); + return filePath; + } + + describe('initialization', () => { + it('should initialize with directory path', async () => { + writeTestFile('test.st', ` +PROGRAM Test +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + + // Should not throw + expect(true).toBe(true); + }); + + it('should detect ST files', async () => { + writeTestFile('program1.st', 'PROGRAM P1 END_PROGRAM'); + writeTestFile('program2.st', 'PROGRAM P2 END_PROGRAM'); + writeTestFile('other.txt', 'Not an ST file'); + + await analyzer.initialize(tempDir); + const status = await analyzer.status(); + + expect(status.files.total).toBe(2); + }); + + it('should handle empty directory', async () => { + await analyzer.initialize(tempDir); + const status = await analyzer.status(); + + expect(status.files.total).toBe(0); + }); + }); + + describe('status', () => { + it('should return project status', async () => { + writeTestFile('test.st', ` +PROGRAM Main +VAR + x : INT; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const status = await analyzer.status(); + + expect(status.project).toBeDefined(); + expect(status.files).toBeDefined(); + expect(status.analysis).toBeDefined(); + expect(status.health).toBeDefined(); + }); + + it('should count POUs', async () => { + writeTestFile('test.st', ` +PROGRAM Main +END_PROGRAM + +FUNCTION_BLOCK FB_Test +END_FUNCTION_BLOCK + +FUNCTION Calc : INT +END_FUNCTION +`); + + await analyzer.initialize(tempDir); + const status = await analyzer.status(); + + expect(status.analysis.pous).toBe(3); + }); + + it('should calculate health score', async () => { + writeTestFile('test.st', ` +(** + * Well documented program + * @author Test + *) +PROGRAM Main +VAR + x : INT; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const status = await analyzer.status(); + + expect(status.health.score).toBeGreaterThanOrEqual(0); + expect(status.health.score).toBeLessThanOrEqual(100); + }); + }); + + describe('docstrings', () => { + it('should extract docstrings', async () => { + writeTestFile('test.st', ` +(** + * Main program + * @param x Input value + *) +PROGRAM Main +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.docstrings(); + + expect(result.docstrings.length).toBeGreaterThanOrEqual(1); + }); + + it('should respect limit option', async () => { + writeTestFile('test.st', ` +(* Doc 1 *) +PROGRAM P1 END_PROGRAM +(* Doc 2 *) +PROGRAM P2 END_PROGRAM +(* Doc 3 *) +PROGRAM P3 END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.docstrings(undefined, { limit: 2 }); + + expect(result.docstrings.length).toBeLessThanOrEqual(2); + }); + }); + + describe('blocks', () => { + it('should list all POUs', async () => { + writeTestFile('test.st', ` +PROGRAM Main +END_PROGRAM + +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK + +FUNCTION Add : INT +END_FUNCTION +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.blocks(); + + expect(result.blocks.length).toBe(3); + expect(result.summary.total).toBe(3); + }); + + it('should categorize by type', async () => { + writeTestFile('test.st', ` +PROGRAM P1 END_PROGRAM +PROGRAM P2 END_PROGRAM +FUNCTION_BLOCK FB1 END_FUNCTION_BLOCK +FUNCTION F1 : INT END_FUNCTION +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.blocks(); + + expect(result.summary.byType.PROGRAM).toBe(2); + expect(result.summary.byType.FUNCTION_BLOCK).toBe(1); + expect(result.summary.byType.FUNCTION).toBe(1); + }); + }); + + describe('stateMachines', () => { + it('should detect state machines', async () => { + writeTestFile('test.st', ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 1: (* Running *); + 2: (* Done *); +END_CASE; +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.stateMachines(); + + expect(result.stateMachines.length).toBeGreaterThanOrEqual(1); + }); + + it('should generate visualizations', async () => { + writeTestFile('test.st', ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; +END_CASE; +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.stateMachines(); + + expect(result.stateMachines[0].visualizations.mermaid).toBeDefined(); + }); + }); + + describe('safety', () => { + it('should detect safety interlocks', async () => { + writeTestFile('test.st', ` +PROGRAM Safety +VAR + bIL_OK : BOOL; + bES_OK : BOOL; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.safety(); + + expect(result.interlocks.length).toBeGreaterThanOrEqual(2); + }); + + it('should detect bypasses', async () => { + writeTestFile('test.st', ` +PROGRAM Safety +VAR + bDbg_SkipIL : BOOL; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.safety(); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + }); + + it('should generate critical warnings for bypasses', async () => { + writeTestFile('test.st', ` +PROGRAM Safety +VAR + bBypassSafety : BOOL; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.safety(); + + expect(result.criticalWarnings.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('tribalKnowledge', () => { + it('should extract tribal knowledge', async () => { + writeTestFile('test.st', ` +PROGRAM Test +(* WARNING: Critical timing *) +(* TODO: Add error handling *) +(* HACK: Workaround for sensor bug *) +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.tribalKnowledge(); + + expect(result.items.length).toBeGreaterThanOrEqual(3); + }); + + it('should classify by importance', async () => { + writeTestFile('test.st', ` +PROGRAM Test +(* DANGER: Risk of injury *) +(* WARNING: Important note *) +(* TODO: Minor task *) +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.tribalKnowledge(); + + expect(result.summary.byImportance.critical).toBeGreaterThanOrEqual(1); + }); + }); + + describe('variables', () => { + it('should extract variables', async () => { + writeTestFile('test.st', ` +PROGRAM Test +VAR + x : INT; + y : REAL; + z : BOOL; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.variables(); + + expect(result.variables.length).toBeGreaterThanOrEqual(3); + }); + + it('should detect I/O mappings', async () => { + writeTestFile('test.st', ` +PROGRAM Test +VAR + bInput AT %IX0.0 : BOOL; + bOutput AT %QX1.0 : BOOL; +END_VAR +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.variables(); + + expect(result.ioMappings.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('all (full analysis)', () => { + it('should run complete analysis', async () => { + writeTestFile('test.st', ` +(** + * Main program + * @warning Safety critical + *) +PROGRAM Main +VAR + nState : INT; + bIL_OK : BOOL; +END_VAR +(* TODO: Add error handling *) +CASE nState OF + 0: x := 1; + 1: x := 2; +END_CASE; +END_PROGRAM +`); + + await analyzer.initialize(tempDir); + const result = await analyzer.all(); + + expect(result.status).toBeDefined(); + expect(result.docstrings).toBeDefined(); + expect(result.stateMachines).toBeDefined(); + expect(result.safety).toBeDefined(); + expect(result.tribalKnowledge).toBeDefined(); + }); + }); + + describe('file filtering', () => { + it('should analyze specific file', async () => { + writeTestFile('file1.st', 'PROGRAM P1 END_PROGRAM'); + writeTestFile('file2.st', 'PROGRAM P2 END_PROGRAM'); + + await analyzer.initialize(tempDir); + const result = await analyzer.blocks('file1.st'); + + expect(result.blocks.length).toBe(1); + expect(result.blocks[0].name).toBe('P1'); + }); + }); + + describe('error handling', () => { + it('should handle malformed files gracefully', async () => { + writeTestFile('broken.st', ` +PROGRAM Broken +VAR + x INT (* Missing colon *) +END_VAR +`); + + await analyzer.initialize(tempDir); + + // Should not throw + const status = await analyzer.status(); + expect(status).toBeDefined(); + }); + + it('should handle non-existent directory', async () => { + await expect(analyzer.initialize('/non/existent/path')).rejects.toThrow(); + }); + }); + + describe('createAnalyzer factory', () => { + it('should create analyzer instance', () => { + const instance = createAnalyzer(); + expect(instance).toBeInstanceOf(IEC61131Analyzer); + }); + + it('should accept options', () => { + const instance = createAnalyzer({ verbose: true }); + expect(instance).toBeInstanceOf(IEC61131Analyzer); + }); + }); +}); + +describe('IEC61131Analyzer - Integration with Sample Files', () => { + /** + * These tests use the actual sample files if available. + * They verify the analyzer works with real-world ST code. + */ + + const sampleDir = path.resolve(__dirname, '../../../../../../samples/iec61131'); + + // Skip if sample directory doesn't exist + const hasSamples = fs.existsSync(sampleDir); + + (hasSamples ? describe : describe.skip)('with sample files', () => { + let analyzer: IEC61131Analyzer; + + beforeEach(async () => { + analyzer = new IEC61131Analyzer(); + await analyzer.initialize(sampleDir); + }); + + it('should analyze sample files', async () => { + const status = await analyzer.status(); + + expect(status.files.total).toBeGreaterThan(0); + expect(status.analysis.pous).toBeGreaterThan(0); + }); + + it('should detect state machines in samples', async () => { + const result = await analyzer.stateMachines(); + + expect(result.stateMachines.length).toBeGreaterThan(0); + }); + + it('should detect safety interlocks in samples', async () => { + const result = await analyzer.safety(); + + expect(result.interlocks.length).toBeGreaterThan(0); + }); + + it('should extract tribal knowledge from samples', async () => { + const result = await analyzer.tribalKnowledge(); + + expect(result.items.length).toBeGreaterThan(0); + }); + + it('should extract docstrings from samples', async () => { + const result = await analyzer.docstrings(); + + expect(result.docstrings.length).toBeGreaterThan(0); + }); + + it('CRITICAL: should detect bypass in LEGACY_BATCH_SYSTEM.st', async () => { + const result = await analyzer.safety(); + + // The sample file has bDbg_SkipIL bypass + const hasBypass = result.bypasses.some(b => + b.name.toLowerCase().includes('skip') || + b.name.toLowerCase().includes('bypass') || + b.name.toLowerCase().includes('dbg') + ); + + expect(hasBypass).toBe(true); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/docstring-extractor.test.ts b/packages/core/src/iec61131/__tests__/docstring-extractor.test.ts new file mode 100644 index 00000000..5daffd6e --- /dev/null +++ b/packages/core/src/iec61131/__tests__/docstring-extractor.test.ts @@ -0,0 +1,553 @@ +/** + * IEC 61131-3 Docstring Extractor Tests + * + * Tests for documentation extraction from ST code. + * This is the PhD's primary request - extracting institutional knowledge. + */ + +import { describe, it, expect } from 'vitest'; +import { extractDocstrings, extractDocstringsFromFiles } from '../extractors/docstring-extractor.js'; +import type { DocstringExtractionResult } from '../extractors/docstring-extractor.js'; + +describe('DocstringExtractor', () => { + describe('basic extraction', () => { + it('should extract simple block comment', () => { + const source = ` +(* This is a simple comment *) +PROGRAM Test +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings.length).toBeGreaterThanOrEqual(1); + }); + + it('should extract docstring-style comment', () => { + const source = ` +(** + * Motor control function block + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings.length).toBeGreaterThanOrEqual(1); + }); + + it('should associate docstring with following POU', () => { + const source = ` +(** + * Main program entry point + *) +PROGRAM Main +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st'); + + const doc = result.docstrings.find(d => d.associatedBlock === 'Main'); + expect(doc).toBeDefined(); + }); + }); + + describe('parameter extraction', () => { + it('should extract @param tags', () => { + const source = ` +(** + * Motor control + * @param bStart Start command + * @param bStop Stop command + * @param nSpeed Speed setpoint + *) +FUNCTION_BLOCK FB_Motor +VAR_INPUT + bStart : BOOL; + bStop : BOOL; + nSpeed : INT; +END_VAR +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].params.length).toBeGreaterThanOrEqual(3); + + const startParam = result.docstrings[0].params.find(p => p.name === 'bStart'); + expect(startParam).toBeDefined(); + expect(startParam?.description).toContain('Start'); + }); + + it('should extract parameter direction', () => { + const source = ` +(** + * @param[in] bInput Input parameter + * @param[out] bOutput Output parameter + * @param[inout] refData Reference parameter + *) +FUNCTION_BLOCK FB_Test +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + const inParam = result.docstrings[0].params.find(p => p.name === 'bInput'); + const outParam = result.docstrings[0].params.find(p => p.name === 'bOutput'); + + if (inParam) expect(inParam.direction).toBe('in'); + if (outParam) expect(outParam.direction).toBe('out'); + }); + }); + + describe('return value extraction', () => { + it('should extract @return tag', () => { + const source = ` +(** + * Calculate sum + * @return INT Sum of inputs + *) +FUNCTION Add : INT +END_FUNCTION +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].returns).toBeDefined(); + expect(result.docstrings[0].returns).toContain('Sum'); + }); + + it('should extract @returns tag (alternative)', () => { + const source = ` +(** + * Calculate sum + * @returns The calculated sum + *) +FUNCTION Add : INT +END_FUNCTION +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].returns).toBeDefined(); + }); + }); + + describe('metadata extraction', () => { + it('should extract @author tag', () => { + const source = ` +(** + * Motor control + * @author John Smith + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].author).toBe('John Smith'); + }); + + it('should extract @date tag', () => { + const source = ` +(** + * Motor control + * @date 2024-01-15 + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].date).toBeDefined(); + }); + + it('should extract @version tag', () => { + const source = ` +(** + * Motor control + * @version 1.2.3 + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + // Version may be in notes or separate field + }); + }); + + describe('history extraction', () => { + it('should extract revision history', () => { + const source = ` +(** + * Motor control + * + * History: + * 2024-01-15 - John - Initial version + * 2024-02-01 - Jane - Added fault handling + * 2024-03-10 - Bob - Fixed timing issue + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].history.length).toBeGreaterThanOrEqual(1); + }); + + it('should extract @history tags', () => { + const source = ` +(** + * Motor control + * @history 2024-01-15 Initial version + * @history 2024-02-01 Added fault handling + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].history.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('warning extraction', () => { + it('should extract @warning tags', () => { + const source = ` +(** + * Motor control + * @warning Do not call while motor is running + * @warning Requires safety interlock + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].warnings.length).toBeGreaterThanOrEqual(2); + }); + + it('should extract @caution tags', () => { + const source = ` +(** + * Motor control + * @caution High voltage present + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + // Caution may be in warnings or notes + const hasWarning = result.docstrings[0].warnings.length > 0 || + result.docstrings[0].notes.length > 0; + expect(hasWarning).toBe(true); + }); + }); + + describe('note extraction', () => { + it('should extract @note tags', () => { + const source = ` +(** + * Motor control + * @note This FB requires FB_Timer + * @note Maximum speed is 1000 RPM + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].notes.length).toBeGreaterThanOrEqual(1); + }); + + it('should extract @see tags', () => { + const source = ` +(** + * Motor control + * @see FB_Timer + * @see FB_Fault + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + // @see may be in notes + }); + }); + + describe('summary extraction', () => { + it('should extract brief description', () => { + const source = ` +(** + * @brief Motor control function block + * + * Detailed description goes here. + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].summary).toContain('Motor control'); + }); + + it('should use first line as summary if no @brief', () => { + const source = ` +(** + * Motor control function block + * + * This is the detailed description. + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].summary).toContain('Motor'); + }); + }); + + describe('quality scoring', () => { + it('should score well-documented code higher', () => { + const wellDocumented = ` +(** + * @brief Motor control function block + * @param bStart Start command + * @param bStop Stop command + * @return BOOL Running status + * @author John Smith + * @date 2024-01-15 + * @warning Requires safety interlock + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const poorlyDocumented = ` +(* Motor *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + + const wellResult = extractDocstrings(wellDocumented, 'test.st'); + const poorResult = extractDocstrings(poorlyDocumented, 'test.st'); + + // Well-documented should have higher quality + expect(wellResult.summary.averageQuality).toBeGreaterThan(poorResult.summary.averageQuality); + }); + }); + + describe('summary statistics', () => { + it('should count total docstrings', () => { + const source = ` +(* Comment 1 *) +PROGRAM P1 +END_PROGRAM + +(* Comment 2 *) +PROGRAM P2 +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.summary.total).toBeGreaterThanOrEqual(2); + }); + + it('should count by block type', () => { + const source = ` +(* Program doc *) +PROGRAM Main +END_PROGRAM + +(* FB doc *) +FUNCTION_BLOCK FB_Test +END_FUNCTION_BLOCK + +(* Function doc *) +FUNCTION Calc : INT +END_FUNCTION +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.summary.byBlock).toBeDefined(); + }); + + it('should count docstrings with params', () => { + const source = ` +(** + * @param x Input value + *) +FUNCTION Test : INT +END_FUNCTION +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.summary.withParams).toBeGreaterThanOrEqual(1); + }); + + it('should count docstrings with warnings', () => { + const source = ` +(** + * @warning Critical safety function + *) +FUNCTION Test : INT +END_FUNCTION +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.summary.withWarnings).toBeGreaterThanOrEqual(1); + }); + }); + + describe('location tracking', () => { + it('should track docstring location', () => { + const source = ` +(* Test comment *) +PROGRAM Test +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings[0].location).toBeDefined(); + expect(result.docstrings[0].location.file).toBe('test.st'); + expect(result.docstrings[0].location.line).toBeGreaterThan(0); + }); + }); + + describe('raw content', () => { + it('should include raw content when requested', () => { + const source = ` +(** + * This is the raw content + * with multiple lines + *) +PROGRAM Test +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st', { includeRaw: true }); + + expect(result.docstrings[0].raw).toBeDefined(); + expect(result.docstrings[0].raw).toContain('raw content'); + }); + }); + + describe('real-world patterns', () => { + it('should handle Siemens-style documentation', () => { + const source = ` +//============================================================================= +// FB_Motor - Motor Control Function Block +//============================================================================= +// Author: John Smith +// Date: 2024-01-15 +// Version: 1.0.0 +// +// Description: +// Controls a motor with start/stop commands and fault handling. +// +// Inputs: +// i_bStart - Start command +// i_bStop - Stop command +// +// Outputs: +// o_bRunning - Motor running status +// o_bFault - Fault status +// +// History: +// 2024-01-15 - Initial version +// 2024-02-01 - Added fault handling +//============================================================================= +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle CODESYS-style documentation', () => { + const source = ` +{attribute 'qualified_only'} +(** + * Motor control function block + * + * @param bStart : BOOL - Start command + * @param bStop : BOOL - Stop command + * @return BOOL - Running status + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle legacy documentation', () => { + const source = ` +(****************************************************************************** + * PROGRAM: Main + * PURPOSE: Main control program + * AUTHOR: J. Smith + * DATE: 15-JAN-2024 + * + * MODIFICATION HISTORY: + * DATE AUTHOR DESCRIPTION + * ---------- -------- ----------------------------------------------------- + * 15-JAN-24 JSmith Initial version + * 01-FEB-24 JDoe Added safety checks + ******************************************************************************) +PROGRAM Main +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings.length).toBeGreaterThanOrEqual(1); + expect(result.docstrings[0].history.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('edge cases', () => { + it('should handle empty source', () => { + const result = extractDocstrings('', 'test.st'); + + expect(result.docstrings).toHaveLength(0); + }); + + it('should handle source without comments', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +x := x + 1; +END_PROGRAM +`; + const result = extractDocstrings(source, 'test.st'); + + expect(result.docstrings).toHaveLength(0); + }); + + it('should handle nested comments', () => { + const source = ` +(* Outer (* Inner *) Outer *) +PROGRAM Test +END_PROGRAM +`; + expect(() => extractDocstrings(source, 'test.st')).not.toThrow(); + }); + + it('should handle special characters in comments', () => { + const source = ` +(* Special chars: @#$%^&*()[]{}|\\;:'",.<>?/ *) +PROGRAM Test +END_PROGRAM +`; + expect(() => extractDocstrings(source, 'test.st')).not.toThrow(); + }); + + it('should handle Unicode in comments', () => { + const source = ` +(* Unicode: äöü Ʊ äø­ę–‡ ę—„ęœ¬čŖž *) +PROGRAM Test +END_PROGRAM +`; + expect(() => extractDocstrings(source, 'test.st')).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/migration-scorer.test.ts b/packages/core/src/iec61131/__tests__/migration-scorer.test.ts new file mode 100644 index 00000000..ce48283a --- /dev/null +++ b/packages/core/src/iec61131/__tests__/migration-scorer.test.ts @@ -0,0 +1,230 @@ +/** + * Migration Scorer Tests + */ + +import { describe, it, expect } from 'vitest'; +import { MigrationScorer, createMigrationScorer } from '../analyzers/migration-scorer.js'; +import type { STPOU, SafetyAnalysisResult } from '../types.js'; +import type { DocstringExtractionResult, StateMachineExtractionResult } from '../extractors/index.js'; + +describe('MigrationScorer', () => { + const createMockPOU = (overrides: Partial = {}): STPOU => ({ + id: 'test-pou-1', + type: 'FUNCTION_BLOCK', + name: 'FB_Test', + qualifiedName: 'FB_Test', + location: { file: 'test.st', line: 1, column: 1 }, + documentation: null, + variables: [], + extends: null, + implements: [], + methods: [], + bodyStartLine: 10, + bodyEndLine: 50, + vendorAttributes: {}, + ...overrides, + }); + + const createMockDocstrings = (): DocstringExtractionResult => ({ + docstrings: [], + summary: { + total: 0, + byBlock: {}, + withParams: 0, + withHistory: 0, + withWarnings: 0, + averageQuality: 0, + }, + }); + + const createMockStateMachines = (): StateMachineExtractionResult => ({ + stateMachines: [], + summary: { + total: 0, + totalStates: 0, + byVariable: {}, + withDeadlocks: 0, + withGaps: 0, + }, + }); + + const createMockSafety = (): SafetyAnalysisResult => ({ + interlocks: [], + bypasses: [], + criticalWarnings: [], + summary: { + totalInterlocks: 0, + byType: { + 'interlock': 0, + 'permissive': 0, + 'estop': 0, + 'safety-relay': 0, + 'safety-device': 0, + 'bypass': 0, + }, + bypassCount: 0, + criticalWarningCount: 0, + }, + }); + + describe('factory function', () => { + it('should create scorer with default weights', () => { + const scorer = createMigrationScorer(); + expect(scorer).toBeInstanceOf(MigrationScorer); + }); + + it('should create scorer with custom weights', () => { + const scorer = createMigrationScorer({ + weights: { documentation: 0.5 }, + }); + expect(scorer).toBeInstanceOf(MigrationScorer); + }); + }); + + describe('calculateReadiness', () => { + it('should return empty report for no POUs', () => { + const scorer = new MigrationScorer(); + const result = scorer.calculateReadiness( + [], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety() + ); + + expect(result.overallScore).toBe(0); + expect(result.pouScores).toHaveLength(0); + expect(result.migrationOrder).toHaveLength(0); + }); + + it('should score a simple POU', () => { + const scorer = new MigrationScorer(); + const pou = createMockPOU(); + + const result = scorer.calculateReadiness( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety() + ); + + expect(result.pouScores).toHaveLength(1); + expect(result.pouScores[0]!.pouName).toBe('FB_Test'); + expect(result.pouScores[0]!.overallScore).toBeGreaterThanOrEqual(0); + expect(result.pouScores[0]!.overallScore).toBeLessThanOrEqual(100); + }); + + it('should penalize POUs with safety bypasses', () => { + const scorer = new MigrationScorer(); + const pou = createMockPOU(); + + const safetyWithBypass: SafetyAnalysisResult = { + ...createMockSafety(), + bypasses: [{ + id: 'bypass-1', + name: 'bDbg_SkipIL', + location: { file: 'test.st', line: 20, column: 1 }, + pouId: null, + affectedInterlocks: [], + condition: null, + severity: 'critical', + }], + }; + + const result = scorer.calculateReadiness( + [pou], + createMockDocstrings(), + createMockStateMachines(), + safetyWithBypass + ); + + expect(result.pouScores[0]!.dimensionScores.safety).toBeLessThan(100); + expect(result.risks.some(r => r.category === 'safety')).toBe(true); + }); + + it('should generate migration order', () => { + const scorer = new MigrationScorer(); + const pou1 = createMockPOU({ id: 'pou-1', name: 'FB_First' }); + const pou2 = createMockPOU({ id: 'pou-2', name: 'FB_Second' }); + + const result = scorer.calculateReadiness( + [pou1, pou2], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety() + ); + + expect(result.migrationOrder).toHaveLength(2); + expect(result.migrationOrder[0]!.order).toBe(1); + expect(result.migrationOrder[1]!.order).toBe(2); + }); + + it('should estimate effort', () => { + const scorer = new MigrationScorer(); + const pou = createMockPOU(); + + const result = scorer.calculateReadiness( + [pou], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety() + ); + + expect(result.estimatedEffort.totalHours).toBeGreaterThan(0); + expect(result.estimatedEffort.confidence).toBeGreaterThan(0); + expect(result.estimatedEffort.confidence).toBeLessThanOrEqual(1); + }); + }); + + describe('grading', () => { + it('should assign correct grades', () => { + const scorer = new MigrationScorer(); + + // Create POUs with different documentation levels + const wellDocumented = createMockPOU({ + id: 'pou-good', + name: 'FB_Good', + documentation: { + id: 'doc-1', + summary: 'Well documented function block', + description: 'Full description', + params: [], + returns: null, + author: 'Test', + date: '2024-01-01', + history: [{ date: '2024-01-01', author: 'Test', description: 'Created' }], + warnings: [], + notes: [], + raw: '', + location: { file: 'test.st', line: 1, column: 1 }, + associatedBlock: 'FB_Good', + associatedBlockType: 'FUNCTION_BLOCK', + }, + variables: [ + { + id: 'var-1', + name: 'bInput', + dataType: 'BOOL', + section: 'VAR_INPUT', + initialValue: null, + comment: 'Input signal', + isArray: false, + arrayBounds: null, + isSafetyCritical: false, + ioAddress: null, + location: { file: 'test.st', line: 5, column: 1 }, + pouId: 'pou-good', + }, + ], + }); + + const result = scorer.calculateReadiness( + [wellDocumented], + createMockDocstrings(), + createMockStateMachines(), + createMockSafety() + ); + + expect(['A', 'B', 'C', 'D', 'F']).toContain(result.overallGrade); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/parser.test.ts b/packages/core/src/iec61131/__tests__/parser.test.ts new file mode 100644 index 00000000..f9497b07 --- /dev/null +++ b/packages/core/src/iec61131/__tests__/parser.test.ts @@ -0,0 +1,441 @@ +/** + * IEC 61131-3 Parser Tests + * + * Tests the ST parser for correct AST generation. + * Critical for ensuring all extractors receive valid data. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { STParser, parseSTSource } from '../parser/st-parser.js'; + +describe('STParser', () => { + let parser: STParser; + const testFile = 'test.st'; + + beforeEach(() => { + parser = new STParser(); + }); + + describe('basic parsing', () => { + it('should parse empty source', () => { + const result = parser.parse('', testFile); + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should parse simple PROGRAM', () => { + const source = ` +PROGRAM Main +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous).toHaveLength(1); + expect(result.pous[0].type).toBe('PROGRAM'); + expect(result.pous[0].name).toBe('Main'); + }); + + it('should parse FUNCTION_BLOCK', () => { + const source = ` +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous).toHaveLength(1); + expect(result.pous[0].type).toBe('FUNCTION_BLOCK'); + expect(result.pous[0].name).toBe('FB_Motor'); + }); + + it('should parse FUNCTION', () => { + const source = ` +FUNCTION Add : INT +END_FUNCTION +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous).toHaveLength(1); + expect(result.pous[0].type).toBe('FUNCTION'); + expect(result.pous[0].name).toBe('Add'); + }); + }); + + describe('variable parsing', () => { + it('should parse VAR section', () => { + const source = ` +PROGRAM Test +VAR + x : INT; + y : REAL; + z : BOOL; +END_VAR +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous[0].variables.length).toBeGreaterThanOrEqual(3); + + const varNames = result.pous[0].variables.map(v => v.name); + expect(varNames).toContain('x'); + expect(varNames).toContain('y'); + expect(varNames).toContain('z'); + }); + + it('should parse VAR_INPUT section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_INPUT + bEnable : BOOL; + nValue : INT; +END_VAR +END_FUNCTION_BLOCK +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const inputs = result.pous[0].variables.filter(v => v.section === 'VAR_INPUT'); + expect(inputs.length).toBeGreaterThanOrEqual(2); + }); + + it('should parse VAR_OUTPUT section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_OUTPUT + bDone : BOOL; + nResult : INT; +END_VAR +END_FUNCTION_BLOCK +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const outputs = result.pous[0].variables.filter(v => v.section === 'VAR_OUTPUT'); + expect(outputs.length).toBeGreaterThanOrEqual(2); + }); + + it('should parse VAR_IN_OUT section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_IN_OUT + refData : INT; +END_VAR +END_FUNCTION_BLOCK +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const inouts = result.pous[0].variables.filter(v => v.section === 'VAR_IN_OUT'); + expect(inouts.length).toBeGreaterThanOrEqual(1); + }); + + it('should parse variable with initial value', () => { + const source = ` +PROGRAM Test +VAR + x : INT := 42; + y : REAL := 3.14; + z : BOOL := TRUE; +END_VAR +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const xVar = result.pous[0].variables.find(v => v.name === 'x'); + expect(xVar?.initialValue).toBe('42'); + }); + + it('should parse variable with comment', () => { + const source = ` +PROGRAM Test +VAR + x : INT; (* This is a counter *) +END_VAR +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const xVar = result.pous[0].variables.find(v => v.name === 'x'); + // Comment may or may not be captured depending on parser implementation + expect(xVar).toBeDefined(); + }); + + it('should parse array variables', () => { + const source = ` +PROGRAM Test +VAR + arr : ARRAY[0..10] OF INT; +END_VAR +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const arrVar = result.pous[0].variables.find(v => v.name === 'arr'); + expect(arrVar?.isArray).toBe(true); + }); + + it('should parse I/O addressed variables', () => { + const source = ` +PROGRAM Test +VAR + bInput AT %IX0.0 : BOOL; + bOutput AT %QX1.0 : BOOL; +END_VAR +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const inputVar = result.pous[0].variables.find(v => v.name === 'bInput'); + expect(inputVar?.ioAddress).toBeDefined(); + }); + }); + + describe('comment parsing', () => { + it('should parse block comments', () => { + const source = ` +(* This is a block comment *) +PROGRAM Test +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.comments.length).toBeGreaterThanOrEqual(1); + }); + + it('should parse line comments', () => { + const source = ` +// This is a line comment +PROGRAM Test +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + // Line comments may or may not be captured + }); + + it('should parse multi-line block comments', () => { + const source = ` +(* + Multi-line + block comment +*) +PROGRAM Test +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + }); + + it('should parse docstring-style comments', () => { + const source = ` +(** + * @brief Motor control function block + * @param bStart Start command + * @return BOOL Running status + *) +FUNCTION_BLOCK FB_Motor +END_FUNCTION_BLOCK +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.comments.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('multiple POUs', () => { + it('should parse multiple POUs in one file', () => { + const source = ` +PROGRAM Main +END_PROGRAM + +FUNCTION_BLOCK FB_Helper +END_FUNCTION_BLOCK + +FUNCTION Calc : INT +END_FUNCTION +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous).toHaveLength(3); + }); + + it('should maintain correct order of POUs', () => { + const source = ` +PROGRAM First +END_PROGRAM + +PROGRAM Second +END_PROGRAM + +PROGRAM Third +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous[0].name).toBe('First'); + expect(result.pous[1].name).toBe('Second'); + expect(result.pous[2].name).toBe('Third'); + }); + }); + + describe('CASE statements', () => { + it('should parse simple CASE statement', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 1: (* Running *); + 2: (* Done *); +END_CASE; +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + }); + + it('should parse CASE with ELSE', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; +ELSE + x := 0; +END_CASE; +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + }); + + it('should parse nested CASE statements', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; + nSubState : INT; +END_VAR +CASE nState OF + 0: + CASE nSubState OF + 0: x := 1; + 1: x := 2; + END_CASE; + 1: + x := 3; +END_CASE; +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + }); + }); + + describe('error handling', () => { + it('should handle missing END_PROGRAM gracefully', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +`; + // Should not throw, may report error or warning + expect(() => parser.parse(source, testFile)).not.toThrow(); + }); + + it('should handle malformed variable declarations', () => { + const source = ` +PROGRAM Test +VAR + x INT; (* Missing colon *) +END_VAR +END_PROGRAM +`; + expect(() => parser.parse(source, testFile)).not.toThrow(); + }); + + it('should handle unknown keywords gracefully', () => { + const source = ` +PROGRAM Test +UNKNOWN_KEYWORD +END_PROGRAM +`; + expect(() => parser.parse(source, testFile)).not.toThrow(); + }); + }); + + describe('location tracking', () => { + it('should track POU locations', () => { + const source = ` +PROGRAM Test +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous[0].location).toBeDefined(); + expect(result.pous[0].location.line).toBeGreaterThan(0); + }); + + it('should track variable locations', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +END_PROGRAM +`; + const result = parser.parse(source, testFile); + + expect(result.success).toBe(true); + const xVar = result.pous[0].variables.find(v => v.name === 'x'); + expect(xVar?.location).toBeDefined(); + expect(xVar?.location.line).toBeGreaterThan(0); + }); + }); + + describe('parseSTSource helper', () => { + it('should work as standalone function', () => { + const source = ` +PROGRAM Test +END_PROGRAM +`; + const result = parseSTSource(source, testFile); + + expect(result.success).toBe(true); + expect(result.pous).toHaveLength(1); + }); + + it('should accept file path and options', () => { + const source = ` +PROGRAM Test +END_PROGRAM +`; + const result = parseSTSource(source, 'test.st', { strict: false }); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/safety-extractor.test.ts b/packages/core/src/iec61131/__tests__/safety-extractor.test.ts new file mode 100644 index 00000000..ba1ddfbd --- /dev/null +++ b/packages/core/src/iec61131/__tests__/safety-extractor.test.ts @@ -0,0 +1,581 @@ +/** + * IEC 61131-3 Safety Extractor Tests + * + * CRITICAL: Tests for safety interlock and bypass detection. + * Zero false negatives on bypass detection is a hard requirement. + * + * @requirements Zero false negatives on safety bypass detection + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { extractSafetyInterlocks, extractSafetyFromFiles } from '../extractors/safety-extractor.js'; +import type { SafetyExtractionResult } from '../extractors/safety-extractor.js'; + +describe('SafetyExtractor', () => { + describe('interlock detection', () => { + it('should detect basic interlock variables', () => { + const source = ` +PROGRAM Safety +VAR + bIL_OK : BOOL; + bIL_Press : BOOL; + bIL_Temp : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.interlocks.length).toBeGreaterThanOrEqual(3); + const names = result.interlocks.map(i => i.name); + expect(names).toContain('bIL_OK'); + expect(names).toContain('bIL_Press'); + expect(names).toContain('bIL_Temp'); + }); + + it('should detect interlock naming patterns', () => { + const patterns = [ + 'bInterlock', + 'bIL_Test', + 'Interlock_OK', + 'IL_Status', + 'bSafetyInterlock', + ]; + + for (const pattern of patterns) { + const source = ` +PROGRAM Test +VAR + ${pattern} : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + expect(result.interlocks.length).toBeGreaterThanOrEqual(1); + } + }); + + it('should detect E-Stop variables', () => { + const source = ` +PROGRAM Safety +VAR + bES_OK : BOOL; + bEStop : BOOL; + EmergencyStop : BOOL; + bE_Stop_Zone1 : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + const estops = result.interlocks.filter(i => i.type === 'estop'); + expect(estops.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect permissive variables', () => { + const source = ` +PROGRAM Safety +VAR + bPermissive : BOOL; + bPerm_Run : BOOL; + bRunPermit : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + const permissives = result.interlocks.filter(i => i.type === 'permissive'); + expect(permissives.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect safety relay variables', () => { + const source = ` +PROGRAM Safety +VAR + bSR_OK : BOOL; + bSafetyRelay : BOOL; + bSR_Zone1 : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect as some type of safety-related variable + expect(result.interlocks.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('CRITICAL: bypass detection', () => { + it('should detect bypass variables - naming pattern bDbg_Skip', () => { + const source = ` +PROGRAM Safety +VAR + bDbg_SkipIL : BOOL; (* DEBUG: Skip interlock for testing *) +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + expect(result.bypasses[0].name).toBe('bDbg_SkipIL'); + expect(result.bypasses[0].severity).toBe('critical'); + }); + + it('should detect bypass variables - naming pattern bBypass', () => { + const source = ` +PROGRAM Safety +VAR + bBypassSafety : BOOL; + bBypass_IL : BOOL; + bBypassInterlock : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(3); + }); + + it('should detect bypass variables - naming pattern bSkip', () => { + const source = ` +PROGRAM Safety +VAR + bSkipSafety : BOOL; + bSkip_Interlock : BOOL; + bSkipCheck : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect bypass variables - naming pattern bOverride', () => { + const source = ` +PROGRAM Safety +VAR + bOverrideSafety : BOOL; + bOverride_IL : BOOL; + bSafetyOverride : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect bypass variables - naming pattern bDisable', () => { + const source = ` +PROGRAM Safety +VAR + bDisableSafety : BOOL; + bDisable_IL : BOOL; + bSafetyDisable : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect bypass variables - naming pattern bForce', () => { + const source = ` +PROGRAM Safety +VAR + bForceSafety : BOOL; + bForce_IL : BOOL; + bForceInterlock : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect bypass in comments', () => { + const source = ` +PROGRAM Safety +VAR + bTestMode : BOOL; (* BYPASS: Used to skip safety checks during commissioning *) +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect either as bypass or generate critical warning + const hasBypassOrWarning = + result.bypasses.length > 0 || + result.criticalWarnings.some(w => w.type === 'bypass-detected'); + expect(hasBypassOrWarning).toBe(true); + }); + + it('should detect bypass logic patterns', () => { + const source = ` +PROGRAM Safety +VAR + bIL_OK : BOOL; + bDebugMode : BOOL; +END_VAR +(* Bypass interlock in debug mode *) +IF bDebugMode THEN + bIL_OK := TRUE; (* Force interlock OK *) +END_IF; +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect the bypass pattern + const hasBypassIndicator = + result.bypasses.length > 0 || + result.criticalWarnings.length > 0; + expect(hasBypassIndicator).toBe(true); + }); + + it('should detect maintenance bypass patterns', () => { + const source = ` +PROGRAM Safety +VAR + bMaintMode : BOOL; + bMaintBypass : BOOL; + bServiceMode : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Maintenance modes are potential bypasses + expect(result.bypasses.length + result.criticalWarnings.length).toBeGreaterThanOrEqual(1); + }); + + it('should NOT false positive on legitimate variables', () => { + const source = ` +PROGRAM Normal +VAR + bMotorRunning : BOOL; + nCounter : INT; + rTemperature : REAL; + sMessage : STRING; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should not detect bypasses in normal code + expect(result.bypasses).toHaveLength(0); + }); + }); + + describe('critical warnings', () => { + it('should generate warning for bypass detection', () => { + const source = ` +PROGRAM Safety +VAR + bDbg_SkipIL : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.criticalWarnings.length).toBeGreaterThanOrEqual(1); + expect(result.criticalWarnings[0].severity).toBe('critical'); + }); + + it('should include remediation in warnings', () => { + const source = ` +PROGRAM Safety +VAR + bBypassSafety : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + if (result.criticalWarnings.length > 0) { + expect(result.criticalWarnings[0].remediation).toBeDefined(); + expect(result.criticalWarnings[0].remediation.length).toBeGreaterThan(0); + } + }); + }); + + describe('summary statistics', () => { + it('should provide accurate summary', () => { + const source = ` +PROGRAM Safety +VAR + bIL_OK : BOOL; + bIL_Press : BOOL; + bES_OK : BOOL; + bBypassSafety : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.summary.totalInterlocks).toBeGreaterThanOrEqual(3); + expect(result.summary.bypassCount).toBeGreaterThanOrEqual(1); + }); + + it('should categorize by type', () => { + const source = ` +PROGRAM Safety +VAR + bIL_OK : BOOL; + bES_OK : BOOL; + bPermissive : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.summary.byType).toBeDefined(); + }); + }); + + describe('location tracking', () => { + it('should track interlock locations', () => { + const source = ` +PROGRAM Safety +VAR + bIL_OK : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.interlocks[0].location).toBeDefined(); + expect(result.interlocks[0].location.file).toBe('test.st'); + expect(result.interlocks[0].location.line).toBeGreaterThan(0); + }); + + it('should track bypass locations', () => { + const source = ` +PROGRAM Safety +VAR + bBypassSafety : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses[0].location).toBeDefined(); + expect(result.bypasses[0].location.file).toBe('test.st'); + expect(result.bypasses[0].location.line).toBeGreaterThan(0); + }); + }); + + describe('real-world patterns', () => { + it('should handle complex safety logic', () => { + const source = ` +PROGRAM R101_Safety +VAR + (* Safety Interlocks *) + bIL_OK : BOOL; (* Master interlock status *) + bIL_Press : BOOL; (* Pressure interlock *) + bIL_Temp : BOOL; (* Temperature interlock *) + bIL_Level : BOOL; (* Level interlock *) + + (* E-Stop *) + bES_OK : BOOL; (* E-Stop chain OK *) + bES_Zone1 : BOOL; (* Zone 1 E-Stop *) + bES_Zone2 : BOOL; (* Zone 2 E-Stop *) + + (* DEBUG - REMOVE BEFORE PRODUCTION *) + bDbg_SkipIL : BOOL; (* Skip interlock for testing *) +END_VAR + +(* Interlock logic *) +bIL_OK := bIL_Press AND bIL_Temp AND bIL_Level AND bES_OK; + +(* DEBUG BYPASS - CRITICAL: Remove before deployment *) +IF bDbg_SkipIL THEN + bIL_OK := TRUE; +END_IF; + +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect all interlocks + expect(result.interlocks.length).toBeGreaterThanOrEqual(5); + + // CRITICAL: Must detect the bypass + expect(result.bypasses.length).toBeGreaterThanOrEqual(1); + expect(result.bypasses.some(b => b.name === 'bDbg_SkipIL')).toBe(true); + + // Should have critical warning + expect(result.criticalWarnings.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle vendor-specific patterns (Siemens)', () => { + const source = ` +FUNCTION_BLOCK FB_SafetyMonitor +VAR_INPUT + i_bEmergencyStop : BOOL; + i_bSafetyGate : BOOL; + i_bLightCurtain : BOOL; +END_VAR +VAR_OUTPUT + o_bSafetyOK : BOOL; +END_VAR +VAR + bSafetyChainOK : BOOL; +END_VAR +END_FUNCTION_BLOCK +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect safety-related variables + expect(result.interlocks.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle vendor-specific patterns (Rockwell)', () => { + const source = ` +PROGRAM MainRoutine +VAR + Safety_OK : BOOL; + EStop_OK : BOOL; + GuardDoor_Closed : BOOL; + LightCurtain_Clear : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect safety-related variables + expect(result.interlocks.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('edge cases', () => { + it('should handle empty source', () => { + const result = extractSafetyInterlocks('', 'test.st'); + + expect(result.interlocks).toHaveLength(0); + expect(result.bypasses).toHaveLength(0); + }); + + it('should handle source with no safety variables', () => { + const source = ` +PROGRAM Calculator +VAR + a : INT; + b : INT; + result : INT; +END_VAR +result := a + b; +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + expect(result.bypasses).toHaveLength(0); + }); + + it('should handle malformed source gracefully', () => { + const source = ` +PROGRAM Broken +VAR + bIL_OK : BOOL + (* Missing semicolon and END_VAR *) +`; + expect(() => extractSafetyInterlocks(source, 'test.st')).not.toThrow(); + }); + + it('should handle case-insensitive matching', () => { + const source = ` +PROGRAM Safety +VAR + BIL_OK : BOOL; + bil_press : BOOL; + Bil_Temp : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + // Should detect all regardless of case + expect(result.interlocks.length).toBeGreaterThanOrEqual(3); + }); + }); +}); + +describe('SafetyExtractor - Zero False Negatives Guarantee', () => { + /** + * CRITICAL TEST SUITE + * + * These tests verify that we NEVER miss a safety bypass. + * False negatives in safety detection can lead to: + * - Equipment damage + * - Personal injury + * - Loss of life + * + * Every known bypass pattern MUST be detected. + */ + + const bypassPatterns = [ + // Debug patterns + 'bDbg_Skip', + 'bDebug_Bypass', + 'bDbgBypass', + 'bDbg_Override', + + // Bypass patterns + 'bBypass', + 'bBypassSafety', + 'bBypass_IL', + 'bSafetyBypass', + 'bypass_enable', + + // Skip patterns + 'bSkipSafety', + 'bSkip_IL', + 'bSkipInterlock', + 'skip_safety', + + // Override patterns + 'bOverride', + 'bOverrideSafety', + 'bSafetyOverride', + 'override_il', + + // Disable patterns + 'bDisableSafety', + 'bDisable_IL', + 'bSafetyDisable', + 'disable_interlock', + + // Force patterns + 'bForceSafety', + 'bForce_IL', + 'bForceInterlock', + 'force_safety_ok', + + // Test/Maintenance patterns + 'bTestMode', + 'bMaintBypass', + 'bServiceBypass', + 'bCommissioningMode', + ]; + + for (const pattern of bypassPatterns) { + it(`MUST detect bypass pattern: ${pattern}`, () => { + const source = ` +PROGRAM Safety +VAR + ${pattern} : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractSafetyInterlocks(source, 'test.st'); + + const detected = + result.bypasses.some(b => b.name.toLowerCase() === pattern.toLowerCase()) || + result.criticalWarnings.length > 0 || + result.interlocks.some(i => i.type === 'bypass'); + + expect(detected).toBe(true); + }); + } +}); diff --git a/packages/core/src/iec61131/__tests__/state-machine-extractor.test.ts b/packages/core/src/iec61131/__tests__/state-machine-extractor.test.ts new file mode 100644 index 00000000..f8771d1c --- /dev/null +++ b/packages/core/src/iec61131/__tests__/state-machine-extractor.test.ts @@ -0,0 +1,562 @@ +/** + * IEC 61131-3 State Machine Extractor Tests + * + * Tests for CASE-based state machine detection and analysis. + */ + +import { describe, it, expect } from 'vitest'; +import { extractStateMachines, extractStateMachinesFromFiles } from '../extractors/state-machine-extractor.js'; +import type { StateMachineExtractionResult } from '../extractors/state-machine-extractor.js'; + +describe('StateMachineExtractor', () => { + describe('basic detection', () => { + it('should detect simple CASE-based state machine', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 1: (* Running *); + 2: (* Done *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect state variable', () => { + const source = ` +PROGRAM Test +VAR + nPhase : INT; +END_VAR +CASE nPhase OF + 0: x := 1; + 10: x := 2; + 20: x := 3; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines.length).toBeGreaterThanOrEqual(1); + expect(result.stateMachines[0].stateVariable).toBe('nPhase'); + }); + + it('should detect all states', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 1: (* Init *); + 2: (* Running *); + 3: (* Stopping *); + 4: (* Done *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines[0].states.length).toBeGreaterThanOrEqual(5); + }); + + it('should extract state values', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 10: (* Running *); + 20: (* Done *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + const values = result.stateMachines[0].states.map(s => s.value); + expect(values).toContain(0); + expect(values).toContain(10); + expect(values).toContain(20); + }); + }); + + describe('state naming', () => { + it('should extract state names from comments', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* === IDLE === *); + 1: (* === RUNNING === *); + 2: (* === DONE === *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Should extract names from comments + const hasNames = result.stateMachines[0].states.some(s => s.name !== null); + expect(hasNames).toBe(true); + }); + + it('should handle states without names', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; + 2: x := 3; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Should still detect states even without names + expect(result.stateMachines[0].states.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('transition detection', () => { + it('should detect state transitions', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: + IF bStart THEN + nState := 1; + END_IF; + 1: + IF bDone THEN + nState := 2; + END_IF; + 2: + nState := 0; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines[0].transitions.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect transition guards', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; + bCondition : BOOL; +END_VAR +CASE nState OF + 0: + IF bCondition THEN + nState := 1; + END_IF; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Should detect the conditional transition + const hasGuardedTransition = result.stateMachines[0].transitions.some(t => t.guard !== null); + // This may or may not be implemented depending on parser sophistication + }); + }); + + describe('verification', () => { + it('should detect deadlock states', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: + nState := 1; + 1: + (* No transition - deadlock *) + x := 1; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Verification should flag potential deadlocks + expect(result.stateMachines[0].verification).toBeDefined(); + }); + + it('should detect gaps in state values', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; + 5: x := 3; (* Gap: 2, 3, 4 missing *) + 10: x := 4; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Should detect gaps + expect(result.stateMachines[0].verification.hasGaps).toBe(true); + }); + + it('should identify initial state', () => { + const source = ` +PROGRAM Test +VAR + nState : INT := 0; +END_VAR +CASE nState OF + 0: (* Initial state *); + 1: (* Running *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + const initialState = result.stateMachines[0].states.find(s => s.isInitial); + expect(initialState).toBeDefined(); + }); + }); + + describe('visualization', () => { + it('should generate Mermaid diagram', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 1: (* Running *); + 2: (* Done *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines[0].visualizations.mermaid).toBeDefined(); + expect(result.stateMachines[0].visualizations.mermaid).toContain('stateDiagram'); + }); + + it('should generate ASCII diagram', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: (* Idle *); + 1: (* Running *); +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines[0].visualizations.ascii).toBeDefined(); + }); + }); + + describe('multiple state machines', () => { + it('should detect multiple state machines in one POU', () => { + const source = ` +PROGRAM Test +VAR + nMainState : INT; + nSubState : INT; +END_VAR +CASE nMainState OF + 0: x := 1; + 1: x := 2; +END_CASE; + +CASE nSubState OF + 0: y := 1; + 1: y := 2; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines.length).toBeGreaterThanOrEqual(2); + }); + + it('should handle nested CASE statements', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; + nSubState : INT; +END_VAR +CASE nState OF + 0: + CASE nSubState OF + 0: x := 1; + 1: x := 2; + END_CASE; + 1: + y := 1; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Should detect both state machines + expect(result.stateMachines.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('summary statistics', () => { + it('should provide accurate summary', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; + 2: x := 3; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.summary.total).toBeGreaterThanOrEqual(1); + expect(result.summary.totalStates).toBeGreaterThanOrEqual(3); + }); + + it('should count machines with issues', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 5: x := 2; (* Gap *) +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.summary.withGaps).toBeGreaterThanOrEqual(1); + }); + }); + + describe('location tracking', () => { + it('should track state machine location', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines[0].location).toBeDefined(); + expect(result.stateMachines[0].location.file).toBe('test.st'); + }); + + it('should track state locations', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + for (const state of result.stateMachines[0].states) { + expect(state.location).toBeDefined(); + expect(state.location.line).toBeGreaterThan(0); + } + }); + }); + + describe('real-world patterns', () => { + it('should handle batch sequence pattern', () => { + const source = ` +PROGRAM R101_BatchSeq +VAR + nPhase : INT; + nStep : INT; +END_VAR + +(* Main phase sequence *) +CASE nPhase OF + 0: (* === IDLE === *) + IF bBatchStart THEN + nPhase := 10; + END_IF; + + 10: (* === CHARGE SOLVENT === *) + CASE nStep OF + 0: (* Open valve *) + bFV101 := TRUE; + nStep := 1; + 1: (* Wait for level *) + IF rLevel >= rSetpoint THEN + bFV101 := FALSE; + nStep := 0; + nPhase := 20; + END_IF; + END_CASE; + + 20: (* === HEAT === *) + IF rTemp >= rTempSetpoint THEN + nPhase := 30; + END_IF; + + 30: (* === REACT === *) + IF tReactTimer.Q THEN + nPhase := 40; + END_IF; + + 40: (* === DISCHARGE === *) + IF bTankEmpty THEN + nPhase := 0; + END_IF; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + // Should detect both phase and step state machines + expect(result.stateMachines.length).toBeGreaterThanOrEqual(2); + + // Phase machine should have multiple states + const phaseMachine = result.stateMachines.find(sm => sm.stateVariable === 'nPhase'); + expect(phaseMachine).toBeDefined(); + expect(phaseMachine!.states.length).toBeGreaterThanOrEqual(5); + }); + + it('should handle motor control pattern', () => { + const source = ` +FUNCTION_BLOCK FB_Motor +VAR + eState : E_MotorState; +END_VAR + +CASE eState OF + E_MotorState.Stopped: + IF bStart AND NOT bFault THEN + eState := E_MotorState.Starting; + END_IF; + + E_MotorState.Starting: + IF tStartDelay.Q THEN + eState := E_MotorState.Running; + END_IF; + + E_MotorState.Running: + IF bStop THEN + eState := E_MotorState.Stopping; + ELSIF bFault THEN + eState := E_MotorState.Faulted; + END_IF; + + E_MotorState.Stopping: + IF tStopDelay.Q THEN + eState := E_MotorState.Stopped; + END_IF; + + E_MotorState.Faulted: + IF bReset AND NOT bFault THEN + eState := E_MotorState.Stopped; + END_IF; +END_CASE; +END_FUNCTION_BLOCK +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines.length).toBeGreaterThanOrEqual(1); + expect(result.stateMachines[0].states.length).toBeGreaterThanOrEqual(5); + }); + }); + + describe('edge cases', () => { + it('should handle empty source', () => { + const result = extractStateMachines('', 'test.st'); + + expect(result.stateMachines).toHaveLength(0); + }); + + it('should handle source without CASE statements', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +x := x + 1; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines).toHaveLength(0); + }); + + it('should handle CASE with ELSE', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1; + 1: x := 2; +ELSE + x := 0; +END_CASE; +END_PROGRAM +`; + const result = extractStateMachines(source, 'test.st'); + + expect(result.stateMachines.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle malformed CASE gracefully', () => { + const source = ` +PROGRAM Test +VAR + nState : INT; +END_VAR +CASE nState OF + 0: x := 1 + (* Missing END_CASE *) +END_PROGRAM +`; + expect(() => extractStateMachines(source, 'test.st')).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/tokenizer.test.ts b/packages/core/src/iec61131/__tests__/tokenizer.test.ts new file mode 100644 index 00000000..69758973 --- /dev/null +++ b/packages/core/src/iec61131/__tests__/tokenizer.test.ts @@ -0,0 +1,295 @@ +/** + * IEC 61131-3 Tokenizer Tests + * + * Tests the ST tokenizer for correct token generation. + * Critical for ensuring parser accuracy. + */ + +import { describe, it, expect } from 'vitest'; +import { STTokenizer, tokenize } from '../parser/tokenizer.js'; +import type { Token, TokenType } from '../parser/tokenizer.js'; + +describe('STTokenizer', () => { + describe('basic tokenization', () => { + it('should tokenize keywords', () => { + const tokens = tokenize('PROGRAM VAR END_VAR END_PROGRAM'); + const types = tokens.map(t => t.type); + + // Tokenizer uses specific keyword types, not generic 'KEYWORD' + expect(types).toContain('PROGRAM'); + expect(types).toContain('VAR'); + expect(types).toContain('END_VAR'); + expect(types).toContain('END_PROGRAM'); + }); + + it('should tokenize identifiers', () => { + const tokens = tokenize('myVariable _test var123'); + const identifiers = tokens.filter(t => t.type === 'IDENTIFIER'); + + expect(identifiers).toHaveLength(3); + expect(identifiers.map(t => t.value)).toEqual(['myVariable', '_test', 'var123']); + }); + + it('should tokenize numbers', () => { + const tokens = tokenize('42 3.14'); + const integers = tokens.filter(t => t.type === 'INTEGER'); + const reals = tokens.filter(t => t.type === 'REAL'); + + expect(integers.length).toBeGreaterThanOrEqual(1); + expect(reals.length).toBeGreaterThanOrEqual(1); + }); + + it('should tokenize strings', () => { + const tokens = tokenize("'hello' \"world\""); + const strings = tokens.filter(t => t.type === 'STRING' || t.type === 'WSTRING'); + + expect(strings.length).toBeGreaterThanOrEqual(2); + }); + + it('should tokenize operators', () => { + const tokens = tokenize(':= + - * / = <> < > <= >='); + const operatorTypes = ['ASSIGN', 'PLUS', 'MINUS', 'STAR', 'SLASH', 'EQ', 'NE', 'LT', 'GT', 'LE', 'GE']; + const operators = tokens.filter(t => operatorTypes.includes(t.type)); + + expect(operators.length).toBeGreaterThanOrEqual(5); + }); + + it('should tokenize comments', () => { + const tokens = tokenize('(* block comment *) // line comment'); + const comments = tokens.filter(t => t.type === 'COMMENT'); + + expect(comments.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('POU declarations', () => { + it('should tokenize PROGRAM declaration', () => { + const source = ` +PROGRAM Main +VAR + x : INT; +END_VAR +END_PROGRAM +`; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('PROGRAM'); + expect(types).toContain('VAR'); + expect(types).toContain('END_VAR'); + expect(types).toContain('END_PROGRAM'); + }); + + it('should tokenize FUNCTION_BLOCK declaration', () => { + const source = ` +FUNCTION_BLOCK FB_Motor +VAR_INPUT + bStart : BOOL; +END_VAR +END_FUNCTION_BLOCK +`; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('FUNCTION_BLOCK'); + expect(types).toContain('VAR_INPUT'); + expect(types).toContain('END_FUNCTION_BLOCK'); + }); + + it('should tokenize FUNCTION declaration', () => { + const source = ` +FUNCTION Add : INT +VAR_INPUT + a : INT; + b : INT; +END_VAR + Add := a + b; +END_FUNCTION +`; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('FUNCTION'); + expect(types).toContain('END_FUNCTION'); + }); + }); + + describe('variable sections', () => { + it('should tokenize all variable section types', () => { + const sections = [ + 'VAR', 'VAR_INPUT', 'VAR_OUTPUT', 'VAR_IN_OUT', + 'VAR_GLOBAL', 'VAR_TEMP', 'VAR_CONSTANT', 'VAR_EXTERNAL' + ]; + + for (const section of sections) { + const source = `${section}\n x : INT;\nEND_VAR`; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain(section); + } + }); + }); + + describe('data types', () => { + it('should tokenize basic data types', () => { + const types = ['BOOL', 'INT', 'DINT', 'REAL', 'LREAL', 'STRING', 'TIME', 'DATE']; + + for (const type of types) { + const source = `VAR x : ${type}; END_VAR`; + const tokens = tokenize(source); + const found = tokens.some(t => + (t.type === 'KEYWORD' || t.type === 'IDENTIFIER') && + t.value.toUpperCase() === type + ); + + expect(found).toBe(true); + } + }); + + it('should tokenize array declarations', () => { + const source = 'VAR arr : ARRAY[0..10] OF INT; END_VAR'; + const tokens = tokenize(source); + + expect(tokens.some(t => t.value.toUpperCase() === 'ARRAY')).toBe(true); + }); + }); + + describe('control structures', () => { + it('should tokenize IF statement', () => { + const source = 'IF x > 0 THEN y := 1; ELSIF x < 0 THEN y := -1; ELSE y := 0; END_IF;'; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('IF'); + expect(types).toContain('THEN'); + expect(types).toContain('ELSIF'); + expect(types).toContain('ELSE'); + expect(types).toContain('END_IF'); + }); + + it('should tokenize CASE statement', () => { + const source = 'CASE nState OF 0: x := 1; 1: x := 2; ELSE x := 0; END_CASE;'; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('CASE'); + expect(types).toContain('OF'); + expect(types).toContain('END_CASE'); + }); + + it('should tokenize FOR loop', () => { + const source = 'FOR i := 0 TO 10 BY 1 DO x := x + 1; END_FOR;'; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('FOR'); + expect(types).toContain('TO'); + expect(types).toContain('DO'); + expect(types).toContain('END_FOR'); + }); + + it('should tokenize WHILE loop', () => { + const source = 'WHILE x < 10 DO x := x + 1; END_WHILE;'; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('WHILE'); + expect(types).toContain('DO'); + expect(types).toContain('END_WHILE'); + }); + + it('should tokenize REPEAT loop', () => { + const source = 'REPEAT x := x + 1; UNTIL x >= 10 END_REPEAT;'; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('REPEAT'); + expect(types).toContain('UNTIL'); + expect(types).toContain('END_REPEAT'); + }); + }); + + describe('I/O addresses', () => { + it('should tokenize AT addresses', () => { + const source = 'VAR bInput AT %IX0.0 : BOOL; END_VAR'; + const tokens = tokenize(source); + const types = tokens.map(t => t.type); + + expect(types).toContain('AT'); + // The % address is tokenized as UNKNOWN or IDENTIFIER depending on implementation + }); + + it('should tokenize various I/O address formats', () => { + // Just verify the tokenizer doesn't crash on these + const addresses = ['%IX0.0', '%QX1.0', '%IW10', '%QW20', '%MD100']; + + for (const addr of addresses) { + const source = `VAR x AT ${addr} : BOOL; END_VAR`; + expect(() => tokenize(source)).not.toThrow(); + } + }); + }); + + describe('line tracking', () => { + it('should track line numbers correctly', () => { + const source = `PROGRAM Test +VAR + x : INT; +END_VAR +END_PROGRAM`; + + const tokens = tokenize(source); + + // PROGRAM should be on line 1 + const programToken = tokens.find(t => t.value.toUpperCase() === 'PROGRAM'); + expect(programToken?.line).toBe(1); + + // VAR should be on line 2 + const varToken = tokens.find(t => t.value.toUpperCase() === 'VAR'); + expect(varToken?.line).toBe(2); + }); + + it('should track column positions', () => { + const source = ' PROGRAM Test'; + const tokens = tokenize(source); + + const programToken = tokens.find(t => t.value.toUpperCase() === 'PROGRAM'); + expect(programToken?.column).toBeGreaterThanOrEqual(2); + }); + }); + + describe('edge cases', () => { + it('should handle empty input', () => { + const tokens = tokenize(''); + // Should have at least EOF token + expect(tokens.length).toBeGreaterThanOrEqual(1); + expect(tokens[tokens.length - 1].type).toBe('EOF'); + }); + + it('should handle whitespace only', () => { + const tokens = tokenize(' \n\t\n '); + // Should have EOF token + expect(tokens[tokens.length - 1].type).toBe('EOF'); + }); + + it('should handle nested comments', () => { + const source = '(* outer (* inner *) outer *)'; + const tokens = tokenize(source); + // Should not crash and should produce some output + expect(tokens.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle unclosed strings gracefully', () => { + const source = "VAR s : STRING := 'unclosed"; + // Should not throw + expect(() => tokenize(source)).not.toThrow(); + }); + + it('should handle special characters in comments', () => { + const source = '(* Comment with special chars: @#$%^&*() *)'; + const tokens = tokenize(source); + expect(tokens.length).toBeGreaterThanOrEqual(1); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/tribal-knowledge-extractor.test.ts b/packages/core/src/iec61131/__tests__/tribal-knowledge-extractor.test.ts new file mode 100644 index 00000000..856c3e67 --- /dev/null +++ b/packages/core/src/iec61131/__tests__/tribal-knowledge-extractor.test.ts @@ -0,0 +1,499 @@ +/** + * IEC 61131-3 Tribal Knowledge Extractor Tests + * + * Tests for extracting institutional knowledge, warnings, workarounds, + * and other critical information embedded in comments. + */ + +import { describe, it, expect } from 'vitest'; +import { extractTribalKnowledge, extractTribalKnowledgeFromFiles } from '../extractors/tribal-knowledge-extractor.js'; +import type { TribalKnowledgeExtractionResult } from '../extractors/tribal-knowledge-extractor.js'; + +describe('TribalKnowledgeExtractor', () => { + describe('warning detection', () => { + it('should detect WARNING comments', () => { + const source = ` +PROGRAM Test +(* WARNING: Do not modify this value during operation *) +VAR + x : INT; +END_VAR +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const warnings = result.items.filter(i => i.type === 'warning'); + expect(warnings.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect CAUTION comments', () => { + const source = ` +PROGRAM Test +(* CAUTION: High voltage present *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const cautions = result.items.filter(i => i.type === 'caution'); + expect(cautions.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect DANGER comments', () => { + const source = ` +PROGRAM Test +(* DANGER: Risk of explosion if modified *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const dangers = result.items.filter(i => i.type === 'danger'); + expect(dangers.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('TODO/FIXME detection', () => { + it('should detect TODO comments', () => { + const source = ` +PROGRAM Test +(* TODO: Add error handling *) +(* TODO: Optimize this loop *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const todos = result.items.filter(i => i.type === 'todo'); + expect(todos.length).toBeGreaterThanOrEqual(2); + }); + + it('should detect FIXME comments', () => { + const source = ` +PROGRAM Test +(* FIXME: This calculation is wrong *) +(* FIXME: Race condition here *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const fixmes = result.items.filter(i => i.type === 'fixme'); + expect(fixmes.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('hack/workaround detection', () => { + it('should detect HACK comments', () => { + const source = ` +PROGRAM Test +(* HACK: Delay added to work around timing issue *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const hacks = result.items.filter(i => i.type === 'hack'); + expect(hacks.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect WORKAROUND comments', () => { + const source = ` +PROGRAM Test +(* WORKAROUND: PLC firmware bug requires this delay *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const workarounds = result.items.filter(i => i.type === 'workaround'); + expect(workarounds.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect "work around" phrase', () => { + const source = ` +PROGRAM Test +(* This is a work around for the sensor glitch *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('do-not-change detection', () => { + it('should detect DO NOT CHANGE comments', () => { + const source = ` +PROGRAM Test +(* DO NOT CHANGE: This timing is critical *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const doNotChange = result.items.filter(i => i.type === 'do-not-change'); + expect(doNotChange.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect DO NOT MODIFY comments', () => { + const source = ` +PROGRAM Test +(* DO NOT MODIFY - calibrated value *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect "don\'t touch" comments', () => { + const source = ` +PROGRAM Test +(* Don't touch this - it took 3 days to get right *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('magic number detection', () => { + it('should detect magic numbers with explanations', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +x := 42; (* Magic number: Answer to everything *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const magicNumbers = result.items.filter(i => i.type === 'magic-number'); + expect(magicNumbers.length).toBeGreaterThanOrEqual(0); // May or may not detect + }); + + it('should detect unexplained constants', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +(* Why 1.732? Nobody knows anymore *) +x := 1.732; +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('history/author detection', () => { + it('should detect author comments', () => { + const source = ` +PROGRAM Test +(* Author: John Smith, 2024-01-15 *) +(* Modified by: Jane Doe, 2024-02-01 *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const authorItems = result.items.filter(i => i.type === 'author' || i.type === 'history'); + expect(authorItems.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect date-based history', () => { + const source = ` +PROGRAM Test +(* 2024-01-15: Initial version *) +(* 2024-02-01: Fixed timing bug *) +(* 2024-03-10: Added safety check *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const historyItems = result.items.filter(i => i.type === 'history'); + expect(historyItems.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('equipment-specific knowledge', () => { + it('should detect equipment references', () => { + const source = ` +PROGRAM Test +(* This only works with Siemens S7-1500 *) +(* Allen-Bradley specific timing *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const equipmentItems = result.items.filter(i => i.type === 'equipment'); + expect(equipmentItems.length).toBeGreaterThanOrEqual(0); // May or may not detect + }); + + it('should detect vendor-specific notes', () => { + const source = ` +PROGRAM Test +(* NOTE: Rockwell firmware v32 has a bug with this *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('mystery/unknown detection', () => { + it('should detect "nobody knows" comments', () => { + const source = ` +PROGRAM Test +(* Nobody knows why this works *) +(* Don't ask me why this is here *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const mysteries = result.items.filter(i => i.type === 'mystery'); + expect(mysteries.length).toBeGreaterThanOrEqual(1); + }); + + it('should detect uncertainty comments', () => { + const source = ` +PROGRAM Test +(* Not sure why this delay is needed *) +(* I think this is for the old sensor *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('importance classification', () => { + it('should classify DANGER as critical', () => { + const source = ` +PROGRAM Test +(* DANGER: Risk of injury *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const danger = result.items.find(i => i.type === 'danger'); + expect(danger?.importance).toBe('critical'); + }); + + it('should classify WARNING as high', () => { + const source = ` +PROGRAM Test +(* WARNING: Important safety note *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const warning = result.items.find(i => i.type === 'warning'); + expect(warning?.importance).toBe('high'); + }); + + it('should classify TODO as medium', () => { + const source = ` +PROGRAM Test +(* TODO: Add feature *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const todo = result.items.find(i => i.type === 'todo'); + expect(todo?.importance).toBe('medium'); + }); + + it('should classify NOTE as low', () => { + const source = ` +PROGRAM Test +(* NOTE: Just FYI *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const note = result.items.find(i => i.type === 'note'); + if (note) { + expect(note.importance).toBe('low'); + } + }); + }); + + describe('summary statistics', () => { + it('should count total items', () => { + const source = ` +PROGRAM Test +(* WARNING: First *) +(* TODO: Second *) +(* FIXME: Third *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.summary.total).toBeGreaterThanOrEqual(3); + }); + + it('should count by type', () => { + const source = ` +PROGRAM Test +(* WARNING: One *) +(* WARNING: Two *) +(* TODO: Three *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.summary.byType).toBeDefined(); + expect(result.summary.byType.warning).toBeGreaterThanOrEqual(2); + }); + + it('should count by importance', () => { + const source = ` +PROGRAM Test +(* DANGER: Critical *) +(* WARNING: High *) +(* TODO: Medium *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.summary.byImportance).toBeDefined(); + expect(result.summary.byImportance.critical).toBeGreaterThanOrEqual(1); + }); + + it('should count critical items', () => { + const source = ` +PROGRAM Test +(* DANGER: One *) +(* DANGER: Two *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.summary.criticalCount).toBeGreaterThanOrEqual(2); + }); + }); + + describe('location tracking', () => { + it('should track item locations', () => { + const source = ` +PROGRAM Test +(* WARNING: Test warning *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items[0].location).toBeDefined(); + expect(result.items[0].location.file).toBe('test.st'); + expect(result.items[0].location.line).toBeGreaterThan(0); + }); + }); + + describe('context extraction', () => { + it('should include surrounding context', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +(* WARNING: x must be positive *) +IF x < 0 THEN + x := 0; +END_IF; +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items[0].context).toBeDefined(); + }); + }); + + describe('real-world patterns', () => { + it('should handle legacy code comments', () => { + const source = ` +PROGRAM Legacy +(* + * WARNING: This code was written in 1995 and has been + * modified many times. The original author is no longer + * with the company. DO NOT CHANGE unless absolutely + * necessary - it controls the main reactor. + * + * Known issues: + * - Sometimes the timer doesn't reset (FIXME) + * - The 0.5 second delay is a workaround for sensor lag + * - Nobody knows why we multiply by 1.732 + * + * History: + * 1995-03-15 - Original version (J. Smith) + * 2001-07-22 - Added safety check (unknown) + * 2015-11-30 - Fixed overflow bug (B. Jones) + *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + // Should extract multiple pieces of tribal knowledge + expect(result.items.length).toBeGreaterThanOrEqual(3); + expect(result.summary.criticalCount).toBeGreaterThanOrEqual(0); + }); + + it('should handle inline tribal knowledge', () => { + const source = ` +PROGRAM Test +VAR + rDelay : REAL := 0.5; (* HACK: Sensor needs time to settle *) + nRetries : INT := 3; (* Magic number - don't change! *) + bBypass : BOOL; (* WARNING: Debug only - remove before production *) +END_VAR +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('edge cases', () => { + it('should handle empty source', () => { + const result = extractTribalKnowledge('', 'test.st'); + + expect(result.items).toHaveLength(0); + }); + + it('should handle source without tribal knowledge', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +x := x + 1; +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + expect(result.items).toHaveLength(0); + }); + + it('should handle case-insensitive matching', () => { + const source = ` +PROGRAM Test +(* warning: lowercase *) +(* WARNING: uppercase *) +(* Warning: mixed case *) +END_PROGRAM +`; + const result = extractTribalKnowledge(source, 'test.st'); + + const warnings = result.items.filter(i => i.type === 'warning'); + expect(warnings.length).toBeGreaterThanOrEqual(3); + }); + + it('should handle special characters', () => { + const source = ` +PROGRAM Test +(* WARNING: Special chars @#$%^&*() *) +END_PROGRAM +`; + expect(() => extractTribalKnowledge(source, 'test.st')).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/iec61131/__tests__/variable-extractor.test.ts b/packages/core/src/iec61131/__tests__/variable-extractor.test.ts new file mode 100644 index 00000000..690f1ca1 --- /dev/null +++ b/packages/core/src/iec61131/__tests__/variable-extractor.test.ts @@ -0,0 +1,534 @@ +/** + * IEC 61131-3 Variable Extractor Tests + * + * Tests for variable extraction including I/O mappings. + */ + +import { describe, it, expect } from 'vitest'; +import { extractVariables, extractVariablesFromFiles } from '../extractors/variable-extractor.js'; +import type { VariableExtractionResult } from '../extractors/variable-extractor.js'; + +describe('VariableExtractor', () => { + describe('basic extraction', () => { + it('should extract simple variables', () => { + const source = ` +PROGRAM Test +VAR + x : INT; + y : REAL; + z : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.variables.length).toBeGreaterThanOrEqual(3); + + const names = result.variables.map(v => v.name); + expect(names).toContain('x'); + expect(names).toContain('y'); + expect(names).toContain('z'); + }); + + it('should extract variable types', () => { + const source = ` +PROGRAM Test +VAR + a : INT; + b : DINT; + c : REAL; + d : LREAL; + e : BOOL; + f : STRING; + g : TIME; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const aVar = result.variables.find(v => v.name === 'a'); + expect(aVar?.dataType).toBe('INT'); + + const cVar = result.variables.find(v => v.name === 'c'); + expect(cVar?.dataType).toBe('REAL'); + }); + }); + + describe('variable sections', () => { + it('should identify VAR section', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.variables[0].section).toBe('VAR'); + }); + + it('should identify VAR_INPUT section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_INPUT + bEnable : BOOL; +END_VAR +END_FUNCTION_BLOCK +`; + const result = extractVariables(source, 'test.st'); + + const inputVar = result.variables.find(v => v.name === 'bEnable'); + expect(inputVar?.section).toBe('VAR_INPUT'); + }); + + it('should identify VAR_OUTPUT section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_OUTPUT + bDone : BOOL; +END_VAR +END_FUNCTION_BLOCK +`; + const result = extractVariables(source, 'test.st'); + + const outputVar = result.variables.find(v => v.name === 'bDone'); + expect(outputVar?.section).toBe('VAR_OUTPUT'); + }); + + it('should identify VAR_IN_OUT section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_IN_OUT + refData : INT; +END_VAR +END_FUNCTION_BLOCK +`; + const result = extractVariables(source, 'test.st'); + + const inoutVar = result.variables.find(v => v.name === 'refData'); + expect(inoutVar?.section).toBe('VAR_IN_OUT'); + }); + + it('should identify VAR_GLOBAL section', () => { + const source = ` +VAR_GLOBAL + gCounter : INT; +END_VAR +`; + const result = extractVariables(source, 'test.st'); + + const globalVar = result.variables.find(v => v.name === 'gCounter'); + expect(globalVar?.section).toBe('VAR_GLOBAL'); + }); + + it('should identify VAR_TEMP section', () => { + const source = ` +PROGRAM Test +VAR_TEMP + temp : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const tempVar = result.variables.find(v => v.name === 'temp'); + expect(tempVar?.section).toBe('VAR_TEMP'); + }); + + it('should identify VAR_CONSTANT section', () => { + const source = ` +PROGRAM Test +VAR CONSTANT + MAX_VALUE : INT := 100; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + // May be VAR_CONSTANT or VAR depending on parser + expect(result.variables.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('initial values', () => { + it('should extract integer initial value', () => { + const source = ` +PROGRAM Test +VAR + x : INT := 42; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const xVar = result.variables.find(v => v.name === 'x'); + expect(xVar?.initialValue).toBe('42'); + }); + + it('should extract real initial value', () => { + const source = ` +PROGRAM Test +VAR + x : REAL := 3.14; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const xVar = result.variables.find(v => v.name === 'x'); + expect(xVar?.initialValue).toBe('3.14'); + }); + + it('should extract boolean initial value', () => { + const source = ` +PROGRAM Test +VAR + x : BOOL := TRUE; + y : BOOL := FALSE; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const xVar = result.variables.find(v => v.name === 'x'); + expect(xVar?.initialValue?.toUpperCase()).toBe('TRUE'); + }); + + it('should extract string initial value', () => { + const source = ` +PROGRAM Test +VAR + s : STRING := 'Hello'; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const sVar = result.variables.find(v => v.name === 's'); + expect(sVar?.initialValue).toContain('Hello'); + }); + }); + + describe('comments', () => { + it('should extract inline comments', () => { + const source = ` +PROGRAM Test +VAR + x : INT; (* Counter for main loop *) +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const xVar = result.variables.find(v => v.name === 'x'); + if (xVar?.comment) { + expect(xVar.comment).toContain('Counter'); + } + }); + + it('should extract line comments', () => { + const source = ` +PROGRAM Test +VAR + x : INT; // Counter for main loop +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + // Comment extraction depends on parser implementation + expect(result.variables.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('arrays', () => { + it('should detect array variables', () => { + const source = ` +PROGRAM Test +VAR + arr : ARRAY[0..10] OF INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const arrVar = result.variables.find(v => v.name === 'arr'); + expect(arrVar?.isArray).toBe(true); + }); + + it('should extract array bounds', () => { + const source = ` +PROGRAM Test +VAR + arr : ARRAY[1..100] OF REAL; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const arrVar = result.variables.find(v => v.name === 'arr'); + if (arrVar?.arrayBounds) { + expect(arrVar.arrayBounds.dimensions[0].lower).toBe(1); + expect(arrVar.arrayBounds.dimensions[0].upper).toBe(100); + } + }); + + it('should handle multi-dimensional arrays', () => { + const source = ` +PROGRAM Test +VAR + matrix : ARRAY[0..9, 0..9] OF INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const matrixVar = result.variables.find(v => v.name === 'matrix'); + expect(matrixVar?.isArray).toBe(true); + }); + }); + + describe('I/O addresses', () => { + it('should extract input addresses', () => { + const source = ` +PROGRAM Test +VAR + bInput AT %IX0.0 : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const inputVar = result.variables.find(v => v.name === 'bInput'); + expect(inputVar?.ioAddress).toContain('IX0.0'); + + expect(result.ioMappings.length).toBeGreaterThanOrEqual(1); + expect(result.ioMappings[0].isInput).toBe(true); + }); + + it('should extract output addresses', () => { + const source = ` +PROGRAM Test +VAR + bOutput AT %QX1.0 : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const outputVar = result.variables.find(v => v.name === 'bOutput'); + expect(outputVar?.ioAddress).toContain('QX1.0'); + + const outputMapping = result.ioMappings.find(m => m.variableName === 'bOutput'); + expect(outputMapping?.isInput).toBe(false); + }); + + it('should extract word addresses', () => { + const source = ` +PROGRAM Test +VAR + nInput AT %IW10 : INT; + nOutput AT %QW20 : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.ioMappings.length).toBeGreaterThanOrEqual(2); + }); + + it('should extract memory addresses', () => { + const source = ` +PROGRAM Test +VAR + nData AT %MD100 : DINT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const dataVar = result.variables.find(v => v.name === 'nData'); + expect(dataVar?.ioAddress).toContain('MD100'); + }); + }); + + describe('safety-critical detection', () => { + it('should flag safety-critical variables', () => { + const source = ` +PROGRAM Test +VAR + bIL_OK : BOOL; (* Safety interlock *) + bES_OK : BOOL; (* E-Stop status *) + nCounter : INT; (* Normal counter *) +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + const ilVar = result.variables.find(v => v.name === 'bIL_OK'); + const esVar = result.variables.find(v => v.name === 'bES_OK'); + const counterVar = result.variables.find(v => v.name === 'nCounter'); + + expect(ilVar?.isSafetyCritical).toBe(true); + expect(esVar?.isSafetyCritical).toBe(true); + expect(counterVar?.isSafetyCritical).toBe(false); + }); + }); + + describe('summary statistics', () => { + it('should count total variables', () => { + const source = ` +PROGRAM Test +VAR + a : INT; + b : INT; + c : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.summary.total).toBeGreaterThanOrEqual(3); + }); + + it('should count by section', () => { + const source = ` +FUNCTION_BLOCK FB_Test +VAR_INPUT + a : INT; + b : INT; +END_VAR +VAR_OUTPUT + c : INT; +END_VAR +VAR + d : INT; +END_VAR +END_FUNCTION_BLOCK +`; + const result = extractVariables(source, 'test.st'); + + expect(result.summary.bySection.VAR_INPUT).toBeGreaterThanOrEqual(2); + expect(result.summary.bySection.VAR_OUTPUT).toBeGreaterThanOrEqual(1); + }); + + it('should count variables with comments', () => { + const source = ` +PROGRAM Test +VAR + x : INT; (* Has comment *) + y : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.summary.withComments).toBeGreaterThanOrEqual(0); + }); + + it('should count variables with I/O addresses', () => { + const source = ` +PROGRAM Test +VAR + bInput AT %IX0.0 : BOOL; + bOutput AT %QX1.0 : BOOL; + x : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.summary.withIOAddress).toBeGreaterThanOrEqual(2); + }); + + it('should count safety-critical variables', () => { + const source = ` +PROGRAM Test +VAR + bIL_OK : BOOL; + bES_OK : BOOL; + x : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.summary.safetyCritical).toBeGreaterThanOrEqual(2); + }); + }); + + describe('location tracking', () => { + it('should track variable locations', () => { + const source = ` +PROGRAM Test +VAR + x : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.variables[0].location).toBeDefined(); + expect(result.variables[0].location.file).toBe('test.st'); + expect(result.variables[0].location.line).toBeGreaterThan(0); + }); + + it('should track I/O mapping locations', () => { + const source = ` +PROGRAM Test +VAR + bInput AT %IX0.0 : BOOL; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.ioMappings[0].location).toBeDefined(); + expect(result.ioMappings[0].location.file).toBe('test.st'); + }); + }); + + describe('edge cases', () => { + it('should handle empty source', () => { + const result = extractVariables('', 'test.st'); + + expect(result.variables).toHaveLength(0); + }); + + it('should handle source without variables', () => { + const source = ` +PROGRAM Test +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + expect(result.variables).toHaveLength(0); + }); + + it('should handle malformed declarations gracefully', () => { + const source = ` +PROGRAM Test +VAR + x INT; (* Missing colon *) +END_VAR +END_PROGRAM +`; + expect(() => extractVariables(source, 'test.st')).not.toThrow(); + }); + + it('should handle multiple variables on one line', () => { + const source = ` +PROGRAM Test +VAR + a, b, c : INT; +END_VAR +END_PROGRAM +`; + const result = extractVariables(source, 'test.st'); + + // May extract as 1 or 3 depending on parser + expect(result.variables.length).toBeGreaterThanOrEqual(1); + }); + }); +}); diff --git a/packages/core/src/iec61131/analyzer.ts b/packages/core/src/iec61131/analyzer.ts new file mode 100644 index 00000000..e92d5d59 --- /dev/null +++ b/packages/core/src/iec61131/analyzer.ts @@ -0,0 +1,808 @@ +/** + * IEC 61131-3 Analyzer + * + * Main entry point for Code Factory analysis. + * Orchestrates parsing, extraction, and analysis. + */ + +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join, extname, relative, basename } from 'path'; + +import { + extractDocstrings, + extractStateMachines, + extractSafetyInterlocks, + extractTribalKnowledge, + extractVariables, +} from './extractors/index.js'; +import type { + DocstringExtractionResult, + StateMachineExtractionResult, + TribalKnowledgeExtractionResult, + VariableExtractionResult, +} from './extractors/index.js'; +import { STParser } from './parser/index.js'; +import type { ParseResult } from './parser/index.js'; +import { MigrationScorer } from './analyzers/migration-scorer.js'; +import { AIContextGenerator } from './analyzers/ai-context.js'; +import type { + STProjectStatus, + SafetyAnalysisResult, + MigrationReadinessReport, + AIContextPackage, + TargetLanguage, + STPOU, + STCallGraph, + STCallGraphNode, + STCallGraphEdge, + CallType, + VendorId, +} from './types.js'; + +// ============================================================================ +// FILE EXTENSIONS +// ============================================================================ + +const ST_FILE_EXTENSIONS = ['.st', '.stx', '.scl', '.pou', '.exp']; + +// ============================================================================ +// ANALYZER CLASS +// ============================================================================ + +export interface AnalyzerOptions { + storagePath?: string; +} + +export class IEC61131Analyzer { + private projectPath: string = ''; + private files: string[] = []; + private fileContents: Map = new Map(); + private parseResults: Map = new Map(); + private parsedPOUs: STPOU[] = []; + private callGraph: STCallGraph | null = null; + + constructor(_options?: AnalyzerOptions) { + // Options for future storage integration + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + /** + * Initialize analyzer with project path + */ + async initialize(projectPath: string): Promise { + this.projectPath = projectPath; + this.files = this.discoverFiles(projectPath); + this.fileContents.clear(); + this.parseResults.clear(); + } + + /** + * Discover all ST files in directory + */ + private discoverFiles(rootPath: string): string[] { + const files: string[] = []; + + const walk = (dir: string): void => { + try { + const entries = readdirSync(dir); + for (const entry of entries) { + const fullPath = join(dir, entry); + try { + const stat = statSync(fullPath); + if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') { + walk(fullPath); + } else if (stat.isFile() && ST_FILE_EXTENSIONS.includes(extname(entry).toLowerCase())) { + files.push(fullPath); + } + } catch { + // Skip inaccessible files + } + } + } catch { + // Skip inaccessible directories + } + }; + + if (existsSync(rootPath)) { + const stat = statSync(rootPath); + if (stat.isFile()) { + files.push(rootPath); + } else { + walk(rootPath); + } + } + + return files; + } + + /** + * Read file content (cached) + */ + private readFile(filePath: string): string { + if (this.fileContents.has(filePath)) { + return this.fileContents.get(filePath)!; + } + + try { + const content = readFileSync(filePath, 'utf-8'); + this.fileContents.set(filePath, content); + return content; + } catch { + return ''; + } + } + + /** + * Get files for analysis + */ + private getFiles(path?: string): Array<{ path: string; content: string }> { + const targetPath = path ?? this.projectPath; + const files = path ? this.discoverFiles(targetPath) : this.files; + + return files.map(f => ({ + path: relative(this.projectPath, f), + content: this.readFile(f), + })); + } + + // ============================================================================ + // STATUS + // ============================================================================ + + /** + * Get project status overview + */ + async status(path?: string): Promise { + const targetPath = path ?? this.projectPath; + const files = this.getFiles(targetPath); + + let totalLines = 0; + let totalPOUs = 0; + let totalStateMachines = 0; + let totalInterlocks = 0; + let totalTribalKnowledge = 0; + let totalDocstrings = 0; + + const byExtension: Record = {}; + + for (const file of files) { + const ext = extname(file.path).toLowerCase(); + byExtension[ext] = (byExtension[ext] || 0) + 1; + totalLines += file.content.split('\n').length; + + // Quick counts + const docResult = extractDocstrings(file.content, file.path, { minLength: 20 }); + totalDocstrings += docResult.docstrings.length; + + const smResult = extractStateMachines(file.content, file.path); + totalStateMachines += smResult.stateMachines.length; + + const safetyResult = extractSafetyInterlocks(file.content, file.path); + totalInterlocks += safetyResult.interlocks.length; + + const tribalResult = extractTribalKnowledge(file.content, file.path); + totalTribalKnowledge += tribalResult.items.length; + + // Count POUs + const pouPattern = /\b(PROGRAM|FUNCTION_BLOCK|FUNCTION)\s+\w+/gi; + const pouMatches = file.content.match(pouPattern); + totalPOUs += pouMatches?.length ?? 0; + } + + return { + project: { + path: targetPath, + name: basename(targetPath), + vendor: null, + plcType: null, + }, + files: { + total: files.length, + byExtension, + totalLines, + }, + analysis: { + lastRun: new Date().toISOString(), + pous: totalPOUs, + stateMachines: totalStateMachines, + safetyInterlocks: totalInterlocks, + tribalKnowledge: totalTribalKnowledge, + docstrings: totalDocstrings, + }, + health: { + score: this.calculateHealthScore(totalDocstrings, totalPOUs, totalInterlocks), + issues: [], + }, + }; + } + + private calculateHealthScore(docstrings: number, pous: number, _interlocks: number): number { + if (pous === 0) return 0; + + // Simple health score based on documentation coverage + const docRatio = Math.min(docstrings / pous, 1); + return Math.round(docRatio * 100); + } + + // ============================================================================ + // EXTRACTION METHODS + // ============================================================================ + + /** + * Extract docstrings (PhD's primary request) + */ + async docstrings(path?: string, options?: { includeRaw?: boolean; limit?: number }): Promise { + const files = this.getFiles(path); + const allDocstrings: DocstringExtractionResult['docstrings'] = []; + + for (const file of files) { + const result = extractDocstrings(file.content, file.path, { + includeRaw: options?.includeRaw ?? false, + }); + allDocstrings.push(...result.docstrings); + } + + // Apply limit + const limited = options?.limit ? allDocstrings.slice(0, options.limit) : allDocstrings; + + // Recalculate summary + const byBlock: Record = {}; + let withParams = 0; + let withHistory = 0; + let withWarnings = 0; + let totalQuality = 0; + + for (const doc of limited) { + const key = doc.associatedBlock || 'standalone'; + byBlock[key] = (byBlock[key] || 0) + 1; + if (doc.params.length > 0) withParams++; + if (doc.history.length > 0) withHistory++; + if (doc.warnings.length > 0) withWarnings++; + totalQuality += doc.quality.score; + } + + return { + docstrings: limited, + summary: { + total: allDocstrings.length, + byBlock, + withParams, + withHistory, + withWarnings, + averageQuality: limited.length > 0 ? totalQuality / limited.length : 0, + }, + }; + } + + /** + * Extract state machines + */ + async stateMachines(path?: string, options?: { limit?: number }): Promise { + const files = this.getFiles(path); + const allMachines: StateMachineExtractionResult['stateMachines'] = []; + + for (const file of files) { + const pouName = this.inferPOUName(file.content, file.path); + const result = extractStateMachines(file.content, file.path, pouName); + allMachines.push(...result.stateMachines); + } + + const limited = options?.limit ? allMachines.slice(0, options.limit) : allMachines; + + return { + stateMachines: limited, + summary: { + total: allMachines.length, + totalStates: allMachines.reduce((sum, sm) => sum + sm.states.length, 0), + byVariable: allMachines.reduce((acc, sm) => { + acc[sm.stateVariable] = (acc[sm.stateVariable] || 0) + 1; + return acc; + }, {} as Record), + withDeadlocks: allMachines.filter(sm => sm.verification.hasDeadlocks).length, + withGaps: allMachines.filter(sm => sm.verification.hasGaps).length, + }, + }; + } + + /** + * Analyze safety interlocks (CRITICAL) + */ + async safety(path?: string): Promise { + const files = this.getFiles(path); + const allInterlocks: SafetyAnalysisResult['interlocks'] = []; + const allBypasses: SafetyAnalysisResult['bypasses'] = []; + const allWarnings: SafetyAnalysisResult['criticalWarnings'] = []; + + for (const file of files) { + const result = extractSafetyInterlocks(file.content, file.path); + allInterlocks.push(...result.interlocks); + allBypasses.push(...result.bypasses); + allWarnings.push(...result.criticalWarnings); + } + + return { + interlocks: allInterlocks, + bypasses: allBypasses, + criticalWarnings: allWarnings, + summary: { + totalInterlocks: allInterlocks.length, + byType: { + 'interlock': allInterlocks.filter(i => i.type === 'interlock').length, + 'permissive': allInterlocks.filter(i => i.type === 'permissive').length, + 'estop': allInterlocks.filter(i => i.type === 'estop').length, + 'safety-relay': allInterlocks.filter(i => i.type === 'safety-relay').length, + 'safety-device': allInterlocks.filter(i => i.type === 'safety-device').length, + 'bypass': allBypasses.length, + }, + bypassCount: allBypasses.length, + criticalWarningCount: allWarnings.filter(w => w.severity === 'critical').length, + }, + }; + } + + /** + * Extract tribal knowledge + */ + async tribalKnowledge(path?: string, options?: { limit?: number }): Promise { + const files = this.getFiles(path); + const allItems: TribalKnowledgeExtractionResult['items'] = []; + + for (const file of files) { + const result = extractTribalKnowledge(file.content, file.path); + allItems.push(...result.items); + } + + const limited = options?.limit ? allItems.slice(0, options.limit) : allItems; + + return { + items: limited, + summary: { + total: allItems.length, + byType: allItems.reduce((acc, item) => { + acc[item.type] = (acc[item.type] || 0) + 1; + return acc; + }, {} as Record), + byImportance: { + critical: allItems.filter(i => i.importance === 'critical').length, + high: allItems.filter(i => i.importance === 'high').length, + medium: allItems.filter(i => i.importance === 'medium').length, + low: allItems.filter(i => i.importance === 'low').length, + }, + criticalCount: allItems.filter(i => i.importance === 'critical').length, + }, + }; + } + + /** + * Extract variables + */ + async variables(path?: string, options?: { limit?: number }): Promise { + const files = this.getFiles(path); + const allVariables: VariableExtractionResult['variables'] = []; + const allIOMappings: VariableExtractionResult['ioMappings'] = []; + + for (const file of files) { + const result = extractVariables(file.content, file.path); + allVariables.push(...result.variables); + allIOMappings.push(...result.ioMappings); + } + + const limited = options?.limit ? allVariables.slice(0, options.limit) : allVariables; + + return { + variables: limited, + ioMappings: allIOMappings, + summary: { + total: allVariables.length, + bySection: allVariables.reduce((acc, v) => { + acc[v.section] = (acc[v.section] || 0) + 1; + return acc; + }, {} as Record), + withComments: allVariables.filter(v => v.comment).length, + withIOAddress: allVariables.filter(v => v.ioAddress).length + allIOMappings.length, + safetyCritical: allVariables.filter(v => v.isSafetyCritical).length, + }, + }; + } + + /** + * List all POUs (blocks) + */ + async blocks(path?: string, options?: { limit?: number }): Promise<{ + blocks: Array<{ file: string; type: string; name: string; line: number }>; + summary: { total: number; byType: Record }; + }> { + const files = this.getFiles(path); + const blocks: Array<{ file: string; type: string; name: string; line: number }> = []; + + const blockPattern = /\b(PROGRAM|FUNCTION_BLOCK|FUNCTION)\s+(\w+)/gi; + + for (const file of files) { + let match: RegExpExecArray | null; + const regex = new RegExp(blockPattern.source, blockPattern.flags); + + while ((match = regex.exec(file.content)) !== null) { + const line = file.content.slice(0, match.index).split('\n').length; + blocks.push({ + file: file.path, + type: match[1]!.toUpperCase(), + name: match[2]!, + line, + }); + } + } + + const limited = options?.limit ? blocks.slice(0, options.limit) : blocks; + + const byType = blocks.reduce((acc, b) => { + acc[b.type] = (acc[b.type] || 0) + 1; + return acc; + }, {} as Record); + + return { + blocks: limited, + summary: { + total: blocks.length, + byType, + }, + }; + } + + // ============================================================================ + // FULL ANALYSIS + // ============================================================================ + + /** + * Run full analysis pipeline + */ + async fullAnalysis(path?: string): Promise<{ + status: STProjectStatus; + docstrings: DocstringExtractionResult; + stateMachines: StateMachineExtractionResult; + safety: SafetyAnalysisResult; + tribalKnowledge: TribalKnowledgeExtractionResult; + variables: VariableExtractionResult; + }> { + const [status, docstrings, stateMachines, safety, tribalKnowledge, variables] = await Promise.all([ + this.status(path), + this.docstrings(path), + this.stateMachines(path), + this.safety(path), + this.tribalKnowledge(path), + this.variables(path), + ]); + + return { + status, + docstrings, + stateMachines, + safety, + tribalKnowledge, + variables, + }; + } + + // ============================================================================ + // MIGRATION SCORING + // ============================================================================ + + /** + * Calculate migration readiness scores for all POUs + */ + async migrationReadiness(path?: string): Promise { + // Get all required data + const [docstrings, stateMachines, safety] = await Promise.all([ + this.docstrings(path), + this.stateMachines(path), + this.safety(path), + ]); + + // Parse POUs + const pous = await this.parsePOUs(path); + + // Build call graph for dependency analysis + const callGraphMap = this.buildCallGraphMap(pous); + + // Calculate readiness + const scorer = new MigrationScorer(); + return scorer.calculateReadiness(pous, docstrings, stateMachines, safety, callGraphMap); + } + + /** + * Parse all POUs from files + */ + private async parsePOUs(path?: string): Promise { + if (this.parsedPOUs.length > 0 && !path) { + return this.parsedPOUs; + } + + const files = this.getFiles(path); + const pous: STPOU[] = []; + const parser = new STParser(); + + for (const file of files) { + try { + const result = parser.parse(file.content, file.path); + pous.push(...result.pous); + } catch { + // Skip files that fail to parse + } + } + + if (!path) { + this.parsedPOUs = pous; + } + + return pous; + } + + /** + * Build call graph map for dependency analysis + */ + private buildCallGraphMap(pous: STPOU[]): Map { + const callGraph = new Map(); + + // Build a map of POU names to IDs + const pouNameToId = new Map(); + for (const pou of pous) { + pouNameToId.set(pou.name.toLowerCase(), pou.id); + } + + // For each POU, find what it calls + for (const pou of pous) { + const dependencies: string[] = []; + + // Check for FB instances in variables + for (const v of pou.variables) { + const fbType = v.dataType.split('[')[0]?.trim().toLowerCase(); + if (fbType && pouNameToId.has(fbType)) { + const depId = pouNameToId.get(fbType)!; + if (!dependencies.includes(depId)) { + dependencies.push(depId); + } + } + } + + callGraph.set(pou.id, dependencies); + } + + return callGraph; + } + + // ============================================================================ + // AI CONTEXT GENERATION + // ============================================================================ + + /** + * Generate AI context package for migration assistance + */ + async generateAIContext( + targetLanguage: TargetLanguage, + path?: string, + options?: { maxTokens?: number } + ): Promise { + // Get all required data + const [status, docstrings, stateMachines, safety, tribalKnowledge] = await Promise.all([ + this.status(path), + this.docstrings(path), + this.stateMachines(path), + this.safety(path), + this.tribalKnowledge(path), + ]); + + // Parse POUs + const pous = await this.parsePOUs(path); + + // Generate context - only pass maxTokens if defined + const generatorConfig = options?.maxTokens !== undefined + ? { maxTokens: options.maxTokens } + : undefined; + const generator = new AIContextGenerator(generatorConfig); + + // Build project info, only including defined values + const projectInfo: { name: string; vendor?: VendorId; plcType?: string } = { + name: status.project.name, + }; + if (status.project.vendor) { + projectInfo.vendor = status.project.vendor; + } + if (status.project.plcType) { + projectInfo.plcType = status.project.plcType; + } + + return generator.generateContext( + pous, + docstrings, + stateMachines, + safety, + tribalKnowledge, + targetLanguage, + projectInfo + ); + } + + // ============================================================================ + // CALL GRAPH + // ============================================================================ + + /** + * Build and return the call graph for the project + */ + async buildCallGraph(path?: string): Promise { + if (this.callGraph && !path) { + return this.callGraph; + } + + const files = this.getFiles(path); + const nodes = new Map(); + const edges: STCallGraphEdge[] = []; + + // First pass: collect all POUs as nodes + const pouPattern = /\b(PROGRAM|FUNCTION_BLOCK|FUNCTION)\s+(\w+)/gi; + const fbInstances = new Map(); // instanceName -> typeName + + for (const file of files) { + let match: RegExpExecArray | null; + const regex = new RegExp(pouPattern.source, pouPattern.flags); + + while ((match = regex.exec(file.content)) !== null) { + const type = match[1]!.toUpperCase() as 'PROGRAM' | 'FUNCTION_BLOCK' | 'FUNCTION'; + const name = match[2]!; + const line = file.content.slice(0, match.index).split('\n').length; + + // Extract variables for this POU + const varResult = extractVariables(file.content, file.path); + const inputs = varResult.variables.filter(v => v.section === 'VAR_INPUT'); + const outputs = varResult.variables.filter(v => v.section === 'VAR_OUTPUT'); + + nodes.set(name.toLowerCase(), { + id: `${file.path}:${name}`, + name, + type, + file: file.path, + line, + inputs: inputs.map(v => ({ + id: v.id, + name: v.name, + dataType: v.dataType, + section: v.section, + initialValue: v.initialValue, + comment: v.comment, + isArray: v.isArray, + arrayBounds: v.arrayBounds, + isSafetyCritical: v.isSafetyCritical, + ioAddress: v.ioAddress, + location: v.location, + pouId: null, + })), + outputs: outputs.map(v => ({ + id: v.id, + name: v.name, + dataType: v.dataType, + section: v.section, + initialValue: v.initialValue, + comment: v.comment, + isArray: v.isArray, + arrayBounds: v.arrayBounds, + isSafetyCritical: v.isSafetyCritical, + ioAddress: v.ioAddress, + location: v.location, + pouId: null, + })), + }); + } + + // Collect FB instances + const instancePattern = /(\w+)\s*:\s*(\w+)\s*;/g; + while ((match = instancePattern.exec(file.content)) !== null) { + fbInstances.set(match[1]!.toLowerCase(), match[2]!.toLowerCase()); + } + } + + // Second pass: find calls + let edgeId = 0; + for (const file of files) { + const lines = file.content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const lineNum = i + 1; + + // Skip comments + if (line.trim().startsWith('//') || line.trim().startsWith('(*')) continue; + + // Find function calls: result := FunctionName(...) + const callPattern = /(\w+)\s*\(/g; + let match: RegExpExecArray | null; + + while ((match = callPattern.exec(line)) !== null) { + const calleeName = match[1]!.toLowerCase(); + + // Check if it's an FB instance call + if (fbInstances.has(calleeName)) { + const fbType = fbInstances.get(calleeName)!; + if (nodes.has(fbType)) { + edges.push({ + id: `edge-${edgeId++}`, + callerId: file.path, + calleeId: nodes.get(fbType)?.id ?? null, + calleeName: fbType, + callType: 'instantiation' as CallType, + location: { file: file.path, line: lineNum, column: match.index }, + arguments: [], + }); + } + } else if (nodes.has(calleeName)) { + // Direct function call + edges.push({ + id: `edge-${edgeId++}`, + callerId: file.path, + calleeId: nodes.get(calleeName)?.id ?? null, + calleeName, + callType: 'function_call' as CallType, + location: { file: file.path, line: lineNum, column: match.index }, + arguments: [], + }); + } + } + } + } + + const result: STCallGraph = { nodes, edges }; + + if (!path) { + this.callGraph = result; + } + + return result; + } + + /** + * Get callers of a specific function/FB + */ + async getCallers(functionName: string, path?: string): Promise<{ + function: string; + callers: Array<{ file: string; line: number; callType: CallType }>; + }> { + const callGraph = await this.buildCallGraph(path); + const targetName = functionName.toLowerCase(); + + const callers: Array<{ file: string; line: number; callType: CallType }> = []; + + for (const edge of callGraph.edges) { + if (edge.calleeName.toLowerCase() === targetName) { + callers.push({ + file: edge.location.file, + line: edge.location.line, + callType: edge.callType, + }); + } + } + + return { function: functionName, callers }; + } + + // ============================================================================ + // HELPERS + // ============================================================================ + + private inferPOUName(content: string, filePath: string): string { + // Try to find first POU name + const match = content.match(/\b(?:PROGRAM|FUNCTION_BLOCK|FUNCTION)\s+(\w+)/i); + if (match) return match[1]!; + + // Fall back to filename + return basename(filePath, extname(filePath)); + } +} + +// ============================================================================ +// CONVENIENCE EXPORT +// ============================================================================ + +export function createAnalyzer(options?: AnalyzerOptions): IEC61131Analyzer { + return new IEC61131Analyzer(options); +} diff --git a/packages/core/src/iec61131/analyzers/ai-context.ts b/packages/core/src/iec61131/analyzers/ai-context.ts new file mode 100644 index 00000000..7a1d19c9 --- /dev/null +++ b/packages/core/src/iec61131/analyzers/ai-context.ts @@ -0,0 +1,810 @@ +/** + * AI Context Generator + * + * Generates structured context optimized for LLM consumption. + * Following architecture doc Part 2.3.2: AI Context Generator + * + * This is THE KEY OUTPUT for AI migration. + * It provides everything an LLM needs to understand and translate the code. + */ + +import type { + STPOU, + STVariable, + AIContextPackage, + AIProjectContext, + AIConventionContext, + AITypeContext, + AISafetyContext, + AIPOUContext, + AIVariableDescription, + AITranslationHint, + AITranslationGuide, + AIPatternMapping, + AIVerificationRequirement, + TargetLanguage, + SafetyAnalysisResult, + VendorId, +} from '../types.js'; +import type { DocstringExtractionResult } from '../extractors/index.js'; +import type { StateMachineExtractionResult } from '../extractors/index.js'; +import type { TribalKnowledgeExtractionResult } from '../extractors/index.js'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +export interface AIContextGeneratorConfig { + maxTokens?: number; + includeRaw?: boolean; +} + +// ============================================================================ +// TYPE MAPPINGS +// ============================================================================ + +const PLC_TO_PYTHON: Record = { + 'BOOL': 'bool', + 'BYTE': 'int', + 'WORD': 'int', + 'DWORD': 'int', + 'LWORD': 'int', + 'SINT': 'int', + 'INT': 'int', + 'DINT': 'int', + 'LINT': 'int', + 'USINT': 'int', + 'UINT': 'int', + 'UDINT': 'int', + 'ULINT': 'int', + 'REAL': 'float', + 'LREAL': 'float', + 'STRING': 'str', + 'WSTRING': 'str', + 'TIME': 'timedelta', + 'DATE': 'date', + 'DATE_AND_TIME': 'datetime', + 'TOD': 'time', + 'ARRAY': 'list', +}; + +const PLC_TO_RUST: Record = { + 'BOOL': 'bool', + 'BYTE': 'u8', + 'WORD': 'u16', + 'DWORD': 'u32', + 'LWORD': 'u64', + 'SINT': 'i8', + 'INT': 'i16', + 'DINT': 'i32', + 'LINT': 'i64', + 'USINT': 'u8', + 'UINT': 'u16', + 'UDINT': 'u32', + 'ULINT': 'u64', + 'REAL': 'f32', + 'LREAL': 'f64', + 'STRING': 'String', + 'WSTRING': 'String', + 'TIME': 'Duration', + 'DATE': 'NaiveDate', + 'DATE_AND_TIME': 'NaiveDateTime', + 'TOD': 'NaiveTime', + 'ARRAY': 'Vec', +}; + +const PLC_TO_TYPESCRIPT: Record = { + 'BOOL': 'boolean', + 'BYTE': 'number', + 'WORD': 'number', + 'DWORD': 'number', + 'LWORD': 'bigint', + 'SINT': 'number', + 'INT': 'number', + 'DINT': 'number', + 'LINT': 'bigint', + 'USINT': 'number', + 'UINT': 'number', + 'UDINT': 'number', + 'ULINT': 'bigint', + 'REAL': 'number', + 'LREAL': 'number', + 'STRING': 'string', + 'WSTRING': 'string', + 'TIME': 'number', + 'DATE': 'Date', + 'DATE_AND_TIME': 'Date', + 'TOD': 'Date', + 'ARRAY': 'Array', +}; + +const TYPE_MAPPINGS: Record> = { + python: PLC_TO_PYTHON, + rust: PLC_TO_RUST, + typescript: PLC_TO_TYPESCRIPT, + csharp: { + 'BOOL': 'bool', + 'BYTE': 'byte', + 'WORD': 'ushort', + 'DWORD': 'uint', + 'LWORD': 'ulong', + 'SINT': 'sbyte', + 'INT': 'short', + 'DINT': 'int', + 'LINT': 'long', + 'REAL': 'float', + 'LREAL': 'double', + 'STRING': 'string', + 'TIME': 'TimeSpan', + 'DATE': 'DateTime', + 'ARRAY': 'List', + }, + cpp: { + 'BOOL': 'bool', + 'BYTE': 'uint8_t', + 'WORD': 'uint16_t', + 'DWORD': 'uint32_t', + 'LWORD': 'uint64_t', + 'SINT': 'int8_t', + 'INT': 'int16_t', + 'DINT': 'int32_t', + 'LINT': 'int64_t', + 'REAL': 'float', + 'LREAL': 'double', + 'STRING': 'std::string', + 'TIME': 'std::chrono::milliseconds', + 'ARRAY': 'std::vector', + }, + go: { + 'BOOL': 'bool', + 'BYTE': 'uint8', + 'WORD': 'uint16', + 'DWORD': 'uint32', + 'LWORD': 'uint64', + 'SINT': 'int8', + 'INT': 'int16', + 'DINT': 'int32', + 'LINT': 'int64', + 'REAL': 'float32', + 'LREAL': 'float64', + 'STRING': 'string', + 'TIME': 'time.Duration', + 'ARRAY': '[]', + }, + java: { + 'BOOL': 'boolean', + 'BYTE': 'byte', + 'WORD': 'short', + 'DWORD': 'int', + 'LWORD': 'long', + 'SINT': 'byte', + 'INT': 'short', + 'DINT': 'int', + 'LINT': 'long', + 'REAL': 'float', + 'LREAL': 'double', + 'STRING': 'String', + 'TIME': 'Duration', + 'ARRAY': 'List', + }, +}; + +// ============================================================================ +// AI CONTEXT GENERATOR CLASS +// ============================================================================ + +export class AIContextGenerator { + constructor(_config?: AIContextGeneratorConfig) { + // Config reserved for future use + } + + /** + * Generate complete AI context package + */ + generateContext( + pous: STPOU[], + docstrings: DocstringExtractionResult, + stateMachines: StateMachineExtractionResult, + safety: SafetyAnalysisResult, + tribalKnowledge: TribalKnowledgeExtractionResult, + targetLanguage: TargetLanguage, + projectInfo?: { name: string; vendor?: VendorId; plcType?: string } + ): AIContextPackage { + return { + version: '1.0.0', + generatedAt: new Date().toISOString(), + targetLanguage, + project: this.generateProjectContext(pous, projectInfo), + conventions: this.extractConventions(pous), + types: this.generateTypeContext(pous, targetLanguage), + safety: this.generateSafetyContext(safety), + pous: pous.map(pou => this.generatePOUContext( + pou, docstrings, stateMachines, safety, targetLanguage + )), + tribalKnowledge: tribalKnowledge.items, + translationGuide: this.generateTranslationGuide(targetLanguage), + verificationRequirements: this.generateVerificationRequirements(pous, safety, stateMachines), + }; + } + + // ========================================================================== + // PROJECT CONTEXT + // ========================================================================== + + private generateProjectContext( + pous: STPOU[], + projectInfo?: { name: string; vendor?: VendorId; plcType?: string } + ): AIProjectContext { + const totalLines = pous.reduce((sum, pou) => + sum + (pou.bodyEndLine - pou.bodyStartLine), 0 + ); + + return { + name: projectInfo?.name ?? 'Unknown Project', + vendor: projectInfo?.vendor ?? 'generic-st', + plcType: projectInfo?.plcType ?? null, + totalPOUs: pous.length, + totalLines, + languages: ['ST'], + }; + } + + // ========================================================================== + // CONVENTIONS + // ========================================================================== + + private extractConventions(pous: STPOU[]): AIConventionContext { + const namingPatterns: Record = {}; + const variablePrefixes: Record = {}; + const stateEncodings: string[] = []; + const commentStyles: string[] = []; + + // Analyze variable naming patterns + for (const pou of pous) { + for (const v of pou.variables) { + // Detect prefixes + const prefixMatch = v.name.match(/^([a-z]+)_/i); + if (prefixMatch) { + const prefix = prefixMatch[1]!.toLowerCase(); + variablePrefixes[prefix] = this.inferPrefixMeaning(prefix); + } + + // Detect Hungarian notation + const hungarianMatch = v.name.match(/^([a-z]{1,3})[A-Z]/); + if (hungarianMatch) { + const prefix = hungarianMatch[1]!; + variablePrefixes[prefix] = this.inferHungarianMeaning(prefix); + } + } + } + + // Common patterns + namingPatterns['function_block'] = 'FB_'; + namingPatterns['program'] = 'PRG_ or _Main'; + namingPatterns['function'] = 'FC_ or '; + + // State encodings + stateEncodings.push('Integer values (0, 10, 20, ...)'); + stateEncodings.push('Enum values'); + + // Comment styles + commentStyles.push('(* Block comment *)'); + commentStyles.push('// Line comment'); + + return { + namingPatterns, + variablePrefixes, + stateEncodings, + commentStyles, + }; + } + + private inferPrefixMeaning(prefix: string): string { + const meanings: Record = { + 'b': 'Boolean', + 'i': 'Integer', + 'r': 'Real/Float', + 's': 'String', + 'n': 'Number', + 'w': 'Word', + 'd': 'Double word', + 't': 'Time/Timer', + 'dt': 'Date/Time', + 'arr': 'Array', + 'st': 'Structure', + 'fb': 'Function Block instance', + 'il': 'Interlock', + 'pb': 'Pushbutton', + 'ls': 'Limit switch', + 'ps': 'Pressure switch', + 'ts': 'Temperature switch', + 'mv': 'Motor valve', + 'sv': 'Solenoid valve', + }; + return meanings[prefix] ?? 'Unknown'; + } + + private inferHungarianMeaning(prefix: string): string { + const meanings: Record = { + 'b': 'Boolean', + 'n': 'Integer', + 'r': 'Real', + 's': 'String', + 'w': 'Word', + 'dw': 'Double word', + 'by': 'Byte', + 'a': 'Array', + 'p': 'Pointer', + 'fb': 'Function Block', + }; + return meanings[prefix] ?? 'Unknown'; + } + + // ========================================================================== + // TYPE CONTEXT + // ========================================================================== + + private generateTypeContext(pous: STPOU[], targetLanguage: TargetLanguage): AITypeContext { + const typeMapping = TYPE_MAPPINGS[targetLanguage] ?? TYPE_MAPPINGS.python; + const customTypes: string[] = []; + const structDefinitions: Record = {}; + + // Collect custom types from variables + for (const pou of pous) { + for (const v of pou.variables) { + const baseType = v.dataType.split('[')[0]!.trim(); + if (!typeMapping[baseType] && !customTypes.includes(baseType)) { + customTypes.push(baseType); + } + } + } + + return { + plcToTarget: typeMapping, + customTypes, + structDefinitions, + }; + } + + // ========================================================================== + // SAFETY CONTEXT + // ========================================================================== + + private generateSafetyContext(safety: SafetyAnalysisResult): AISafetyContext { + return { + interlocks: safety.interlocks, + criticalPaths: safety.interlocks + .filter(i => i.severity === 'critical') + .map(i => `${i.name} at ${i.location.file}:${i.location.line}`), + mustPreserve: [ + ...safety.interlocks.map(i => `Interlock: ${i.name}`), + ...safety.bypasses.map(b => `BYPASS (review): ${b.name}`), + ], + }; + } + + // ========================================================================== + // POU CONTEXT + // ========================================================================== + + private generatePOUContext( + pou: STPOU, + docstrings: DocstringExtractionResult, + stateMachines: StateMachineExtractionResult, + safety: SafetyAnalysisResult, + targetLanguage: TargetLanguage + ): AIPOUContext { + const pouDoc = docstrings.docstrings.find(d => + d.associatedBlock === pou.name + ); + + const pouStateMachines = stateMachines.stateMachines.filter(sm => + sm.file === pou.location.file + ); + + const pouInterlocks = safety.interlocks.filter(i => + i.location.file === pou.location.file + ); + + return { + pouId: pou.id, + pouName: pou.name, + pouType: pou.type, + purpose: pouDoc?.summary ?? pouDoc?.description ?? 'No documentation available', + interface: { + inputs: pou.variables + .filter(v => v.section === 'VAR_INPUT') + .map(v => this.describeVariable(v)), + outputs: pou.variables + .filter(v => v.section === 'VAR_OUTPUT') + .map(v => this.describeVariable(v)), + inOuts: pou.variables + .filter(v => v.section === 'VAR_IN_OUT') + .map(v => this.describeVariable(v)), + }, + behavior: { + summary: this.summarizeBehavior(pou, pouStateMachines), + stateMachines: pouStateMachines.map(sm => + `${sm.name}: ${sm.states.length} states, ${sm.transitions.length} transitions` + ), + algorithms: this.extractAlgorithms(pou), + }, + safety: { + isSafetyCritical: pouInterlocks.length > 0 || + pou.variables.some(v => v.isSafetyCritical), + interlocks: pouInterlocks.map(i => i.name), + constraints: this.extractSafetyConstraints(pou, safety), + }, + translationHints: this.generateTranslationHints(pou, targetLanguage), + suggestedTests: this.suggestTestCases(pou, pouStateMachines), + }; + } + + private describeVariable(v: STVariable): AIVariableDescription { + return { + name: v.name, + type: v.dataType, + description: v.comment ?? 'No description', + constraints: this.inferConstraints(v), + }; + } + + private inferConstraints(v: STVariable): string[] { + const constraints: string[] = []; + + if (v.isSafetyCritical) { + constraints.push('SAFETY CRITICAL - must preserve behavior exactly'); + } + + if (v.initialValue) { + constraints.push(`Default: ${v.initialValue}`); + } + + if (v.isArray && v.arrayBounds) { + constraints.push(`Array bounds: ${JSON.stringify(v.arrayBounds)}`); + } + + return constraints; + } + + private summarizeBehavior( + pou: STPOU, + stateMachines: StateMachineExtractionResult['stateMachines'] + ): string { + const parts: string[] = []; + + if (pou.documentation?.summary) { + parts.push(pou.documentation.summary); + } + + if (stateMachines.length > 0) { + parts.push(`Contains ${stateMachines.length} state machine(s)`); + } + + const inputs = pou.variables.filter(v => v.section === 'VAR_INPUT'); + const outputs = pou.variables.filter(v => v.section === 'VAR_OUTPUT'); + parts.push(`${inputs.length} inputs, ${outputs.length} outputs`); + + return parts.join('. '); + } + + private extractAlgorithms(pou: STPOU): string[] { + const algorithms: string[] = []; + + // Look for common patterns in variable names + const hasTimer = pou.variables.some(v => + v.dataType.includes('TON') || v.dataType.includes('TOF') || v.dataType.includes('TP') + ); + if (hasTimer) { + algorithms.push('Timer-based logic'); + } + + const hasCounter = pou.variables.some(v => + v.dataType.includes('CTU') || v.dataType.includes('CTD') || v.dataType.includes('CTUD') + ); + if (hasCounter) { + algorithms.push('Counter-based logic'); + } + + const hasPID = pou.variables.some(v => + v.name.toLowerCase().includes('pid') || v.dataType.toLowerCase().includes('pid') + ); + if (hasPID) { + algorithms.push('PID control loop'); + } + + return algorithms; + } + + private extractSafetyConstraints(pou: STPOU, safety: SafetyAnalysisResult): string[] { + const constraints: string[] = []; + + const pouInterlocks = safety.interlocks.filter(i => + i.location.file === pou.location.file + ); + + for (const interlock of pouInterlocks) { + constraints.push(`${interlock.type}: ${interlock.name} must be preserved`); + } + + const safetyVars = pou.variables.filter(v => v.isSafetyCritical); + for (const v of safetyVars) { + constraints.push(`Safety variable ${v.name} behavior must be preserved`); + } + + return constraints; + } + + // ========================================================================== + // TRANSLATION HINTS + // ========================================================================== + + private generateTranslationHints(pou: STPOU, target: TargetLanguage): AITranslationHint[] { + const hints: AITranslationHint[] = []; + + // Timer translation + const hasTimers = pou.variables.some(v => + v.dataType.includes('TON') || v.dataType.includes('TOF') || v.dataType.includes('TP') + ); + if (hasTimers) { + hints.push({ + category: 'timing', + plcConstruct: 'TON/TOF/TP timers', + targetEquivalent: this.getTimerEquivalent(target), + notes: 'PLC timers are scan-based. Ensure equivalent behavior in target.', + example: this.getTimerExample(target), + }); + } + + // I/O access + const hasIO = pou.variables.some(v => v.ioAddress); + if (hasIO) { + hints.push({ + category: 'io', + plcConstruct: 'Direct I/O (%IX, %QX, etc.)', + targetEquivalent: 'Hardware abstraction layer', + notes: 'I/O must be abstracted through hardware interface layer.', + example: this.getIOExample(target), + }); + } + + // State machine + // (Would need state machine info passed in) + + return hints; + } + + private getTimerEquivalent(target: TargetLanguage): string { + switch (target) { + case 'python': return 'asyncio.sleep() or threading.Timer'; + case 'rust': return 'tokio::time::sleep() or std::thread::sleep()'; + case 'typescript': return 'setTimeout() or setInterval()'; + case 'csharp': return 'System.Timers.Timer or Task.Delay()'; + case 'cpp': return 'std::chrono with std::this_thread::sleep_for()'; + case 'go': return 'time.Sleep() or time.After()'; + case 'java': return 'ScheduledExecutorService or Timer'; + default: return 'Language-specific timer'; + } + } + + private getTimerExample(target: TargetLanguage): string { + switch (target) { + case 'python': + return `# TON equivalent +class TON: + def __init__(self, preset_time: float): + self.PT = preset_time + self.ET = 0.0 + self.Q = False + self._start_time = None + + def __call__(self, IN: bool) -> bool: + if IN and self._start_time is None: + self._start_time = time.time() + elif not IN: + self._start_time = None + self.ET = 0.0 + self.Q = False + return False + + if self._start_time: + self.ET = time.time() - self._start_time + self.Q = self.ET >= self.PT + return self.Q`; + default: + return '// See language-specific timer implementation'; + } + } + + private getIOExample(target: TargetLanguage): string { + switch (target) { + case 'python': + return `# I/O abstraction +class IOInterface: + def read_input(self, address: str) -> bool: + # Implement hardware-specific read + pass + + def write_output(self, address: str, value: bool) -> None: + # Implement hardware-specific write + pass`; + default: + return '// See language-specific I/O abstraction'; + } + } + + // ========================================================================== + // TRANSLATION GUIDE + // ========================================================================== + + private generateTranslationGuide(targetLanguage: TargetLanguage): AITranslationGuide { + const typeMapping = TYPE_MAPPINGS[targetLanguage] ?? TYPE_MAPPINGS.python; + + const patternMapping: AIPatternMapping[] = [ + { + plcPattern: 'CASE state OF ... END_CASE', + targetPattern: this.getStateMachinePattern(targetLanguage), + example: this.getStateMachineExample(targetLanguage), + }, + { + plcPattern: 'TON timer', + targetPattern: this.getTimerEquivalent(targetLanguage), + example: this.getTimerExample(targetLanguage), + }, + { + plcPattern: 'IF condition THEN ... END_IF', + targetPattern: 'Standard if statement', + example: 'if condition: ... (Python) / if (condition) { ... } (others)', + }, + ]; + + const warnings: string[] = [ + 'PLC code executes cyclically - ensure equivalent behavior', + 'Timer behavior is scan-based - may need adjustment', + 'I/O access must be abstracted', + 'Safety interlocks must be preserved exactly', + ]; + + return { + targetLanguage, + typeMapping, + patternMapping, + warnings, + }; + } + + private getStateMachinePattern(target: TargetLanguage): string { + switch (target) { + case 'python': return 'Enum-based state machine or state pattern'; + case 'rust': return 'Enum with match expression'; + case 'typescript': return 'Union types with switch or state machine library'; + default: return 'Enum-based state machine'; + } + } + + private getStateMachineExample(target: TargetLanguage): string { + switch (target) { + case 'python': + return `from enum import Enum, auto + +class State(Enum): + IDLE = 0 + RUNNING = 10 + STOPPED = 20 + +class StateMachine: + def __init__(self): + self.state = State.IDLE + + def update(self): + match self.state: + case State.IDLE: + # Handle idle state + pass + case State.RUNNING: + # Handle running state + pass`; + default: + return '// See language-specific state machine implementation'; + } + } + + // ========================================================================== + // VERIFICATION REQUIREMENTS + // ========================================================================== + + private generateVerificationRequirements( + pous: STPOU[], + safety: SafetyAnalysisResult, + stateMachines: StateMachineExtractionResult + ): AIVerificationRequirement[] { + const requirements: AIVerificationRequirement[] = []; + + // Safety verification + if (safety.interlocks.length > 0) { + requirements.push({ + category: 'safety', + requirement: 'All safety interlocks must produce identical behavior', + testApproach: 'Test each interlock with boundary conditions', + }); + } + + // State machine verification + if (stateMachines.stateMachines.length > 0) { + requirements.push({ + category: 'state-machine', + requirement: 'State transitions must match original behavior', + testApproach: 'Test all state transitions with guard conditions', + }); + } + + // I/O verification + const hasIO = pous.some(pou => pou.variables.some(v => v.ioAddress)); + if (hasIO) { + requirements.push({ + category: 'io', + requirement: 'I/O behavior must be verified against hardware', + testApproach: 'Hardware-in-the-loop testing or simulation', + }); + } + + // Timing verification + const hasTimers = pous.some(pou => + pou.variables.some(v => + v.dataType.includes('TON') || v.dataType.includes('TOF') + ) + ); + if (hasTimers) { + requirements.push({ + category: 'timing', + requirement: 'Timer behavior must match within acceptable tolerance', + testApproach: 'Timing tests with measurement', + }); + } + + return requirements; + } + + // ========================================================================== + // TEST CASE SUGGESTIONS + // ========================================================================== + + private suggestTestCases( + pou: STPOU, + stateMachines: StateMachineExtractionResult['stateMachines'] + ): string[] { + const tests: string[] = []; + + // Input boundary tests + const inputs = pou.variables.filter(v => v.section === 'VAR_INPUT'); + for (const input of inputs) { + tests.push(`Test ${input.name} with boundary values`); + } + + // State machine tests + for (const sm of stateMachines) { + tests.push(`Test all ${sm.states.length} states in ${sm.name}`); + tests.push(`Test all ${sm.transitions.length} transitions in ${sm.name}`); + if (sm.verification.hasDeadlocks) { + tests.push(`Verify deadlock handling in ${sm.name}`); + } + } + + // Safety tests + const safetyVars = pou.variables.filter(v => v.isSafetyCritical); + for (const v of safetyVars) { + tests.push(`Test safety behavior of ${v.name}`); + } + + return tests; + } +} + + +// ============================================================================ +// FACTORY FUNCTION +// ============================================================================ + +export function createAIContextGenerator(config?: AIContextGeneratorConfig): AIContextGenerator { + return new AIContextGenerator(config); +} diff --git a/packages/core/src/iec61131/analyzers/index.ts b/packages/core/src/iec61131/analyzers/index.ts new file mode 100644 index 00000000..06ced518 --- /dev/null +++ b/packages/core/src/iec61131/analyzers/index.ts @@ -0,0 +1,11 @@ +/** + * IEC 61131-3 Analyzers + * + * Advanced analysis modules for migration scoring and AI context generation. + */ + +export { MigrationScorer, createMigrationScorer } from './migration-scorer.js'; +export type { MigrationScorerConfig, ScoringWeights } from './migration-scorer.js'; + +export { AIContextGenerator, createAIContextGenerator } from './ai-context.js'; +export type { AIContextGeneratorConfig } from './ai-context.js'; diff --git a/packages/core/src/iec61131/analyzers/migration-scorer.ts b/packages/core/src/iec61131/analyzers/migration-scorer.ts new file mode 100644 index 00000000..0395a305 --- /dev/null +++ b/packages/core/src/iec61131/analyzers/migration-scorer.ts @@ -0,0 +1,655 @@ +/** + * Migration Readiness Scorer + * + * Quantifies AI migration readiness for IEC 61131-3 code. + * Following architecture doc Part 2.2.2: Migration Readiness Scorer + * + * Produces a comprehensive score that tells you: + * 1. How ready is this code for AI-assisted migration? + * 2. What are the risks? + * 3. What order should blocks be migrated? + * 4. What documentation is missing? + */ + +import type { + STPOU, + POUMigrationScore, + MigrationReadinessReport, + MigrationOrderItem, + MigrationRisk, + MigrationBlocker, + MigrationGrade, + MigrationDimensionScores, + MigrationEffortEstimate, + SafetyAnalysisResult, +} from '../types.js'; +import type { DocstringExtractionResult } from '../extractors/index.js'; +import type { StateMachineExtractionResult } from '../extractors/index.js'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +export interface MigrationScorerConfig { + weights?: Partial; +} + +export interface ScoringWeights { + documentation: number; + safety: number; + complexity: number; + dependencies: number; + testability: number; +} + +const DEFAULT_WEIGHTS: ScoringWeights = { + documentation: 0.25, + safety: 0.30, + complexity: 0.15, + dependencies: 0.15, + testability: 0.15, +}; + +// ============================================================================ +// MIGRATION SCORER CLASS +// ============================================================================ + +export class MigrationScorer { + private weights: ScoringWeights; + + constructor(config?: MigrationScorerConfig) { + this.weights = { ...DEFAULT_WEIGHTS, ...config?.weights }; + } + + /** + * Calculate migration readiness for all POUs + */ + calculateReadiness( + pous: STPOU[], + docstrings: DocstringExtractionResult, + stateMachines: StateMachineExtractionResult, + safety: SafetyAnalysisResult, + callGraph?: Map + ): MigrationReadinessReport { + const pouScores: POUMigrationScore[] = []; + + for (const pou of pous) { + const score = this.scorePOU(pou, docstrings, stateMachines, safety, callGraph); + pouScores.push(score); + } + + // Calculate overall score + const overallScore = pouScores.length > 0 + ? pouScores.reduce((sum, s) => sum + s.overallScore, 0) / pouScores.length + : 0; + + // Calculate migration order + const migrationOrder = this.calculateMigrationOrder(pouScores, callGraph); + + // Assess risks + const risks = this.assessRisks(pouScores, safety); + + // Estimate effort + const estimatedEffort = this.estimateEffort(pouScores); + + return { + overallScore, + overallGrade: this.scoreToGrade(overallScore), + pouScores, + migrationOrder, + risks, + estimatedEffort, + }; + } + + /** + * Score a single POU + */ + private scorePOU( + pou: STPOU, + docstrings: DocstringExtractionResult, + stateMachines: StateMachineExtractionResult, + safety: SafetyAnalysisResult, + callGraph?: Map + ): POUMigrationScore { + const dimensionScores: MigrationDimensionScores = { + documentation: this.scoreDocumentation(pou, docstrings), + safety: this.scoreSafety(pou, safety), + complexity: this.scoreComplexity(pou, stateMachines), + dependencies: this.scoreDependencies(pou, callGraph), + testability: this.scoreTestability(pou, docstrings, stateMachines), + }; + + // Calculate weighted score + const overallScore = + dimensionScores.documentation * this.weights.documentation + + dimensionScores.safety * this.weights.safety + + dimensionScores.complexity * this.weights.complexity + + dimensionScores.dependencies * this.weights.dependencies + + dimensionScores.testability * this.weights.testability; + + // Identify blockers + const blockers = this.identifyBlockers(pou, dimensionScores, safety, stateMachines); + + // Generate warnings + const warnings = this.generateWarnings(pou, dimensionScores); + + // Generate suggestions + const suggestions = this.generateSuggestions(pou, dimensionScores, docstrings, stateMachines); + + return { + pouId: pou.id, + pouName: pou.name, + pouType: pou.type, + overallScore, + dimensionScores, + grade: this.scoreToGrade(overallScore), + blockers, + warnings, + suggestions, + }; + } + + // ========================================================================== + // DIMENSION SCORERS + // ========================================================================== + + /** + * Score documentation quality (25% weight) + */ + private scoreDocumentation(pou: STPOU, docstrings: DocstringExtractionResult): number { + let score = 0; + const maxScore = 100; + + // Find docstring for this POU + const pouDoc = docstrings.docstrings.find(d => + d.associatedBlock === pou.name || d.file === pou.location.file + ); + + // Has any documentation? (+20) + if (pouDoc || pou.documentation) { + score += 20; + } + + // Has summary/description? (+15) + if (pouDoc?.summary || pou.documentation?.summary) { + score += 15; + } + + // Has parameter documentation? (+20) + const inputVars = pou.variables.filter(v => v.section === 'VAR_INPUT'); + if (inputVars.length === 0) { + score += 20; // No inputs = full score + } else { + const documentedInputs = inputVars.filter(v => v.comment); + score += (documentedInputs.length / inputVars.length) * 20; + } + + // Has history/changelog? (+15) + if (pouDoc?.history && pouDoc.history.length > 0) { + score += 15; + } + + // Has inline comments? (+15) + const commentedVars = pou.variables.filter(v => v.comment); + const commentRatio = pou.variables.length > 0 + ? commentedVars.length / pou.variables.length + : 1; + score += commentRatio * 15; + + // Has safety notes where needed? (+15) + const hasSafetyVars = pou.variables.some(v => v.isSafetyCritical); + if (hasSafetyVars) { + const safetyVarsWithComments = pou.variables.filter(v => v.isSafetyCritical && v.comment); + if (safetyVarsWithComments.length > 0) { + score += 15; + } + } else { + score += 15; // No safety vars = full score + } + + return Math.min(score, maxScore); + } + + /** + * Score safety understanding (30% weight - highest because most critical) + */ + private scoreSafety(pou: STPOU, safety: SafetyAnalysisResult): number { + let score = 100; // Start at 100, deduct for issues + + // Check for bypasses in this POU (CRITICAL - major deduction) + const pouBypasses = safety.bypasses.filter(b => + b.location.file === pou.location.file + ); + if (pouBypasses.length > 0) { + score -= 40; // Major deduction for bypasses + } + + // Check for undocumented interlocks + const pouInterlocks = safety.interlocks.filter(i => + i.location.file === pou.location.file + ); + const undocumentedInterlocks = pouInterlocks.filter(i => !i.relatedInterlocks.length); + if (undocumentedInterlocks.length > 0) { + score -= undocumentedInterlocks.length * 5; + } + + // Check for critical warnings + const pouWarnings = safety.criticalWarnings.filter(w => + w.location.file === pou.location.file + ); + for (const warning of pouWarnings) { + switch (warning.severity) { + case 'critical': score -= 20; break; + case 'high': score -= 10; break; + case 'medium': score -= 5; break; + case 'low': score -= 2; break; + } + } + + // Check for safety-critical variables without documentation + const undocSafetyVars = pou.variables.filter(v => v.isSafetyCritical && !v.comment); + score -= undocSafetyVars.length * 3; + + return Math.max(0, score); + } + + /** + * Score complexity (15% weight) + */ + private scoreComplexity(pou: STPOU, stateMachines: StateMachineExtractionResult): number { + let score = 100; + + // Lines of code penalty + const lines = pou.bodyEndLine - pou.bodyStartLine; + if (lines > 500) score -= 30; + else if (lines > 200) score -= 15; + else if (lines > 100) score -= 5; + + // State machine complexity + const pouStateMachines = stateMachines.stateMachines.filter(sm => + sm.file === pou.location.file + ); + for (const sm of pouStateMachines) { + // Deduct for large state machines + if (sm.states.length > 20) score -= 20; + else if (sm.states.length > 10) score -= 10; + else if (sm.states.length > 5) score -= 5; + + // Deduct for verification issues + if (sm.verification.hasDeadlocks) score -= 15; + if (sm.verification.hasGaps) score -= 10; + if (sm.verification.unreachableStates.length > 0) score -= 5; + } + + // Variable count penalty + if (pou.variables.length > 50) score -= 15; + else if (pou.variables.length > 30) score -= 10; + else if (pou.variables.length > 20) score -= 5; + + return Math.max(0, score); + } + + /** + * Score dependencies (15% weight) + */ + private scoreDependencies(pou: STPOU, callGraph?: Map): number { + let score = 100; + + if (!callGraph) { + return 80; // Default score if no call graph available + } + + const dependencies = callGraph.get(pou.id) ?? []; + + // Deduct for many dependencies + if (dependencies.length > 10) score -= 30; + else if (dependencies.length > 5) score -= 15; + else if (dependencies.length > 3) score -= 5; + + // Check for circular dependencies (would need more analysis) + // For now, just penalize high dependency count + + return Math.max(0, score); + } + + /** + * Score testability (15% weight) + */ + private scoreTestability( + pou: STPOU, + _docstrings: DocstringExtractionResult, + stateMachines: StateMachineExtractionResult + ): number { + let score = 100; + + // Clear inputs/outputs? (+bonus or no penalty) + const inputs = pou.variables.filter(v => v.section === 'VAR_INPUT'); + const outputs = pou.variables.filter(v => v.section === 'VAR_OUTPUT'); + + if (inputs.length === 0 && outputs.length === 0) { + score -= 20; // No clear interface + } + + // Documented inputs/outputs? + const documentedInputs = inputs.filter(v => v.comment); + const documentedOutputs = outputs.filter(v => v.comment); + + if (inputs.length > 0) { + const inputDocRatio = documentedInputs.length / inputs.length; + if (inputDocRatio < 0.5) score -= 15; + } + if (outputs.length > 0) { + const outputDocRatio = documentedOutputs.length / outputs.length; + if (outputDocRatio < 0.5) score -= 15; + } + + // State machines with clear states? + const pouStateMachines = stateMachines.stateMachines.filter(sm => + sm.file === pou.location.file + ); + for (const sm of pouStateMachines) { + const namedStates = sm.states.filter(s => s.name); + if (namedStates.length < sm.states.length * 0.5) { + score -= 10; // Many unnamed states + } + } + + return Math.max(0, score); + } + + // ========================================================================== + // BLOCKERS & WARNINGS + // ========================================================================== + + private identifyBlockers( + pou: STPOU, + scores: MigrationDimensionScores, + safety: SafetyAnalysisResult, + stateMachines: StateMachineExtractionResult + ): MigrationBlocker[] { + const blockers: MigrationBlocker[] = []; + + // Safety bypass is a blocker + const pouBypasses = safety.bypasses.filter(b => + b.location.file === pou.location.file + ); + for (const bypass of pouBypasses) { + blockers.push({ + type: 'safety-bypass', + description: `Safety bypass detected: ${bypass.name}`, + severity: 'critical', + remediation: 'Review with safety engineer before migration. Document bypass purpose and conditions.', + }); + } + + // Undocumented state machine is a blocker + const pouStateMachines = stateMachines.stateMachines.filter(sm => + sm.file === pou.location.file + ); + for (const sm of pouStateMachines) { + const unnamedStates = sm.states.filter(s => !s.name); + if (unnamedStates.length > sm.states.length * 0.5) { + blockers.push({ + type: 'undocumented-state-machine', + description: `Undocumented state machine: ${sm.name} (${unnamedStates.length}/${sm.states.length} states unnamed)`, + severity: 'high', + remediation: 'Document state machine states before migration.', + }); + } + } + + // Very low documentation score is a blocker + if (scores.documentation < 30) { + blockers.push({ + type: 'missing-documentation', + description: 'Critical lack of documentation', + severity: 'high', + remediation: 'Add documentation for inputs, outputs, and purpose before migration.', + }); + } + + return blockers; + } + + private generateWarnings(_pou: STPOU, scores: MigrationDimensionScores): string[] { + const warnings: string[] = []; + + if (scores.documentation < 50) { + warnings.push('Documentation is below recommended level'); + } + if (scores.complexity < 50) { + warnings.push('High complexity may make migration error-prone'); + } + if (scores.dependencies < 50) { + warnings.push('Many dependencies - consider migration order carefully'); + } + if (scores.testability < 50) { + warnings.push('Low testability - verification may be difficult'); + } + + return warnings; + } + + private generateSuggestions( + pou: STPOU, + scores: MigrationDimensionScores, + _docstrings: DocstringExtractionResult, + stateMachines: StateMachineExtractionResult + ): string[] { + const suggestions: string[] = []; + + // Documentation suggestions + if (scores.documentation < 70) { + const undocVars = pou.variables.filter(v => !v.comment); + if (undocVars.length > 0) { + suggestions.push(`Document ${undocVars.length} variables without comments`); + } + if (!pou.documentation) { + suggestions.push('Add header documentation describing purpose and behavior'); + } + } + + // State machine suggestions + const pouStateMachines = stateMachines.stateMachines.filter(sm => + sm.file === pou.location.file + ); + for (const sm of pouStateMachines) { + const unnamedStates = sm.states.filter(s => !s.name); + if (unnamedStates.length > 0) { + suggestions.push(`Name ${unnamedStates.length} unnamed states in ${sm.name}`); + } + if (sm.verification.hasDeadlocks) { + suggestions.push(`Review deadlock states in ${sm.name}`); + } + } + + // Complexity suggestions + if (scores.complexity < 50) { + suggestions.push('Consider breaking into smaller function blocks'); + } + + return suggestions; + } + + // ========================================================================== + // MIGRATION ORDER + // ========================================================================== + + private calculateMigrationOrder( + pouScores: POUMigrationScore[], + callGraph?: Map + ): MigrationOrderItem[] { + // Sort by: + // 1. No blockers first + // 2. Fewer dependencies first + // 3. Higher score first + const sorted = [...pouScores].sort((a, b) => { + // Blockers last + if (a.blockers.length !== b.blockers.length) { + return a.blockers.length - b.blockers.length; + } + // Higher score first + return b.overallScore - a.overallScore; + }); + + return sorted.map((score, index) => { + const dependencies = callGraph?.get(score.pouId) ?? []; + + let reason: string; + if (score.blockers.length > 0) { + reason = `Has ${score.blockers.length} blocker(s) - resolve before migration`; + } else if (dependencies.length === 0) { + reason = 'No dependencies, well documented'; + } else if (score.overallScore >= 80) { + reason = 'High readiness score, good documentation'; + } else { + reason = 'Moderate readiness, review documentation'; + } + + return { + order: index + 1, + pouId: score.pouId, + pouName: score.pouName, + reason, + dependencies, + estimatedEffort: this.estimatePOUEffort(score), + }; + }); + } + + // ========================================================================== + // RISK ASSESSMENT + // ========================================================================== + + private assessRisks( + pouScores: POUMigrationScore[], + safety: SafetyAnalysisResult + ): MigrationRisk[] { + const risks: MigrationRisk[] = []; + + // Safety bypass risk + if (safety.bypasses.length > 0) { + risks.push({ + severity: 'critical', + category: 'safety', + description: `${safety.bypasses.length} safety bypass(es) detected`, + affectedPOUs: safety.bypasses.map(b => b.location.file), + mitigation: 'Review all bypasses with safety engineer before migration', + }); + } + + // Low documentation risk + const lowDocPOUs = pouScores.filter(s => s.dimensionScores.documentation < 40); + if (lowDocPOUs.length > 0) { + risks.push({ + severity: 'high', + category: 'documentation', + description: `${lowDocPOUs.length} POU(s) have insufficient documentation`, + affectedPOUs: lowDocPOUs.map(s => s.pouName), + mitigation: 'Document POUs before migration to ensure correct translation', + }); + } + + // High complexity risk + const complexPOUs = pouScores.filter(s => s.dimensionScores.complexity < 40); + if (complexPOUs.length > 0) { + risks.push({ + severity: 'medium', + category: 'complexity', + description: `${complexPOUs.length} POU(s) have high complexity`, + affectedPOUs: complexPOUs.map(s => s.pouName), + mitigation: 'Consider refactoring or extra testing for complex POUs', + }); + } + + // Blocked POUs risk + const blockedPOUs = pouScores.filter(s => s.blockers.length > 0); + if (blockedPOUs.length > 0) { + risks.push({ + severity: 'high', + category: 'blockers', + description: `${blockedPOUs.length} POU(s) have migration blockers`, + affectedPOUs: blockedPOUs.map(s => s.pouName), + mitigation: 'Resolve all blockers before attempting migration', + }); + } + + return risks; + } + + // ========================================================================== + // EFFORT ESTIMATION + // ========================================================================== + + private estimateEffort(pouScores: POUMigrationScore[]): MigrationEffortEstimate { + const byPOU: Record = {}; + let totalHours = 0; + + for (const score of pouScores) { + const hours = this.estimatePOUHours(score); + byPOU[score.pouName] = hours; + totalHours += hours; + } + + return { + totalHours, + byPOU, + confidence: this.calculateEffortConfidence(pouScores), + }; + } + + private estimatePOUHours(score: POUMigrationScore): number { + // Base hours by type + let baseHours: number; + switch (score.pouType) { + case 'PROGRAM': baseHours = 8; break; + case 'FUNCTION_BLOCK': baseHours = 4; break; + case 'FUNCTION': baseHours = 2; break; + default: baseHours = 4; + } + + // Adjust by score (lower score = more time) + const scoreMultiplier = 2 - (score.overallScore / 100); + + // Adjust for blockers + const blockerMultiplier = 1 + (score.blockers.length * 0.5); + + return Math.round(baseHours * scoreMultiplier * blockerMultiplier); + } + + private estimatePOUEffort(score: POUMigrationScore): string { + const hours = this.estimatePOUHours(score); + if (hours <= 2) return '1-2 hours'; + if (hours <= 4) return '2-4 hours'; + if (hours <= 8) return '4-8 hours'; + if (hours <= 16) return '1-2 days'; + return '2+ days'; + } + + private calculateEffortConfidence(pouScores: POUMigrationScore[]): number { + // Higher average score = higher confidence in estimate + const avgScore = pouScores.reduce((sum, s) => sum + s.overallScore, 0) / pouScores.length; + return Math.min(0.9, avgScore / 100); + } + + // ========================================================================== + // UTILITIES + // ========================================================================== + + private scoreToGrade(score: number): MigrationGrade { + if (score >= 90) return 'A'; + if (score >= 80) return 'B'; + if (score >= 70) return 'C'; + if (score >= 60) return 'D'; + return 'F'; + } +} + +// ============================================================================ +// FACTORY FUNCTION +// ============================================================================ + +export function createMigrationScorer(config?: MigrationScorerConfig): MigrationScorer { + return new MigrationScorer(config); +} diff --git a/packages/core/src/iec61131/extractors/docstring-extractor.ts b/packages/core/src/iec61131/extractors/docstring-extractor.ts new file mode 100644 index 00000000..3b35eda6 --- /dev/null +++ b/packages/core/src/iec61131/extractors/docstring-extractor.ts @@ -0,0 +1,442 @@ +/** + * Docstring Extractor + * + * Extracts documentation from IEC 61131-3 Structured Text files. + * This is the PRIMARY feature requested - comprehensive docstring extraction. + * + * Handles: + * - Block comments (* ... *) + * - Header comments (***...***) + * - @param, @returns, @author, @date annotations + * - History entries (YYYY-MM-DD format) + * - Warnings, notes, cautions + * - Associated block detection + */ + +import type { + STDocstring, + STDocParam, + STHistoryEntry, + POUType, +} from '../types.js'; +import { generateId } from '../utils/id-generator.js'; + +// ============================================================================ +// EXTRACTION RESULT +// ============================================================================ + +export interface DocstringExtractionResult { + docstrings: ExtractedDocstring[]; + summary: DocstringSummary; +} + +export interface ExtractedDocstring extends STDocstring { + file: string; + quality: DocstringQuality; +} + +export interface DocstringQuality { + score: number; + hasSummary: boolean; + hasParams: boolean; + hasHistory: boolean; + hasWarnings: boolean; + completeness: 'complete' | 'partial' | 'minimal'; +} + +export interface DocstringSummary { + total: number; + byBlock: Record; + withParams: number; + withHistory: number; + withWarnings: number; + averageQuality: number; +} + +export interface DocstringExtractionOptions { + includeRaw?: boolean; + minLength?: number; + includeOrphaned?: boolean; +} + +// ============================================================================ +// PATTERNS +// ============================================================================ + +// Block comment pattern - matches (* ... *) +const BLOCK_COMMENT_PATTERN = /\(\*+\s*([\s\S]*?)\s*\*+\)/g; + +// POU patterns for association +const POU_PATTERNS: Array<{ pattern: RegExp; type: POUType }> = [ + { pattern: /^\s*FUNCTION_BLOCK\s+(\w+)/im, type: 'FUNCTION_BLOCK' }, + { pattern: /^\s*PROGRAM\s+(\w+)/im, type: 'PROGRAM' }, + { pattern: /^\s*FUNCTION\s+(\w+)/im, type: 'FUNCTION' }, + { pattern: /^\s*CLASS\s+(\w+)/im, type: 'CLASS' }, + { pattern: /^\s*INTERFACE\s+(\w+)/im, type: 'INTERFACE' }, +]; + +// ============================================================================ +// EXTRACTOR +// ============================================================================ + +export function extractDocstrings( + source: string, + filePath: string, + options: DocstringExtractionOptions = {} +): DocstringExtractionResult { + const { + includeRaw = false, + minLength = 20, + includeOrphaned = true, + } = options; + + const docstrings: ExtractedDocstring[] = []; + let match: RegExpExecArray | null; + + // Reset regex + BLOCK_COMMENT_PATTERN.lastIndex = 0; + + while ((match = BLOCK_COMMENT_PATTERN.exec(source)) !== null) { + const fullMatch = match[0]; + const content = match[1] ?? ''; + + // Skip short comments (likely inline) + if (content.length < minLength && !content.includes('\n')) { + continue; + } + + const startOffset = match.index; + const endOffset = startOffset + fullMatch.length; + const line = getLineNumber(source, startOffset); + const endLine = getLineNumber(source, endOffset); + + // Check if this looks like a docstring + if (!isDocstringComment(content)) { + continue; + } + + // Parse the docstring content + const parsed = parseDocstringContent(content); + + // Find associated block + const afterComment = source.slice(endOffset); + const { blockName, blockType } = findAssociatedBlock(afterComment); + + // Skip orphaned if not requested + if (!includeOrphaned && !blockName) { + continue; + } + + // Calculate quality + const quality = calculateQuality(parsed); + + const docstring: ExtractedDocstring = { + id: generateId(), + file: filePath, + summary: parsed.summary, + description: parsed.description, + params: parsed.params, + returns: parsed.returns, + author: parsed.author, + date: parsed.date, + history: parsed.history, + warnings: parsed.warnings, + notes: parsed.notes, + raw: includeRaw ? fullMatch : '', + location: { + file: filePath, + line, + column: 1, + endLine, + endColumn: fullMatch.length - fullMatch.lastIndexOf('\n'), + }, + associatedBlock: blockName, + associatedBlockType: blockType, + quality, + }; + + docstrings.push(docstring); + } + + // Calculate summary + const summary = calculateSummary(docstrings); + + return { docstrings, summary }; +} + +// ============================================================================ +// PARSING HELPERS +// ============================================================================ + +interface ParsedContent { + summary: string; + description: string; + params: STDocParam[]; + returns: string | null; + author: string | null; + date: string | null; + history: STHistoryEntry[]; + warnings: string[]; + notes: string[]; +} + +function parseDocstringContent(content: string): ParsedContent { + const lines = content.split('\n').map(l => + l.replace(/^\s*\*?\s?/, '').trim() + ); + + let summary = ''; + const descLines: string[] = []; + const params: STDocParam[] = []; + const history: STHistoryEntry[] = []; + const warnings: string[] = []; + const notes: string[] = []; + let returns: string | null = null; + let author: string | null = null; + let date: string | null = null; + let inDescription = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + + // Skip separator lines + if (/^[=\-*_]+$/.test(line)) continue; + if (!line) { + if (summary) inDescription = true; + continue; + } + + // @param - multiple formats + const paramMatch = line.match( + /@param\s+(\w+)\s*(?::\s*(\w+))?\s*[-:]?\s*(.*)/i + ) || line.match( + /^\s*(\w+)\s*:\s*(\w+)\s*[-:]\s*(.*)/ // name : TYPE - description + ); + if (paramMatch && line.toLowerCase().includes('param')) { + params.push({ + name: paramMatch[1]!, + type: paramMatch[2] || null, + description: paramMatch[3]?.trim() || '', + direction: inferDirection(paramMatch[1]!), + }); + continue; + } + + // @returns / @return + const returnMatch = line.match(/@returns?\s+(.*)/i); + if (returnMatch) { + returns = returnMatch[1]?.trim() || null; + continue; + } + + // @author or Auth: + const authorMatch = line.match(/@author\s+(.*)/i) || line.match(/^Auth(?:or)?:\s*(.*)/i); + if (authorMatch) { + author = authorMatch[1]?.trim() || null; + continue; + } + + // @date or Date: + const dateMatch = line.match(/@date\s+(.*)/i) || line.match(/^Date:\s*(.*)/i); + if (dateMatch) { + date = dateMatch[1]?.trim() || null; + continue; + } + + // History entries - YYYY-MM-DD or YYYY/MM/DD + const historyMatch = line.match(/^(\d{4}[-/]\d{2}[-/]\d{2})\s*(?:[-:]?\s*)?(\w+)?\s*[-:]?\s*(.*)/); + if (historyMatch) { + history.push({ + date: historyMatch[1]!.replace(/\//g, '-'), + author: historyMatch[2] || null, + description: historyMatch[3]?.trim() || '', + }); + continue; + } + + // Year-only history (common in legacy code) + const yearMatch = line.match(/^(\d{4})\s*[-:]?\s*(\w+)?\s*[-:]?\s*(.*)/); + if (yearMatch && parseInt(yearMatch[1]!) >= 1990 && parseInt(yearMatch[1]!) <= 2030) { + history.push({ + date: yearMatch[1]!, + author: yearMatch[2] || null, + description: yearMatch[3]?.trim() || '', + }); + continue; + } + + // Warnings, dangers, cautions + if (/^(WARNING|DANGER|CAUTION)\s*[:\-!]?\s*/i.test(line)) { + warnings.push(line); + continue; + } + + // Notes + if (/^NOTE\s*[:\-!]?\s*/i.test(line)) { + notes.push(line.replace(/^NOTE\s*[:\-!]?\s*/i, '')); + continue; + } + + // TODO, FIXME (add to notes) + if (/^(TODO|FIXME)\s*[:\-!]?\s*/i.test(line)) { + notes.push(line); + continue; + } + + // Skip section headers + if (/^(HISTORY|PARAMETERS|INPUTS|OUTPUTS|DESCRIPTION|CONTENTS)\s*:?\s*$/i.test(line)) { + continue; + } + + // First meaningful line is summary + if (!summary && !line.startsWith('@') && line.length > 5) { + summary = line; + continue; + } + + // Rest is description + if (summary && !line.startsWith('@') && (inDescription || descLines.length > 0)) { + descLines.push(line); + } + } + + return { + summary, + description: descLines.join(' ').trim(), + params, + returns, + author, + date, + history, + warnings, + notes, + }; +} + +function isDocstringComment(content: string): boolean { + // Multi-line is likely docstring + if (content.includes('\n')) return true; + + // Has annotations + if (/@(param|returns?|author|date|history)/i.test(content)) return true; + + // Has special markers + if (/(WARNING|DANGER|CAUTION|NOTE|TODO|FIXME)/i.test(content)) return true; + + // Header style + if (/^[=\-*]{3,}/.test(content)) return true; + + // Long single line + if (content.length > 100) return true; + + return false; +} + +function findAssociatedBlock(afterComment: string): { blockName: string | null; blockType: POUType | null } { + // Look in the first 500 chars after comment + const searchArea = afterComment.slice(0, 500); + + for (const { pattern, type } of POU_PATTERNS) { + const match = searchArea.match(pattern); + if (match) { + return { blockName: match[1]!, blockType: type }; + } + } + + return { blockName: null, blockType: null }; +} + +function inferDirection(paramName: string): 'in' | 'out' | 'inout' | null { + const lower = paramName.toLowerCase(); + if (lower.startsWith('b') || lower.startsWith('r') || lower.startsWith('n') || lower.startsWith('i')) { + // Common input prefixes + return 'in'; + } + if (lower.includes('out') || lower.includes('result')) { + return 'out'; + } + return null; +} + +function calculateQuality(parsed: ParsedContent): DocstringQuality { + let score = 0; + + const hasSummary = parsed.summary.length > 10; + const hasParams = parsed.params.length > 0; + const hasHistory = parsed.history.length > 0; + const hasWarnings = parsed.warnings.length > 0; + + if (hasSummary) score += 30; + if (parsed.description.length > 20) score += 20; + if (hasParams) score += 20; + if (hasHistory) score += 15; + if (hasWarnings) score += 10; + if (parsed.author) score += 5; + + let completeness: 'complete' | 'partial' | 'minimal'; + if (score >= 70) completeness = 'complete'; + else if (score >= 40) completeness = 'partial'; + else completeness = 'minimal'; + + return { + score, + hasSummary, + hasParams, + hasHistory, + hasWarnings, + completeness, + }; +} + +function calculateSummary(docstrings: ExtractedDocstring[]): DocstringSummary { + const byBlock: Record = {}; + let withParams = 0; + let withHistory = 0; + let withWarnings = 0; + let totalQuality = 0; + + for (const doc of docstrings) { + const key = doc.associatedBlock || 'standalone'; + byBlock[key] = (byBlock[key] || 0) + 1; + + if (doc.params.length > 0) withParams++; + if (doc.history.length > 0) withHistory++; + if (doc.warnings.length > 0) withWarnings++; + totalQuality += doc.quality.score; + } + + return { + total: docstrings.length, + byBlock, + withParams, + withHistory, + withWarnings, + averageQuality: docstrings.length > 0 ? totalQuality / docstrings.length : 0, + }; +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +/** + * Extract docstrings from multiple files + */ +export function extractDocstringsFromFiles( + files: Array<{ path: string; content: string }>, + options?: DocstringExtractionOptions +): DocstringExtractionResult { + const allDocstrings: ExtractedDocstring[] = []; + + for (const file of files) { + const result = extractDocstrings(file.content, file.path, options); + allDocstrings.push(...result.docstrings); + } + + const summary = calculateSummary(allDocstrings); + return { docstrings: allDocstrings, summary }; +} diff --git a/packages/core/src/iec61131/extractors/index.ts b/packages/core/src/iec61131/extractors/index.ts new file mode 100644 index 00000000..aa10ea35 --- /dev/null +++ b/packages/core/src/iec61131/extractors/index.ts @@ -0,0 +1,64 @@ +/** + * IEC 61131-3 Extractors + * + * All extraction modules for ST code analysis. + */ + +// Docstring extraction (PhD's primary request) +export { + extractDocstrings, + extractDocstringsFromFiles, +} from './docstring-extractor.js'; +export type { + DocstringExtractionResult, + ExtractedDocstring, + DocstringQuality, + DocstringSummary, + DocstringExtractionOptions, +} from './docstring-extractor.js'; + +// State machine extraction +export { + extractStateMachines, + extractStateMachinesFromFiles, +} from './state-machine-extractor.js'; +export type { + StateMachineExtractionResult, + ExtractedStateMachine, + StateMachineSummary, + StateMachineExtractionOptions, +} from './state-machine-extractor.js'; + +// Safety interlock extraction (CRITICAL) +export { + extractSafetyInterlocks, + extractSafetyFromFiles, +} from './safety-extractor.js'; +export type { + SafetyExtractionResult, + SafetyExtractionOptions, +} from './safety-extractor.js'; + +// Tribal knowledge extraction +export { + extractTribalKnowledge, + extractTribalKnowledgeFromFiles, +} from './tribal-knowledge-extractor.js'; +export type { + TribalKnowledgeExtractionResult, + ExtractedTribalKnowledge, + TribalKnowledgeSummary, + TribalKnowledgeExtractionOptions, +} from './tribal-knowledge-extractor.js'; + +// Variable extraction +export { + extractVariables, + extractVariablesFromFiles, +} from './variable-extractor.js'; +export type { + VariableExtractionResult, + ExtractedVariable, + VariableSummary, + VariableExtractionOptions, +} from './variable-extractor.js'; diff --git a/packages/core/src/iec61131/extractors/safety-extractor.ts b/packages/core/src/iec61131/extractors/safety-extractor.ts new file mode 100644 index 00000000..d944cbe1 --- /dev/null +++ b/packages/core/src/iec61131/extractors/safety-extractor.ts @@ -0,0 +1,499 @@ +/** + * Safety Interlock Extractor + * + * CRITICAL: Detects safety-related patterns in IEC 61131-3 code. + * Zero tolerance for false negatives on bypass detection. + * + * Detects: + * - Safety interlocks (bIL_, IL_, Interlock) + * - Permissives + * - E-Stop signals + * - Safety relays and devices + * - BYPASS CONDITIONS (CRITICAL) + */ + +import type { + SafetyInterlock, + SafetyBypass, + SafetyAnalysisResult, + SafetyCriticalWarning, + SafetySummary, + SafetyInterlockType, + SafetySeverity, +} from '../types.js'; +import { generateId } from '../utils/id-generator.js'; + +// ============================================================================ +// EXTRACTION RESULT +// ============================================================================ + +export interface SafetyExtractionResult extends SafetyAnalysisResult { + file: string; +} + +export interface SafetyExtractionOptions { + strict?: boolean; + customPatterns?: SafetyPattern[]; +} + +interface SafetyPattern { + pattern: RegExp; + type: SafetyInterlockType; + confidence: number; +} + +// ============================================================================ +// PATTERNS - COMPREHENSIVE SAFETY DETECTION +// ============================================================================ + +// Interlock patterns - HIGH CONFIDENCE +const INTERLOCK_PATTERNS: SafetyPattern[] = [ + // Standard IEC patterns - case insensitive + { pattern: /\b(bIL_\w+)\b/gi, type: 'interlock', confidence: 0.95 }, + { pattern: /\b(IL_\w+)\b/gi, type: 'interlock', confidence: 0.90 }, + { pattern: /\b(b?Interlock\w*)\b/gi, type: 'interlock', confidence: 0.85 }, + { pattern: /\b(\w*_Interlock\w*)\b/gi, type: 'interlock', confidence: 0.85 }, + { pattern: /\b(b?SafetyInterlock\w*)\b/gi, type: 'interlock', confidence: 0.90 }, + + // Permissive patterns + { pattern: /\b(b?Permissive\w*)\b/gi, type: 'permissive', confidence: 0.85 }, + { pattern: /\b(bPerm_\w+)\b/gi, type: 'permissive', confidence: 0.90 }, + { pattern: /\b(\w*Permit\w*)\b/gi, type: 'permissive', confidence: 0.80 }, + + // E-Stop patterns + { pattern: /\b(b?EStop\w*)\b/gi, type: 'estop', confidence: 0.95 }, + { pattern: /\b(b?E_Stop\w*)\b/gi, type: 'estop', confidence: 0.95 }, + { pattern: /\b(b?EmergencyStop\w*)\b/gi, type: 'estop', confidence: 0.95 }, + { pattern: /\b(bES_\w+)\b/gi, type: 'estop', confidence: 0.90 }, + { pattern: /\b(\w*Emergency\w*Stop\w*)\b/gi, type: 'estop', confidence: 0.90 }, + + // Safety relay patterns + { pattern: /\b(b?SafetyRelay\w*)\b/gi, type: 'safety-relay', confidence: 0.90 }, + { pattern: /\b(SR_\w+)\b/gi, type: 'safety-relay', confidence: 0.85 }, + { pattern: /\b(bSR_\w+)\b/gi, type: 'safety-relay', confidence: 0.90 }, + + // Safety device patterns + { pattern: /\b(b?LightCurtain\w*)\b/gi, type: 'safety-device', confidence: 0.85 }, + { pattern: /\b(LC_\w+)\b/gi, type: 'safety-device', confidence: 0.85 }, + { pattern: /\b(b?SafetyMat\w*)\b/gi, type: 'safety-device', confidence: 0.85 }, + { pattern: /\b(b?GuardDoor\w*)\b/gi, type: 'safety-device', confidence: 0.85 }, + { pattern: /\b(b?SafetyGate\w*)\b/gi, type: 'safety-device', confidence: 0.85 }, + + // Generic safety patterns (Siemens/Rockwell style) + { pattern: /\b(i_b\w*Safety\w*)\b/gi, type: 'interlock', confidence: 0.85 }, + { pattern: /\b(o_b\w*Safety\w*)\b/gi, type: 'interlock', confidence: 0.85 }, + { pattern: /\b(b?SafetyChain\w*)\b/gi, type: 'interlock', confidence: 0.90 }, + { pattern: /\b(Safety_\w+)\b/gi, type: 'interlock', confidence: 0.80 }, + { pattern: /\b(\w*_Safety\w*)\b/gi, type: 'interlock', confidence: 0.75 }, +]; + +// BYPASS PATTERNS - CRITICAL - ZERO FALSE NEGATIVES +const BYPASS_PATTERNS: SafetyPattern[] = [ + // Explicit bypass patterns + { pattern: /\b(bDbg_SkipIL)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bDbg_Skip\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bDebug_Bypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bDbgBypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bDbg_Override\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(BypassInterlock\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(IL_Bypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(SkipSafety\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(Debug_NoIL\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bSkip_IL\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + + // Generic bypass patterns - CRITICAL + { pattern: /\b(bBypass\w*)\b/gi, type: 'bypass', confidence: 0.95 }, + { pattern: /\b(\w*Bypass\w*)\b/gi, type: 'bypass', confidence: 0.85 }, + { pattern: /\b(bypass_\w+)\b/gi, type: 'bypass', confidence: 0.95 }, + + // Skip patterns - CRITICAL + { pattern: /\b(bSkip\w*)\b/gi, type: 'bypass', confidence: 0.90 }, + { pattern: /\b(skip_\w+)\b/gi, type: 'bypass', confidence: 0.90 }, + { pattern: /\b(bSkipSafety\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bSkipInterlock\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bSkipCheck\w*)\b/gi, type: 'bypass', confidence: 0.90 }, + + // Override patterns - CRITICAL + { pattern: /\b(bOverride\w*)\b/gi, type: 'bypass', confidence: 0.90 }, + { pattern: /\b(override_\w+)\b/gi, type: 'bypass', confidence: 0.90 }, + { pattern: /\b(bOverrideSafety\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bSafetyOverride\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bOverride_IL\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + + // Disable patterns - CRITICAL + { pattern: /\b(bDisable\w*)\b/gi, type: 'bypass', confidence: 0.90 }, + { pattern: /\b(disable_\w+)\b/gi, type: 'bypass', confidence: 0.90 }, + { pattern: /\b(bDisableSafety\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bSafetyDisable\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bDisable_IL\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(disable_interlock\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + + // Force patterns - CRITICAL + { pattern: /\b(bForce\w*)\b/gi, type: 'bypass', confidence: 0.85 }, + { pattern: /\b(force_\w+)\b/gi, type: 'bypass', confidence: 0.85 }, + { pattern: /\b(bForceSafety\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bForce_IL\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bForceInterlock\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(force_safety_ok\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + + // Test/simulation mode patterns (often bypass safety) + { pattern: /\b(bTestMode)\b/gi, type: 'bypass', confidence: 0.80 }, + { pattern: /\b(bSimulation)\b/gi, type: 'bypass', confidence: 0.80 }, + { pattern: /\b(bDebugMode)\b/gi, type: 'bypass', confidence: 0.85 }, + { pattern: /\b(bMaintenanceMode)\b/gi, type: 'bypass', confidence: 0.75 }, + + // Maintenance/Service patterns - CRITICAL + { pattern: /\b(bMaintBypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bMaint\w*Bypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bServiceBypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bService\w*Bypass\w*)\b/gi, type: 'bypass', confidence: 1.0 }, + { pattern: /\b(bCommissioningMode\w*)\b/gi, type: 'bypass', confidence: 0.85 }, + + // Abbreviations that might slip through + { pattern: /\b(bDbgSkpIL)\b/gi, type: 'bypass', confidence: 0.95 }, + { pattern: /\b(bSkpIntlk)\b/gi, type: 'bypass', confidence: 0.95 }, +]; + +// Bypass context patterns - detect bypass via assignment +const BYPASS_CONTEXT_PATTERNS = [ + /IF\s+\w*[Bb]ypass\w*\s+(?:OR|THEN)/gi, + /IF\s+\w*[Dd]ebug\w*\s+(?:OR|THEN)/gi, + /IF\s+\w*[Tt]est\w*\s+(?:OR|THEN)/gi, + /NOT\s+\w*[Ii]nterlock\w*\s+OR\s+\w*[Bb]ypass/gi, + /:=\s*FALSE\s*;\s*\(\*.*[Bb]ypass/gi, +]; + +// ============================================================================ +// EXTRACTOR +// ============================================================================ + +export function extractSafetyInterlocks( + source: string, + filePath: string, + options: SafetyExtractionOptions = {} +): SafetyExtractionResult { + const { customPatterns = [] } = options; + + const interlocks: SafetyInterlock[] = []; + const bypasses: SafetyBypass[] = []; + const criticalWarnings: SafetyCriticalWarning[] = []; + const seen = new Set(); + + // Combine patterns + const allInterlockPatterns = [...INTERLOCK_PATTERNS, ...customPatterns.filter(p => p.type !== 'bypass')]; + const allBypassPatterns = [...BYPASS_PATTERNS, ...customPatterns.filter(p => p.type === 'bypass')]; + + // First pass: Find all bypass variables + const bypassVars = new Set(); + for (const { pattern } of allBypassPatterns) { + let match: RegExpExecArray | null; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + bypassVars.add(match[1]!.toLowerCase()); + } + } + + // Check for bypass context patterns + for (const pattern of BYPASS_CONTEXT_PATTERNS) { + if (pattern.test(source)) { + const match = source.match(pattern); + if (match) { + criticalWarnings.push({ + type: 'bypass-detected', + message: `Potential safety bypass pattern detected: ${match[0].slice(0, 50)}`, + severity: 'critical', + location: { + file: filePath, + line: getLineNumber(source, source.indexOf(match[0])), + column: 1, + }, + remediation: 'Review this code path for safety bypass conditions', + }); + } + } + } + + // Extract interlocks + for (const { pattern, type, confidence } of allInterlockPatterns) { + let match: RegExpExecArray | null; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const name = match[1]!; + const nameLower = name.toLowerCase(); + + if (seen.has(nameLower)) continue; + seen.add(nameLower); + + const line = getLineNumber(source, match.index); + const isBypassed = bypassVars.has(nameLower) || isUsedWithBypass(name, source); + const severity = determineSeverity(type, isBypassed); + + const interlock: SafetyInterlock = { + id: generateId(), + name, + type, + location: { + file: filePath, + line, + column: match.index - source.lastIndexOf('\n', match.index), + }, + pouId: null, + isBypassed, + bypassCondition: isBypassed ? findBypassCondition(name, source) : null, + confidence, + severity, + relatedInterlocks: findRelatedInterlocks(name, source), + }; + + interlocks.push(interlock); + + // Add warning if bypassed + if (isBypassed) { + criticalWarnings.push({ + type: 'bypass-detected', + message: `Safety interlock '${name}' may be bypassed`, + severity: 'critical', + location: interlock.location, + remediation: 'Review bypass conditions and ensure proper safety measures', + }); + } + } + } + + // Extract bypass variables as separate entries + for (const { pattern } of allBypassPatterns) { + let match: RegExpExecArray | null; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const name = match[1]!; + const nameLower = name.toLowerCase(); + + if (seen.has(nameLower)) continue; + seen.add(nameLower); + + const line = getLineNumber(source, match.index); + const affectedInterlocks = findAffectedInterlocks(name, source, interlocks); + + const bypass: SafetyBypass = { + id: generateId(), + name, + location: { + file: filePath, + line, + column: match.index - source.lastIndexOf('\n', match.index), + }, + pouId: null, + affectedInterlocks, + condition: findBypassUsageCondition(name, source), + severity: 'critical', + }; + + bypasses.push(bypass); + + // Always add critical warning for bypass variables + criticalWarnings.push({ + type: 'bypass-detected', + message: `āš ļø SAFETY BYPASS VARIABLE DETECTED: ${name}`, + severity: 'critical', + location: bypass.location, + remediation: 'This variable can bypass safety interlocks. Review immediately.', + }); + } + } + + // Calculate summary + const summary = calculateSummary(interlocks, bypasses, criticalWarnings); + + return { + file: filePath, + interlocks, + bypasses, + criticalWarnings, + summary, + }; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function isUsedWithBypass(name: string, source: string): boolean { + const bypassContexts = [ + new RegExp(`\\w*[Bb]ypass\\w*\\s+OR\\s+${escapeRegex(name)}`, 'gi'), + new RegExp(`${escapeRegex(name)}\\s+OR\\s+\\w*[Bb]ypass\\w*`, 'gi'), + new RegExp(`NOT\\s+${escapeRegex(name)}\\s+OR\\s+\\w*[Bb]ypass`, 'gi'), + new RegExp(`\\w*[Dd]ebug\\w*\\s+OR\\s+${escapeRegex(name)}`, 'gi'), + new RegExp(`${escapeRegex(name)}\\s+OR\\s+\\w*[Dd]ebug\\w*`, 'gi'), + ]; + + return bypassContexts.some(pattern => pattern.test(source)); +} + +function findBypassCondition(name: string, source: string): string | null { + // Look for patterns like "IF bBypass OR bIL_Air THEN" + const patterns = [ + new RegExp(`IF\\s+(\\w*[Bb]ypass\\w*\\s+OR\\s+${escapeRegex(name)})\\s+THEN`, 'gi'), + new RegExp(`IF\\s+(${escapeRegex(name)}\\s+OR\\s+\\w*[Bb]ypass\\w*)\\s+THEN`, 'gi'), + new RegExp(`IF\\s+(NOT\\s+${escapeRegex(name)}\\s+OR\\s+\\w*[Bb]ypass\\w*)\\s+THEN`, 'gi'), + ]; + + for (const pattern of patterns) { + const match = source.match(pattern); + if (match) { + return match[1] || null; + } + } + + return null; +} + +function findBypassUsageCondition(bypassName: string, source: string): string | null { + // Find how the bypass variable is used + const pattern = new RegExp(`IF\\s+([^;]*${escapeRegex(bypassName)}[^;]*)\\s+THEN`, 'gi'); + const match = source.match(pattern); + return match ? match[1]?.trim() || null : null; +} + +function findRelatedInterlocks(name: string, source: string): string[] { + const related: string[] = []; + + // Look for interlocks used in the same IF statement + const ifPattern = new RegExp(`IF\\s+[^;]*${escapeRegex(name)}[^;]*THEN`, 'gi'); + const matches = source.match(ifPattern); + + if (matches) { + for (const match of matches) { + // Extract other interlock names from the condition + const interlockPattern = /\b(bIL_\w+|IL_\w+|b?Interlock\w*)\b/gi; + let ilMatch: RegExpExecArray | null; + + while ((ilMatch = interlockPattern.exec(match)) !== null) { + const ilName = ilMatch[1]!; + if (ilName.toLowerCase() !== name.toLowerCase() && !related.includes(ilName)) { + related.push(ilName); + } + } + } + } + + return related; +} + +function findAffectedInterlocks( + bypassName: string, + source: string, + interlocks: SafetyInterlock[] +): string[] { + const affected: string[] = []; + + // Check which interlocks are used with this bypass + for (const il of interlocks) { + const pattern = new RegExp( + `(${escapeRegex(bypassName)}\\s+OR\\s+${escapeRegex(il.name)}|${escapeRegex(il.name)}\\s+OR\\s+${escapeRegex(bypassName)})`, + 'gi' + ); + if (pattern.test(source)) { + affected.push(il.name); + } + } + + return affected; +} + +function determineSeverity(type: SafetyInterlockType, isBypassed: boolean): SafetySeverity { + if (isBypassed) return 'critical'; + + switch (type) { + case 'estop': + return 'critical'; + case 'interlock': + case 'safety-relay': + return 'high'; + case 'permissive': + case 'safety-device': + return 'medium'; + case 'bypass': + return 'critical'; + default: + return 'medium'; + } +} + +function calculateSummary( + interlocks: SafetyInterlock[], + bypasses: SafetyBypass[], + warnings: SafetyCriticalWarning[] +): SafetySummary { + const byType: Record = { + 'interlock': 0, + 'permissive': 0, + 'estop': 0, + 'safety-relay': 0, + 'safety-device': 0, + 'bypass': 0, + }; + + for (const il of interlocks) { + byType[il.type]++; + } + byType['bypass'] = bypasses.length; + + return { + totalInterlocks: interlocks.length, + byType, + bypassCount: bypasses.length, + criticalWarningCount: warnings.filter(w => w.severity === 'critical').length, + }; +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +export function extractSafetyFromFiles( + files: Array<{ path: string; content: string }>, + options?: SafetyExtractionOptions +): SafetyAnalysisResult { + const allInterlocks: SafetyInterlock[] = []; + const allBypasses: SafetyBypass[] = []; + const allWarnings: SafetyCriticalWarning[] = []; + + for (const file of files) { + const result = extractSafetyInterlocks(file.content, file.path, options); + allInterlocks.push(...result.interlocks); + allBypasses.push(...result.bypasses); + allWarnings.push(...result.criticalWarnings); + } + + const summary: SafetySummary = { + totalInterlocks: allInterlocks.length, + byType: { + 'interlock': allInterlocks.filter(i => i.type === 'interlock').length, + 'permissive': allInterlocks.filter(i => i.type === 'permissive').length, + 'estop': allInterlocks.filter(i => i.type === 'estop').length, + 'safety-relay': allInterlocks.filter(i => i.type === 'safety-relay').length, + 'safety-device': allInterlocks.filter(i => i.type === 'safety-device').length, + 'bypass': allBypasses.length, + }, + bypassCount: allBypasses.length, + criticalWarningCount: allWarnings.filter(w => w.severity === 'critical').length, + }; + + return { + interlocks: allInterlocks, + bypasses: allBypasses, + criticalWarnings: allWarnings, + summary, + }; +} diff --git a/packages/core/src/iec61131/extractors/state-machine-extractor.ts b/packages/core/src/iec61131/extractors/state-machine-extractor.ts new file mode 100644 index 00000000..e34f34a2 --- /dev/null +++ b/packages/core/src/iec61131/extractors/state-machine-extractor.ts @@ -0,0 +1,606 @@ +/** + * State Machine Extractor + * + * Detects and analyzes CASE-based state machines in IEC 61131-3 code. + * Generates visualizations (Mermaid, ASCII) and verification data. + */ + +import type { + StateMachine, + StateMachineState, + StateMachineTransition, + StateMachineVerification, + StateMachineVisualizations, +} from '../types.js'; +import { generateId } from '../utils/id-generator.js'; + +// ============================================================================ +// EXTRACTION RESULT +// ============================================================================ + +export interface StateMachineExtractionResult { + stateMachines: ExtractedStateMachine[]; + summary: StateMachineSummary; +} + +export interface ExtractedStateMachine extends StateMachine { + file: string; +} + +export interface StateMachineSummary { + total: number; + totalStates: number; + byVariable: Record; + withDeadlocks: number; + withGaps: number; +} + +export interface StateMachineExtractionOptions { + generateDiagrams?: boolean; + includeTransitions?: boolean; + minStates?: number; +} + +// ============================================================================ +// PATTERNS +// ============================================================================ + +// State variable naming patterns +const STATE_VAR_PATTERNS = [ + /^n?state$/i, + /^i?step$/i, + /^n?mode$/i, + /^n?phase$/i, + /^seq/i, + /state$/i, + /step$/i, + /^nSeq/i, + /^iState/i, +]; + +// CASE statement pattern +const CASE_PATTERN = /CASE\s+(\w+)\s+OF/gi; + +// ============================================================================ +// EXTRACTOR +// ============================================================================ + +export function extractStateMachines( + source: string, + filePath: string, + pouName: string = 'UNKNOWN', + options: StateMachineExtractionOptions = {} +): StateMachineExtractionResult { + const { + generateDiagrams = true, + includeTransitions = true, + minStates = 2, + } = options; + + const stateMachines: ExtractedStateMachine[] = []; + let match: RegExpExecArray | null; + + // Reset regex + CASE_PATTERN.lastIndex = 0; + + while ((match = CASE_PATTERN.exec(source)) !== null) { + const variable = match[1]!; + + // Only process if it looks like a state variable + if (!isStateVariable(variable)) continue; + + const caseStart = match.index; + const line = getLineNumber(source, caseStart); + + // Find END_CASE + const afterCase = source.slice(caseStart); + const endMatch = afterCase.match(/END_CASE/i); + const caseBody = endMatch + ? afterCase.slice(0, endMatch.index! + 8) + : afterCase.slice(0, 3000); + const endLine = line + caseBody.split('\n').length - 1; + + // Extract states + const states = extractStates(caseBody, line, filePath); + + // Skip if too few states + if (states.length < minStates) continue; + + // Extract transitions + const transitions = includeTransitions + ? extractTransitions(caseBody, states, variable, line, filePath) + : []; + + // Verify state machine + const verification = verifyStateMachine(states, transitions); + + // Generate visualizations + const visualizations = generateDiagrams + ? generateVisualizations(variable, states, transitions, pouName) + : { mermaid: '', ascii: '' }; + + const stateMachine: ExtractedStateMachine = { + id: generateId(), + file: filePath, + name: `${pouName}_${variable}`, + pouId: '', + pouName, + stateVariable: variable, + stateVariableType: 'INT', + states, + transitions, + location: { + file: filePath, + line, + column: 1, + endLine, + }, + verification, + visualizations, + }; + + stateMachines.push(stateMachine); + } + + const summary = calculateSummary(stateMachines); + return { stateMachines, summary }; +} + +// ============================================================================ +// STATE EXTRACTION +// ============================================================================ + +function extractStates( + caseBody: string, + baseLine: number, + filePath: string +): StateMachineState[] { + const states: StateMachineState[] = []; + const lines = caseBody.split('\n'); + + // Pattern for state labels: "10:" or "STATE_IDLE:" or "0: (* comment *)" + const statePattern = /^\s*(\d+|[\w_]+)\s*:\s*(?:\(\*\s*(.*?)\s*\*\))?/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const stateMatch = line.match(statePattern); + + if (stateMatch) { + const rawValue = stateMatch[1]!; + const value = /^\d+$/.test(rawValue) ? parseInt(rawValue, 10) : rawValue; + const inlineComment = stateMatch[2] || null; + + // Look for comment on next line if not inline + let documentation = inlineComment; + if (!documentation && i + 1 < lines.length) { + const nextLine = lines[i + 1]!; + const commentMatch = nextLine.match(/^\s*\(\*\s*(.*?)\s*\*\)/); + if (commentMatch) { + documentation = commentMatch[1] || null; + } + } + + // Infer state name from value or comment + const name = inferStateName(value, documentation); + + // Detect if initial (value 0 or named IDLE/INIT) + const isInitial = value === 0 || + (typeof value === 'string' && /^(IDLE|INIT|START|READY)/i.test(value)); + + // Detect if final (named DONE/COMPLETE/FINISHED) + const isFinal = typeof value === 'string' && + /^(DONE|COMPLETE|FINISHED|END|STOP)/i.test(value); + + // Extract actions in this state + const actions = extractStateActions(lines, i); + + states.push({ + id: generateId(), + value, + name, + documentation, + isInitial, + isFinal, + actions, + location: { + file: filePath, + line: baseLine + i, + column: 1, + }, + }); + } + } + + return states; +} + +function inferStateName(value: number | string, documentation: string | null): string | null { + // If value is already a name, use it + if (typeof value === 'string') { + return value.replace(/_/g, ' '); + } + + // Try to extract from documentation + if (documentation) { + // "State 10: Filling" -> "Filling" + const match = documentation.match(/(?:state\s*\d*\s*[-:]?\s*)?(.+)/i); + if (match) { + return match[1]!.trim(); + } + } + + // Common state value conventions + const commonNames: Record = { + 0: 'Idle', + 10: 'Initialize', + 20: 'Ready', + 100: 'Complete', + 999: 'Fault', + 90: 'Stopping', + }; + + return commonNames[value] || null; +} + +function extractStateActions(lines: string[], stateLineIndex: number): string[] { + const actions: string[] = []; + + // Look at lines after state label until next state or END_CASE + for (let i = stateLineIndex + 1; i < lines.length; i++) { + const line = lines[i]!.trim(); + + // Stop at next state or END_CASE + if (/^\d+\s*:/.test(line) || /^[\w_]+\s*:/.test(line) || /END_CASE/i.test(line)) { + break; + } + + // Skip empty lines and comments + if (!line || line.startsWith('(*')) continue; + + // Capture assignments and function calls + if (line.includes(':=') || line.includes('(')) { + actions.push(line.replace(/;$/, '')); + } + } + + return actions.slice(0, 5); // Limit to first 5 actions +} + +// ============================================================================ +// TRANSITION EXTRACTION +// ============================================================================ + +function extractTransitions( + caseBody: string, + states: StateMachineState[], + stateVar: string, + baseLine: number, + filePath: string +): StateMachineTransition[] { + const transitions: StateMachineTransition[] = []; + const lines = caseBody.split('\n'); + + // Build state value to ID map + const stateMap = new Map(); + for (const state of states) { + stateMap.set(String(state.value), state.id); + } + + let currentStateId: string | null = null; + let currentStateLine = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + + // Track current state + const stateMatch = line.match(/^\s*(\d+|[\w_]+)\s*:/); + if (stateMatch) { + currentStateId = stateMap.get(stateMatch[1]!) || null; + currentStateLine = i; + continue; + } + + if (!currentStateId) continue; + + // Look for state assignments + const assignPattern = new RegExp(`${stateVar}\\s*:=\\s*(\\d+|[\\w_]+)\\s*;`, 'gi'); + let assignMatch: RegExpExecArray | null; + + while ((assignMatch = assignPattern.exec(line)) !== null) { + const targetValue = assignMatch[1]!; + const targetStateId = stateMap.get(targetValue); + + if (targetStateId && targetStateId !== currentStateId) { + // Extract guard condition (IF before assignment) + const guard = extractGuard(lines, i, currentStateLine); + + transitions.push({ + id: generateId(), + fromStateId: currentStateId, + toStateId: targetStateId, + guard, + actions: [], + documentation: null, + location: { + file: filePath, + line: baseLine + i, + column: 1, + }, + }); + } + } + } + + return transitions; +} + +function extractGuard(lines: string[], assignLine: number, stateStartLine: number): string | null { + // Look backwards for IF condition + for (let i = assignLine; i >= stateStartLine; i--) { + const line = lines[i]!.trim(); + + const ifMatch = line.match(/IF\s+(.+?)\s+THEN/i); + if (ifMatch) { + return ifMatch[1]!.trim(); + } + + const elsifMatch = line.match(/ELSIF\s+(.+?)\s+THEN/i); + if (elsifMatch) { + return elsifMatch[1]!.trim(); + } + } + + return null; +} + +// ============================================================================ +// VERIFICATION +// ============================================================================ + +function verifyStateMachine( + states: StateMachineState[], + transitions: StateMachineTransition[] +): StateMachineVerification { + // Check for gaps in numeric states + const numericValues = states + .map(s => s.value) + .filter((v): v is number => typeof v === 'number') + .sort((a, b) => a - b); + + const { hasGaps, gapValues } = analyzeGaps(numericValues); + + // Check for unreachable states (no incoming transitions except initial) + const reachable = new Set(); + const initial = states.find(s => s.isInitial); + if (initial) reachable.add(initial.id); + + for (const trans of transitions) { + reachable.add(trans.toStateId); + } + + const unreachableStates = states + .filter(s => !s.isInitial && !reachable.has(s.id)) + .map(s => s.name || String(s.value)); + + // Check for deadlocks (states with no outgoing transitions except final) + const hasOutgoing = new Set(); + for (const trans of transitions) { + hasOutgoing.add(trans.fromStateId); + } + + const deadlockStates = states + .filter(s => !s.isFinal && !hasOutgoing.has(s.id)) + .map(s => s.name || String(s.value)); + + // Check for missing transitions (states that should connect but don't) + const missingTransitions: string[] = []; + // This is a simplified check - could be more sophisticated + + return { + hasDeadlocks: deadlockStates.length > 0, + unreachableStates, + missingTransitions, + hasGaps, + gapValues, + }; +} + +function analyzeGaps(numericStates: number[]): { hasGaps: boolean; gapValues: number[] } { + if (numericStates.length < 2) { + return { hasGaps: false, gapValues: [] }; + } + + const gapValues: number[] = []; + const min = numericStates[0]!; + const max = numericStates[numericStates.length - 1]!; + const stateSet = new Set(numericStates); + + // Only check for gaps if states are reasonably sequential + const avgGap = (max - min) / (numericStates.length - 1); + + if (avgGap <= 2) { + for (let i = min; i <= max; i++) { + if (!stateSet.has(i)) { + gapValues.push(i); + } + } + } + + return { + hasGaps: gapValues.length > 0, + gapValues, + }; +} + +// ============================================================================ +// VISUALIZATION +// ============================================================================ + +function generateVisualizations( + variable: string, + states: StateMachineState[], + transitions: StateMachineTransition[], + pouName: string +): StateMachineVisualizations { + return { + mermaid: generateMermaidDiagram(variable, states, transitions, pouName), + ascii: generateAsciiDiagram(variable, states, transitions), + }; +} + +function generateMermaidDiagram( + variable: string, + states: StateMachineState[], + transitions: StateMachineTransition[], + pouName: string +): string { + const lines: string[] = [ + 'stateDiagram-v2', + ` %% State Machine: ${pouName}.${variable}`, + ]; + + // Build state ID map + const stateIdMap = new Map(); + for (let i = 0; i < states.length; i++) { + const state = states[i]!; + const mermaidId = `s${i}`; + stateIdMap.set(state.id, mermaidId); + + const label = state.name || `State_${state.value}`; + lines.push(` ${mermaidId}: ${label}`); + + if (state.documentation) { + lines.push(` note right of ${mermaidId}: ${state.documentation.slice(0, 50)}`); + } + } + + // Initial state + const initial = states.find(s => s.isInitial); + if (initial) { + lines.push(` [*] --> ${stateIdMap.get(initial.id)}`); + } + + // Transitions + for (const trans of transitions) { + const from = stateIdMap.get(trans.fromStateId); + const to = stateIdMap.get(trans.toStateId); + if (from && to) { + const guard = trans.guard ? ` : ${trans.guard.slice(0, 30)}` : ''; + lines.push(` ${from} --> ${to}${guard}`); + } + } + + // Final states + for (const state of states.filter(s => s.isFinal)) { + const mermaidId = stateIdMap.get(state.id); + if (mermaidId) { + lines.push(` ${mermaidId} --> [*]`); + } + } + + return lines.join('\n'); +} + +function generateAsciiDiagram( + variable: string, + states: StateMachineState[], + transitions: StateMachineTransition[] +): string { + const lines: string[] = [ + `State Machine: ${variable}`, + '=' .repeat(40), + '', + ]; + + // List states + lines.push('States:'); + for (const state of states) { + const markers: string[] = []; + if (state.isInitial) markers.push('INITIAL'); + if (state.isFinal) markers.push('FINAL'); + const markerStr = markers.length > 0 ? ` [${markers.join(', ')}]` : ''; + + const name = state.name || `State_${state.value}`; + lines.push(` ${state.value}: ${name}${markerStr}`); + + if (state.documentation) { + lines.push(` "${state.documentation.slice(0, 50)}"`); + } + } + + lines.push(''); + lines.push('Transitions:'); + + // Build state value map + const stateValueMap = new Map(); + for (const state of states) { + stateValueMap.set(state.id, state.value); + } + + for (const trans of transitions) { + const from = stateValueMap.get(trans.fromStateId); + const to = stateValueMap.get(trans.toStateId); + const guard = trans.guard ? ` [${trans.guard.slice(0, 30)}]` : ''; + lines.push(` ${from} --> ${to}${guard}`); + } + + return lines.join('\n'); +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +function isStateVariable(name: string): boolean { + return STATE_VAR_PATTERNS.some(p => p.test(name)); +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function calculateSummary(stateMachines: ExtractedStateMachine[]): StateMachineSummary { + const byVariable: Record = {}; + let totalStates = 0; + let withDeadlocks = 0; + let withGaps = 0; + + for (const sm of stateMachines) { + byVariable[sm.stateVariable] = (byVariable[sm.stateVariable] || 0) + 1; + totalStates += sm.states.length; + if (sm.verification.hasDeadlocks) withDeadlocks++; + if (sm.verification.hasGaps) withGaps++; + } + + return { + total: stateMachines.length, + totalStates, + byVariable, + withDeadlocks, + withGaps, + }; +} + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +export function extractStateMachinesFromFiles( + files: Array<{ path: string; content: string; pouName?: string }>, + options?: StateMachineExtractionOptions +): StateMachineExtractionResult { + const allMachines: ExtractedStateMachine[] = []; + + for (const file of files) { + const result = extractStateMachines( + file.content, + file.path, + file.pouName || 'UNKNOWN', + options + ); + allMachines.push(...result.stateMachines); + } + + const summary = calculateSummary(allMachines); + return { stateMachines: allMachines, summary }; +} diff --git a/packages/core/src/iec61131/extractors/tribal-knowledge-extractor.ts b/packages/core/src/iec61131/extractors/tribal-knowledge-extractor.ts new file mode 100644 index 00000000..6ff5cfab --- /dev/null +++ b/packages/core/src/iec61131/extractors/tribal-knowledge-extractor.ts @@ -0,0 +1,439 @@ +/** + * Tribal Knowledge Extractor + * + * Extracts institutional knowledge embedded in IEC 61131-3 code comments. + * This captures the "why" behind code decisions that would otherwise be lost. + * + * Detects: + * - Warnings, dangers, cautions + * - Workarounds and hacks + * - Historical context + * - Equipment-specific notes + * - Magic numbers and mysteries + * - TODO/FIXME items + * - "Do not change" warnings + */ + +import type { + TribalKnowledgeItem, + TribalKnowledgeType, + TribalKnowledgeImportance, +} from '../types.js'; +import { generateId } from '../utils/id-generator.js'; + +// ============================================================================ +// EXTRACTION RESULT +// ============================================================================ + +export interface TribalKnowledgeExtractionResult { + items: ExtractedTribalKnowledge[]; + summary: TribalKnowledgeSummary; +} + +export interface ExtractedTribalKnowledge extends TribalKnowledgeItem { + file: string; +} + +export interface TribalKnowledgeSummary { + total: number; + byType: Record; + byImportance: Record; + criticalCount: number; +} + +export interface TribalKnowledgeExtractionOptions { + includeContext?: boolean; + contextLines?: number; +} + +// ============================================================================ +// PATTERNS +// ============================================================================ + +interface KnowledgePattern { + pattern: RegExp; + type: TribalKnowledgeType; + importance: TribalKnowledgeImportance; +} + +const KNOWLEDGE_PATTERNS: KnowledgePattern[] = [ + // Critical warnings + { + pattern: /DANGER\s*[:\-!]?\s*(.*)/gi, + type: 'danger' as TribalKnowledgeType, + importance: 'critical' + }, + { + pattern: /WARNING\s*[:\-!]?\s*(.*)/gi, + type: 'warning', + importance: 'high' + }, + { + pattern: /CAUTION\s*[:\-!]?\s*(.*)/gi, + type: 'caution' as TribalKnowledgeType, + importance: 'high' + }, + + // Do not change warnings + { + pattern: /DO\s+NOT\s+(CHANGE|MODIFY|REMOVE|DELETE|TOUCH)[^*\n]*/gi, + type: 'do-not-change', + importance: 'critical' + }, + { + pattern: /DON'?T\s+(CHANGE|MODIFY|REMOVE|DELETE|TOUCH)[^*\n]*/gi, + type: 'do-not-change', + importance: 'critical' + }, + { + pattern: /NEVER\s+(CHANGE|MODIFY|REMOVE|DELETE)[^*\n]*/gi, + type: 'do-not-change', + importance: 'critical' + }, + + // Workarounds and hacks + { + pattern: /WORKAROUND\s*[:\-!]?\s*(.*)/gi, + type: 'workaround', + importance: 'high' + }, + { + pattern: /HACK\s*[:\-!]?\s*(.*)/gi, + type: 'hack', + importance: 'high' + }, + { + pattern: /KLUDGE\s*[:\-!]?\s*(.*)/gi, + type: 'hack', + importance: 'high' + }, + { + pattern: /BODGE\s*[:\-!]?\s*(.*)/gi, + type: 'hack', + importance: 'high' + }, + + // Notes and explanations + { + pattern: /NOTE\s*[:\-!]?\s*(.*)/gi, + type: 'note', + importance: 'medium' + }, + { + pattern: /IMPORTANT\s*[:\-!]?\s*(.*)/gi, + type: 'note', + importance: 'high' + }, + + // TODO/FIXME + { + pattern: /TODO\s*[:\-!]?\s*(.*)/gi, + type: 'todo', + importance: 'medium' + }, + { + pattern: /FIXME\s*[:\-!]?\s*(.*)/gi, + type: 'fixme', + importance: 'high' + }, + { + pattern: /BUG\s*[:\-!]?\s*(.*)/gi, + type: 'fixme', + importance: 'high' + }, + { + pattern: /XXX\s*[:\-!]?\s*(.*)/gi, + type: 'fixme', + importance: 'high' + }, + + // Equipment-specific + { + pattern: /EQUIPMENT\s*[:\-!]?\s*(.*)/gi, + type: 'equipment', + importance: 'medium' + }, + { + pattern: /MACHINE\s*[:\-!]?\s*(.*)/gi, + type: 'equipment', + importance: 'medium' + }, + { + pattern: /VENDOR\s*[:\-!]?\s*(.*)/gi, + type: 'equipment', + importance: 'medium' + }, + + // Magic numbers + { + pattern: /MAGIC\s*(?:NUMBER)?\s*[:\-!]?\s*(.*)/gi, + type: 'magic-number', + importance: 'medium' + }, + { + pattern: /WHY\s+(\d+(?:\.\d+)?)\s*\?/gi, + type: 'magic-number', + importance: 'medium' + }, + + // Mystery/unknown + { + pattern: /MYSTERY\s*[:\-!]?\s*(.*)/gi, + type: 'mystery', + importance: 'medium' + }, + { + pattern: /UNKNOWN\s*[:\-!]?\s*(.*)/gi, + type: 'mystery', + importance: 'medium' + }, + { + pattern: /NOT\s+SURE\s+WHY[^*\n]*/gi, + type: 'mystery', + importance: 'medium' + }, + { + pattern: /DON'?T\s+KNOW\s+WHY[^*\n]*/gi, + type: 'mystery', + importance: 'medium' + }, + + // History entries + { + pattern: /(\d{4}[-/]\d{2}[-/]\d{2})\s*[-:]?\s*(\w+)?\s*[-:]?\s*(.*)/g, + type: 'history', + importance: 'low' + }, + + // Author attribution + { + pattern: /(?:Auth(?:or)?|By|Written\s+by)\s*[:\-]?\s*(\w+(?:\s+\w+)?)/gi, + type: 'author', + importance: 'low' + }, +]; + +// ============================================================================ +// EXTRACTOR +// ============================================================================ + +export function extractTribalKnowledge( + source: string, + filePath: string, + options: TribalKnowledgeExtractionOptions = {} +): TribalKnowledgeExtractionResult { + const { includeContext = true, contextLines = 3 } = options; + + const items: ExtractedTribalKnowledge[] = []; + const lines = source.split('\n'); + const seen = new Set(); + + // Extract from patterns + for (const { pattern, type, importance } of KNOWLEDGE_PATTERNS) { + let match: RegExpExecArray | null; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const content = match[0].trim(); + const contentKey = `${type}:${content.toLowerCase().slice(0, 50)}`; + + // Skip duplicates + if (seen.has(contentKey)) continue; + seen.add(contentKey); + + const line = getLineNumber(source, match.index); + const context = includeContext + ? extractContext(lines, line - 1, contextLines) + : ''; + + items.push({ + id: generateId(), + file: filePath, + type, + content, + context, + location: { + file: filePath, + line, + column: match.index - source.lastIndexOf('\n', match.index), + }, + pouId: null, + importance, + extractedAt: new Date().toISOString(), + }); + } + } + + // Look for unexplained magic numbers + const magicNumbers = findMagicNumbers(source, filePath, lines, contextLines); + for (const mn of magicNumbers) { + const contentKey = `magic-number:${mn.content.toLowerCase()}`; + if (!seen.has(contentKey)) { + seen.add(contentKey); + items.push(mn); + } + } + + // Sort by importance and line number + items.sort((a, b) => { + const importanceOrder: Record = { + 'critical': 0, + 'high': 1, + 'medium': 2, + 'low': 3, + }; + const impDiff = importanceOrder[a.importance] - importanceOrder[b.importance]; + if (impDiff !== 0) return impDiff; + return a.location.line - b.location.line; + }); + + const summary = calculateSummary(items); + return { items, summary }; +} + +// ============================================================================ +// MAGIC NUMBER DETECTION +// ============================================================================ + +function findMagicNumbers( + source: string, + filePath: string, + lines: string[], + contextLines: number +): ExtractedTribalKnowledge[] { + const magicNumbers: ExtractedTribalKnowledge[] = []; + + // Look for numeric assignments without comments + const assignPattern = /(\w+)\s*:=\s*(\d+(?:\.\d+)?)\s*;(?!\s*\(\*)/gm; + let match: RegExpExecArray | null; + + while ((match = assignPattern.exec(source)) !== null) { + const varName = match[1]!; + const value = match[2]!; + const numValue = parseFloat(value); + + // Skip common non-magic values + if (isCommonValue(numValue)) continue; + + // Skip if there's a comment on the same line + const lineNum = getLineNumber(source, match.index); + const lineContent = lines[lineNum - 1] || ''; + if (lineContent.includes('(*') || lineContent.includes('//')) continue; + + // Skip if variable name is self-documenting + if (isSelfDocumenting(varName, numValue)) continue; + + // This looks like a magic number + const context = extractContext(lines, lineNum - 1, contextLines); + + magicNumbers.push({ + id: generateId(), + file: filePath, + type: 'magic-number', + content: `${varName} := ${value} (unexplained constant)`, + context, + location: { + file: filePath, + line: lineNum, + column: match.index - source.lastIndexOf('\n', match.index), + }, + pouId: null, + importance: 'medium', + extractedAt: new Date().toISOString(), + }); + } + + return magicNumbers; +} + +function isCommonValue(value: number): boolean { + // Common non-magic values + const common = [0, 1, 2, 10, 100, 1000, 0.0, 1.0, 100.0]; + return common.includes(value); +} + +function isSelfDocumenting(varName: string, value: number): boolean { + const lower = varName.toLowerCase(); + + // Variable name contains the value + if (lower.includes(String(Math.floor(value)))) return true; + + // Common self-documenting patterns + if (lower.includes('max') || lower.includes('min')) return true; + if (lower.includes('count') || lower.includes('limit')) return true; + if (lower.includes('timeout') || lower.includes('delay')) return true; + if (lower.includes('default') || lower.includes('init')) return true; + + return false; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +function extractContext(lines: string[], lineIndex: number, contextLines: number): string { + const start = Math.max(0, lineIndex - contextLines); + const end = Math.min(lines.length, lineIndex + contextLines + 1); + return lines.slice(start, end).join('\n'); +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function calculateSummary(items: ExtractedTribalKnowledge[]): TribalKnowledgeSummary { + const byType: Record = { + 'warning': 0, + 'caution': 0, + 'danger': 0, + 'note': 0, + 'todo': 0, + 'fixme': 0, + 'hack': 0, + 'workaround': 0, + 'do-not-change': 0, + 'magic-number': 0, + 'history': 0, + 'author': 0, + 'equipment': 0, + 'mystery': 0, + }; + + const byImportance: Record = { + 'critical': 0, + 'high': 0, + 'medium': 0, + 'low': 0, + }; + + for (const item of items) { + byType[item.type] = (byType[item.type] || 0) + 1; + byImportance[item.importance]++; + } + + return { + total: items.length, + byType, + byImportance, + criticalCount: byImportance['critical'], + }; +} + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +export function extractTribalKnowledgeFromFiles( + files: Array<{ path: string; content: string }>, + options?: TribalKnowledgeExtractionOptions +): TribalKnowledgeExtractionResult { + const allItems: ExtractedTribalKnowledge[] = []; + + for (const file of files) { + const result = extractTribalKnowledge(file.content, file.path, options); + allItems.push(...result.items); + } + + const summary = calculateSummary(allItems); + return { items: allItems, summary }; +} diff --git a/packages/core/src/iec61131/extractors/variable-extractor.ts b/packages/core/src/iec61131/extractors/variable-extractor.ts new file mode 100644 index 00000000..35c21310 --- /dev/null +++ b/packages/core/src/iec61131/extractors/variable-extractor.ts @@ -0,0 +1,457 @@ +/** + * Variable Extractor + * + * Extracts all variables from IEC 61131-3 code with full metadata. + * Handles all variable sections and I/O address mappings. + */ + +import type { + STVariable, + VariableSection, + IOMapping, + IOAddressType, +} from '../types.js'; +import { generateId } from '../utils/id-generator.js'; + +// ============================================================================ +// EXTRACTION RESULT +// ============================================================================ + +export interface VariableExtractionResult { + variables: ExtractedVariable[]; + ioMappings: IOMapping[]; + summary: VariableSummary; +} + +export interface ExtractedVariable extends STVariable { + file: string; +} + +export interface VariableSummary { + total: number; + bySection: Record; + withComments: number; + withIOAddress: number; + safetyCritical: number; +} + +export interface VariableExtractionOptions { + extractIO?: boolean; + detectSafety?: boolean; +} + +// ============================================================================ +// PATTERNS +// ============================================================================ + +// Variable section patterns +const VAR_SECTION_PATTERNS: Array<{ pattern: RegExp; section: VariableSection }> = [ + { pattern: /VAR_INPUT\b/gi, section: 'VAR_INPUT' }, + { pattern: /VAR_OUTPUT\b/gi, section: 'VAR_OUTPUT' }, + { pattern: /VAR_IN_OUT\b/gi, section: 'VAR_IN_OUT' }, + { pattern: /VAR_GLOBAL\b/gi, section: 'VAR_GLOBAL' }, + { pattern: /VAR_TEMP\b/gi, section: 'VAR_TEMP' }, + { pattern: /VAR_CONSTANT\b/gi, section: 'VAR_CONSTANT' }, + { pattern: /VAR_EXTERNAL\b/gi, section: 'VAR_EXTERNAL' }, + { pattern: /VAR\b(?!_)/gi, section: 'VAR' }, +]; + +// I/O address pattern +const IO_ADDRESS_PATTERN = /%([IQM])([XBWD]?)(\d+(?:\.\d+)?)/gi; + +// Safety variable patterns +const SAFETY_PATTERNS = [ + /^bIL_/i, + /^IL_/i, + /Interlock/i, + /Permissive/i, + /EStop/i, + /E_Stop/i, + /EmergencyStop/i, + /Safety/i, + /Bypass/i, +]; + +// ============================================================================ +// EXTRACTOR +// ============================================================================ + +export function extractVariables( + source: string, + filePath: string, + options: VariableExtractionOptions = {} +): VariableExtractionResult { + const { extractIO = true, detectSafety = true } = options; + + const variables: ExtractedVariable[] = []; + const ioMappings: IOMapping[] = []; + + // Find all variable sections + const sections = findVariableSections(source); + + for (const section of sections) { + const sectionVars = parseVariableSection( + section.content, + section.section, + section.startLine, + filePath, + detectSafety + ); + variables.push(...sectionVars); + } + + // Extract I/O mappings if requested + if (extractIO) { + const ios = extractIOAddresses(source, filePath); + ioMappings.push(...ios); + } + + const summary = calculateSummary(variables, ioMappings); + return { variables, ioMappings, summary }; +} + +// ============================================================================ +// SECTION FINDING +// ============================================================================ + +interface VariableSectionInfo { + section: VariableSection; + content: string; + startLine: number; + endLine: number; +} + +function findVariableSections(source: string): VariableSectionInfo[] { + const sections: VariableSectionInfo[] = []; + const lines = source.split('\n'); + + let currentSection: VariableSection | null = null; + let sectionStart = 0; + let sectionContent: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const lineUpper = line.toUpperCase().trim(); + + // Check for section start + for (const { pattern, section } of VAR_SECTION_PATTERNS) { + if (pattern.test(line)) { + // Save previous section if exists + if (currentSection && sectionContent.length > 0) { + sections.push({ + section: currentSection, + content: sectionContent.join('\n'), + startLine: sectionStart, + endLine: i, + }); + } + + currentSection = section; + sectionStart = i + 1; + sectionContent = []; + break; + } + } + + // Check for section end + if (lineUpper.includes('END_VAR')) { + if (currentSection && sectionContent.length > 0) { + sections.push({ + section: currentSection, + content: sectionContent.join('\n'), + startLine: sectionStart, + endLine: i + 1, + }); + } + currentSection = null; + sectionContent = []; + continue; + } + + // Accumulate content + if (currentSection) { + sectionContent.push(line); + } + } + + return sections; +} + +// ============================================================================ +// VARIABLE PARSING +// ============================================================================ + +function parseVariableSection( + content: string, + section: VariableSection, + startLine: number, + filePath: string, + detectSafety: boolean +): ExtractedVariable[] { + const variables: ExtractedVariable[] = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + + // Skip empty lines and comments + if (!line || line.startsWith('(*') || line.startsWith('//')) continue; + + // Skip modifiers + if (/^(CONSTANT|RETAIN|PERSISTENT)\s*$/i.test(line)) continue; + + const variable = parseVariableLine(line, section, startLine + i, filePath, detectSafety); + if (variable) { + variables.push(variable); + } + } + + return variables; +} + +function parseVariableLine( + line: string, + section: VariableSection, + lineNum: number, + filePath: string, + detectSafety: boolean +): ExtractedVariable | null { + // Pattern: name [AT %address] : type [:= value] ; (* comment *) + const pattern = /^(\w+)\s*(?:AT\s+(%[IQM][XBWD]?\d+(?:\.\d+)?))?\s*:\s*(\w+(?:\s*\[\s*\d+\s*(?:\.\.\s*\d+)?\s*\])?(?:\s+OF\s+\w+)?)\s*(?::=\s*([^;]+))?\s*;?\s*(?:\(\*\s*(.*?)\s*\*\))?/i; + + const match = line.match(pattern); + if (!match) { + // Try simpler pattern + const simplePattern = /^(\w+)\s*:\s*(\w+)/; + const simpleMatch = line.match(simplePattern); + if (!simpleMatch) return null; + + return { + id: generateId(), + file: filePath, + name: simpleMatch[1]!, + dataType: simpleMatch[2]!, + section, + initialValue: null, + comment: null, + isArray: false, + arrayBounds: null, + isSafetyCritical: detectSafety && isSafetyVariable(simpleMatch[1]!), + ioAddress: null, + location: { + file: filePath, + line: lineNum, + column: 1, + }, + pouId: null, + }; + } + + const name = match[1]!; + const ioAddress = match[2] || null; + const dataType = match[3]!; + const initialValue = match[4]?.trim() || null; + const comment = match[5]?.trim() || null; + + // Check for array + const isArray = /ARRAY\s*\[/i.test(dataType) || /\[\s*\d+/.test(dataType); + const arrayBounds = isArray ? parseArrayBounds(dataType) : null; + + return { + id: generateId(), + file: filePath, + name, + dataType: cleanDataType(dataType), + section, + initialValue, + comment, + isArray, + arrayBounds, + isSafetyCritical: detectSafety && isSafetyVariable(name), + ioAddress, + location: { + file: filePath, + line: lineNum, + column: 1, + }, + pouId: null, + }; +} + +function parseArrayBounds(dataType: string): { dimensions: Array<{ lower: number; upper: number }> } | null { + const boundsPattern = /\[\s*(\d+)\s*\.\.\s*(\d+)\s*\]/g; + const dimensions: Array<{ lower: number; upper: number }> = []; + + let match: RegExpExecArray | null; + while ((match = boundsPattern.exec(dataType)) !== null) { + dimensions.push({ + lower: parseInt(match[1]!, 10), + upper: parseInt(match[2]!, 10), + }); + } + + // Simple array [n] + const simplePattern = /\[\s*(\d+)\s*\]/g; + while ((match = simplePattern.exec(dataType)) !== null) { + dimensions.push({ + lower: 0, + upper: parseInt(match[1]!, 10) - 1, + }); + } + + return dimensions.length > 0 ? { dimensions } : null; +} + +function cleanDataType(dataType: string): string { + // Remove array bounds for cleaner type + return dataType + .replace(/ARRAY\s*\[[^\]]+\]\s*OF\s*/gi, '') + .replace(/\[[^\]]+\]/g, '') + .trim(); +} + +function isSafetyVariable(name: string): boolean { + return SAFETY_PATTERNS.some(p => p.test(name)); +} + +// ============================================================================ +// I/O ADDRESS EXTRACTION +// ============================================================================ + +function extractIOAddresses(source: string, filePath: string): IOMapping[] { + const mappings: IOMapping[] = []; + const seen = new Set(); + + // Find all I/O addresses + let match: RegExpExecArray | null; + const pattern = new RegExp(IO_ADDRESS_PATTERN.source, IO_ADDRESS_PATTERN.flags); + + while ((match = pattern.exec(source)) !== null) { + const fullAddress = match[0]; + + if (seen.has(fullAddress)) continue; + seen.add(fullAddress); + + const areaType = match[1]!; // I, Q, or M + const sizeType = match[2] || 'X'; // X, B, W, D + + const addressType = `${areaType}${sizeType}` as IOAddressType; + const isInput = areaType === 'I'; + const bitSize = getBitSize(sizeType); + + // Try to find associated variable name + const varName = findAssociatedVariable(source, fullAddress, match.index); + + const line = getLineNumber(source, match.index); + + mappings.push({ + id: generateId(), + address: fullAddress, + addressType, + variableName: varName, + description: null, + location: { + file: filePath, + line, + column: match.index - source.lastIndexOf('\n', match.index), + }, + pouId: null, + isInput, + bitSize, + }); + } + + return mappings; +} + +function getBitSize(sizeType: string): number { + switch (sizeType.toUpperCase()) { + case 'X': return 1; + case 'B': return 8; + case 'W': return 16; + case 'D': return 32; + default: return 1; + } +} + +function findAssociatedVariable(source: string, _address: string, addressIndex: number): string | null { + // Look backwards for variable name + const beforeAddress = source.slice(Math.max(0, addressIndex - 100), addressIndex); + + // Pattern: varName AT %address + const atPattern = /(\w+)\s+AT\s*$/i; + const atMatch = beforeAddress.match(atPattern); + if (atMatch) { + return atMatch[1]!; + } + + // Pattern: varName := %address + const assignPattern = /(\w+)\s*:=\s*$/; + const assignMatch = beforeAddress.match(assignPattern); + if (assignMatch) { + return assignMatch[1]!; + } + + return null; +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +// ============================================================================ +// SUMMARY +// ============================================================================ + +function calculateSummary(variables: ExtractedVariable[], ioMappings: IOMapping[]): VariableSummary { + const bySection: Record = { + 'VAR_INPUT': 0, + 'VAR_OUTPUT': 0, + 'VAR_IN_OUT': 0, + 'VAR': 0, + 'VAR_GLOBAL': 0, + 'VAR_TEMP': 0, + 'VAR_CONSTANT': 0, + 'VAR_EXTERNAL': 0, + }; + + let withComments = 0; + let withIOAddress = 0; + let safetyCritical = 0; + + for (const v of variables) { + bySection[v.section]++; + if (v.comment) withComments++; + if (v.ioAddress) withIOAddress++; + if (v.isSafetyCritical) safetyCritical++; + } + + return { + total: variables.length, + bySection, + withComments, + withIOAddress: withIOAddress + ioMappings.length, + safetyCritical, + }; +} + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +export function extractVariablesFromFiles( + files: Array<{ path: string; content: string }>, + options?: VariableExtractionOptions +): VariableExtractionResult { + const allVariables: ExtractedVariable[] = []; + const allIOMappings: IOMapping[] = []; + + for (const file of files) { + const result = extractVariables(file.content, file.path, options); + allVariables.push(...result.variables); + allIOMappings.push(...result.ioMappings); + } + + const summary = calculateSummary(allVariables, allIOMappings); + return { variables: allVariables, ioMappings: allIOMappings, summary }; +} diff --git a/packages/core/src/iec61131/index.ts b/packages/core/src/iec61131/index.ts new file mode 100644 index 00000000..d5ca9c6e --- /dev/null +++ b/packages/core/src/iec61131/index.ts @@ -0,0 +1,180 @@ +/** + * IEC 61131-3 Code Factory + * + * Enterprise-grade analysis for industrial automation code. + * Extracts docstrings, state machines, safety interlocks, and tribal knowledge. + * + * @module @drift/core/iec61131 + */ + +// Main analyzer +export { IEC61131Analyzer, createAnalyzer } from './analyzer.js'; +export type { AnalyzerOptions } from './analyzer.js'; + +// Parser +export { STParser, parseSTSource, STTokenizer, tokenize } from './parser/index.js'; +export type { + ParseResult, + ParseError, + ParseWarning, + ParsedComment, + ParseMetadata, + ParseOptions, + Token, + TokenType, +} from './parser/index.js'; + +// Extractors +export { + extractDocstrings, + extractDocstringsFromFiles, + extractStateMachines, + extractStateMachinesFromFiles, + extractSafetyInterlocks, + extractSafetyFromFiles, + extractTribalKnowledge, + extractTribalKnowledgeFromFiles, + extractVariables, + extractVariablesFromFiles, +} from './extractors/index.js'; + +export type { + DocstringExtractionResult, + ExtractedDocstring, + DocstringQuality, + DocstringSummary, + DocstringExtractionOptions, + StateMachineExtractionResult, + ExtractedStateMachine, + StateMachineSummary, + StateMachineExtractionOptions, + SafetyExtractionResult, + SafetyExtractionOptions, + TribalKnowledgeExtractionResult, + ExtractedTribalKnowledge, + TribalKnowledgeSummary, + TribalKnowledgeExtractionOptions, + VariableExtractionResult, + ExtractedVariable, + VariableSummary, + VariableExtractionOptions, +} from './extractors/index.js'; + +// Types - Export all types from types.ts +export type { + // Vendor & Parser types + VendorId, + ParserConfidence, + POUType, + VariableSection, + // Source location + SourceLocation, + // Docstring types + STDocstring, + STDocParam, + STHistoryEntry, + // Variable types + STVariable, + ArrayBounds, + // POU types + STPOU, + STMethod, + // State machine types + StateMachine, + StateMachineState, + StateMachineTransition, + StateMachineVerification, + StateMachineVisualizations, + // Safety types + SafetyInterlockType, + SafetySeverity, + SafetyInterlock, + SafetyBypass, + SafetyAnalysisResult, + SafetyCriticalWarning, + SafetySummary, + // Tribal knowledge types + TribalKnowledgeType, + TribalKnowledgeImportance, + TribalKnowledgeItem, + // I/O mapping types + IOAddressType, + IOMapping, + // Call graph types + CallType, + STCallGraphNode, + STCallGraphEdge, + STCallGraph, + // Migration scoring types + MigrationGrade, + MigrationDimensionScores, + POUMigrationScore, + MigrationBlocker, + MigrationReadinessReport, + MigrationOrderItem, + MigrationRisk, + MigrationEffortEstimate, + // AI context types + TargetLanguage, + AIContextPackage, + AIProjectContext, + AIConventionContext, + AITypeContext, + AISafetyContext, + AIPOUContext, + AIVariableDescription, + AITranslationHint, + AITranslationGuide, + AIPatternMapping, + AIVerificationRequirement, + // Diagram types + DiagramFormat, + DiagramType, + DiagramOptions, + DiagramResult, + // Analysis result types + STProjectStatus, + HealthIssue, + // Export types + ExportFormat, + ExportOptions, + ExportResult, + // Analyzer types + AnalyzerId, + AnalysisContext, + AnalysisOptions, + AnalysisResult, + AnalysisError, + // File extensions + STExtension, +} from './types.js'; + +// Export constants +export { ST_EXTENSIONS } from './types.js'; + +// Utilities +export { generateId, generateContentId } from './utils/index.js'; + +// Analyzers +export { MigrationScorer, createMigrationScorer } from './analyzers/index.js'; +export type { MigrationScorerConfig, ScoringWeights } from './analyzers/index.js'; + +export { AIContextGenerator, createAIContextGenerator } from './analyzers/index.js'; +export type { AIContextGeneratorConfig } from './analyzers/index.js'; + +// Storage +export { IEC61131Repository, createIEC61131Repository } from './storage/index.js'; +export type { + IEC61131RepositoryConfig, + StoredSTFile, + StoredSTPOU, + StoredSTVariable, + StoredSTDocstring, + StoredStateMachine, + StoredSafetyInterlock, + StoredSafetyBypass, + StoredTribalKnowledge, + StoredIOMapping, + StoredMigrationScore, + STAnalysisRun, +} from './storage/index.js'; diff --git a/packages/core/src/iec61131/parser/index.ts b/packages/core/src/iec61131/parser/index.ts new file mode 100644 index 00000000..c531ef37 --- /dev/null +++ b/packages/core/src/iec61131/parser/index.ts @@ -0,0 +1,18 @@ +/** + * IEC 61131-3 Parser Module + * + * Exports tokenizer and parser for Structured Text. + */ + +export { STTokenizer, tokenize } from './tokenizer.js'; +export type { Token, TokenType } from './tokenizer.js'; + +export { STParser, parseSTSource } from './st-parser.js'; +export type { + ParseResult, + ParseError, + ParseWarning, + ParsedComment, + ParseMetadata, + ParseOptions, +} from './st-parser.js'; diff --git a/packages/core/src/iec61131/parser/st-parser.ts b/packages/core/src/iec61131/parser/st-parser.ts new file mode 100644 index 00000000..451ac0e1 --- /dev/null +++ b/packages/core/src/iec61131/parser/st-parser.ts @@ -0,0 +1,890 @@ +/** + * IEC 61131-3 Structured Text Parser + * + * Parses ST source code into a vendor-neutral AST. + * Handles POUs, variables, comments, and basic control structures. + */ + +import { STTokenizer } from './tokenizer.js'; +import type { Token, TokenType } from './tokenizer.js'; +import type { + STPOU, + STVariable, + STDocstring, + STDocParam, + STHistoryEntry, + STMethod, + POUType, + VariableSection, + VendorId, + ParserConfidence, +} from '../types.js'; +import { generateId } from '../utils/id-generator.js'; + +// ============================================================================ +// PARSE RESULT +// ============================================================================ + +export interface ParseResult { + success: boolean; + pous: STPOU[]; + globalVariables: STVariable[]; + docstrings: STDocstring[]; + comments: ParsedComment[]; + errors: ParseError[]; + warnings: ParseWarning[]; + metadata: ParseMetadata; +} + +export interface ParseError { + code: string; + message: string; + line: number; + column: number; + recoverable: boolean; +} + +export interface ParseWarning { + code: string; + message: string; + line: number; + column: number; +} + +export interface ParsedComment { + content: string; + line: number; + endLine: number; + isDocstring: boolean; +} + +export interface ParseMetadata { + vendor: VendorId; + confidence: ParserConfidence; + totalLines: number; + parseTime: number; +} + +export interface ParseOptions { + extractDocstrings?: boolean; + preserveComments?: boolean; + strict?: boolean; +} + +// ============================================================================ +// PARSER CLASS +// ============================================================================ + +export class STParser { + private tokens: Token[] = []; + private current: number = 0; + private source: string = ''; + private filePath: string = ''; + private options: ParseOptions; + + private pous: STPOU[] = []; + private globalVariables: STVariable[] = []; + private docstrings: STDocstring[] = []; + private comments: ParsedComment[] = []; + private errors: ParseError[] = []; + private warnings: ParseWarning[] = []; + + constructor(options: ParseOptions = {}) { + this.options = { + extractDocstrings: true, + preserveComments: true, + strict: false, + ...options, + }; + } + + parse(source: string, filePath: string): ParseResult { + const startTime = Date.now(); + + this.source = source; + this.filePath = filePath; + this.current = 0; + this.pous = []; + this.globalVariables = []; + this.docstrings = []; + this.comments = []; + this.errors = []; + this.warnings = []; + + // Tokenize + const tokenizer = new STTokenizer(source); + this.tokens = tokenizer.tokenize(); + + // Extract comments first + this.extractComments(); + + // Parse POUs + this.parseProgram(); + + const parseTime = Date.now() - startTime; + + return { + success: this.errors.filter(e => !e.recoverable).length === 0, + pous: this.pous, + globalVariables: this.globalVariables, + docstrings: this.docstrings, + comments: this.comments, + errors: this.errors, + warnings: this.warnings, + metadata: { + vendor: this.detectVendor(), + confidence: this.calculateConfidence(), + totalLines: source.split('\n').length, + parseTime, + }, + }; + } + + // ============================================================================ + // COMMENT EXTRACTION + // ============================================================================ + + private extractComments(): void { + for (const token of this.tokens) { + if (token.type === 'COMMENT') { + const isDocstring = this.isDocstringComment(token); + + this.comments.push({ + content: token.value, + line: token.line, + endLine: token.endLine, + isDocstring, + }); + + if (isDocstring && this.options.extractDocstrings) { + const docstring = this.parseDocstring(token); + if (docstring) { + this.docstrings.push(docstring); + } + } + } + } + } + + private isDocstringComment(token: Token): boolean { + const content = token.value; + + // Multi-line block comments are likely docstrings + if (content.startsWith('(*') && content.includes('\n')) { + return true; + } + + // Comments with special markers + if (/(@param|@returns?|@author|@date|@history|@warning|@note)/i.test(content)) { + return true; + } + + // Header-style comments with asterisks + if (/^\(\*{3,}/.test(content) || /^\(\*={3,}/.test(content)) { + return true; + } + + return false; + } + + private parseDocstring(token: Token): STDocstring | null { + const content = token.value + .replace(/^\(\*+\s*/, '') + .replace(/\s*\*+\)$/, ''); + + const lines = content.split('\n').map(l => l.replace(/^\s*\*?\s?/, '').trim()); + + let summary = ''; + const descLines: string[] = []; + const params: STDocParam[] = []; + const history: STHistoryEntry[] = []; + const warnings: string[] = []; + const notes: string[] = []; + let returns: string | null = null; + let author: string | null = null; + let date: string | null = null; + + for (const line of lines) { + // Skip separator lines + if (/^[=\-*]+$/.test(line)) continue; + if (!line) continue; + + // @param + const paramMatch = line.match(/@param\s+(\w+)\s*(?::\s*(\w+))?\s*[-:]?\s*(.*)/i); + if (paramMatch) { + params.push({ + name: paramMatch[1]!, + type: paramMatch[2] || null, + description: paramMatch[3]?.trim() || '', + direction: null, + }); + continue; + } + + // @returns + const returnMatch = line.match(/@returns?\s+(.*)/i); + if (returnMatch) { + returns = returnMatch[1]?.trim() || null; + continue; + } + + // @author + const authorMatch = line.match(/@author\s+(.*)/i); + if (authorMatch) { + author = authorMatch[1]?.trim() || null; + continue; + } + + // @date + const dateMatch = line.match(/@date\s+(.*)/i); + if (dateMatch) { + date = dateMatch[1]?.trim() || null; + continue; + } + + // History entries (YYYY-MM-DD format) + const historyMatch = line.match(/^(\d{4}-\d{2}-\d{2})\s*(?:(\w+)\s*)?[-:]?\s*(.*)/); + if (historyMatch) { + history.push({ + date: historyMatch[1]!, + author: historyMatch[2] || null, + description: historyMatch[3]?.trim() || '', + }); + continue; + } + + // Warnings + if (/^(WARNING|DANGER|CAUTION)\s*[:!]?\s*/i.test(line)) { + warnings.push(line); + continue; + } + + // Notes + if (/^NOTE\s*[:!]?\s*/i.test(line)) { + notes.push(line.replace(/^NOTE\s*[:!]?\s*/i, '')); + continue; + } + + // Auth: pattern (common in legacy code) + const authMatch = line.match(/^Auth:\s*(.*)/i); + if (authMatch) { + author = authMatch[1]?.trim() || null; + continue; + } + + // First non-special line is summary + if (!summary && !line.startsWith('@')) { + summary = line; + continue; + } + + // Rest is description + if (summary && !line.startsWith('@')) { + descLines.push(line); + } + } + + // Find associated block + const afterComment = this.findTokenAfterLine(token.endLine); + let associatedBlock: string | null = null; + let associatedBlockType: POUType | null = null; + + if (afterComment) { + if (this.isPOUKeyword(afterComment.type)) { + const nameToken = this.findNextToken(afterComment, 'IDENTIFIER'); + if (nameToken) { + associatedBlock = nameToken.value; + associatedBlockType = afterComment.type as POUType; + } + } + } + + return { + id: generateId(), + summary, + description: descLines.join(' ').trim(), + params, + returns, + author, + date, + history, + warnings, + notes, + raw: token.value, + location: { + file: this.filePath, + line: token.line, + column: token.column, + endLine: token.endLine, + endColumn: token.endColumn, + }, + associatedBlock, + associatedBlockType, + }; + } + + // ============================================================================ + // POU PARSING + // ============================================================================ + + private parseProgram(): void { + while (!this.isAtEnd()) { + try { + this.skipComments(); + + if (this.isAtEnd()) break; + + if (this.check('PROGRAM')) { + this.parsePOU('PROGRAM', 'END_PROGRAM'); + } else if (this.check('FUNCTION_BLOCK')) { + this.parsePOU('FUNCTION_BLOCK', 'END_FUNCTION_BLOCK'); + } else if (this.check('FUNCTION')) { + this.parsePOU('FUNCTION', 'END_FUNCTION'); + } else if (this.check('CLASS')) { + this.parsePOU('CLASS', 'END_CLASS'); + } else if (this.check('INTERFACE')) { + this.parsePOU('INTERFACE', 'END_INTERFACE'); + } else if (this.isVariableSection(this.peek().type)) { + // Global variables + const vars = this.parseVariableSection(); + this.globalVariables.push(...vars); + } else { + // Skip unknown tokens + this.advance(); + } + } catch (error) { + this.recoverFromError(); + } + } + } + + private parsePOU(startKeyword: TokenType, endKeyword: TokenType): void { + const startToken = this.advance(); // consume keyword + const startLine = startToken.line; + + // Get name + this.skipComments(); + const nameToken = this.consume('IDENTIFIER', `Expected ${startKeyword} name`); + const name = nameToken?.value ?? 'UNKNOWN'; + + // Check for EXTENDS + let extendsName: string | null = null; + this.skipComments(); + if (this.check('EXTENDS')) { + this.advance(); + this.skipComments(); + const extendsToken = this.consume('IDENTIFIER', 'Expected base class name'); + extendsName = extendsToken?.value ?? null; + } + + // Check for IMPLEMENTS + const implementsList: string[] = []; + this.skipComments(); + if (this.check('IMPLEMENTS')) { + this.advance(); + do { + this.skipComments(); + const implToken = this.consume('IDENTIFIER', 'Expected interface name'); + if (implToken) implementsList.push(implToken.value); + this.skipComments(); + } while (this.match('COMMA')); + } + + // Parse variable sections + const variables: STVariable[] = []; + const methods: STMethod[] = []; + let bodyStartLine = startLine; + + this.skipComments(); + while (!this.isAtEnd() && !this.check(endKeyword)) { + this.skipComments(); + + if (this.isVariableSection(this.peek().type)) { + const vars = this.parseVariableSection(); + variables.push(...vars); + } else if (this.check('METHOD')) { + const method = this.parseMethod(); + if (method) methods.push(method); + } else if (this.check('PROPERTY')) { + this.skipProperty(); + } else { + // Body starts here + if (bodyStartLine === startLine) { + bodyStartLine = this.peek().line; + } + this.advance(); + } + } + + // Consume end keyword + const endToken = this.match(endKeyword) ? this.previous() : this.peek(); + const endLine = endToken.line; + + // Find associated docstring + const docstring = this.findDocstringForPOU(startLine); + + const pou: STPOU = { + id: generateId(), + type: this.tokenTypeToPOUType(startKeyword), + name, + qualifiedName: name, + location: { + file: this.filePath, + line: startLine, + column: startToken.column, + endLine, + endColumn: endToken.endColumn, + }, + documentation: docstring, + variables, + extends: extendsName, + implements: implementsList, + methods, + bodyStartLine, + bodyEndLine: endLine, + vendorAttributes: {}, + }; + + this.pous.push(pou); + } + + private parseMethod(): STMethod | null { + const startToken = this.advance(); // consume METHOD + this.skipComments(); + + const nameToken = this.consume('IDENTIFIER', 'Expected method name'); + const name = nameToken?.value ?? 'UNKNOWN'; + + // Check for return type + let returnType: string | null = null; + this.skipComments(); + if (this.check('COLON')) { + this.advance(); + this.skipComments(); + const typeToken = this.consume('IDENTIFIER', 'Expected return type'); + returnType = typeToken?.value ?? null; + } + + // Parse parameters + const parameters: STVariable[] = []; + this.skipComments(); + while (!this.isAtEnd() && !this.check('END_METHOD') && this.isVariableSection(this.peek().type)) { + const vars = this.parseVariableSection(); + parameters.push(...vars); + this.skipComments(); + } + + // Skip to END_METHOD + while (!this.isAtEnd() && !this.check('END_METHOD')) { + this.advance(); + } + this.match('END_METHOD'); + + return { + id: generateId(), + name, + returnType, + parameters, + location: { + file: this.filePath, + line: startToken.line, + column: startToken.column, + endLine: this.previous().line, + endColumn: this.previous().endColumn, + }, + documentation: null, + }; + } + + private skipProperty(): void { + this.advance(); // consume PROPERTY + while (!this.isAtEnd() && !this.check('END_PROPERTY')) { + this.advance(); + } + this.match('END_PROPERTY'); + } + + // ============================================================================ + // VARIABLE PARSING + // ============================================================================ + + private parseVariableSection(): STVariable[] { + const variables: STVariable[] = []; + const sectionToken = this.advance(); + const section = this.tokenTypeToVariableSection(sectionToken.type); + + // Check for modifiers (CONSTANT, RETAIN, etc.) + this.skipComments(); + while (this.check('CONSTANT') || this.check('RETAIN') || this.check('PERSISTENT')) { + this.advance(); + this.skipComments(); + } + + // Parse variables until END_VAR + while (!this.isAtEnd() && !this.check('END_VAR')) { + this.skipComments(); + if (this.check('END_VAR')) break; + + const variable = this.parseVariable(section); + if (variable) { + variables.push(variable); + } + } + + this.match('END_VAR'); + return variables; + } + + private parseVariable(section: VariableSection): STVariable | null { + this.skipComments(); + if (!this.check('IDENTIFIER')) return null; + + const nameToken = this.advance(); + const name = nameToken.value; + let ioAddress: string | null = null; + + // Check for AT %address + this.skipComments(); + if (this.check('AT')) { + this.advance(); + this.skipComments(); + // Parse address like %IX0.0, %QW10, etc. + if (this.peek().value.startsWith('%') || this.check('IDENTIFIER')) { + ioAddress = this.advance().value; + } + } + + // Expect colon + this.skipComments(); + if (!this.match('COLON')) { + this.addWarning('MISSING_COLON', `Expected ':' after variable name '${name}'`, nameToken.line, nameToken.column); + return null; + } + + // Parse type + this.skipComments(); + let dataType = ''; + let isArray = false; + let arrayBounds = null; + + if (this.check('ARRAY')) { + isArray = true; + this.advance(); + this.skipComments(); + + // Parse array bounds [lower..upper] or [lower..upper, lower..upper] + if (this.match('LBRACKET')) { + arrayBounds = this.parseArrayBounds(); + this.match('RBRACKET'); + } + + this.skipComments(); + this.match('OF'); + this.skipComments(); + } + + // Get base type + if (this.check('IDENTIFIER')) { + dataType = this.advance().value; + } + + // Check for initial value + let initialValue: string | null = null; + this.skipComments(); + if (this.match('ASSIGN')) { + initialValue = this.parseInitialValue(); + } + + // Check for inline comment + let comment: string | null = null; + this.skipComments(); + const nextToken = this.peek(); + if (nextToken.type === 'COMMENT' && nextToken.line === nameToken.line) { + comment = this.extractInlineComment(nextToken.value); + } + + // Consume semicolon + this.skipComments(); + this.match('SEMICOLON'); + + // Detect safety-critical variables + const isSafetyCritical = this.isSafetyVariable(name); + + return { + id: generateId(), + name, + dataType, + section, + initialValue, + comment, + isArray, + arrayBounds, + isSafetyCritical, + ioAddress, + location: { + file: this.filePath, + line: nameToken.line, + column: nameToken.column, + }, + pouId: null, + }; + } + + private parseArrayBounds(): { dimensions: Array<{ lower: number; upper: number }> } | null { + const dimensions: Array<{ lower: number; upper: number }> = []; + + do { + this.skipComments(); + const lowerToken = this.advance(); + const lower = parseInt(lowerToken.value, 10) || 0; + + this.skipComments(); + this.match('DOTDOT'); + + this.skipComments(); + const upperToken = this.advance(); + const upper = parseInt(upperToken.value, 10) || 0; + + dimensions.push({ lower, upper }); + this.skipComments(); + } while (this.match('COMMA')); + + return { dimensions }; + } + + private parseInitialValue(): string { + let value = ''; + let depth = 0; + + while (!this.isAtEnd()) { + const token = this.peek(); + + if (token.type === 'SEMICOLON' && depth === 0) break; + if (token.type === 'COMMENT') break; + + if (token.type === 'LPAREN' || token.type === 'LBRACKET') depth++; + if (token.type === 'RPAREN' || token.type === 'RBRACKET') depth--; + + value += token.value; + this.advance(); + } + + return value.trim(); + } + + private extractInlineComment(comment: string): string { + return comment + .replace(/^\(\*\s*/, '') + .replace(/\s*\*\)$/, '') + .replace(/^\/\/\s*/, '') + .trim(); + } + + private isSafetyVariable(name: string): boolean { + const safetyPatterns = [ + /^bIL_/i, + /^IL_/i, + /Interlock/i, + /Permissive/i, + /EStop/i, + /E_Stop/i, + /EmergencyStop/i, + /Safety/i, + /Bypass/i, + ]; + return safetyPatterns.some(p => p.test(name)); + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + private skipComments(): void { + while (this.check('COMMENT')) { + this.advance(); + } + } + + private isVariableSection(type: TokenType): boolean { + return [ + 'VAR', 'VAR_INPUT', 'VAR_OUTPUT', 'VAR_IN_OUT', + 'VAR_GLOBAL', 'VAR_TEMP', 'VAR_CONSTANT', 'VAR_EXTERNAL', + ].includes(type); + } + + private isPOUKeyword(type: TokenType): boolean { + return ['PROGRAM', 'FUNCTION_BLOCK', 'FUNCTION', 'CLASS', 'INTERFACE'].includes(type); + } + + private tokenTypeToPOUType(type: TokenType): POUType { + const map: Record = { + 'PROGRAM': 'PROGRAM', + 'FUNCTION_BLOCK': 'FUNCTION_BLOCK', + 'FUNCTION': 'FUNCTION', + 'CLASS': 'CLASS', + 'INTERFACE': 'INTERFACE', + }; + return map[type] ?? 'PROGRAM'; + } + + private tokenTypeToVariableSection(type: TokenType): VariableSection { + const map: Record = { + 'VAR': 'VAR', + 'VAR_INPUT': 'VAR_INPUT', + 'VAR_OUTPUT': 'VAR_OUTPUT', + 'VAR_IN_OUT': 'VAR_IN_OUT', + 'VAR_GLOBAL': 'VAR_GLOBAL', + 'VAR_TEMP': 'VAR_TEMP', + 'VAR_CONSTANT': 'VAR_CONSTANT', + 'VAR_EXTERNAL': 'VAR_EXTERNAL', + }; + return map[type] ?? 'VAR'; + } + + private findDocstringForPOU(pouLine: number): STDocstring | null { + // Find docstring immediately before POU + for (const doc of this.docstrings) { + const endLine = doc.location.endLine ?? doc.location.line; + if (endLine === pouLine - 1 || endLine === pouLine - 2) { + return doc; + } + if (doc.associatedBlock && doc.location.line < pouLine && endLine >= pouLine - 5) { + return doc; + } + } + return null; + } + + private findTokenAfterLine(line: number): Token | null { + for (const token of this.tokens) { + if (token.line > line && token.type !== 'COMMENT' && token.type !== 'NEWLINE') { + return token; + } + } + return null; + } + + private findNextToken(after: Token, type: TokenType): Token | null { + let found = false; + for (const token of this.tokens) { + if (found && token.type === type) { + return token; + } + if (token === after) { + found = true; + } + } + return null; + } + + private detectVendor(): VendorId { + // Check for vendor-specific patterns + const sourceUpper = this.source.toUpperCase(); + + if (sourceUpper.includes('ORGANISATION_BLOCK') || sourceUpper.includes('OB1')) { + return 'siemens-step7'; + } + if (sourceUpper.includes('#TEMP') || sourceUpper.includes('REGION')) { + return 'siemens-tia'; + } + if (sourceUpper.includes('')) { + return 'rockwell-studio5000'; + } + if (sourceUpper.includes('')) { + return 'beckhoff-twincat'; + } + if (sourceUpper.includes('CODESYS')) { + return 'codesys'; + } + + return 'generic-st'; + } + + private calculateConfidence(): ParserConfidence { + const errorCount = this.errors.length; + const pouCount = this.pous.length; + + if (errorCount === 0 && pouCount > 0) { + return { level: 'definite', reason: 'Clean parse with POUs found' }; + } + if (errorCount < 3 && pouCount > 0) { + return { level: 'probable', score: 0.8, reason: 'Minor parse issues' }; + } + if (pouCount > 0) { + return { level: 'possible', score: 0.5, reason: 'Multiple parse issues' }; + } + return { level: 'none' }; + } + + // ============================================================================ + // TOKEN NAVIGATION + // ============================================================================ + + private isAtEnd(): boolean { + return this.peek().type === 'EOF'; + } + + private peek(): Token { + return this.tokens[this.current] ?? { type: 'EOF', value: '', line: 0, column: 0, endLine: 0, endColumn: 0 }; + } + + private previous(): Token { + return this.tokens[this.current - 1] ?? this.peek(); + } + + private advance(): Token { + if (!this.isAtEnd()) this.current++; + return this.previous(); + } + + private check(type: TokenType): boolean { + return this.peek().type === type; + } + + private match(...types: TokenType[]): boolean { + for (const type of types) { + if (this.check(type)) { + this.advance(); + return true; + } + } + return false; + } + + private consume(type: TokenType, message: string): Token | null { + if (this.check(type)) { + return this.advance(); + } + this.addError('UNEXPECTED_TOKEN', message, this.peek().line, this.peek().column, true); + return null; + } + + // ============================================================================ + // ERROR HANDLING + // ============================================================================ + + private addError(code: string, message: string, line: number, column: number, recoverable: boolean): void { + this.errors.push({ code, message, line, column, recoverable }); + } + + private addWarning(code: string, message: string, line: number, column: number): void { + this.warnings.push({ code, message, line, column }); + } + + private recoverFromError(): void { + // Skip to next POU or variable section + while (!this.isAtEnd()) { + if (this.isPOUKeyword(this.peek().type) || this.isVariableSection(this.peek().type)) { + return; + } + this.advance(); + } + } +} + +/** + * Convenience function to parse ST source + */ +export function parseSTSource(source: string, filePath: string, options?: ParseOptions): ParseResult { + return new STParser(options).parse(source, filePath); +} diff --git a/packages/core/src/iec61131/parser/tokenizer.ts b/packages/core/src/iec61131/parser/tokenizer.ts new file mode 100644 index 00000000..f13efce0 --- /dev/null +++ b/packages/core/src/iec61131/parser/tokenizer.ts @@ -0,0 +1,617 @@ +/** + * IEC 61131-3 Structured Text Tokenizer + * + * Lexical analysis for ST code. Produces tokens for the parser. + * Handles comments, strings, identifiers, keywords, operators. + */ + +// ============================================================================ +// TOKEN TYPES +// ============================================================================ + +export type TokenType = + // Keywords - POU + | 'PROGRAM' | 'END_PROGRAM' + | 'FUNCTION_BLOCK' | 'END_FUNCTION_BLOCK' + | 'FUNCTION' | 'END_FUNCTION' + | 'CLASS' | 'END_CLASS' + | 'INTERFACE' | 'END_INTERFACE' + | 'METHOD' | 'END_METHOD' + | 'PROPERTY' | 'END_PROPERTY' + // Keywords - Variables + | 'VAR' | 'END_VAR' + | 'VAR_INPUT' | 'VAR_OUTPUT' | 'VAR_IN_OUT' + | 'VAR_GLOBAL' | 'VAR_TEMP' | 'VAR_CONSTANT' | 'VAR_EXTERNAL' + | 'CONSTANT' | 'RETAIN' | 'PERSISTENT' + // Keywords - Control + | 'IF' | 'THEN' | 'ELSIF' | 'ELSE' | 'END_IF' + | 'CASE' | 'OF' | 'END_CASE' + | 'FOR' | 'TO' | 'BY' | 'DO' | 'END_FOR' + | 'WHILE' | 'END_WHILE' + | 'REPEAT' | 'UNTIL' | 'END_REPEAT' + | 'EXIT' | 'CONTINUE' | 'RETURN' + // Keywords - Types + | 'TYPE' | 'END_TYPE' + | 'STRUCT' | 'END_STRUCT' + | 'ARRAY' + | 'AT' + | 'EXTENDS' | 'IMPLEMENTS' + // Operators + | 'ASSIGN' // := + | 'COLON' // : + | 'SEMICOLON' // ; + | 'COMMA' // , + | 'DOT' // . + | 'DOTDOT' // .. + | 'LPAREN' // ( + | 'RPAREN' // ) + | 'LBRACKET' // [ + | 'RBRACKET' // ] + | 'HASH' // # + | 'PLUS' // + + | 'MINUS' // - + | 'STAR' // * + | 'SLASH' // / + | 'MOD' + | 'POWER' // ** + | 'EQ' // = + | 'NE' // <> + | 'LT' // < + | 'LE' // <= + | 'GT' // > + | 'GE' // >= + | 'AND' | 'OR' | 'XOR' | 'NOT' + // Literals + | 'INTEGER' + | 'REAL' + | 'STRING' + | 'WSTRING' + | 'TIME' + | 'DATE' + | 'DATETIME' + | 'TRUE' | 'FALSE' + // Other + | 'IDENTIFIER' + | 'COMMENT' + | 'NEWLINE' + | 'WHITESPACE' + | 'EOF' + | 'UNKNOWN'; + +export interface Token { + type: TokenType; + value: string; + line: number; + column: number; + endLine: number; + endColumn: number; +} + +// ============================================================================ +// KEYWORD MAP +// ============================================================================ + +const KEYWORDS: Record = { + // POU + 'PROGRAM': 'PROGRAM', + 'END_PROGRAM': 'END_PROGRAM', + 'FUNCTION_BLOCK': 'FUNCTION_BLOCK', + 'END_FUNCTION_BLOCK': 'END_FUNCTION_BLOCK', + 'FUNCTION': 'FUNCTION', + 'END_FUNCTION': 'END_FUNCTION', + 'CLASS': 'CLASS', + 'END_CLASS': 'END_CLASS', + 'INTERFACE': 'INTERFACE', + 'END_INTERFACE': 'END_INTERFACE', + 'METHOD': 'METHOD', + 'END_METHOD': 'END_METHOD', + 'PROPERTY': 'PROPERTY', + 'END_PROPERTY': 'END_PROPERTY', + // Variables + 'VAR': 'VAR', + 'END_VAR': 'END_VAR', + 'VAR_INPUT': 'VAR_INPUT', + 'VAR_OUTPUT': 'VAR_OUTPUT', + 'VAR_IN_OUT': 'VAR_IN_OUT', + 'VAR_GLOBAL': 'VAR_GLOBAL', + 'VAR_TEMP': 'VAR_TEMP', + 'VAR_CONSTANT': 'VAR_CONSTANT', + 'VAR_EXTERNAL': 'VAR_EXTERNAL', + 'CONSTANT': 'CONSTANT', + 'RETAIN': 'RETAIN', + 'PERSISTENT': 'PERSISTENT', + // Control + 'IF': 'IF', + 'THEN': 'THEN', + 'ELSIF': 'ELSIF', + 'ELSE': 'ELSE', + 'END_IF': 'END_IF', + 'CASE': 'CASE', + 'OF': 'OF', + 'END_CASE': 'END_CASE', + 'FOR': 'FOR', + 'TO': 'TO', + 'BY': 'BY', + 'DO': 'DO', + 'END_FOR': 'END_FOR', + 'WHILE': 'WHILE', + 'END_WHILE': 'END_WHILE', + 'REPEAT': 'REPEAT', + 'UNTIL': 'UNTIL', + 'END_REPEAT': 'END_REPEAT', + 'EXIT': 'EXIT', + 'CONTINUE': 'CONTINUE', + 'RETURN': 'RETURN', + // Types + 'TYPE': 'TYPE', + 'END_TYPE': 'END_TYPE', + 'STRUCT': 'STRUCT', + 'END_STRUCT': 'END_STRUCT', + 'ARRAY': 'ARRAY', + 'AT': 'AT', + 'EXTENDS': 'EXTENDS', + 'IMPLEMENTS': 'IMPLEMENTS', + // Operators + 'MOD': 'MOD', + 'AND': 'AND', + 'OR': 'OR', + 'XOR': 'XOR', + 'NOT': 'NOT', + // Literals + 'TRUE': 'TRUE', + 'FALSE': 'FALSE', +}; + +// ============================================================================ +// TOKENIZER CLASS +// ============================================================================ + +export class STTokenizer { + private source: string; + private pos: number = 0; + private line: number = 1; + private column: number = 1; + private tokens: Token[] = []; + + constructor(source: string) { + this.source = source; + } + + tokenize(): Token[] { + this.tokens = []; + this.pos = 0; + this.line = 1; + this.column = 1; + + while (!this.isAtEnd()) { + const token = this.scanToken(); + if (token) { + this.tokens.push(token); + } + } + + this.tokens.push({ + type: 'EOF', + value: '', + line: this.line, + column: this.column, + endLine: this.line, + endColumn: this.column, + }); + + return this.tokens; + } + + private scanToken(): Token | null { + const startLine = this.line; + const startColumn = this.column; + const char = this.advance(); + + // Whitespace (skip but track newlines) + if (char === ' ' || char === '\t' || char === '\r') { + return null; + } + + // Newline + if (char === '\n') { + this.line++; + this.column = 1; + return null; + } + + // Comments (* ... *) or // ... + if (char === '(' && this.peek() === '*') { + return this.scanBlockComment(startLine, startColumn); + } + if (char === '/' && this.peek() === '/') { + return this.scanLineComment(startLine, startColumn); + } + + // Strings + if (char === "'") { + return this.scanString(startLine, startColumn); + } + if (char === '"') { + return this.scanWString(startLine, startColumn); + } + + // Numbers + if (this.isDigit(char)) { + return this.scanNumber(char, startLine, startColumn); + } + + // Identifiers and keywords + if (this.isAlpha(char) || char === '_') { + return this.scanIdentifier(char, startLine, startColumn); + } + + // Operators and punctuation + return this.scanOperator(char, startLine, startColumn); + } + + private scanBlockComment(startLine: number, startColumn: number): Token { + this.advance(); // consume * + let value = '(*'; + let depth = 1; + + while (!this.isAtEnd() && depth > 0) { + const char = this.advance(); + value += char; + + if (char === '\n') { + this.line++; + this.column = 1; + } else if (char === '(' && this.peek() === '*') { + this.advance(); + value += '*'; + depth++; + } else if (char === '*' && this.peek() === ')') { + this.advance(); + value += ')'; + depth--; + } + } + + return { + type: 'COMMENT', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanLineComment(startLine: number, startColumn: number): Token { + let value = '//'; + this.advance(); // consume second / + + while (!this.isAtEnd() && this.peek() !== '\n') { + value += this.advance(); + } + + return { + type: 'COMMENT', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanString(startLine: number, startColumn: number): Token { + let value = "'"; + + while (!this.isAtEnd() && this.peek() !== "'") { + const char = this.advance(); + value += char; + if (char === '\n') { + this.line++; + this.column = 1; + } + } + + if (!this.isAtEnd()) { + value += this.advance(); // closing quote + } + + return { + type: 'STRING', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanWString(startLine: number, startColumn: number): Token { + let value = '"'; + + while (!this.isAtEnd() && this.peek() !== '"') { + const char = this.advance(); + value += char; + if (char === '\n') { + this.line++; + this.column = 1; + } + } + + if (!this.isAtEnd()) { + value += this.advance(); // closing quote + } + + return { + type: 'WSTRING', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanNumber(firstChar: string, startLine: number, startColumn: number): Token { + let value = firstChar; + let type: TokenType = 'INTEGER'; + + // Integer part + while (this.isDigit(this.peek()) || this.peek() === '_') { + const char = this.advance(); + if (char !== '_') value += char; + } + + // Decimal part + if (this.peek() === '.' && this.isDigit(this.peekNext())) { + type = 'REAL'; + value += this.advance(); // . + while (this.isDigit(this.peek()) || this.peek() === '_') { + const char = this.advance(); + if (char !== '_') value += char; + } + } + + // Exponent + if (this.peek() === 'e' || this.peek() === 'E') { + type = 'REAL'; + value += this.advance(); + if (this.peek() === '+' || this.peek() === '-') { + value += this.advance(); + } + while (this.isDigit(this.peek())) { + value += this.advance(); + } + } + + return { + type, + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanIdentifier(firstChar: string, startLine: number, startColumn: number): Token { + let value = firstChar; + + while (this.isAlphaNumeric(this.peek()) || this.peek() === '_') { + value += this.advance(); + } + + // Check for time literals (T#, TIME#) + if ((value.toUpperCase() === 'T' || value.toUpperCase() === 'TIME') && this.peek() === '#') { + return this.scanTimeLiteral(value, startLine, startColumn); + } + + // Check for date literals (D#, DATE#) + if ((value.toUpperCase() === 'D' || value.toUpperCase() === 'DATE') && this.peek() === '#') { + return this.scanDateLiteral(value, startLine, startColumn); + } + + // Check for datetime literals (DT#, DATE_AND_TIME#) + if ((value.toUpperCase() === 'DT' || value.toUpperCase() === 'DATE_AND_TIME') && this.peek() === '#') { + return this.scanDateTimeLiteral(value, startLine, startColumn); + } + + const upper = value.toUpperCase(); + const type = KEYWORDS[upper] ?? 'IDENTIFIER'; + + return { + type, + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanTimeLiteral(prefix: string, startLine: number, startColumn: number): Token { + let value = prefix + this.advance(); // # + + while (!this.isAtEnd() && (this.isAlphaNumeric(this.peek()) || this.peek() === '_' || this.peek() === '.')) { + value += this.advance(); + } + + return { + type: 'TIME', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanDateLiteral(prefix: string, startLine: number, startColumn: number): Token { + let value = prefix + this.advance(); // # + + while (!this.isAtEnd() && (this.isAlphaNumeric(this.peek()) || this.peek() === '-')) { + value += this.advance(); + } + + return { + type: 'DATE', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanDateTimeLiteral(prefix: string, startLine: number, startColumn: number): Token { + let value = prefix + this.advance(); // # + + while (!this.isAtEnd() && (this.isAlphaNumeric(this.peek()) || this.peek() === '-' || this.peek() === ':' || this.peek() === '.')) { + value += this.advance(); + } + + return { + type: 'DATETIME', + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + private scanOperator(char: string, startLine: number, startColumn: number): Token { + let type: TokenType; + let value = char; + + switch (char) { + case ':': + if (this.peek() === '=') { + value += this.advance(); + type = 'ASSIGN'; + } else { + type = 'COLON'; + } + break; + case ';': + type = 'SEMICOLON'; + break; + case ',': + type = 'COMMA'; + break; + case '.': + if (this.peek() === '.') { + value += this.advance(); + type = 'DOTDOT'; + } else { + type = 'DOT'; + } + break; + case '(': + type = 'LPAREN'; + break; + case ')': + type = 'RPAREN'; + break; + case '[': + type = 'LBRACKET'; + break; + case ']': + type = 'RBRACKET'; + break; + case '#': + type = 'HASH'; + break; + case '+': + type = 'PLUS'; + break; + case '-': + type = 'MINUS'; + break; + case '*': + if (this.peek() === '*') { + value += this.advance(); + type = 'POWER'; + } else { + type = 'STAR'; + } + break; + case '/': + type = 'SLASH'; + break; + case '=': + type = 'EQ'; + break; + case '<': + if (this.peek() === '>') { + value += this.advance(); + type = 'NE'; + } else if (this.peek() === '=') { + value += this.advance(); + type = 'LE'; + } else { + type = 'LT'; + } + break; + case '>': + if (this.peek() === '=') { + value += this.advance(); + type = 'GE'; + } else { + type = 'GT'; + } + break; + default: + type = 'UNKNOWN'; + } + + return { + type, + value, + line: startLine, + column: startColumn, + endLine: this.line, + endColumn: this.column, + }; + } + + // Helper methods + private isAtEnd(): boolean { + return this.pos >= this.source.length; + } + + private advance(): string { + const char = this.source[this.pos++]!; + this.column++; + return char; + } + + private peek(): string { + if (this.isAtEnd()) return '\0'; + return this.source[this.pos]!; + } + + private peekNext(): string { + if (this.pos + 1 >= this.source.length) return '\0'; + return this.source[this.pos + 1]!; + } + + private isDigit(char: string): boolean { + return char >= '0' && char <= '9'; + } + + private isAlpha(char: string): boolean { + return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z'); + } + + private isAlphaNumeric(char: string): boolean { + return this.isAlpha(char) || this.isDigit(char); + } +} + +/** + * Convenience function to tokenize ST source + */ +export function tokenize(source: string): Token[] { + return new STTokenizer(source).tokenize(); +} diff --git a/packages/core/src/iec61131/storage/index.ts b/packages/core/src/iec61131/storage/index.ts new file mode 100644 index 00000000..045ba6a2 --- /dev/null +++ b/packages/core/src/iec61131/storage/index.ts @@ -0,0 +1,22 @@ +/** + * IEC 61131-3 Storage Module + * + * SQLite-backed storage for Code Factory analysis results. + * Extends the unified Drift database with ST-specific tables. + */ + +export { IEC61131Repository, createIEC61131Repository } from './repository.js'; +export type { + IEC61131RepositoryConfig, + StoredSTFile, + StoredSTPOU, + StoredSTVariable, + StoredSTDocstring, + StoredStateMachine, + StoredSafetyInterlock, + StoredSafetyBypass, + StoredTribalKnowledge, + StoredIOMapping, + StoredMigrationScore, + STAnalysisRun, +} from './repository.js'; diff --git a/packages/core/src/iec61131/storage/repository.ts b/packages/core/src/iec61131/storage/repository.ts new file mode 100644 index 00000000..9d073b12 --- /dev/null +++ b/packages/core/src/iec61131/storage/repository.ts @@ -0,0 +1,712 @@ +/** + * IEC 61131-3 Repository + * + * SQLite-backed storage for Code Factory analysis results. + * Provides CRUD operations for all ST-related entities. + */ + +import type { Database } from 'better-sqlite3'; +import { generateId } from '../utils/index.js'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +export interface IEC61131RepositoryConfig { + db: Database; +} + +// ============================================================================ +// STORED ENTITY TYPES +// ============================================================================ + +export interface StoredSTFile { + id: string; + path: string; + vendor: string | null; + language: string; + lineCount: number; + hash: string; + parsedAt: string | null; + createdAt: string; +} + +export interface StoredSTPOU { + id: string; + fileId: string; + type: string; + name: string; + qualifiedName: string; + startLine: number; + endLine: number; + extends: string | null; + implements: string[]; + vendorAttributes: Record; + createdAt: string; +} + +export interface StoredSTVariable { + id: string; + pouId: string | null; + fileId: string | null; + section: string; + name: string; + dataType: string; + initialValue: string | null; + isArray: boolean; + arrayBounds: unknown | null; + comment: string | null; + lineNumber: number | null; + isSafetyCritical: boolean; + ioAddress: string | null; +} + +export interface StoredSTDocstring { + id: string; + pouId: string | null; + fileId: string; + summary: string | null; + description: string | null; + rawText: string | null; + author: string | null; + date: string | null; + startLine: number; + endLine: number; + associatedBlock: string | null; + associatedBlockType: string | null; + qualityScore: number; + extractedAt: string; +} + +export interface StoredStateMachine { + id: string; + pouId: string; + name: string; + stateVariable: string; + stateVariableType: string | null; + stateCount: number; + hasDeadlocks: boolean; + hasGaps: boolean; + unreachableStates: string[]; + gapValues: number[]; + mermaidDiagram: string; + asciiDiagram: string; + plantumlDiagram: string | null; + startLine: number | null; + endLine: number | null; + createdAt: string; +} + +export interface StoredSafetyInterlock { + id: string; + pouId: string | null; + fileId: string; + name: string; + type: string; + lineNumber: number | null; + isBypassed: boolean; + bypassCondition: string | null; + confidence: number; + severity: string; + relatedInterlocks: string[]; +} + +export interface StoredSafetyBypass { + id: string; + pouId: string | null; + fileId: string; + name: string; + lineNumber: number | null; + affectedInterlocks: string[]; + condition: string | null; + severity: string; + detectedAt: string; +} + +export interface StoredTribalKnowledge { + id: string; + pouId: string | null; + fileId: string; + type: string; + content: string; + context: string | null; + lineNumber: number | null; + importance: string; + extractedAt: string; +} + +export interface StoredIOMapping { + id: string; + pouId: string | null; + fileId: string; + address: string; + addressType: string; + variableName: string | null; + description: string | null; + lineNumber: number | null; + isInput: boolean; + bitSize: number; +} + +export interface StoredMigrationScore { + id: string; + pouId: string; + overallScore: number; + documentationScore: number | null; + safetyScore: number | null; + complexityScore: number | null; + dependenciesScore: number | null; + testabilityScore: number | null; + grade: string; + blockers: unknown[]; + warnings: string[]; + suggestions: string[]; + calculatedAt: string; +} + +export interface STAnalysisRun { + id: string; + startedAt: string; + completedAt: string | null; + status: 'running' | 'completed' | 'failed'; + filesAnalyzed: number; + pousFound: number; + stateMachinesFound: number; + interlocksFound: number; + bypassesFound: number; + tribalKnowledgeFound: number; + errors: unknown[]; + summary: unknown; +} + +// ============================================================================ +// ROW TYPE (for SQLite results) +// ============================================================================ + +type DbRow = Record; + +// ============================================================================ +// REPOSITORY CLASS +// ============================================================================ + +export class IEC61131Repository { + private db: Database; + + constructor(config: IEC61131RepositoryConfig) { + this.db = config.db; + } + + // ========================================================================== + // FILE OPERATIONS + // ========================================================================== + + storeFile(file: Omit): StoredSTFile { + const createdAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_files (id, path, vendor, language, line_count, hash, parsed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run(file.id, file.path, file.vendor, file.language, file.lineCount, file.hash, file.parsedAt, createdAt); + return { ...file, createdAt }; + } + + getFile(id: string): StoredSTFile | null { + const stmt = this.db.prepare('SELECT * FROM st_files WHERE id = ?'); + const row = stmt.get(id) as DbRow | undefined; + return row ? this.mapFileRow(row) : null; + } + + getFileByPath(path: string): StoredSTFile | null { + const stmt = this.db.prepare('SELECT * FROM st_files WHERE path = ?'); + const row = stmt.get(path) as DbRow | undefined; + return row ? this.mapFileRow(row) : null; + } + + getAllFiles(): StoredSTFile[] { + const stmt = this.db.prepare('SELECT * FROM st_files ORDER BY path'); + const rows = stmt.all() as DbRow[]; + return rows.map(row => this.mapFileRow(row)); + } + + private mapFileRow(row: DbRow): StoredSTFile { + return { + id: row['id'] as string, + path: row['path'] as string, + vendor: row['vendor'] as string | null, + language: row['language'] as string, + lineCount: row['line_count'] as number, + hash: row['hash'] as string, + parsedAt: row['parsed_at'] as string | null, + createdAt: row['created_at'] as string, + }; + } + + // ========================================================================== + // POU OPERATIONS + // ========================================================================== + + storePOU(pou: Omit): StoredSTPOU { + const createdAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_pous (id, file_id, type, name, qualified_name, start_line, end_line, extends, implements, vendor_attributes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + pou.id, + pou.fileId, + pou.type, + pou.name, + pou.qualifiedName, + pou.startLine, + pou.endLine, + pou.extends, + JSON.stringify(pou.implements), + JSON.stringify(pou.vendorAttributes), + createdAt + ); + return { ...pou, createdAt }; + } + + getPOU(id: string): StoredSTPOU | null { + const stmt = this.db.prepare('SELECT * FROM st_pous WHERE id = ?'); + const row = stmt.get(id) as DbRow | undefined; + return row ? this.mapPOURow(row) : null; + } + + getPOUsByFile(fileId: string): StoredSTPOU[] { + const stmt = this.db.prepare('SELECT * FROM st_pous WHERE file_id = ? ORDER BY start_line'); + const rows = stmt.all(fileId) as DbRow[]; + return rows.map(row => this.mapPOURow(row)); + } + + getAllPOUs(): StoredSTPOU[] { + const stmt = this.db.prepare('SELECT * FROM st_pous ORDER BY name'); + const rows = stmt.all() as DbRow[]; + return rows.map(row => this.mapPOURow(row)); + } + + private mapPOURow(row: DbRow): StoredSTPOU { + return { + id: row['id'] as string, + fileId: row['file_id'] as string, + type: row['type'] as string, + name: row['name'] as string, + qualifiedName: row['qualified_name'] as string, + startLine: row['start_line'] as number, + endLine: row['end_line'] as number, + extends: row['extends'] as string | null, + implements: JSON.parse((row['implements'] as string) || '[]'), + vendorAttributes: JSON.parse((row['vendor_attributes'] as string) || '{}'), + createdAt: row['created_at'] as string, + }; + } + + // ========================================================================== + // STATE MACHINE OPERATIONS + // ========================================================================== + + storeStateMachine(sm: Omit): StoredStateMachine { + const createdAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_state_machines + (id, pou_id, name, state_variable, state_variable_type, state_count, has_deadlocks, has_gaps, + unreachable_states, gap_values, mermaid_diagram, ascii_diagram, plantuml_diagram, start_line, end_line, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + sm.id, + sm.pouId, + sm.name, + sm.stateVariable, + sm.stateVariableType, + sm.stateCount, + sm.hasDeadlocks ? 1 : 0, + sm.hasGaps ? 1 : 0, + JSON.stringify(sm.unreachableStates), + JSON.stringify(sm.gapValues), + sm.mermaidDiagram, + sm.asciiDiagram, + sm.plantumlDiagram, + sm.startLine, + sm.endLine, + createdAt + ); + return { ...sm, createdAt }; + } + + getStateMachine(id: string): StoredStateMachine | null { + const stmt = this.db.prepare('SELECT * FROM st_state_machines WHERE id = ?'); + const row = stmt.get(id) as DbRow | undefined; + return row ? this.mapStateMachineRow(row) : null; + } + + getStateMachinesByPOU(pouId: string): StoredStateMachine[] { + const stmt = this.db.prepare('SELECT * FROM st_state_machines WHERE pou_id = ?'); + const rows = stmt.all(pouId) as DbRow[]; + return rows.map(row => this.mapStateMachineRow(row)); + } + + getAllStateMachines(): StoredStateMachine[] { + const stmt = this.db.prepare('SELECT * FROM st_state_machines ORDER BY name'); + const rows = stmt.all() as DbRow[]; + return rows.map(row => this.mapStateMachineRow(row)); + } + + private mapStateMachineRow(row: DbRow): StoredStateMachine { + return { + id: row['id'] as string, + pouId: row['pou_id'] as string, + name: row['name'] as string, + stateVariable: row['state_variable'] as string, + stateVariableType: row['state_variable_type'] as string | null, + stateCount: row['state_count'] as number, + hasDeadlocks: Boolean(row['has_deadlocks']), + hasGaps: Boolean(row['has_gaps']), + unreachableStates: JSON.parse((row['unreachable_states'] as string) || '[]'), + gapValues: JSON.parse((row['gap_values'] as string) || '[]'), + mermaidDiagram: row['mermaid_diagram'] as string, + asciiDiagram: row['ascii_diagram'] as string, + plantumlDiagram: row['plantuml_diagram'] as string | null, + startLine: row['start_line'] as number | null, + endLine: row['end_line'] as number | null, + createdAt: row['created_at'] as string, + }; + } + + // ========================================================================== + // SAFETY INTERLOCK OPERATIONS + // ========================================================================== + + storeSafetyInterlock(interlock: StoredSafetyInterlock): void { + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_safety_interlocks + (id, pou_id, file_id, name, type, line_number, is_bypassed, bypass_condition, confidence, severity, related_interlocks) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + interlock.id, + interlock.pouId, + interlock.fileId, + interlock.name, + interlock.type, + interlock.lineNumber, + interlock.isBypassed ? 1 : 0, + interlock.bypassCondition, + interlock.confidence, + interlock.severity, + JSON.stringify(interlock.relatedInterlocks) + ); + } + + getSafetyInterlocks(fileId?: string): StoredSafetyInterlock[] { + const stmt = fileId + ? this.db.prepare('SELECT * FROM st_safety_interlocks WHERE file_id = ?') + : this.db.prepare('SELECT * FROM st_safety_interlocks'); + const rows = (fileId ? stmt.all(fileId) : stmt.all()) as DbRow[]; + return rows.map(row => this.mapSafetyInterlockRow(row)); + } + + private mapSafetyInterlockRow(row: DbRow): StoredSafetyInterlock { + return { + id: row['id'] as string, + pouId: row['pou_id'] as string | null, + fileId: row['file_id'] as string, + name: row['name'] as string, + type: row['type'] as string, + lineNumber: row['line_number'] as number | null, + isBypassed: Boolean(row['is_bypassed']), + bypassCondition: row['bypass_condition'] as string | null, + confidence: row['confidence'] as number, + severity: row['severity'] as string, + relatedInterlocks: JSON.parse((row['related_interlocks'] as string) || '[]'), + }; + } + + // ========================================================================== + // SAFETY BYPASS OPERATIONS + // ========================================================================== + + storeSafetyBypass(bypass: Omit): StoredSafetyBypass { + const detectedAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_safety_bypasses + (id, pou_id, file_id, name, line_number, affected_interlocks, condition, severity, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + bypass.id, + bypass.pouId, + bypass.fileId, + bypass.name, + bypass.lineNumber, + JSON.stringify(bypass.affectedInterlocks), + bypass.condition, + bypass.severity, + detectedAt + ); + return { ...bypass, detectedAt }; + } + + getSafetyBypasses(): StoredSafetyBypass[] { + const stmt = this.db.prepare('SELECT * FROM st_safety_bypasses ORDER BY detected_at DESC'); + const rows = stmt.all() as DbRow[]; + return rows.map(row => ({ + id: row['id'] as string, + pouId: row['pou_id'] as string | null, + fileId: row['file_id'] as string, + name: row['name'] as string, + lineNumber: row['line_number'] as number | null, + affectedInterlocks: JSON.parse((row['affected_interlocks'] as string) || '[]'), + condition: row['condition'] as string | null, + severity: row['severity'] as string, + detectedAt: row['detected_at'] as string, + })); + } + + // ========================================================================== + // TRIBAL KNOWLEDGE OPERATIONS + // ========================================================================== + + storeTribalKnowledge(item: Omit): StoredTribalKnowledge { + const extractedAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_tribal_knowledge + (id, pou_id, file_id, type, content, context, line_number, importance, extracted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + item.id, + item.pouId, + item.fileId, + item.type, + item.content, + item.context, + item.lineNumber, + item.importance, + extractedAt + ); + return { ...item, extractedAt }; + } + + getTribalKnowledge(options?: { type?: string; importance?: string; limit?: number }): StoredTribalKnowledge[] { + let sql = 'SELECT * FROM st_tribal_knowledge WHERE 1=1'; + const params: unknown[] = []; + + if (options?.type) { + sql += ' AND type = ?'; + params.push(options.type); + } + if (options?.importance) { + sql += ' AND importance = ?'; + params.push(options.importance); + } + sql += ' ORDER BY importance DESC, extracted_at DESC'; + if (options?.limit) { + sql += ' LIMIT ?'; + params.push(options.limit); + } + + const stmt = this.db.prepare(sql); + const rows = stmt.all(...params) as DbRow[]; + return rows.map(row => ({ + id: row['id'] as string, + pouId: row['pou_id'] as string | null, + fileId: row['file_id'] as string, + type: row['type'] as string, + content: row['content'] as string, + context: row['context'] as string | null, + lineNumber: row['line_number'] as number | null, + importance: row['importance'] as string, + extractedAt: row['extracted_at'] as string, + })); + } + + // ========================================================================== + // MIGRATION SCORE OPERATIONS + // ========================================================================== + + storeMigrationScore(score: Omit): StoredMigrationScore { + const calculatedAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO st_migration_scores + (id, pou_id, overall_score, documentation_score, safety_score, complexity_score, + dependencies_score, testability_score, grade, blockers, warnings, suggestions, calculated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + score.id, + score.pouId, + score.overallScore, + score.documentationScore, + score.safetyScore, + score.complexityScore, + score.dependenciesScore, + score.testabilityScore, + score.grade, + JSON.stringify(score.blockers), + JSON.stringify(score.warnings), + JSON.stringify(score.suggestions), + calculatedAt + ); + return { ...score, calculatedAt }; + } + + getMigrationScores(): StoredMigrationScore[] { + const stmt = this.db.prepare(` + SELECT * FROM st_migration_scores ORDER BY overall_score DESC + `); + const rows = stmt.all() as DbRow[]; + return rows.map(row => ({ + id: row['id'] as string, + pouId: row['pou_id'] as string, + overallScore: row['overall_score'] as number, + documentationScore: row['documentation_score'] as number | null, + safetyScore: row['safety_score'] as number | null, + complexityScore: row['complexity_score'] as number | null, + dependenciesScore: row['dependencies_score'] as number | null, + testabilityScore: row['testability_score'] as number | null, + grade: row['grade'] as string, + blockers: JSON.parse((row['blockers'] as string) || '[]'), + warnings: JSON.parse((row['warnings'] as string) || '[]'), + suggestions: JSON.parse((row['suggestions'] as string) || '[]'), + calculatedAt: row['calculated_at'] as string, + })); + } + + // ========================================================================== + // ANALYSIS RUN OPERATIONS + // ========================================================================== + + startAnalysisRun(): STAnalysisRun { + const id = generateId(); + const startedAt = new Date().toISOString(); + const stmt = this.db.prepare(` + INSERT INTO st_analysis_runs (id, started_at, status) + VALUES (?, ?, 'running') + `); + stmt.run(id, startedAt); + return { + id, + startedAt, + completedAt: null, + status: 'running', + filesAnalyzed: 0, + pousFound: 0, + stateMachinesFound: 0, + interlocksFound: 0, + bypassesFound: 0, + tribalKnowledgeFound: 0, + errors: [], + summary: null, + }; + } + + completeAnalysisRun(id: string, summary: Partial): void { + const completedAt = new Date().toISOString(); + const stmt = this.db.prepare(` + UPDATE st_analysis_runs SET + completed_at = ?, + status = ?, + files_analyzed = ?, + pous_found = ?, + state_machines_found = ?, + interlocks_found = ?, + bypasses_found = ?, + tribal_knowledge_found = ?, + errors = ?, + summary = ? + WHERE id = ? + `); + stmt.run( + completedAt, + summary.status ?? 'completed', + summary.filesAnalyzed ?? 0, + summary.pousFound ?? 0, + summary.stateMachinesFound ?? 0, + summary.interlocksFound ?? 0, + summary.bypassesFound ?? 0, + summary.tribalKnowledgeFound ?? 0, + JSON.stringify(summary.errors ?? []), + JSON.stringify(summary.summary ?? {}), + id + ); + } + + // ========================================================================== + // STATISTICS + // ========================================================================== + + getStats(): { + files: number; + pous: number; + stateMachines: number; + interlocks: number; + bypasses: number; + tribalKnowledge: number; + avgMigrationScore: number | null; + } { + const stmt = this.db.prepare(` + SELECT + (SELECT COUNT(*) FROM st_files) as files, + (SELECT COUNT(*) FROM st_pous) as pous, + (SELECT COUNT(*) FROM st_state_machines) as state_machines, + (SELECT COUNT(*) FROM st_safety_interlocks) as interlocks, + (SELECT COUNT(*) FROM st_safety_bypasses) as bypasses, + (SELECT COUNT(*) FROM st_tribal_knowledge) as tribal_knowledge, + (SELECT AVG(overall_score) FROM st_migration_scores) as avg_migration_score + `); + const row = stmt.get() as DbRow; + return { + files: row['files'] as number, + pous: row['pous'] as number, + stateMachines: row['state_machines'] as number, + interlocks: row['interlocks'] as number, + bypasses: row['bypasses'] as number, + tribalKnowledge: row['tribal_knowledge'] as number, + avgMigrationScore: row['avg_migration_score'] as number | null, + }; + } + + // ========================================================================== + // CLEANUP + // ========================================================================== + + clearAll(): void { + const tables = [ + 'st_analysis_runs', + 'st_ai_contexts', + 'st_migration_scores', + 'st_call_graph', + 'st_io_mappings', + 'st_tribal_knowledge', + 'st_safety_warnings', + 'st_safety_bypasses', + 'st_safety_interlocks', + 'st_sm_transitions', + 'st_sm_states', + 'st_state_machines', + 'st_doc_history', + 'st_doc_params', + 'st_docstrings', + 'st_variables', + 'st_pous', + 'st_files', + ]; + + for (const table of tables) { + try { + this.db.prepare(`DELETE FROM ${table}`).run(); + } catch { + // Table may not exist yet + } + } + } +} + +// ============================================================================ +// FACTORY FUNCTION +// ============================================================================ + +export function createIEC61131Repository(config: IEC61131RepositoryConfig): IEC61131Repository { + return new IEC61131Repository(config); +} diff --git a/packages/core/src/iec61131/types.ts b/packages/core/src/iec61131/types.ts new file mode 100644 index 00000000..f3be4a4e --- /dev/null +++ b/packages/core/src/iec61131/types.ts @@ -0,0 +1,675 @@ +/** + * IEC 61131-3 Code Factory - Type Definitions + * + * Single source of truth for all IEC 61131-3 analysis types. + * Following architecture doc Part 3: Data Model + */ + +// ============================================================================ +// VENDOR & PARSER TYPES +// ============================================================================ + +export type VendorId = + | 'siemens-step7' + | 'siemens-tia' + | 'rockwell-rslogix' + | 'rockwell-studio5000' + | 'beckhoff-twincat' + | 'codesys' + | 'schneider-unity' + | 'omron-sysmac' + | 'mitsubishi-gxworks' + | 'generic-st'; + +export type ParserConfidence = + | { level: 'definite'; reason: string } + | { level: 'probable'; score: number; reason: string } + | { level: 'possible'; score: number; reason: string } + | { level: 'none' }; + +export type POUType = 'PROGRAM' | 'FUNCTION_BLOCK' | 'FUNCTION' | 'CLASS' | 'INTERFACE'; + +export type VariableSection = + | 'VAR_INPUT' + | 'VAR_OUTPUT' + | 'VAR_IN_OUT' + | 'VAR' + | 'VAR_GLOBAL' + | 'VAR_TEMP' + | 'VAR_CONSTANT' + | 'VAR_EXTERNAL'; + +// ============================================================================ +// SOURCE LOCATION +// ============================================================================ + +export interface SourceLocation { + file: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; +} + +// ============================================================================ +// DOCSTRING TYPES +// ============================================================================ + +export interface STDocstring { + id: string; + summary: string; + description: string; + params: STDocParam[]; + returns: string | null; + author: string | null; + date: string | null; + history: STHistoryEntry[]; + warnings: string[]; + notes: string[]; + raw: string; + location: SourceLocation; + associatedBlock: string | null; + associatedBlockType: POUType | null; +} + +export interface STDocParam { + name: string; + type: string | null; + description: string; + direction: 'in' | 'out' | 'inout' | null; +} + +export interface STHistoryEntry { + date: string; + author: string | null; + description: string; +} + +// ============================================================================ +// VARIABLE TYPES +// ============================================================================ + +export interface STVariable { + id: string; + name: string; + dataType: string; + section: VariableSection; + initialValue: string | null; + comment: string | null; + isArray: boolean; + arrayBounds: ArrayBounds | null; + isSafetyCritical: boolean; + ioAddress: string | null; + location: SourceLocation; + pouId: string | null; +} + +export interface ArrayBounds { + dimensions: Array<{ lower: number; upper: number }>; +} + +// ============================================================================ +// POU (PROGRAM ORGANIZATION UNIT) TYPES +// ============================================================================ + +export interface STPOU { + id: string; + type: POUType; + name: string; + qualifiedName: string; + location: SourceLocation; + documentation: STDocstring | null; + variables: STVariable[]; + extends: string | null; + implements: string[]; + methods: STMethod[]; + bodyStartLine: number; + bodyEndLine: number; + vendorAttributes: Record; +} + +export interface STMethod { + id: string; + name: string; + returnType: string | null; + parameters: STVariable[]; + location: SourceLocation; + documentation: STDocstring | null; +} + +// ============================================================================ +// STATE MACHINE TYPES +// ============================================================================ + +export interface StateMachine { + id: string; + name: string; + pouId: string; + pouName: string; + stateVariable: string; + stateVariableType: string; + states: StateMachineState[]; + transitions: StateMachineTransition[]; + location: SourceLocation; + verification: StateMachineVerification; + visualizations: StateMachineVisualizations; +} + +export interface StateMachineState { + id: string; + value: number | string; + name: string | null; + documentation: string | null; + isInitial: boolean; + isFinal: boolean; + actions: string[]; + location: SourceLocation; +} + +export interface StateMachineTransition { + id: string; + fromStateId: string; + toStateId: string; + guard: string | null; + actions: string[]; + documentation: string | null; + location: SourceLocation; +} + +export interface StateMachineVerification { + hasDeadlocks: boolean; + unreachableStates: string[]; + missingTransitions: string[]; + hasGaps: boolean; + gapValues: number[]; +} + +export interface StateMachineVisualizations { + mermaid: string; + ascii: string; + plantUml?: string; + dot?: string; +} + +// ============================================================================ +// SAFETY TYPES +// ============================================================================ + +export type SafetyInterlockType = + | 'interlock' + | 'permissive' + | 'estop' + | 'safety-relay' + | 'safety-device' + | 'bypass'; + +export type SafetySeverity = 'critical' | 'high' | 'medium' | 'low'; + +export interface SafetyInterlock { + id: string; + name: string; + type: SafetyInterlockType; + location: SourceLocation; + pouId: string | null; + isBypassed: boolean; + bypassCondition: string | null; + confidence: number; + severity: SafetySeverity; + relatedInterlocks: string[]; +} + +export interface SafetyBypass { + id: string; + name: string; + location: SourceLocation; + pouId: string | null; + affectedInterlocks: string[]; + condition: string | null; + severity: SafetySeverity; +} + +export interface SafetyAnalysisResult { + interlocks: SafetyInterlock[]; + bypasses: SafetyBypass[]; + criticalWarnings: SafetyCriticalWarning[]; + summary: SafetySummary; +} + +export interface SafetyCriticalWarning { + type: 'bypass-detected' | 'unprotected-output' | 'missing-estop' | 'interlock-gap'; + message: string; + severity: SafetySeverity; + location: SourceLocation; + remediation: string; +} + +export interface SafetySummary { + totalInterlocks: number; + byType: Record; + bypassCount: number; + criticalWarningCount: number; +} + +// ============================================================================ +// TRIBAL KNOWLEDGE TYPES +// ============================================================================ + +export type TribalKnowledgeType = + | 'warning' + | 'caution' + | 'danger' + | 'note' + | 'todo' + | 'fixme' + | 'hack' + | 'workaround' + | 'do-not-change' + | 'magic-number' + | 'history' + | 'author' + | 'equipment' + | 'mystery'; + +export type TribalKnowledgeImportance = 'critical' | 'high' | 'medium' | 'low'; + +export interface TribalKnowledgeItem { + id: string; + type: TribalKnowledgeType; + content: string; + context: string; + location: SourceLocation; + pouId: string | null; + importance: TribalKnowledgeImportance; + extractedAt: string; +} + +// ============================================================================ +// I/O MAPPING TYPES +// ============================================================================ + +export type IOAddressType = 'IX' | 'QX' | 'IW' | 'QW' | 'ID' | 'QD' | 'IB' | 'QB' | 'MW' | 'MD' | 'MB'; + +export interface IOMapping { + id: string; + address: string; + addressType: IOAddressType; + variableName: string | null; + description: string | null; + location: SourceLocation; + pouId: string | null; + isInput: boolean; + bitSize: number; +} + +// ============================================================================ +// CALL GRAPH TYPES +// ============================================================================ + +export type CallType = 'instantiation' | 'method_call' | 'function_call'; + +export interface STCallGraphNode { + id: string; + name: string; + type: POUType; + file: string; + line: number; + inputs: STVariable[]; + outputs: STVariable[]; +} + +export interface STCallGraphEdge { + id: string; + callerId: string; + calleeId: string | null; + calleeName: string; + callType: CallType; + location: SourceLocation; + arguments: string[]; +} + +export interface STCallGraph { + nodes: Map; + edges: STCallGraphEdge[]; +} + +// ============================================================================ +// MIGRATION SCORING TYPES +// ============================================================================ + +export type MigrationGrade = 'A' | 'B' | 'C' | 'D' | 'F'; + +export interface MigrationDimensionScores { + documentation: number; + safety: number; + complexity: number; + dependencies: number; + testability: number; +} + +export interface POUMigrationScore { + pouId: string; + pouName: string; + pouType: POUType; + overallScore: number; + dimensionScores: MigrationDimensionScores; + grade: MigrationGrade; + blockers: MigrationBlocker[]; + warnings: string[]; + suggestions: string[]; +} + +export interface MigrationBlocker { + type: 'safety-bypass' | 'undocumented-state-machine' | 'circular-dependency' | 'missing-documentation' | 'complex-logic'; + description: string; + severity: SafetySeverity; + remediation: string; +} + +export interface MigrationReadinessReport { + overallScore: number; + overallGrade: MigrationGrade; + pouScores: POUMigrationScore[]; + migrationOrder: MigrationOrderItem[]; + risks: MigrationRisk[]; + estimatedEffort: MigrationEffortEstimate; +} + +export interface MigrationOrderItem { + order: number; + pouId: string; + pouName: string; + reason: string; + dependencies: string[]; + estimatedEffort: string; +} + +export interface MigrationRisk { + severity: SafetySeverity; + category: string; + description: string; + affectedPOUs: string[]; + mitigation: string; +} + +export interface MigrationEffortEstimate { + totalHours: number; + byPOU: Record; + confidence: number; +} + +// ============================================================================ +// AI CONTEXT TYPES +// ============================================================================ + +export type TargetLanguage = 'python' | 'rust' | 'typescript' | 'csharp' | 'cpp' | 'go' | 'java'; + +export interface AIContextPackage { + version: string; + generatedAt: string; + targetLanguage: TargetLanguage; + project: AIProjectContext; + conventions: AIConventionContext; + types: AITypeContext; + safety: AISafetyContext; + pous: AIPOUContext[]; + tribalKnowledge: TribalKnowledgeItem[]; + translationGuide: AITranslationGuide; + verificationRequirements: AIVerificationRequirement[]; +} + +export interface AIProjectContext { + name: string; + vendor: VendorId; + plcType: string | null; + totalPOUs: number; + totalLines: number; + languages: string[]; +} + +export interface AIConventionContext { + namingPatterns: Record; + variablePrefixes: Record; + stateEncodings: string[]; + commentStyles: string[]; +} + +export interface AITypeContext { + plcToTarget: Record; + customTypes: string[]; + structDefinitions: Record; +} + +export interface AISafetyContext { + interlocks: SafetyInterlock[]; + criticalPaths: string[]; + mustPreserve: string[]; +} + +export interface AIPOUContext { + pouId: string; + pouName: string; + pouType: POUType; + purpose: string; + interface: { + inputs: AIVariableDescription[]; + outputs: AIVariableDescription[]; + inOuts: AIVariableDescription[]; + }; + behavior: { + summary: string; + stateMachines: string[]; + algorithms: string[]; + }; + safety: { + isSafetyCritical: boolean; + interlocks: string[]; + constraints: string[]; + }; + translationHints: AITranslationHint[]; + suggestedTests: string[]; +} + +export interface AIVariableDescription { + name: string; + type: string; + description: string; + constraints: string[]; +} + +export interface AITranslationHint { + category: 'timing' | 'pattern' | 'io' | 'safety' | 'type'; + plcConstruct: string; + targetEquivalent: string; + notes: string; + example: string; +} + +export interface AITranslationGuide { + targetLanguage: TargetLanguage; + typeMapping: Record; + patternMapping: AIPatternMapping[]; + warnings: string[]; +} + +export interface AIPatternMapping { + plcPattern: string; + targetPattern: string; + example: string; +} + +export interface AIVerificationRequirement { + category: string; + requirement: string; + testApproach: string; +} + +// ============================================================================ +// DIAGRAM TYPES +// ============================================================================ + +export type DiagramFormat = 'mermaid' | 'plantuml' | 'ascii' | 'dot' | 'd2' | 'svg'; + +export type DiagramType = 'fbd' | 'state' | 'call-graph' | 'safety' | 'io'; + +export interface DiagramOptions { + format: DiagramFormat; + width?: number; + height?: number; + includeComments?: boolean; + highlightSafety?: boolean; +} + +export interface DiagramResult { + format: DiagramFormat; + type: DiagramType; + content: string; + metadata: { + nodeCount: number; + edgeCount: number; + complexity: 'simple' | 'moderate' | 'complex'; + }; + alternatives?: Partial>; +} + +// ============================================================================ +// ANALYSIS RESULT TYPES +// ============================================================================ + +export interface STProjectStatus { + project: { + path: string; + name: string; + vendor: VendorId | null; + plcType: string | null; + }; + files: { + total: number; + byExtension: Record; + totalLines: number; + }; + analysis: { + lastRun: string | null; + pous: number; + stateMachines: number; + safetyInterlocks: number; + tribalKnowledge: number; + docstrings: number; + }; + health: { + score: number; + issues: HealthIssue[]; + }; +} + +export interface HealthIssue { + type: 'warning' | 'error' | 'info'; + message: string; + file?: string; + line?: number; +} + +// ============================================================================ +// EXPORT TYPES +// ============================================================================ + +export type ExportFormat = + | 'json' + | 'markdown' + | 'html' + | 'yaml' + | 'sqlite' + | 'ai-context' + | 'mermaid-bundle' + | 'csv'; + +export interface ExportOptions { + format: ExportFormat; + outputPath?: string; + includeRaw?: boolean; + maxTokens?: number; + targetLanguage?: TargetLanguage; +} + +export interface ExportResult { + format: ExportFormat; + content: string; + metadata: { + itemCount: number; + generatedAt: string; + tokenEstimate?: number; + }; +} + +// ============================================================================ +// ANALYZER TYPES +// ============================================================================ + +export type AnalyzerId = + // Structural + | 'structure/pou-hierarchy' + | 'structure/call-graph' + | 'structure/data-flow' + | 'structure/control-flow' + // Documentation + | 'docs/docstring-extractor' + | 'docs/tribal-knowledge' + | 'docs/history-tracker' + // Safety + | 'safety/interlock-detector' + | 'safety/estop-paths' + | 'safety/bypass-detector' + // Patterns + | 'pattern/state-machine' + | 'pattern/timer-counter' + | 'pattern/pid-loop' + // Quality + | 'quality/complexity-scorer' + | 'quality/naming-conventions' + // I/O + | 'io/address-mapper' + | 'io/signal-tracer' + // Migration + | 'migration/readiness-scorer' + | 'migration/risk-assessor'; + +export interface AnalysisContext { + projectPath: string; + files: string[]; + options: AnalysisOptions; + results: Map; + getResult(analyzerId: AnalyzerId): T | null; +} + +export interface AnalysisOptions { + includeRaw?: boolean; + limit?: number; + verbose?: boolean; + targetLanguage?: TargetLanguage; + maxTokens?: number; +} + +export interface AnalysisResult { + success: boolean; + data: T; + errors: AnalysisError[]; + metadata: { + analyzerId: AnalyzerId; + duration: number; + itemsProcessed: number; + }; +} + +export interface AnalysisError { + code: string; + message: string; + file?: string; + line?: number; + recoverable: boolean; +} + +// ============================================================================ +// FILE EXTENSIONS +// ============================================================================ + +export const ST_EXTENSIONS = ['.st', '.stx', '.scl', '.pou', '.exp', '.xml'] as const; +export type STExtension = typeof ST_EXTENSIONS[number]; diff --git a/packages/core/src/iec61131/utils/id-generator.ts b/packages/core/src/iec61131/utils/id-generator.ts new file mode 100644 index 00000000..48e6e5bc --- /dev/null +++ b/packages/core/src/iec61131/utils/id-generator.ts @@ -0,0 +1,37 @@ +/** + * ID Generator Utility + * + * Generates unique IDs for IEC 61131-3 entities. + */ + +import { randomBytes } from 'crypto'; + +let counter = 0; + +/** + * Generate a unique ID + * Format: st___ + */ +export function generateId(): string { + const timestamp = Date.now().toString(36); + const count = (counter++).toString(36).padStart(4, '0'); + const random = randomBytes(4).toString('hex'); + return `st_${timestamp}_${count}_${random}`; +} + +/** + * Generate a deterministic ID from content + * Useful for deduplication + */ +export function generateContentId(content: string): string { + const { createHash } = require('crypto'); + const hash = createHash('sha256').update(content).digest('hex').slice(0, 16); + return `st_${hash}`; +} + +/** + * Reset counter (for testing) + */ +export function resetIdCounter(): void { + counter = 0; +} diff --git a/packages/core/src/iec61131/utils/index.ts b/packages/core/src/iec61131/utils/index.ts new file mode 100644 index 00000000..f75e4542 --- /dev/null +++ b/packages/core/src/iec61131/utils/index.ts @@ -0,0 +1,5 @@ +/** + * IEC 61131-3 Utilities + */ + +export { generateId, generateContentId, resetIdCounter } from './id-generator.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 94aaa4d3..05fb6775 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,7 @@ export type { PatternMatch, PatternMatchResult, AggregatedMatchResult, + Location, Location as MatchLocation, SourceRange, @@ -2025,3 +2026,84 @@ export type { MigrationOptions as StorageMigrationOptions, MigrationResult as StorageMigrationResult, } from './storage/index.js'; + + +// ============================================================================ +// IEC 61131-3 Code Factory (Industrial Automation Analysis) +// ============================================================================ + +export { + // Main analyzer + IEC61131Analyzer, + createAnalyzer, + // Parser + STParser, + parseSTSource, + STTokenizer, + tokenize, + // Extractors + extractDocstrings, + extractDocstringsFromFiles, + extractStateMachines, + extractStateMachinesFromFiles, + extractSafetyInterlocks, + extractSafetyFromFiles, + extractTribalKnowledge, + extractTribalKnowledgeFromFiles, + extractVariables, + extractVariablesFromFiles, + // Utilities + generateId, + generateContentId, +} from './iec61131/index.js'; + +export type { + // Analyzer options + AnalyzerOptions, + // Parser types + ParseResult as STParseResult, + ParseError as STParseError, + ParseWarning as STParseWarning, + ParsedComment, + ParseMetadata, + ParseOptions as STParseOptions, + Token, + TokenType, + // Extractor result types + DocstringExtractionResult, + ExtractedDocstring, + DocstringQuality, + DocstringSummary, + DocstringExtractionOptions, + StateMachineExtractionResult, + ExtractedStateMachine, + StateMachineSummary, + StateMachineExtractionOptions, + SafetyExtractionResult, + SafetyExtractionOptions, + TribalKnowledgeExtractionResult, + ExtractedTribalKnowledge, + TribalKnowledgeSummary, + TribalKnowledgeExtractionOptions, + VariableExtractionResult, + ExtractedVariable, + VariableSummary, + VariableExtractionOptions, + // Core types + STProjectStatus, + STPOU, + SafetyAnalysisResult, + AnalysisOptions as STAnalysisOptions, + StateMachineState, + StateMachineTransition, + StateMachine, + SafetyInterlock, + SafetyBypass, + SafetyCriticalWarning, + TribalKnowledgeItem, + STVariable, + IOMapping, + STDocstring, + STDocParam, + STHistoryEntry, +} from './iec61131/index.js'; diff --git a/packages/core/src/parsers/languages/structured-text/extractors/block-extractor.ts b/packages/core/src/parsers/languages/structured-text/extractors/block-extractor.ts new file mode 100644 index 00000000..a423f4ce --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/extractors/block-extractor.ts @@ -0,0 +1,88 @@ +/** + * ST Block Extractor + * + * Single responsibility: Extract PROGRAM, FUNCTION_BLOCK, FUNCTION definitions + */ + +import type { STBlock, STBlockType } from '../types.js'; + +export interface BlockExtractorResult { + blocks: STBlock[]; + errors: string[]; +} + +const BLOCK_PATTERNS: Record = { + PROGRAM: /^(PROGRAM)\s+(\w+)/i, + FUNCTION_BLOCK: /^(FUNCTION_BLOCK)\s+(\w+)/i, + FUNCTION: /^(FUNCTION)\s+(\w+)\s*:\s*(\w+)/i, +}; + +const END_PATTERNS: Record = { + PROGRAM: /^END_PROGRAM\b/i, + FUNCTION_BLOCK: /^END_FUNCTION_BLOCK\b/i, + FUNCTION: /^END_FUNCTION\b/i, +}; + +export function extractBlocks(source: string): BlockExtractorResult { + const blocks: STBlock[] = []; + const errors: string[] = []; + const lines = source.split('\n'); + + const openBlocks: Array<{ type: STBlockType; name: string; startLine: number; startColumn: number }> = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + const lineNum = i + 1; + + // Check for block starts + for (const [type, pattern] of Object.entries(BLOCK_PATTERNS) as [STBlockType, RegExp][]) { + const match = trimmed.match(pattern); + if (match) { + openBlocks.push({ + type, + name: match[2]!, + startLine: lineNum, + startColumn: line.indexOf(match[0]) + 1, + }); + break; + } + } + + // Check for block ends + for (const [type, pattern] of Object.entries(END_PATTERNS) as [STBlockType, RegExp][]) { + if (pattern.test(trimmed)) { + // Find last matching open block (compatible with older ES targets) + let openIdx = -1; + for (let i = openBlocks.length - 1; i >= 0; i--) { + if (openBlocks[i]!.type === type) { + openIdx = i; + break; + } + } + if (openIdx >= 0) { + const open = openBlocks[openIdx]!; + blocks.push({ + name: open.name, + type: open.type, + startLine: open.startLine, + endLine: lineNum, + startColumn: open.startColumn, + endColumn: line.length, + }); + openBlocks.splice(openIdx, 1); + } else { + errors.push(`Unmatched END_${type} at line ${lineNum}`); + } + break; + } + } + } + + // Report unclosed blocks + for (const open of openBlocks) { + errors.push(`Unclosed ${open.type} '${open.name}' starting at line ${open.startLine}`); + } + + return { blocks, errors }; +} diff --git a/packages/core/src/parsers/languages/structured-text/extractors/comment-extractor.ts b/packages/core/src/parsers/languages/structured-text/extractors/comment-extractor.ts new file mode 100644 index 00000000..1664296a --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/extractors/comment-extractor.ts @@ -0,0 +1,65 @@ +/** + * ST Comment Extractor + * + * Single responsibility: Extract comments from ST source + */ + +import type { STComment } from '../types.js'; + +export interface CommentExtractorResult { + comments: STComment[]; +} + +// Block comment: (* ... *) +const BLOCK_COMMENT = /\(\*[\s\S]*?\*\)/g; + +// Line comment: // ... +const LINE_COMMENT = /\/\/.*$/gm; + +export function extractComments(source: string): CommentExtractorResult { + const comments: STComment[] = []; + + // Extract block comments + let match; + while ((match = BLOCK_COMMENT.exec(source)) !== null) { + const startLine = getLineNumber(source, match.index); + const endLine = getLineNumber(source, match.index + match[0].length); + const startColumn = getColumnNumber(source, match.index); + + comments.push({ + content: match[0], + style: 'block', + startLine, + endLine, + startColumn, + }); + } + + // Extract line comments + while ((match = LINE_COMMENT.exec(source)) !== null) { + const line = getLineNumber(source, match.index); + const column = getColumnNumber(source, match.index); + + comments.push({ + content: match[0], + style: 'line', + startLine: line, + endLine: line, + startColumn: column, + }); + } + + // Sort by position + comments.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn); + + return { comments }; +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function getColumnNumber(source: string, offset: number): number { + const lastNewline = source.lastIndexOf('\n', offset - 1); + return offset - lastNewline; +} diff --git a/packages/core/src/parsers/languages/structured-text/extractors/index.ts b/packages/core/src/parsers/languages/structured-text/extractors/index.ts new file mode 100644 index 00000000..f53e0587 --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/extractors/index.ts @@ -0,0 +1,11 @@ +/** + * ST Extractors Index + * + * Single responsibility: Export all extractors + */ + +export { extractBlocks, type BlockExtractorResult } from './block-extractor.js'; +export { extractVariables, type VariableExtractorResult } from './variable-extractor.js'; +export { extractComments, type CommentExtractorResult } from './comment-extractor.js'; +export { extractTimersAndCounters, type TimerCounterExtractorResult } from './timer-counter-extractor.js'; +export { extractStateMachines, type StateMachineExtractorResult } from './state-machine-extractor.js'; diff --git a/packages/core/src/parsers/languages/structured-text/extractors/state-machine-extractor.ts b/packages/core/src/parsers/languages/structured-text/extractors/state-machine-extractor.ts new file mode 100644 index 00000000..83a7ea16 --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/extractors/state-machine-extractor.ts @@ -0,0 +1,88 @@ +/** + * ST State Machine Extractor + * + * Single responsibility: Extract CASE-based state machines + * + * Detects state machines by looking for CASE statements on variables + * that match common state variable naming patterns (nState, iStep, etc.) + */ + +import type { STStateCase } from '../types.js'; + +export interface StateMachineExtractorResult { + stateCases: STStateCase[]; +} + +// Common state variable naming patterns +const STATE_VAR_PATTERNS = [ + /^n?state$/i, + /^i?step$/i, + /^n?mode$/i, + /^n?phase$/i, + /^seq/i, + /state$/i, + /step$/i, +]; + +// CASE variable OF +const CASE_PATTERN = /CASE\s+(\w+)\s+OF/gi; + +export function extractStateMachines(source: string): StateMachineExtractorResult { + const stateCases: STStateCase[] = []; + + // Reset regex state + const casePattern = new RegExp(CASE_PATTERN.source, CASE_PATTERN.flags); + let match; + + while ((match = casePattern.exec(source)) !== null) { + const variable = match[1]!; + + // Only process if it looks like a state variable + if (!STATE_VAR_PATTERNS.some(p => p.test(variable))) { + continue; + } + + const line = getLineNumber(source, match.index); + + // Extract CASE body until END_CASE + const afterCase = source.slice(match.index); + const endMatch = afterCase.match(/END_CASE/i); + const caseBody = endMatch ? afterCase.slice(0, endMatch.index) : afterCase.slice(0, 2000); + + // Extract states - only match numeric labels at start of line + // Pattern: whitespace, number, colon (not assignments like "nState := 10;") + const states: Array<{ value: number | string; line: number }> = []; + const statePattern = /^\s*(\d+)\s*:/gm; + let stateMatch; + + while ((stateMatch = statePattern.exec(caseBody)) !== null) { + const rawValue = stateMatch[1]!; + const value = parseInt(rawValue, 10); + const stateLine = line + caseBody.slice(0, stateMatch.index).split('\n').length - 1; + + states.push({ value, line: stateLine }); + } + + // Calculate end line + const endLine = endMatch + ? line + caseBody.split('\n').length + : line + Math.min(caseBody.split('\n').length, 50); + + stateCases.push({ + variable, + states, + line, + endLine, + }); + } + + return { stateCases }; +} + +// ============================================================================ +// Utilities +// ============================================================================ + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} diff --git a/packages/core/src/parsers/languages/structured-text/extractors/timer-counter-extractor.ts b/packages/core/src/parsers/languages/structured-text/extractors/timer-counter-extractor.ts new file mode 100644 index 00000000..f8ee61ec --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/extractors/timer-counter-extractor.ts @@ -0,0 +1,45 @@ +/** + * ST Timer/Counter Extractor + * + * Single responsibility: Extract timer and counter instantiations + */ + +import type { STTimerInstance, STCounterInstance, STTimerType, STCounterType } from '../types.js'; + +export interface TimerCounterExtractorResult { + timers: STTimerInstance[]; + counters: STCounterInstance[]; +} + +const TIMER_TYPES: STTimerType[] = ['TON', 'TOF', 'TP', 'TONR']; +const COUNTER_TYPES: STCounterType[] = ['CTU', 'CTD', 'CTUD']; + +// Pattern: instanceName : TimerType or instanceName : CounterType +const INSTANCE_PATTERN = /(\w+)\s*:\s*(TON|TOF|TP|TONR|CTU|CTD|CTUD)\b/gi; + +export function extractTimersAndCounters(source: string): TimerCounterExtractorResult { + const timers: STTimerInstance[] = []; + const counters: STCounterInstance[] = []; + const lines = source.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const lineNum = i + 1; + + let match; + const pattern = new RegExp(INSTANCE_PATTERN.source, INSTANCE_PATTERN.flags); + + while ((match = pattern.exec(line)) !== null) { + const name = match[1]!; + const type = match[2]!.toUpperCase(); + + if (TIMER_TYPES.includes(type as STTimerType)) { + timers.push({ name, type: type as STTimerType, line: lineNum }); + } else if (COUNTER_TYPES.includes(type as STCounterType)) { + counters.push({ name, type: type as STCounterType, line: lineNum }); + } + } + } + + return { timers, counters }; +} diff --git a/packages/core/src/parsers/languages/structured-text/extractors/variable-extractor.ts b/packages/core/src/parsers/languages/structured-text/extractors/variable-extractor.ts new file mode 100644 index 00000000..30b36e20 --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/extractors/variable-extractor.ts @@ -0,0 +1,96 @@ +/** + * ST Variable Extractor + * + * Single responsibility: Extract variable declarations from VAR sections + */ + +import type { STVariable, STVarSection } from '../types.js'; + +export interface VariableExtractorResult { + variables: STVariable[]; + errors: string[]; +} + +const VAR_SECTION_PATTERNS: Record = { + VAR_INPUT: /^VAR_INPUT\b/i, + VAR_OUTPUT: /^VAR_OUTPUT\b/i, + VAR_IN_OUT: /^VAR_IN_OUT\b/i, + VAR_GLOBAL: /^VAR_GLOBAL\b/i, + VAR_TEMP: /^VAR_TEMP\b/i, + VAR: /^VAR\b/i, +}; + +const END_VAR = /^END_VAR\b/i; + +// Variable declaration: name : TYPE := value; (* comment *) +const VAR_DECL = /^\s*(\w+)\s*:\s*(ARRAY\s*\[([^\]]+)\]\s*OF\s*)?(\w+)(?:\s*:=\s*([^;]+))?;?\s*(?:\(\*([^*]*)\*\))?/i; + +export function extractVariables(source: string): VariableExtractorResult { + const variables: STVariable[] = []; + const errors: string[] = []; + const lines = source.split('\n'); + + let currentSection: STVarSection | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + const lineNum = i + 1; + + // Check for section start + if (currentSection === null) { + for (const [section, pattern] of Object.entries(VAR_SECTION_PATTERNS) as [STVarSection, RegExp][]) { + if (pattern.test(trimmed)) { + currentSection = section; + break; + } + } + continue; + } + + // Check for section end + if (END_VAR.test(trimmed)) { + currentSection = null; + continue; + } + + // Skip empty lines and comments in VAR section + if (!trimmed || trimmed.startsWith('(*') || trimmed.startsWith('//')) { + continue; + } + + // Parse variable declaration + const match = trimmed.match(VAR_DECL); + if (match) { + const [, name, arrayPart, arrayBounds, dataType, initialValue, comment] = match; + + const variable: STVariable = { + name: name!, + dataType: dataType!, + section: currentSection, + line: lineNum, + isArray: !!arrayPart, + }; + + if (initialValue) variable.initialValue = initialValue.trim(); + if (comment) variable.comment = comment.trim(); + + if (arrayBounds) { + const bounds = parseArrayBounds(arrayBounds); + if (bounds) variable.arrayBounds = bounds; + } + + variables.push(variable); + } + } + + return { variables, errors }; +} + +function parseArrayBounds(bounds: string): { low: number; high: number } | null { + const match = bounds.match(/(\d+)\s*\.\.\s*(\d+)/); + if (match) { + return { low: parseInt(match[1]!, 10), high: parseInt(match[2]!, 10) }; + } + return null; +} diff --git a/packages/core/src/parsers/languages/structured-text/index.ts b/packages/core/src/parsers/languages/structured-text/index.ts new file mode 100644 index 00000000..414b0565 --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/index.ts @@ -0,0 +1,9 @@ +/** + * Structured Text Parser Module + * + * Single responsibility: Export public API + */ + +export { STParser, type STExtendedParseResult } from './st-parser.js'; +export * from './types.js'; +export * from './extractors/index.js'; diff --git a/packages/core/src/parsers/languages/structured-text/st-parser.ts b/packages/core/src/parsers/languages/structured-text/st-parser.ts new file mode 100644 index 00000000..6a322455 --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/st-parser.ts @@ -0,0 +1,100 @@ +/** + * ST Parser - Orchestrator + * + * Single responsibility: Orchestrate extractors, produce unified parse result + */ + +import { BaseParser } from '../../base-parser.js'; +import { extractBlocks } from './extractors/block-extractor.js'; +import { extractVariables } from './extractors/variable-extractor.js'; +import { extractComments } from './extractors/comment-extractor.js'; +import { extractTimersAndCounters } from './extractors/timer-counter-extractor.js'; +import { extractStateMachines } from './extractors/state-machine-extractor.js'; + +import type { AST, ASTNode, Language, ParseResult, Position } from '../../types.js'; +import type { STParseResult, STBlock } from './types.js'; + +/** + * Extended parse result with ST-specific data + */ +export interface STExtendedParseResult extends ParseResult { + st: STParseResult; +} + +export class STParser extends BaseParser { + readonly language: Language = 'structured-text' as Language; + readonly extensions: string[] = ['.st', '.stx', '.scl', '.pou', '.exp']; + + parse(source: string, _filePath?: string): STExtendedParseResult { + const errors: string[] = []; + + // Run all extractors (single responsibility each) + const blockResult = extractBlocks(source); + const variableResult = extractVariables(source); + const commentResult = extractComments(source); + const timerCounterResult = extractTimersAndCounters(source); + const stateMachineResult = extractStateMachines(source); + + // Collect errors + errors.push(...blockResult.errors); + errors.push(...variableResult.errors); + + // Build AST from blocks + const ast = this.buildAST(source, blockResult.blocks); + + // Build ST-specific result + const stResult: STParseResult = { + blocks: blockResult.blocks, + variables: variableResult.variables, + comments: commentResult.comments, + timers: timerCounterResult.timers, + counters: timerCounterResult.counters, + stateCases: stateMachineResult.stateCases, + errors, + }; + + return { + ast, + language: this.language, + errors: errors.map(msg => ({ message: msg, position: { row: 0, column: 0 } })), + success: errors.length === 0, + st: stResult, + }; + } + + query(ast: AST, pattern: string): ASTNode[] { + // Query by node type + return this.findNodesByType(ast, pattern); + } + + private buildAST(source: string, blocks: STBlock[]): AST { + const lines = source.split('\n'); + const children: ASTNode[] = []; + + for (const block of blocks) { + const blockText = lines.slice(block.startLine - 1, block.endLine).join('\n'); + + children.push(this.createNode( + block.type, + blockText, + { row: block.startLine - 1, column: block.startColumn - 1 }, + { row: block.endLine - 1, column: block.endColumn - 1 }, + [] + )); + } + + const endPos: Position = lines.length > 0 + ? { row: lines.length - 1, column: lines[lines.length - 1]?.length ?? 0 } + : { row: 0, column: 0 }; + + const rootNode = this.createNode( + 'SourceFile', + source, + { row: 0, column: 0 }, + endPos, + children + ); + + return this.createAST(rootNode, source); + } +} diff --git a/packages/core/src/parsers/languages/structured-text/types.ts b/packages/core/src/parsers/languages/structured-text/types.ts new file mode 100644 index 00000000..31d0c98d --- /dev/null +++ b/packages/core/src/parsers/languages/structured-text/types.ts @@ -0,0 +1,105 @@ +/** + * IEC 61131-3 Structured Text Types + * + * Single responsibility: Type definitions only + */ + +// ============================================================================ +// Block Types +// ============================================================================ + +export type STBlockType = 'PROGRAM' | 'FUNCTION_BLOCK' | 'FUNCTION'; + +export interface STBlock { + name: string; + type: STBlockType; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; +} + +// ============================================================================ +// Variable Types +// ============================================================================ + +export type STVarSection = 'VAR_INPUT' | 'VAR_OUTPUT' | 'VAR_IN_OUT' | 'VAR' | 'VAR_GLOBAL' | 'VAR_TEMP'; + +export interface STVariable { + name: string; + dataType: string; + section: STVarSection; + initialValue?: string; + comment?: string; + isArray: boolean; + arrayBounds?: { low: number; high: number }; + line: number; +} + +// ============================================================================ +// Comment Types +// ============================================================================ + +export interface STComment { + content: string; + style: 'block' | 'line'; // (* *) vs // + startLine: number; + endLine: number; + startColumn: number; +} + +// ============================================================================ +// Function Block Info +// ============================================================================ + +export interface STFunctionBlockInfo extends STBlock { + inputs: STVariable[]; + outputs: STVariable[]; + inOuts: STVariable[]; + locals: STVariable[]; + temps: STVariable[]; +} + +// ============================================================================ +// Timer/Counter Types +// ============================================================================ + +export type STTimerType = 'TON' | 'TOF' | 'TP' | 'TONR'; +export type STCounterType = 'CTU' | 'CTD' | 'CTUD'; + +export interface STTimerInstance { + name: string; + type: STTimerType; + line: number; +} + +export interface STCounterInstance { + name: string; + type: STCounterType; + line: number; +} + +// ============================================================================ +// State Machine Types +// ============================================================================ + +export interface STStateCase { + variable: string; + states: Array<{ value: number | string; line: number }>; + line: number; + endLine: number; +} + +// ============================================================================ +// Parse Result +// ============================================================================ + +export interface STParseResult { + blocks: STBlock[]; + variables: STVariable[]; + comments: STComment[]; + timers: STTimerInstance[]; + counters: STCounterInstance[]; + stateCases: STStateCase[]; + errors: string[]; +} diff --git a/packages/core/src/parsers/parser-manager.ts b/packages/core/src/parsers/parser-manager.ts index 7b584d17..f176b406 100644 --- a/packages/core/src/parsers/parser-manager.ts +++ b/packages/core/src/parsers/parser-manager.ts @@ -140,6 +140,12 @@ const EXTENSION_TO_LANGUAGE: Record = { '.md': 'markdown', '.markdown': 'markdown', '.mdx': 'markdown', + // IEC 61131-3 Structured Text + '.st': 'structured-text', + '.stx': 'structured-text', + '.scl': 'structured-text', // Siemens SCL + '.pou': 'structured-text', // Program Organization Unit + '.exp': 'structured-text', // PLCopen XML export }; const DEFAULT_OPTIONS: ParserManagerOptions = { diff --git a/packages/core/src/parsers/types.ts b/packages/core/src/parsers/types.ts index cfc88c4a..5ceadd00 100644 --- a/packages/core/src/parsers/types.ts +++ b/packages/core/src/parsers/types.ts @@ -18,7 +18,8 @@ export type Language = | 'scss' | 'json' | 'yaml' - | 'markdown'; + | 'markdown' + | 'structured-text'; export interface ParseResult { /** The parsed AST */ diff --git a/packages/core/src/services/detector-worker.ts b/packages/core/src/services/detector-worker.ts index fa856a55..98fbf584 100644 --- a/packages/core/src/services/detector-worker.ts +++ b/packages/core/src/services/detector-worker.ts @@ -13,14 +13,98 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { - createAllDetectorsArray, - type BaseDetector, - type DetectionContext, -} from 'driftdetect-detectors'; - import type { Language, PatternMatch } from '../index.js'; +// ============================================================================ +// Dynamic Detector Import (breaks cyclic dependency with detectors package) +// ============================================================================ + +/** + * Detector types - defined locally to avoid static import from detectors package + */ +interface BaseDetector { + id: string; + getInfo(): { + name: string; + description: string; + category: string; + subcategory: string; + supportedLanguages: Language[]; + }; + detect(context: DetectionContext): Promise<{ + patterns: PatternMatch[]; + violations: Array<{ + id: string; + patternId: string; + severity: 'error' | 'warning' | 'info' | 'hint'; + file: string; + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + message: string; + expected: string; + actual: string; + explanation?: string; + aiExplainAvailable: boolean; + aiFixAvailable: boolean; + firstSeen: Date; + occurrences: number; + }>; + metadata?: { custom?: Record }; + }>; +} + +interface DetectionContext { + file: string; + content: string; + language: Language; + ast: unknown; + imports: string[]; + exports: string[]; + extension: string; + isTestFile: boolean; + isTypeDefinition: boolean; + projectContext: { + rootDir: string; + files: string[]; + config: Record; + }; +} + +interface DetectorsModule { + createAllDetectorsArray: () => Promise; +} + +let detectorsModule: DetectorsModule | null = null; +let detectorsLoadPromise: Promise | null = null; + +/** + * Dynamically load the detectors module + * This breaks the cyclic dependency between core and detectors packages + */ +async function loadDetectorsModule(): Promise { + if (detectorsModule) { + return detectorsModule; + } + + if (detectorsLoadPromise) { + return detectorsLoadPromise; + } + + detectorsLoadPromise = import('driftdetect-detectors').then((mod) => { + detectorsModule = mod as unknown as DetectorsModule; + return detectorsModule; + }); + + return detectorsLoadPromise; +} + +/** + * Create all detectors array (dynamic wrapper) + */ +async function createAllDetectorsArray(): Promise { + const mod = await loadDetectorsModule(); + return mod.createAllDetectorsArray(); +} + // ============================================================================ // Types // ============================================================================ diff --git a/packages/core/src/services/detectors.d.ts b/packages/core/src/services/detectors.d.ts new file mode 100644 index 00000000..82e7c59a --- /dev/null +++ b/packages/core/src/services/detectors.d.ts @@ -0,0 +1,61 @@ +/** + * Type declarations for driftdetect-detectors module + * + * This module is dynamically imported at runtime to break the cyclic dependency + * between core and detectors packages. The detectors package depends on core, + * but core needs to use detectors at runtime for scanning. + */ + +declare module 'driftdetect-detectors' { + import type { Language, PatternMatch } from '../index.js'; + + interface BaseDetector { + id: string; + getInfo(): { + name: string; + description: string; + category: string; + subcategory: string; + supportedLanguages: Language[]; + }; + detect(context: DetectionContext): Promise<{ + patterns: PatternMatch[]; + violations: Array<{ + id: string; + patternId: string; + severity: 'error' | 'warning' | 'info' | 'hint'; + file: string; + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + message: string; + expected: string; + actual: string; + explanation?: string; + aiExplainAvailable: boolean; + aiFixAvailable: boolean; + firstSeen: Date; + occurrences: number; + }>; + metadata?: { custom?: Record }; + }>; + } + + interface DetectionContext { + file: string; + content: string; + language: Language; + ast: unknown; + imports: string[]; + exports: string[]; + extension: string; + isTestFile: boolean; + isTypeDefinition: boolean; + projectContext: { + rootDir: string; + files: string[]; + config: Record; + }; + } + + export function createAllDetectorsArray(): Promise; + export function getDetectorCounts(): Record; +} diff --git a/packages/core/src/services/scanner-service.ts b/packages/core/src/services/scanner-service.ts index b7a0e70e..fa6adac9 100644 --- a/packages/core/src/services/scanner-service.ts +++ b/packages/core/src/services/scanner-service.ts @@ -28,19 +28,111 @@ import { OutlierDetector, } from '../index.js'; -import { - createAllDetectorsArray, - getDetectorCounts, - type BaseDetector, - type DetectionContext, -} from 'driftdetect-detectors'; - import type { DetectorWorkerTask, DetectorWorkerResult, WorkerPatternMatch, } from './detector-worker.js'; +// ============================================================================ +// Dynamic Detector Import (breaks cyclic dependency with detectors package) +// ============================================================================ + +/** + * Detector types - imported dynamically to avoid cyclic dependency + */ +interface BaseDetector { + id: string; + getInfo(): { + name: string; + description: string; + category: string; + subcategory: string; + supportedLanguages: Language[]; + }; + detect(context: DetectionContext): Promise<{ + patterns: PatternMatch[]; + violations: Array<{ + id: string; + patternId: string; + severity: 'error' | 'warning' | 'info' | 'hint'; + file: string; + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + message: string; + expected: string; + actual: string; + explanation?: string; + aiExplainAvailable: boolean; + aiFixAvailable: boolean; + firstSeen: Date; + occurrences: number; + }>; + metadata?: { custom?: Record }; + }>; +} + +interface DetectionContext { + file: string; + content: string; + language: Language; + ast: unknown; + imports: string[]; + exports: string[]; + extension: string; + isTestFile: boolean; + isTypeDefinition: boolean; + projectContext: { + rootDir: string; + files: string[]; + config: Record; + }; +} + +interface DetectorsModule { + createAllDetectorsArray: () => Promise; + getDetectorCounts: () => Record; +} + +let detectorsModule: DetectorsModule | null = null; +let detectorsLoadPromise: Promise | null = null; + +/** + * Dynamically load the detectors module + * This breaks the cyclic dependency between core and detectors packages + */ +async function loadDetectorsModule(): Promise { + if (detectorsModule) { + return detectorsModule; + } + + if (detectorsLoadPromise) { + return detectorsLoadPromise; + } + + detectorsLoadPromise = import('driftdetect-detectors').then((mod) => { + detectorsModule = mod as unknown as DetectorsModule; + return detectorsModule; + }); + + return detectorsLoadPromise; +} + +/** + * Create all detectors array (dynamic wrapper) + */ +async function createAllDetectorsArray(): Promise { + const mod = await loadDetectorsModule(); + return mod.createAllDetectorsArray(); +} + +/** + * Get detector counts by category (dynamic wrapper) + */ +async function getDetectorCounts(): Promise> { + const mod = await loadDetectorsModule(); + return mod.getDetectorCounts(); +} + // Get the directory of this module for worker path resolution const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -454,8 +546,8 @@ export class ScannerService { /** * Get detector counts by category */ - getDetectorCounts() { - return getDetectorCounts(); + async getDetectorCounts() { + return await getDetectorCounts(); } /** diff --git a/packages/core/src/storage/schema-iec61131.sql b/packages/core/src/storage/schema-iec61131.sql new file mode 100644 index 00000000..e180036f --- /dev/null +++ b/packages/core/src/storage/schema-iec61131.sql @@ -0,0 +1,197 @@ +-- ============================================================================ +-- IEC 61131-3 Docstring Storage Schema +-- +-- This schema stores extracted documentation from industrial automation code. +-- It's a new extraction type for drift - capturing institutional knowledge +-- from legacy PLC codebases. +-- ============================================================================ + +-- ============================================================================ +-- DOCSTRINGS (Primary extraction - PhD's request) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS st_docstrings ( + id TEXT PRIMARY KEY, + file TEXT NOT NULL, + line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + + -- Content + summary TEXT, + description TEXT, + raw TEXT, -- Original comment text + + -- Associated code block + associated_block TEXT, -- PROGRAM/FUNCTION_BLOCK/FUNCTION name + block_type TEXT CHECK (block_type IN ('PROGRAM', 'FUNCTION_BLOCK', 'FUNCTION')), + + -- Structured data (JSON) + params TEXT, -- JSON array of {name, type, description} + returns TEXT, -- Return value description + author TEXT, + date TEXT, + history TEXT, -- JSON array of {year, author, description} + warnings TEXT, -- JSON array of warning strings + tags TEXT, -- JSON array of custom tags + + -- Metadata + language TEXT DEFAULT 'structured-text', + confidence REAL DEFAULT 1.0, + extracted_at TEXT NOT NULL DEFAULT (datetime('now')), + + UNIQUE(file, line) +); + +-- ============================================================================ +-- STATE MACHINES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS st_state_machines ( + id TEXT PRIMARY KEY, + file TEXT NOT NULL, + line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + + -- State machine info + variable TEXT NOT NULL, -- e.g., nState, iStep + state_count INTEGER NOT NULL, + states TEXT NOT NULL, -- JSON array of {value, line, label, hasComment} + + -- Associated block + parent_block TEXT, + + -- Metadata + extracted_at TEXT NOT NULL DEFAULT (datetime('now')), + + UNIQUE(file, line, variable) +); + +-- ============================================================================ +-- SAFETY INTERLOCKS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS st_safety_interlocks ( + id TEXT PRIMARY KEY, + file TEXT NOT NULL, + line INTEGER NOT NULL, + + -- Interlock info + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('interlock', 'permissive', 'estop', 'bypass')), + is_bypassed INTEGER DEFAULT 0, + + -- Context + parent_block TEXT, + context TEXT, -- Surrounding code + + -- Metadata + severity TEXT DEFAULT 'info' CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')), + extracted_at TEXT NOT NULL DEFAULT (datetime('now')), + + UNIQUE(file, name) +); + +-- ============================================================================ +-- TRIBAL KNOWLEDGE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS st_tribal_knowledge ( + id TEXT PRIMARY KEY, + file TEXT NOT NULL, + line INTEGER NOT NULL, + + -- Knowledge item + type TEXT NOT NULL CHECK (type IN ( + 'warning', 'caution', 'note', 'todo', 'hack', 'workaround', + 'do-not-change', 'magic-number', 'history', 'author', 'equipment' + )), + content TEXT NOT NULL, + context TEXT, -- Surrounding code + + -- Metadata + importance TEXT DEFAULT 'medium' CHECK (importance IN ('critical', 'high', 'medium', 'low')), + extracted_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ============================================================================ +-- PLC BLOCKS (PROGRAM, FUNCTION_BLOCK, FUNCTION) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS st_blocks ( + id TEXT PRIMARY KEY, + file TEXT NOT NULL, + line INTEGER NOT NULL, + end_line INTEGER, + + -- Block info + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('PROGRAM', 'FUNCTION_BLOCK', 'FUNCTION')), + + -- Variables (JSON arrays) + var_input TEXT, + var_output TEXT, + var_in_out TEXT, + var_local TEXT, + var_temp TEXT, + + -- Metadata + has_docstring INTEGER DEFAULT 0, + docstring_id TEXT REFERENCES st_docstrings(id), + extracted_at TEXT NOT NULL DEFAULT (datetime('now')), + + UNIQUE(file, name) +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_st_docstrings_file ON st_docstrings(file); +CREATE INDEX IF NOT EXISTS idx_st_docstrings_block ON st_docstrings(associated_block); +CREATE INDEX IF NOT EXISTS idx_st_state_machines_file ON st_state_machines(file); +CREATE INDEX IF NOT EXISTS idx_st_state_machines_variable ON st_state_machines(variable); +CREATE INDEX IF NOT EXISTS idx_st_safety_file ON st_safety_interlocks(file); +CREATE INDEX IF NOT EXISTS idx_st_safety_type ON st_safety_interlocks(type); +CREATE INDEX IF NOT EXISTS idx_st_safety_bypassed ON st_safety_interlocks(is_bypassed); +CREATE INDEX IF NOT EXISTS idx_st_tribal_file ON st_tribal_knowledge(file); +CREATE INDEX IF NOT EXISTS idx_st_tribal_type ON st_tribal_knowledge(type); +CREATE INDEX IF NOT EXISTS idx_st_blocks_file ON st_blocks(file); +CREATE INDEX IF NOT EXISTS idx_st_blocks_type ON st_blocks(type); + +-- ============================================================================ +-- VIEWS +-- ============================================================================ + +-- Summary view for IEC 61131-3 analysis +CREATE VIEW IF NOT EXISTS v_st_summary AS +SELECT + (SELECT COUNT(*) FROM st_docstrings) as total_docstrings, + (SELECT COUNT(*) FROM st_state_machines) as total_state_machines, + (SELECT COUNT(*) FROM st_safety_interlocks) as total_interlocks, + (SELECT COUNT(*) FROM st_safety_interlocks WHERE is_bypassed = 1) as bypassed_interlocks, + (SELECT COUNT(*) FROM st_tribal_knowledge) as total_tribal_knowledge, + (SELECT COUNT(*) FROM st_blocks) as total_blocks; + +-- Docstrings with their blocks +CREATE VIEW IF NOT EXISTS v_st_documented_blocks AS +SELECT + b.name as block_name, + b.type as block_type, + b.file, + b.line as block_line, + d.summary, + d.params, + d.history, + d.warnings +FROM st_blocks b +LEFT JOIN st_docstrings d ON b.docstring_id = d.id +ORDER BY b.file, b.line; + +-- Safety overview +CREATE VIEW IF NOT EXISTS v_st_safety_overview AS +SELECT + type, + COUNT(*) as count, + SUM(CASE WHEN is_bypassed = 1 THEN 1 ELSE 0 END) as bypassed_count +FROM st_safety_interlocks +GROUP BY type; diff --git a/packages/core/src/storage/schema.sql b/packages/core/src/storage/schema.sql index d1ae7684..cdebeb83 100644 --- a/packages/core/src/storage/schema.sql +++ b/packages/core/src/storage/schema.sql @@ -889,6 +889,357 @@ SELECT (SELECT COUNT(*) FROM data_access_points WHERE table_name IN (SELECT table_name FROM sensitive_fields)) as sensitive_access_count; +-- ============================================================================ +-- IEC 61131-3 CODE FACTORY TABLES +-- ============================================================================ +-- Enterprise-grade analysis for industrial automation code. +-- Stores POUs, state machines, safety interlocks, tribal knowledge, and migration scores. +-- ============================================================================ + +-- ST Files (Structured Text source files) +CREATE TABLE IF NOT EXISTS st_files ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + vendor TEXT, + language TEXT DEFAULT 'st', + line_count INTEGER, + hash TEXT NOT NULL, + parsed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Program Organization Units (POUs) +CREATE TABLE IF NOT EXISTS st_pous ( + id TEXT PRIMARY KEY, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('PROGRAM', 'FUNCTION_BLOCK', 'FUNCTION', 'CLASS', 'INTERFACE')), + name TEXT NOT NULL, + qualified_name TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + extends TEXT, + implements JSON, + vendor_attributes JSON, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ST Variables +CREATE TABLE IF NOT EXISTS st_variables ( + id TEXT PRIMARY KEY, + pou_id TEXT REFERENCES st_pous(id) ON DELETE CASCADE, + file_id TEXT REFERENCES st_files(id) ON DELETE CASCADE, + section TEXT NOT NULL CHECK(section IN ('VAR_INPUT', 'VAR_OUTPUT', 'VAR_IN_OUT', 'VAR', 'VAR_GLOBAL', 'VAR_TEMP', 'VAR_CONSTANT', 'VAR_EXTERNAL')), + name TEXT NOT NULL, + data_type TEXT NOT NULL, + initial_value TEXT, + is_array INTEGER DEFAULT 0, + array_bounds JSON, + comment TEXT, + line_number INTEGER, + is_safety_critical INTEGER DEFAULT 0, + io_address TEXT +); + +-- ST Docstrings (Documentation extracted from comments) +CREATE TABLE IF NOT EXISTS st_docstrings ( + id TEXT PRIMARY KEY, + pou_id TEXT REFERENCES st_pous(id) ON DELETE CASCADE, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + summary TEXT, + description TEXT, + raw_text TEXT, + author TEXT, + date TEXT, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + associated_block TEXT, + associated_block_type TEXT, + quality_score REAL DEFAULT 0.0, + extracted_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Docstring parameters +CREATE TABLE IF NOT EXISTS st_doc_params ( + id TEXT PRIMARY KEY, + docstring_id TEXT NOT NULL REFERENCES st_docstrings(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + param_type TEXT, + direction TEXT CHECK(direction IN ('in', 'out', 'inout')) +); + +-- Docstring history entries +CREATE TABLE IF NOT EXISTS st_doc_history ( + id TEXT PRIMARY KEY, + docstring_id TEXT NOT NULL REFERENCES st_docstrings(id) ON DELETE CASCADE, + date TEXT, + author TEXT, + description TEXT NOT NULL +); + +-- State machines +CREATE TABLE IF NOT EXISTS st_state_machines ( + id TEXT PRIMARY KEY, + pou_id TEXT NOT NULL REFERENCES st_pous(id) ON DELETE CASCADE, + name TEXT NOT NULL, + state_variable TEXT NOT NULL, + state_variable_type TEXT, + state_count INTEGER NOT NULL, + has_deadlocks INTEGER DEFAULT 0, + has_gaps INTEGER DEFAULT 0, + unreachable_states JSON, + gap_values JSON, + mermaid_diagram TEXT, + ascii_diagram TEXT, + plantuml_diagram TEXT, + start_line INTEGER, + end_line INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- State machine states +CREATE TABLE IF NOT EXISTS st_sm_states ( + id TEXT PRIMARY KEY, + state_machine_id TEXT NOT NULL REFERENCES st_state_machines(id) ON DELETE CASCADE, + value TEXT NOT NULL, + name TEXT, + documentation TEXT, + is_initial INTEGER DEFAULT 0, + is_final INTEGER DEFAULT 0, + actions JSON, + line_number INTEGER +); + +-- State machine transitions +CREATE TABLE IF NOT EXISTS st_sm_transitions ( + id TEXT PRIMARY KEY, + state_machine_id TEXT NOT NULL REFERENCES st_state_machines(id) ON DELETE CASCADE, + from_state_id TEXT NOT NULL REFERENCES st_sm_states(id) ON DELETE CASCADE, + to_state_id TEXT NOT NULL REFERENCES st_sm_states(id) ON DELETE CASCADE, + guard_condition TEXT, + actions JSON, + documentation TEXT, + line_number INTEGER +); + +-- Safety interlocks +CREATE TABLE IF NOT EXISTS st_safety_interlocks ( + id TEXT PRIMARY KEY, + pou_id TEXT REFERENCES st_pous(id) ON DELETE CASCADE, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('interlock', 'estop', 'permissive', 'safety-relay', 'safety-device', 'bypass')), + line_number INTEGER, + is_bypassed INTEGER DEFAULT 0, + bypass_condition TEXT, + confidence REAL DEFAULT 0.0, + severity TEXT CHECK(severity IN ('critical', 'high', 'medium', 'low')), + related_interlocks JSON +); + +-- Safety bypasses (CRITICAL - must track all bypasses) +CREATE TABLE IF NOT EXISTS st_safety_bypasses ( + id TEXT PRIMARY KEY, + pou_id TEXT REFERENCES st_pous(id) ON DELETE CASCADE, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + name TEXT NOT NULL, + line_number INTEGER, + affected_interlocks JSON, + condition TEXT, + severity TEXT CHECK(severity IN ('critical', 'high', 'medium', 'low')), + detected_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Safety critical warnings +CREATE TABLE IF NOT EXISTS st_safety_warnings ( + id TEXT PRIMARY KEY, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('bypass-detected', 'unprotected-output', 'missing-estop', 'interlock-gap')), + message TEXT NOT NULL, + severity TEXT CHECK(severity IN ('critical', 'high', 'medium', 'low')), + line_number INTEGER, + remediation TEXT +); + +-- Tribal knowledge +CREATE TABLE IF NOT EXISTS st_tribal_knowledge ( + id TEXT PRIMARY KEY, + pou_id TEXT REFERENCES st_pous(id) ON DELETE CASCADE, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('warning', 'caution', 'danger', 'note', 'todo', 'fixme', 'hack', 'workaround', 'do-not-change', 'magic-number', 'history', 'author', 'equipment', 'mystery')), + content TEXT NOT NULL, + context TEXT, + line_number INTEGER, + importance TEXT CHECK(importance IN ('critical', 'high', 'medium', 'low')), + extracted_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- I/O mappings +CREATE TABLE IF NOT EXISTS st_io_mappings ( + id TEXT PRIMARY KEY, + pou_id TEXT REFERENCES st_pous(id) ON DELETE CASCADE, + file_id TEXT NOT NULL REFERENCES st_files(id) ON DELETE CASCADE, + address TEXT NOT NULL, + address_type TEXT NOT NULL CHECK(address_type IN ('IX', 'QX', 'IW', 'QW', 'ID', 'QD', 'IB', 'QB', 'MW', 'MD', 'MB')), + variable_name TEXT, + description TEXT, + line_number INTEGER, + is_input INTEGER DEFAULT 1, + bit_size INTEGER DEFAULT 1 +); + +-- ST Call graph edges +CREATE TABLE IF NOT EXISTS st_call_graph ( + id TEXT PRIMARY KEY, + caller_pou_id TEXT NOT NULL REFERENCES st_pous(id) ON DELETE CASCADE, + callee_pou_id TEXT REFERENCES st_pous(id) ON DELETE SET NULL, + callee_name TEXT NOT NULL, + call_type TEXT NOT NULL CHECK(call_type IN ('instantiation', 'method_call', 'function_call')), + line_number INTEGER, + arguments JSON +); + +-- Migration scores +CREATE TABLE IF NOT EXISTS st_migration_scores ( + id TEXT PRIMARY KEY, + pou_id TEXT NOT NULL REFERENCES st_pous(id) ON DELETE CASCADE, + overall_score REAL NOT NULL, + documentation_score REAL, + safety_score REAL, + complexity_score REAL, + dependencies_score REAL, + testability_score REAL, + grade TEXT CHECK(grade IN ('A', 'B', 'C', 'D', 'F')), + blockers JSON, + warnings JSON, + suggestions JSON, + calculated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- AI Context exports +CREATE TABLE IF NOT EXISTS st_ai_contexts ( + id TEXT PRIMARY KEY, + target_language TEXT NOT NULL, + context_json TEXT NOT NULL, + token_estimate INTEGER, + generated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Analysis runs for ST files +CREATE TABLE IF NOT EXISTS st_analysis_runs ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL CHECK(status IN ('running', 'completed', 'failed')), + files_analyzed INTEGER DEFAULT 0, + pous_found INTEGER DEFAULT 0, + state_machines_found INTEGER DEFAULT 0, + interlocks_found INTEGER DEFAULT 0, + bypasses_found INTEGER DEFAULT 0, + tribal_knowledge_found INTEGER DEFAULT 0, + errors JSON, + summary JSON +); + +-- ============================================================================ +-- IEC 61131-3 INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_st_files_path ON st_files(path); +CREATE INDEX IF NOT EXISTS idx_st_pous_file ON st_pous(file_id); +CREATE INDEX IF NOT EXISTS idx_st_pous_type ON st_pous(type); +CREATE INDEX IF NOT EXISTS idx_st_pous_name ON st_pous(name); +CREATE INDEX IF NOT EXISTS idx_st_variables_pou ON st_variables(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_variables_safety ON st_variables(is_safety_critical); +CREATE INDEX IF NOT EXISTS idx_st_variables_io ON st_variables(io_address); +CREATE INDEX IF NOT EXISTS idx_st_docstrings_pou ON st_docstrings(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_docstrings_file ON st_docstrings(file_id); +CREATE INDEX IF NOT EXISTS idx_st_state_machines_pou ON st_state_machines(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_sm_states_machine ON st_sm_states(state_machine_id); +CREATE INDEX IF NOT EXISTS idx_st_sm_transitions_machine ON st_sm_transitions(state_machine_id); +CREATE INDEX IF NOT EXISTS idx_st_safety_interlocks_type ON st_safety_interlocks(type); +CREATE INDEX IF NOT EXISTS idx_st_safety_interlocks_bypassed ON st_safety_interlocks(is_bypassed); +CREATE INDEX IF NOT EXISTS idx_st_safety_interlocks_pou ON st_safety_interlocks(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_safety_bypasses_pou ON st_safety_bypasses(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_tribal_knowledge_type ON st_tribal_knowledge(type); +CREATE INDEX IF NOT EXISTS idx_st_tribal_knowledge_importance ON st_tribal_knowledge(importance); +CREATE INDEX IF NOT EXISTS idx_st_tribal_knowledge_pou ON st_tribal_knowledge(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_io_mappings_address ON st_io_mappings(address); +CREATE INDEX IF NOT EXISTS idx_st_io_mappings_pou ON st_io_mappings(pou_id); +CREATE INDEX IF NOT EXISTS idx_st_call_graph_caller ON st_call_graph(caller_pou_id); +CREATE INDEX IF NOT EXISTS idx_st_call_graph_callee ON st_call_graph(callee_pou_id); +CREATE INDEX IF NOT EXISTS idx_st_migration_scores_grade ON st_migration_scores(grade); +CREATE INDEX IF NOT EXISTS idx_st_migration_scores_pou ON st_migration_scores(pou_id); + +-- ============================================================================ +-- IEC 61131-3 VIEWS +-- ============================================================================ + +-- ST Project status view +CREATE VIEW IF NOT EXISTS v_st_status AS +SELECT + (SELECT COUNT(*) FROM st_files) as total_files, + (SELECT COUNT(*) FROM st_pous) as total_pous, + (SELECT COUNT(*) FROM st_pous WHERE type = 'PROGRAM') as programs, + (SELECT COUNT(*) FROM st_pous WHERE type = 'FUNCTION_BLOCK') as function_blocks, + (SELECT COUNT(*) FROM st_pous WHERE type = 'FUNCTION') as functions, + (SELECT COUNT(*) FROM st_state_machines) as state_machines, + (SELECT COUNT(*) FROM st_safety_interlocks) as safety_interlocks, + (SELECT COUNT(*) FROM st_safety_bypasses) as safety_bypasses, + (SELECT COUNT(*) FROM st_tribal_knowledge) as tribal_knowledge, + (SELECT COUNT(*) FROM st_docstrings) as docstrings, + (SELECT AVG(overall_score) FROM st_migration_scores) as avg_migration_score; + +-- Safety summary view +CREATE VIEW IF NOT EXISTS v_st_safety_summary AS +SELECT + (SELECT COUNT(*) FROM st_safety_interlocks WHERE type = 'interlock') as interlocks, + (SELECT COUNT(*) FROM st_safety_interlocks WHERE type = 'estop') as estops, + (SELECT COUNT(*) FROM st_safety_interlocks WHERE type = 'permissive') as permissives, + (SELECT COUNT(*) FROM st_safety_interlocks WHERE type = 'safety-relay') as safety_relays, + (SELECT COUNT(*) FROM st_safety_interlocks WHERE type = 'safety-device') as safety_devices, + (SELECT COUNT(*) FROM st_safety_bypasses) as bypasses, + (SELECT COUNT(*) FROM st_safety_warnings WHERE severity = 'critical') as critical_warnings; + +-- Migration readiness view +CREATE VIEW IF NOT EXISTS v_st_migration_readiness AS +SELECT + p.id as pou_id, + p.name as pou_name, + p.type as pou_type, + m.overall_score, + m.grade, + m.documentation_score, + m.safety_score, + m.complexity_score, + m.dependencies_score, + m.testability_score +FROM st_pous p +LEFT JOIN st_migration_scores m ON p.id = m.pou_id +ORDER BY m.overall_score DESC; + +-- ============================================================================ +-- IEC 61131-3 FULL-TEXT SEARCH +-- ============================================================================ + +CREATE VIRTUAL TABLE IF NOT EXISTS fts_st_docstrings USING fts5( + pou_id, + summary, + description, + raw_text, + content='st_docstrings', + content_rowid='rowid' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS fts_st_tribal_knowledge USING fts5( + pou_id, + type, + content, + context, + content='st_tribal_knowledge', + content_rowid='rowid' +); + -- ============================================================================ -- SCHEMA VERSION -- ============================================================================ diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 8563e100..daaf1320 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -21,6 +21,9 @@ export type { // Analysis types export * from './analysis.js'; +// Contract types (BE↔FE mismatch detection) +export * from './contracts.js'; + // Common types (selective re-export to avoid conflicts) export type { Severity, PatternCategory, PatternStatus } from './common.js'; export type { Language } from '../parsers/types.js'; diff --git a/packages/core/src/wrappers/integration/adapter.ts b/packages/core/src/wrappers/integration/adapter.ts index 2b973e0b..bd005c3e 100644 --- a/packages/core/src/wrappers/integration/adapter.ts +++ b/packages/core/src/wrappers/integration/adapter.ts @@ -49,6 +49,7 @@ export function mapLanguage(lang: CallGraphLanguage): SupportedLanguage | null { go: null, // Go wrapper detection not yet implemented rust: 'rust', cpp: 'cpp', + 'structured-text': null, // ST wrapper detection not yet implemented }; return mapping[lang]; } diff --git a/packages/cortex/package.json b/packages/cortex/package.json index 38dc1580..a6cddfc7 100644 --- a/packages/cortex/package.json +++ b/packages/cortex/package.json @@ -82,10 +82,10 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" }, "peerDependencies": { "driftdetect-core": "^0.9.0" diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index aca4aaf3..0787ca7b 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -63,7 +63,7 @@ "@react-three/postprocessing": "^2.15.11", "@tanstack/react-query": "^5.17.0", "@types/express": "^4.17.21", - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@types/prismjs": "^1.26.3", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", @@ -85,7 +85,7 @@ "tsx": "^4.7.0", "typescript": "^5.3.0", "vite": "^5.0.10", - "vitest": "^1.0.0", + "vitest": "^4.0.18", "zustand": "^4.4.7" }, "peerDependencies": { diff --git a/packages/detectors/package.json b/packages/detectors/package.json index 20eb588b..f6084e59 100644 --- a/packages/detectors/package.json +++ b/packages/detectors/package.json @@ -41,13 +41,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "driftdetect-core": "0.9.45" + "driftdetect-core": "workspace:*" }, "devDependencies": { - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "fast-check": "^3.15.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" } } diff --git a/packages/detectors/src/iec61131/detectors/docstring-detector.ts b/packages/detectors/src/iec61131/detectors/docstring-detector.ts new file mode 100644 index 00000000..f87c2459 --- /dev/null +++ b/packages/detectors/src/iec61131/detectors/docstring-detector.ts @@ -0,0 +1,130 @@ +/** + * ST Docstring Detector + * + * Single responsibility: Detect documentation patterns and violations + */ + +import { RegexDetector } from '../../base/regex-detector.js'; +import { extractDocstrings } from '../extractors/docstring-extractor.js'; + +import type { DetectionContext, DetectionResult } from '../../base/base-detector.js'; +import type { PatternCategory, Language, Violation, QuickFix, PatternMatch } from 'driftdetect-core'; +import type { STDocstring } from '../types.js'; + +export class STDocstringDetector extends RegexDetector { + readonly id = 'iec61131/docstrings'; + readonly name = 'IEC 61131-3 Docstring Detector'; + readonly description = 'Detects documentation patterns in Structured Text'; + readonly category: PatternCategory = 'documentation'; + readonly subcategory = 'docstrings'; + readonly supportedLanguages: Language[] = ['structured-text' as Language]; + + async detect(context: DetectionContext): Promise { + if (!this.supportsLanguage(context.language)) { + return this.createEmptyResult(); + } + + const docstrings = extractDocstrings(context.content); + const patterns: PatternMatch[] = []; + const violations: Violation[] = []; + + for (const doc of docstrings) { + // Create pattern for each docstring + patterns.push(this.createPatternMatch(doc, context.file)); + + // Check for violations + this.checkViolations(doc, context.file, violations); + } + + // Check for undocumented blocks + this.checkUndocumentedBlocks(context.content, docstrings, context.file, violations); + + return this.createResult(patterns, violations, 0.9, { + custom: { + docstringCount: docstrings.length, + withParams: docstrings.filter(d => d.params.length > 0).length, + withHistory: docstrings.filter(d => d.history.length > 0).length, + withWarnings: docstrings.filter(d => d.warnings.length > 0).length, + }, + }); + } + + generateQuickFix(_violation: Violation): QuickFix | null { + // QuickFix generation requires proper WorkspaceEdit structure + // For now, return null - can be enhanced later + return null; + } + + private createPatternMatch(doc: STDocstring, file: string): PatternMatch { + return { + patternId: `${this.id}/${doc.associatedBlock || 'standalone'}`, + location: { + file, + line: doc.line, + column: 1, + }, + confidence: 0.95, + isOutlier: false, + }; + } + + private checkViolations(doc: STDocstring, file: string, violations: Violation[]): void { + // Check for empty summary + if (!doc.summary && doc.associatedBlock) { + violations.push(this.convertViolationInfo({ + type: 'empty-summary', + file, + line: doc.line, + column: 1, + issue: `Docstring for '${doc.associatedBlock}' has no summary`, + severity: 'warning', + })); + } + + // Check for params without descriptions + for (const param of doc.params) { + if (!param.description) { + violations.push(this.convertViolationInfo({ + type: 'param-no-description', + file, + line: doc.line, + column: 1, + issue: `Parameter '${param.name}' has no description`, + severity: 'info', + })); + } + } + } + + private checkUndocumentedBlocks( + content: string, + docstrings: STDocstring[], + file: string, + violations: Violation[] + ): void { + const documentedBlocks = new Set( + docstrings.map(d => d.associatedBlock).filter(Boolean) + ); + + // Find all blocks + const blockPattern = /^(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+(\w+)/gim; + let match; + + while ((match = blockPattern.exec(content)) !== null) { + const blockName = match[2]!; + if (!documentedBlocks.has(blockName)) { + const line = content.slice(0, match.index).split('\n').length; + violations.push(this.convertViolationInfo({ + type: 'undocumented-block', + file, + line, + column: 1, + value: blockName, + issue: `${match[1]} '${blockName}' lacks documentation`, + suggestedFix: 'Add (* ... *) docstring before block definition', + severity: 'warning', + })); + } + } + } +} diff --git a/packages/detectors/src/iec61131/detectors/index.ts b/packages/detectors/src/iec61131/detectors/index.ts new file mode 100644 index 00000000..a333df7c --- /dev/null +++ b/packages/detectors/src/iec61131/detectors/index.ts @@ -0,0 +1,10 @@ +/** + * IEC 61131-3 Detectors Index + * + * Single responsibility: Export all detectors + */ + +export { STDocstringDetector as DocstringDetector } from './docstring-detector.js'; +export { TribalKnowledgeDetector } from './tribal-knowledge-detector.js'; +export { SafetyInterlockDetector as SafetyDetector } from './safety-detector.js'; +export { StateMachineDetector } from './state-machine-detector.js'; diff --git a/packages/detectors/src/iec61131/detectors/safety-detector.ts b/packages/detectors/src/iec61131/detectors/safety-detector.ts new file mode 100644 index 00000000..11beb55d --- /dev/null +++ b/packages/detectors/src/iec61131/detectors/safety-detector.ts @@ -0,0 +1,87 @@ +/** + * Safety Interlock Detector + * + * Single responsibility: Detect safety patterns and violations + */ + +import { RegexDetector } from '../../base/regex-detector.js'; +import { extractSafetyInterlocks } from '../extractors/safety-extractor.js'; + +import type { DetectionContext, DetectionResult } from '../../base/base-detector.js'; +import type { PatternCategory, Language, Violation, QuickFix, PatternMatch } from 'driftdetect-core'; + +export class SafetyInterlockDetector extends RegexDetector { + readonly id = 'iec61131/safety-interlocks'; + readonly name = 'IEC 61131-3 Safety Interlock Detector'; + readonly description = 'Detects safety interlock patterns and bypass violations'; + readonly category: PatternCategory = 'security'; + readonly subcategory = 'safety-interlocks'; + readonly supportedLanguages: Language[] = ['structured-text' as Language]; + + async detect(context: DetectionContext): Promise { + if (!this.supportsLanguage(context.language)) { + return this.createEmptyResult(); + } + + const interlocks = extractSafetyInterlocks(context.content); + const patterns: PatternMatch[] = []; + const violations: Violation[] = []; + + for (const interlock of interlocks) { + // Create pattern + patterns.push({ + patternId: `${this.id}/${interlock.type}`, + location: { + file: context.file, + line: interlock.line, + column: 1, + }, + confidence: 0.95, + isOutlier: interlock.isBypassed || interlock.type === 'bypass', + }); + + // Create violations for bypasses + if (interlock.type === 'bypass') { + violations.push(this.convertViolationInfo({ + type: 'safety-bypass', + file: context.file, + line: interlock.line, + column: 1, + value: interlock.name, + issue: `Safety bypass variable detected: ${interlock.name}`, + severity: 'error', + })); + } else if (interlock.isBypassed) { + violations.push(this.convertViolationInfo({ + type: 'bypassed-interlock', + file: context.file, + line: interlock.line, + column: 1, + value: interlock.name, + issue: `Interlock '${interlock.name}' may be bypassed`, + severity: 'warning', + })); + } + } + + // Group by type + const byType: Record = {}; + for (const il of interlocks) { + byType[il.type] = (byType[il.type] || 0) + 1; + } + + return this.createResult(patterns, violations, 0.95, { + custom: { + totalInterlocks: interlocks.length, + bypassCount: interlocks.filter(i => i.type === 'bypass').length, + bypassedCount: interlocks.filter(i => i.isBypassed).length, + byType, + }, + }); + } + + generateQuickFix(_violation: Violation): QuickFix | null { + // QuickFix generation requires proper WorkspaceEdit structure + return null; + } +} diff --git a/packages/detectors/src/iec61131/detectors/state-machine-detector.ts b/packages/detectors/src/iec61131/detectors/state-machine-detector.ts new file mode 100644 index 00000000..2d28f89b --- /dev/null +++ b/packages/detectors/src/iec61131/detectors/state-machine-detector.ts @@ -0,0 +1,190 @@ +/** + * State Machine Detector + * + * Single responsibility: Detect and analyze CASE-based state machines + */ + +import { RegexDetector } from '../../base/regex-detector.js'; + +import type { DetectionContext, DetectionResult } from '../../base/base-detector.js'; +import type { PatternCategory, Language, Violation, QuickFix, PatternMatch } from 'driftdetect-core'; +import type { StateMachineAnalysis } from '../types.js'; + +// Common state variable naming patterns +const STATE_VAR_PATTERNS = [ + /^n?state$/i, + /^i?step$/i, + /^n?mode$/i, + /^n?phase$/i, + /^seq/i, + /state$/i, + /step$/i, +]; + +export class StateMachineDetector extends RegexDetector { + readonly id = 'iec61131/state-machines'; + readonly name = 'IEC 61131-3 State Machine Detector'; + readonly description = 'Detects and analyzes CASE-based state machines'; + readonly category: PatternCategory = 'structural'; + readonly subcategory = 'state-machines'; + readonly supportedLanguages: Language[] = ['structured-text' as Language]; + + async detect(context: DetectionContext): Promise { + if (!this.supportsLanguage(context.language)) { + return this.createEmptyResult(); + } + + const stateMachines = this.extractStateMachines(context.content); + const patterns: PatternMatch[] = []; + const violations: Violation[] = []; + + for (const sm of stateMachines) { + const hasIssues = sm.hasGaps || sm.states.filter(s => !s.hasComment).length > sm.stateCount / 2; + + patterns.push({ + patternId: `${this.id}/${sm.variable}`, + location: { + file: context.file, + line: sm.line, + column: 1, + }, + confidence: 0.9, + isOutlier: hasIssues, + }); + + // Check for gaps in state numbering + if (sm.hasGaps && sm.gapValues.length > 0) { + violations.push(this.convertViolationInfo({ + type: 'state-gaps', + file: context.file, + line: sm.line, + column: 1, + issue: `State machine '${sm.variable}' has gaps in numbering: ${sm.gapValues.join(', ')}`, + severity: 'info', + })); + } + + // Check for undocumented states + const undocumented = sm.states.filter(s => !s.hasComment); + if (undocumented.length > sm.stateCount / 2) { + violations.push(this.convertViolationInfo({ + type: 'undocumented-states', + file: context.file, + line: sm.line, + column: 1, + issue: `State machine '${sm.variable}' has ${undocumented.length} undocumented states`, + severity: 'warning', + })); + } + } + + return this.createResult(patterns, violations, 0.9, { + custom: { + stateMachineCount: stateMachines.length, + totalStates: stateMachines.reduce((sum, sm) => sum + sm.stateCount, 0), + stateMachines, + }, + }); + } + + generateQuickFix(_violation: Violation): QuickFix | null { + return null; + } + + private extractStateMachines(content: string): StateMachineAnalysis[] { + const machines: StateMachineAnalysis[] = []; + + const casePattern = /CASE\s+(\w+)\s+OF/gi; + let match; + + while ((match = casePattern.exec(content)) !== null) { + const variable = match[1]!; + + // Only process if it looks like a state variable + if (!this.isStateVariable(variable)) continue; + + const line = content.slice(0, match.index).split('\n').length; + const states = this.extractStates(content, match.index); + const numericStates = states + .map(s => s.value) + .filter((v): v is number => typeof v === 'number') + .sort((a, b) => a - b); + + const { hasGaps, gapValues } = this.analyzeGaps(numericStates); + + machines.push({ + variable, + stateCount: states.length, + states, + hasGaps, + gapValues, + line, + }); + } + + return machines; + } + + private isStateVariable(name: string): boolean { + return STATE_VAR_PATTERNS.some(p => p.test(name)); + } + + private extractStates(content: string, caseStart: number): Array<{ value: number | string; line: number; hasComment: boolean }> { + const states: Array<{ value: number | string; line: number; hasComment: boolean }> = []; + const afterCase = content.slice(caseStart); + const endMatch = afterCase.match(/END_CASE/i); + const caseBody = endMatch ? afterCase.slice(0, endMatch.index) : afterCase.slice(0, 2000); + + const caseLines = caseBody.split('\n'); + const baseLineNum = content.slice(0, caseStart).split('\n').length; + + const statePattern = /^\s*(\d+|[\w_]+)\s*:/; + + for (let i = 0; i < caseLines.length; i++) { + const caseLine = caseLines[i]!; + const stateMatch = caseLine.match(statePattern); + + if (stateMatch) { + const rawValue = stateMatch[1]!; + const value = /^\d+$/.test(rawValue) ? parseInt(rawValue, 10) : rawValue; + const hasComment = caseLine.includes('(*') || caseLine.includes('//'); + + states.push({ + value, + line: baseLineNum + i, + hasComment, + }); + } + } + + return states; + } + + private analyzeGaps(numericStates: number[]): { hasGaps: boolean; gapValues: number[] } { + if (numericStates.length < 2) { + return { hasGaps: false, gapValues: [] }; + } + + const gapValues: number[] = []; + const min = numericStates[0]!; + const max = numericStates[numericStates.length - 1]!; + const stateSet = new Set(numericStates); + + // Only check for gaps if states are reasonably sequential + // (not if they're like 0, 10, 20, 30 which is intentional) + const avgGap = (max - min) / (numericStates.length - 1); + + if (avgGap <= 2) { + for (let i = min; i <= max; i++) { + if (!stateSet.has(i)) { + gapValues.push(i); + } + } + } + + return { + hasGaps: gapValues.length > 0, + gapValues, + }; + } +} diff --git a/packages/detectors/src/iec61131/detectors/tribal-knowledge-detector.ts b/packages/detectors/src/iec61131/detectors/tribal-knowledge-detector.ts new file mode 100644 index 00000000..f7a07467 --- /dev/null +++ b/packages/detectors/src/iec61131/detectors/tribal-knowledge-detector.ts @@ -0,0 +1,59 @@ +/** + * Tribal Knowledge Detector + * + * Single responsibility: Detect and surface institutional knowledge + */ + +import { RegexDetector } from '../../base/regex-detector.js'; +import { extractTribalKnowledge } from '../extractors/tribal-knowledge-extractor.js'; + +import type { DetectionContext, DetectionResult } from '../../base/base-detector.js'; +import type { PatternCategory, Language, Violation, QuickFix, PatternMatch } from 'driftdetect-core'; + +export class TribalKnowledgeDetector extends RegexDetector { + readonly id = 'iec61131/tribal-knowledge'; + readonly name = 'IEC 61131-3 Tribal Knowledge Detector'; + readonly description = 'Extracts institutional knowledge from legacy PLC code'; + readonly category: PatternCategory = 'documentation'; + readonly subcategory = 'tribal-knowledge'; + readonly supportedLanguages: Language[] = ['structured-text' as Language]; + + async detect(context: DetectionContext): Promise { + if (!this.supportsLanguage(context.language)) { + return this.createEmptyResult(); + } + + const knowledge = extractTribalKnowledge(context.content, context.file); + const patterns: PatternMatch[] = []; + + // Group by type for summary + const byType: Record = {}; + + for (const item of knowledge) { + byType[item.type] = (byType[item.type] || 0) + 1; + + patterns.push({ + patternId: `${this.id}/${item.type}`, + location: { + file: context.file, + line: item.line, + column: 1, + }, + confidence: 0.8, + isOutlier: false, + }); + } + + return this.createResult(patterns, [], 0.85, { + custom: { + totalItems: knowledge.length, + byType, + items: knowledge, + }, + }); + } + + generateQuickFix(_violation: Violation): QuickFix | null { + return null; + } +} diff --git a/packages/detectors/src/iec61131/extractors/docstring-extractor.ts b/packages/detectors/src/iec61131/extractors/docstring-extractor.ts new file mode 100644 index 00000000..f1c08221 --- /dev/null +++ b/packages/detectors/src/iec61131/extractors/docstring-extractor.ts @@ -0,0 +1,167 @@ +/** + * ST Docstring Extractor + * + * Single responsibility: Extract and parse docstrings from ST comments + * + * Handles both standard (* ... *) and decorated (****...****) comment styles + * common in legacy IEC 61131-3 codebases. + */ + +import type { STDocstring, STDocParam, STHistoryEntry } from '../types.js'; + +// ============================================================================ +// Patterns +// ============================================================================ + +// Match both (* ... *) and (****...*****) style comments +const BLOCK_COMMENT = /\(\*+\s*\n?([\s\S]*?)\*+\)/g; +const PARAM_TAG = /@param\s+(\w+)\s*[-:]?\s*(.*)/i; +const RETURN_TAG = /@returns?\s+(.*)/i; +const AUTHOR_TAG = /@author\s*[:-]?\s*(.*)/gi; +const DATE_TAG = /@date\s*[:-]?\s*(.*)/gi; +const HISTORY_DATE = /^\d{4}-\d{2}-\d{2}/; +const WARNING_KEYWORDS = /\b(WARNING|DANGER|CAUTION)\b/i; + +// ============================================================================ +// Extractor +// ============================================================================ + +export function extractDocstrings(source: string): STDocstring[] { + const docstrings: STDocstring[] = []; + + // Reset regex state + const pattern = new RegExp(BLOCK_COMMENT.source, BLOCK_COMMENT.flags); + let match; + + while ((match = pattern.exec(source)) !== null) { + const content = match[1]!; + const line = getLineNumber(source, match.index); + const endLine = getLineNumber(source, match.index + match[0].length); + + // Skip single-line inline comments (short, no newlines) + if (!content.includes('\n') && content.length < 100) { + continue; + } + + const docstring = parseDocstring(content, match[0], line, endLine); + + // Find associated block after the comment + const afterComment = source.slice(match.index + match[0].length); + const blockMatch = afterComment.match(/^\s*(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+(\w+)/i); + if (blockMatch) { + docstring.associatedBlock = blockMatch[2]!; + } + + docstrings.push(docstring); + } + + return docstrings; +} + +function parseDocstring(content: string, raw: string, line: number, endLine: number): STDocstring { + // Clean content: remove leading asterisks from each line + const lines = content.split('\n').map(l => l.replace(/^\s*\*?\s?/, '')); + + let summary = ''; + const params: STDocParam[] = []; + const history: STHistoryEntry[] = []; + const warnings: string[] = []; + let returns: string | null = null; + let author: string | null = null; + let date: string | null = null; + const descLines: string[] = []; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + + // @param tag + if (trimmed.startsWith('@param')) { + const paramMatch = trimmed.match(PARAM_TAG); + if (paramMatch) { + params.push({ + name: paramMatch[1]!, + type: null, + description: paramMatch[2]?.trim() || '', + }); + } + continue; + } + + // @returns tag + if (trimmed.startsWith('@returns') || trimmed.startsWith('@return')) { + const returnMatch = trimmed.match(RETURN_TAG); + if (returnMatch) { + returns = returnMatch[1]?.trim() || null; + } + continue; + } + + // @author tag + if (trimmed.startsWith('@author')) { + const authorMatch = trimmed.match(AUTHOR_TAG); + if (authorMatch) { + author = authorMatch[1]?.trim() || null; + } + continue; + } + + // @date tag + if (trimmed.startsWith('@date')) { + const dateMatch = trimmed.match(DATE_TAG); + if (dateMatch) { + date = dateMatch[1]?.trim() || null; + } + continue; + } + + // History entries (YYYY-MM-DD format) + if (HISTORY_DATE.test(trimmed)) { + history.push({ + year: trimmed.slice(0, 4), + author: null, + description: trimmed, + }); + continue; + } + + // Warning/caution lines + if (WARNING_KEYWORDS.test(trimmed)) { + warnings.push(trimmed); + continue; + } + + // Summary: first non-empty, non-tag, non-separator line + if (!summary && trimmed && !trimmed.startsWith('@') && !trimmed.match(/^[=\-*]+$/) && !trimmed.startsWith('HISTORY')) { + summary = trimmed; + continue; + } + + // Description: subsequent content lines + if (summary && trimmed && !trimmed.startsWith('@') && !trimmed.match(/^[=\-*]+$/)) { + descLines.push(trimmed); + } + } + + return { + summary, + description: descLines.join(' ').trim(), + params, + returns, + author, + date, + history, + warnings, + raw, + line, + endLine, + associatedBlock: null, + }; +} + +// ============================================================================ +// Utilities +// ============================================================================ + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} diff --git a/packages/detectors/src/iec61131/extractors/index.ts b/packages/detectors/src/iec61131/extractors/index.ts new file mode 100644 index 00000000..c7ea1569 --- /dev/null +++ b/packages/detectors/src/iec61131/extractors/index.ts @@ -0,0 +1,9 @@ +/** + * IEC 61131-3 Extractors Index + * + * Single responsibility: Export all extractors + */ + +export { extractDocstrings } from './docstring-extractor.js'; +export { extractTribalKnowledge } from './tribal-knowledge-extractor.js'; +export { extractSafetyInterlocks } from './safety-extractor.js'; diff --git a/packages/detectors/src/iec61131/extractors/safety-extractor.ts b/packages/detectors/src/iec61131/extractors/safety-extractor.ts new file mode 100644 index 00000000..c64023c4 --- /dev/null +++ b/packages/detectors/src/iec61131/extractors/safety-extractor.ts @@ -0,0 +1,116 @@ +/** + * Safety Interlock Extractor + * + * Single responsibility: Extract safety-related patterns + */ + +import type { SafetyInterlock } from '../types.js'; + +// ============================================================================ +// Patterns +// ============================================================================ + +const INTERLOCK_PATTERNS = [ + { pattern: /\b(bIL_\w+)\b/g, type: 'interlock' as const }, + { pattern: /\b(IL_\w+)\b/g, type: 'interlock' as const }, + { pattern: /\b(b?Interlock\w*)\b/gi, type: 'interlock' as const }, + { pattern: /\b(b?Permissive\w*)\b/gi, type: 'permissive' as const }, + { pattern: /\b(b?EStop\w*|E_Stop\w*|EmergencyStop\w*)\b/gi, type: 'estop' as const }, +]; + +const BYPASS_PATTERNS = [ + /\b(bDbg_SkipIL)\b/gi, + /\b(BypassInterlock\w*)\b/gi, + /\b(IL_Bypass\w*)\b/gi, + /\b(bBypass\w*)\b/gi, + /\b(SkipSafety\w*)\b/gi, +]; + +// ============================================================================ +// Extractor +// ============================================================================ + +export function extractSafetyInterlocks(source: string): SafetyInterlock[] { + const interlocks: SafetyInterlock[] = []; + const seen = new Set(); + + // Find all bypass variables first + const bypassVars = new Set(); + for (const pattern of BYPASS_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + while ((match = regex.exec(source)) !== null) { + bypassVars.add(match[1]!.toLowerCase()); + } + } + + // Extract interlocks + for (const { pattern, type } of INTERLOCK_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const name = match[1]!; + const nameLower = name.toLowerCase(); + + // Skip if already seen + if (seen.has(nameLower)) continue; + seen.add(nameLower); + + const line = getLineNumber(source, match.index); + const isBypassed = bypassVars.has(nameLower) || isUsedWithBypass(name, source); + + interlocks.push({ + name, + type, + line, + isBypassed, + }); + } + } + + // Add bypass variables as their own entries + for (const pattern of BYPASS_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const name = match[1]!; + const nameLower = name.toLowerCase(); + + if (seen.has(nameLower)) continue; + seen.add(nameLower); + + const line = getLineNumber(source, match.index); + + interlocks.push({ + name, + type: 'bypass', + line, + isBypassed: true, + }); + } + } + + return interlocks; +} + +// ============================================================================ +// Utilities +// ============================================================================ + +function isUsedWithBypass(name: string, source: string): boolean { + // Check if the interlock is used in a bypass context + // e.g., "IF bDbg_SkipIL OR bIL_Air THEN" or "NOT bIL_Air" + const bypassContexts = [ + new RegExp(`bDbg_SkipIL\\s+OR\\s+${name}`, 'gi'), + new RegExp(`${name}\\s+OR\\s+bDbg_SkipIL`, 'gi'), + new RegExp(`NOT\\s+${name}\\s+OR\\s+bBypass`, 'gi'), + ]; + + return bypassContexts.some(pattern => pattern.test(source)); +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} diff --git a/packages/detectors/src/iec61131/extractors/tribal-knowledge-extractor.ts b/packages/detectors/src/iec61131/extractors/tribal-knowledge-extractor.ts new file mode 100644 index 00000000..6157d5ea --- /dev/null +++ b/packages/detectors/src/iec61131/extractors/tribal-knowledge-extractor.ts @@ -0,0 +1,138 @@ +/** + * Tribal Knowledge Extractor + * + * Single responsibility: Extract institutional knowledge from legacy comments + */ + +import type { TribalKnowledgeItem, TribalKnowledgeType } from '../types.js'; + +// ============================================================================ +// Pattern Definitions +// ============================================================================ + +interface KnowledgePattern { + type: TribalKnowledgeType; + pattern: RegExp; +} + +const KNOWLEDGE_PATTERNS: KnowledgePattern[] = [ + // Historical references + { type: 'history', pattern: /(?:since|from|in)\s+\d{4}/gi }, + { type: 'history', pattern: /(?:original|originally)\s+(?:written|coded|done)/gi }, + { type: 'history', pattern: /(?:converted|migrated|ported)\s+(?:from|to)/gi }, + + // Person references + { type: 'author', pattern: /\b(Bob|Joe|Jim|Dave|Mike|Tom|Bill|Steve)\s+(?:wrote|did|added|fixed|made)/gi }, + { type: 'author', pattern: /\b[A-Z]{2,4}\s+(?:wrote|did|added|fixed)/gi }, + + // Equipment notes + { type: 'equipment', pattern: /(?:Tank|Pump|Valve|Motor|Conveyor|Sensor)\s*[-#]?\s*\d+\s+(?:is|has|was|needs|sticks|fails)/gi }, + { type: 'equipment', pattern: /(?:different|new|old)\s+(?:transmitter|sensor|motor|valve)/gi }, + + // Workarounds + { type: 'workaround', pattern: /(?:workaround|hack|kludge|temporary|quick)\s*(?:fix|solution)?/gi }, + { type: 'workaround', pattern: /(?:this|it)\s+(?:works|worked)\s+(?:but|because|somehow)/gi }, + { type: 'workaround', pattern: /(?:should|supposed)\s+to\s+(?:be|work|fix)/gi }, + + // Warnings + { type: 'warning', pattern: /(?:WARNING|DANGER|CAUTION|DO NOT|DONT|NEVER)\s+(?:TOUCH|CHANGE|MODIFY|REMOVE|DELETE)/gi }, + { type: 'warning', pattern: /(?:caused|cause)\s+(?:a|an)?\s*(?:outage|crash|failure|problem)/gi }, + + // Mysteries + { type: 'mystery', pattern: /(?:nobody|no one)\s+(?:knows|understands|remembers)/gi }, + { type: 'mystery', pattern: /(?:magic|voodoo)\s+(?:number|value|constant)/gi }, + { type: 'mystery', pattern: /(?:dont|don't)\s+(?:ask|know)\s+(?:why|how)/gi }, + + // TODOs + { type: 'todo', pattern: /\bTODO\b[:\s]*/gi }, + { type: 'todo', pattern: /\bFIXME\b[:\s]*/gi }, + { type: 'todo', pattern: /(?:needs|need)\s+(?:to be|to)\s+(?:fixed|updated|changed)/gi }, +]; + +// ============================================================================ +// Extractor +// ============================================================================ + +export function extractTribalKnowledge(source: string, filePath: string): TribalKnowledgeItem[] { + const items: TribalKnowledgeItem[] = []; + const lines = source.split('\n'); + const seen = new Set(); + + // Only look in comments + const commentRanges = findCommentRanges(source); + + for (const { pattern, type } of KNOWLEDGE_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + // Check if match is within a comment + if (!isInComment(match.index, commentRanges)) { + continue; + } + + const line = getLineNumber(source, match.index); + const fullLine = lines[line - 1]?.trim() || ''; + + // Deduplicate + const key = `${type}:${line}:${fullLine.slice(0, 50)}`; + if (seen.has(key)) continue; + seen.add(key); + + // Get surrounding context + const context = getContext(lines, line - 1, 2); + + items.push({ + type, + content: match[0], + context, + line, + file: filePath, + }); + } + } + + return items; +} + +// ============================================================================ +// Utilities +// ============================================================================ + +interface CommentRange { + start: number; + end: number; +} + +function findCommentRanges(source: string): CommentRange[] { + const ranges: CommentRange[] = []; + + // Block comments + const blockPattern = /\(\*[\s\S]*?\*\)/g; + let match; + while ((match = blockPattern.exec(source)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length }); + } + + // Line comments + const linePattern = /\/\/.*$/gm; + while ((match = linePattern.exec(source)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length }); + } + + return ranges; +} + +function isInComment(offset: number, ranges: CommentRange[]): boolean { + return ranges.some(r => offset >= r.start && offset < r.end); +} + +function getLineNumber(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function getContext(lines: string[], lineIndex: number, radius: number): string { + const start = Math.max(0, lineIndex - radius); + const end = Math.min(lines.length, lineIndex + radius + 1); + return lines.slice(start, end).join('\n'); +} diff --git a/packages/detectors/src/iec61131/index.ts b/packages/detectors/src/iec61131/index.ts new file mode 100644 index 00000000..f00bee56 --- /dev/null +++ b/packages/detectors/src/iec61131/index.ts @@ -0,0 +1,34 @@ +/** + * IEC 61131-3 Module + * + * Enterprise-grade detection for industrial automation code. + * Extracts docstrings, tribal knowledge, safety interlocks, and state machines. + */ + +// Types +export * from './types.js'; + +// Extractors +export * from './extractors/index.js'; + +// Detectors +export * from './detectors/index.js'; + +// Convenience function to register all IEC 61131-3 detectors +import { DetectorRegistry } from '../registry/detector-registry.js'; +import { STDocstringDetector } from './detectors/docstring-detector.js'; +import { TribalKnowledgeDetector } from './detectors/tribal-knowledge-detector.js'; +import { SafetyInterlockDetector } from './detectors/safety-detector.js'; +import { StateMachineDetector } from './detectors/state-machine-detector.js'; + +/** + * Register all IEC 61131-3 detectors with a registry + * + * @param registry - The detector registry to register with + */ +export function registerIEC61131Detectors(registry: DetectorRegistry): void { + registry.register(new STDocstringDetector()); + registry.register(new TribalKnowledgeDetector()); + registry.register(new SafetyInterlockDetector()); + registry.register(new StateMachineDetector()); +} diff --git a/packages/detectors/src/iec61131/test-extractors.mjs b/packages/detectors/src/iec61131/test-extractors.mjs new file mode 100644 index 00000000..fd0ca971 --- /dev/null +++ b/packages/detectors/src/iec61131/test-extractors.mjs @@ -0,0 +1,394 @@ +/** + * Quick test script for IEC 61131-3 extractors + * Run with: node packages/detectors/src/iec61131/test-extractors.mjs + */ + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// Get directory of this script +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Simple implementations of the extractors for testing (copy the logic) + +// ============================================================================ +// Docstring Extractor +// ============================================================================ + +function extractDocstrings(source) { + const docstrings = []; + // Match both (* ... *) and (****...*****) style comments + const docPattern = /\(\*+\s*\n?([\s\S]*?)\*+\)/g; + let match; + + while ((match = docPattern.exec(source)) !== null) { + const content = match[1]; + const line = source.slice(0, match.index).split('\n').length; + const endLine = source.slice(0, match.index + match[0].length).split('\n').length; + + // Skip single-line inline comments + if (!content.includes('\n') && content.length < 100) continue; + + // Parse the docstring content + const parsed = parseDocstringContent(content); + + // Find associated block + const afterDoc = source.slice(match.index + match[0].length); + const blockMatch = afterDoc.match(/^\s*(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+(\w+)/i); + + docstrings.push({ + ...parsed, + line, + endLine, + associatedBlock: blockMatch ? blockMatch[2] : undefined, + }); + } + + return docstrings; +} + +function parseDocstringContent(content) { + const lines = content.split('\n').map(l => l.replace(/^\s*\*?\s?/, '')); + + let summary = ''; + const params = []; + const history = []; + const warnings = []; + let returns = undefined; + + for (const line of lines) { + if (line.startsWith('@param')) { + const paramMatch = line.match(/@param\s+(\w+)\s*[-:]?\s*(.*)/); + if (paramMatch) { + params.push({ name: paramMatch[1], description: paramMatch[2] || '' }); + } + } else if (line.startsWith('@returns') || line.startsWith('@return')) { + const returnMatch = line.match(/@returns?\s+(.*)/); + if (returnMatch) returns = returnMatch[1]; + } else if (line.match(/^\d{4}-\d{2}-\d{2}/)) { + history.push(line); + } else if (line.toUpperCase().includes('WARNING') || line.toUpperCase().includes('CAUTION')) { + warnings.push(line); + } else if (!summary && line.trim() && !line.startsWith('@') && !line.startsWith('HISTORY')) { + summary = line.trim(); + } + } + + return { summary, params, returns, history, warnings }; +} + +// ============================================================================ +// Safety Extractor +// ============================================================================ + +const INTERLOCK_PATTERNS = [ + { pattern: /\b(bIL_\w+)\b/g, type: 'interlock' }, + { pattern: /\b(IL_\w+)\b/g, type: 'interlock' }, + { pattern: /\b(b?Interlock\w*)\b/gi, type: 'interlock' }, + { pattern: /\b(b?Permissive\w*)\b/gi, type: 'permissive' }, + { pattern: /\b(b?EStop\w*|E_Stop\w*|EmergencyStop\w*)\b/gi, type: 'estop' }, +]; + +const BYPASS_PATTERNS = [ + /\b(bDbg_SkipIL)\b/gi, + /\b(BypassInterlock\w*)\b/gi, + /\b(IL_Bypass\w*)\b/gi, + /\b(bBypass\w*)\b/gi, + /\b(SkipSafety\w*)\b/gi, +]; + +function extractSafetyInterlocks(source) { + const interlocks = []; + const seen = new Set(); + + // Find all bypass variables first + const bypassVars = new Set(); + for (const pattern of BYPASS_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + while ((match = regex.exec(source)) !== null) { + bypassVars.add(match[1].toLowerCase()); + } + } + + // Extract interlocks + for (const { pattern, type } of INTERLOCK_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const name = match[1]; + const nameLower = name.toLowerCase(); + + if (seen.has(nameLower)) continue; + seen.add(nameLower); + + const line = source.slice(0, match.index).split('\n').length; + const isBypassed = bypassVars.has(nameLower); + + interlocks.push({ name, type, line, isBypassed }); + } + } + + // Add bypass variables + for (const pattern of BYPASS_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const name = match[1]; + const nameLower = name.toLowerCase(); + + if (seen.has(nameLower)) continue; + seen.add(nameLower); + + const line = source.slice(0, match.index).split('\n').length; + interlocks.push({ name, type: 'bypass', line, isBypassed: true }); + } + } + + return interlocks; +} + +// ============================================================================ +// Tribal Knowledge Extractor +// ============================================================================ + +const TRIBAL_PATTERNS = [ + { pattern: /WARNING[:\s]+(.*)/gi, type: 'warning' }, + { pattern: /CAUTION[:\s]+(.*)/gi, type: 'caution' }, + { pattern: /NOTE[:\s]+(.*)/gi, type: 'note' }, + { pattern: /TODO[:\s]+(.*)/gi, type: 'todo' }, + { pattern: /HACK[:\s]+(.*)/gi, type: 'hack' }, + { pattern: /WORKAROUND[:\s]+(.*)/gi, type: 'workaround' }, + { pattern: /DO NOT (CHANGE|MODIFY|REMOVE|DELETE)[^*\n]*/gi, type: 'do-not-change' }, + { pattern: /MAGIC NUMBER[:\s]+(.*)/gi, type: 'magic-number' }, + { pattern: /(\d{4}-\d{2}-\d{2})\s+(\w+)[:\s]+(.*)/g, type: 'history' }, +]; + +function extractTribalKnowledge(source, file) { + const knowledge = []; + const lines = source.split('\n'); + + for (const { pattern, type } of TRIBAL_PATTERNS) { + let match; + const regex = new RegExp(pattern.source, pattern.flags); + + while ((match = regex.exec(source)) !== null) { + const line = source.slice(0, match.index).split('\n').length; + const content = match[0]; + + // Get surrounding context + const startLine = Math.max(0, line - 3); + const endLine = Math.min(lines.length, line + 3); + const context = lines.slice(startLine, endLine).join('\n'); + + knowledge.push({ type, content, line, context, file }); + } + } + + return knowledge; +} + +// ============================================================================ +// State Machine Extractor +// ============================================================================ + +const STATE_VAR_PATTERNS = [ + /^n?state$/i, + /^i?step$/i, + /^n?mode$/i, + /^n?phase$/i, + /^seq/i, + /state$/i, + /step$/i, +]; + +function extractStateMachines(source) { + const machines = []; + const casePattern = /CASE\s+(\w+)\s+OF/gi; + let match; + + while ((match = casePattern.exec(source)) !== null) { + const variable = match[1]; + + // Only process if it looks like a state variable + if (!STATE_VAR_PATTERNS.some(p => p.test(variable))) continue; + + const line = source.slice(0, match.index).split('\n').length; + + // Extract states from CASE body + const afterCase = source.slice(match.index); + const endMatch = afterCase.match(/END_CASE/i); + const caseBody = endMatch ? afterCase.slice(0, endMatch.index) : afterCase.slice(0, 2000); + + const states = []; + // Only match state labels at the start of a line (with optional whitespace) + // State labels are: number followed by colon, or identifier followed by colon + // But NOT assignments like "nState := 10;" + const statePattern = /^\s*(\d+)\s*:/gm; + let stateMatch; + + while ((stateMatch = statePattern.exec(caseBody)) !== null) { + const rawValue = stateMatch[1]; + const value = parseInt(rawValue, 10); + const stateLine = source.slice(0, match.index).split('\n').length + + caseBody.slice(0, stateMatch.index).split('\n').length - 1; + const lineContent = caseBody.split('\n')[caseBody.slice(0, stateMatch.index).split('\n').length - 1] || ''; + const hasComment = lineContent.includes('(*'); + + states.push({ value, line: stateLine, hasComment }); + } + + machines.push({ + variable, + stateCount: states.length, + states, + line, + }); + } + + return machines; +} + +// ============================================================================ +// Run Tests +// ============================================================================ + +console.log('='.repeat(70)); +console.log('IEC 61131-3 Extractor Tests'); +console.log('='.repeat(70)); + +// Test with sample code +const testCode = ` +(* + * FB_Motor - Standard motor control block + * + * @param bStart - Start command + * @param bStop - Stop command + * @returns bRunning - Motor running status + * + * HISTORY: + * 1989-03-15 JSmith: Initial version + * 2005-08-22 MJones: Added thermal protection + *) +FUNCTION_BLOCK FB_Motor +VAR_INPUT + bStart : BOOL; + bStop : BOOL; +END_VAR +VAR_OUTPUT + bRunning : BOOL; +END_VAR +VAR + bIL_MotorOK : BOOL; (* Interlock - motor healthy *) + bDbg_SkipIL : BOOL; (* DEBUG: Skip interlocks - REMOVE IN PRODUCTION *) + nState : INT; +END_VAR + +(* WARNING: Do not change timing without consulting plant engineer *) +IF bDbg_SkipIL OR bIL_MotorOK THEN + CASE nState OF + 0: (* Idle *) + IF bStart THEN nState := 10; END_IF; + 10: (* Starting *) + nState := 20; + 20: (* Running *) + bRunning := TRUE; + IF bStop THEN nState := 30; END_IF; + 30: (* Stopping *) + bRunning := FALSE; + nState := 0; + END_CASE; +END_IF; + +END_FUNCTION_BLOCK +`; + +console.log('\n--- Docstrings ---'); +const docs = extractDocstrings(testCode); +console.log(`Found ${docs.length} docstring(s)`); +for (const doc of docs) { + console.log(` Line ${doc.line}: "${doc.summary}" (block: ${doc.associatedBlock || 'none'})`); + console.log(` Params: ${doc.params.map(p => p.name).join(', ') || 'none'}`); + console.log(` History entries: ${doc.history.length}`); + console.log(` Warnings: ${doc.warnings.length}`); +} + +console.log('\n--- Safety Interlocks ---'); +const safety = extractSafetyInterlocks(testCode); +console.log(`Found ${safety.length} interlock(s)`); +for (const il of safety) { + console.log(` Line ${il.line}: ${il.name} (${il.type})${il.isBypassed ? ' [BYPASSED]' : ''}`); +} + +console.log('\n--- Tribal Knowledge ---'); +const tribal = extractTribalKnowledge(testCode, 'test.st'); +console.log(`Found ${tribal.length} item(s)`); +for (const item of tribal) { + console.log(` Line ${item.line}: [${item.type}] ${item.content.slice(0, 60)}...`); +} + +console.log('\n--- State Machines ---'); +const stateMachines = extractStateMachines(testCode); +console.log(`Found ${stateMachines.length} state machine(s)`); +for (const sm of stateMachines) { + console.log(` Line ${sm.line}: ${sm.variable} with ${sm.stateCount} states`); + for (const state of sm.states) { + console.log(` State ${state.value} at line ${state.line}${state.hasComment ? ' (documented)' : ''}`); + } +} + +// Test with real file if available +console.log('\n' + '='.repeat(70)); +console.log('Testing with LEGACY_BATCH_SYSTEM.st'); +console.log('='.repeat(70)); + +try { + const legacyPath = join(__dirname, '../../../../../samples/iec61131/factory/LEGACY_BATCH_SYSTEM.st'); + const legacyCode = readFileSync(legacyPath, 'utf-8'); + + console.log(`\nFile size: ${legacyCode.length} characters, ${legacyCode.split('\n').length} lines`); + + const legacyDocs = extractDocstrings(legacyCode); + console.log(`\nDocstrings: ${legacyDocs.length}`); + for (const doc of legacyDocs.slice(0, 5)) { + console.log(` - ${doc.associatedBlock || 'standalone'}: "${doc.summary?.slice(0, 50)}..."`); + } + if (legacyDocs.length > 5) console.log(` ... and ${legacyDocs.length - 5} more`); + + const legacySafety = extractSafetyInterlocks(legacyCode); + console.log(`\nSafety Interlocks: ${legacySafety.length}`); + const byType = {}; + for (const il of legacySafety) { + byType[il.type] = (byType[il.type] || 0) + 1; + } + console.log(` By type: ${JSON.stringify(byType)}`); + const bypassed = legacySafety.filter(il => il.isBypassed); + if (bypassed.length > 0) { + console.log(` BYPASSED: ${bypassed.map(il => il.name).join(', ')}`); + } + + const legacyTribal = extractTribalKnowledge(legacyCode, 'LEGACY_BATCH_SYSTEM.st'); + console.log(`\nTribal Knowledge: ${legacyTribal.length}`); + const tribalByType = {}; + for (const item of legacyTribal) { + tribalByType[item.type] = (tribalByType[item.type] || 0) + 1; + } + console.log(` By type: ${JSON.stringify(tribalByType)}`); + + const legacySM = extractStateMachines(legacyCode); + console.log(`\nState Machines: ${legacySM.length}`); + for (const sm of legacySM) { + console.log(` - ${sm.variable}: ${sm.stateCount} states`); + } + +} catch (err) { + console.log(`Could not read LEGACY_BATCH_SYSTEM.st: ${err.message}`); +} + +console.log('\n' + '='.repeat(70)); +console.log('All tests completed!'); +console.log('='.repeat(70)); diff --git a/packages/detectors/src/iec61131/types.ts b/packages/detectors/src/iec61131/types.ts new file mode 100644 index 00000000..a96bc48d --- /dev/null +++ b/packages/detectors/src/iec61131/types.ts @@ -0,0 +1,96 @@ +/** + * IEC 61131-3 Detector Types + * + * Single responsibility: Type definitions for ST detectors + */ + +// ============================================================================ +// Docstring Types +// ============================================================================ + +export interface STDocstring { + summary: string; + description: string; + params: STDocParam[]; + returns: string | null; + author: string | null; + date: string | null; + history: STHistoryEntry[]; + warnings: string[]; + raw: string; + line: number; + endLine: number; + associatedBlock: string | null; +} + +export interface STDocParam { + name: string; + type: string | null; + description: string; +} + +export interface STHistoryEntry { + year: string; + author: string | null; + description: string; +} + +// ============================================================================ +// Tribal Knowledge Types +// ============================================================================ + +export type TribalKnowledgeType = + | 'history' + | 'author' + | 'equipment' + | 'workaround' + | 'warning' + | 'mystery' + | 'todo'; + +export interface TribalKnowledgeItem { + type: TribalKnowledgeType; + content: string; + context: string; + line: number; + file: string; +} + +// ============================================================================ +// Safety Types +// ============================================================================ + +export interface SafetyInterlock { + name: string; + type: 'interlock' | 'permissive' | 'estop' | 'bypass'; + line: number; + isBypassed: boolean; +} + +// ============================================================================ +// Function Block Analysis Types +// ============================================================================ + +export interface FunctionBlockAnalysis { + name: string; + type: 'PROGRAM' | 'FUNCTION_BLOCK' | 'FUNCTION'; + inputCount: number; + outputCount: number; + localCount: number; + hasDocstring: boolean; + complexity: 'simple' | 'moderate' | 'complex'; + line: number; +} + +// ============================================================================ +// State Machine Analysis Types +// ============================================================================ + +export interface StateMachineAnalysis { + variable: string; + stateCount: number; + states: Array<{ value: number | string; line: number; hasComment: boolean }>; + hasGaps: boolean; + gapValues: number[]; + line: number; +} diff --git a/packages/detectors/src/index.ts b/packages/detectors/src/index.ts index d32e83b5..e79e39be 100644 --- a/packages/detectors/src/index.ts +++ b/packages/detectors/src/index.ts @@ -1680,3 +1680,10 @@ export function createAllPerformanceSemanticDetectors() { createBundleSizeSemanticDetector(), ]; } + + +// ============================================================================ +// IEC 61131-3 Structured Text Detectors (Industrial Automation) +// ============================================================================ + +export * from './iec61131/index.js'; diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 64efdb2f..79181250 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -41,9 +41,9 @@ "vscode-uri": "^3.0.8" }, "devDependencies": { - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "@vitest/coverage-v8": "^1.0.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index dd9219d2..ca55086a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -63,7 +63,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@types/node": "^20.10.0", + "@types/node": "^25.2.0", "typescript": "^5.3.0" } } diff --git a/packages/mcp/src/enterprise-server.ts b/packages/mcp/src/enterprise-server.ts index df6b01de..8fdcdac2 100644 --- a/packages/mcp/src/enterprise-server.ts +++ b/packages/mcp/src/enterprise-server.ts @@ -111,6 +111,7 @@ import { executeTypeScriptTool, type TypeScriptArgs } from './tools/analysis/typ import { executePythonTool, type PythonArgs } from './tools/analysis/python.js'; import { executeJavaTool, type JavaArgs } from './tools/analysis/java.js'; import { executePhpTool, type PhpArgs } from './tools/analysis/php.js'; +import { executeIEC61131Tool, type IEC61131Args } from './tools/analysis/iec61131.js'; import { handleQualityGate } from './tools/analysis/quality-gate.js'; import { ALL_TOOLS, TOOL_CATEGORIES } from './tools/registry.js'; @@ -761,6 +762,9 @@ async function routeToolCall( case 'drift_php': return executePhpTool(args as unknown as PhpArgs, { projectRoot }); + case 'drift_iec61131': + return executeIEC61131Tool(args as unknown as IEC61131Args, { projectRoot }); + case 'drift_constants': return handleConstants(projectRoot, args as Parameters[1]); diff --git a/packages/mcp/src/infrastructure/tool-filter.ts b/packages/mcp/src/infrastructure/tool-filter.ts index 57cf3886..125b0f53 100644 --- a/packages/mcp/src/infrastructure/tool-filter.ts +++ b/packages/mcp/src/infrastructure/tool-filter.ts @@ -32,7 +32,8 @@ export type Language = | 'rust' | 'cpp' | 'csharp' - | 'wpf'; + | 'wpf' + | 'iec61131'; /** * Language detection markers @@ -92,6 +93,11 @@ const LANGUAGE_MARKERS: Record = { cpp: ['drift_cpp'], csharp: [], // No specific tool yet wpf: ['drift_wpf'], + iec61131: ['drift_iec61131'], }; /** @@ -170,6 +177,10 @@ const CORE_TOOLS = [ 'drift_decisions', 'drift_constraints', 'drift_constants', + 'drift_audit', + + // Industrial Automation (IEC 61131-3) + 'drift_iec61131', // Generation 'drift_suggest_changes', diff --git a/packages/mcp/src/tools/analysis/iec61131.ts b/packages/mcp/src/tools/analysis/iec61131.ts new file mode 100644 index 00000000..aa9ef9a5 --- /dev/null +++ b/packages/mcp/src/tools/analysis/iec61131.ts @@ -0,0 +1,504 @@ +/** + * IEC 61131-3 Analysis MCP Tool + * + * Analyze industrial automation code: docstrings, state machines, safety interlocks, tribal knowledge. + * + * This is a thin wrapper around driftdetect-core/iec61131. + * All logic lives in the core library for CLI/MCP parity. + */ + +import { IEC61131Analyzer } from 'driftdetect-core/iec61131'; +import type { + DocstringExtractionResult, + StateMachineExtractionResult, + SafetyAnalysisResult, + TribalKnowledgeExtractionResult, + VariableExtractionResult, + TargetLanguage, + IOMapping, + ExtractedDocstring, + StateMachine, + StateMachineState, + SafetyInterlock, + SafetyBypass, + SafetyCriticalWarning, + TribalKnowledgeItem, + ExtractedVariable, + MigrationReadinessReport, + STCallGraph, +} from 'driftdetect-core/iec61131'; + +// ============================================================================ +// Types +// ============================================================================ + +export type IEC61131Action = + | 'status' // Project overview + | 'docstrings' // Extract all docstrings (PhD's primary request) + | 'state-machines' // Find CASE-based state machines + | 'safety' // Safety interlocks and bypasses + | 'tribal-knowledge' // Warnings, workarounds, institutional knowledge + | 'blocks' // PROGRAM/FUNCTION_BLOCK/FUNCTION definitions + | 'variables' // Extract all variables with types + | 'io-map' // I/O address mapping + | 'migration' // Migration readiness scoring + | 'ai-context' // Generate AI context package + | 'call-graph' // Build call graph + | 'all'; // Full analysis + +export interface IEC61131Args { + action: IEC61131Action; + path?: string; + file?: string; + limit?: number; + includeRaw?: boolean; + format?: 'json' | 'markdown'; + targetLanguage?: TargetLanguage; + maxTokens?: number; +} + +export interface ToolContext { + projectRoot: string; +} + +// ============================================================================ +// Tool Implementation +// ============================================================================ + +export async function executeIEC61131Tool( + args: IEC61131Args, + context: ToolContext +): Promise<{ content: Array<{ type: string; text: string }> }> { + const projectPath = args.path ?? context.projectRoot; + const limit = args.limit ?? 50; + const includeRaw = args.includeRaw ?? false; + + // Create analyzer + const analyzer = new IEC61131Analyzer(); + await analyzer.initialize(projectPath); + + let result: unknown; + + try { + switch (args.action) { + case 'status': + result = await analyzer.status(); + break; + + case 'docstrings': + result = formatDocstrings( + await analyzer.docstrings(undefined, { includeRaw, limit }) + ); + break; + + case 'state-machines': + result = formatStateMachines( + await analyzer.stateMachines(undefined, { limit }) + ); + break; + + case 'safety': + result = formatSafety(await analyzer.safety()); + break; + + case 'tribal-knowledge': + result = formatTribalKnowledge( + await analyzer.tribalKnowledge(undefined, { limit }) + ); + break; + + case 'blocks': + result = await analyzer.blocks(undefined, { limit }); + break; + + case 'variables': + result = formatVariables( + await analyzer.variables(undefined, { limit }) + ); + break; + + case 'io-map': { + const varResult = await analyzer.variables(); + result = { + ioMappings: varResult.ioMappings, + summary: { + total: varResult.ioMappings.length, + inputs: varResult.ioMappings.filter((io: IOMapping) => io.isInput).length, + outputs: varResult.ioMappings.filter((io: IOMapping) => !io.isInput).length, + }, + }; + break; + } + + case 'migration': + result = formatMigrationReadiness( + await analyzer.migrationReadiness() + ); + break; + + case 'ai-context': { + const targetLang = args.targetLanguage ?? 'python'; + const options = args.maxTokens !== undefined ? { maxTokens: args.maxTokens } : {}; + result = await analyzer.generateAIContext(targetLang, undefined, options); + break; + } + + case 'call-graph': + result = formatCallGraph(await analyzer.buildCallGraph()); + break; + + case 'all': + result = await analyzer.fullAnalysis(); + break; + + default: + throw new Error(`Unknown action: ${args.action}`); + } + } catch (error) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: true, + message: error instanceof Error ? error.message : 'Unknown error', + action: args.action, + path: projectPath, + }, null, 2), + }], + }; + } + + return { + content: [{ + type: 'text', + text: JSON.stringify(result, null, 2), + }], + }; +} + +// ============================================================================ +// Formatters +// ============================================================================ + +function formatDocstrings(result: DocstringExtractionResult): unknown { + return { + total: result.summary.total, + byBlock: result.summary.byBlock, + withParams: result.summary.withParams, + withHistory: result.summary.withHistory, + withWarnings: result.summary.withWarnings, + averageQuality: Math.round(result.summary.averageQuality), + docstrings: result.docstrings.map((d: ExtractedDocstring) => ({ + file: d.file, + line: d.location.line, + endLine: d.location.endLine, + summary: d.summary, + description: d.description, + associatedBlock: d.associatedBlock, + params: d.params, + returns: d.returns, + history: d.history, + warnings: d.warnings, + quality: d.quality.completeness, + ...(d.raw ? { raw: d.raw } : {}), + })), + truncated: result.docstrings.length < result.summary.total, + summary: `${result.summary.total} docstrings extracted`, + }; +} + +function formatStateMachines(result: StateMachineExtractionResult): unknown { + return { + total: result.summary.total, + totalStates: result.summary.totalStates, + byVariable: result.summary.byVariable, + withDeadlocks: result.summary.withDeadlocks, + withGaps: result.summary.withGaps, + machines: result.stateMachines.map((sm: StateMachine) => ({ + file: sm.location.file, + name: sm.name, + variable: sm.stateVariable, + stateCount: sm.states.length, + states: sm.states.map((s: StateMachineState) => ({ + value: s.value, + name: s.name, + isInitial: s.isInitial, + isFinal: s.isFinal, + documentation: s.documentation, + })), + transitions: sm.transitions.length, + verification: sm.verification, + diagram: sm.visualizations.mermaid, + })), + truncated: result.stateMachines.length < result.summary.total, + summary: `${result.summary.total} state machines with ${result.summary.totalStates} total states`, + }; +} + +function formatSafety(result: SafetyAnalysisResult): unknown { + return { + total: result.summary.totalInterlocks, + byType: result.summary.byType, + bypassed: { + count: result.bypasses.length, + items: result.bypasses.map((b: SafetyBypass) => ({ + name: b.name, + file: b.location.file, + line: b.location.line, + affectedInterlocks: b.affectedInterlocks, + })), + }, + criticalWarnings: result.criticalWarnings.map((w: SafetyCriticalWarning) => ({ + type: w.type, + message: w.message, + severity: w.severity, + file: w.location.file, + line: w.location.line, + remediation: w.remediation, + })), + interlocks: result.interlocks.map((il: SafetyInterlock) => ({ + file: il.location.file, + name: il.name, + type: il.type, + line: il.location.line, + isBypassed: il.isBypassed, + severity: il.severity, + })), + summary: `${result.summary.totalInterlocks} safety interlocks, ${result.bypasses.length} BYPASSED`, + warning: result.bypasses.length > 0 + ? `āš ļø ${result.bypasses.length} safety bypass(es) detected - review immediately!` + : undefined, + }; +} + +function formatTribalKnowledge(result: TribalKnowledgeExtractionResult): unknown { + return { + total: result.summary.total, + byType: result.summary.byType, + byImportance: result.summary.byImportance, + criticalCount: result.summary.criticalCount, + knowledge: result.items.map((k: TribalKnowledgeItem) => ({ + file: k.location.file, + type: k.type, + content: k.content, + line: k.location.line, + importance: k.importance, + context: k.context, + })), + truncated: result.items.length < result.summary.total, + summary: `${result.summary.total} tribal knowledge items found`, + highlights: { + warnings: result.summary.byType['warning'] ?? 0, + workarounds: result.summary.byType['workaround'] ?? 0, + hacks: result.summary.byType['hack'] ?? 0, + todos: result.summary.byType['todo'] ?? 0, + critical: result.summary.criticalCount, + }, + }; +} + +function formatVariables(result: VariableExtractionResult): unknown { + return { + total: result.summary.total, + bySection: result.summary.bySection, + withComments: result.summary.withComments, + withIOAddress: result.summary.withIOAddress, + safetyCritical: result.summary.safetyCritical, + variables: result.variables.map((v: ExtractedVariable) => ({ + file: v.file, + name: v.name, + type: v.dataType, + section: v.section, + line: v.location.line, + comment: v.comment, + ioAddress: v.ioAddress, + isSafetyCritical: v.isSafetyCritical, + initialValue: v.initialValue, + })), + ioMappings: result.ioMappings.map((io: IOMapping) => ({ + address: io.address, + type: io.addressType, + variable: io.variableName, + isInput: io.isInput, + file: io.location.file, + line: io.location.line, + })), + truncated: result.variables.length < result.summary.total, + summary: `${result.summary.total} variables, ${result.summary.safetyCritical} safety-critical`, + }; +} + + +// ============================================================================ +// New Formatters for Migration, AI Context, and Call Graph +// ============================================================================ + +function formatMigrationReadiness(result: MigrationReadinessReport): unknown { + return { + overallScore: Math.round(result.overallScore), + overallGrade: result.overallGrade, + summary: `Migration readiness: ${result.overallGrade} (${Math.round(result.overallScore)}/100)`, + pouScores: result.pouScores.map(score => ({ + name: score.pouName, + type: score.pouType, + score: Math.round(score.overallScore), + grade: score.grade, + dimensions: { + documentation: Math.round(score.dimensionScores.documentation), + safety: Math.round(score.dimensionScores.safety), + complexity: Math.round(score.dimensionScores.complexity), + dependencies: Math.round(score.dimensionScores.dependencies), + testability: Math.round(score.dimensionScores.testability), + }, + blockers: score.blockers.map(b => ({ + type: b.type, + description: b.description, + severity: b.severity, + remediation: b.remediation, + })), + warnings: score.warnings, + suggestions: score.suggestions, + })), + migrationOrder: result.migrationOrder.map(item => ({ + order: item.order, + name: item.pouName, + reason: item.reason, + dependencies: item.dependencies, + estimatedEffort: item.estimatedEffort, + })), + risks: result.risks.map(risk => ({ + severity: risk.severity, + category: risk.category, + description: risk.description, + affectedPOUs: risk.affectedPOUs, + mitigation: risk.mitigation, + })), + estimatedEffort: { + totalHours: result.estimatedEffort.totalHours, + confidence: Math.round(result.estimatedEffort.confidence * 100) + '%', + byPOU: result.estimatedEffort.byPOU, + }, + guidance: generateMigrationGuidance(result), + }; +} + +function generateMigrationGuidance(result: MigrationReadinessReport): string[] { + const guidance: string[] = []; + + if (result.overallScore >= 80) { + guidance.push('āœ… Project is well-prepared for migration'); + } else if (result.overallScore >= 60) { + guidance.push('āš ļø Project needs some preparation before migration'); + } else { + guidance.push('🚨 Significant preparation needed before migration'); + } + + const blockedCount = result.pouScores.filter(s => s.blockers.length > 0).length; + if (blockedCount > 0) { + guidance.push(`${blockedCount} POU(s) have blockers that must be resolved`); + } + + const criticalRisks = result.risks.filter(r => r.severity === 'critical'); + if (criticalRisks.length > 0) { + guidance.push(`${criticalRisks.length} critical risk(s) identified - review immediately`); + } + + if (result.migrationOrder.length > 0) { + guidance.push(`Recommended first migration: ${result.migrationOrder[0]?.pouName}`); + } + + return guidance; +} + +function formatCallGraph(result: STCallGraph): unknown { + const nodes: Array<{ + id: string; + name: string; + type: string; + file: string; + line: number; + inputs: unknown[]; + outputs: unknown[]; + }> = Array.from(result.nodes.values()); + const edges = result.edges; + + // Calculate metrics + const nodesByType: Record = {}; + for (const node of nodes) { + nodesByType[node.type] = (nodesByType[node.type] || 0) + 1; + } + + // Find most called functions + const callCounts: Record = {}; + for (const edge of edges) { + callCounts[edge.calleeName] = (callCounts[edge.calleeName] || 0) + 1; + } + + const mostCalled = Object.entries(callCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name, count]) => ({ name, callCount: count })); + + // Find entry points (nodes with no incoming edges) + const calledNodes = new Set(edges.map(e => e.calleeName.toLowerCase())); + const entryPoints = nodes + .filter(n => !calledNodes.has(n.name.toLowerCase())) + .map(n => n.name); + + return { + summary: { + totalNodes: nodes.length, + totalEdges: edges.length, + byType: nodesByType, + entryPoints, + mostCalled, + }, + nodes: nodes.map(n => ({ + id: n.id, + name: n.name, + type: n.type, + file: n.file, + line: n.line, + inputCount: n.inputs.length, + outputCount: n.outputs.length, + })), + edges: edges.map(e => ({ + from: e.callerId, + to: e.calleeName, + type: e.callType, + file: e.location.file, + line: e.location.line, + })), + mermaid: generateCallGraphMermaid(nodes, edges), + }; +} + +function generateCallGraphMermaid( + nodes: Array<{ name: string; type: string }>, + edges: Array<{ callerId: string; calleeName: string; callType: string }> +): string { + const lines: string[] = ['graph TD']; + + // Add nodes with styling + for (const node of nodes) { + const shape = node.type === 'PROGRAM' ? `[${node.name}]` : + node.type === 'FUNCTION_BLOCK' ? `[[${node.name}]]` : + `(${node.name})`; + lines.push(` ${node.name}${shape}`); + } + + // Add edges + const seenEdges = new Set(); + for (const edge of edges) { + const edgeKey = `${edge.callerId}->${edge.calleeName}`; + if (!seenEdges.has(edgeKey)) { + seenEdges.add(edgeKey); + const arrow = edge.callType === 'instantiation' ? '-.->|instance|' : '-->'; + // Extract caller name from path + const callerName = edge.callerId.split(':').pop() || edge.callerId; + lines.push(` ${callerName}${arrow}${edge.calleeName}`); + } + } + + return lines.join('\n'); +} diff --git a/packages/mcp/src/tools/analysis/index.ts b/packages/mcp/src/tools/analysis/index.ts index 76b7cde9..98fedcc0 100644 --- a/packages/mcp/src/tools/analysis/index.ts +++ b/packages/mcp/src/tools/analysis/index.ts @@ -22,6 +22,7 @@ const TYPESCRIPT_ACTIONS = ['status', 'routes', 'components', 'hooks', 'errors', const PYTHON_ACTIONS = ['status', 'routes', 'errors', 'data-access', 'decorators', 'async']; const JAVA_ACTIONS = ['status', 'routes', 'errors', 'data-access', 'annotations']; const PHP_ACTIONS = ['status', 'routes', 'errors', 'data-access', 'traits']; +const IEC61131_ACTIONS = ['status', 'docstrings', 'state-machines', 'safety', 'tribal-knowledge', 'blocks', 'all']; const DECISION_CATEGORIES = [ 'technology-adoption', 'technology-removal', 'pattern-introduction', @@ -622,6 +623,37 @@ export const ANALYSIS_TOOLS: Tool[] = [ required: ['action'], }, }, + { + name: 'drift_iec61131', + description: 'Analyze IEC 61131-3 industrial automation code (Structured Text). Extract docstrings, state machines, safety interlocks, and tribal knowledge from legacy PLC codebases. Actions: status (project overview), docstrings (extract documentation - PhD primary request), state-machines (CASE-based state machines), safety (interlocks and bypasses), tribal-knowledge (warnings, workarounds, institutional knowledge), blocks (PROGRAM/FUNCTION_BLOCK/FUNCTION), all (full analysis).', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: IEC61131_ACTIONS, + description: 'Action to perform: status, docstrings, state-machines, safety, tribal-knowledge, blocks, all', + }, + path: { + type: 'string', + description: 'File or directory path (defaults to project root)', + }, + file: { + type: 'string', + description: 'Specific .st file to analyze', + }, + limit: { + type: 'number', + description: 'Limit number of results (default: 50)', + }, + includeRaw: { + type: 'boolean', + description: 'Include raw docstring text in output (default: false)', + }, + }, + required: ['action'], + }, + }, ]; export { handleTestTopology, type TestTopologyArgs, type TestTopologyAction } from './test-topology.js'; @@ -641,3 +673,4 @@ export { executePythonTool, type PythonArgs, type PythonAction } from './python. export { executeJavaTool, type JavaArgs, type JavaAction } from './java.js'; export { executePhpTool, type PhpArgs, type PhpAction } from './php.js'; export { handleAudit, auditTool, type AuditArgs } from './audit.js'; +export { executeIEC61131Tool, type IEC61131Args, type IEC61131Action } from './iec61131.js'; diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 5cc55576..8f60a331 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -395,11 +395,11 @@ "vscode-languageclient": "^9.0.1" }, "devDependencies": { - "@types/node": "^20.10.0", - "@types/vscode": "^1.85.0", + "@types/node": "^25.2.0", + "@types/vscode": "^1.109.0", "@vitest/coverage-v8": "^1.0.0", "@vscode/vsce": "^2.22.0", "typescript": "^5.3.0", - "vitest": "^1.0.0" + "vitest": "^4.0.18" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 246157a5..885abc03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: ^9.0.0 version: 9.39.2 '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) eslint: specifier: ^9.0.0 version: 9.39.2 @@ -29,6 +29,9 @@ importers: prettier: specifier: ^3.1.0 version: 3.8.1 + tsx: + specifier: ^4.7.0 + version: 4.21.0 turbo: specifier: ^1.11.0 version: 1.13.4 @@ -39,8 +42,8 @@ importers: specifier: ^8.0.0 version: 8.54.0(eslint@9.39.2)(typescript@5.9.3) vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/ai: dependencies: @@ -49,17 +52,17 @@ importers: version: link:../core devDependencies: '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) typescript: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/ci: dependencies: @@ -86,8 +89,8 @@ importers: version: 3.30.0 devDependencies: '@types/node': - specifier: ^20.19.30 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 tsx: specifier: ^4.7.0 version: 4.21.0 @@ -95,8 +98,8 @@ importers: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/cibench: dependencies: @@ -111,20 +114,20 @@ importers: version: 10.5.0 devDependencies: '@types/node': - specifier: ^20.11.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 typescript: specifier: ^5.3.3 version: 5.9.3 vitest: - specifier: ^1.2.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/cli: dependencies: '@inquirer/prompts': specifier: ^7.0.0 - version: 7.10.1(@types/node@20.19.30) + version: 7.10.1(@types/node@25.2.0) chalk: specifier: ^5.3.0 version: 5.6.2 @@ -166,11 +169,11 @@ importers: specifier: ^3.11.6 version: 3.11.6 '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) fast-check: specifier: ^3.15.0 version: 3.23.2 @@ -178,8 +181,8 @@ importers: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/core: dependencies: @@ -187,7 +190,7 @@ importers: specifier: ^11.0.0 version: 11.10.0 driftdetect-detectors: - specifier: workspace:0.9.47 + specifier: workspace:* version: link:../detectors ignore: specifier: ^5.3.1 @@ -246,17 +249,17 @@ importers: specifier: ^5.1.2 version: 5.1.2 '@types/node': - specifier: ^20.19.30 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) fast-check: specifier: ^3.15.0 version: 3.23.2 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/cortex: dependencies: @@ -277,17 +280,17 @@ importers: specifier: ^7.6.8 version: 7.6.13 '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) typescript: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/dashboard: dependencies: @@ -323,8 +326,8 @@ importers: specifier: ^4.17.21 version: 4.17.25 '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@types/prismjs': specifier: ^1.26.3 version: 1.26.5 @@ -348,7 +351,7 @@ importers: version: 4.7.0(vite@5.4.21) '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) autoprefixer: specifier: ^10.4.16 version: 10.4.23(postcss@8.5.6) @@ -387,10 +390,10 @@ importers: version: 5.9.3 vite: specifier: ^5.0.10 - version: 5.4.21(@types/node@20.19.30) + version: 5.4.21(@types/node@25.2.0) vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) zustand: specifier: ^4.4.7 version: 4.5.7(@types/react@18.3.27)(react@18.3.1) @@ -398,15 +401,15 @@ importers: packages/detectors: dependencies: driftdetect-core: - specifier: 0.9.45 - version: 0.9.45 + specifier: workspace:* + version: link:../core devDependencies: '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) fast-check: specifier: ^3.15.0 version: 3.23.2 @@ -414,8 +417,8 @@ importers: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/galaxy: dependencies: @@ -476,17 +479,17 @@ importers: version: 3.1.0 devDependencies: '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) typescript: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages/mcp: dependencies: @@ -507,8 +510,8 @@ importers: version: 4.3.6 devDependencies: '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 typescript: specifier: ^5.3.0 version: 5.9.3 @@ -523,14 +526,14 @@ importers: version: 9.0.1 devDependencies: '@types/node': - specifier: ^20.10.0 - version: 20.19.30 + specifier: ^25.2.0 + version: 25.2.0 '@types/vscode': - specifier: ^1.85.0 - version: 1.108.1 + specifier: ^1.109.0 + version: 1.109.0 '@vitest/coverage-v8': specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + version: 1.6.1(vitest@4.0.18) '@vscode/vsce': specifier: ^2.22.0 version: 2.32.0 @@ -538,8 +541,8 @@ importers: specifier: ^5.3.0 version: 5.9.3 vitest: - specifier: ^1.0.0 - version: 1.6.1(@types/node@20.19.30) + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) packages: @@ -1421,7 +1424,7 @@ packages: engines: {node: '>=18'} dev: false - /@inquirer/checkbox@4.3.2(@types/node@20.19.30): + /@inquirer/checkbox@4.3.2(@types/node@25.2.0): resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} engines: {node: '>=18'} peerDependencies: @@ -1431,14 +1434,14 @@ packages: optional: true dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@20.19.30) + '@inquirer/core': 10.3.2(@types/node@25.2.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/confirm@5.1.21(@types/node@20.19.30): + /@inquirer/confirm@5.1.21(@types/node@25.2.0): resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} peerDependencies: @@ -1447,12 +1450,12 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 dev: false - /@inquirer/core@10.3.2(@types/node@20.19.30): + /@inquirer/core@10.3.2(@types/node@25.2.0): resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} engines: {node: '>=18'} peerDependencies: @@ -1463,8 +1466,8 @@ packages: dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 @@ -1472,7 +1475,7 @@ packages: yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/editor@4.2.23(@types/node@20.19.30): + /@inquirer/editor@4.2.23(@types/node@25.2.0): resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} engines: {node: '>=18'} peerDependencies: @@ -1481,13 +1484,13 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/external-editor': 1.0.3(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 dev: false - /@inquirer/expand@4.0.23(@types/node@20.19.30): + /@inquirer/expand@4.0.23(@types/node@25.2.0): resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} engines: {node: '>=18'} peerDependencies: @@ -1496,13 +1499,13 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/external-editor@1.0.3(@types/node@20.19.30): + /@inquirer/external-editor@1.0.3(@types/node@25.2.0): resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} peerDependencies: @@ -1511,7 +1514,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 chardet: 2.1.1 iconv-lite: 0.7.2 dev: false @@ -1521,7 +1524,7 @@ packages: engines: {node: '>=18'} dev: false - /@inquirer/input@4.3.1(@types/node@20.19.30): + /@inquirer/input@4.3.1(@types/node@25.2.0): resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} engines: {node: '>=18'} peerDependencies: @@ -1530,12 +1533,12 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 dev: false - /@inquirer/number@3.0.23(@types/node@20.19.30): + /@inquirer/number@3.0.23(@types/node@25.2.0): resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} engines: {node: '>=18'} peerDependencies: @@ -1544,12 +1547,12 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 dev: false - /@inquirer/password@4.0.23(@types/node@20.19.30): + /@inquirer/password@4.0.23(@types/node@25.2.0): resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} engines: {node: '>=18'} peerDependencies: @@ -1559,12 +1562,12 @@ packages: optional: true dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 dev: false - /@inquirer/prompts@7.10.1(@types/node@20.19.30): + /@inquirer/prompts@7.10.1(@types/node@25.2.0): resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} engines: {node: '>=18'} peerDependencies: @@ -1573,20 +1576,20 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@20.19.30) - '@inquirer/confirm': 5.1.21(@types/node@20.19.30) - '@inquirer/editor': 4.2.23(@types/node@20.19.30) - '@inquirer/expand': 4.0.23(@types/node@20.19.30) - '@inquirer/input': 4.3.1(@types/node@20.19.30) - '@inquirer/number': 3.0.23(@types/node@20.19.30) - '@inquirer/password': 4.0.23(@types/node@20.19.30) - '@inquirer/rawlist': 4.1.11(@types/node@20.19.30) - '@inquirer/search': 3.2.2(@types/node@20.19.30) - '@inquirer/select': 4.4.2(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/checkbox': 4.3.2(@types/node@25.2.0) + '@inquirer/confirm': 5.1.21(@types/node@25.2.0) + '@inquirer/editor': 4.2.23(@types/node@25.2.0) + '@inquirer/expand': 4.0.23(@types/node@25.2.0) + '@inquirer/input': 4.3.1(@types/node@25.2.0) + '@inquirer/number': 3.0.23(@types/node@25.2.0) + '@inquirer/password': 4.0.23(@types/node@25.2.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.2.0) + '@inquirer/search': 3.2.2(@types/node@25.2.0) + '@inquirer/select': 4.4.2(@types/node@25.2.0) + '@types/node': 25.2.0 dev: false - /@inquirer/rawlist@4.1.11(@types/node@20.19.30): + /@inquirer/rawlist@4.1.11(@types/node@25.2.0): resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} engines: {node: '>=18'} peerDependencies: @@ -1595,13 +1598,13 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/core': 10.3.2(@types/node@25.2.0) + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/search@3.2.2(@types/node@20.19.30): + /@inquirer/search@3.2.2(@types/node@25.2.0): resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} engines: {node: '>=18'} peerDependencies: @@ -1610,14 +1613,14 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) + '@inquirer/core': 10.3.2(@types/node@25.2.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/select@4.4.2(@types/node@20.19.30): + /@inquirer/select@4.4.2(@types/node@25.2.0): resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} engines: {node: '>=18'} peerDependencies: @@ -1627,14 +1630,14 @@ packages: optional: true dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@20.19.30) + '@inquirer/core': 10.3.2(@types/node@25.2.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) - '@types/node': 20.19.30 + '@inquirer/type': 3.0.10(@types/node@25.2.0) + '@types/node': 25.2.0 yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/type@3.0.10(@types/node@20.19.30): + /@inquirer/type@3.0.10(@types/node@25.2.0): resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} peerDependencies: @@ -1643,7 +1646,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: false /@isaacs/cliui@8.0.2: @@ -1663,13 +1666,6 @@ packages: engines: {node: '>=8'} dev: true - /@jest/schemas@29.6.3: - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@sinclair/typebox': 0.27.8 - dev: true - /@jridgewell/gen-mapping@0.3.13: resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: @@ -2300,6 +2296,14 @@ packages: dev: true optional: true + /@rollup/rollup-android-arm-eabi@4.57.1: + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-android-arm64@4.57.0: resolution: {integrity: sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==} cpu: [arm64] @@ -2308,6 +2312,14 @@ packages: dev: true optional: true + /@rollup/rollup-android-arm64@4.57.1: + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-darwin-arm64@4.57.0: resolution: {integrity: sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==} cpu: [arm64] @@ -2316,6 +2328,14 @@ packages: dev: true optional: true + /@rollup/rollup-darwin-arm64@4.57.1: + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-darwin-x64@4.57.0: resolution: {integrity: sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==} cpu: [x64] @@ -2324,6 +2344,14 @@ packages: dev: true optional: true + /@rollup/rollup-darwin-x64@4.57.1: + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-freebsd-arm64@4.57.0: resolution: {integrity: sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==} cpu: [arm64] @@ -2332,6 +2360,14 @@ packages: dev: true optional: true + /@rollup/rollup-freebsd-arm64@4.57.1: + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-freebsd-x64@4.57.0: resolution: {integrity: sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==} cpu: [x64] @@ -2340,6 +2376,14 @@ packages: dev: true optional: true + /@rollup/rollup-freebsd-x64@4.57.1: + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-arm-gnueabihf@4.57.0: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] @@ -2348,6 +2392,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-arm-gnueabihf@4.57.1: + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-arm-musleabihf@4.57.0: resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] @@ -2356,6 +2408,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-arm-musleabihf@4.57.1: + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-arm64-gnu@4.57.0: resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] @@ -2364,6 +2424,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-arm64-gnu@4.57.1: + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-arm64-musl@4.57.0: resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] @@ -2372,6 +2440,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-arm64-musl@4.57.1: + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-loong64-gnu@4.57.0: resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] @@ -2380,6 +2456,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-loong64-gnu@4.57.1: + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-loong64-musl@4.57.0: resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] @@ -2388,6 +2472,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-loong64-musl@4.57.1: + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-ppc64-gnu@4.57.0: resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] @@ -2396,6 +2488,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-ppc64-gnu@4.57.1: + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-ppc64-musl@4.57.0: resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] @@ -2404,6 +2504,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-ppc64-musl@4.57.1: + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-riscv64-gnu@4.57.0: resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] @@ -2412,6 +2520,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-riscv64-gnu@4.57.1: + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-riscv64-musl@4.57.0: resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] @@ -2420,6 +2536,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-riscv64-musl@4.57.1: + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-s390x-gnu@4.57.0: resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] @@ -2428,6 +2552,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-s390x-gnu@4.57.1: + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-x64-gnu@4.57.0: resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] @@ -2436,6 +2568,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-x64-gnu@4.57.1: + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-linux-x64-musl@4.57.0: resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] @@ -2444,6 +2584,14 @@ packages: dev: true optional: true + /@rollup/rollup-linux-x64-musl@4.57.1: + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-openbsd-x64@4.57.0: resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} cpu: [x64] @@ -2452,6 +2600,14 @@ packages: dev: true optional: true + /@rollup/rollup-openbsd-x64@4.57.1: + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-openharmony-arm64@4.57.0: resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==} cpu: [arm64] @@ -2460,6 +2616,14 @@ packages: dev: true optional: true + /@rollup/rollup-openharmony-arm64@4.57.1: + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-win32-arm64-msvc@4.57.0: resolution: {integrity: sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==} cpu: [arm64] @@ -2468,6 +2632,14 @@ packages: dev: true optional: true + /@rollup/rollup-win32-arm64-msvc@4.57.1: + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-win32-ia32-msvc@4.57.0: resolution: {integrity: sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==} cpu: [ia32] @@ -2476,6 +2648,14 @@ packages: dev: true optional: true + /@rollup/rollup-win32-ia32-msvc@4.57.1: + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-win32-x64-gnu@4.57.0: resolution: {integrity: sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==} cpu: [x64] @@ -2484,6 +2664,14 @@ packages: dev: true optional: true + /@rollup/rollup-win32-x64-gnu@4.57.1: + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@rollup/rollup-win32-x64-msvc@4.57.0: resolution: {integrity: sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==} cpu: [x64] @@ -2492,12 +2680,20 @@ packages: dev: true optional: true + /@rollup/rollup-win32-x64-msvc@4.57.1: + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@rtsao/scc@1.1.0: resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} dev: true - /@sinclair/typebox@0.27.8: - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + /@standard-schema/spec@1.1.0: + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} dev: true /@tanstack/query-core@5.90.20: @@ -2545,32 +2741,43 @@ packages: /@types/better-sqlite3@7.6.13: resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: true /@types/body-parser@1.19.6: resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} dependencies: '@types/connect': 3.4.38 - '@types/node': 20.19.30 + '@types/node': 25.2.0 + dev: true + + /@types/chai@5.2.3: + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 dev: true /@types/cli-progress@3.11.6: resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==} dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: true /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: true /@types/cookiejar@2.1.5: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true + /@types/deep-eql@4.0.2: + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + dev: true + /@types/draco3d@1.4.10: resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} @@ -2581,7 +2788,7 @@ packages: /@types/express-serve-static-core@4.19.8: resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -2624,10 +2831,10 @@ packages: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: true - /@types/node@20.19.30: - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + /@types/node@25.2.0: + resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} dependencies: - undici-types: 6.21.0 + undici-types: 7.16.0 /@types/offscreencanvas@2019.7.3: resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} @@ -2677,20 +2884,20 @@ packages: resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} dependencies: '@types/mime': 1.3.5 - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: true /@types/send@1.2.1: resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: true /@types/serve-static@1.15.10: resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} dependencies: '@types/http-errors': 2.0.5 - '@types/node': 20.19.30 + '@types/node': 25.2.0 '@types/send': 0.17.6 dev: true @@ -2702,7 +2909,7 @@ packages: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 20.19.30 + '@types/node': 25.2.0 form-data: 4.0.5 dev: true @@ -2721,8 +2928,8 @@ packages: fflate: 0.6.10 meshoptimizer: 0.18.1 - /@types/vscode@1.108.1: - resolution: {integrity: sha512-DerV0BbSzt87TbrqmZ7lRDIYaMiqvP8tmJTzW2p49ZBVtGUnGAu2RGQd1Wv4XMzEVUpaHbsemVM5nfuQJj7H6w==} + /@types/vscode@1.109.0: + resolution: {integrity: sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==} dev: true /@types/webxr@0.5.24: @@ -2731,7 +2938,7 @@ packages: /@types/ws@8.18.1: resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 dev: true /@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0)(eslint@9.39.2)(typescript@5.9.3): @@ -2908,12 +3115,12 @@ packages: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.30) + vite: 5.4.21(@types/node@25.2.0) transitivePeerDependencies: - supports-color dev: true - /@vitest/coverage-v8@1.6.1(vitest@1.6.1): + /@vitest/coverage-v8@1.6.1(vitest@4.0.18): resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} peerDependencies: vitest: 1.6.1 @@ -2931,48 +3138,69 @@ packages: std-env: 3.10.0 strip-literal: 2.1.1 test-exclude: 6.0.0 - vitest: 1.6.1(@types/node@20.19.30) + vitest: 4.0.18(@types/node@25.2.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color dev: true - /@vitest/expect@1.6.1: - resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + /@vitest/expect@4.0.18: + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} dependencies: - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - chai: 4.5.0 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 dev: true - /@vitest/runner@1.6.1: - resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + /@vitest/mocker@4.0.18(vite@7.3.1): + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true dependencies: - '@vitest/utils': 1.6.1 - p-limit: 5.0.0 - pathe: 1.1.2 + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + vite: 7.3.1(@types/node@25.2.0)(tsx@4.21.0) dev: true - /@vitest/snapshot@1.6.1: - resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + /@vitest/pretty-format@4.0.18: + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} dependencies: - magic-string: 0.30.21 - pathe: 1.1.2 - pretty-format: 29.7.0 + tinyrainbow: 3.0.3 dev: true - /@vitest/spy@1.6.1: - resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + /@vitest/runner@4.0.18: + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} dependencies: - tinyspy: 2.2.1 + '@vitest/utils': 4.0.18 + pathe: 2.0.3 dev: true - /@vitest/utils@1.6.1: - resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + /@vitest/snapshot@4.0.18: + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} dependencies: - diff-sequences: 29.6.3 - estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + dev: true + + /@vitest/spy@4.0.18: + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + dev: true + + /@vitest/utils@4.0.18: + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 dev: true /@vscode/vsce-sign-alpine-arm64@2.0.6: @@ -3135,13 +3363,6 @@ packages: acorn: 8.15.0 dev: true - /acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - dependencies: - acorn: 8.15.0 - dev: true - /acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -3212,11 +3433,6 @@ packages: dependencies: color-convert: 2.0.1 - /ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - dev: true - /ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3318,8 +3534,9 @@ packages: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} dev: true - /assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + /assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} dev: true /async-function@1.0.0: @@ -3593,11 +3810,6 @@ packages: engines: {node: '>= 0.8'} dev: false - /cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - dev: true - /call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3643,17 +3855,9 @@ packages: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} dev: true - /chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 + /chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} dev: true /chalk@2.4.2: @@ -3682,12 +3886,6 @@ packages: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} dev: false - /check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - dependencies: - get-func-name: 2.0.2 - dev: true - /cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} dependencies: @@ -3872,10 +4070,6 @@ packages: yargs: 17.7.2 dev: true - /confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - dev: true - /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -4035,13 +4229,6 @@ packages: dependencies: mimic-response: 3.1.0 - /deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - dependencies: - type-detect: 4.1.0 - dev: true - /deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4124,11 +4311,6 @@ packages: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true - /diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} dev: true @@ -4423,6 +4605,10 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + /es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + dev: true + /es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4745,26 +4931,16 @@ packages: eventsource-parser: 3.0.6 dev: false - /execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - dev: true - /expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} requiresBuild: true + /expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + dev: true + /express-rate-limit@7.5.1(express@5.2.1): resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -5097,10 +5273,6 @@ packages: engines: {node: '>=18'} dev: false - /get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - dev: true - /get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -5123,11 +5295,6 @@ packages: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - /get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - dev: true - /get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -5318,11 +5485,6 @@ packages: - supports-color dev: true - /human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - dev: true - /husky@8.0.3: resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} engines: {node: '>=14'} @@ -5590,11 +5752,6 @@ packages: call-bound: 1.0.4 dev: true - /is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dev: true - /is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -5863,14 +6020,6 @@ packages: uc.micro: 1.0.6 dev: true - /local-pkg@0.5.1: - resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} - engines: {node: '>=14'} - dependencies: - mlly: 1.8.0 - pkg-types: 1.3.1 - dev: true - /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5932,12 +6081,6 @@ packages: dependencies: js-tokens: 4.0.0 - /loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - dependencies: - get-func-name: 2.0.2 - dev: true - /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} dev: false @@ -6032,10 +6175,6 @@ packages: engines: {node: '>=18'} dev: false - /merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - dev: true - /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -6096,11 +6235,6 @@ packages: hasBin: true dev: true - /mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - dev: true - /mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -6142,15 +6276,6 @@ packages: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} requiresBuild: true - /mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - dev: true - /ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} dev: false @@ -6248,13 +6373,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - path-key: 4.0.0 - dev: true - /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: @@ -6320,6 +6438,10 @@ packages: es-object-atoms: 1.1.1 dev: true + /obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + dev: true + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -6332,13 +6454,6 @@ packages: dependencies: wrappy: 1.0.2 - /onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - dependencies: - mimic-fn: 4.0.0 - dev: true - /onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -6428,13 +6543,6 @@ packages: yocto-queue: 0.1.0 dev: true - /p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - dependencies: - yocto-queue: 1.2.2 - dev: true - /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -6497,11 +6605,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - /path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - dev: true - /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true @@ -6522,18 +6625,10 @@ packages: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} dev: false - /pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - dev: true - /pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} dev: true - /pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - dev: true - /pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} dev: true @@ -6574,14 +6669,6 @@ packages: engines: {node: '>=16.20.0'} dev: false - /pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - dev: true - /platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} dev: false @@ -6708,15 +6795,6 @@ packages: hasBin: true dev: true - /pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - dev: true - /prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -6751,7 +6829,7 @@ packages: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 20.19.30 + '@types/node': 25.2.0 long: 4.0.0 dev: false @@ -6844,10 +6922,6 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - /react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - dev: true - /react-reconciler@0.27.0(react@18.3.1): resolution: {integrity: sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==} engines: {node: '>=0.10.0'} @@ -7012,6 +7086,41 @@ packages: fsevents: 2.3.3 dev: true + /rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + dev: true + /router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -7281,6 +7390,7 @@ packages: /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + dev: false /simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -7491,11 +7601,6 @@ packages: engines: {node: '>=4'} dev: true - /strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - dev: true - /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -7722,6 +7827,11 @@ packages: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} dev: true + /tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + dev: true + /tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -7730,13 +7840,8 @@ packages: picomatch: 4.0.3 dev: true - /tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} - engines: {node: '>=14.0.0'} - dev: true - - /tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + /tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} dev: true @@ -8051,11 +8156,6 @@ packages: prelude-ls: 1.2.1 dev: true - /type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - dev: true - /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -8152,10 +8252,6 @@ packages: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} dev: true - /ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - dev: true - /unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -8170,8 +8266,8 @@ packages: resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} dev: true - /undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + /undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} /undici@7.19.2: resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==} @@ -8237,29 +8333,7 @@ packages: engines: {node: '>= 0.8'} dev: false - /vite-node@1.6.1(@types/node@20.19.30): - resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - dependencies: - cac: 6.7.14 - debug: 4.4.3 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.21(@types/node@20.19.30) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - dev: true - - /vite@5.4.21(@types/node@20.19.30): + /vite@5.4.21(@types/node@25.2.0): resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -8290,7 +8364,7 @@ packages: terser: optional: true dependencies: - '@types/node': 20.19.30 + '@types/node': 25.2.0 esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.57.0 @@ -8298,23 +8372,84 @@ packages: fsevents: 2.3.3 dev: true - /vitest@1.6.1(@types/node@20.19.30): - resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} - engines: {node: ^18.0.0 || >=20.0.0} + /vite@7.3.1(@types/node@25.2.0)(tsx@4.21.0): + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + dependencies: + '@types/node': 25.2.0 + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + tsx: 4.21.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitest@4.0.18(@types/node@25.2.0)(tsx@4.21.0): + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.1 - '@vitest/ui': 1.6.1 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@opentelemetry/api': + optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -8323,36 +8458,39 @@ packages: jsdom: optional: true dependencies: - '@types/node': 20.19.30 - '@vitest/expect': 1.6.1 - '@vitest/runner': 1.6.1 - '@vitest/snapshot': 1.6.1 - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - acorn-walk: 8.3.4 - chai: 4.5.0 - debug: 4.4.3 - execa: 8.0.1 - local-pkg: 0.5.1 + '@types/node': 25.2.0 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 magic-string: 0.30.21 - pathe: 1.1.2 - picocolors: 1.1.1 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 std-env: 3.10.0 - strip-literal: 2.1.1 tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.4.21(@types/node@20.19.30) - vite-node: 1.6.1(@types/node@20.19.30) + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.2.0)(tsx@4.21.0) why-is-node-running: 2.3.0 transitivePeerDependencies: + - jiti - less - lightningcss + - msw - sass - sass-embedded - stylus - sugarss - - supports-color - terser + - tsx + - yaml dev: true /vscode-jsonrpc@8.2.0: @@ -8598,11 +8736,6 @@ packages: engines: {node: '>=10'} dev: true - /yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - dev: true - /yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'}