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..0acb4a4b 100644 --- a/package.json +++ b/package.json @@ -47,12 +47,13 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^20.10.0", - "@vitest/coverage-v8": "^1.0.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.0.0", "eslint-plugin-import": "^2.29.0", - "husky": "^8.0.3", + "husky": "^9.1.7", "prettier": "^3.1.0", - "turbo": "^1.11.0", + "tsx": "^4.7.0", + "turbo": "^2.8.3", "typescript": "^5.3.0", "typescript-eslint": "^8.0.0", "vitest": "^1.0.0" diff --git a/packages/ai/package.json b/packages/ai/package.json index f2497c8d..e27713a4 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@types/node": "^20.10.0", - "@vitest/coverage-v8": "^1.0.0", + "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.3.0", "vitest": "^1.0.0" } diff --git a/packages/ci/package.json b/packages/ci/package.json index dc300361..2cf5d949 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -29,9 +29,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@octokit/rest": "^20.0.0", - "@octokit/webhooks": "^12.0.0", - "commander": "^11.0.0", + "@octokit/rest": "^22.0.1", + "@octokit/webhooks": "^14.2.0", + "commander": "^14.0.3", "driftdetect-core": "0.9.47", "driftdetect-cortex": "0.9.47", "driftdetect-detectors": "0.9.47", diff --git a/packages/cibench/package.json b/packages/cibench/package.json index 7bc955ac..daf12472 100644 --- a/packages/cibench/package.json +++ b/packages/cibench/package.json @@ -30,8 +30,8 @@ "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", - "commander": "^12.0.0", - "glob": "^10.3.10" + "commander": "^14.0.3", + "glob": "^13.0.1" }, "devDependencies": { "@types/node": "^20.11.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index e2bb8b8f..162835cb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -63,25 +63,25 @@ "postinstall": "node scripts/postinstall.js || true" }, "dependencies": { - "@inquirer/prompts": "^7.0.0", + "@inquirer/prompts": "^8.2.0", "chalk": "^5.3.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", - "commander": "^12.1.0", + "commander": "^14.0.3", "driftdetect-core": "0.9.47", "driftdetect-cortex": "0.9.47", "driftdetect-dashboard": "0.9.47", "driftdetect-detectors": "0.9.47", - "ora": "^8.1.0", + "ora": "^9.3.0", "piscina": "^5.1.4", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.2.4", + "react-dom": "^19.2.4" }, "devDependencies": { "@types/cli-progress": "^3.11.6", "@types/node": "^20.10.0", - "@vitest/coverage-v8": "^1.0.0", - "fast-check": "^3.15.0", + "@vitest/coverage-v8": "^4.0.18", + "fast-check": "^4.5.3", "typescript": "^5.3.0", "vitest": "^1.0.0" } 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..127b302d 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", @@ -59,34 +63,41 @@ "node": ">=18" }, "dependencies": { - "better-sqlite3": "^11.0.0", - "driftdetect-detectors": "workspace:0.9.47", - "ignore": "^5.3.1", - "minimatch": "^9.0.3", + "better-sqlite3": "^12.6.2", + "ignore": "^7.0.5", + "minimatch": "^10.1.2", "piscina": "^5.1.4", "simple-git": "^3.30.0", - "tree-sitter": "^0.21.1", - "tree-sitter-c-sharp": "^0.21.3", - "tree-sitter-cpp": "^0.22.0", - "tree-sitter-go": "^0.21.0", - "tree-sitter-java": "^0.21.0", - "tree-sitter-javascript": "^0.21.4", - "tree-sitter-php": "^0.22.0", - "tree-sitter-python": "^0.21.0", - "tree-sitter-rust": "^0.21.0", - "tree-sitter-typescript": "^0.21.2", + "tree-sitter": "^0.25.0", + "tree-sitter-c-sharp": "^0.23.1", + "tree-sitter-cpp": "^0.23.4", + "tree-sitter-go": "^0.25.0", + "tree-sitter-java": "^0.23.5", + "tree-sitter-javascript": "^0.25.0", + "tree-sitter-php": "^0.24.2", + "tree-sitter-python": "^0.25.0", + "tree-sitter-rust": "^0.24.0", + "tree-sitter-typescript": "^0.23.2", "typescript": "^5.3.0" }, "optionalDependencies": { - "driftdetect-native": "0.9.45" + "driftdetect-native": "0.9.47" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", "@types/minimatch": "^5.1.2", "@types/node": "^20.19.30", - "@vitest/coverage-v8": "^1.0.0", - "fast-check": "^3.15.0", + "@vitest/coverage-v8": "^4.0.18", + "fast-check": "^4.5.3", "typescript": "^5.3.0", "vitest": "^1.0.0" + }, + "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..1758bc4b 100644 --- a/packages/cortex/package.json +++ b/packages/cortex/package.json @@ -76,14 +76,14 @@ "node": ">=18" }, "dependencies": { - "better-sqlite3": "^11.0.0", + "better-sqlite3": "^12.6.2", "sqlite-vec": "^0.1.6", "@xenova/transformers": "^2.17.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", "@types/node": "^20.10.0", - "@vitest/coverage-v8": "^1.0.0", + "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.3.0", "vitest": "^1.0.0" }, diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index aca4aaf3..da113eb8 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -53,43 +53,43 @@ "dependencies": { "driftdetect-core": "0.9.45", "driftdetect-galaxy": "0.9.45", - "express": "^4.18.2", - "open": "^10.0.0", + "express": "^5.2.1", + "open": "^11.0.0", "ws": "^8.16.0" }, "devDependencies": { - "@react-three/drei": "^9.92.0", - "@react-three/fiber": "^8.15.12", - "@react-three/postprocessing": "^2.15.11", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "@tanstack/react-query": "^5.17.0", - "@types/express": "^4.17.21", + "@types/express": "^5.0.6", "@types/node": "^20.10.0", "@types/prismjs": "^1.26.3", - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", + "@types/react": "^19.2.11", + "@types/react-dom": "^19.2.3", "@types/supertest": "^6.0.3", - "@types/three": "^0.160.0", + "@types/three": "^0.182.0", "@types/ws": "^8.5.10", - "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^1.0.0", - "autoprefixer": "^10.4.16", - "concurrently": "^8.2.2", - "fast-check": "^3.15.0", + "@vitejs/plugin-react": "^5.1.3", + "@vitest/coverage-v8": "^4.0.18", + "autoprefixer": "^10.4.24", + "concurrently": "^9.2.1", + "fast-check": "^4.5.3", "postcss": "^8.4.32", "prismjs": "^1.29.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "supertest": "^7.2.2", - "tailwindcss": "^3.4.0", - "three": "^0.160.0", + "tailwindcss": "^4.1.18", + "three": "^0.182.0", "tsx": "^4.7.0", "typescript": "^5.3.0", - "vite": "^5.0.10", + "vite": "^7.3.1", "vitest": "^1.0.0", - "zustand": "^4.4.7" + "zustand": "^5.0.11" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.2.4", + "react-dom": "^19.2.4" } } diff --git a/packages/detectors/package.json b/packages/detectors/package.json index 20eb588b..d438a847 100644 --- a/packages/detectors/package.json +++ b/packages/detectors/package.json @@ -41,12 +41,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "driftdetect-core": "0.9.45" + "driftdetect-core": "workspace:*" }, "devDependencies": { "@types/node": "^20.10.0", - "@vitest/coverage-v8": "^1.0.0", - "fast-check": "^3.15.0", + "@vitest/coverage-v8": "^4.0.18", + "fast-check": "^4.5.3", "typescript": "^5.3.0", "vitest": "^1.0.0" } 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/galaxy/package.json b/packages/galaxy/package.json index 7653c581..add83d43 100644 --- a/packages/galaxy/package.json +++ b/packages/galaxy/package.json @@ -39,23 +39,23 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@react-three/drei": "^9.92.0", - "@react-three/fiber": "^8.15.12", - "@react-three/postprocessing": "^2.15.11", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "jsfxr": "^1.2.2", - "three": "^0.160.0", - "zustand": "^4.4.7" + "three": "^0.182.0", + "zustand": "^5.0.11" }, "devDependencies": { - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", - "@types/three": "^0.160.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "@types/react": "^19.2.11", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "typescript": "^5.3.0" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.2.4", + "react-dom": "^19.2.4" } } diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 64efdb2f..99912571 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@types/node": "^20.10.0", - "@vitest/coverage-v8": "^1.0.0", + "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.3.0", "vitest": "^1.0.0" } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index dd9219d2..c0467377 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -56,7 +56,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.26.0", "driftdetect-core": "0.9.47", "driftdetect-cortex": "0.9.47", "driftdetect-detectors": "0.9.47", 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..77ed19e1 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -397,8 +397,8 @@ "devDependencies": { "@types/node": "^20.10.0", "@types/vscode": "^1.85.0", - "@vitest/coverage-v8": "^1.0.0", - "@vscode/vsce": "^2.22.0", + "@vitest/coverage-v8": "^4.0.18", + "@vscode/vsce": "^3.7.1", "typescript": "^5.3.0", "vitest": "^1.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 246157a5..0682d670 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^20.10.0 version: 20.19.30 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) eslint: specifier: ^9.0.0 version: 9.39.2 @@ -24,14 +24,17 @@ importers: specifier: ^2.29.0 version: 2.32.0(@typescript-eslint/parser@8.54.0)(eslint@9.39.2) husky: - specifier: ^8.0.3 - version: 8.0.3 + specifier: ^9.1.7 + version: 9.1.7 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 + specifier: ^2.8.3 + version: 2.8.3 typescript: specifier: ^5.3.0 version: 5.9.3 @@ -52,8 +55,8 @@ importers: specifier: ^20.10.0 version: 20.19.30 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -64,14 +67,14 @@ importers: packages/ci: dependencies: '@octokit/rest': - specifier: ^20.0.0 - version: 20.1.2 + specifier: ^22.0.1 + version: 22.0.1 '@octokit/webhooks': - specifier: ^12.0.0 - version: 12.3.2 + specifier: ^14.2.0 + version: 14.2.0 commander: - specifier: ^11.0.0 - version: 11.1.0 + specifier: ^14.0.3 + version: 14.0.3 driftdetect-core: specifier: 0.9.47 version: link:../core @@ -104,11 +107,11 @@ importers: specifier: ^5.3.0 version: 5.6.2 commander: - specifier: ^12.0.0 - version: 12.1.0 + specifier: ^14.0.3 + version: 14.0.3 glob: - specifier: ^10.3.10 - version: 10.5.0 + specifier: ^13.0.1 + version: 13.0.1 devDependencies: '@types/node': specifier: ^20.11.0 @@ -123,8 +126,8 @@ importers: packages/cli: dependencies: '@inquirer/prompts': - specifier: ^7.0.0 - version: 7.10.1(@types/node@20.19.30) + specifier: ^8.2.0 + version: 8.2.0(@types/node@20.19.30) chalk: specifier: ^5.3.0 version: 5.6.2 @@ -135,8 +138,8 @@ importers: specifier: ^0.6.5 version: 0.6.5 commander: - specifier: ^12.1.0 - version: 12.1.0 + specifier: ^14.0.3 + version: 14.0.3 driftdetect-core: specifier: 0.9.47 version: link:../core @@ -150,17 +153,17 @@ importers: specifier: 0.9.47 version: link:../detectors ora: - specifier: ^8.1.0 - version: 8.2.0 + specifier: ^9.3.0 + version: 9.3.0 piscina: specifier: ^5.1.4 version: 5.1.4 react: - specifier: ^18.2.0 - version: 18.3.1 + specifier: ^19.2.4 + version: 19.2.4 react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) devDependencies: '@types/cli-progress': specifier: ^3.11.6 @@ -169,11 +172,11 @@ importers: specifier: ^20.10.0 version: 20.19.30 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) fast-check: - specifier: ^3.15.0 - version: 3.23.2 + specifier: ^4.5.3 + version: 4.5.3 typescript: specifier: ^5.3.0 version: 5.9.3 @@ -184,17 +187,17 @@ importers: packages/core: dependencies: better-sqlite3: - specifier: ^11.0.0 - version: 11.10.0 + specifier: ^12.6.2 + version: 12.6.2 driftdetect-detectors: - specifier: workspace:0.9.47 + specifier: workspace:* version: link:../detectors ignore: - specifier: ^5.3.1 - version: 5.3.2 + specifier: ^7.0.5 + version: 7.0.5 minimatch: - specifier: ^9.0.3 - version: 9.0.5 + specifier: ^10.1.2 + version: 10.1.2 piscina: specifier: ^5.1.4 version: 5.1.4 @@ -202,42 +205,42 @@ importers: specifier: ^3.30.0 version: 3.30.0 tree-sitter: - specifier: ^0.21.1 - version: 0.21.1 + specifier: ^0.25.0 + version: 0.25.0 tree-sitter-c-sharp: - specifier: ^0.21.3 - version: 0.21.3(tree-sitter@0.21.1) + specifier: ^0.23.1 + version: 0.23.1(tree-sitter@0.25.0) tree-sitter-cpp: - specifier: ^0.22.0 - version: 0.22.3(tree-sitter@0.21.1) + specifier: ^0.23.4 + version: 0.23.4(tree-sitter@0.25.0) tree-sitter-go: - specifier: ^0.21.0 - version: 0.21.2(tree-sitter@0.21.1) + specifier: ^0.25.0 + version: 0.25.0(tree-sitter@0.25.0) tree-sitter-java: - specifier: ^0.21.0 - version: 0.21.0(tree-sitter@0.21.1) + specifier: ^0.23.5 + version: 0.23.5(tree-sitter@0.25.0) tree-sitter-javascript: - specifier: ^0.21.4 - version: 0.21.4(tree-sitter@0.21.1) + specifier: ^0.25.0 + version: 0.25.0(tree-sitter@0.25.0) tree-sitter-php: - specifier: ^0.22.0 - version: 0.22.8(tree-sitter@0.21.1) + specifier: ^0.24.2 + version: 0.24.2(tree-sitter@0.25.0) tree-sitter-python: - specifier: ^0.21.0 - version: 0.21.0(tree-sitter@0.21.1) + specifier: ^0.25.0 + version: 0.25.0(tree-sitter@0.25.0) tree-sitter-rust: - specifier: ^0.21.0 - version: 0.21.0(tree-sitter@0.21.1) + specifier: ^0.24.0 + version: 0.24.0(tree-sitter@0.25.0) tree-sitter-typescript: - specifier: ^0.21.2 - version: 0.21.2(tree-sitter@0.21.1) + specifier: ^0.23.2 + version: 0.23.2(tree-sitter@0.25.0) typescript: specifier: ^5.3.0 version: 5.9.3 optionalDependencies: driftdetect-native: - specifier: 0.9.45 - version: 0.9.45 + specifier: 0.9.47 + version: 0.9.47 devDependencies: '@types/better-sqlite3': specifier: ^7.6.11 @@ -249,11 +252,11 @@ importers: specifier: ^20.19.30 version: 20.19.30 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) fast-check: - specifier: ^3.15.0 - version: 3.23.2 + specifier: ^4.5.3 + version: 4.5.3 vitest: specifier: ^1.0.0 version: 1.6.1(@types/node@20.19.30) @@ -264,8 +267,8 @@ importers: specifier: ^2.17.0 version: 2.17.2 better-sqlite3: - specifier: ^11.0.0 - version: 11.10.0 + specifier: ^12.6.2 + version: 12.6.2 driftdetect-core: specifier: ^0.9.0 version: link:../core @@ -280,8 +283,8 @@ importers: specifier: ^20.10.0 version: 20.19.30 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -296,32 +299,32 @@ importers: version: 0.9.45 driftdetect-galaxy: specifier: 0.9.45 - version: 0.9.45(@types/react@18.3.27)(@types/three@0.160.0)(react-dom@18.3.1)(react@18.3.1) + version: 0.9.45(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4) express: - specifier: ^4.18.2 - version: 4.22.1 + specifier: ^5.2.1 + version: 5.2.1 open: - specifier: ^10.0.0 - version: 10.2.0 + specifier: ^11.0.0 + version: 11.0.0 ws: specifier: ^8.16.0 version: 8.19.0 devDependencies: '@react-three/drei': - specifier: ^9.92.0 - version: 9.122.0(@react-three/fiber@8.18.0)(@types/react@18.3.27)(@types/three@0.160.0)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) + specifier: ^10.7.7 + version: 10.7.7(@react-three/fiber@9.5.0)(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0) '@react-three/fiber': - specifier: ^8.15.12 - version: 8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) + specifier: ^9.5.0 + version: 9.5.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0) '@react-three/postprocessing': - specifier: ^2.15.11 - version: 2.19.1(@react-three/fiber@8.18.0)(@types/three@0.160.0)(react@18.3.1)(three@0.160.1) + specifier: ^3.0.4 + version: 3.0.4(@react-three/fiber@9.5.0)(@types/three@0.182.0)(react@19.2.4)(three@0.182.0) '@tanstack/react-query': specifier: ^5.17.0 - version: 5.90.20(react@18.3.1) + version: 5.90.20(react@19.2.4) '@types/express': - specifier: ^4.17.21 - version: 4.17.25 + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: ^20.10.0 version: 20.19.30 @@ -329,35 +332,35 @@ importers: specifier: ^1.26.3 version: 1.26.5 '@types/react': - specifier: ^18.2.45 - version: 18.3.27 + specifier: ^19.2.11 + version: 19.2.11 '@types/react-dom': - specifier: ^18.2.18 - version: 18.3.7(@types/react@18.3.27) + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.11) '@types/supertest': specifier: ^6.0.3 version: 6.0.3 '@types/three': - specifier: ^0.160.0 - version: 0.160.0 + specifier: ^0.182.0 + version: 0.182.0 '@types/ws': specifier: ^8.5.10 version: 8.18.1 '@vitejs/plugin-react': - specifier: ^4.2.1 - version: 4.7.0(vite@5.4.21) + specifier: ^5.1.3 + version: 5.1.3(vite@7.3.1) '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) autoprefixer: - specifier: ^10.4.16 - version: 10.4.23(postcss@8.5.6) + specifier: ^10.4.24 + version: 10.4.24(postcss@8.5.6) concurrently: - specifier: ^8.2.2 - version: 8.2.2 + specifier: ^9.2.1 + version: 9.2.1 fast-check: - specifier: ^3.15.0 - version: 3.23.2 + specifier: ^4.5.3 + version: 4.5.3 postcss: specifier: ^8.4.32 version: 8.5.6 @@ -365,20 +368,20 @@ importers: specifier: ^1.29.0 version: 1.30.0 react: - specifier: ^18.2.0 - version: 18.3.1 + specifier: ^19.2.4 + version: 19.2.4 react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) supertest: specifier: ^7.2.2 version: 7.2.2 tailwindcss: - specifier: ^3.4.0 - version: 3.4.19(tsx@4.21.0) + specifier: ^4.1.18 + version: 4.1.18 three: - specifier: ^0.160.0 - version: 0.160.1 + specifier: ^0.182.0 + version: 0.182.0 tsx: specifier: ^4.7.0 version: 4.21.0 @@ -386,30 +389,30 @@ importers: specifier: ^5.3.0 version: 5.9.3 vite: - specifier: ^5.0.10 - version: 5.4.21(@types/node@20.19.30) + specifier: ^7.3.1 + version: 7.3.1(@types/node@20.19.30)(tsx@4.21.0) vitest: specifier: ^1.0.0 version: 1.6.1(@types/node@20.19.30) zustand: - specifier: ^4.4.7 - version: 4.5.7(@types/react@18.3.27)(react@18.3.1) + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.11)(react@19.2.4)(use-sync-external-store@1.6.0) 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 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) fast-check: - specifier: ^3.15.0 - version: 3.23.2 + specifier: ^4.5.3 + version: 4.5.3 typescript: specifier: ^5.3.0 version: 5.9.3 @@ -420,39 +423,39 @@ importers: packages/galaxy: dependencies: '@react-three/drei': - specifier: ^9.92.0 - version: 9.122.0(@react-three/fiber@8.18.0)(@types/react@18.3.27)(@types/three@0.160.0)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) + specifier: ^10.7.7 + version: 10.7.7(@react-three/fiber@9.5.0)(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0) '@react-three/fiber': - specifier: ^8.15.12 - version: 8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) + specifier: ^9.5.0 + version: 9.5.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0) '@react-three/postprocessing': - specifier: ^2.15.11 - version: 2.19.1(@react-three/fiber@8.18.0)(@types/three@0.160.0)(react@18.3.1)(three@0.160.1) + specifier: ^3.0.4 + version: 3.0.4(@react-three/fiber@9.5.0)(@types/three@0.182.0)(react@19.2.4)(three@0.182.0) jsfxr: specifier: ^1.2.2 version: 1.4.0 three: - specifier: ^0.160.0 - version: 0.160.1 + specifier: ^0.182.0 + version: 0.182.0 zustand: - specifier: ^4.4.7 - version: 4.5.7(@types/react@18.3.27)(react@18.3.1) + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.11)(react@19.2.4)(use-sync-external-store@1.6.0) devDependencies: '@types/react': - specifier: ^18.2.45 - version: 18.3.27 + specifier: ^19.2.11 + version: 19.2.11 '@types/react-dom': - specifier: ^18.2.18 - version: 18.3.7(@types/react@18.3.27) + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.11) '@types/three': - specifier: ^0.160.0 - version: 0.160.0 + specifier: ^0.182.0 + version: 0.182.0 react: - specifier: ^18.2.0 - version: 18.3.1 + specifier: ^19.2.4 + version: 19.2.4 react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -479,8 +482,8 @@ importers: specifier: ^20.10.0 version: 20.19.30 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -491,8 +494,8 @@ importers: packages/mcp: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.25.3(hono@4.11.7)(zod@4.3.6) + specifier: ^1.26.0 + version: 1.26.0(zod@4.3.6) driftdetect-core: specifier: 0.9.47 version: link:../core @@ -529,11 +532,11 @@ importers: specifier: ^1.85.0 version: 1.108.1 '@vitest/coverage-v8': - specifier: ^1.0.0 - version: 1.6.1(vitest@1.6.1) + specifier: ^4.0.18 + version: 4.0.18(vitest@1.6.1) '@vscode/vsce': - specifier: ^2.22.0 - version: 2.32.0 + specifier: ^3.7.1 + version: 3.7.1 typescript: specifier: ^5.3.0 version: 5.9.3 @@ -543,17 +546,14 @@ importers: packages: - /@alloc/quick-lru@5.2.0: - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} + /@azu/format-text@1.0.2: + resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} dev: true - /@ampproject/remapping@2.3.0: - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} + /@azu/style-format@1.0.1: + resolution: {integrity: sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==} dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@azu/format-text': 1.0.2 dev: true /@azure/abort-controller@2.1.2: @@ -672,8 +672,8 @@ packages: uuid: 8.3.2 dev: true - /@babel/code-frame@7.28.6: - resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + /@babel/code-frame@7.29.0: + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -681,24 +681,24 @@ packages: picocolors: 1.1.1 dev: true - /@babel/compat-data@7.28.6: - resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + /@babel/compat-data@7.29.0: + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} dev: true - /@babel/core@7.28.6: - resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + /@babel/core@7.29.0: + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.28.6 - '@babel/generator': 7.28.6 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.28.6 - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -709,12 +709,12 @@ packages: - supports-color dev: true - /@babel/generator@7.28.6: - resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + /@babel/generator@7.29.1: + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -724,7 +724,7 @@ packages: resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/compat-data': 7.28.6 + '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.1 lru-cache: 5.1.1 @@ -740,22 +740,22 @@ packages: resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color dev: true - /@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6): + /@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0): resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.28.6 + '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.6 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color dev: true @@ -785,34 +785,34 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.28.6 - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 dev: true - /@babel/parser@7.28.6: - resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + /@babel/parser@7.29.0: + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 dev: true - /@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6): + /@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0): resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.28.6 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 dev: true - /@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6): + /@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0): resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.28.6 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 dev: true @@ -824,36 +824,37 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.28.6 - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 dev: true - /@babel/traverse@7.28.6: - resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + /@babel/traverse@7.29.0: + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.28.6 - '@babel/generator': 7.28.6 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color dev: true - /@babel/types@7.28.6: - resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + /@babel/types@7.29.0: + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 dev: true - /@bcoe/v8-coverage@0.2.3: - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + /@bcoe/v8-coverage@1.0.2: + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} dev: true /@colors/colors@1.5.0: @@ -863,6 +864,9 @@ packages: dev: false optional: true + /@dimforge/rapier3d-compat@0.12.0: + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + /@esbuild/aix-ppc64@0.21.5: resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1416,95 +1420,92 @@ packages: engines: {node: '>=18.18'} dev: true - /@inquirer/ansi@1.0.2: - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + /@inquirer/ansi@2.0.3: + resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} dev: false - /@inquirer/checkbox@4.3.2(@types/node@20.19.30): - resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} - engines: {node: '>=18'} + /@inquirer/checkbox@5.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-DrAMU3YBGMUAp6ArwTIp/25CNDtDbxk7UjIrrtM25JVVrlVYlVzHh5HR1BDFu9JMyUoZ4ZanzeaHqNDttf3gVg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 - yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/confirm@5.1.21(@types/node@20.19.30): - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + /@inquirer/confirm@6.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-WdaPe7foUnoGYvXzH4jp4wH/3l+dBhZ3uwhKjXjwdrq5tEIFaANxj6zrGHxLdsIA0yKM0kFPVcEalOZXBB5ISA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 dev: false - /@inquirer/core@10.3.2(@types/node@20.19.30): - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + /@inquirer/core@11.1.1(@types/node@20.19.30): + resolution: {integrity: sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) + '@inquirer/ansi': 2.0.3 + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 cli-width: 4.1.0 - mute-stream: 2.0.0 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 + wrap-ansi: 9.0.2 dev: false - /@inquirer/editor@4.2.23(@types/node@20.19.30): - resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} - engines: {node: '>=18'} + /@inquirer/editor@5.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-QI3Jfqcv6UO2/VJaEFONH8Im1ll++Xn/AJTBn9Xf+qx2M+H8KZAdQ5sAe2vtYlo+mLW+d7JaMJB4qWtK4BG3pw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/external-editor': 2.0.3(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 dev: false - /@inquirer/expand@4.0.23(@types/node@20.19.30): - resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} - engines: {node: '>=18'} + /@inquirer/expand@5.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-0I/16YwPPP0Co7a5MsomlZLpch48NzYfToyqYAOWtBmaXSB80RiNQ1J+0xx2eG+Wfxt0nHtpEWSRr6CzNVnOGg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 - yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/external-editor@1.0.3(@types/node@20.19.30): - resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} - engines: {node: '>=18'} + /@inquirer/external-editor@2.0.3(@types/node@20.19.30): + resolution: {integrity: sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1516,127 +1517,124 @@ packages: iconv-lite: 0.7.2 dev: false - /@inquirer/figures@1.0.15: - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + /@inquirer/figures@2.0.3: + resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} dev: false - /@inquirer/input@4.3.1(@types/node@20.19.30): - resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} - engines: {node: '>=18'} + /@inquirer/input@5.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-4B3s3jvTREDFvXWit92Yc6jF1RJMDy2VpSqKtm4We2oVU65YOh2szY5/G14h4fHlyQdpUmazU5MPCFZPRJ0AOw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 dev: false - /@inquirer/number@3.0.23(@types/node@20.19.30): - resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} - engines: {node: '>=18'} + /@inquirer/number@4.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-CmMp9LF5HwE+G/xWsC333TlCzYYbXMkcADkKzcawh49fg2a1ryLc7JL1NJYYt1lJ+8f4slikNjJM9TEL/AljYQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 dev: false - /@inquirer/password@4.0.23(@types/node@20.19.30): - resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} - engines: {node: '>=18'} + /@inquirer/password@5.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-ZCEPyVYvHK4W4p2Gy6sTp9nqsdHQCfiPXIP9LbJVW4yCinnxL/dDDmPaEZVysGrj8vxVReRnpfS2fOeODe9zjg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': 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) + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 dev: false - /@inquirer/prompts@7.10.1(@types/node@20.19.30): - resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} - engines: {node: '>=18'} + /@inquirer/prompts@8.2.0(@types/node@20.19.30): + resolution: {integrity: sha512-rqTzOprAj55a27jctS3vhvDDJzYXsr33WXTjODgVOru21NvBo9yIgLIAf7SBdSV0WERVly3dR6TWyp7ZHkvKFA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/checkbox': 5.0.4(@types/node@20.19.30) + '@inquirer/confirm': 6.0.4(@types/node@20.19.30) + '@inquirer/editor': 5.0.4(@types/node@20.19.30) + '@inquirer/expand': 5.0.4(@types/node@20.19.30) + '@inquirer/input': 5.0.4(@types/node@20.19.30) + '@inquirer/number': 4.0.4(@types/node@20.19.30) + '@inquirer/password': 5.0.4(@types/node@20.19.30) + '@inquirer/rawlist': 5.2.0(@types/node@20.19.30) + '@inquirer/search': 4.1.0(@types/node@20.19.30) + '@inquirer/select': 5.0.4(@types/node@20.19.30) '@types/node': 20.19.30 dev: false - /@inquirer/rawlist@4.1.11(@types/node@20.19.30): - resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} - engines: {node: '>=18'} + /@inquirer/rawlist@5.2.0(@types/node@20.19.30): + resolution: {integrity: sha512-CciqGoOUMrFo6HxvOtU5uL8fkjCmzyeB6fG7O1vdVAZVSopUBYECOwevDBlqNLyyYmzpm2Gsn/7nLrpruy9RFg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@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) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 - yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/search@3.2.2(@types/node@20.19.30): - resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} - engines: {node: '>=18'} + /@inquirer/search@4.1.0(@types/node@20.19.30): + resolution: {integrity: sha512-EAzemfiP4IFvIuWnrHpgZs9lAhWDA0GM3l9F4t4mTQ22IFtzfrk8xbkMLcAN7gmVML9O/i+Hzu8yOUyAaL6BKA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 - yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/select@4.4.2(@types/node@20.19.30): - resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} - engines: {node: '>=18'} + /@inquirer/select@5.0.4(@types/node@20.19.30): + resolution: {integrity: sha512-s8KoGpPYMEQ6WXc0dT9blX2NtIulMdLOO3LA1UKOiv7KFWzlJ6eLkEYTDBIi+JkyKXyn8t/CD6TinxGjyLt57g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@20.19.30) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.30) + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.1(@types/node@20.19.30) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@20.19.30) '@types/node': 20.19.30 - yoctocolors-cjs: 2.1.3 dev: false - /@inquirer/type@3.0.10(@types/node@20.19.30): - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + /@inquirer/type@4.0.3(@types/node@20.19.30): + resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1646,6 +1644,16 @@ packages: '@types/node': 20.19.30 dev: false + /@isaacs/balanced-match@4.0.1: + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + /@isaacs/brace-expansion@5.0.1: + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/balanced-match': 4.0.1 + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1656,11 +1664,6 @@ packages: strip-ansi-cjs: /strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: /wrap-ansi@7.0.0 - dev: false - - /@istanbuljs/schema@0.1.3: - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} dev: true /@jest/schemas@29.6.3: @@ -1715,8 +1718,8 @@ packages: /@mediapipe/tasks-vision@0.10.17: resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} - /@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6): - resolution: {integrity: sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==} + /@modelcontextprotocol/sdk@1.26.0(zod@4.3.6): + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1734,7 +1737,8 @@ packages: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 7.5.1(express@5.2.1) + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.11.7 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -1742,7 +1746,6 @@ packages: zod: 4.3.6 zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - - hono - supports-color dev: false @@ -1753,6 +1756,15 @@ packages: dependencies: promise-worker-transferable: 1.0.4 three: 0.160.1 + dev: false + + /@monogrid/gainmap-js@3.4.0(three@0.182.0): + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.182.0 /@napi-rs/nice-android-arm-eabi@1.1.1: resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} @@ -1958,126 +1970,124 @@ packages: fastq: 1.20.1 dev: true - /@octokit/auth-token@4.0.0: - resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} - engines: {node: '>= 18'} + /@octokit/auth-token@6.0.0: + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} dev: false - /@octokit/core@5.2.2: - resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} - engines: {node: '>= 18'} + /@octokit/core@7.0.6: + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} dependencies: - '@octokit/auth-token': 4.0.0 - '@octokit/graphql': 7.1.1 - '@octokit/request': 8.4.1 - '@octokit/request-error': 5.1.1 - '@octokit/types': 13.10.0 - before-after-hook: 2.2.3 - universal-user-agent: 6.0.1 + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 dev: false - /@octokit/endpoint@9.0.6: - resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} - engines: {node: '>= 18'} + /@octokit/endpoint@11.0.2: + resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + engines: {node: '>= 20'} dependencies: - '@octokit/types': 13.10.0 - universal-user-agent: 6.0.1 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 dev: false - /@octokit/graphql@7.1.1: - resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} - engines: {node: '>= 18'} + /@octokit/graphql@9.0.3: + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} dependencies: - '@octokit/request': 8.4.1 - '@octokit/types': 13.10.0 - universal-user-agent: 6.0.1 + '@octokit/request': 10.0.7 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 dev: false - /@octokit/openapi-types@24.2.0: - resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + /@octokit/openapi-types@27.0.0: + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} dev: false - /@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2): - resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '5' - dependencies: - '@octokit/core': 5.2.2 - '@octokit/types': 13.10.0 + /@octokit/openapi-webhooks-types@12.1.0: + resolution: {integrity: sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==} dev: false - /@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2): - resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} - engines: {node: '>= 18'} + /@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6): + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} peerDependencies: - '@octokit/core': '5' + '@octokit/core': '>=6' dependencies: - '@octokit/core': 5.2.2 + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 dev: false - /@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2): - resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} - engines: {node: '>= 18'} + /@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6): + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} peerDependencies: - '@octokit/core': ^5 + '@octokit/core': '>=6' dependencies: - '@octokit/core': 5.2.2 - '@octokit/types': 13.10.0 + '@octokit/core': 7.0.6 dev: false - /@octokit/request-error@5.1.1: - resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} - engines: {node: '>= 18'} + /@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6): + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' dependencies: - '@octokit/types': 13.10.0 - deprecation: 2.3.1 - once: 1.4.0 + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 dev: false - /@octokit/request@8.4.1: - resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} - engines: {node: '>= 18'} + /@octokit/request-error@7.1.0: + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} dependencies: - '@octokit/endpoint': 9.0.6 - '@octokit/request-error': 5.1.1 - '@octokit/types': 13.10.0 - universal-user-agent: 6.0.1 + '@octokit/types': 16.0.0 dev: false - /@octokit/rest@20.1.2: - resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} - engines: {node: '>= 18'} + /@octokit/request@10.0.7: + resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + engines: {node: '>= 20'} dependencies: - '@octokit/core': 5.2.2 - '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) - '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) - '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/endpoint': 11.0.2 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + universal-user-agent: 7.0.3 dev: false - /@octokit/types@13.10.0: - resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + /@octokit/rest@22.0.1: + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} dependencies: - '@octokit/openapi-types': 24.2.0 + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) dev: false - /@octokit/webhooks-methods@4.1.0: - resolution: {integrity: sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ==} - engines: {node: '>= 18'} + /@octokit/types@16.0.0: + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + dependencies: + '@octokit/openapi-types': 27.0.0 dev: false - /@octokit/webhooks-types@7.6.1: - resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==} + /@octokit/webhooks-methods@6.0.0: + resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==} + engines: {node: '>= 20'} dev: false - /@octokit/webhooks@12.3.2: - resolution: {integrity: sha512-exj1MzVXoP7xnAcAB3jZ97pTvVPkQF9y6GA/dvYC47HV7vLv+24XRS6b/v/XnyikpEuvMhugEXdGtAlU086WkQ==} - engines: {node: '>= 18'} + /@octokit/webhooks@14.2.0: + resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} + engines: {node: '>= 20'} dependencies: - '@octokit/request-error': 5.1.1 - '@octokit/webhooks-methods': 4.1.0 - '@octokit/webhooks-types': 7.6.1 - aggregate-error: 3.1.0 + '@octokit/openapi-webhooks-types': 12.1.0 + '@octokit/request-error': 7.1.0 + '@octokit/webhooks-methods': 6.0.0 dev: false /@paralleldrive/cuid2@2.3.1: @@ -2086,13 +2096,6 @@ packages: '@noble/hashes': 1.8.0 dev: true - /@pkgjs/parseargs@0.11.0: - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - requiresBuild: true - dev: false - optional: true - /@protobufjs/aspromise@1.1.2: resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} dev: false @@ -2136,56 +2139,103 @@ packages: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} dev: false - /@react-spring/animated@9.7.5(react@18.3.1): + /@react-spring/animated@9.7.5(react@19.2.4): resolution: {integrity: sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - '@react-spring/shared': 9.7.5(react@18.3.1) + '@react-spring/shared': 9.7.5(react@19.2.4) '@react-spring/types': 9.7.5 - react: 18.3.1 + react: 19.2.4 + dev: false - /@react-spring/core@9.7.5(react@18.3.1): + /@react-spring/core@9.7.5(react@19.2.4): resolution: {integrity: sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - '@react-spring/animated': 9.7.5(react@18.3.1) - '@react-spring/shared': 9.7.5(react@18.3.1) + '@react-spring/animated': 9.7.5(react@19.2.4) + '@react-spring/shared': 9.7.5(react@19.2.4) '@react-spring/types': 9.7.5 - react: 18.3.1 + react: 19.2.4 + dev: false /@react-spring/rafz@9.7.5: resolution: {integrity: sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==} + dev: false - /@react-spring/shared@9.7.5(react@18.3.1): + /@react-spring/shared@9.7.5(react@19.2.4): resolution: {integrity: sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: '@react-spring/rafz': 9.7.5 '@react-spring/types': 9.7.5 - react: 18.3.1 + react: 19.2.4 + dev: false - /@react-spring/three@9.7.5(@react-three/fiber@8.18.0)(react@18.3.1)(three@0.160.1): + /@react-spring/three@9.7.5(@react-three/fiber@8.18.0)(react@19.2.4)(three@0.160.1): resolution: {integrity: sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==} peerDependencies: '@react-three/fiber': '>=6.0' react: ^16.8.0 || ^17.0.0 || ^18.0.0 three: '>=0.126' dependencies: - '@react-spring/animated': 9.7.5(react@18.3.1) - '@react-spring/core': 9.7.5(react@18.3.1) - '@react-spring/shared': 9.7.5(react@18.3.1) + '@react-spring/animated': 9.7.5(react@19.2.4) + '@react-spring/core': 9.7.5(react@19.2.4) + '@react-spring/shared': 9.7.5(react@19.2.4) '@react-spring/types': 9.7.5 - '@react-three/fiber': 8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) - react: 18.3.1 + '@react-three/fiber': 8.18.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1) + react: 19.2.4 three: 0.160.1 + dev: false /@react-spring/types@9.7.5: resolution: {integrity: sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==} + dev: false + + /@react-three/drei@10.7.7(@react-three/fiber@9.5.0)(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0): + resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19 + react-dom: ^19 + three: '>=0.159' + peerDependenciesMeta: + react-dom: + optional: true + dependencies: + '@babel/runtime': 7.28.6 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.182.0) + '@react-three/fiber': 9.5.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0) + '@use-gesture/react': 10.3.1(react@19.2.4) + camera-controls: 3.1.2(three@0.182.0) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.15 + maath: 0.10.8(@types/three@0.182.0)(three@0.182.0) + meshline: 3.3.1(three@0.182.0) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + stats-gl: 2.4.2(@types/three@0.182.0)(three@0.182.0) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.182.0 + three-mesh-bvh: 0.8.3(three@0.182.0) + three-stdlib: 2.36.1(three@0.182.0) + troika-three-text: 0.52.4(three@0.182.0) + tunnel-rat: 0.1.2(@types/react@19.2.11)(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + utility-types: 3.11.0 + zustand: 5.0.11(@types/react@19.2.11)(react@19.2.4)(use-sync-external-store@1.6.0) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer - /@react-three/drei@9.122.0(@react-three/fiber@8.18.0)(@types/react@18.3.27)(@types/three@0.160.0)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1): + /@react-three/drei@9.122.0(@react-three/fiber@8.18.0)(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1): resolution: {integrity: sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==} peerDependencies: '@react-three/fiber': ^8 @@ -2199,36 +2249,37 @@ packages: '@babel/runtime': 7.28.6 '@mediapipe/tasks-vision': 0.10.17 '@monogrid/gainmap-js': 3.4.0(three@0.160.1) - '@react-spring/three': 9.7.5(@react-three/fiber@8.18.0)(react@18.3.1)(three@0.160.1) - '@react-three/fiber': 8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) - '@use-gesture/react': 10.3.1(react@18.3.1) + '@react-spring/three': 9.7.5(@react-three/fiber@8.18.0)(react@19.2.4)(three@0.160.1) + '@react-three/fiber': 8.18.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1) + '@use-gesture/react': 10.3.1(react@19.2.4) camera-controls: 2.10.1(three@0.160.1) cross-env: 7.0.3 detect-gpu: 5.0.70 glsl-noise: 0.0.0 hls.js: 1.6.15 - maath: 0.10.8(@types/three@0.160.0)(three@0.160.1) + maath: 0.10.8(@types/three@0.182.0)(three@0.160.1) meshline: 3.3.1(three@0.160.1) - react: 18.3.1 - react-composer: 5.0.3(react@18.3.1) - react-dom: 18.3.1(react@18.3.1) - stats-gl: 2.4.2(@types/three@0.160.0)(three@0.160.1) + react: 19.2.4 + react-composer: 5.0.3(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + stats-gl: 2.4.2(@types/three@0.182.0)(three@0.160.1) stats.js: 0.17.0 - suspend-react: 0.1.3(react@18.3.1) + suspend-react: 0.1.3(react@19.2.4) three: 0.160.1 three-mesh-bvh: 0.7.8(three@0.160.1) three-stdlib: 2.36.1(three@0.160.1) troika-three-text: 0.52.4(three@0.160.1) - tunnel-rat: 0.1.2(@types/react@18.3.27)(react@18.3.1) + tunnel-rat: 0.1.2(@types/react@19.2.11)(react@19.2.4) utility-types: 3.11.0 - zustand: 5.0.10(@types/react@18.3.27)(react@18.3.1) + zustand: 5.0.11(@types/react@19.2.11)(react@19.2.4)(use-sync-external-store@1.6.0) transitivePeerDependencies: - '@types/react' - '@types/three' - immer - use-sync-external-store + dev: false - /@react-three/fiber@8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1): + /@react-three/fiber@8.18.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1): resolution: {integrity: sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==} peerDependencies: expo: '>=43.0' @@ -2258,38 +2309,98 @@ packages: '@types/webxr': 0.5.24 base64-js: 1.5.1 buffer: 6.0.3 - its-fine: 1.2.5(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-reconciler: 0.27.0(react@18.3.1) - react-use-measure: 2.1.7(react-dom@18.3.1)(react@18.3.1) + its-fine: 1.2.5(@types/react@19.2.11)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-reconciler: 0.27.0(react@19.2.4) + react-use-measure: 2.1.7(react-dom@19.2.4)(react@19.2.4) scheduler: 0.21.0 - suspend-react: 0.1.3(react@18.3.1) + suspend-react: 0.1.3(react@19.2.4) three: 0.160.1 - zustand: 3.7.2(react@18.3.1) + zustand: 3.7.2(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@react-three/fiber@9.5.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0): + resolution: {integrity: sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.28.6 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.11)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-use-measure: 2.1.7(react-dom@19.2.4)(react@19.2.4) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.182.0 + use-sync-external-store: 1.6.0(react@19.2.4) + zustand: 5.0.11(@types/react@19.2.11)(react@19.2.4)(use-sync-external-store@1.6.0) transitivePeerDependencies: - '@types/react' + - immer - /@react-three/postprocessing@2.19.1(@react-three/fiber@8.18.0)(@types/three@0.160.0)(react@18.3.1)(three@0.160.1): + /@react-three/postprocessing@2.19.1(@react-three/fiber@8.18.0)(@types/three@0.182.0)(react@19.2.4)(three@0.160.1): resolution: {integrity: sha512-7P25LOSToH/I6b3UipNK17IIFlX4FDUmWcaomfwu82+CzhXTOz8Fcc1ZXEZ7vFA/5Fr/2peNlXgXZJvoa+aCdA==} peerDependencies: '@react-three/fiber': ^8.0 react: ^18.0 three: '>= 0.138.0' dependencies: - '@react-three/fiber': 8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) + '@react-three/fiber': 8.18.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1) buffer: 6.0.3 - maath: 0.6.0(@types/three@0.160.0)(three@0.160.1) + maath: 0.6.0(@types/three@0.182.0)(three@0.160.1) n8ao: 1.10.1(postprocessing@6.38.2)(three@0.160.1) - postprocessing: 6.38.2(three@0.160.1) - react: 18.3.1 + postprocessing: 6.38.2(three@0.182.0) + react: 19.2.4 three: 0.160.1 three-stdlib: 2.36.1(three@0.160.1) transitivePeerDependencies: - '@types/three' + dev: false + + /@react-three/postprocessing@3.0.4(@react-three/fiber@9.5.0)(@types/three@0.182.0)(react@19.2.4)(three@0.182.0): + resolution: {integrity: sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19.0 + three: '>= 0.156.0' + dependencies: + '@react-three/fiber': 9.5.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.182.0) + maath: 0.6.0(@types/three@0.182.0)(three@0.182.0) + n8ao: 1.10.1(postprocessing@6.38.2)(three@0.182.0) + postprocessing: 6.38.2(three@0.182.0) + react: 19.2.4 + three: 0.182.0 + transitivePeerDependencies: + - '@types/three' - /@rolldown/pluginutils@1.0.0-beta.27: - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + /@rolldown/pluginutils@1.0.0-rc.2: + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} dev: true /@rollup/rollup-android-arm-eabi@4.57.0: @@ -2300,6 +2411,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 +2427,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 +2443,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 +2459,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 +2475,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 +2491,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 +2507,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 +2523,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 +2539,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 +2555,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 +2571,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 +2587,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 +2603,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 +2619,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 +2635,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 +2651,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 +2667,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 +2683,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 +2699,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,8 +2715,24 @@ packages: dev: true optional: true - /@rollup/rollup-openharmony-arm64@4.57.0: - resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==} + /@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] + os: [openharmony] + requiresBuild: true + 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 @@ -2468,6 +2747,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 +2763,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 +2779,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,32 +2795,194 @@ 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 + /@secretlint/config-creator@10.2.2: + resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/types': 10.2.2 + dev: true + + /@secretlint/config-loader@10.2.2: + resolution: {integrity: sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + ajv: 8.17.1 + debug: 4.4.3 + rc-config-loader: 4.1.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@secretlint/core@10.2.2: + resolution: {integrity: sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3 + structured-source: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@secretlint/formatter@10.2.2: + resolution: {integrity: sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + '@textlint/linter-formatter': 15.5.1 + '@textlint/module-interop': 15.5.1 + '@textlint/types': 15.5.1 + chalk: 5.6.2 + debug: 4.4.3 + pluralize: 8.0.0 + strip-ansi: 7.1.2 + table: 6.9.0 + terminal-link: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@secretlint/node@10.2.2: + resolution: {integrity: sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/config-loader': 10.2.2 + '@secretlint/core': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/profiler': 10.2.2 + '@secretlint/source-creator': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3 + p-map: 7.0.4 + transitivePeerDependencies: + - supports-color + dev: true + + /@secretlint/profiler@10.2.2: + resolution: {integrity: sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==} + dev: true + + /@secretlint/resolver@10.2.2: + resolution: {integrity: sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==} + dev: true + + /@secretlint/secretlint-formatter-sarif@10.2.2: + resolution: {integrity: sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==} + dependencies: + node-sarif-builder: 3.4.0 + dev: true + + /@secretlint/secretlint-rule-no-dotenv@10.2.2: + resolution: {integrity: sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/types': 10.2.2 + dev: true + + /@secretlint/secretlint-rule-preset-recommend@10.2.2: + resolution: {integrity: sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==} + engines: {node: '>=20.0.0'} + dev: true + + /@secretlint/source-creator@10.2.2: + resolution: {integrity: sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==} + engines: {node: '>=20.0.0'} + dependencies: + '@secretlint/types': 10.2.2 + istextorbinary: 9.5.0 + dev: true + + /@secretlint/types@10.2.2: + resolution: {integrity: sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==} + engines: {node: '>=20.0.0'} + dev: true + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@sindresorhus/merge-streams@2.3.0: + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + dev: true + /@tanstack/query-core@5.90.20: resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} dev: true - /@tanstack/react-query@5.90.20(react@18.3.1): + /@tanstack/react-query@5.90.20(react@19.2.4): resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} peerDependencies: react: ^18 || ^19 dependencies: '@tanstack/query-core': 5.90.20 - react: 18.3.1 + react: 19.2.4 + dev: true + + /@textlint/ast-node-types@15.5.1: + resolution: {integrity: sha512-2ABQSaQoM9u9fycXLJKcCv4XQulJWTUSwjo6F0i/ujjqOH8/AZ2A0RDKKbAddqxDhuabVB20lYoEsZZgzehccg==} + dev: true + + /@textlint/linter-formatter@15.5.1: + resolution: {integrity: sha512-7wfzpcQtk7TZ3UJO2deTI71mJCm4VvPGUmSwE4iuH6FoaxpdWpwSBiMLcZtjYrt/oIFOtNz0uf5rI+xJiHTFww==} + dependencies: + '@azu/format-text': 1.0.2 + '@azu/style-format': 1.0.1 + '@textlint/module-interop': 15.5.1 + '@textlint/resolver': 15.5.1 + '@textlint/types': 15.5.1 + chalk: 4.1.2 + debug: 4.4.3 + js-yaml: 4.1.1 + lodash: 4.17.23 + pluralize: 2.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + table: 6.9.0 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@textlint/module-interop@15.5.1: + resolution: {integrity: sha512-Y1jcFGCKNSmHxwsLO3mshOfLYX4Wavq2+w5BG6x5lGgZv0XrF1xxURRhbnhns4LzCu0fAcx6W+3V8/1bkyTZCw==} dev: true + /@textlint/resolver@15.5.1: + resolution: {integrity: sha512-CVHxMIm8iNGccqM12CQ/ycvh+HjJId4RyC6as5ynCcp2E1Uy1TCe0jBWOpmLsbT4Nx15Ke29BmspyByawuIRyA==} + dev: true + + /@textlint/types@15.5.1: + resolution: {integrity: sha512-IY1OVZZk8LOOrbapYCsaeH7XSJT89HVukixDT8CoiWMrKGCTCZ3/Kzoa3DtMMbY8jtY777QmPOVCNnR+8fF6YQ==} + dependencies: + '@textlint/ast-node-types': 15.5.1 + dev: true + + /@tweenjs/tween.js@23.1.3: + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -2526,20 +2991,20 @@ packages: /@types/babel__generator@7.27.0: resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 dev: true /@types/babel__template@7.4.4: resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 dev: true /@types/babel__traverse@7.28.0: resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 dev: true /@types/better-sqlite3@7.6.13: @@ -2578,8 +3043,8 @@ packages: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true - /@types/express-serve-static-core@4.19.8: - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + /@types/express-serve-static-core@5.1.1: + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} dependencies: '@types/node': 20.19.30 '@types/qs': 6.14.0 @@ -2587,13 +3052,12 @@ packages: '@types/send': 1.2.1 dev: true - /@types/express@4.17.25: - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + /@types/express@5.0.6: + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} dependencies: '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.14.0 - '@types/serve-static': 1.15.10 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 dev: true /@types/http-errors@2.0.5: @@ -2616,10 +3080,6 @@ packages: resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} dev: true - /@types/mime@1.3.5: - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - dev: true - /@types/minimatch@5.1.2: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: true @@ -2629,6 +3089,10 @@ packages: dependencies: undici-types: 6.21.0 + /@types/normalize-package-data@2.4.4: + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + dev: true + /@types/offscreencanvas@2019.7.3: resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} @@ -2636,9 +3100,6 @@ packages: resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} dev: true - /@types/prop-types@15.7.15: - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - /@types/qs@6.14.0: resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} dev: true @@ -2647,37 +3108,34 @@ packages: resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} dev: true - /@types/react-dom@18.3.7(@types/react@18.3.27): - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + /@types/react-dom@19.2.3(@types/react@19.2.11): + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: - '@types/react': ^18.0.0 + '@types/react': ^19.2.0 dependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.11 dev: true /@types/react-reconciler@0.26.7: resolution: {integrity: sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==} dependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.11 + dev: false - /@types/react-reconciler@0.28.9(@types/react@18.3.27): + /@types/react-reconciler@0.28.9(@types/react@19.2.11): resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} peerDependencies: '@types/react': '*' dependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.11 - /@types/react@18.3.27: - resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + /@types/react@19.2.11: + resolution: {integrity: sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==} dependencies: - '@types/prop-types': 15.7.15 csstype: 3.2.3 - /@types/send@0.17.6: - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - dependencies: - '@types/mime': 1.3.5 - '@types/node': 20.19.30 + /@types/sarif@2.1.7: + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} dev: true /@types/send@1.2.1: @@ -2686,12 +3144,11 @@ packages: '@types/node': 20.19.30 dev: true - /@types/serve-static@1.15.10: - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + /@types/serve-static@2.2.0: + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} dependencies: '@types/http-errors': 2.0.5 '@types/node': 20.19.30 - '@types/send': 0.17.6 dev: true /@types/stats.js@0.17.4: @@ -2713,13 +3170,16 @@ packages: '@types/superagent': 8.1.9 dev: true - /@types/three@0.160.0: - resolution: {integrity: sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==} + /@types/three@0.182.0: + resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==} dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 '@types/stats.js': 0.17.4 '@types/webxr': 0.5.24 - fflate: 0.6.10 - meshoptimizer: 0.18.1 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 0.22.0 /@types/vscode@1.108.1: resolution: {integrity: sha512-DerV0BbSzt87TbrqmZ7lRDIYaMiqvP8tmJTzW2p49ZBVtGUnGAu2RGQd1Wv4XMzEVUpaHbsemVM5nfuQJj7H6w==} @@ -2888,52 +3348,51 @@ packages: /@use-gesture/core@10.3.1: resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} - /@use-gesture/react@10.3.1(react@18.3.1): + /@use-gesture/react@10.3.1(react@19.2.4): resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} peerDependencies: react: '>= 16.8.0' dependencies: '@use-gesture/core': 10.3.1 - react: 18.3.1 + react: 19.2.4 - /@vitejs/plugin-react@4.7.0(vite@5.4.21): - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + /@vitejs/plugin-react@5.1.3(vite@7.3.1): + resolution: {integrity: sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 dependencies: - '@babel/core': 7.28.6 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6) - '@rolldown/pluginutils': 1.0.0-beta.27 + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.2 '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.30) + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@20.19.30)(tsx@4.21.0) transitivePeerDependencies: - supports-color dev: true - /@vitest/coverage-v8@1.6.1(vitest@1.6.1): - resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} + /@vitest/coverage-v8@4.0.18(vitest@1.6.1): + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: - vitest: 1.6.1 + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.3 + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - picocolors: 1.1.1 + magicast: 0.5.2 + obug: 2.1.1 std-env: 3.10.0 - strip-literal: 2.1.1 - test-exclude: 6.0.0 + tinyrainbow: 3.0.3 vitest: 1.6.1(@types/node@20.19.30) - transitivePeerDependencies: - - supports-color dev: true /@vitest/expect@1.6.1: @@ -2944,6 +3403,12 @@ packages: chai: 4.5.0 dev: true + /@vitest/pretty-format@4.0.18: + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + dependencies: + tinyrainbow: 3.0.3 + dev: true + /@vitest/runner@1.6.1: resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} dependencies: @@ -2975,6 +3440,13 @@ packages: pretty-format: 29.7.0 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: resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} cpu: [arm64] @@ -3062,28 +3534,33 @@ packages: '@vscode/vsce-sign-win32-x64': 2.0.6 dev: true - /@vscode/vsce@2.32.0: - resolution: {integrity: sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==} - engines: {node: '>= 16'} + /@vscode/vsce@3.7.1: + resolution: {integrity: sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==} + engines: {node: '>= 20'} hasBin: true dependencies: '@azure/identity': 4.13.0 + '@secretlint/node': 10.2.2 + '@secretlint/secretlint-formatter-sarif': 10.2.2 + '@secretlint/secretlint-rule-no-dotenv': 10.2.2 + '@secretlint/secretlint-rule-preset-recommend': 10.2.2 '@vscode/vsce-sign': 2.0.9 azure-devops-node-api: 12.5.0 - chalk: 2.4.2 + chalk: 4.1.2 cheerio: 1.2.0 cockatiel: 3.2.1 - commander: 6.2.1 + commander: 12.1.0 form-data: 4.0.5 - glob: 7.2.3 + glob: 11.1.0 hosted-git-info: 4.1.0 jsonc-parser: 3.3.1 leven: 3.1.0 - markdown-it: 12.3.2 + markdown-it: 14.1.0 mime: 1.6.0 minimatch: 3.1.2 parse-semver: 1.1.1 read: 1.0.7 + secretlint: 10.2.2 semver: 7.7.3 tmp: 0.2.5 typed-rest-client: 1.8.11 @@ -3097,6 +3574,9 @@ packages: - supports-color dev: true + /@webgpu/types@0.1.69: + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + /@xenova/transformers@2.17.2: resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} dependencies: @@ -3111,14 +3591,6 @@ packages: - react-native-b4a dev: false - /accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - dev: false - /accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3153,14 +3625,6 @@ packages: engines: {node: '>= 14'} dev: true - /aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - dev: false - /ajv-formats@3.0.1(ajv@8.17.1): resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3188,7 +3652,13 @@ packages: fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - dev: false + + /ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + dependencies: + environment: 1.1.0 + dev: true /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -3197,20 +3667,13 @@ packages: /ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - dev: false - - /ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - dependencies: - color-convert: 1.9.3 - dev: true /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} dependencies: color-convert: 2.0.1 + dev: true /ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} @@ -3220,23 +3683,6 @@ packages: /ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - dev: false - - /any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - dev: true - - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true - - /arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - dev: true /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3250,10 +3696,6 @@ packages: is-array-buffer: 3.0.5 dev: true - /array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - dev: false - /array-includes@3.1.9: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} @@ -3322,6 +3764,19 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + dev: true + + /astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + dev: true + /async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -3331,15 +3786,15 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: true - /autoprefixer@10.4.23(postcss@8.5.6): - resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} + /autoprefixer@10.4.24(postcss@8.5.6): + resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001766 + caniuse-lite: 1.0.30001767 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -3453,12 +3908,13 @@ packages: hasBin: true dev: true - /before-after-hook@2.2.3: - resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + /before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} dev: false - /better-sqlite3@11.10.0: - resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + /better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} requiresBuild: true dependencies: bindings: 1.5.0 @@ -3470,9 +3926,11 @@ packages: dependencies: require-from-string: 2.0.2 - /binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} + /binaryextensions@6.11.0: + resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} + engines: {node: '>=4'} + dependencies: + editions: 6.22.0 dev: true /bindings@1.5.0: @@ -3489,26 +3947,6 @@ packages: inherits: 2.0.4 readable-stream: 3.6.2 - /body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.1 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - dev: false - /body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3530,6 +3968,10 @@ packages: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} dev: true + /boundary@2.0.0: + resolution: {integrity: sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==} + dev: true + /brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} dependencies: @@ -3555,8 +3997,8 @@ packages: hasBin: true dependencies: baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001766 - electron-to-chromium: 1.5.282 + caniuse-lite: 1.0.30001767 + electron-to-chromium: 1.5.286 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) dev: true @@ -3627,20 +4069,24 @@ packages: engines: {node: '>=6'} dev: true - /camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - dev: true - /camera-controls@2.10.1(three@0.160.1): resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==} peerDependencies: three: '>=0.126.1' dependencies: three: 0.160.1 + dev: false - /caniuse-lite@1.0.30001766: - resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + /camera-controls@3.1.2(three@0.182.0): + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + dependencies: + three: 0.182.0 + + /caniuse-lite@1.0.30001767: + resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} dev: true /chai@4.5.0: @@ -3656,15 +4102,6 @@ packages: type-detect: 4.1.0 dev: true - /chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - dev: true - /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3676,7 +4113,6 @@ packages: /chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - dev: false /chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -3712,34 +4148,14 @@ packages: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.19.2 + undici: 7.20.0 whatwg-mimetype: 4.0.0 dev: true - /chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - dev: true - /chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} requiresBuild: true - /clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - dev: false - /cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3754,9 +4170,9 @@ packages: string-width: 4.2.3 dev: false - /cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} + /cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} dev: false /cli-table3@0.6.5: @@ -3787,22 +4203,12 @@ packages: engines: {node: '>=16'} dev: true - /color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - dependencies: - color-name: 1.1.3 - dev: true - /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 - /color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true - /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -3828,25 +4234,15 @@ packages: delayed-stream: 1.0.0 dev: true - /commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - dev: false - /commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} - dev: false - - /commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} dev: true - /commander@6.2.1: - resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} - engines: {node: '>= 6'} - dev: true + /commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + dev: false /component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -3856,17 +4252,14 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true - /concurrently@8.2.2: - resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} - engines: {node: ^14.13.0 || >=16.0.0} + /concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} hasBin: true dependencies: chalk: 4.1.2 - date-fns: 2.30.0 - lodash: 4.17.23 rxjs: 7.8.2 shell-quote: 1.8.3 - spawn-command: 0.0.2 supports-color: 8.1.1 tree-kill: 1.2.2 yargs: 17.7.2 @@ -3876,13 +4269,6 @@ packages: 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'} - dependencies: - safe-buffer: 5.2.1 - dev: false - /content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -3897,10 +4283,6 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true - /cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - dev: false - /cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -3952,12 +4334,6 @@ packages: engines: {node: '>= 6'} dev: true - /cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - dev: true - /csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3988,24 +4364,6 @@ packages: is-data-view: 1.0.2 dev: true - /date-fns@2.30.0: - resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} - engines: {node: '>=0.11'} - dependencies: - '@babel/runtime': 7.28.6 - dev: true - - /debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.0.0 - dev: false - /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4055,8 +4413,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - /default-browser@5.4.0: - resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} + /default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} dependencies: bundle-name: 4.1.0 @@ -4094,15 +4452,6 @@ packages: engines: {node: '>= 0.8'} dev: false - /deprecation@2.3.1: - resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} - dev: false - - /destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dev: false - /detect-gpu@5.0.70: resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} dependencies: @@ -4120,19 +4469,11 @@ packages: wrappy: 1.0.2 dev: true - /didyoumean@1.2.2: - 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 - /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -4196,21 +4537,21 @@ packages: - tree_sitter dev: false - /driftdetect-galaxy@0.9.45(@types/react@18.3.27)(@types/three@0.160.0)(react-dom@18.3.1)(react@18.3.1): + /driftdetect-galaxy@0.9.45(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4): resolution: {integrity: sha512-lAi6Mbk5mHJa5i1WLx5lEt/V0dGCSJdHSHUg40ecRRVc5QbcPi2Z4yHvU9RV83gPqFIYodUGpS0S47fIP5wo7Q==} engines: {node: '>=18'} peerDependencies: react: ^18.2.0 react-dom: ^18.2.0 dependencies: - '@react-three/drei': 9.122.0(@react-three/fiber@8.18.0)(@types/react@18.3.27)(@types/three@0.160.0)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) - '@react-three/fiber': 8.18.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1)(three@0.160.1) - '@react-three/postprocessing': 2.19.1(@react-three/fiber@8.18.0)(@types/three@0.160.0)(react@18.3.1)(three@0.160.1) + '@react-three/drei': 9.122.0(@react-three/fiber@8.18.0)(@types/react@19.2.11)(@types/three@0.182.0)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1) + '@react-three/fiber': 8.18.0(@types/react@19.2.11)(react-dom@19.2.4)(react@19.2.4)(three@0.160.1) + '@react-three/postprocessing': 2.19.1(@react-three/fiber@8.18.0)(@types/three@0.182.0)(react@19.2.4)(three@0.160.1) jsfxr: 1.4.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) three: 0.160.1 - zustand: 4.5.7(@types/react@18.3.27)(react@18.3.1) + zustand: 4.5.7(@types/react@19.2.11)(react@19.2.4) transitivePeerDependencies: - '@types/react' - '@types/three' @@ -4232,6 +4573,15 @@ packages: dev: false optional: true + /driftdetect-native-darwin-arm64@0.9.47: + resolution: {integrity: sha512-4qJwmqt9wpn2CKFTH3SzCPRLkOtAzsZC4E33Hy24r/6el5SNvHWNZTgtH1O0TflIdaeDEF9Tcu7mIoqN9y0NXg==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /driftdetect-native-darwin-x64@0.9.45: resolution: {integrity: sha512-lzTpRf+IgQWQhItDM+/mPQPGWrRWV7ouKzhcY2imY1edRC2nCaHFKyCEoqLaEe/edLHmJBpOKIxl8sGjjtB5+A==} engines: {node: '>= 18'} @@ -4241,6 +4591,15 @@ packages: dev: false optional: true + /driftdetect-native-darwin-x64@0.9.47: + resolution: {integrity: sha512-+MzEEooeot6pI32gMVeGnsLqJUfOqBPXKZv3lV5zyskPyIn6JjS/y2Gj+vpUTXS4tomDJEwNSMqy2Ri/+OwLHw==} + engines: {node: '>= 18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /driftdetect-native-linux-arm64-gnu@0.9.45: resolution: {integrity: sha512-ANbPlrMaSkjALZIj8bHD5sfQs5azuE9TQCIiiG6IG8fkLafE7tfA3bM4tVhFsLyPtlntjvG6Mr/jWfMng4l2YA==} engines: {node: '>= 18'} @@ -4250,6 +4609,15 @@ packages: dev: false optional: true + /driftdetect-native-linux-arm64-gnu@0.9.47: + resolution: {integrity: sha512-RmGibT54H5IKie7Vi6PGn6vTSfrjDdAq0xVEFXJY8rDbnic9eGSde5aRqRi2fY3kBHWxMdRfllBbkM1quxNhNg==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /driftdetect-native-linux-x64-gnu@0.9.45: resolution: {integrity: sha512-afXXLGyut4GpCw/2/gROnkvb0N+1bXjjQu7vmTl6HGoDof3kKXAvz3O4vexIxd2emDUyHRRSLK0xVefBoWc06Q==} engines: {node: '>= 18'} @@ -4259,6 +4627,15 @@ packages: dev: false optional: true + /driftdetect-native-linux-x64-gnu@0.9.47: + resolution: {integrity: sha512-vt0+GW4KtkypXS2rRXQC11BCBQp+3dbdp2MOdskVLCmUn4zFLm0CZb4hAaz9gWGV4r0g/HXrqnDseEVB/YQDdw==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /driftdetect-native-win32-x64-msvc@0.9.45: resolution: {integrity: sha512-3Np3l01rZGnBu1ricdhSOrz+5OlYhUFhBtNkzwXTC8OE84U6PmgMNiLjnlUm09e8AP6UA83O5Ve/bDH5MQUcbA==} engines: {node: '>= 18'} @@ -4268,6 +4645,15 @@ packages: dev: false optional: true + /driftdetect-native-win32-x64-msvc@0.9.47: + resolution: {integrity: sha512-6wa5YiZllO6TdeckIT1lTw6qXJeagEYSHkXX/2HdQzL+LDJZC5JA1olLwC2oN0AfKcDLjB3xQdBtHO+Iogq6hA==} + engines: {node: '>= 18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /driftdetect-native@0.9.45: resolution: {integrity: sha512-wMPO/T7vOPJH/vNEgE1QzI7MDXQPUoNZURIJlYbH+YuZM4r4R1aOM3kfif79YcSPLzHLKMIArwUTo95VQnccdw==} engines: {node: '>= 18'} @@ -4281,6 +4667,19 @@ packages: dev: false optional: true + /driftdetect-native@0.9.47: + resolution: {integrity: sha512-tW8tlLvHTKf7iwohwf44bXSkNXt+EJ/+/td1tBhzIltfHmEb4n3IpKNgzydDU2hUxnzGRH18DvUNejS98ErozA==} + engines: {node: '>= 18'} + requiresBuild: true + optionalDependencies: + driftdetect-native-darwin-arm64: 0.9.47 + driftdetect-native-darwin-x64: 0.9.47 + driftdetect-native-linux-arm64-gnu: 0.9.47 + driftdetect-native-linux-x64-gnu: 0.9.47 + driftdetect-native-win32-x64-msvc: 0.9.47 + dev: false + optional: true + /dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4291,7 +4690,7 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: false + dev: true /ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4299,12 +4698,19 @@ packages: safe-buffer: 5.2.1 dev: true + /editions@6.22.0: + resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} + engines: {ecmascript: '>= es5', node: '>=4'} + dependencies: + version-range: 4.15.0 + dev: true + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false - /electron-to-chromium@1.5.282: - resolution: {integrity: sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==} + /electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} dev: true /emoji-regex@10.6.0: @@ -4316,7 +4722,7 @@ packages: /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: false + dev: true /encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -4336,10 +4742,6 @@ packages: dependencies: once: 1.4.0 - /entities@2.1.0: - resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} - dev: true - /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4355,6 +4757,11 @@ packages: engines: {node: '>=0.12'} dev: true + /environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + dev: true + /es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -4529,11 +4936,6 @@ packages: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} dev: false - /escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - dev: true - /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -4765,52 +5167,14 @@ packages: engines: {node: '>=6'} requiresBuild: true - /express-rate-limit@7.5.1(express@5.2.1): - resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + /express-rate-limit@8.2.1(express@5.2.1): + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' dependencies: express: 5.2.1 - dev: false - - /express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.14.1 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color + ip-address: 10.0.1 dev: false /express@5.2.1: @@ -4849,13 +5213,17 @@ packages: - supports-color dev: false - /fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + /fast-check@4.5.3: + resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==} + engines: {node: '>=12.17.0'} dependencies: - pure-rand: 6.1.0 + pure-rand: 7.0.1 dev: true + /fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4888,7 +5256,6 @@ packages: /fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - dev: false /fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4917,6 +5284,9 @@ packages: /fflate@0.6.10: resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + /file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -4935,21 +5305,6 @@ packages: to-regex-range: 5.0.1 dev: true - /finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - dev: false - /finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -5001,7 +5356,7 @@ packages: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 - dev: false + dev: true /form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} @@ -5032,11 +5387,6 @@ packages: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} dev: true - /fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - dev: false - /fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -5046,8 +5396,13 @@ packages: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} requiresBuild: true - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + /fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 dev: true /fsevents@2.3.3: @@ -5161,29 +5516,28 @@ packages: is-glob: 4.0.3 dev: true - /glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + /glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true dependencies: foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 + jackspeak: 4.1.1 + minimatch: 10.1.2 minipass: 7.1.2 package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - dev: false + path-scurry: 2.0.1 + dev: true - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + /glob@13.0.1: + resolution: {integrity: sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==} + engines: {node: 20 || >=22} dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: true + minimatch: 10.1.2 + minipass: 7.1.2 + path-scurry: 2.0.1 + dev: false /globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -5203,6 +5557,18 @@ packages: gopd: 1.2.0 dev: true + /globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + dev: true + /glsl-noise@0.0.0: resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} @@ -5210,6 +5576,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + /guid-typescript@1.0.9: resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} dev: false @@ -5219,11 +5589,6 @@ packages: engines: {node: '>= 0.4'} dev: true - /has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - dev: true - /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -5274,6 +5639,13 @@ packages: lru-cache: 6.0.0 dev: true + /hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + dependencies: + lru-cache: 10.4.3 + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true @@ -5323,19 +5695,12 @@ packages: engines: {node: '>=16.17.0'} dev: true - /husky@8.0.3: - resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} - engines: {node: '>=14'} + /husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} hasBin: true dev: true - /iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - dependencies: - safer-buffer: 2.1.2 - dev: false - /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5360,7 +5725,6 @@ packages: /ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - dev: true /immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -5378,17 +5742,9 @@ packages: engines: {node: '>=0.8.19'} dev: true - /indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - dev: false - - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - dependencies: - once: 1.4.0 - wrappy: 1.0.2 + /index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} dev: true /inherits@2.0.4: @@ -5407,6 +5763,11 @@ packages: side-channel: 1.1.0 dev: true + /ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + dev: false + /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5443,13 +5804,6 @@ packages: has-bigints: 1.1.0 dev: true - /is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - dependencies: - binary-extensions: 2.3.0 - dev: true - /is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -5526,6 +5880,11 @@ packages: is-extglob: 2.1.1 dev: true + /is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + dev: false + /is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -5619,11 +5978,6 @@ packages: which-typed-array: 1.1.20 dev: true - /is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - dev: false - /is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -5676,17 +6030,6 @@ packages: supports-color: 7.2.0 dev: true - /istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - dev: true - /istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -5695,33 +6038,51 @@ packages: istanbul-lib-report: 3.0.1 dev: true - /its-fine@1.2.5(@types/react@18.3.27)(react@18.3.1): + /istextorbinary@9.5.0: + resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} + engines: {node: '>=4'} + dependencies: + binaryextensions: 6.11.0 + editions: 6.22.0 + textextensions: 6.11.0 + dev: true + + /its-fine@1.2.5(@types/react@19.2.11)(react@19.2.4): resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==} peerDependencies: react: '>=18.0' dependencies: - '@types/react-reconciler': 0.28.9(@types/react@18.3.27) - react: 18.3.1 + '@types/react-reconciler': 0.28.9(@types/react@19.2.11) + react: 19.2.4 transitivePeerDependencies: - '@types/react' + dev: false - /jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + /its-fine@2.0.0(@types/react@19.2.11)(react@19.2.4): + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - dev: false + '@types/react-reconciler': 0.28.9(@types/react@19.2.11) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' - /jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true + /jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/cliui': 8.0.2 dev: true /jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} dev: false + /js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5757,7 +6118,6 @@ packages: /json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - dev: false /json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} @@ -5784,6 +6144,14 @@ packages: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} dev: true + /jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + /jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -5848,19 +6216,10 @@ packages: dependencies: immediate: 3.0.6 - /lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - dev: true - - /lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true - - /linkify-it@3.0.3: - resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} + /linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} dependencies: - uc.micro: 1.0.6 + uc.micro: 2.1.0 dev: true /local-pkg@0.5.1: @@ -5910,16 +6269,20 @@ packages: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} dev: true + /lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + dev: true + /lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} dev: true - /log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + /log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 dev: false /long@4.0.0: @@ -5931,6 +6294,7 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 + dev: false /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} @@ -5940,7 +6304,11 @@ packages: /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - dev: false + dev: true + + /lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + engines: {node: 20 || >=22} /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5955,23 +6323,43 @@ packages: yallist: 4.0.0 dev: true - /maath@0.10.8(@types/three@0.160.0)(three@0.160.1): + /maath@0.10.8(@types/three@0.182.0)(three@0.160.1): resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} peerDependencies: '@types/three': '>=0.134.0' three: '>=0.134.0' dependencies: - '@types/three': 0.160.0 + '@types/three': 0.182.0 three: 0.160.1 + dev: false - /maath@0.6.0(@types/three@0.160.0)(three@0.160.1): + /maath@0.10.8(@types/three@0.182.0)(three@0.182.0): + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + dependencies: + '@types/three': 0.182.0 + three: 0.182.0 + + /maath@0.6.0(@types/three@0.182.0)(three@0.160.1): resolution: {integrity: sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==} peerDependencies: '@types/three': '>=0.144.0' three: '>=0.144.0' dependencies: - '@types/three': 0.160.0 + '@types/three': 0.182.0 three: 0.160.1 + dev: false + + /maath@0.6.0(@types/three@0.182.0)(three@0.182.0): + resolution: {integrity: sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==} + peerDependencies: + '@types/three': '>=0.144.0' + three: '>=0.144.0' + dependencies: + '@types/three': 0.182.0 + three: 0.182.0 /magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -5979,11 +6367,11 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true - /magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + /magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 dev: true @@ -5994,39 +6382,31 @@ packages: semver: 7.7.3 dev: true - /markdown-it@12.3.2: - resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} + /markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true dependencies: argparse: 2.0.1 - entities: 2.1.0 - linkify-it: 3.0.3 - mdurl: 1.0.1 - uc.micro: 1.0.6 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 dev: true /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - /mdurl@1.0.1: - resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} + /mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} dev: true - /media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - dev: false - /media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} dev: false - /merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - dev: false - /merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -6047,13 +6427,22 @@ packages: three: '>=0.137' dependencies: three: 0.160.1 + dev: false + + /meshline@3.3.1(three@0.182.0): + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + dependencies: + three: 0.182.0 - /meshoptimizer@0.18.1: - resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + /meshoptimizer@0.22.0: + resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} /methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + dev: true /micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} @@ -6066,6 +6455,7 @@ packages: /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + dev: true /mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} @@ -6077,6 +6467,7 @@ packages: engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 + dev: true /mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} @@ -6089,6 +6480,7 @@ packages: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} hasBin: true + dev: true /mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} @@ -6111,6 +6503,12 @@ packages: engines: {node: '>=10'} requiresBuild: true + /minimatch@10.1.2: + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/brace-expansion': 5.0.1 + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -6136,7 +6534,6 @@ packages: /minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - dev: false /mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -6151,10 +6548,6 @@ packages: ufo: 1.6.3 dev: true - /ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - dev: false - /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6162,29 +6555,31 @@ packages: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: true - /mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + /mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} dev: false - /mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - dev: true - /n8ao@1.10.1(postprocessing@6.38.2)(three@0.160.1): resolution: {integrity: sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==} peerDependencies: postprocessing: '>=6.30.0' three: '>=0.137' dependencies: - postprocessing: 6.38.2(three@0.160.1) + postprocessing: 6.38.2(three@0.182.0) three: 0.160.1 + dev: false - /nanoid@3.3.11: + /n8ao@1.10.1(postprocessing@6.38.2)(three@0.182.0): + resolution: {integrity: sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==} + peerDependencies: + postprocessing: '>=6.30.0' + three: '>=0.137' + dependencies: + postprocessing: 6.38.2(three@0.182.0) + three: 0.182.0 + + /nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -6198,11 +6593,6 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - dev: false - /negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -6243,9 +6633,21 @@ packages: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} dev: true - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} + /node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.3.3 + dev: true + + /normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + dependencies: + hosted-git-info: 7.0.2 + semver: 7.7.3 + validate-npm-package-license: 3.0.4 dev: true /npm-run-path@5.3.0: @@ -6264,11 +6666,7 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - - /object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - dev: true + dev: false /object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} @@ -6320,6 +6718,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'} @@ -6380,10 +6782,23 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} dependencies: - default-browser: 5.4.0 + default-browser: 5.5.0 define-lazy-prop: 3.0.0 is-inside-container: 1.0.0 wsl-utils: 0.1.0 + dev: true + + /open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + dev: false /optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -6397,19 +6812,18 @@ packages: word-wrap: 1.2.5 dev: true - /ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} + /ora@9.3.0: + resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + engines: {node: '>=20'} dependencies: chalk: 5.6.2 cli-cursor: 5.0.0 - cli-spinners: 2.9.2 + cli-spinners: 3.4.0 is-interactive: 2.0.0 is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.1.2 + log-symbols: 7.0.1 + stdin-discarder: 0.3.1 + string-width: 8.1.1 dev: false /own-keys@1.0.1: @@ -6442,9 +6856,14 @@ packages: p-limit: 3.1.0 dev: true + /p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + dev: true + /package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - dev: false + dev: true /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -6453,6 +6872,15 @@ packages: callsites: 3.1.0 dev: true + /parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + dev: true + /parse-semver@1.1.1: resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} dependencies: @@ -6488,11 +6916,6 @@ packages: engines: {node: '>=8'} dev: true - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: true - /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -6506,22 +6929,22 @@ packages: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true - /path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + /path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} dependencies: - lru-cache: 10.4.3 + lru-cache: 11.2.5 minipass: 7.1.2 - dev: false - - /path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - dev: false /path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} dev: false + /path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + dev: true + /pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} dev: true @@ -6552,16 +6975,6 @@ packages: engines: {node: '>=12'} dev: true - /pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - dev: true - - /pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - dev: true - /piscina@5.1.4: resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==} engines: {node: '>=20.x'} @@ -6586,73 +6999,18 @@ packages: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} dev: false - /possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - dev: true - - /postcss-import@15.1.0(postcss@8.5.6): - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.11 - dev: true - - /postcss-js@4.1.0(postcss@8.5.6): - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.6 + /pluralize@2.0.0: + resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} dev: true - /postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - dependencies: - jiti: 1.21.7 - lilconfig: 3.1.3 - postcss: 8.5.6 - tsx: 4.21.0 - dev: true - - /postcss-nested@6.2.0(postcss@8.5.6): - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 + /pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} dev: true - /postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 + /possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} dev: true /postcss-value-parser@4.2.0: @@ -6668,16 +7026,21 @@ packages: source-map-js: 1.2.1 dev: true - /postprocessing@6.38.2(three@0.160.1): + /postprocessing@6.38.2(three@0.182.0): resolution: {integrity: sha512-7DwuT7Tkst41ZjSj287g7C9c5/D3Xx5rMgBosg0dadbUPoZD2HNzkadKPol1d2PJAoI9f+Jeh1/v9YfLzpFGVw==} peerDependencies: three: '>= 0.157.0 < 0.183.0' dependencies: - three: 0.160.1 + three: 0.182.0 /potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + /powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + dev: false + /prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -6734,6 +7097,7 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + dev: false /protobufjs@6.11.4: resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==} @@ -6770,13 +7134,18 @@ packages: end-of-stream: 1.4.5 once: 1.4.0 + /punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + dev: true + /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} dev: true - /pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + /pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} dev: true /qs@6.14.1: @@ -6794,16 +7163,6 @@ packages: engines: {node: '>= 0.6'} dev: false - /raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - dev: false - /raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -6814,6 +7173,17 @@ packages: unpipe: 1.0.0 dev: false + /rc-config-loader@4.1.3: + resolution: {integrity: sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==} + dependencies: + debug: 4.4.3 + js-yaml: 4.1.1 + json5: 2.2.3 + require-from-string: 2.0.2 + transitivePeerDependencies: + - supports-color + dev: true + /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -6824,46 +7194,48 @@ packages: minimist: 1.2.8 strip-json-comments: 2.0.1 - /react-composer@5.0.3(react@18.3.1): + /react-composer@5.0.3(react@19.2.4): resolution: {integrity: sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==} peerDependencies: react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 dependencies: prop-types: 15.8.1 - react: 18.3.1 + react: 19.2.4 + dev: false - /react-dom@18.3.1(react@18.3.1): - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + /react-dom@19.2.4(react@19.2.4): + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^18.3.1 + react: ^19.2.4 dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + react: 19.2.4 + scheduler: 0.27.0 /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: false /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): + /react-reconciler@0.27.0(react@19.2.4): resolution: {integrity: sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==} engines: {node: '>=0.10.0'} peerDependencies: react: ^18.0.0 dependencies: loose-envify: 1.4.0 - react: 18.3.1 + react: 19.2.4 scheduler: 0.21.0 + dev: false - /react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + /react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} dev: true - /react-use-measure@2.1.7(react-dom@18.3.1)(react@18.3.1): + /react-use-measure@2.1.7(react-dom@19.2.4)(react@19.2.4): resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} peerDependencies: react: '>=16.13' @@ -6872,19 +7244,22 @@ packages: react-dom: optional: true dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - /react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + /react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - dependencies: - loose-envify: 1.4.0 - /read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + /read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} dependencies: - pify: 2.3.0 + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 dev: true /read@1.0.7: @@ -6903,13 +7278,6 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 - /readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - dependencies: - picomatch: 2.3.1 - dev: true - /reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -7012,6 +7380,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'} @@ -7084,11 +7487,26 @@ packages: resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} dependencies: loose-envify: 1.4.0 + dev: false - /scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + /scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + /secretlint@10.2.2: + resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} + engines: {node: '>=20.0.0'} + hasBin: true dependencies: - loose-envify: 1.4.0 + '@secretlint/config-creator': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/node': 10.2.2 + '@secretlint/profiler': 10.2.2 + debug: 4.4.3 + globby: 14.1.0 + read-pkg: 9.0.1 + transitivePeerDependencies: + - supports-color + dev: true /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} @@ -7105,27 +7523,6 @@ packages: engines: {node: '>=10'} hasBin: true - /send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - dev: false - /send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -7145,18 +7542,6 @@ packages: - supports-color dev: false - /serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - dev: false - /serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -7310,13 +7695,45 @@ packages: is-arrayish: 0.3.4 dev: false + /slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + dev: true + + /slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} dev: true - /spawn-command@0.0.2: - resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + /spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.22 + dev: true + + /spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + dev: true + + /spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.22 + dev: true + + /spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} dev: true /sqlite-vec-darwin-arm64@0.1.6: @@ -7364,14 +7781,24 @@ packages: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true - /stats-gl@2.4.2(@types/three@0.160.0)(three@0.160.1): + /stats-gl@2.4.2(@types/three@0.182.0)(three@0.160.1): resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} peerDependencies: '@types/three': '*' three: '*' dependencies: - '@types/three': 0.160.0 + '@types/three': 0.182.0 three: 0.160.1 + dev: false + + /stats-gl@2.4.2(@types/three@0.182.0)(three@0.182.0): + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + dependencies: + '@types/three': 0.182.0 + three: 0.182.0 /stats.js@0.17.0: resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} @@ -7385,8 +7812,8 @@ packages: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} dev: true - /stdin-discarder@0.2.2: - resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + /stdin-discarder@0.3.1: + resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} engines: {node: '>=18'} dev: false @@ -7424,7 +7851,7 @@ packages: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 strip-ansi: 7.1.2 - dev: false + dev: true /string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} @@ -7435,6 +7862,14 @@ packages: strip-ansi: 7.1.2 dev: false + /string-width@8.1.1: + resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} + engines: {node: '>=20'} + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + dev: false + /string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -7484,7 +7919,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.2.2 - dev: false /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} @@ -7512,18 +7946,10 @@ packages: js-tokens: 9.0.1 dev: true - /sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true + /structured-source@4.0.0: + resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 + boundary: 2.0.0 dev: true /superagent@10.3.0: @@ -7554,13 +7980,6 @@ packages: - supports-color dev: true - /supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - dependencies: - has-flag: 3.0.0 - dev: true - /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -7575,48 +7994,39 @@ packages: has-flag: 4.0.0 dev: true + /supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + dev: true + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} dev: true - /suspend-react@0.1.3(react@18.3.1): + /suspend-react@0.1.3(react@19.2.4): resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} peerDependencies: react: '>=17.0' dependencies: - react: 18.3.1 + react: 19.2.4 - /tailwindcss@3.4.19(tsx@4.21.0): - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true + /table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) - postcss-nested: 6.2.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 - resolve: 1.22.11 - sucrase: 3.35.1 - transitivePeerDependencies: - - tsx - - yaml + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} dev: true /tar-fs@2.1.4: @@ -7664,13 +8074,12 @@ packages: - react-native-b4a dev: false - /test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + /terminal-link@4.0.0: + resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} + engines: {node: '>=18'} dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 + ansi-escapes: 7.3.0 + supports-hyperlinks: 3.2.0 dev: true /text-decoder@1.2.3: @@ -7681,17 +8090,15 @@ packages: - react-native-b4a dev: false - /thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - dependencies: - thenify: 3.3.1 + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true - /thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + /textextensions@6.11.0: + resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} + engines: {node: '>=4'} dependencies: - any-promise: 1.3.0 + editions: 6.22.0 dev: true /three-mesh-bvh@0.7.8(three@0.160.1): @@ -7701,6 +8108,14 @@ packages: three: '>= 0.151.0' dependencies: three: 0.160.1 + dev: false + + /three-mesh-bvh@0.8.3(three@0.182.0): + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==} + peerDependencies: + three: '>= 0.159.0' + dependencies: + three: 0.182.0 /three-stdlib@2.36.1(three@0.160.1): resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} @@ -7714,9 +8129,27 @@ packages: fflate: 0.6.10 potpack: 1.0.2 three: 0.160.1 + dev: false + + /three-stdlib@2.36.1(three@0.182.0): + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.182.0 /three@0.160.1: resolution: {integrity: sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==} + dev: false + + /three@0.182.0: + resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} /tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -7735,6 +8168,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + dev: true + /tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} @@ -7777,6 +8215,34 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-c-sharp@0.23.1(tree-sitter@0.25.0): + resolution: {integrity: sha512-9zZ4FlcTRWWfRf6f4PgGhG8saPls6qOOt75tDfX7un9vQZJmARjPrAC6yBNCX2T/VKcCjIDbgq0evFaB3iGhQw==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + + /tree-sitter-c@0.23.6(tree-sitter@0.25.0): + resolution: {integrity: sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.22.1 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-cpp@0.22.3(tree-sitter@0.21.1): resolution: {integrity: sha512-p7w5903L/koqTQFVDwyyX0vjioxoZu2G4zT2ZHVG8DvLQbWN6OjNAqfMsCi+WdVkfMgU+7j06hS8i3j6Q0sPNQ==} requiresBuild: true @@ -7792,6 +8258,21 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-cpp@0.23.4(tree-sitter@0.25.0): + resolution: {integrity: sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + tree-sitter-c: 0.23.6(tree-sitter@0.25.0) + dev: false + /tree-sitter-go@0.21.2(tree-sitter@0.21.1): resolution: {integrity: sha512-aMFwjsB948nWhURiIxExK8QX29JYKs96P/IfXVvluVMRJZpL04SREHsdOZHYqJr1whkb7zr3/gWHqqvlkczmvw==} requiresBuild: true @@ -7807,6 +8288,20 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-go@0.25.0(tree-sitter@0.25.0): + resolution: {integrity: sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-java@0.21.0(tree-sitter@0.21.1): resolution: {integrity: sha512-CKJiTo1uc3SUsgEcaZgufGx8my6dzihy8JR/JsJH40Tj3uSe2/eFLk+0q+fpbosGAyY4YiXJtEoFB2O4bS2yOw==} requiresBuild: true @@ -7822,6 +8317,20 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-java@0.23.5(tree-sitter@0.25.0): + resolution: {integrity: sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-javascript@0.21.4(tree-sitter@0.21.1): resolution: {integrity: sha512-Lrk8yahebwrwc1sWJE9xPcz1OnnqiEV7Dh5fbN6EN3wNAdu9r06HpTqLqDwUUbnG4EB46Sfk+FJFAOldfoKLOw==} requiresBuild: true @@ -7837,6 +8346,34 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-javascript@0.23.1(tree-sitter@0.25.0): + resolution: {integrity: sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + + /tree-sitter-javascript@0.25.0(tree-sitter@0.25.0): + resolution: {integrity: sha512-1fCbmzAskZkxcZzN41sFZ2br2iqTYP3tKls1b/HKGNPQUVOpsUxpmGxdN/wMqAk3jYZnYBR1dd/y/0avMeU7dw==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-php@0.22.8(tree-sitter@0.21.1): resolution: {integrity: sha512-X3VeTwgofcRaR1+GQBBkA0dGdS8MntAPjkgmsqmYtx2Jh/+bYzvc8/nM+D5S6HQW7npvAIf6DzP1DADP5xooSw==} requiresBuild: true @@ -7853,6 +8390,20 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-php@0.24.2(tree-sitter@0.25.0): + resolution: {integrity: sha512-zwgAePc/HozNaWOOfwRAA+3p8yhuehRw8Fb7vn5qd2XjiIc93uJPryDTMYTSjBRjVIUg/KY6pM3rRzs8dSwKfw==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.22.4 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-python@0.21.0(tree-sitter@0.21.1): resolution: {integrity: sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==} requiresBuild: true @@ -7868,6 +8419,20 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-python@0.25.0(tree-sitter@0.25.0): + resolution: {integrity: sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-rust@0.21.0(tree-sitter@0.21.1): resolution: {integrity: sha512-unVr73YLn3VC4Qa/GF0Nk+Wom6UtI526p5kz9Rn2iZSqwIFedyCZ3e0fKCEmUJLIPGrTb/cIEdu3ZUNGzfZx7A==} requiresBuild: true @@ -7883,6 +8448,20 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-rust@0.24.0(tree-sitter@0.25.0): + resolution: {integrity: sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.22.1 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + dev: false + /tree-sitter-typescript@0.21.2(tree-sitter@0.21.1): resolution: {integrity: sha512-/RyNK41ZpkA8PuPZimR6pGLvNR1p0ibRUJwwQn4qAjyyLEIQD/BNlwS3NSxWtGsAWZe9gZ44VK1mWx2+eQVldg==} requiresBuild: true @@ -7898,6 +8477,21 @@ packages: tree-sitter: 0.21.1 dev: false + /tree-sitter-typescript@0.23.2(tree-sitter@0.25.0): + resolution: {integrity: sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==} + requiresBuild: true + peerDependencies: + tree-sitter: ^0.21.0 + peerDependenciesMeta: + tree-sitter: + optional: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + tree-sitter-javascript: 0.23.1(tree-sitter@0.25.0) + dev: false + /tree-sitter@0.21.1: resolution: {integrity: sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==} requiresBuild: true @@ -7906,6 +8500,14 @@ packages: node-gyp-build: 4.8.4 dev: false + /tree-sitter@0.25.0: + resolution: {integrity: sha512-PGZZzFW63eElZJDe/b/R/LbsjDDYJa5UEjLZJB59RQsMX+fo0j54fqBPn1MGKav/QNa0JR0zBiVaikYDWCj5KQ==} + requiresBuild: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + dev: false + /troika-three-text@0.52.4(three@0.160.1): resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} peerDependencies: @@ -7916,6 +8518,18 @@ packages: troika-three-utils: 0.52.4(three@0.160.1) troika-worker-utils: 0.52.0 webgl-sdf-generator: 1.1.1 + dev: false + + /troika-three-text@0.52.4(three@0.182.0): + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} + peerDependencies: + three: '>=0.125.0' + dependencies: + bidi-js: 1.0.3 + three: 0.182.0 + troika-three-utils: 0.52.4(three@0.182.0) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 /troika-three-utils@0.52.4(three@0.160.1): resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} @@ -7923,6 +8537,14 @@ packages: three: '>=0.125.0' dependencies: three: 0.160.1 + dev: false + + /troika-three-utils@0.52.4(three@0.182.0): + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} + peerDependencies: + three: '>=0.125.0' + dependencies: + three: 0.182.0 /troika-worker-utils@0.52.0: resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} @@ -7936,10 +8558,6 @@ packages: typescript: 5.9.3 dev: true - /ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - dev: true - /tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: @@ -7970,10 +8588,10 @@ packages: dependencies: safe-buffer: 5.2.1 - /tunnel-rat@0.1.2(@types/react@18.3.27)(react@18.3.1): + /tunnel-rat@0.1.2(@types/react@19.2.11)(react@19.2.4): resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} dependencies: - zustand: 4.5.7(@types/react@18.3.27)(react@18.3.1) + zustand: 4.5.7(@types/react@19.2.11)(react@19.2.4) transitivePeerDependencies: - '@types/react' - immer @@ -7984,64 +8602,64 @@ packages: engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} dev: true - /turbo-darwin-64@1.13.4: - resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==} + /turbo-darwin-64@2.8.3: + resolution: {integrity: sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.13.4: - resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==} + /turbo-darwin-arm64@2.8.3: + resolution: {integrity: sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.13.4: - resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==} + /turbo-linux-64@2.8.3: + resolution: {integrity: sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.13.4: - resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==} + /turbo-linux-arm64@2.8.3: + resolution: {integrity: sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.13.4: - resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==} + /turbo-windows-64@2.8.3: + resolution: {integrity: sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.13.4: - resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==} + /turbo-windows-arm64@2.8.3: + resolution: {integrity: sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.13.4: - resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==} + /turbo@2.8.3: + resolution: {integrity: sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.13.4 - turbo-darwin-arm64: 1.13.4 - turbo-linux-64: 1.13.4 - turbo-linux-arm64: 1.13.4 - turbo-windows-64: 1.13.4 - turbo-windows-arm64: 1.13.4 + turbo-darwin-64: 2.8.3 + turbo-darwin-arm64: 2.8.3 + turbo-linux-64: 2.8.3 + turbo-linux-arm64: 2.8.3 + turbo-windows-64: 2.8.3 + turbo-windows-arm64: 2.8.3 dev: true /type-check@0.4.0: @@ -8056,13 +8674,10 @@ packages: engines: {node: '>=4'} dev: true - /type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - dev: false + /type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + dev: true /type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} @@ -8148,8 +8763,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - /uc.micro@1.0.6: - resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} + /uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} dev: true /ufo@1.6.3: @@ -8173,15 +8788,30 @@ packages: /undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - /undici@7.19.2: - resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==} + /undici@7.20.0: + resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} engines: {node: '>=20.18.1'} dev: true - /universal-user-agent@6.0.1: - resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + /unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + dev: true + + /unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + dev: true + + /universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} dev: false + /universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + dev: true + /unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -8208,12 +8838,12 @@ packages: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} dev: true - /use-sync-external-store@1.6.0(react@18.3.1): + /use-sync-external-store@1.6.0(react@19.2.4): resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 dependencies: - react: 18.3.1 + react: 19.2.4 /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -8222,21 +8852,28 @@ packages: resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} engines: {node: '>= 4'} - /utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - dev: false - /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true dev: true + /validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + dev: true + /vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} dev: false + /version-range@4.15.0: + resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} + engines: {node: '>=4'} + dev: true + /vite-node@1.6.1(@types/node@20.19.30): resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8298,6 +8935,58 @@ packages: fsevents: 2.3.3 dev: true + /vite@7.3.1(@types/node@20.19.30)(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': 20.19.30 + 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@1.6.1(@types/node@20.19.30): resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8488,15 +9177,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - dev: false - /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8504,6 +9184,7 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + dev: true /wrap-ansi@8.1.0: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} @@ -8512,6 +9193,15 @@ packages: ansi-styles: 6.2.3 string-width: 5.1.2 strip-ansi: 7.1.2 + dev: true + + /wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 dev: false /wrappy@1.0.2: @@ -8535,6 +9225,15 @@ packages: engines: {node: '>=18'} dependencies: is-wsl: 3.1.0 + dev: true + + /wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + dependencies: + is-wsl: 3.1.0 + powershell-utils: 0.1.0 + dev: false /xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} @@ -8603,8 +9302,8 @@ packages: engines: {node: '>=12.20'} dev: true - /yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + /yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} dev: false @@ -8620,7 +9319,7 @@ packages: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} dev: false - /zustand@3.7.2(react@18.3.1): + /zustand@3.7.2(react@19.2.4): resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==} engines: {node: '>=12.7.0'} peerDependencies: @@ -8629,9 +9328,10 @@ packages: react: optional: true dependencies: - react: 18.3.1 + react: 19.2.4 + dev: false - /zustand@4.5.7(@types/react@18.3.27)(react@18.3.1): + /zustand@4.5.7(@types/react@19.2.11)(react@19.2.4): resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} peerDependencies: @@ -8646,12 +9346,12 @@ packages: react: optional: true dependencies: - '@types/react': 18.3.27 - react: 18.3.1 - use-sync-external-store: 1.6.0(react@18.3.1) + '@types/react': 19.2.11 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) - /zustand@5.0.10(@types/react@18.3.27)(react@18.3.1): - resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==} + /zustand@5.0.11(@types/react@19.2.11)(react@19.2.4)(use-sync-external-store@1.6.0): + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -8668,5 +9368,6 @@ packages: use-sync-external-store: optional: true dependencies: - '@types/react': 18.3.27 - react: 18.3.1 + '@types/react': 19.2.11 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4)