diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 186917ffd..2ba0e556c 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -17,11 +17,7 @@ import { promisify } from 'node:util' type StrucppCompileError = import('strucpp').CompileError import { buildArduinoCliCompileArgs } from '@root/backend/shared/firmware/build-arduino-cli-args' -import { - composeVerificationProject, - libraryBuildFromTranspiledSt, - prepareXmlForLibraryBuild, -} from '@root/backend/shared/library/build-pipeline' +import { runLibraryBuildPipeline } from '@root/backend/shared/library/library-build-orchestrator' import { buildKnownPous, emitCompileErrorEvents } from '@root/backend/shared/library/program-build-helpers' import { runProgramBuildPipeline } from '@root/backend/shared/library/program-build-pipeline' import { loadStrucpp } from '@root/backend/shared/library/strucpp-runtime' @@ -84,7 +80,7 @@ import { mergeStrucppRuntimeIntoSkeleton } from '@root/backend/shared/compile/st import { resolveBoardSelection } from '@root/backend/shared/compile/steps/resolve-board-selection' import { readHalsFile } from '@root/backend/shared/firmware/hals-loader' import type { DeviceConfiguration, DevicePin } from '@root/backend/shared/types/PLC/devices' -import type { PLCProject, PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' +import type { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { type CppPouData as CppPouDataCode, generateCBlocksCode, @@ -97,7 +93,6 @@ import { validatePathId } from '@root/backend/shared/utils/path-safety' import { XmlGenerator } from '@root/backend/shared/utils/PLC/xml-generator' import { generateVendorPluginConfig } from '@root/backend/shared/utils/vpp/generate-vendor-plugin-config' import { getErrorMessage } from '@root/frontend/utils/get-error-message' -import type { CompileLibraryResult } from '@root/middleware/shared/ports/types' import { app as electronApp, dialog, MessageChannelMain } from 'electron' import type { MessagePortMain } from 'electron/main' import JSZip from 'jszip' @@ -105,6 +100,7 @@ import JSZip from 'jszip' import type { PackageManifest } from '../package-manager' import { PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' +import { createDesktopLibraryBuildPort } from './desktop-library-build-port' import { createEditorCompilerPlatformPort } from './editor-compiler-platform-port' import type { ArduinoCoreControl, HalsFile } from './types' @@ -2379,327 +2375,45 @@ class CompilerModule { ): Promise { _mainProcessPort.start() - const post = (message: string, logLevel: 'info' | 'warning' | 'error' = 'info') => - _mainProcessPort.postMessage({ logLevel, message }) - - // Sends the structured result and closes the port. No - // `closePort: true` flag is needed on the payload: the - // renderer-side bridge already synthesises one callback for the - // MessagePort `'close'` event the `setTimeout` triggers, and - // posting an explicit flag in the same message just made the - // adapter fire its closePort branch twice (once via onmessage, - // once via the close listener). Keep the 25 ms delay so the - // result payload is delivered before the port closes. - const finish = (result: CompileLibraryResult) => { - _mainProcessPort.postMessage({ libraryBuildResult: result }) - setTimeout(() => _mainProcessPort.close(), 25) - } - - // Single-shot error path used by every "fail-fast" stage below. - // Every stage that aborts the build with one error message posts - // it to the console then forwards the same string as `error` on - // the structured result; this helper collapses both calls so the - // 8 fail-fast sites read as one line each. Extra fields (e.g. - // `libraryName` once the manifest is known) can be threaded via - // the second arg. - const bail = (msg: string, extra: Partial = {}) => { - post(msg, 'error') - finish({ success: false, error: msg, ...extra }) - } - - // The renderer adapter sends two preprocessed datasets: - // - `projectData` (formerly the only one) is preprocessed with - // `isSimulator: false` — Python POUs carry the full - // Python-as-ST conversion, C++ POUs carry the ST stub + - // `originalCppPous` sidecar. Used for the library build - // itself (Stages 1–6). - // - `verifyProjectData` is preprocessed with `isSimulator: - // true` — Python POUs are no-op stubs the AVR simulator - // can compile cleanly; C++ POUs are unchanged. Used as - // input to `composeVerificationProject` so the verify - // compile (Stage 3) doesn't try to link Python loader - // externs the simulator runtime doesn't ship. - // - // Both datasets share the same source POU list, just with - // different Python treatment. C++ POUs and ST/IL/data-types - // are identical between them. + // The IPC args contract is preserved verbatim from the pre- + // refactor signature so the renderer-side adapter is unchanged: + // [projectPath, projectData (build-pass), verifyProjectData, + // cleanBuild?] const [projectPath, projectData, verifyProjectData, cleanBuild = false] = args as [ string, PLCProjectData, PLCProjectData, boolean | undefined, ] - const normalizedProjectPath = projectPath.replace('project.json', '') - - post('Starting library build...') - // Stage 0: read manifest from disk. - const manifestPath = join(normalizedProjectPath, 'library.json') - let manifestJson: string - try { - manifestJson = await readFile(manifestPath, { encoding: 'utf8' }) - } catch (error) { - bail(`Could not read library.json: ${getErrorMessage(error)}`) - return - } - - // Stage 1: manifest validation + XML generation. - const project: PLCProject = { - meta: { name: '', type: 'plc-library' as const }, - data: projectData as unknown as PLCProjectData, - } - const stage1 = prepareXmlForLibraryBuild(project, manifestJson) - if ('error' in stage1) { - bail(stage1.error) - return - } - const { xml, knownPous, manifest } = stage1 - post(`Manifest OK — building "${manifest.name}" v${manifest.version}.`) - - // Persist plc.xml in an isolated `library` build sub-directory so - // it doesn't collide with the program-build artefacts when both - // modes coexist on the same project tree. - const libraryBuildDir = join(normalizedProjectPath, 'build', 'library') - const libraryBuildSrcDir = join(libraryBuildDir, 'src') - try { - await fs.rm(libraryBuildDir, { recursive: true, force: true }) - await mkdir(libraryBuildSrcDir, { recursive: true }) - } catch (error) { - bail(`Could not prepare build directory: ${getErrorMessage(error)}`) - return - } - - const xmlPath = join(libraryBuildSrcDir, 'plc.xml') - try { - await writeFile(xmlPath, xml, 'utf-8') - } catch (error) { - bail(`Could not write plc.xml: ${getErrorMessage(error)}`) - return - } - - // Stage 2: xml2st spawn (shared with the program-build path). - try { - await this.handleTranspileXMLtoST( - xmlPath, - (data, logLevel) => { - // xml2st's stdout doubles as progress + error stream; surface - // it verbatim so the user sees the same diagnostics the - // program-build path produces. - const message = typeof data === 'string' ? data : data.toString() - post(message, logLevel ?? 'info') - }, - ['--keep-structs'], - ) - } catch (error) { - bail(`xml2st failed: ${getErrorMessage(error)}`) - return - } - - // Stage 3: read program.st + run library compile. - const programStPath = join(libraryBuildSrcDir, 'program.st') - let programSt: string - try { - programSt = await readFile(programStPath, { encoding: 'utf8' }) - } catch (error) { - bail(`Could not read program.st from xml2st output: ${getErrorMessage(error)}`) - return - } - - // Resolve project-enabled libraries up front — these archives feed - // both verification (so the simulator compile sees the same symbols - // a real user would) and `compileStlib` below. Missing names fail - // the build with the same "open the Library Manager" message - // `compileProgram` uses, before either heavy step runs. - const enabledLibraryRefs = (projectData.libraries ?? []).map((ref) => ({ - name: ref.name, - version: ref.version, - })) - const { archives: depArchives, missing: missingDeps } = mainProcessBridge.loadEnabledArchives( - enabledLibraryRefs.map((r) => r.name), - ) - if (missingDeps.length > 0) { - bail( - `Library build aborted: enabled libraries are not installed (${missingDeps.join(', ')}). ` + - `Open the Library Manager to install or remove them.`, - { libraryName: manifest.name }, - ) - return - } - - // Stage 3: end-to-end C++ verification against the OpenPLC - // Simulator target — same strucpp → arduino-cli → bundled avr-gcc - // pipeline the program build uses, so the editor never depends on - // a host compiler. Runs BEFORE the `.stlib` write so the artefact - // generation is unconditionally the last step: whatever the - // verification outcome, the user always sees a fresh `.stlib` on - // disk when "Library built successfully" lands. - // - // Verification is advisory: a failure surfaces as a warning, not - // a build error. A legitimate user target may have more memory - // than the AVR simulator, and the tight AVR memory budget the - // simulator imposes is exactly the constraint many real - // industrial targets don't share. - // - // The MD5 cache short-circuits the slow compile when the - // already-verified program.st hasn't changed. `cleanBuild` - // skips the cache and forces a re-verification. - const programStMd5 = crypto.createHash('md5').update(programSt).digest('hex') - // Keep the cache OUTSIDE `libraryBuildDir` — that directory is - // wiped at the start of every build (line ~3119 above), so a - // cache file living inside it would never survive between - // runs. Sitting one level up in `build/` keeps it adjacent to - // the build outputs without being clobbered. - const verifyCachePath = join(normalizedProjectPath, 'build', '.verify-cache-library.json') - let cachedVerification: CompileLibraryResult['verification'] - if (!cleanBuild) { - try { - const raw = await readFile(verifyCachePath, { encoding: 'utf8' }) - const parsed = JSON.parse(raw) as { md5?: string; success?: boolean; message?: string } - if (parsed && parsed.md5 === programStMd5 && typeof parsed.success === 'boolean') { - cachedVerification = { success: parsed.success, message: parsed.message } - } - } catch { - // Missing or malformed cache — fall through to fresh - // verification. Never fail the build over the cache. - } - } - - let verification: CompileLibraryResult['verification'] - if (cachedVerification) { - verification = cachedVerification - post( - `Skipping verification (cached: ${cachedVerification.success ? 'pass' : 'fail'}). ` + - 'Use "Clean build" to force re-verification.', - ) - } else { - // Feed `composeVerificationProject` the verify-preprocessed - // dataset (Python POUs as no-op stubs) — the AVR simulator's - // compile path can't link the Python loader externs the - // full Python-as-ST shape produces. The build dataset - // (Python as full ST) is intentionally NOT used here. - const verifyProject = composeVerificationProject({ - meta: { name: manifest.name, type: 'plc-library' }, - data: verifyProjectData as unknown as PLCProjectData, - }) - post('Verifying with OpenPLC Simulator (avr-gcc)...') - try { - // Stream the inner pipeline's output through the renderer - // port with a `[verify]` prefix so the user sees the same - // strucpp + arduino-cli progress they'd see on a normal - // simulator build. Critical for two reasons: - // - avr-gcc compile can take 10+ seconds on a library - // with a lot of C++; a silent console looks frozen. - // - When verification fails, the user needs the actual - // compile diagnostic, not just the summary line. - // The success line at the bottom of compileLibrary still - // comes after this stream — `.stlib` generation is the - // last step regardless of verification outcome. - verification = await this.runVerificationCompile( - normalizedProjectPath, - verifyProject.data, - mainProcessBridge, - (message, logLevel) => - // Demote inner errors to warnings on the way out. - // The library's own `.stlib` will still be produced, - // so an `[verify]` line being level=error in the - // console would falsely suggest the build failed. - _mainProcessPort.postMessage({ - logLevel: logLevel === 'error' ? 'warning' : (logLevel ?? 'info'), - message: `[verify] ${message}`, - }), - ) - try { - await writeFile(verifyCachePath, JSON.stringify({ md5: programStMd5, ...verification }, null, 2), 'utf-8') - } catch (cacheErr) { - post(`Could not write verification cache: ${getErrorMessage(cacheErr)}`, 'warning') - } - } catch (err) { - verification = { success: false, message: getErrorMessage(err) } - } - if (verification.success) { - post('Verification passed.') - } else { - post( - `Verification reported issues (warning only — .stlib will still be generated): ${verification.message ?? 'see log'}`, - 'warning', - ) - } - } - - // Stage 4: gather per-symbol documentation from the editor view - // so `decorateArchive` can stamp it onto the manifest entries. - // POUs contribute their "Description" field; data types - // contribute their own optional `documentation` field. - const pouDocs: Record = {} - for (const pou of projectData.pous) { - if (pou.data.documentation && pou.data.documentation.length > 0) { - pouDocs[pou.data.name] = pou.data.documentation - } - } - for (const dt of projectData.dataTypes ?? []) { - const doc = (dt as { documentation?: string }).documentation - if (typeof doc === 'string' && doc.length > 0) { - pouDocs[(dt as { name: string }).name] = doc - } - } - - // Stage 5: strucpp `compileStlib` — splits program.st per-POU, - // drops the synthetic main, builds the archive. Hard failures - // here (xml2st-malformed output, strucpp internal errors) stop - // the build because we have no archive to ship. These are NOT - // advisory like verification — strucpp owns the artefact format. - // Pull the C/C++ FBs out of the preprocessed data — they live - // on `originalCppPous` (placed there by `preprocessPous`'s C++ - // branch). These ride through the archive verbatim; strucpp - // never sees them. The consumer-side compile reads them back - // and routes them through the existing user-C++-block path - // with a `__` rename for collision - // avoidance. - const cppBlocks = ( - (projectData as { originalCppPous?: Array<{ name: string; code: string; variables: unknown[] }> }) - .originalCppPous ?? [] - ).map((b) => ({ - name: b.name, - code: b.code, - variables: b.variables, - })) - - const stage2 = libraryBuildFromTranspiledSt(programSt, knownPous, manifest, { - pouDocs, - dependencyArchives: depArchives, - dependencyRefs: enabledLibraryRefs, - cppBlocks, + // Bridge the orchestrator's structured port API onto the desktop + // platform's existing helpers. This is the only desktop-specific + // glue the library build needs — every stage decision lives in + // the shared orchestrator from here on. + const libraryPort = createDesktopLibraryBuildPort({ + transpileXmlToSt: (xmlPath, log, extraArgs) => this.handleTranspileXMLtoST(xmlPath, log, extraArgs), + loadEnabledArchives: (names) => mainProcessBridge.loadEnabledArchives(names), + runVerificationCompile: ({ projectPath: p, verifyProjectData: v, emit }) => + this.runVerificationCompile(p, v as PLCProjectData, mainProcessBridge, (message, logLevel) => + emit(message, logLevel), + ), }) - if (!stage2.success) { - for (const err of stage2.errors) { - const where = err.file ? `[${err.file}${err.line ? `:${err.line}` : ''}] ` : '' - post(`${where}${err.message}`, 'error') - } - finish({ - success: false, - error: stage2.errors[0]?.message ?? 'Library compilation failed.', - libraryName: manifest.name, - }) - return - } - // Stage 6 (final): serialise the archive to disk. Same JSON - // shape `library-manager-module` persists user-installed archives - // with, so a future "build then install" round-trip uses the - // identical on-disk format. This is unconditionally the last - // step so the user's "Library built successfully" line refers - // to a fresh artefact, never a stale one. - const stlibPath = join(normalizedProjectPath, 'build', `${manifest.name}.stlib`) - try { - await mkdir(join(normalizedProjectPath, 'build'), { recursive: true }) - await writeFile(stlibPath, JSON.stringify(stage2.archive, null, 2) + '\n', 'utf-8') - } catch (error) { - bail(`Could not write ${manifest.name}.stlib: ${getErrorMessage(error)}`) - return - } + const result = await runLibraryBuildPipeline( + { + projectPath, + projectData, + verifyProjectData, + cleanBuild, + }, + libraryPort, + (event) => _mainProcessPort.postMessage({ logLevel: event.level, message: event.message }), + ) - post(`Library built successfully: ${stlibPath}`) - finish({ success: true, stlibPath, libraryName: manifest.name, verification }) + _mainProcessPort.postMessage({ libraryBuildResult: result }) + // Same 25ms delay the pre-refactor code used so the result + // message is delivered before the port closes. + setTimeout(() => _mainProcessPort.close(), 25) } /** diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts new file mode 100644 index 000000000..f368967d8 --- /dev/null +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Desktop implementation of {@link LibraryBuildPort}. + * + * Owns ONLY the platform-specific primitives the shared + * `runLibraryBuildPipeline` cannot perform itself: + * + * - MD5 hashing (Node `crypto`) + * - xml2st subprocess invocation (the desktop binary) + * - read / write / delete project files on the local disk + * - resolve library-name → `.stlib` archive via the main-process bridge + * - drive a verification compile through the editor's existing + * `compileProgram` flow (which already routes through the + * shared `runCompilePipeline`) + * + * No business logic lives here. The orchestrator owns the build + * sequence, cache decisions, error formatting, and the stable + * `build/library/*` file names — every byte of that surface is + * shared with the web port impl that lands in a follow-up PR. + */ + +import { createHash, randomUUID } from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +import { assertPathContained } from '@root/backend/editor/utils/path-containment' +import type { TranspileXmlToStArgs, TranspileXmlToStResult } from '@root/middleware/shared/ports/compiler-platform-port' +import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-build-port' + +/** + * Subset of the desktop CompilerModule that the port leans on. + * Injected (not imported as a module reference) so this file's + * surface stays narrow + unit-testable. + */ +export interface DesktopLibraryBuildPortDeps { + /** + * Spawn the bundled `xml2st` binary on the given input path. The + * binary writes `program.st` next to its input; the port reads it + * back from disk after the spawn resolves. Matches the existing + * `CompilerModule.handleTranspileXMLtoST` signature so the + * adapter can pass it through verbatim without a wrapper. + */ + transpileXmlToSt( + xmlPath: string, + log: (chunk: Buffer | string, level?: 'info' | 'error') => void, + extraArgs: readonly string[], + ): Promise + + /** + * Resolve the names of project-enabled libraries to their parsed + * `.stlib` archives. Bundled IEC standard set is included + * automatically. Names that can't be resolved come back under + * `missing` — orchestrator fails the build with a Library-Manager- + * pointing message before any heavy step runs. + */ + loadEnabledArchives(enabledNames: string[]): { archives: unknown[]; missing: string[] } + + /** + * Run a verification compile against the OpenPLC Simulator board. + * Wraps `CompilerModule.runVerificationCompile` so the port stays + * decoupled from the compiler module's full surface. Failures + * here are advisory — caller surfaces them as warnings, never as + * a fatal build error. + */ + runVerificationCompile(args: { + projectPath: string + verifyProjectData: unknown + emit: (message: string, level?: 'info' | 'warning' | 'error') => void + }): Promise<{ success: boolean; message?: string }> +} + +export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps): LibraryBuildPort { + return { + computeMd5(input: string): Promise { + // Web's port impl computes the same digest via `spark-md5`. + // The orchestrator's verification cache keys off this value, + // so both platforms MUST agree byte-for-byte. + return Promise.resolve(createHash('md5').update(input).digest('hex')) + }, + + async transpileXmlToSt( + args: TranspileXmlToStArgs, + log: (message: string, level: 'info' | 'warning' | 'error') => void, + ): Promise { + // xml2st takes a file path on stdin, so materialise the + // in-memory XML to a unique temp file before spawning. + // Lives in `os.tmpdir()` because the user-visible `plc.xml` + // is written separately by the orchestrator via + // writeBuildFile — the intermediate here exists only for the + // subprocess. + const sessionDir = path.join(os.tmpdir(), `openplc-lib-xml2st-${randomUUID()}`) + try { + await fs.mkdir(sessionDir, { recursive: true }) + const xmlPath = path.join(sessionDir, 'plc.xml') + const programStPath = path.join(sessionDir, 'program.st') + await fs.writeFile(xmlPath, args.xml, 'utf-8') + + await deps.transpileXmlToSt( + xmlPath, + (chunk, level) => log(typeof chunk === 'string' ? chunk : chunk.toString(), level ?? 'info'), + args.xml2stArgs, + ) + + const programSt = await fs.readFile(programStPath, 'utf-8') + return { ok: true, programSt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`xml2st failed: ${message}`, 'error') + return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } finally { + // Best-effort cleanup. Leaks aren't fatal (os.tmpdir is + // the OS's responsibility) but tidying after ourselves + // keeps the dev disk clean. + await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => { + /* swallow — the temp dir is the OS's to GC */ + }) + } + }, + + async readBuildFile(projectPath: string, relPath: string): Promise { + const fullPath = resolveProjectRelativePath(projectPath, relPath) + try { + return await fs.readFile(fullPath, { encoding: 'utf8' }) + } catch (error) { + if (isFsNotFound(error)) return null + throw error + } + }, + + async writeBuildFile(projectPath: string, relPath: string, content: string): Promise { + const fullPath = resolveProjectRelativePath(projectPath, relPath) + await fs.mkdir(path.dirname(fullPath), { recursive: true }) + await fs.writeFile(fullPath, content, 'utf-8') + }, + + async deleteBuildSubtree(projectPath: string, relPath: string): Promise { + const fullPath = resolveProjectRelativePath(projectPath, relPath) + // `force: true` makes the call a no-op when the subtree is + // absent — matches the contract. `recursive: true` wipes the + // whole subtree, mirroring the pre-refactor `fs.rm` call site. + await fs.rm(fullPath, { recursive: true, force: true }) + }, + + loadLibraryArchives({ projectLibraryRefs }) { + // Bridge resolves bundled (always-included) + user-installed + // archives in one call; names that don't resolve come back + // under `missing` for the orchestrator to fail the build on. + return Promise.resolve(deps.loadEnabledArchives(projectLibraryRefs.map((r) => r.name))) + }, + + async verifyCompile({ projectPath, verifyProjectData, emit }) { + return deps.runVerificationCompile({ + projectPath, + verifyProjectData, + // `runVerificationCompile` forwards every line off + // `compileProgram`'s message port; pass them straight + // through to the orchestrator's emit. + emit: (message, level) => emit(message, level ?? 'info'), + }) + }, + } +} + +/** + * Normalises a project-relative path to an absolute fs path while + * preserving the path-containment guarantee the orchestrator relies + * on (no `..`-escapes out of the project root). The pre-refactor + * code did the join manually; routing through `assertPathContained` + * here makes the symmetric web port impl's S3-key sanitisation + * easier to mirror. + */ +function resolveProjectRelativePath(projectPath: string, relPath: string): string { + const normalizedProjectPath = projectPath.replace(/\/project\.json$/, '') + const joined = path.join(normalizedProjectPath, relPath) + assertPathContained(normalizedProjectPath, joined, 'relPath') + return joined +} + +function isFsNotFound(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && (error as { code?: string }).code === 'ENOENT' +} diff --git a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts new file mode 100644 index 000000000..0e2073c8c --- /dev/null +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for the shared library-build orchestrator. + * + * The orchestrator's job is to drive the 7-stage flow through a + * `LibraryBuildPort`. These tests mock the port + the inner shared + * helpers (`prepareXmlForLibraryBuild`, `libraryBuildFromTranspiledSt`) + * and verify the orchestrator's contract: stage ordering, error + * propagation, cache hit/miss, archive feed-through, verification + * gating. + * + * Production callers (desktop adapter and the future web adapter) get + * exercised through their own integration paths — this file is + * unit-level for the orchestration logic itself. + */ + +import type { LibraryBuildPort, VerifyCompileArgs } from '../../../../middleware/shared/ports/library-build-port' +import type { PLCProjectData } from '../../types/PLC/open-plc' + +// --------------------------------------------------------------------------- +// Mocks for the inner shared helpers +// --------------------------------------------------------------------------- + +const mockPrepareXml = jest.fn() +const mockLibraryBuild = jest.fn() +const mockComposeVerify = jest.fn((project: { meta: unknown; data: unknown }) => ({ + meta: { ...(project.meta as Record), type: 'plc-project' }, + data: project.data, +})) + +jest.mock('../build-pipeline', () => ({ + prepareXmlForLibraryBuild: (...args: unknown[]) => mockPrepareXml(...args), + libraryBuildFromTranspiledSt: (...args: unknown[]) => mockLibraryBuild(...args), + composeVerificationProject: (...args: unknown[]) => + mockComposeVerify(...(args as [{ meta: unknown; data: unknown }])), +})) + +import { runLibraryBuildPipeline } from '../library-build-orchestrator' + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +interface PortHarness { + port: LibraryBuildPort + files: Map + manifestContent: string | null + archives: unknown[] + missing: string[] + verifyResult: { success: boolean; message?: string } + verifyCalls: VerifyCompileArgs[] + /** Programmable error for whichever method the test wants to fail. */ + throwOn: Partial> +} + +function makePort(): PortHarness { + const harness: PortHarness = { + port: undefined as unknown as LibraryBuildPort, + files: new Map(), + manifestContent: '{"name":"lib","version":"0.1.0","namespace":"lib"}', + archives: [], + missing: [], + verifyResult: { success: true }, + verifyCalls: [], + throwOn: {}, + } + harness.port = { + async computeMd5(input: string) { + // Deterministic stand-in — same input → same hash, different + // inputs → different hashes. Length-prefix makes near-duplicates + // distinguishable. + return `md5-${input.length}-${input.charCodeAt(0) ?? 0}` + }, + async transpileXmlToSt({ xml }) { + if (harness.throwOn.transpileXmlToSt) throw harness.throwOn.transpileXmlToSt + return { ok: true, programSt: `PROGRAM main\n(* from xml: ${xml.length} bytes *)\nEND_PROGRAM\n` } + }, + async readBuildFile(_projectPath: string, relPath: string) { + if (harness.throwOn.readBuildFile) throw harness.throwOn.readBuildFile + if (relPath === 'library.json') return harness.manifestContent + return harness.files.get(relPath) ?? null + }, + async writeBuildFile(_projectPath: string, relPath: string, content: string) { + if (harness.throwOn.writeBuildFile) throw harness.throwOn.writeBuildFile + harness.files.set(relPath, content) + }, + async deleteBuildSubtree(_projectPath: string, relPath: string) { + if (harness.throwOn.deleteBuildSubtree) throw harness.throwOn.deleteBuildSubtree + for (const key of [...harness.files.keys()]) { + if (key.startsWith(`${relPath}/`)) harness.files.delete(key) + } + }, + async loadLibraryArchives() { + if (harness.throwOn.loadLibraryArchives) throw harness.throwOn.loadLibraryArchives + return { archives: harness.archives, missing: harness.missing } + }, + async verifyCompile(args) { + harness.verifyCalls.push(args) + args.emit('verifying...', 'info') + return harness.verifyResult + }, + } + return harness +} + +function projectDataEmpty(): PLCProjectData { + return { + pous: [], + dataTypes: [], + libraries: [], + configuration: { resource: { tasks: [], instances: [], globalVariables: [] } }, + } as unknown as PLCProjectData +} + +function captureEvents() { + const events: Array<{ message: string; level: string }> = [] + return { events, emit: (e: { message: string; level: string }) => events.push(e) } +} + +beforeEach(() => { + mockPrepareXml.mockReset() + mockLibraryBuild.mockReset() + mockComposeVerify.mockClear() + + mockPrepareXml.mockReturnValue({ + xml: '...', + knownPous: [], + manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', extra: {} }, + }) + mockLibraryBuild.mockReturnValue({ success: true, archive: { stub: true }, errors: [] }) +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('runLibraryBuildPipeline', () => { + it('emits stages in canonical order and writes the .stlib at build/{name}.stlib', async () => { + const harness = makePort() + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(true) + expect(result.libraryName).toBe('lib') + expect(result.stlibPath).toBe('build/lib.stlib') + expect(harness.files.get('build/lib.stlib')).toMatch(/^\{[\s\S]+\}\n$/) + // Verification cache persisted with the MD5 the orchestrator computed. + expect(harness.files.has('build/.verify-cache-library.json')).toBe(true) + // Intermediates (plc.xml, program.st) live in memory only — the + // orchestrator does NOT persist them. See the path-constants + // comment in library-build-orchestrator.ts for the rationale. + expect(harness.files.has('build/library/src/plc.xml')).toBe(false) + expect(harness.files.has('build/library/src/program.st')).toBe(false) + // Stage messages flow through in order. + expect(events.map((e) => e.message)).toEqual( + expect.arrayContaining([ + 'Starting library build...', + 'Manifest OK — building "lib" v0.1.0.', + 'Compiling file plc.xml', + 'Verifying with OpenPLC Simulator (avr-gcc)...', + 'Compiling library archive...', + 'Library built successfully: build/lib.stlib', + ]), + ) + }) + + it('does not call deleteBuildSubtree (intermediates are no longer persisted)', async () => { + // Regression guard: dropping the intermediate file writes means + // dropping the subtree clear too — no orphan files to leave + // behind from the previous run. + const harness = makePort() + const deleteSpy = jest.spyOn(harness.port, 'deleteBuildSubtree') + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(deleteSpy).not.toHaveBeenCalled() + }) + + it('fails fast when the manifest is missing from the project', async () => { + const harness = makePort() + harness.manifestContent = null + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/library\.json.*missing/) + // Should NOT have called any later stages. + expect(mockPrepareXml).not.toHaveBeenCalled() + expect(mockLibraryBuild).not.toHaveBeenCalled() + expect(harness.verifyCalls).toHaveLength(0) + }) + + it('feeds the resolved library archives into libraryBuildFromTranspiledSt', async () => { + // Regression for the TON bug: the orchestrator MUST hand the + // archives (bundled IEC set + user-enabled) to the strucpp call. + const harness = makePort() + const bundledArchive = { manifest: { name: 'iec-standard-fb', functionBlocks: [{ name: 'TON' }] } } + const userArchive = { manifest: { name: 'oscat-basic', functionBlocks: [] } } + harness.archives = [bundledArchive, userArchive] + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: { + ...projectDataEmpty(), + libraries: [{ name: 'oscat-basic', version: '1.0.0' }], + } as PLCProjectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + const [, , , aux] = mockLibraryBuild.mock.calls[0] + expect(aux.dependencyArchives).toBe(harness.archives) + expect(aux.dependencyRefs).toEqual([{ name: 'oscat-basic', version: '1.0.0' }]) + }) + + it('aborts before xml2st when the project enables an unresolved library', async () => { + const harness = makePort() + harness.missing = ['ghost-lib'] + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: { + ...projectDataEmpty(), + libraries: [{ name: 'ghost-lib', version: '1.0.0' }], + } as PLCProjectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/ghost-lib/) + expect(result.error).toMatch(/Library Manager/) + expect(harness.verifyCalls).toHaveLength(0) + expect(mockLibraryBuild).not.toHaveBeenCalled() + }) + + it('skips verification when the MD5 cache matches', async () => { + const harness = makePort() + // Pre-seed the cache. computeMd5 in the harness is deterministic + // off program.st length + first char; the orchestrator's value + // will match this when the same xml2st output replays. + const programSt = `PROGRAM main\n(* from xml: 24 bytes *)\nEND_PROGRAM\n` + const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` + harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) + const { events, emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(0) + expect(events.some((e) => e.message.includes('Skipping verification'))).toBe(true) + }) + + it('cleanBuild forces a fresh verification regardless of cache', async () => { + const harness = makePort() + const programSt = `PROGRAM main\n(* from xml: 24 bytes *)\nEND_PROGRAM\n` + const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` + harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: true, + }, + harness.port, + emit, + ) + + expect(harness.verifyCalls).toHaveLength(1) + }) + + it('surfaces a verification failure as a warning but still emits the .stlib', async () => { + const harness = makePort() + harness.verifyResult = { success: false, message: 'AVR ran out of flash' } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(true) // verification failure is advisory + expect(result.verification?.success).toBe(false) + expect(result.verification?.message).toBe('AVR ran out of flash') + expect(harness.files.has('build/lib.stlib')).toBe(true) + expect(events.some((e) => e.level === 'warning' && /Verification reported issues/.test(e.message))).toBe(true) + }) + + it('propagates strucpp compile errors as a fatal build failure', async () => { + const harness = makePort() + mockLibraryBuild.mockReturnValueOnce({ + success: false, + errors: [{ message: "Undefined type 'TON'", file: 'main.st', line: 15 }], + }) + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe("Undefined type 'TON'") + expect(result.libraryName).toBe('lib') + // .stlib should NOT be written when compilation failed. + expect(harness.files.has('build/lib.stlib')).toBe(false) + // Error line formatted with file + line prefix. + expect(events.some((e) => /\[main\.st:15\].*Undefined type 'TON'/.test(e.message))).toBe(true) + }) + + it('threads pouDocs and cppBlocks through to libraryBuildFromTranspiledSt', async () => { + const harness = makePort() + const { emit } = captureEvents() + + const projectData = { + ...projectDataEmpty(), + pous: [{ type: 'function-block', data: { name: 'MyFb', documentation: 'A docstring' } }], + dataTypes: [{ name: 'MyType', documentation: 'A type description' }], + originalCppPous: [{ name: 'MyCppFb', code: 'void setup() {}', variables: [] }], + } as unknown as PLCProjectData + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData, + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + const [, , , aux] = mockLibraryBuild.mock.calls[0] + expect(aux.pouDocs).toEqual({ MyFb: 'A docstring', MyType: 'A type description' }) + expect(aux.cppBlocks).toEqual([{ name: 'MyCppFb', code: 'void setup() {}', variables: [] }]) + }) + + it('returns the manifest validation error verbatim without proceeding', async () => { + const harness = makePort() + mockPrepareXml.mockReturnValueOnce({ error: 'library.json is missing manifest.namespace' }) + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('library.json is missing manifest.namespace') + expect(mockLibraryBuild).not.toHaveBeenCalled() + expect(harness.verifyCalls).toHaveLength(0) + }) +}) diff --git a/src/backend/shared/library/library-build-orchestrator.ts b/src/backend/shared/library/library-build-orchestrator.ts new file mode 100644 index 000000000..44b539383 --- /dev/null +++ b/src/backend/shared/library/library-build-orchestrator.ts @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Shared library-build orchestrator. + * + * Single source of truth for the `.stlib` compilation flow. Both + * desktop and web drive this function through their own + * `LibraryBuildPort` implementation; every decision, every event, + * every file name, every hash, every error message, every cache rule + * is owned by this module. The port carries only the IO primitives + * (read / write / delete project files, resolve library archives, + * run a verification compile) — anything that looks like business + * logic stays here, by design. + * + * Stages: + * + * 0. Read `library.json` manifest. + * 1. Validate manifest + generate plc.xml (via shared + * `prepareXmlForLibraryBuild`). + * 2. xml2st → program.st (via `LibraryBuildPort.transpileXmlToSt`). + * The XML and ST live in memory only — no intermediate file + * persistence (see path-constants comment). + * 3. Resolve project-enabled library archives + fail on missing + * names (one place — feeds BOTH verification and strucpp). + * 4. Verification compile against the OpenPLC Simulator target + * via `LibraryBuildPort.verifyCompile`. MD5 cache hit short- + * circuits. Cache record persisted under `build/`. + * 5. Gather `pouDocs` + `cppBlocks` from the project data. + * 6. strucpp compile via `libraryBuildFromTranspiledSt`. + * 7. Write `.stlib` archive to `build/{name}.stlib`. + * + * The orchestrator returns a structured result; the adapter wraps it + * in whatever transport it owns (IPC port message on desktop, Promise + * resolution on web). See `LibraryBuildPort` for the contract the + * adapter implements. + */ + +import type { LibraryBuildPort } from '../../../middleware/shared/ports/library-build-port' +import type { CompileLibraryResult } from '../../../middleware/shared/ports/types' +import type { PLCProject, PLCProjectData } from '../types/PLC/open-plc' +import { + composeVerificationProject, + libraryBuildFromTranspiledSt, + type LibraryCppBlock, + prepareXmlForLibraryBuild, +} from './build-pipeline' + +// --------------------------------------------------------------------------- +// Public contract +// --------------------------------------------------------------------------- + +export interface LibraryBuildEvent { + message: string + level: 'info' | 'warning' | 'error' +} + +export interface LibraryBuildArgs { + /** Project root path (platform-shaped — desktop fs path, web project + * id / S3 prefix). Passed through to the port verbatim; the port + * decides how to interpret it. */ + projectPath: string + /** Build-pass project data: Python POUs lowered to runtime ST, C/C++ + * POUs replaced by stubs with the originals on `originalCppPous`. */ + projectData: PLCProjectData + /** Verification-pass project data: Python POUs lowered to no-op + * stubs (the AVR simulator has no Python interpreter). */ + verifyProjectData: PLCProjectData + /** Skip the MD5 verification cache and force a fresh verify run. */ + cleanBuild: boolean +} + +// Standard project-relative paths the orchestrator owns. Centralised +// here so adapters never hardcode them — both platforms write to the +// same project-relative locations, which is what makes a desktop- +// produced project tree byte-identical to a web-produced one. +// +// Intermediate files (plc.xml, program.st) are deliberately NOT +// persisted. They're transient artifacts that exist only between +// `prepareXmlForLibraryBuild` and `libraryBuildFromTranspiledSt` — +// passing them through the port would force every platform to spend +// a round-trip on data nobody reads after the build finishes. Only +// the user-visible `.stlib` artifact and the verification cache are +// written to the project tree. +const VERIFY_CACHE_REL_PATH = 'build/.verify-cache-library.json' +const LIBRARY_MANIFEST_REL_PATH = 'library.json' +const STLIB_OUT_DIR = 'build' + +/** + * Run the full library-build pipeline. Pure with respect to its + * arguments — every side effect funnels through `port` or `emit`. + * + * The result mirrors the existing `CompileLibraryResult` shape so the + * desktop's MessagePort wrapper and the web adapter both surface the + * same structure to the renderer. `success: false` from this function + * is a fatal build error; verification failures show up under + * `verification.success: false` with `success: true` overall. + */ +export async function runLibraryBuildPipeline( + args: LibraryBuildArgs, + port: LibraryBuildPort, + emit: (event: LibraryBuildEvent) => void, +): Promise { + const { projectPath, projectData, verifyProjectData, cleanBuild } = args + + emit({ message: 'Starting library build...', level: 'info' }) + + // ------------------------------------------------------------------------- + // Stage 0: read library.json manifest + // ------------------------------------------------------------------------- + let manifestJson: string | null + try { + manifestJson = await port.readBuildFile(projectPath, LIBRARY_MANIFEST_REL_PATH) + } catch (error) { + return fail(emit, `Could not read library.json: ${formatError(error)}`) + } + if (manifestJson === null) { + return fail(emit, 'Could not read library.json: file missing from project') + } + + // ------------------------------------------------------------------------- + // Stage 1: manifest validation + XML generation + // ------------------------------------------------------------------------- + const project: PLCProject = { + meta: { name: '', type: 'plc-library' }, + data: projectData, + } + const stage1 = prepareXmlForLibraryBuild(project, manifestJson) + if ('error' in stage1) { + return fail(emit, stage1.error) + } + const { xml, knownPous, manifest } = stage1 + emit({ message: `Manifest OK — building "${manifest.name}" v${manifest.version}.`, level: 'info' }) + + // ------------------------------------------------------------------------- + // Stage 2: xml2st via the shared compiler platform port + // + // The shared port abstracts the spawn vs HTTP divergence; the + // `--keep-structs` arg mirrors what `runCompilePipeline` passes + // for program builds so manifest type compatibility carries over. + // The XML and the resulting ST live in memory only — no + // intermediate-file persistence, see the path-constants comment. + // ------------------------------------------------------------------------- + emit({ message: 'Compiling file plc.xml', level: 'info' }) + const transpile = await port.transpileXmlToSt( + { xml, xml2stArgs: ['--keep-structs'] }, + // Forward xml2st's stdout / stderr to the caller — same channel + // the desktop pre-refactor compileLibrary used (`post(message, + // logLevel)`), so the console output stays unchanged. + (message, level) => emit({ message, level }), + ) + if (!transpile.ok || !transpile.programSt) { + const firstError = transpile.errors?.[0]?.message ?? 'xml2st failed' + return fail(emit, `xml2st failed: ${firstError}`, { libraryName: manifest.name }) + } + const programSt = transpile.programSt + + // ------------------------------------------------------------------------- + // Stage 4: resolve project-enabled library archives + // + // ONE resolution path feeding both verification (so the simulator + // compile sees the same symbol set the user's project sees) and + // the strucpp library compile. Missing names fail fast with a + // Library-Manager-pointing message before either heavy step runs. + // The bundled IEC standard set (TON, TP, CTU, etc.) is included + // automatically by every port impl — desktop reads it off disk, + // web pulls it from its bundled-stlibs asset glob. THIS is the + // step whose absence on web caused the "Undefined type 'TON'" bug. + // ------------------------------------------------------------------------- + const enabledLibraryRefs = (projectData.libraries ?? []).map((ref) => ({ + name: ref.name, + version: ref.version, + })) + const { archives: depArchives, missing: missingDeps } = await port.loadLibraryArchives({ + projectLibraryRefs: enabledLibraryRefs, + }) + if (missingDeps.length > 0) { + return fail( + emit, + `Library build aborted: enabled libraries are not installed (${missingDeps.join(', ')}). ` + + 'Open the Library Manager to install or remove them.', + { libraryName: manifest.name }, + ) + } + + // ------------------------------------------------------------------------- + // Stage 5: verification compile + // + // Hash program.st and consult the cache; cache hit short-circuits + // the slow avr-gcc compile. cleanBuild forces a fresh run. + // Verification failures are advisory: they surface as warnings on + // `verification.success` with the build still producing a `.stlib`. + // + // The MD5 routes through the platform port instead of `node:crypto` + // so the shared module ships without a host-runtime dependency. + // Editor's port wires it to Node's hash; web's port wires it to + // spark-md5 — both produce byte-identical output. + // ------------------------------------------------------------------------- + const programStMd5 = await port.computeMd5(programSt) + let verification: CompileLibraryResult['verification'] + let usedCache = false + if (!cleanBuild) { + const cached = await readVerificationCache(port, projectPath, programStMd5) + if (cached) { + verification = cached + usedCache = true + emit({ + message: `Skipping verification (cached: ${cached.success ? 'pass' : 'fail'}). Use "Clean build" to force re-verification.`, + level: 'info', + }) + } + } + if (!verification) { + const verifyProject = composeVerificationProject({ + meta: { name: manifest.name, type: 'plc-library' }, + data: verifyProjectData, + }) + emit({ message: 'Verifying with OpenPLC Simulator (avr-gcc)...', level: 'info' }) + try { + verification = await port.verifyCompile({ + projectPath, + verifyProjectData: verifyProject.data, + emit: (message, logLevel) => { + // Demote inner errors to warnings on the way out. `.stlib` + // is still produced, so an error-level `[verify]` line in + // the console would falsely suggest the build failed. + const level = logLevel === 'error' ? 'warning' : (logLevel ?? 'info') + emit({ message: `[verify] ${message}`, level }) + }, + }) + } catch (error) { + verification = { success: false, message: formatError(error) } + } + if (verification.success) { + emit({ message: 'Verification passed.', level: 'info' }) + } else { + emit({ + message: `Verification reported issues (warning only — .stlib will still be generated): ${verification.message ?? 'see log'}`, + level: 'warning', + }) + } + } + if (!usedCache && verification) { + try { + await port.writeBuildFile( + projectPath, + VERIFY_CACHE_REL_PATH, + JSON.stringify({ md5: programStMd5, ...verification }, null, 2), + ) + } catch (cacheErr) { + emit({ message: `Could not write verification cache: ${formatError(cacheErr)}`, level: 'warning' }) + } + } + + // ------------------------------------------------------------------------- + // Stage 6: gather per-symbol documentation + // + // POUs contribute their editor "Description" field; data types + // contribute their own optional documentation. Both ride through + // `libraryBuildFromTranspiledSt`'s aux block and get stamped onto + // the corresponding manifest entries via `decorateArchive`. + // ------------------------------------------------------------------------- + const pouDocs: Record = {} + for (const pou of projectData.pous) { + if (pou.data.documentation && pou.data.documentation.length > 0) { + pouDocs[pou.data.name] = pou.data.documentation + } + } + for (const dt of projectData.dataTypes ?? []) { + const doc = (dt as { documentation?: string }).documentation + const name = (dt as { name?: string }).name + if (typeof name === 'string' && typeof doc === 'string' && doc.length > 0) { + pouDocs[name] = doc + } + } + const cppBlocks: LibraryCppBlock[] = ( + (projectData as { originalCppPous?: Array<{ name: string; code: string; variables: unknown[] }> }) + .originalCppPous ?? [] + ).map((b) => ({ + name: b.name, + code: b.code, + variables: b.variables, + })) + + // ------------------------------------------------------------------------- + // Stage 7: strucpp compileStlib + // ------------------------------------------------------------------------- + emit({ message: 'Compiling library archive...', level: 'info' }) + const stage7 = libraryBuildFromTranspiledSt(programSt, knownPous, manifest, { + pouDocs, + dependencyArchives: depArchives, + dependencyRefs: enabledLibraryRefs, + cppBlocks, + }) + if (!stage7.success) { + for (const err of stage7.errors) { + const where = err.file ? `[${err.file}${err.line ? `:${err.line}` : ''}] ` : '' + emit({ message: `${where}${err.message}`, level: 'error' }) + } + return { + success: false, + error: stage7.errors[0]?.message ?? 'Library compilation failed.', + libraryName: manifest.name, + } + } + + // ------------------------------------------------------------------------- + // Stage 8: write .stlib archive + // ------------------------------------------------------------------------- + const stlibRelPath = `${STLIB_OUT_DIR}/${manifest.name}.stlib` + try { + await port.writeBuildFile(projectPath, stlibRelPath, JSON.stringify(stage7.archive, null, 2) + '\n') + } catch (error) { + return fail(emit, `Could not write ${manifest.name}.stlib: ${formatError(error)}`, { libraryName: manifest.name }) + } + + emit({ message: `Library built successfully: ${stlibRelPath}`, level: 'info' }) + return { + success: true, + stlibPath: stlibRelPath, + libraryName: manifest.name, + verification, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Read + validate the verification cache. Returns the cached + * `{ success, message }` only when the persisted MD5 matches the + * current `programSt`. Malformed cache files and missing files are + * indistinguishable from a fresh build — both return null so the + * caller falls through to a real verification run. + */ +async function readVerificationCache( + port: LibraryBuildPort, + projectPath: string, + programStMd5: string, +): Promise<{ success: boolean; message?: string } | null> { + let raw: string | null + try { + raw = await port.readBuildFile(projectPath, VERIFY_CACHE_REL_PATH) + } catch { + return null + } + if (raw === null) return null + try { + const parsed = JSON.parse(raw) as { md5?: string; success?: boolean; message?: string } + if (parsed?.md5 === programStMd5 && typeof parsed.success === 'boolean') { + return { success: parsed.success, message: parsed.message } + } + } catch { + /* malformed cache — fall through to fresh run */ + } + return null +} + +function fail( + emit: (event: LibraryBuildEvent) => void, + message: string, + extra: Partial = {}, +): CompileLibraryResult { + emit({ message, level: 'error' }) + return { success: false, error: message, ...extra } +} + +function formatError(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} diff --git a/src/middleware/shared/ports/library-build-port.ts b/src/middleware/shared/ports/library-build-port.ts new file mode 100644 index 000000000..7a9b06bfc --- /dev/null +++ b/src/middleware/shared/ports/library-build-port.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Library build port — the **entire** platform-specific surface of a + * `.stlib` compilation. Every byte of orchestration / decision / + * formatting / hashing logic lives in the shared + * `runLibraryBuildPipeline` orchestrator; this port owns only the + * primitives the orchestrator cannot perform itself because they + * cross the platform boundary (filesystem ↔ HTTP, etc.). + * + * Contract symmetry rule + * ---------------------- + * Both desktop and web MUST implement every method on this interface + * with semantically identical behaviour. When the orchestrator calls + * `port.readBuildFile(p, 'build/.verify-cache-library.json')`, both + * platforms return the same content if the project tree carries the + * same bytes. Differences are confined to *transport* (fs vs HTTP), + * never to logic — anything that looks like business logic belongs in + * the shared orchestrator instead. + * + * Web safety reminder + * ------------------- + * Web's save endpoint is delete-by-omission: a save with a missing + * file deletes it on S3. Any port impl method that touches the + * project tree MUST load the current snapshot first, mutate it, and + * save the full snapshot — never POST a partial state. See the + * `LibraryBuildPort` implementation guide alongside the web impl for + * the exact pattern. + */ + +import type { TranspileXmlToStArgs, TranspileXmlToStResult } from './compiler-platform-port' + +/** + * Outcome of an attempted verification compile against the OpenPLC + * Simulator board. Verification is advisory: a `success: false` + * surfaces as a warning on the build result, never as a fatal error + * (the `.stlib` still ships). See `runLibraryBuildPipeline` for the + * cache + skip-on-md5-match flow that wraps this. + */ +export interface LibraryVerificationResult { + success: boolean + /** Human-readable summary of the failure, surfaced as a console + * warning when `success` is false. Undefined on success. */ + message?: string +} + +/** + * Library-enabled archives the project pulls in for the strucpp + * compile. `archives` carries the bundled IEC standard set PLUS any + * user-installed `.stlib`s the project's `data.libraries` list + * enables. `missing` lists names the project enables but no archive + * could be resolved for; the orchestrator fails the build with a + * "Library Manager" message when `missing` is non-empty. + */ +export interface LibraryArchiveLookup { + archives: unknown[] + missing: string[] +} + +export interface LibraryArchiveLookupArgs { + projectLibraryRefs: ReadonlyArray<{ name: string; version: string }> +} + +export interface VerifyCompileArgs { + /** Project root path on the host platform. Same value the build + * orchestrator received; the port impl knows how to interpret it. */ + projectPath: string + /** + * Verification-pass project data — Python POUs already lowered to + * no-op stubs (the AVR simulator has no Python interpreter). The + * orchestrator preprocesses this separately from the build pass + * and hands the result through. + * + * Typed as `unknown` on purpose: the architecture rule forbids the + * port from importing `backend/shared` types. The orchestrator + * lives in `backend/shared` and produces shape-correct data; the + * port impl casts to its platform's expected shape (port-shape on + * web before invoking `runCompilePipeline`, schema-shape on editor + * before threading into the IPC envelope). + */ + verifyProjectData: unknown + /** Caller log callback. Every line the inner compile emits is + * forwarded here; the orchestrator prefixes them with `[verify]` + * before forwarding to its own caller. */ + emit: (message: string, level: 'info' | 'warning' | 'error') => void +} + +export interface LibraryBuildPort { + // ------------------------------------------------------------------------- + // Cryptography + transpile (same signatures `CompilerPlatformPort` + // uses for the program build — duplicated here intentionally so the + // orchestrator takes a single port object instead of two. Each + // impl is free to delegate to whatever its program-build path uses + // internally; the contract is just that bytes in match bytes out.) + // ------------------------------------------------------------------------- + + /** MD5 hex digest. Editor wires it to Node's `crypto`; web wires + * it to `spark-md5`. Both byte-identical. */ + computeMd5(input: string): Promise + + /** + * Transpile IEC 61131-3 XML to ST. Editor spawns the bundled + * `xml2st` binary; web posts to its compile-service. Both honor + * the same `xml2stArgs` and surface the same diagnostic shape. + * The `log` callback is the orchestrator's emit channel — every + * line xml2st produces flows through here. + */ + transpileXmlToSt( + args: TranspileXmlToStArgs, + log: (message: string, level: 'info' | 'warning' | 'error') => void, + ): Promise + + // ------------------------------------------------------------------------- + // Generic file IO over the project tree + // ------------------------------------------------------------------------- + + /** + * Read a project-relative file. Returns `null` when the file does + * not exist (the orchestrator treats null as a cache miss / first-run + * sentinel — never throws on missing files). Throws only for genuine + * IO errors the orchestrator surfaces as build failures. + */ + readBuildFile(projectPath: string, relPath: string): Promise + + /** + * Write a project-relative file, creating parent directories as + * needed. Implementations MUST preserve every other file in the + * project tree — see the safety reminder at the top of this file. + */ + writeBuildFile(projectPath: string, relPath: string, content: string): Promise + + /** + * Recursively remove a project-relative subtree. No-op when the + * subtree doesn't exist. Implementations MUST scope deletion to + * the named subtree — wiping anything outside it is a contract + * violation (the orchestrator relies on this guarantee when it + * clears `build/library/` between runs). + */ + deleteBuildSubtree(projectPath: string, relPath: string): Promise + + // ------------------------------------------------------------------------- + // Library-resolution and verification (platform-shaped operations + // whose implementations differ in transport but not in semantics) + // ------------------------------------------------------------------------- + + /** + * Resolve the project-enabled library refs to their parsed `.stlib` + * archives. Includes the bundled IEC standard set automatically — + * callers don't list it in `projectLibraryRefs`. Names that can't + * be resolved come back via `missing` for the orchestrator to fail + * the build with a clear "Library Manager" message. + */ + loadLibraryArchives(args: LibraryArchiveLookupArgs): Promise + + /** + * Run a verification compile of `verifyProjectData` against the + * OpenPLC Simulator board. Both platform impls internally drive + * the shared `runCompilePipeline` — the only thing they own is the + * platform-specific arg assembly (board entry, hals data, firmware + * skeleton) and the transport. Failures are advisory: the + * orchestrator surfaces them as a warning on the build result, + * never as a fatal error. + */ + verifyCompile(args: VerifyCompileArgs): Promise +}