From 9340e3e1e02487a0675317869182c769dfd28314 Mon Sep 17 00:00:00 2001 From: Patrick Bareiss Date: Tue, 28 Apr 2026 10:45:14 +0200 Subject: [PATCH 01/14] Move requirements editing to RGW as single source of truth Replaces the prior XLSX/CSV file-based requirements flow with one centred on the VectorCAST Requirements Gateway (RGW). The RGW directory is the durable store; the editor reads directly from /requirements_gateway/{requirements,traceability,origin}.json and writes back through the source CSV (panreq) plus a direct requirements.json mirror. User-visible changes: - Removed: Import Requirements from Gateway, Populate RGW from Requirements. RGW is now the gateway, so the "sync" commands no longer make sense. - Generate Requirements auto-creates reqs-/rgw/ and writes VCAST_REPOSITORY into CCAST_.CFG when unset. - Remove Requirements wipes the RGW dir and clears VCAST_REPOSITORY. - Show Requirements is now an interactive editor: title/description editable when origin.json reports generated_by_reqs2x: true; traceability (unit, function) is always editable. unit/function render as cascading dropdowns populated from the env's testData; falls back to free text when env data isn't available. - New "Infer Traceability" button uses the configured LLM (panreq --infer-traceability) with the standard cancellable progress notification. - Generate Tests from Requirements prompts to infer traceability when no requirement maps to a function yet. - Context menus: env-only commands restricted via ^vcast:[^|]+$ regex; Generate Tests works on sub-nodes, with vcastRequirementsAvailable now populated for descendant test-node IDs so enablement evaluates correctly on every level of the tree. Internal layout: - requirementsUtils.ts (1253 lines) split into rgwPath, rgwIo, llmProvider, availability, processRunner, webview/template, webview/messages. - Webview CSS + JS extracted to src/requirements/webviews/{css, webviewScripts} and loaded via webview URIs with a per-render nonce. .vscodeignore updated to ship them with the extension. - Wire protocol typed in webview/messages.ts (FromWebview / ToWebview discriminated unions). - Single runReqs2xTool helper replaces three near-identical spawn flows (generateRequirements, generateTestsFromRequirements, inferTraceability, panreq save). Encapsulates ProgressTracker, LLM provider check, and cancellation. - Removed deps: convert-excel-to-json, csv-parse, xlsx (no longer used). - Webview disposal is tracked so in-flight save/infer can complete in the background without tripping "Webview is disposed" when the user closes the panel mid-operation. Origin-aware editability: origin.json's generated_by_reqs2x flag (written by code2reqs/panreq) gates whether requirement bodies can be edited. External-source RGWs (Polarion, DOORS, ...) show a read-only banner over title/description while still allowing local traceability edits. --- .vscodeignore | 4 +- docs/reqs2x/reqs2x_documentation.md | 18 +- package.json | 49 +- src/extension.ts | 321 +++++---- src/requirements/availability.ts | 131 ++++ src/requirements/llmProvider.ts | 329 +++++++++ src/requirements/processRunner.ts | 191 +++++ src/requirements/requirementsOperations.ts | 678 ++++-------------- src/requirements/requirementsUtils.ts | 651 ----------------- src/requirements/rgwIo.ts | 321 +++++++++ src/requirements/rgwPath.ts | 106 +++ src/requirements/webview/messages.ts | 65 ++ src/requirements/webview/template.ts | 256 +++++++ .../webviews/css/requirements.css | 137 ++++ .../webviews/webviewScripts/requirements.js | 161 +++++ src/testPane.ts | 10 + 16 files changed, 2048 insertions(+), 1380 deletions(-) create mode 100644 src/requirements/availability.ts create mode 100644 src/requirements/llmProvider.ts create mode 100644 src/requirements/processRunner.ts delete mode 100644 src/requirements/requirementsUtils.ts create mode 100644 src/requirements/rgwIo.ts create mode 100644 src/requirements/rgwPath.ts create mode 100644 src/requirements/webview/messages.ts create mode 100644 src/requirements/webview/template.ts create mode 100644 src/requirements/webviews/css/requirements.css create mode 100644 src/requirements/webviews/webviewScripts/requirements.js diff --git a/.vscodeignore b/.vscodeignore index e8335426..2e1354a1 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -27,4 +27,6 @@ tsconfig_test.json # <— unignore webview assets !src/manage/webviews/html/** !src/manage/webviews/css/** -!src/manage/webviews/webviewScripts/** \ No newline at end of file +!src/manage/webviews/webviewScripts/** +!src/requirements/webviews/css/** +!src/requirements/webviews/webviewScripts/** \ No newline at end of file diff --git a/docs/reqs2x/reqs2x_documentation.md b/docs/reqs2x/reqs2x_documentation.md index bf0ff9a1..3291e3f0 100644 --- a/docs/reqs2x/reqs2x_documentation.md +++ b/docs/reqs2x/reqs2x_documentation.md @@ -34,8 +34,7 @@ Remove any existing versions of the VectorCAST VS-Code extension. ### VS Code Extensions -1. Install the **Excel Viewer** extension (`GrapeCity.gc-excelviewer`) to view/edit Excel sheets directly in VS Code (optional). Alternatively, use MS Excel to view/edit Excel sheets. -2. Install the **VectorCAST Text Explorer** VS-Code extension from the Microsoft Marketplace. +1. Install the **VectorCAST Text Explorer** VS-Code extension from the Microsoft Marketplace. ### Configuration 1. Activate the extension: Press `Ctrl+Shift+P`, search for **Vectorcast Test Explorer**, and press `Enter`. @@ -94,7 +93,7 @@ Remove any existing versions of the VectorCAST VS-Code extension. ## 2. Generating Tests from Requirements -The demo release includes an Excel file with requirements and requirements-to-code traceability. +The demo release ships with a pre-populated requirements gateway (RGW) including requirements-to-code traceability. The RGW is the single source of truth — the extension reads from and writes to it directly. ![Reqs2x demo requirements](./screenshots/VectorCAST_Reqs2x_demo_requirements.png) @@ -102,13 +101,9 @@ The demo release includes an Excel file with requirements and requirements-to-co ### Initial Setup 1. Click the **Flask icon** (Test Explorer) on the left sidebar to show the environment tree. -2. Right-click `TUTORIAL_C` and select **VectorCAST -> Show Requirements**. The requirements webview will appear. -3. Right-click `TUTORIAL_C` and select **VectorCAST -> Populate RGW from Requirements**. - * *Note: This creates a requirements gateway and populates it from the Excel sheet. Wait for the notification (approx. 3-5 seconds).* - * If you do not have the requirements-to-code traceability for a function, Reqs2x will generate it automatically with your approval. +2. Right-click `TUTORIAL_C` and select **VectorCAST -> Show Requirements**. The requirements webview will appear, rendered directly from the RGW. ![Reqs2x demo show requirements](./screenshots/VectorCAST_Reqs2x_show_requirements.png) ![Reqs2x demo requirements webview](./screenshots/VectorCAST_Reqs2x_requirements_webview.png) -![Reqs2x demo populate RGW](./screenshots/VectorCAST_Reqs2x_populate_rgw.png) ### Test Generation 1. Right-click `TUTORIAL_C` and select **VectorCAST -> Generate Tests from Requirements**. @@ -143,12 +138,11 @@ The demo release includes an Excel file with requirements and requirements-to-co 1. **Modify requirement**: - * Open `reqs.xlsx` using the Excel Viewer. - * Find `FR27` (Add Included Dessert). + * Edit the requirement directly in the requirements gateway (RGW) — either through the VectorCAST GUI, `clicast`, or whatever RGW editor you use. + * Find `FR27` (Add Included Dessert). * Change the free dessert for `steak, caesar salad and mixed drink` from `pie` to `cake`. * Change the free dessert for `lobster, green salad and wine` from `cake` to `pie`. - * Save (`Ctrl+S`) and close the tab. - * Right-click `TUTORIAL_C` and select **VectorCAST -> Populate RGW from Requirements** to push the change to the requirements gateway. You will be prompted to accept replacing the old requirements gateway with the new one. + * Save the change. The extension always reads from the RGW, so no further sync step is required. 2. **Update tests**: * In Test Explorer, find the `Add_Included_Dessert` node. diff --git a/package.json b/package.json index 32402e3e..dcea4a1e 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,8 @@ { "command": "vectorcastTestExplorer.generateRequirements", "category": "VectorCAST Test Explorer", - "title": "Generate Requirements" + "title": "Generate Requirements", + "enablement": "testId not in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.insertBasisPathTestsFromEditor", @@ -307,7 +308,8 @@ { "command": "vectorcastTestExplorer.generateTestsFromRequirements", "category": "VectorCAST Test Explorer", - "title": "Generate Tests from Requirements" + "title": "Generate Tests from Requirements", + "enablement": "testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.viewMCDCReport", @@ -317,22 +319,14 @@ { "command": "vectorcastTestExplorer.showRequirements", "category": "VectorCAST Test Explorer", - "title": "Show Requirements" + "title": "Show Requirements", + "enablement": "testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.removeRequirements", "category": "VectorCAST Test Explorer", - "title": "Remove Requirements" - }, - { - "command": "vectorcastTestExplorer.importRequirementsFromGateway", - "category": "VectorCAST Test Explorer", - "title": "Import Requirements from Gateway" - }, - { - "command": "vectorcastTestExplorer.populateRequirementsGateway", - "category": "VectorCAST Test Explorer", - "title": "Populate RGW from Requirements" + "title": "Remove Requirements", + "enablement": "testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.testLLMConfiguration", @@ -926,14 +920,6 @@ "command": "vectorcastTestExplorer.removeRequirements", "when": "never" }, - { - "command": "vectorcastTestExplorer.importRequirementsFromGateway", - "when": "never" - }, - { - "command": "vectorcastTestExplorer.populateRequirementsGateway", - "when": "never" - }, { "command": "vectorcastTestExplorer.testLLMConfiguration", "when": "vectorcastTestExplorer.reqs2xFeatureEnabled" @@ -1183,20 +1169,15 @@ "group": "vcast.delete", "when": "testId in vectorcastTestExplorer.globalProjectCompilers" }, - { - "command": "vectorcastTestExplorer.importRequirementsFromGateway", - "group": "vcast@8", - "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId not in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" - }, { "command": "vectorcastTestExplorer.generateRequirements", "group": "vcast@8", - "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId not in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && vectorcastTestExplorer.generateRequirementsEnabled" }, { "command": "vectorcastTestExplorer.showRequirements", "group": "vcast@9", - "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && vectorcastTestExplorer.generateRequirementsEnabled" }, { "command": "vectorcastTestExplorer.generateTestsFromRequirements", @@ -1206,12 +1187,7 @@ { "command": "vectorcastTestExplorer.removeRequirements", "group": "vcast@9", - "when": "testId =~ /^vcast:.*$/ && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" - }, - { - "command": "vectorcastTestExplorer.populateRequirementsGateway", - "group": "vcast@9", - "when": "testId =~ /^vcast:.*$/ && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && vectorcastTestExplorer.reqs2xFeatureEnabled && vectorcastTestExplorer.generateRequirementsEnabled" }, { "command": "vectorcastTestExplorer.viewResults", @@ -1303,9 +1279,7 @@ "dependencies": { "@vscode/vsce": "^2.32.0", "axios": "^1.7.7", - "convert-excel-to-json": "^1.7.0", "crc-32": "^1.2.0", - "csv-parse": "^5.6.0", "glob": "^7.1.7", "hasbin": "^1.2.3", "jsonc-parser": "^3.2.1", @@ -1315,7 +1289,6 @@ "tslib": "^1.9.3", "vscode-languageclient": "^5.2.1", "vscode-languageserver": "^5.2.1", - "xlsx": "^0.18.5", "xo": "^0.58.0" }, "extensionDependencies": [], diff --git a/src/extension.ts b/src/extension.ts index 7d859e6c..4397e3a3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -125,21 +125,33 @@ import { } from "./vcastInstallation"; import { + clearVcastRepositoryInConfig, findRelevantRequirementGateway, +} from "./requirements/rgwPath"; +import { updateRequirementsAvailability } from "./requirements/availability"; +import { performLLMProviderUsableCheck } from "./requirements/llmProvider"; +import { + inferTraceability, + readRGWBundle, + RGWBundle, + RGWStaleWriteError, + writeRGWBundle, +} from "./requirements/rgwIo"; +import { generateRequirementsHtml, - parseRequirementsFromFile, - performLLMProviderUsableCheck, - requirementsFileWatcher, - updateRequirementsAvailability, -} from "./requirements/requirementsUtils"; + renderRequirementsBody, + resolveRequirementsWebviewBase, +} from "./requirements/webview/template"; +import type { + FromWebview, + ToWebview, +} from "./requirements/webview/messages"; import { GENERATE_REQUIREMENTS_ENABLED, generateRequirements, generateTestsFromRequirements, - importRequirementsFromGateway, initializeReqs2X, - populateRequirementsGateway, } from "./requirements/requirementsOperations"; import { @@ -512,30 +524,6 @@ function configureExtension(context: vscode.ExtensionContext) { ); context.subscriptions.push(generateRequirementsTestsCommand); - let importRequirementsFromGatewayCommand = vscode.commands.registerCommand( - "vectorcastTestExplorer.importRequirementsFromGateway", - (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - const enviroPath = testNode.enviroPath; - importRequirementsFromGateway(enviroPath); - } - } - ); - context.subscriptions.push(importRequirementsFromGatewayCommand); - - let populateRequirementsGatewayCommand = vscode.commands.registerCommand( - "vectorcastTestExplorer.populateRequirementsGateway", - async (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - const enviroPath = testNode.enviroPath; - await populateRequirementsGateway(enviroPath); - } - } - ); - context.subscriptions.push(populateRequirementsGatewayCommand); - let testLLMConfigurationCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.testLLMConfiguration", async (args: any) => { @@ -1311,55 +1299,159 @@ function configureExtension(context: vscode.ExtensionContext) { let showRequirementsCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.showRequirements", async (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - const enviroPath = testNode.enviroPath; + if (!args) return; + const testNode: testNodeType = getTestNode(args.id); + const enviroPath = testNode.enviroPath; + + let bundle: RGWBundle | null; + try { + bundle = readRGWBundle(enviroPath); + } catch (err) { + vscode.window.showErrorMessage(`Failed to load requirements: ${err}`); + return; + } - const parentDir = path.dirname(enviroPath); - const enviroNameWithExt = path.basename(enviroPath); - // remove ".env" if present - const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` + if (!bundle) { + vscode.window.showErrorMessage( + "No requirements gateway found. Generate requirements first." ); + return; + } - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - - let filePath = ""; - let fileType = ""; - if (fs.existsSync(xlsxPath)) { - filePath = xlsxPath; - fileType = "Excel"; - } else if (fs.existsSync(csvPath)) { - filePath = csvPath; - fileType = "CSV"; - } else { - vscode.window.showErrorMessage( - "Requirements file not found. Generate requirements first." - ); - return; + const loaded: RGWBundle = bundle; + + // Best-effort: pull units + their functions from the env so the + // traceability fields render as dropdowns. If the env can't be queried + // (unbuilt / data server down), fall back to free-text inputs. + let unitsToFunctions: Record | null = null; + try { + const envData = await getEnvironmentData(enviroPath); + const testData = envData?.testData; + if (Array.isArray(testData)) { + unitsToFunctions = {}; + for (const unit of testData) { + // Skip synthetic test-pane nodes ("Compound Tests", + // "Initialization Tests", etc.) — they have no source path. + if (!unit?.name || !unit.path) continue; + const fns: string[] = []; + if (Array.isArray(unit.functions)) { + for (const f of unit.functions) { + if (f?.name) fns.push(f.name); + } + } + unitsToFunctions[unit.name] = fns; + } } + } catch { + // ignore; webview will fall back to free-text inputs + } - try { - const panel = vscode.window.createWebviewPanel( - "requirementsReport", - "Requirements Report", - vscode.ViewColumn.One, - { enableScripts: true } - ); - - panel.webview.html = `

Loading ${fileType} requirements...

`; - const requirements = await parseRequirementsFromFile(filePath); - const htmlContent = generateRequirementsHtml(requirements); - panel.webview.html = htmlContent; - } catch (err) { - vscode.window.showErrorMessage( - `Error generating requirements report: ${err}` - ); + const webviewBaseDir = resolveRequirementsWebviewBase(context); + const panel = vscode.window.createWebviewPanel( + "requirementsReport", + "Requirements Report", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(webviewBaseDir)], } - } + ); + const nonce = getNonce(); + panel.webview.html = generateRequirementsHtml( + panel.webview, + webviewBaseDir, + nonce, + loaded, + unitsToFunctions + ); + + let currentBundle: RGWBundle = loaded; + + // Save / infer can complete after the user closes the panel — let the + // underlying operation finish but skip the postMessage so we don't trip + // "Webview is disposed". + let disposed = false; + panel.onDidDispose( + () => { + disposed = true; + }, + null, + context.subscriptions + ); + const post = (msg: ToWebview) => { + if (disposed) return; + panel.webview.postMessage(msg); + }; + + panel.webview.onDidReceiveMessage( + async (msg: FromWebview) => { + if (msg?.type === "save") { + try { + // External-source RGWs lock requirement bodies; trust the loaded + // copy over anything the webview sends for that field. + const safeUpdates = currentBundle.origin.generated_by_reqs2x + ? msg.updates + : { + requirements: currentBundle.requirements, + traceability: msg.updates.traceability, + }; + const newMtimes = await writeRGWBundle( + enviroPath, + currentBundle.gatewayPath, + safeUpdates, + msg.expectedMtimes + ); + currentBundle = { + ...currentBundle, + requirements: safeUpdates.requirements, + traceability: safeUpdates.traceability, + mtimes: newMtimes, + }; + post({ + type: "saved", + mtimes: newMtimes, + requirements: safeUpdates.requirements, + traceability: safeUpdates.traceability, + body: renderRequirementsBody(currentBundle, unitsToFunctions), + }); + } catch (err) { + const message = + err instanceof RGWStaleWriteError + ? `${err.message} Reopen the requirements view to reload and re-apply your edits.` + : `Failed to save requirements: ${err}`; + vscode.window.showErrorMessage(message); + post({ type: "save-failed", message }); + } + } else if (msg?.type === "infer-traceability") { + try { + const refreshed = await inferTraceability( + enviroPath, + currentBundle.gatewayPath + ); + if (!refreshed) { + // Cancelled by user — re-enable the webview buttons silently. + post({ type: "infer-cancelled" }); + return; + } + currentBundle = refreshed; + post({ + type: "inferred", + mtimes: refreshed.mtimes, + requirements: refreshed.requirements, + traceability: refreshed.traceability, + body: renderRequirementsBody(refreshed, unitsToFunctions), + }); + } catch (err) { + const message = `Failed to infer traceability: ${err}`; + vscode.window.showErrorMessage(message); + post({ type: "infer-failed", message }); + } + } + }, + undefined, + context.subscriptions + ); } ); context.subscriptions.push(showRequirementsCommand); @@ -1372,7 +1464,7 @@ function configureExtension(context: vscode.ExtensionContext) { const enviroPath = testNode.enviroPath; const message = - "This will remove all generated requirements files. This action cannot be undone."; + "This will delete the requirements gateway and clear VCAST_REPOSITORY from CCAST_.CFG. This action cannot be undone."; const choice = await vscode.window.showWarningMessage( message, "Remove", @@ -1381,63 +1473,46 @@ function configureExtension(context: vscode.ExtensionContext) { if (choice === "Remove") { const parentDir = path.dirname(enviroPath); - const enviroNameWithExt = path.basename(enviroPath); - // remove ".env" if present - const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); + const enviroNameWithoutExt = path + .basename(enviroPath) + .replace(/\.env$/, ""); const envReqsFolderPath = path.join( parentDir, `reqs-${enviroNameWithoutExt}` ); - const filesToRemove = [ - path.join(envReqsFolderPath, "reqs.csv"), - path.join(envReqsFolderPath, "reqs.xlsx"), - path.join(envReqsFolderPath, "reqs_converted.csv"), - path.join(envReqsFolderPath, "reqs.html"), - path.join(envReqsFolderPath, "reqs2tests.tst"), - ]; - - // Remove files - for (const file of filesToRemove) { - if (fs.existsSync(file)) { - try { - fs.unlinkSync(file); - } catch (err) { - vscode.window.showErrorMessage( - `Failed to remove ${file}: ${err}` - ); - } + const gatewayPath = findRelevantRequirementGateway(enviroPath); + if (gatewayPath && fs.existsSync(gatewayPath)) { + try { + fs.rmSync(gatewayPath, { recursive: true, force: true }); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to remove requirements gateway: ${err}` + ); } } - const generatedRepositoryPath = path.join( - envReqsFolderPath, - "generated_requirement_repository" - ); - const actualRepositoryPath = - findRelevantRequirementGateway(enviroPath); + clearVcastRepositoryInConfig(enviroPath); + + const tstPath = path.join(parentDir, "reqs2tests.tst"); + if (fs.existsSync(tstPath)) { + try { + fs.unlinkSync(tstPath); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to remove ${tstPath}: ${err}` + ); + } + } - // Separately prompt for repository directory removal if ( - fs.existsSync(generatedRepositoryPath) && - path.relative(generatedRepositoryPath, actualRepositoryPath) === "" + fs.existsSync(envReqsFolderPath) && + fs.readdirSync(envReqsFolderPath).length === 0 ) { - const repoMessage = - "Would you also like to remove the auto-generated requirements gateway too?"; - const repoChoice = await vscode.window.showWarningMessage( - repoMessage, - "Yes", - "No" - ); - - if (repoChoice === "Yes") { - try { - fs.rmdirSync(generatedRepositoryPath, { recursive: true }); - } catch (err) { - vscode.window.showErrorMessage( - `Failed to remove repository directory: ${err}` - ); - } + try { + fs.rmdirSync(envReqsFolderPath); + } catch { + // best-effort } } @@ -2257,10 +2332,6 @@ async function installPreActivationEventHandlers( // this method is called when your extension is deactivated export async function deactivate() { - if (requirementsFileWatcher) { - requirementsFileWatcher.dispose(); - } - await serverProcessController(serverStateType.stopped); // delete the server log if it exists await deleteServerLog(); diff --git a/src/requirements/availability.ts b/src/requirements/availability.ts new file mode 100644 index 00000000..98ef113e --- /dev/null +++ b/src/requirements/availability.ts @@ -0,0 +1,131 @@ +import * as vscode from "vscode"; +import { vcastInstallationDirectory } from "../vcastInstallation"; +import { exeFilename, normalizePath } from "../utilities"; +import { makeEnviroNodeID } from "../testPane"; +import { testNodeCache } from "../testData"; +import { findRelevantRequirementGateway } from "./rgwPath"; +import { logCliError, logCliOperation } from "./requirementsOperations"; + +const fs = require("fs"); + +const NECCESSARY_REQS2X_EXECUTABLES = [ + "code2reqs", + "reqs2tests", + "panreq", + "llm2check", +]; + +// Tracks node IDs (env + descendants) currently flagged as having +// requirements available. Sub-node IDs are included so the +// `testId in vcastRequirementsAvailable` check on `Generate Tests from +// Requirements` matches when the user right-clicks a unit / function / test +// inside an env that has requirements. +let availableNodeIds: string[] = []; + +/** + * Collect every node ID descended from `envNodeID` that's currently in + * the test cache. Sub-node IDs follow the convention + * `|[.[.]]`, so a prefix filter is enough. + */ +function descendantNodeIds(envNodeID: string): string[] { + const prefix = `${envNodeID}|`; + const out: string[] = []; + for (const id of testNodeCache.keys()) { + if (typeof id === "string" && id.startsWith(prefix)) { + out.push(id); + } + } + return out; +} + +function setAvailableContext(ids: string[]) { + vscode.commands.executeCommand( + "setContext", + "vectorcastTestExplorer.vcastRequirementsAvailable", + ids + ); + availableNodeIds = ids; +} + +/** + * Update the `vectorcastTestExplorer.vcastRequirementsAvailable` context key + * for an environment. Includes descendants discovered in `testNodeCache` so + * sub-node menu enablements (Generate Tests from Requirements) work too. + * + * Call this after the env's tree has been processed (descendants exist in + * the cache); otherwise only the env ID gets added until the tree is built. + */ +export function updateRequirementsAvailability(enviroPath: string) { + const envNodeID = makeEnviroNodeID(normalizePath(enviroPath)); + const hasRequirements = findRelevantRequirementGateway(enviroPath) !== null; + const idsForThisEnv = [envNodeID, ...descendantNodeIds(envNodeID)]; + + if (hasRequirements) { + let updated = availableNodeIds.slice(); + let changed = false; + for (const id of idsForThisEnv) { + if (!updated.includes(id)) { + updated.push(id); + changed = true; + } + } + if (changed) setAvailableContext(updated); + } else { + const drop = new Set(idsForThisEnv); + const updated = availableNodeIds.filter((id) => !drop.has(id)); + if (updated.length !== availableNodeIds.length) setAvailableContext(updated); + } +} + +/** + * Resolve where the Reqs2X executables (code2reqs, reqs2tests, panreq, + * llm2check) live on disk. Priority: + * 1. The `reqs2x.installationLocation` setting if it contains all binaries. + * 2. The configured VectorCAST installation directory. + * 3. The bundled CI/VSIX resource path. + */ +export function getAutoreqExecutableDirectory( + context: vscode.ExtensionContext +): vscode.Uri | undefined { + const pathHasAllExecutables = (dirPath: string): boolean => { + return NECCESSARY_REQS2X_EXECUTABLES.every((exe) => + fs.existsSync( + vscode.Uri.joinPath(vscode.Uri.file(dirPath), exeFilename(exe)).fsPath + ) + ); + }; + + const config = vscode.workspace.getConfiguration( + "vectorcastTestExplorer.reqs2x" + ); + const installationLocation = config.get("installationLocation"); + + if (installationLocation && pathHasAllExecutables(installationLocation)) { + return vscode.Uri.file(installationLocation); + } + + if (pathHasAllExecutables(vcastInstallationDirectory)) { + return vscode.Uri.file(vcastInstallationDirectory); + } + + const isCI = process.env.HOME?.startsWith("/github") ?? false; + const vsixResourceBasePath = `${process.env.GITHUB_WORKSPACE}/vsix`; + + if (!fs.existsSync(vsixResourceBasePath)) { + logCliError( + `VSIX resource folder not found at expected path: ${vsixResourceBasePath}` + ); + } else { + logCliOperation(`Found VSIX resource folder at: ${vsixResourceBasePath}`); + } + + const vsixBaseURI = isCI + ? vscode.Uri.file(vsixResourceBasePath) + : context.extensionUri; + + if (pathHasAllExecutables(vsixBaseURI.fsPath)) { + return vscode.Uri.joinPath(vsixBaseURI, "resources", "distribution"); + } + + return undefined; +} diff --git a/src/requirements/llmProvider.ts b/src/requirements/llmProvider.ts new file mode 100644 index 00000000..33055e0c --- /dev/null +++ b/src/requirements/llmProvider.ts @@ -0,0 +1,329 @@ +import * as vscode from "vscode"; +import { + ChildProcessWithoutNullStreams, + spawn, +} from "node:child_process"; +import { vcastInstallationDirectory } from "../vcastInstallation"; +import { showSettings } from "../utilities"; +import { extractJson } from "../../src-common/commonUtilities"; +import { LLM2CHECK_EXECUTABLE_PATH } from "./requirementsOperations"; + +export interface LLMProviderSettingsResult { + provider: string | null; + env: Record; + missing: string[]; +} + +/** + * Project the user's LLM provider settings from VS Code config into the env + * variables Reqs2X tools expect. Returns a list of missing required fields + * for the chosen provider so the caller can show a useful error. + */ +export function gatherLLMProviderSettings(): LLMProviderSettingsResult { + const config = vscode.workspace.getConfiguration("vectorcastTestExplorer"); + + const provider = config.get("reqs2x.provider"); + const baseEnv: Record = {}; + const missing: string[] = []; + + if (!provider) { + missing.push("Provider (reqs2x.provider)"); + return { provider: null, env: baseEnv, missing }; + } + + function need(value: string | undefined, label: string, envVarName: string) { + if (!value) { + missing.push(label); + return; + } + baseEnv[envVarName] = value; + } + + function optional(value: string | undefined, envVarName: string) { + if (value) { + baseEnv[envVarName] = value; + } + } + + if (provider === "azure_openai") { + need( + config.get("reqs2x.azure.baseUrl"), + "Azure Base URL", + "VCAST_REQS2X_AZURE_OPENAI_BASE_URL" + ); + need( + config.get("reqs2x.azure.apiKey"), + "Azure API Key", + "VCAST_REQS2X_AZURE_OPENAI_API_KEY" + ); + need( + config.get("reqs2x.azure.deployment"), + "Azure Deployment", + "VCAST_REQS2X_AZURE_OPENAI_DEPLOYMENT" + ); + need( + config.get("reqs2x.azure.modelName"), + "Azure Model Name", + "VCAST_REQS2X_AZURE_OPENAI_MODEL_NAME" + ); + need( + config.get("reqs2x.azure.apiVersion"), + "Azure API Version", + "VCAST_REQS2X_AZURE_OPENAI_API_VERSION" + ); + optional( + config.get("reqs2x.azure.reasoningModelName"), + "VCAST_REQS2X_REASONING_AZURE_OPENAI_MODEL_NAME" + ); + optional( + config.get("reqs2x.azure.reasoningDeployment"), + "VCAST_REQS2X_REASONING_AZURE_OPENAI_DEPLOYMENT" + ); + } else if (provider === "openai") { + optional( + config.get("reqs2x.openai.baseUrl"), + "VCAST_REQS2X_OPENAI_BASE_URL" + ); + need( + config.get("reqs2x.openai.apiKey"), + "OpenAI API Key", + "VCAST_REQS2X_OPENAI_API_KEY" + ); + need( + config.get("reqs2x.openai.modelName"), + "OpenAI Model Name", + "VCAST_REQS2X_OPENAI_MODEL_NAME" + ); + optional( + config.get("reqs2x.openai.reasoningModelName"), + "VCAST_REQS2X_REASONING_OPENAI_MODEL_NAME" + ); + } else if (provider === "anthropic") { + need( + config.get("reqs2x.anthropic.apiKey"), + "Anthropic API Key", + "VCAST_REQS2X_ANTHROPIC_API_KEY" + ); + need( + config.get("reqs2x.anthropic.modelName"), + "Anthropic Model Name", + "VCAST_REQS2X_ANTHROPIC_MODEL_NAME" + ); + optional( + config.get("reqs2x.anthropic.reasoningModelName"), + "VCAST_REQS2X_REASONING_ANTHROPIC_MODEL_NAME" + ); + } else if (provider === "litellm") { + need( + config.get("reqs2x.litellm.modelName"), + "LiteLLM Model Name", + "VCAST_REQS2X_LITELLM_MODEL_NAME" + ); + optional( + config.get("reqs2x.litellm.reasoningModelName"), + "VCAST_REQS2X_REASONING_LITELLM_MODEL_NAME" + ); + + const litellmProviderEnvVarsString = config.get( + "reqs2x.litellm.providerEnvVars", + "" + ); + const entries = litellmProviderEnvVarsString + .split(",") + .map((pair) => pair.split("=")) + .filter((kv) => kv[0].trim().length); + + if (entries.some((entryValues) => entryValues.length !== 2)) { + missing.push( + "LiteLLM Provider Environment Variables must be KEY=VALUE pairs" + ); + } else { + for (const [key, value] of entries) { + baseEnv[key.trim()] = value.trim(); + } + } + } else if (provider === "azure_apim") { + need( + config.get("reqs2x.azureApim.subscriptionKey"), + "APIM Subscription Key", + "VCAST_REQS2X_AZURE_APIM_SUBSCRIPTION_KEY" + ); + need( + config.get("reqs2x.azureApim.baseUrl"), + "APIM Base URL", + "VCAST_REQS2X_AZURE_APIM_BASE_URL" + ); + need( + config.get("reqs2x.azureApim.modelName"), + "APIM Model Name", + "VCAST_REQS2X_AZURE_APIM_MODEL_NAME" + ); + optional( + config.get("reqs2x.azureApim.apiKey"), + "VCAST_REQS2X_AZURE_APIM_API_KEY" + ); + optional( + config.get("reqs2x.azureApim.reasoningModelName"), + "VCAST_REQS2X_REASONING_AZURE_APIM_MODEL_NAME" + ); + } else if (provider === "openai_at") { + need( + config.get("reqs2x.openaiAt.modelName"), + "OpenAI AT Model Name", + "VCAST_REQS2X_OPENAI_AT_MODEL_NAME" + ); + need( + config.get("reqs2x.openaiAt.modelUrl"), + "OpenAI AT Model URL", + "VCAST_REQS2X_OPENAI_AT_MODEL_URL" + ); + need( + config.get("reqs2x.openaiAt.authUrl"), + "OpenAI AT Auth URL", + "VCAST_REQS2X_OPENAI_AT_AUTH_URL" + ); + need( + config.get("reqs2x.openaiAt.appKey"), + "OpenAI AT App Key", + "VCAST_REQS2X_OPENAI_AT_APP_KEY" + ); + need( + config.get("reqs2x.openaiAt.appSecret"), + "OpenAI AT App Secret", + "VCAST_REQS2X_OPENAI_AT_APP_SECRET" + ); + optional( + config.get("reqs2x.openaiAt.reasoningModelName"), + "VCAST_REQS2X_REASONING_OPENAI_AT_MODEL_NAME" + ); + } else { + missing.push("Unsupported provider value"); + } + + return { provider, env: baseEnv, missing }; +} + +/** + * Run llm2check to verify the configured provider is reachable. Used by both + * the explicit "Test LLM Configuration" command and as a precondition before + * invoking other Reqs2X tools. + */ +export function isLLMProviderEnvironmentUsable(): Promise<{ + usable: boolean; + problem: string | null; +}> { + const processEnv = { ...process.env }; + + const gatheredSettings = gatherLLMProviderSettings(); + for (const [k, v] of Object.entries(gatheredSettings.env)) { + if (v) processEnv[k] = v; + } + + if ( + vscode.workspace + .getConfiguration("vectorcastTestExplorer.reqs2x") + .get("modelCompatibilityMode", false) + ) { + processEnv.VCAST_REQS2X_MODEL_COMPATIBILITY_MODE = "1"; + } + + const proc = spawn(LLM2CHECK_EXECUTABLE_PATH, ["--json"], { + env: processEnv, + }); + + return new Promise((resolve) => { + let output = ""; + proc.stdout.on("data", (data) => { + output += data.toString(); + }); + + proc.on("close", () => { + const result = extractJson(output); + if (result && typeof result.usable === "boolean") { + resolve({ usable: result.usable, problem: result.problem || null }); + } else { + console.error(`Failed to parse llm2check output: ${output}`); + resolve({ usable: false, problem: "Failed to parse llm2check output" }); + } + }); + }); +} + +/** + * Wraps `isLLMProviderEnvironmentUsable` with a user-facing error toast and + * "Open Settings" action. Returns true if the provider is usable, false + * otherwise (toast already shown). + */ +export async function performLLMProviderUsableCheck(): Promise { + const { usable, problem } = await isLLMProviderEnvironmentUsable(); + const gatheredSettings = gatherLLMProviderSettings(); + + if (!usable) { + const causedByMissing = problem?.includes( + "No provider configuration found" + ); + + const errorMessage = causedByMissing + ? `Required information to run Reqs2X with currently selected LLM provider (${gatheredSettings.provider}) is missing: ${gatheredSettings.missing.join(", ")}` + : `The current LLM provider settings for Reqs2X (either set in the extension or in the environment) are not usable: ${problem}`; + + vscode.window + .showErrorMessage(errorMessage, "Open Settings") + .then((choice) => { + if (choice === "Open Settings") showSettings(); + }); + + return false; + } + + return true; +} + +/** + * Build the env block Reqs2X tools expect: provider creds + extras + * (generation language, debug logging, model-compatibility mode). Adds + * VSCODE_VECTORCAST_DIR so the tools can find the VC installation. + */ +export async function createProcessEnvironment(): Promise { + const processEnv = { ...process.env }; + processEnv.VSCODE_VECTORCAST_DIR = vcastInstallationDirectory; + + const gatheredSettings = gatherLLMProviderSettings(); + for (const [k, v] of Object.entries(gatheredSettings.env)) { + if (v) processEnv[k] = v; + } + + const config = vscode.workspace.getConfiguration( + "vectorcastTestExplorer.reqs2x" + ); + const languageCode = config.get("generationLanguage", "en"); + processEnv.VCAST_REQS2X_RESPONSE_LANGUAGE = languageCode; + + if (config.get("outputDebugInfo", false)) { + processEnv.VCAST_REQS2X_LOG_LEVEL = "debug"; + } + + if (config.get("modelCompatibilityMode", false)) { + processEnv.VCAST_REQS2X_MODEL_COMPATIBILITY_MODE = "1"; + } + + return processEnv; +} + +/** + * Spawn a Reqs2X tool with the LLM-aware env block. Performs the provider + * check up-front; throws if the user hasn't configured a usable provider. + */ +export async function spawnWithVcastEnv( + command: string, + args: string[], + options: any = {} +): Promise { + const checkSuccessful = await performLLMProviderUsableCheck(); + if (!checkSuccessful) { + throw new Error("LLM provider settings are not usable"); + } + + const env = await createProcessEnvironment(); + return spawn(command, args, { ...options, env }); +} diff --git a/src/requirements/processRunner.ts b/src/requirements/processRunner.ts new file mode 100644 index 00000000..07ab8d24 --- /dev/null +++ b/src/requirements/processRunner.ts @@ -0,0 +1,191 @@ +import * as vscode from "vscode"; +import { + ChildProcessWithoutNullStreams, + spawn, +} from "node:child_process"; +import { logCliError, logCliOperation } from "./requirementsOperations"; +import { spawnWithVcastEnv } from "./llmProvider"; + +/** + * Parses Reqs2X tool stdout for `--json-events` lines and drives a VS Code + * progress reporter from them. Plain (non-JSON) lines are forwarded to the + * operations output channel verbatim. + */ +export class ProgressTracker { + private lastProgress = 0.0; + + constructor( + private progress: vscode.Progress<{ message?: string; increment?: number }>, + private logPrefix: string + ) {} + + public processOutput(output: string) { + const lines = output.split("\n"); + for (const line of lines) { + if (!line.trim()) continue; + try { + const json = JSON.parse(line); + this.handleJson(json); + } catch { + logCliOperation(`${this.logPrefix}: ${line}`); + } + } + } + + private handleJson(json: any) { + if (!json.event) { + throw new Error( + `Invalid JSON event: ${JSON.stringify(json)}. Missing 'event' field.` + ); + } + if (!json.value) { + throw new Error( + `Invalid JSON event: ${JSON.stringify(json)}. Missing 'value' field.` + ); + } + if (json.event === "progress") { + let step: string | undefined; + let newProgress: number | undefined; + if (typeof json.value === "object") { + step = json.value.step; + newProgress = json.value.progress; + } else if (typeof json.value === "number") { + newProgress = json.value; // legacy + } + + if (newProgress === undefined) return; + + const increment = (newProgress - this.lastProgress) * 100; + if (increment > 0) { + this.progress.report({ message: step, increment }); + this.lastProgress = newProgress; + logCliOperation( + `${this.logPrefix} Progress: ${(newProgress * 100).toFixed(2)}% - ${step ?? ""}` + ); + } + } else if (json.event === "problem") { + if (this.logPrefix === "reqs2tests" && json.value.includes("Individual")) { + return; + } + vscode.window.showWarningMessage(json.value); + logCliOperation(`Warning: ${json.value}`); + } + } +} + +export interface RunReqs2xToolOptions { + /** Absolute path to the executable (panreq, code2reqs, reqs2tests, etc.). */ + exe: string; + /** Arguments. */ + args: string[]; + /** Optional working directory. */ + cwd?: string; + /** + * If true, the tool needs the LLM-aware env block (provider check + env + * vars). Use for code2reqs, reqs2tests, panreq --infer-traceability. + * Skip for plain panreq format conversions or other LLM-free operations. + */ + llm?: boolean; + /** + * If provided, wraps the run in a VS Code progress notification and parses + * the tool's stdout via ProgressTracker. The notification is cancellable — + * cancel kills the subprocess and the runner returns `{ cancelled: true }`. + */ + progress?: { title: string; logPrefix: string }; +} + +export interface RunReqs2xToolResult { + cancelled: boolean; +} + +/** + * Single entry point for invoking a Reqs2X CLI tool. Replaces the previous + * mix of `spawnAndWait`, ad-hoc `spawn(...)` blocks, and inline progress + * wiring scattered across the requirements modules. + * + * - Logs the command, stdout, stderr and exit code to the operations output. + * - Honors `llm` to decide between plain `spawn` and LLM-checked + * `spawnWithVcastEnv`. + * - With `progress` set, drives the standard notification + cancel button. + * + * Resolves on success, throws on non-zero exit, or returns + * `{ cancelled: true }` if the user cancelled a progress run. + */ +export async function runReqs2xTool( + opts: RunReqs2xToolOptions +): Promise { + logCliOperation( + `Running: ${opts.exe} ${opts.args.join(" ")}${opts.cwd ? ` (cwd=${opts.cwd})` : ""}` + ); + + const spawnOpts = opts.cwd ? { cwd: opts.cwd } : {}; + const startProc = async (): Promise => + opts.llm + ? await spawnWithVcastEnv(opts.exe, opts.args, spawnOpts) + : spawn(opts.exe, opts.args, spawnOpts); + + if (opts.progress) { + const progressOpts = opts.progress; + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: progressOpts.title, + cancellable: true, + }, + async (progress, cancellationToken) => { + const proc = await startProc(); + const tracker = new ProgressTracker(progress, progressOpts.logPrefix); + + let cancelled = false; + cancellationToken.onCancellationRequested(() => { + cancelled = true; + proc.kill(); + logCliOperation(`${progressOpts.logPrefix}: cancelled by user`); + }); + + await new Promise((resolve, reject) => { + proc.stdout.on("data", (d) => { + if (!cancelled) tracker.processOutput(d.toString()); + }); + proc.stderr.on("data", (d) => { + const errOut = d.toString(); + if (errOut.trim()) logCliError(`${progressOpts.logPrefix}: ${errOut.trim()}`); + }); + proc.on("error", reject); + proc.on("close", (code) => { + logCliOperation(`${progressOpts.logPrefix} exit code: ${code}`); + if (cancelled) return resolve(); + if (code === 0) resolve(); + else reject(new Error(`${progressOpts.logPrefix} exited with code ${code}`)); + }); + }); + + return { cancelled }; + } + ); + } + + // No progress notification: capture both streams and log them. + const proc = await startProc(); + let stdout = ""; + let stderr = ""; + + await new Promise((resolve, reject) => { + proc.stdout.on("data", (d) => { + stdout += d.toString(); + }); + proc.stderr.on("data", (d) => { + stderr += d.toString(); + }); + proc.on("error", reject); + proc.on("close", (code) => { + if (stdout.trim()) logCliOperation(`stdout: ${stdout.trim()}`); + if (stderr.trim()) logCliOperation(`stderr: ${stderr.trim()}`); + logCliOperation(`exit code: ${code}`); + if (code === 0) resolve(); + else reject(new Error(`${opts.exe} exited with code ${code}: ${stderr}`)); + }); + }); + + return { cancelled: false }; +} diff --git a/src/requirements/requirementsOperations.ts b/src/requirements/requirementsOperations.ts index 91451ecb..42c16ad7 100644 --- a/src/requirements/requirementsOperations.ts +++ b/src/requirements/requirementsOperations.ts @@ -1,12 +1,5 @@ import * as vscode from "vscode"; import { exeFilename, showSettings } from "../utilities"; -import { - findRelevantRequirementGateway, - getAutoreqExecutableDirectory, - setupRequirementsFileWatchers, - spawnWithVcastEnv, - updateRequirementsAvailability, -} from "./requirementsUtils"; import { refreshAllExtensionData } from "../testPane"; import { loadTestScriptIntoEnvironment } from "../vcastAdapter"; @@ -17,15 +10,13 @@ let reqs2XFeatureEnabled: boolean = false; export const GENERATE_REQUIREMENTS_ENABLED: boolean = true; -// Setup the paths to the reqs2x executables let CODE2REQS_EXECUTABLE_PATH: string; let REQS2TESTS_EXECUTABLE_PATH: string; -let PANREQ_EXECUTABLE_PATH: string; +export let PANREQ_EXECUTABLE_PATH: string; export let LLM2CHECK_EXECUTABLE_PATH: string; -// Add a new output channel for CLI operations -let cliOutputChannel: vscode.OutputChannel = vscode.window.createOutputChannel( +const cliOutputChannel: vscode.OutputChannel = vscode.window.createOutputChannel( "VectorCAST Requirement Test Generation Operations" ); @@ -40,81 +31,11 @@ export function logCliError( ): void { const timestamp = new Date().toLocaleTimeString(); cliOutputChannel.appendLine(`[${timestamp}] ${message}`); - if (show) { cliOutputChannel.show(); } } -class ProgressTracker { - private lastProgress = 0.0; - - constructor( - private progress: vscode.Progress<{ message?: string; increment?: number }>, - private logPrefix: string - ) {} - - public processOutput(output: string) { - const lines = output.split("\n"); - for (const line of lines) { - if (!line.trim()) continue; - try { - const json = JSON.parse(line); - this.handleJson(json); - } catch (e) { - logCliOperation(`${this.logPrefix}: ${line}`); - } - } - } - - private handleJson(json: any) { - if (!json.event) { - throw new Error( - `Invalid JSON event: ${JSON.stringify(json)}. Missing 'event' field.` - ); - } - if (!json.value) { - throw new Error( - `Invalid JSON event: ${JSON.stringify(json)}. Missing 'value' field.` - ); - } - if (json.event === "progress") { - let step: string | undefined; - let newProgress: number | undefined; - if (typeof json.value === "object") { - step = json.value.step; - newProgress = json.value.progress; - } else if (typeof json.value === "number") { - newProgress = json.value; // legacy - } - - if (newProgress === undefined) { - return; - } - - const increment = (newProgress - this.lastProgress) * 100; - if (increment > 0) { - this.progress.report({ message: step, increment }); - this.lastProgress = newProgress; - logCliOperation( - `${this.logPrefix} Progress: ${(newProgress * 100).toFixed( - 2 - )}% - ${step ?? ""}` - ); - } - } else if (json.event === "problem") { - if ( - this.logPrefix === "reqs2tests" && - json.value.includes("Individual") - ) { - return; - } - vscode.window.showWarningMessage(json.value); - logCliOperation(`Warning: ${json.value}`); - } - } -} - export function initializeReqs2X(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration( "vectorcastTestExplorer.reqs2x" @@ -124,19 +45,15 @@ export function initializeReqs2X(context: vscode.ExtensionContext) { let featureEnabled: boolean = false; if (reqs2XFeatureEnabled) { - setupRequirementsFileWatchers(context); const successful = setupReqs2XExecutablePaths(context); if (!successful) { - // Tell the user that we couldn't find the executables as an error popup and offer to open settings vscode.window .showErrorMessage( "Could not find the reqs2X executables anywhere, disabling Reqs2X. Please check your settings.", "Open Settings" ) .then((selection) => { - if (selection === "Open Settings") { - showSettings(); - } + if (selection === "Open Settings") showSettings(); }); } else { featureEnabled = true; @@ -151,11 +68,14 @@ export function initializeReqs2X(context: vscode.ExtensionContext) { } function setupReqs2XExecutablePaths(context: vscode.ExtensionContext): boolean { - const baseUri = getAutoreqExecutableDirectory(context); + // Lazy import to break the circular dependency: + // availability → requirementsOperations (for log helpers) → availability. + const { + getAutoreqExecutableDirectory, + } = require("./availability") as typeof import("./availability"); - if (!baseUri) { - return false; - } + const baseUri = getAutoreqExecutableDirectory(context); + if (!baseUri) return false; CODE2REQS_EXECUTABLE_PATH = vscode.Uri.joinPath( baseUri, @@ -178,58 +98,36 @@ function setupReqs2XExecutablePaths(context: vscode.ExtensionContext): boolean { } export async function generateRequirements(enviroPath: string) { + const { + defaultRequirementGatewayPath, + findRelevantRequirementGateway, + setVcastRepositoryInConfig, + } = require("./rgwPath") as typeof import("./rgwPath"); + const { updateRequirementsAvailability } = + require("./availability") as typeof import("./availability"); + const { runReqs2xTool } = + require("./processRunner") as typeof import("./processRunner"); + const parentDir = path.dirname(enviroPath); const lowestDirname = path.basename(enviroPath); const envName = `${lowestDirname}.env`; const envPath = path.join(parentDir, envName); - // remove ".env" if present - const enviroNameWithoutExt = envName.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` - ); - - // Ensure the requirements folder exists - if (!fs.existsSync(envReqsFolderPath)) { - fs.mkdirSync(envReqsFolderPath, { recursive: true }); - } - - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const repositoryDir = path.join( - envReqsFolderPath, - "generated_requirement_repository" - ); - - // Check for existing gateway - const existingGateway = findRelevantRequirementGateway(enviroPath); - if (existingGateway) { - const warningMessage = `Warning: An existing requirements gateway was found at ${existingGateway}. Generating requirements will switch the environment gateway to a new one.`; + // Resolve the RGW path: use an already-configured VCAST_REPOSITORY if set, + // otherwise fall back to the extension's default location and write it into + // CCAST_.CFG so subsequent operations find the same gateway. + let repositoryDir = findRelevantRequirementGateway(enviroPath); + if (repositoryDir) { const choice = await vscode.window.showWarningMessage( - warningMessage, + `Warning: An existing requirements gateway was found at ${repositoryDir}. Generating requirements will overwrite it.`, "Continue", "Cancel" ); - - if (choice !== "Continue") { - return; - } - } - - // Check for existing reqs.csv or reqs.xlsx - if (fs.existsSync(xlsxPath) || fs.existsSync(csvPath)) { - const message = - "Existing requirements files found. Do you want to overwrite them?"; - const choice = await vscode.window.showWarningMessage( - message, - "Overwrite", - "Cancel" - ); - - if (choice !== "Overwrite") { - return; - } + if (choice !== "Continue") return; + } else { + repositoryDir = defaultRequirementGatewayPath(enviroPath); + fs.mkdirSync(path.dirname(repositoryDir), { recursive: true }); + setVcastRepositoryInConfig(enviroPath, repositoryDir); } const config = vscode.workspace.getConfiguration( @@ -241,133 +139,102 @@ export async function generateRequirements(enviroPath: string) { ); const reorder = config.get("reorder", true); - const commandArgs = [ + const args = [ "-e", envPath, - "--export-excel", - xlsxPath, "--export-repository", - `${existingGateway ?? repositoryDir}`, // use existingGateway if not null/undefined, else repositoryDir. This would mean that the vcast env in the path is not defined. + repositoryDir, "--json-events", - //"--combine-related-requirements", - //"--extended-reasoning" + ...(generateHighLevelRequirements + ? ["--generate-high-level-requirements"] + : []), + ...(reorder ? [] : ["--no-reorder"]), ]; - if (generateHighLevelRequirements) { - commandArgs.push("--generate-high-level-requirements"); - } - - if (!reorder) { - commandArgs.push("--no-reorder"); + try { + const { cancelled } = await runReqs2xTool({ + exe: CODE2REQS_EXECUTABLE_PATH, + args, + llm: true, + progress: { + title: `Generating Requirements for ${envName.split(".")[0]}`, + logPrefix: "code2reqs", + }, + }); + if (cancelled) return; + + await refreshAllExtensionData(); + updateRequirementsAvailability(enviroPath); + vscode.commands.executeCommand("vectorcastTestExplorer.showRequirements", { + id: enviroPath, + }); + vscode.window.showInformationMessage( + "Successfully generated requirements for the environment!" + ); + } catch (err) { + const message = `Error: ${err instanceof Error ? err.message : String(err)}`; + vscode.window.showErrorMessage(message); + logCliError(message, true); } - - // Log the command being executed - const commandString = `${CODE2REQS_EXECUTABLE_PATH} ${commandArgs.join(" ")}`; - logCliOperation(`Executing command: ${commandString}`); - - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Generating Requirements for ${envName.split(".")[0]}`, - cancellable: true, - }, - async (progress, cancellationToken) => { - const process = await spawnWithVcastEnv( - CODE2REQS_EXECUTABLE_PATH, - commandArgs - ); - - return new Promise((resolve, reject) => { - const tracker = new ProgressTracker(progress, "code2reqs"); - - cancellationToken.onCancellationRequested(() => { - process.kill(); - logCliOperation("Operation cancelled by user"); - resolve(); - }); - - process.stdout.on("data", (data) => { - if (cancellationToken.isCancellationRequested) return; - tracker.processOutput(data.toString()); - }); - - process.stderr.on("data", (data) => { - const errorOutput = data.toString(); - logCliError(`code2reqs: ${errorOutput}`); - console.error(`Stderr: ${errorOutput}`); - }); - - process.on("close", async (code) => { - if (cancellationToken.isCancellationRequested) return; - if (code === 0) { - logCliOperation( - `code2reqs completed successfully with code ${code}` - ); - await refreshAllExtensionData(); - updateRequirementsAvailability(enviroPath); - vscode.commands.executeCommand( - "vectorcastTestExplorer.showRequirements", - { id: enviroPath } - ); - vscode.window.showInformationMessage( - "Successfully generated requirements for the environment!" - ); - resolve(); - } else { - const errorMessage = `Error: code2reqs exited with code ${code}`; - vscode.window.showErrorMessage(errorMessage); - logCliError(errorMessage, true); - reject(new Error(errorMessage)); - } - }); - }); - } - ); } export async function generateTestsFromRequirements( enviroPath: string, unitOrFunctionName: string | null ) { + const { findRelevantRequirementGateway } = + require("./rgwPath") as typeof import("./rgwPath"); + const { readRGWBundle, inferTraceability } = + require("./rgwIo") as typeof import("./rgwIo"); + const { runReqs2xTool } = + require("./processRunner") as typeof import("./processRunner"); + const parentDir = path.dirname(enviroPath); const lowestDirname = path.basename(enviroPath); const envName = `${lowestDirname}.env`; const envPath = path.join(parentDir, envName); - // remove ".env" if present - const enviroNameWithoutExt = envName.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` - ); - - // Ensure the requirements folder exists - if (!fs.existsSync(envReqsFolderPath)) { - fs.mkdirSync(envReqsFolderPath, { recursive: true }); - } - - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - - // tstPath must be in the same parent directory as the .env. - // If the .tst is stored inside reqs-, VectorCAST treats the - // environment as read-only and refuses to load it. Therefore we place - // reqs2tests.tst directly under parentDir, alongside .env. + // tstPath must be in the same parent directory as the .env. If the .tst is + // stored inside reqs-, VectorCAST treats the environment as + // read-only and refuses to load it. const tstPath = path.join(parentDir, "reqs2tests.tst"); - let reqsFile = ""; - if (fs.existsSync(xlsxPath)) { - reqsFile = xlsxPath; - } else if (fs.existsSync(csvPath)) { - reqsFile = csvPath; - } else { + const gatewayPath = findRelevantRequirementGateway(enviroPath); + if (!gatewayPath) { vscode.window.showErrorMessage( - "No requirements file found. Please generate requirements first." + "No requirements gateway found. Please generate requirements first." ); return; } - // Get the decompose setting from configuration + // Test generation needs per-requirement traceability — reqs2tests routes + // each requirement to its mapped function. If the RGW has none, offer to + // infer it now rather than letting reqs2tests no-op or fail downstream. + const bundle = readRGWBundle(enviroPath); + if (bundle) { + const hasAnyFunction = Object.values(bundle.traceability).some( + (entry) => entry?.function != null + ); + if (!hasAnyFunction) { + const choice = await vscode.window.showWarningMessage( + "None of the requirements trace to a function. Test generation requires per-requirement traceability. Would you like to infer it automatically first?", + "Infer traceability", + "Cancel" + ); + if (choice !== "Infer traceability") return; + try { + const refreshed = await inferTraceability( + enviroPath, + bundle.gatewayPath + ); + if (!refreshed) return; // user cancelled the inference progress + } catch (err) { + vscode.window.showErrorMessage(`Failed to infer traceability: ${err}`); + return; + } + } + } + const config = vscode.workspace.getConfiguration( "vectorcastTestExplorer.reqs2x" ); @@ -375,7 +242,6 @@ export async function generateTestsFromRequirements( "decomposeRequirements", true ); - const noTestExamples = config.get("noTestExamples", false); const reorder = config.get("reorder", true); const funcDefs = config.get("functionDefinitions", true); @@ -389,14 +255,10 @@ export async function generateTestsFromRequirements( return; } - const enableRequirementKeys = - findRelevantRequirementGateway(enviroPath) !== null; - console.log(decomposeRequirements, enableRequirementKeys); - - const commandArgs = [ + const args = [ "-e", envPath, - reqsFile, // use the chosen requirements file + gatewayPath, ...(unitOrFunctionName ? ["-f", unitOrFunctionName] : []), "--export-tst", tstPath, @@ -410,319 +272,29 @@ export async function generateTestsFromRequirements( ...(allowUUTStubs ? [] : ["--no-allow-uut-stubs"]), "--allow-partial", "--json-events", - ...(enableRequirementKeys ? ["--requirement-keys"] : []), + "--requirement-keys", ]; - // Log the command being executed - const commandString = `${REQS2TESTS_EXECUTABLE_PATH} ${commandArgs.join( - " " - )}`; - logCliOperation(`Executing command: ${commandString}`); - - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Generating Requirement Tests for ${envName.split(".")[0]}`, - cancellable: true, - }, - async (progress, cancellationToken) => { - const process = await spawnWithVcastEnv( - REQS2TESTS_EXECUTABLE_PATH, - commandArgs - ); - - return new Promise((resolve, reject) => { - const tracker = new ProgressTracker(progress, "reqs2tests"); - - cancellationToken.onCancellationRequested(() => { - process.kill(); - logCliOperation("Operation cancelled by user"); - resolve(); - }); - - process.stdout.on("data", (data) => { - if (cancellationToken.isCancellationRequested) return; - tracker.processOutput(data.toString()); - }); - - process.stderr.on("data", (data) => { - const errorOutput = data.toString(); - logCliError(`reqs2tests: ${errorOutput}`); - console.error(`Stderr: ${errorOutput}`); - }); - - process.on("close", async (code) => { - if (cancellationToken.isCancellationRequested) return; - - if (code === 0) { - logCliOperation( - `reqs2tests completed successfully with code ${code}` - ); - await loadTestScriptIntoEnvironment(envName.split(".")[0], tstPath); - await refreshAllExtensionData(); - - vscode.window.showInformationMessage( - "Successfully generated tests for the requirements!" - ); - resolve(); - } else { - const errorMessage = `Error: reqs2tests exited with code ${code}`; - vscode.window.showErrorMessage(errorMessage); - logCliError(errorMessage, true); - reject(new Error(errorMessage)); - } - }); - }); - } - ); -} - -export async function importRequirementsFromGateway(enviroPath: string) { - const parentDir = path.dirname(enviroPath); - const lowestDirname = path.basename(enviroPath); - const envName = `${lowestDirname}.env`; - const envPath = path.join(parentDir, envName); - - // Determine (or create) the requirements folder used elsewhere in the extension - const enviroNameWithoutExt = lowestDirname.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` - ); - const repositoryDirExpandedEnv = path.join( - envReqsFolderPath, - "generated_requirement_repository" - ); - if (!fs.existsSync(envReqsFolderPath)) { - fs.mkdirSync(envReqsFolderPath, { recursive: true }); - } - - // Look for requirement gateway - const repositoryPath = findRelevantRequirementGateway(enviroPath); - if (!repositoryPath) { - vscode.window.showErrorMessage( - "Requirements Gateway either is not specified or does not exist. Aborting." - ); - return; - } - - // Target files INSIDE the reqs- folder (align with generate/remove logic) - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - - // Check if requirements files already exist - const xlsxExists = fs.existsSync(xlsxPath); - const csvExists = fs.existsSync(csvPath); - - if (xlsxExists || csvExists) { - let warningMessage = "Warning: "; - if (xlsxExists) { - warningMessage += - "An existing Excel requirements file (reqs.xlsx) will be overwritten."; - } - if (csvExists) { - if (xlsxExists) warningMessage += " Additionally, "; - warningMessage += - "An existing CSV requirements file (reqs.csv) will be ignored as the new Excel file takes precedence."; - } - - const choice = await vscode.window.showWarningMessage( - warningMessage, - "Continue", - "Cancel" - ); - if (choice !== "Continue") return; - } - - const choice = await vscode.window.showInformationMessage( - "Would you like the system to automatically try to add traceability to the requirements?", - "Yes", - "No" - ); - const addTraceability = choice === "Yes"; - - const commandArgs = [ - `${repositoryPath ?? repositoryDirExpandedEnv}`, // repositoryPath should never be undefined here, as we return before but just to be sure - xlsxPath, - "--target-format", - "excel", - ...(addTraceability ? ["--infer-traceability"] : []), - "--target-env", - envPath, - "--json-events", - ]; - - const commandString = `${PANREQ_EXECUTABLE_PATH} ${commandArgs.join(" ")}`; - logCliOperation(`Executing command: ${commandString}`); - - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Importing Requirements from Gateway`, - cancellable: true, - }, - async (progress, cancellationToken) => { - const proc = await spawnWithVcastEnv(PANREQ_EXECUTABLE_PATH, commandArgs); - - return new Promise((resolve, reject) => { - const tracker = new ProgressTracker(progress, "panreq"); - - cancellationToken.onCancellationRequested(() => { - proc.kill(); - logCliOperation("Operation cancelled by user"); - resolve(); - }); - - proc.stdout.on("data", (d) => { - if (cancellationToken.isCancellationRequested) return; - tracker.processOutput(d.toString()); - }); - - proc.stderr.on("data", (d) => { - const errOut = d.toString(); - logCliError(`panreq: ${errOut.trim()}`); - }); - - proc.on("close", async (code) => { - if (cancellationToken.isCancellationRequested) return; - if (code === 0) { - logCliOperation( - `reqs2excel completed successfully with code ${code}` - ); - await refreshAllExtensionData(); - updateRequirementsAvailability(enviroPath); - vscode.commands.executeCommand( - "vectorcastTestExplorer.showRequirements", - { id: enviroPath } - ); - vscode.window.showInformationMessage( - "Successfully imported requirements from gateway" - ); - resolve(); - } else { - const msg = `Error: panreq exited with code ${code}`; - vscode.window.showErrorMessage(msg); - logCliError(msg, true); - reject(new Error(msg)); - } - }); - }); - } - ); -} - -export async function populateRequirementsGateway(enviroPath: string) { - const parentDir = path.dirname(enviroPath); - const envName = path.basename(enviroPath); - const envPath = path.join(parentDir, envName); - - // remove ".env" if present - const enviroNameWithoutExt = envName.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` - ); - - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - - // Check which requirements file exists - let requirementsFile = ""; - if (fs.existsSync(xlsxPath)) { - requirementsFile = xlsxPath; - } else if (fs.existsSync(csvPath)) { - requirementsFile = csvPath; - } else { - vscode.window.showErrorMessage( - "No requirements file found. Generate requirements first." - ); - return; - } - - // Check if there is an existing requirements gateway - const existingGateway = findRelevantRequirementGateway(enviroPath); - if (existingGateway) { - const warningMessage = `Warning: An existing requirements gateway was found at ${existingGateway}. Generating requirements will switch the environment gateway to a new one.`; - const choice = await vscode.window.showWarningMessage( - warningMessage, - "Continue", - "Cancel" + try { + const { cancelled } = await runReqs2xTool({ + exe: REQS2TESTS_EXECUTABLE_PATH, + args, + llm: true, + progress: { + title: `Generating Requirement Tests for ${envName.split(".")[0]}`, + logPrefix: "reqs2tests", + }, + }); + if (cancelled) return; + + await loadTestScriptIntoEnvironment(envName.split(".")[0], tstPath); + await refreshAllExtensionData(); + vscode.window.showInformationMessage( + "Successfully generated tests for the requirements!" ); - - if (choice !== "Continue") { - return; - } + } catch (err) { + const message = `Error: ${err instanceof Error ? err.message : String(err)}`; + vscode.window.showErrorMessage(message); + logCliError(message, true); } - - const exportRepository = path.join( - envReqsFolderPath, - "generated_requirement_repository" - ); - - const commandArgs = [ - requirementsFile, - `${existingGateway ?? exportRepository}`, - "--target-format", - "rgw", - "--target-env", - envPath, - ]; - - // Log the command being executed - const commandString = `${PANREQ_EXECUTABLE_PATH} ${commandArgs.join(" ")}`; - logCliOperation(`Executing command: ${commandString}`); - - // Show progress while running - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Populating Requirements Gateway...", - cancellable: false, - }, - async (progress) => { - const process = await spawnWithVcastEnv( - PANREQ_EXECUTABLE_PATH, - commandArgs - ); - - return new Promise((resolve, reject) => { - process.stdout.on("data", (data) => { - const output = data.toString().trim(); - logCliOperation(`panreq: ${output}`); - }); - - process.stderr.on("data", (data) => { - const errorOutput = data.toString().trim(); - logCliError(`panreq: ${errorOutput}`); - }); - - process.on("close", async (code) => { - if (code === 0) { - logCliOperation( - `reqs2rgw completed successfully with code ${code}` - ); - - try { - await refreshAllExtensionData(); - vscode.window.showInformationMessage( - `Successfully populated requirements gateway at ${exportRepository}` - ); - resolve(); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - vscode.window.showErrorMessage( - `Error updating environment configuration: ${error.message}` - ); - reject(error); - } - } else { - const errorMessage = `Error: reqs2rgw exited with code ${code}`; - vscode.window.showErrorMessage(errorMessage); - logCliError(errorMessage, true); - reject(new Error(errorMessage)); - } - }); - }); - } - ); } diff --git a/src/requirements/requirementsUtils.ts b/src/requirements/requirementsUtils.ts deleted file mode 100644 index 7eb8d682..00000000 --- a/src/requirements/requirementsUtils.ts +++ /dev/null @@ -1,651 +0,0 @@ -import * as vscode from "vscode"; -import { workspace } from "vscode"; -import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import { parse as csvParse } from "csv-parse/sync"; -import { vcastInstallationDirectory } from "../vcastInstallation"; -import { exeFilename, normalizePath, showSettings } from "../utilities"; -import { - LLM2CHECK_EXECUTABLE_PATH, - logCliError, - logCliOperation, -} from "./requirementsOperations"; -import { makeEnviroNodeID } from "../testPane"; -import { extractJson } from "../../src-common/commonUtilities"; - -const path = require("path"); -const fs = require("fs"); -const excelToJson = require("convert-excel-to-json"); - -const NECCESSARY_REQS2X_EXECUTABLES = [ - "code2reqs", - "reqs2tests", - "panreq", - "llm2check", -]; - -export let alreadyInitializedFileWatchers: boolean = false; -export let requirementsFileWatcher: vscode.FileSystemWatcher | undefined; - -let existingEnvs: string[] = []; - -/** - * Find the most relevant requirement gateway for a given environment path - * @param enviroPath The environment path - * @returns The most relevant gateway path, or null if none found - */ -export function findRelevantRequirementGateway( - enviroPath: string -): string | null { - const parentDir = path.dirname(enviroPath); - const configPath = path.join(parentDir, "CCAST_.CFG"); - - const configContent = fs.readFileSync(configPath, "utf-8"); - - const gatewayMatch = configContent.match(/VCAST_REPOSITORY:\s*(.+)\s*/); - - if (gatewayMatch == null) { - return null; - } - - // Expand variables before checking existence - const rawGatewayPath = gatewayMatch[1].trim(); - const gatewayPath = expandEnvVars(rawGatewayPath); - - if (!fs.existsSync(gatewayPath)) { - return null; - } - - return rawGatewayPath; -} - -export async function parseRequirementsFromFile( - filePath: string -): Promise { - try { - if (filePath.endsWith(".xlsx")) { - const result = excelToJson({ - sourceFile: filePath, - }).Requirements; - - const columnNames: string[] = Object.values(result[0]); - - const requirements = []; - - console.log(columnNames, result); - - for (const row of result.slice(1)) { - const requirement: Record = {}; - for (let i = 0; i < columnNames.length; i++) { - requirement[columnNames[i]] = Object.values(row)[i] as string; - } - requirements.push(requirement); - } - - return requirements; - } else { - const fileContent = await fs.promises.readFile(filePath, "utf8"); - return csvParse(fileContent, { - columns: true, - skip_empty_lines: true, - trim: true, - ltrim: true, - quote: '"', - }); - } - } catch (error) { - logCliError(`Failed to parse requirements file: ${error}`, true); - throw error; - } -} - -export function findEnvironmentInPath(dirPath: string): string | null { - // dirPath will be the path were the requirements are, but the env files are in the parent folder - const parentDir = path.dirname(dirPath); - - // the folder is named reqs-, so we cut out the correct env name to find the corresponding env file - const baseFolder = path.basename(dirPath); - const envName = baseFolder.split("reqs-")[1]; - - // Check if the directory contains an environment file - const files = fs.readdirSync(parentDir); - const envFiles = files.filter((file: string) => - file.endsWith(`${envName}.env`) - ); - - // Now see if there is a directory with the same name as the env file - for (const file of envFiles) { - // remove ".env" - const envName = file.slice(0, -4); - const envDirPath = path.join(parentDir, envName); - if (fs.existsSync(envDirPath) && fs.lstatSync(envDirPath).isDirectory()) { - return envDirPath; - } - } - return null; -} - -export function setupRequirementsFileWatchers( - context: vscode.ExtensionContext -) { - if (alreadyInitializedFileWatchers) { - return; - } - alreadyInitializedFileWatchers = true; - - if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { - // Create a file watcher that watches for requirements files changes - // using a glob pattern to match all reqs.csv and reqs.xlsx files in the workspace - requirementsFileWatcher = workspace.createFileSystemWatcher( - "**/reqs-*/reqs.{csv,xlsx}" - ); - - // When a requirements file is created - requirementsFileWatcher.onDidCreate( - async (uri) => { - logCliOperation(`Requirements file created: ${uri.fsPath}`); - const changeDir = path.dirname(uri.fsPath); - const envDirPath = findEnvironmentInPath(changeDir); - if (envDirPath) { - updateRequirementsAvailability(envDirPath); - } - }, - null, - context.subscriptions - ); - - // When a requirements file is deleted - requirementsFileWatcher.onDidDelete( - async (uri) => { - logCliOperation(`Requirements file deleted: ${uri.fsPath}`); - const parentDir = path.dirname(uri.fsPath); - const envDirPath = findEnvironmentInPath(parentDir); - if (envDirPath) { - updateRequirementsAvailability(envDirPath); - } - }, - null, - context.subscriptions - ); - - // Register the watcher to be disposed when the extension deactivates - context.subscriptions.push(requirementsFileWatcher); - } -} - -export function updateRequirementsAvailability(enviroPath: string) { - const nodeID = makeEnviroNodeID(normalizePath(enviroPath)); - - // the vcast: prefix to allow package.json nodes to control - // when the VectorCAST context menu should be shown - - // Check if this environment has requirements - const parentDir = path.dirname(enviroPath); - const enviroNameWithExt = path.basename(enviroPath); - // remove ".env" if present - const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` - ); - - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - - const hasRequirementsFiles = - fs.existsSync(csvPath) || fs.existsSync(xlsxPath); - - if (hasRequirementsFiles) { - // Add this environment to the list if not already present - if (!existingEnvs.includes(nodeID)) { - const updatedEnvs = [...existingEnvs, nodeID]; - vscode.commands.executeCommand( - "setContext", - "vectorcastTestExplorer.vcastRequirementsAvailable", - updatedEnvs - ); - existingEnvs = updatedEnvs; - } - } else { - // Remove this environment from the list if present - const updatedEnvs = existingEnvs.filter((env) => env !== nodeID); - vscode.commands.executeCommand( - "setContext", - "vectorcastTestExplorer.vcastRequirementsAvailable", - updatedEnvs - ); - existingEnvs = updatedEnvs; - } -} - -export function getAutoreqExecutableDirectory( - context: vscode.ExtensionContext -): vscode.Uri | undefined { - const pathHasAllExecutables = (dirPath: string): boolean => { - return NECCESSARY_REQS2X_EXECUTABLES.every((exe) => - fs.existsSync( - vscode.Uri.joinPath(vscode.Uri.file(dirPath), exeFilename(exe)).fsPath - ) - ); - }; - - // Resolve the location of the reqs2x executables according to the following priority: - - // 1. Reqs2X path setting - const config = vscode.workspace.getConfiguration( - "vectorcastTestExplorer.reqs2x" - ); - const installationLocation = config.get("installationLocation"); - - if (installationLocation && pathHasAllExecutables(installationLocation)) { - return vscode.Uri.file(installationLocation); - } - - // 2. VectorCAST installation path setting - if (pathHasAllExecutables(vcastInstallationDirectory)) { - return vscode.Uri.file(vcastInstallationDirectory); - } - - // 3. Search in vsixResourceBasePath - - // We need to check if we are on CI because in that case we have to use an alternate base dir to the resource files - const isCI = process.env.HOME?.startsWith("/github") ?? false; - - // Base dir of the resource files should be here (see run-tests-workflow.yml/Pull latest reqs2tests release) - const vsixResourceBasePath = `${process.env.GITHUB_WORKSPACE}/vsix`; - - // Check existence for debugging reasons - if (!fs.existsSync(vsixResourceBasePath)) { - logCliError( - `VSIX resource folder not found at expected path: ${vsixResourceBasePath}` - ); - } else { - logCliOperation(`Found VSIX resource folder at: ${vsixResourceBasePath}`); - } - - const vsixBaseURI = isCI - ? vscode.Uri.file(vsixResourceBasePath) - : context.extensionUri; - - if (pathHasAllExecutables(vsixBaseURI.fsPath)) { - return vscode.Uri.joinPath(vsixBaseURI, "resources", "distribution"); - } - - return undefined; -} - -/** - * Generate HTML from requirements data - */ -export function generateRequirementsHtml(requirements: any[]): string { - let htmlContent = ` - - - Requirements - - - -

Requirements

- `; - - // Group requirements by function - const requirementsByFunction: Record = {}; - for (const req of requirements) { - const funcName = req.Function || req.Module || "Unknown Function"; - if (!requirementsByFunction[funcName]) { - requirementsByFunction[funcName] = []; - } - requirementsByFunction[funcName].push(req); - } - - // Generate HTML content for each function - for (const [funcName, reqs] of Object.entries(requirementsByFunction)) { - htmlContent += `

${funcName}

`; - for (const req of reqs) { - htmlContent += ` -
-
${req.ID || "No ID"}
-
${ - req.Description || "No Description" - }
-
- `; - } - } - - htmlContent += ""; - return htmlContent; -} - -export interface LLMProviderSettingsResult { - provider: string | null; - env: Record; - missing: string[]; -} - -export function isLLMProviderEnvironmentUsable(): Promise<{ - usable: boolean; - problem: string | null; -}> { - const processEnv = { ...process.env }; - - const gatheredSettings = gatherLLMProviderSettings(); - for (const [k, v] of Object.entries(gatheredSettings.env)) { - if (v) processEnv[k] = v; - } - - if ( - vscode.workspace - .getConfiguration("vectorcastTestExplorer.reqs2x") - .get("modelCompatibilityMode", false) - ) { - processEnv.VCAST_REQS2X_MODEL_COMPATIBILITY_MODE = "1"; - } - - const proc = spawn(LLM2CHECK_EXECUTABLE_PATH, ["--json"], { - env: processEnv, - }); - - return new Promise((resolve) => { - let output = ""; - proc.stdout.on("data", (data) => { - output += data.toString(); - }); - - proc.on("close", () => { - const result = extractJson(output); - if (result && typeof result.usable === "boolean") { - resolve({ usable: result.usable, problem: result.problem || null }); - } else { - console.error(`Failed to parse llm2check output: ${output}`); - resolve({ usable: false, problem: "Failed to parse llm2check output" }); - } - }); - }); -} - -export function gatherLLMProviderSettings(): LLMProviderSettingsResult { - const config = vscode.workspace.getConfiguration("vectorcastTestExplorer"); - - const provider = config.get("reqs2x.provider"); - const baseEnv: Record = {}; - const missing: string[] = []; - - if (!provider) { - missing.push("Provider (reqs2x.provider)"); - return { provider: null, env: baseEnv, missing }; - } - - function need(value: string | undefined, label: string, envVarName: string) { - if (!value) { - missing.push(label); - return; - } - baseEnv[envVarName] = value; - } - - function optional(value: string | undefined, envVarName: string) { - if (value) { - baseEnv[envVarName] = value; - } - } - - if (provider === "azure_openai") { - need( - config.get("reqs2x.azure.baseUrl"), - "Azure Base URL", - "VCAST_REQS2X_AZURE_OPENAI_BASE_URL" - ); - need( - config.get("reqs2x.azure.apiKey"), - "Azure API Key", - "VCAST_REQS2X_AZURE_OPENAI_API_KEY" - ); - need( - config.get("reqs2x.azure.deployment"), - "Azure Deployment", - "VCAST_REQS2X_AZURE_OPENAI_DEPLOYMENT" - ); - need( - config.get("reqs2x.azure.modelName"), - "Azure Model Name", - "VCAST_REQS2X_AZURE_OPENAI_MODEL_NAME" - ); - need( - config.get("reqs2x.azure.apiVersion"), - "Azure API Version", - "VCAST_REQS2X_AZURE_OPENAI_API_VERSION" - ); - optional( - config.get("reqs2x.azure.reasoningModelName"), - "VCAST_REQS2X_REASONING_AZURE_OPENAI_MODEL_NAME" - ); - optional( - config.get("reqs2x.azure.reasoningDeployment"), - "VCAST_REQS2X_REASONING_AZURE_OPENAI_DEPLOYMENT" - ); - } else if (provider === "openai") { - optional( - config.get("reqs2x.openai.baseUrl"), - "VCAST_REQS2X_OPENAI_BASE_URL" - ); - need( - config.get("reqs2x.openai.apiKey"), - "OpenAI API Key", - "VCAST_REQS2X_OPENAI_API_KEY" - ); - need( - config.get("reqs2x.openai.modelName"), - "OpenAI Model Name", - "VCAST_REQS2X_OPENAI_MODEL_NAME" - ); - optional( - config.get("reqs2x.openai.reasoningModelName"), - "VCAST_REQS2X_REASONING_OPENAI_MODEL_NAME" - ); - } else if (provider === "anthropic") { - need( - config.get("reqs2x.anthropic.apiKey"), - "Anthropic API Key", - "VCAST_REQS2X_ANTHROPIC_API_KEY" - ); - need( - config.get("reqs2x.anthropic.modelName"), - "Anthropic Model Name", - "VCAST_REQS2X_ANTHROPIC_MODEL_NAME" - ); - optional( - config.get("reqs2x.anthropic.reasoningModelName"), - "VCAST_REQS2X_REASONING_ANTHROPIC_MODEL_NAME" - ); - } else if (provider === "litellm") { - need( - config.get("reqs2x.litellm.modelName"), - "LiteLLM Model Name", - "VCAST_REQS2X_LITELLM_MODEL_NAME" - ); - optional( - config.get("reqs2x.litellm.reasoningModelName"), - "VCAST_REQS2X_REASONING_LITELLM_MODEL_NAME" - ); - - const litellmProviderEnvVarsString = config.get( - "reqs2x.litellm.providerEnvVars", - "" - ); - const entries = litellmProviderEnvVarsString - .split(",") - .map((pair) => pair.split("=")) - .filter((kv) => kv[0].trim().length); - - if (entries.some((entryValues) => entryValues.length !== 2)) { - missing.push( - "LiteLLM Provider Environment Variables must be KEY=VALUE pairs" - ); - } else { - for (const [key, value] of entries) { - baseEnv[key.trim()] = value.trim(); - } - } - } else if (provider === "azure_apim") { - need( - config.get("reqs2x.azureApim.subscriptionKey"), - "APIM Subscription Key", - "VCAST_REQS2X_AZURE_APIM_SUBSCRIPTION_KEY" - ); - need( - config.get("reqs2x.azureApim.baseUrl"), - "APIM Base URL", - "VCAST_REQS2X_AZURE_APIM_BASE_URL" - ); - need( - config.get("reqs2x.azureApim.modelName"), - "APIM Model Name", - "VCAST_REQS2X_AZURE_APIM_MODEL_NAME" - ); - optional( - config.get("reqs2x.azureApim.apiKey"), - "VCAST_REQS2X_AZURE_APIM_API_KEY" - ); - optional( - config.get("reqs2x.azureApim.reasoningModelName"), - "VCAST_REQS2X_REASONING_AZURE_APIM_MODEL_NAME" - ); - } else if (provider === "openai_at") { - need( - config.get("reqs2x.openaiAt.modelName"), - "OpenAI AT Model Name", - "VCAST_REQS2X_OPENAI_AT_MODEL_NAME" - ); - need( - config.get("reqs2x.openaiAt.modelUrl"), - "OpenAI AT Model URL", - "VCAST_REQS2X_OPENAI_AT_MODEL_URL" - ); - need( - config.get("reqs2x.openaiAt.authUrl"), - "OpenAI AT Auth URL", - "VCAST_REQS2X_OPENAI_AT_AUTH_URL" - ); - need( - config.get("reqs2x.openaiAt.appKey"), - "OpenAI AT App Key", - "VCAST_REQS2X_OPENAI_AT_APP_KEY" - ); - need( - config.get("reqs2x.openaiAt.appSecret"), - "OpenAI AT App Secret", - "VCAST_REQS2X_OPENAI_AT_APP_SECRET" - ); - optional( - config.get("reqs2x.openaiAt.reasoningModelName"), - "VCAST_REQS2X_REASONING_OPENAI_AT_MODEL_NAME" - ); - } else { - missing.push("Unsupported provider value"); - } - - return { provider, env: baseEnv, missing }; -} - -export async function performLLMProviderUsableCheck(): Promise { - const { usable, problem } = await isLLMProviderEnvironmentUsable(); - - const gatheredSettings = gatherLLMProviderSettings(); - - if (!usable) { - // TODO: Error based on what the problem was i.e. missing stuff or something else - const causedByMissing = problem?.includes( - "No provider configuration found" - ); - - let errorMessage: string; - - if (causedByMissing) { - errorMessage = `Required information to run Reqs2X with currently selected LLM provider (${gatheredSettings.provider}) is missing: ${gatheredSettings.missing.join(", ")}`; - } else { - errorMessage = `The current LLM provider settings for Reqs2X (either set in the extension or in the environment) are not usable: ${problem}`; - } - - vscode.window - .showErrorMessage(errorMessage, "Open Settings") - .then((choice) => { - if (choice === "Open Settings") { - showSettings(); - } - }); - - return false; - } - - return true; -} - -export async function createProcessEnvironment(): Promise { - const processEnv = { ...process.env }; - - // Setup correct VectorCAST directory variable - processEnv.VSCODE_VECTORCAST_DIR = vcastInstallationDirectory; - - // Setup LLM provider settings - const gatheredSettings = gatherLLMProviderSettings(); - - for (const [k, v] of Object.entries(gatheredSettings.env)) { - if (v) processEnv[k] = v; - } - - // Add non-provider specific settings (language, debug) here - const config = vscode.workspace.getConfiguration( - "vectorcastTestExplorer.reqs2x" - ); - const languageCode = config.get("generationLanguage", "en"); - processEnv.VCAST_REQS2X_RESPONSE_LANGUAGE = languageCode; - - if (config.get("outputDebugInfo", false)) { - processEnv.VCAST_REQS2X_LOG_LEVEL = "debug"; - } - - if (config.get("modelCompatibilityMode", false)) { - processEnv.VCAST_REQS2X_MODEL_COMPATIBILITY_MODE = "1"; - } - - // Return the constructed environment - return processEnv; -} - -export async function spawnWithVcastEnv( - command: string, - args: string[], - options: any = {} -): Promise { - const checkSuccessful = await performLLMProviderUsableCheck(); // Check if the LLM provider settings are usable - - if (!checkSuccessful) { - throw new Error("LLM provider settings are not usable"); - } - - const env = await createProcessEnvironment(); - return spawn(command, args, { ...options, env }); -} - -export function expandEnvVars(inputPath: string): string { - return inputPath.replace(/\$\(([^)]+)\)/g, (match, varName) => { - const value = process.env[varName]; - - if (!value) { - vscode.window.showWarningMessage( - `Environment variable "${varName}" in VCAST_REPOSITORY is not defined.` - ); - // leave it as-is - return match; - } - - return value; - }); -} diff --git a/src/requirements/rgwIo.ts b/src/requirements/rgwIo.ts new file mode 100644 index 00000000..3ec4a80b --- /dev/null +++ b/src/requirements/rgwIo.ts @@ -0,0 +1,321 @@ +import * as os from "node:os"; +import { + expandEnvVars, + findRelevantRequirementGateway, + RGW_INNER_DIR, +} from "./rgwPath"; +import { runReqs2xTool } from "./processRunner"; +import { PANREQ_EXECUTABLE_PATH } from "./requirementsOperations"; + +const path = require("path"); +const fs = require("fs"); + +// ---------- Types ----------------------------------------------------------- + +export interface RGWRequirement { + id: string; + title: string; + description: string; + last_modified?: string; + [key: string]: any; +} + +// requirements.json: { "": { "": RGWRequirement } } +export type RGWRequirementsFile = Record< + string, + Record +>; + +export interface RGWTraceabilityEntry { + unit: string | null; + function: string | null; + lines: number[] | null; +} + +// traceability.json: { "": RGWTraceabilityEntry } +export type RGWTraceabilityFile = Record; + +export interface RGWOrigin { + generated_by_reqs2x: boolean; +} + +export interface RGWFileMtimes { + requirements: number; + traceability: number; + origin: number; +} + +export interface RGWBundle { + gatewayPath: string; + origin: RGWOrigin; + requirements: RGWRequirementsFile; + traceability: RGWTraceabilityFile; + mtimes: RGWFileMtimes; +} + +export class RGWStaleWriteError extends Error { + constructor(public readonly file: "requirements" | "traceability") { + super( + `RGW ${file} file changed on disk since it was loaded; refusing to overwrite.` + ); + this.name = "RGWStaleWriteError"; + } +} + +// ---------- Path / file helpers -------------------------------------------- + +function rgwFilePaths(gatewayPath: string) { + const inner = path.join(gatewayPath, RGW_INNER_DIR); + return { + inner, + requirements: path.join(inner, "requirements.json"), + traceability: path.join(inner, "traceability.json"), + origin: path.join(inner, "origin.json"), + settings: path.join(inner, "settings.json"), + }; +} + +function mtimeMsOrZero(filePath: string): number { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } +} + +function writeAtomic(target: string, contents: string): void { + const tmp = `${target}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, contents, "utf-8"); + fs.renameSync(tmp, target); +} + +/** + * Read the source CSV path out of `settings.json`. Returns null when the + * gateway isn't CSV-backed (we don't yet know how to round-trip other + * source types) or when the field is missing. + */ +function readCsvSourcePath(gatewayPath: string): string | null { + const settingsPath = rgwFilePaths(gatewayPath).settings; + if (!fs.existsSync(settingsPath)) return null; + try { + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + if (settings.current_gateway !== "csv") return null; + const csvPath = settings.csv?.csv_path; + return typeof csvPath === "string" && csvPath.length > 0 ? csvPath : null; + } catch { + return null; + } +} + +// ---------- Read ------------------------------------------------------------ + +/** + * Read the RGW bundle from `/requirements_gateway/`. Missing + * traceability/origin files are tolerated (treated as empty / external-source). + * Returns null when no gateway is configured, the gateway directory is absent, + * or there are no requirements yet to display. + */ +export function readRGWBundle(enviroPath: string): RGWBundle | null { + const rawGatewayPath = findRelevantRequirementGateway(enviroPath); + if (!rawGatewayPath) return null; + + const gatewayPath = expandEnvVars(rawGatewayPath); + if (!fs.existsSync(gatewayPath)) return null; + + const files = rgwFilePaths(gatewayPath); + if (!fs.existsSync(files.requirements)) return null; + + const requirements: RGWRequirementsFile = JSON.parse( + fs.readFileSync(files.requirements, "utf-8") + ); + + const traceability: RGWTraceabilityFile = fs.existsSync(files.traceability) + ? JSON.parse(fs.readFileSync(files.traceability, "utf-8")) + : {}; + + let origin: RGWOrigin = { generated_by_reqs2x: false }; + if (fs.existsSync(files.origin)) { + try { + const parsed = JSON.parse(fs.readFileSync(files.origin, "utf-8")); + origin = { generated_by_reqs2x: parsed.generated_by_reqs2x === true }; + } catch { + // malformed origin.json → treat as external/read-only + } + } + + return { + gatewayPath, + origin, + requirements, + traceability, + mtimes: { + requirements: mtimeMsOrZero(files.requirements), + traceability: mtimeMsOrZero(files.traceability), + origin: mtimeMsOrZero(files.origin), + }, + }; +} + +// ---------- Write ----------------------------------------------------------- + +/** + * Persist edits back into the RGW. + * + * Requirement bodies travel via the source CSV (kept in sync as the durable + * external artifact): we serialize the updates to a temp JSON in panreq's + * input shape and run panreq to overwrite the CSV. We then mirror the same + * data into `requirements.json` ourselves — `clicast RGw Import` only adds + * *new* requirements (it deliberately doesn't overwrite existing bodies, and + * there's no `Reimport` subcommand). + * + * Traceability is written directly. Done last so a panreq failure leaves + * on-disk state consistent (no fresh trace pointing at stale requirements). + * + * Throws RGWStaleWriteError if either file's mtime has advanced since the + * bundle was loaded. + */ +export async function writeRGWBundle( + enviroPath: string, + gatewayPath: string, + updates: { + requirements: RGWRequirementsFile; + traceability: RGWTraceabilityFile; + }, + expected: Pick +): Promise { + const files = rgwFilePaths(gatewayPath); + + const currentReqMtime = mtimeMsOrZero(files.requirements); + if (currentReqMtime !== expected.requirements) { + throw new RGWStaleWriteError("requirements"); + } + const currentTraceMtime = mtimeMsOrZero(files.traceability); + if (currentTraceMtime !== expected.traceability) { + throw new RGWStaleWriteError("traceability"); + } + + const csvPath = readCsvSourcePath(gatewayPath); + if (!csvPath) { + throw new Error( + "Cannot save: this RGW's source is not a CSV (or settings.json is missing). Only CSV-backed RGWs are editable." + ); + } + + // 1. Hand panreq the requirements as a temp JSON; have it write the CSV. + // panreq's JSON *input* shape is a flat list, each entry carrying `key`, + // `id`, `title`, `description` (plus any extras). This is asymmetric with + // panreq's JSON *output* shape (nested `{bucket: {id: req}}`), so we + // flatten + project here. The inner-mapping key is panreq's `key`, + // distinct from `id` in general. + const flatList: Array> = []; + for (const bucket of Object.values(updates.requirements)) { + for (const [reqKey, req] of Object.entries(bucket)) { + const trace = updates.traceability[reqKey]; + flatList.push({ + ...req, + key: reqKey, + id: req.id ?? reqKey, + title: req.title ?? "", + description: req.description ?? "", + unit: trace?.unit ?? null, + function: trace?.function ?? null, + }); + } + } + + const tmpJson = path.join( + os.tmpdir(), + `vcast-reqs-${process.pid}-${Date.now()}.json` + ); + fs.writeFileSync(tmpJson, JSON.stringify(flatList, null, 2), "utf-8"); + + try { + await runReqs2xTool({ + exe: PANREQ_EXECUTABLE_PATH, + args: [ + tmpJson, + csvPath, + "--target-format", + "csv", + "--target-env", + `${enviroPath}.env`, + ], + }); + } finally { + try { + fs.unlinkSync(tmpJson); + } catch { + // best-effort cleanup + } + } + + // 2. Mirror the same data into requirements.json. Bucket key is normalized + // to the current csv_path so consumers see a consistent source path. + const consolidated: Record = {}; + for (const bucket of Object.values(updates.requirements)) { + for (const [reqKey, req] of Object.entries(bucket)) { + consolidated[reqKey] = req; + } + } + writeAtomic( + files.requirements, + JSON.stringify({ [`[CSV] [${csvPath}]`]: consolidated }, null, 4) + ); + + // 3. Write traceability.json directly. + writeAtomic( + files.traceability, + JSON.stringify(updates.traceability, null, 4) + ); + + return { + requirements: mtimeMsOrZero(files.requirements), + traceability: mtimeMsOrZero(files.traceability), + origin: mtimeMsOrZero(files.origin), + }; +} + +// ---------- Infer traceability --------------------------------------------- + +/** + * Run panreq with `--infer-traceability` against the RGW's source CSV. The + * tool uses an LLM to populate unit/function for each requirement and writes + * the result back into the RGW. Wrapped in a cancellable progress + * notification (same UX as code2reqs/reqs2tests). + * + * Returns the freshly-read bundle on success, null if the user cancelled. + */ +export async function inferTraceability( + enviroPath: string, + gatewayPath: string +): Promise { + const csvPath = readCsvSourcePath(gatewayPath); + if (!csvPath) { + throw new Error( + "Cannot infer traceability: this RGW's source is not a CSV (or settings.json is missing)." + ); + } + + const enviroName = path.basename(enviroPath); + const result = await runReqs2xTool({ + exe: PANREQ_EXECUTABLE_PATH, + args: [ + csvPath, + gatewayPath, + "--target-format", + "rgw", + "--infer-traceability", + "--target-env", + `${enviroPath}.env`, + "--json-events", + ], + llm: true, + progress: { + title: `Inferring Traceability for ${enviroName}`, + logPrefix: "panreq", + }, + }); + + if (result.cancelled) return null; + return readRGWBundle(enviroPath); +} diff --git a/src/requirements/rgwPath.ts b/src/requirements/rgwPath.ts new file mode 100644 index 00000000..32510587 --- /dev/null +++ b/src/requirements/rgwPath.ts @@ -0,0 +1,106 @@ +import * as vscode from "vscode"; + +const path = require("path"); +const fs = require("fs"); + +export const DEFAULT_RGW_SUBDIR = "rgw"; + +// The RGW directory contains a `requirements_gateway/` subdirectory which +// holds the JSON files we read and write. code2reqs/panreq own this layout. +export const RGW_INNER_DIR = "requirements_gateway"; + +/** + * Expand $(VAR) tokens in the configured VCAST_REPOSITORY path against + * process.env. Unknown variables are left as-is and a one-time warning is + * surfaced to the user — better to leave the literal in place than silently + * resolve to "". + */ +export function expandEnvVars(inputPath: string): string { + return inputPath.replace(/\$\(([^)]+)\)/g, (match, varName) => { + const value = process.env[varName]; + + if (!value) { + vscode.window.showWarningMessage( + `Environment variable "${varName}" in VCAST_REPOSITORY is not defined.` + ); + return match; + } + + return value; + }); +} + +/** + * Read VCAST_REPOSITORY out of the CCAST_.CFG adjacent to `enviroPath`. + * Returns the *raw* (unexpanded) configured form so callers that want to + * write the value back to CCAST_.CFG can preserve the user's $(VAR) syntax. + * Returns null if the config is missing, the line isn't set, or the resolved + * directory doesn't exist. + */ +export function findRelevantRequirementGateway( + enviroPath: string +): string | null { + const parentDir = path.dirname(enviroPath); + const configPath = path.join(parentDir, "CCAST_.CFG"); + + const configContent = fs.readFileSync(configPath, "utf-8"); + const gatewayMatch = configContent.match(/VCAST_REPOSITORY:\s*(.+)\s*/); + if (gatewayMatch == null) return null; + + const rawGatewayPath = gatewayMatch[1].trim(); + const gatewayPath = expandEnvVars(rawGatewayPath); + + if (!fs.existsSync(gatewayPath)) return null; + return rawGatewayPath; +} + +/** + * Default on-disk location for a freshly created RGW. Used when the user + * runs Generate Requirements on an environment that has no VCAST_REPOSITORY + * configured yet. + */ +export function defaultRequirementGatewayPath(enviroPath: string): string { + const parentDir = path.dirname(enviroPath); + const enviroName = path.basename(enviroPath).replace(/\.env$/, ""); + return path.join(parentDir, `reqs-${enviroName}`, DEFAULT_RGW_SUBDIR); +} + +/** + * Set VCAST_REPOSITORY in the CCAST_.CFG adjacent to `enviroPath`. Replaces + * any existing line, otherwise appends a new one. + */ +export function setVcastRepositoryInConfig( + enviroPath: string, + gatewayPath: string +): void { + const parentDir = path.dirname(enviroPath); + const configPath = path.join(parentDir, "CCAST_.CFG"); + const line = `VCAST_REPOSITORY: ${gatewayPath}`; + + let content = ""; + if (fs.existsSync(configPath)) { + content = fs.readFileSync(configPath, "utf-8"); + } + + if (/^VCAST_REPOSITORY:.*$/m.test(content)) { + content = content.replace(/^VCAST_REPOSITORY:.*$/m, line); + } else { + if (content.length > 0 && !content.endsWith("\n")) content += "\n"; + content += `${line}\n`; + } + + fs.writeFileSync(configPath, content, "utf-8"); +} + +/** Remove the VCAST_REPOSITORY line from CCAST_.CFG if present. */ +export function clearVcastRepositoryInConfig(enviroPath: string): void { + const parentDir = path.dirname(enviroPath); + const configPath = path.join(parentDir, "CCAST_.CFG"); + if (!fs.existsSync(configPath)) return; + + const content = fs.readFileSync(configPath, "utf-8"); + const stripped = content.replace(/^VCAST_REPOSITORY:.*\r?\n?/m, ""); + if (stripped !== content) { + fs.writeFileSync(configPath, stripped, "utf-8"); + } +} diff --git a/src/requirements/webview/messages.ts b/src/requirements/webview/messages.ts new file mode 100644 index 00000000..06663ec1 --- /dev/null +++ b/src/requirements/webview/messages.ts @@ -0,0 +1,65 @@ +import type { + RGWFileMtimes, + RGWRequirementsFile, + RGWTraceabilityFile, +} from "../rgwIo"; + +// Wire protocol for the requirements webview ↔ extension. +// Discriminated unions on `type`. The webview script and extension handler +// both branch on these — keep them in sync. + +// --------- Webview → Extension --------------------------------------------- + +export interface SaveMessage { + type: "save"; + updates: { + requirements: RGWRequirementsFile; + traceability: RGWTraceabilityFile; + }; + expectedMtimes: Pick; +} + +export interface InferTraceabilityMessage { + type: "infer-traceability"; +} + +export type FromWebview = SaveMessage | InferTraceabilityMessage; + +// --------- Extension → Webview --------------------------------------------- + +interface BundleRefreshPayload { + mtimes: RGWFileMtimes; + requirements: RGWRequirementsFile; + traceability: RGWTraceabilityFile; + /** Re-rendered cards section (regrouped by current trace.function/unit). */ + body: string; +} + +export interface SavedMessage extends BundleRefreshPayload { + type: "saved"; +} + +export interface InferredMessage extends BundleRefreshPayload { + type: "inferred"; +} + +export interface SaveFailedMessage { + type: "save-failed"; + message: string; +} + +export interface InferFailedMessage { + type: "infer-failed"; + message: string; +} + +export interface InferCancelledMessage { + type: "infer-cancelled"; +} + +export type ToWebview = + | SavedMessage + | InferredMessage + | SaveFailedMessage + | InferFailedMessage + | InferCancelledMessage; diff --git a/src/requirements/webview/template.ts b/src/requirements/webview/template.ts new file mode 100644 index 00000000..0fb3eedd --- /dev/null +++ b/src/requirements/webview/template.ts @@ -0,0 +1,256 @@ +import * as vscode from "vscode"; +import type { + RGWBundle, + RGWRequirement, + RGWTraceabilityEntry, +} from "../rgwIo"; + +const path = require("path"); + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Render the grouped headings + cards for a bundle. Exported so the extension + * can call it again after save/infer to refresh the headings — they reflect + * the current `function || unit || source` grouping, which can change as the + * user edits traceability. + */ +export function renderRequirementsBody( + bundle: RGWBundle, + unitsToFunctions: Record | null +): string { + const editable = bundle.origin.generated_by_reqs2x; + + const flat: Array<{ + source: string; + id: string; + req: RGWRequirement; + trace: RGWTraceabilityEntry; + }> = []; + for (const [source, bucket] of Object.entries(bundle.requirements)) { + for (const [id, req] of Object.entries(bucket)) { + flat.push({ + source, + id, + req, + trace: bundle.traceability[id] ?? { + unit: null, + function: null, + lines: null, + }, + }); + } + } + + // Group by function, falling back to unit, then source. + const groups: Record = {}; + for (const entry of flat) { + const key = + entry.trace.function || entry.trace.unit || entry.source || "Unknown"; + if (!groups[key]) groups[key] = []; + groups[key].push(entry); + } + + let body = ""; + for (const [group, entries] of Object.entries(groups)) { + body += `

${escapeHtml(group)}

`; + for (const entry of entries) { + body += renderCard(entry, editable, unitsToFunctions); + } + } + return body; +} + +/** + * Build the full webview HTML. Loads CSS + JS from the `webviews/` media + * folder via webview-relative URIs (a generated nonce gates ` + + +`; +} + +function renderCard( + entry: { + source: string; + id: string; + req: RGWRequirement; + trace: RGWTraceabilityEntry; + }, + editable: boolean, + unitsToFunctions: Record | null +): string { + const idAttrs = `data-req-id="${escapeHtml(entry.id)}"`; + const reqDis = editable ? "" : "disabled"; + const lastMod = entry.req.last_modified ?? ""; + + return ` +
+
+
${escapeHtml(entry.id)}
+
${lastMod ? `modified: ${escapeHtml(lastMod)} · ` : ""}source: ${escapeHtml(entry.source)}
+
+
+ + +
+
+ + +
+
+
+ + ${renderUnitField(entry, idAttrs, unitsToFunctions)} +
+
+ + ${renderFunctionField(entry, idAttrs, unitsToFunctions)} +
+
+
+ `; +} + +function renderUnitField( + entry: { trace: RGWTraceabilityEntry }, + idAttrs: string, + unitsToFunctions: Record | null +): string { + const current = entry.trace.unit ?? ""; + if (!unitsToFunctions) { + return ``; + } + const units = Object.keys(unitsToFunctions); + const opts = [ + ``, + ]; + let found = current === ""; + for (const u of units) { + const sel = u === current ? " selected" : ""; + if (u === current) found = true; + opts.push(``); + } + if (!found) { + opts.push( + `` + ); + } + return ``; +} + +function renderFunctionField( + entry: { trace: RGWTraceabilityEntry }, + idAttrs: string, + unitsToFunctions: Record | null +): string { + const current = entry.trace.function ?? ""; + if (!unitsToFunctions) { + return ``; + } + const unit = entry.trace.unit ?? ""; + const fns = unit && unitsToFunctions[unit] ? unitsToFunctions[unit] : []; + const opts = [ + ``, + ]; + let found = current === ""; + for (const f of fns) { + const sel = f === current ? " selected" : ""; + if (f === current) found = true; + opts.push(``); + } + if (!found) { + opts.push( + `` + ); + } + return ``; +} + +/** + * Resolve the on-disk webview-resources directory. Mirrors the manage + * webviews' resolution: normal install path first, then an E2E fallback + * stripping the test-harness suffix. + */ +export function resolveRequirementsWebviewBase( + context: vscode.ExtensionContext +): string { + const fs = require("fs"); + const normal = path.join( + context.extensionPath, + "src", + "requirements", + "webviews" + ); + if (fs.existsSync(normal)) return normal; + + const marker = path.join("tests", "internal", "e2e", "test", "extension"); + const idx = context.extensionPath.indexOf(marker); + if (idx !== -1) { + const repoRoot = context.extensionPath.slice(0, idx); + const fallback = path.join(repoRoot, "src", "requirements", "webviews"); + if (fs.existsSync(fallback)) return fallback; + } + + throw new Error( + `Could not resolve requirements webview base. Tried:\n ${normal}` + ); +} diff --git a/src/requirements/webviews/css/requirements.css b/src/requirements/webviews/css/requirements.css new file mode 100644 index 00000000..e71f96a8 --- /dev/null +++ b/src/requirements/webviews/css/requirements.css @@ -0,0 +1,137 @@ +body { + font-family: -apple-system, Segoe UI, sans-serif; + margin: 20px; + padding-bottom: 80px; +} + +h1 { margin-top: 0; } +h2 { + margin-top: 24px; + border-bottom: 1px solid var(--vscode-panel-border, #ddd); + padding-bottom: 4px; +} + +.banner { + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 18px; + font-size: 0.95em; +} +.banner-editable { + background: var(--vscode-inputValidation-infoBackground, #eef5ff); + border: 1px solid var(--vscode-inputValidation-infoBorder, #9cc); +} +.banner-readonly { + background: var(--vscode-inputValidation-warningBackground, #fff5e6); + border: 1px solid var(--vscode-inputValidation-warningBorder, #d89); +} + +.req { + border: 1px solid var(--vscode-panel-border, #ddd); + border-radius: 6px; + padding: 12px 14px; + margin: 10px 0; + background: var(--vscode-editor-background, #fff); +} +.req-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} +.req-id { + font-weight: 600; + color: var(--vscode-textLink-foreground, #2980b9); +} +.req-meta { + font-size: 0.8em; + color: var(--vscode-descriptionForeground, #888); +} + +.field { margin-top: 8px; } +.field label { + display: block; + font-size: 0.8em; + color: var(--vscode-descriptionForeground, #666); + margin-bottom: 2px; +} +.field input, +.field textarea, +.field select { + width: 100%; + box-sizing: border-box; + background: var(--vscode-input-background, #fff); + color: var(--vscode-input-foreground, #000); + border: 1px solid var(--vscode-input-border, #ccc); + padding: 4px 6px; + font: inherit; +} +.field textarea { + resize: vertical; + min-height: 48px; +} +.field input:disabled, +.field textarea:disabled, +.field select:disabled { + opacity: 0.7; + cursor: default; +} +.trace-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.dirty { + outline: 2px solid var(--vscode-inputValidation-infoBorder, #9cc); +} + +#rgw-pill { + position: fixed; + bottom: 16px; + left: 16px; + max-width: 50vw; + padding: 5px 12px; + font-size: 0.8em; + color: var(--vscode-descriptionForeground, #666); + background: var(--vscode-editorWidget-background, var(--vscode-editor-background, #f3f3f3)); + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #ddd)); + border-radius: 999px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.85; + z-index: 100; +} + +#save-toolbar { + position: fixed; + bottom: 16px; + right: 16px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background, #fff)); + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #ddd)); + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); + display: flex; + gap: 12px; + align-items: center; + z-index: 100; +} + +#save-btn, +#infer-btn { + padding: 8px 18px; + font-size: 1em; + background: var(--vscode-button-background, #0078d4); + color: var(--vscode-button-foreground, #fff); + border: 0; + border-radius: 4px; + cursor: pointer; + font-family: inherit; +} +#save-btn[disabled], +#infer-btn[disabled] { + opacity: 0.5; + cursor: default; +} diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js new file mode 100644 index 00000000..5920742b --- /dev/null +++ b/src/requirements/webviews/webviewScripts/requirements.js @@ -0,0 +1,161 @@ +// Requirements editor webview script. +// +// State (`window.__rgwState`) is injected as a JSON blob by the extension at +// load time and refreshed on `saved` / `inferred` messages. Listeners are +// delegated on #reqs-body so they survive the innerHTML swap that happens on +// regrouping. +// +// Wire protocol (see src/requirements/webview/messages.ts): +// FROM webview: { type: "save" | "infer-traceability" } +// TO webview: { type: "saved" | "inferred" | "save-failed" | "infer-failed" | "infer-cancelled" } + +(function () { + const vscode = acquireVsCodeApi(); + const state = window.__rgwState; + // reqId -> { req: {title?, description?}, trace: {unit?, function?} } + const dirty = new Map(); + + const saveBtn = document.getElementById("save-btn"); + const inferBtn = document.getElementById("infer-btn"); + const reqsBody = document.getElementById("reqs-body"); + + function setDirty(reqId, scope, field, value) { + if (!dirty.has(reqId)) dirty.set(reqId, { req: {}, trace: {} }); + dirty.get(reqId)[scope][field] = value; + saveBtn.disabled = dirty.size === 0; + inferBtn.disabled = dirty.size > 0; // don't clobber unsaved local edits + } + + function refreshFunctionOptions(card, selectedUnit, currentFunction) { + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (!fnSelect || fnSelect.tagName !== "SELECT") return; + const map = state.unitsToFunctions || {}; + const fns = selectedUnit && map[selectedUnit] ? map[selectedUnit] : []; + const desired = currentFunction != null ? String(currentFunction) : ""; + const opts = ['']; + let foundDesired = !desired; + for (const fn of fns) { + const sel = fn === desired ? " selected" : ""; + if (fn === desired) foundDesired = true; + opts.push('"); + } + if (!foundDesired) { + opts.push( + '" + ); + } + fnSelect.innerHTML = opts.join(""); + } + + function handleFieldChange(el) { + const reqId = el.dataset.reqId; + const scope = el.dataset.scope; + const field = el.dataset.field; + let value = el.value; + if (field === "unit" || field === "function") { + value = value === "" ? null : value; + } + setDirty(reqId, scope, field, value); + el.classList.add("dirty"); + + if (field === "unit") { + const card = el.closest(".req"); + const dirtyEntry = dirty.get(reqId); + const currentFn = + dirtyEntry && dirtyEntry.trace && "function" in dirtyEntry.trace + ? dirtyEntry.trace.function + : (state.traceability[reqId] && state.traceability[reqId].function) || + null; + refreshFunctionOptions(card, value, currentFn); + // function dropdown's value may have changed implicitly; record it + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (fnSelect) { + const newFn = fnSelect.value === "" ? null : fnSelect.value; + setDirty(reqId, "trace", "function", newFn); + fnSelect.classList.add("dirty"); + } + } + } + + // Event delegation on the cards container so listeners survive innerHTML + // replacement when the body is regrouped after save/infer. + reqsBody.addEventListener("input", (e) => { + const t = e.target; + if (t && t.matches && t.matches("[data-field]") && t.tagName !== "SELECT") { + handleFieldChange(t); + } + }); + reqsBody.addEventListener("change", (e) => { + const t = e.target; + if (t && t.matches && t.matches("[data-field]") && t.tagName === "SELECT") { + handleFieldChange(t); + } + }); + + saveBtn.addEventListener("click", () => { + const updates = { + requirements: JSON.parse(JSON.stringify(state.requirements)), + traceability: JSON.parse(JSON.stringify(state.traceability)), + }; + for (const [reqId, patch] of dirty.entries()) { + if (state.editable && Object.keys(patch.req).length > 0) { + for (const bucket of Object.keys(updates.requirements)) { + if (updates.requirements[bucket][reqId]) { + Object.assign(updates.requirements[bucket][reqId], patch.req); + break; + } + } + } + if (Object.keys(patch.trace).length > 0) { + const existing = updates.traceability[reqId] || { + unit: null, + function: null, + lines: null, + }; + updates.traceability[reqId] = { ...existing, ...patch.trace }; + } + } + saveBtn.disabled = true; + inferBtn.disabled = true; + vscode.postMessage({ + type: "save", + updates, + expectedMtimes: state.mtimes, + }); + }); + + inferBtn.addEventListener("click", () => { + inferBtn.disabled = true; + saveBtn.disabled = true; + vscode.postMessage({ type: "infer-traceability" }); + }); + + function applyRefreshedBundle(msg) { + dirty.clear(); + state.mtimes = msg.mtimes; + state.requirements = msg.requirements; + state.traceability = msg.traceability; + if (typeof msg.body === "string") { + reqsBody.innerHTML = msg.body; + } + saveBtn.disabled = true; + inferBtn.disabled = false; + } + + window.addEventListener("message", (event) => { + const msg = event.data; + if (msg.type === "saved" || msg.type === "inferred") { + applyRefreshedBundle(msg); + } else if (msg.type === "save-failed") { + saveBtn.disabled = false; + inferBtn.disabled = dirty.size > 0; + } else if (msg.type === "infer-failed" || msg.type === "infer-cancelled") { + saveBtn.disabled = dirty.size === 0; + inferBtn.disabled = dirty.size > 0; + } + }); +})(); diff --git a/src/testPane.ts b/src/testPane.ts index fe8dee68..5e601005 100644 --- a/src/testPane.ts +++ b/src/testPane.ts @@ -833,6 +833,16 @@ async function processSingleEnvData( ); } + // Refresh the `vcastRequirementsAvailable` context key now that the env's + // descendants exist in the cache — without this, sub-node menu enablements + // (Generate Tests from Requirements) stay greyed even when the env has + // requirements. Lazy require to avoid an import cycle through + // requirements → testPane. + const { + updateRequirementsAvailability, + } = require("./requirements/availability") as typeof import("./requirements/availability"); + updateRequirementsAvailability(enviroData.buildDirectory); + // Instead of grouping, add the environment directly. globalController.items.delete(enviroNode.id); if (parentNode) { From 352fc29ed6dbf17dbe366d7065133da0187e682a Mon Sep 17 00:00:00 2001 From: Patrick Bareiss Date: Tue, 28 Apr 2026 11:25:17 +0200 Subject: [PATCH 02/14] Cleanups around requirements editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from a self-review of the prior commit. No behavior change. - Centralize the "external-source RGWs lock requirement bodies" rule. New RequirementsEditPolicy + editPolicyFor / applyEditPolicy in rgwIo.ts. Template, webview script, and the extension save handler now all consult the single helper instead of inlining `bundle.origin.generated_by_reqs2x`. - Drop the dead GENERATE_REQUIREMENTS_ENABLED flag and the vectorcastTestExplorer.generateRequirementsEnabled context key. Always evaluated to true; `when` clauses simplified accordingly. - Harden the inline state injection. The serialized RGW state used to land in via JSON.stringify alone — a requirement title containing `` (or U+2028/U+2029) could break out of the script block. New serializeStateForScriptTag escapes those so the literal stays contained. - Collapse renderUnitField + renderFunctionField into one renderTraceField. ~40 lines of near-duplicated dropdown rendering removed. - Move getNonce + resolveWebviewBase to a shared src/webviewUtils.ts. resolveWebviewBase now takes ...subpath segments so manage and requirements both call the same helper. The dedicated resolveRequirementsWebviewBase is gone. --- package.json | 6 +- src/extension.ts | 50 +++--- src/manage/manageSrc/manageUtils.ts | 68 +-------- src/requirements/requirementsOperations.ts | 2 - src/requirements/rgwIo.ts | 34 +++++ src/requirements/webview/template.ts | 142 ++++++++---------- .../webviews/webviewScripts/requirements.js | 2 +- src/webviewUtils.ts | 55 +++++++ 8 files changed, 173 insertions(+), 186 deletions(-) create mode 100644 src/webviewUtils.ts diff --git a/package.json b/package.json index dcea4a1e..6f2277d9 100644 --- a/package.json +++ b/package.json @@ -1172,12 +1172,12 @@ { "command": "vectorcastTestExplorer.generateRequirements", "group": "vcast@8", - "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.showRequirements", "group": "vcast@9", - "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.generateTestsFromRequirements", @@ -1187,7 +1187,7 @@ { "command": "vectorcastTestExplorer.removeRequirements", "group": "vcast@9", - "when": "testId =~ /^vcast:[^|]+$/ && vectorcastTestExplorer.reqs2xFeatureEnabled && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.viewResults", diff --git a/src/extension.ts b/src/extension.ts index 4397e3a3..a2552fc9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -131,6 +131,7 @@ import { import { updateRequirementsAvailability } from "./requirements/availability"; import { performLLMProviderUsableCheck } from "./requirements/llmProvider"; import { + applyEditPolicy, inferTraceability, readRGWBundle, RGWBundle, @@ -140,7 +141,6 @@ import { import { generateRequirementsHtml, renderRequirementsBody, - resolveRequirementsWebviewBase, } from "./requirements/webview/template"; import type { FromWebview, @@ -148,7 +148,6 @@ import type { } from "./requirements/webview/messages"; import { - GENERATE_REQUIREMENTS_ENABLED, generateRequirements, generateTestsFromRequirements, initializeReqs2X, @@ -177,10 +176,9 @@ import { import fs = require("fs"); import { compilerTagList, - getNonce, - resolveWebviewBase, setCompilerList, } from "./manage/manageSrc/manageUtils"; +import { getNonce, resolveWebviewBase } from "./webviewUtils"; const path = require("path"); @@ -325,13 +323,6 @@ async function activationLogic(context: vscode.ExtensionContext) { // start the language server activateLanguageServerClient(context); - // Enable/disable the requirement generation component of the extension - vscode.commands.executeCommand( - "setContext", - "vectorcastTestExplorer.generateRequirementsEnabled", - GENERATE_REQUIREMENTS_ENABLED - ); - // Initialize requirements availability for all environments if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { const envPaths = await getEnvironmentListIncludingUnbuilt( @@ -903,7 +894,7 @@ function configureExtension(context: vscode.ExtensionContext) { const addTestsuiteToCompiler = vscode.commands.registerCommand( "vectorcastTestExplorer.addTestsuiteToCompiler", async (node: any) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "addTestsuiteToCompiler", "Add Testsuite to Compiler", @@ -954,7 +945,7 @@ function configureExtension(context: vscode.ExtensionContext) { context: vscode.ExtensionContext, panel: vscode.WebviewPanel ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); // on-disk locations const cssOnDisk = vscode.Uri.file( @@ -1346,7 +1337,7 @@ function configureExtension(context: vscode.ExtensionContext) { // ignore; webview will fall back to free-text inputs } - const webviewBaseDir = resolveRequirementsWebviewBase(context); + const webviewBaseDir = resolveWebviewBase(context, "requirements", "webviews"); const panel = vscode.window.createWebviewPanel( "requirementsReport", "Requirements Report", @@ -1388,14 +1379,11 @@ function configureExtension(context: vscode.ExtensionContext) { async (msg: FromWebview) => { if (msg?.type === "save") { try { - // External-source RGWs lock requirement bodies; trust the loaded - // copy over anything the webview sends for that field. - const safeUpdates = currentBundle.origin.generated_by_reqs2x - ? msg.updates - : { - requirements: currentBundle.requirements, - traceability: msg.updates.traceability, - }; + // Single source of truth for the edit policy lives in + // applyEditPolicy: it redacts any field the loaded bundle + // reports as locked, so a tampered webview can't sneak edits + // through. + const safeUpdates = applyEditPolicy(currentBundle, msg.updates); const newMtimes = await writeRGWBundle( enviroPath, currentBundle.gatewayPath, @@ -1697,7 +1685,7 @@ async function installPreActivationEventHandlers( const importEnviroToProject = vscode.commands.registerCommand( "vectorcastTestExplorer.importEnviroToProject", async (_args: vscode.Uri, argList: vscode.Uri[]) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "importEnviroToProject", "Import Environment to Project", @@ -1783,7 +1771,7 @@ async function installPreActivationEventHandlers( const addEnviroToProject = vscode.commands.registerCommand( "vectorcastTestExplorer.addEnviroToProject", async (_projectNode: any) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "addEnviroToProject", "Add Environment To Project", @@ -1890,7 +1878,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, argList: vscode.Uri[] ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); // on-disk resource locations const cssOnDisk = vscode.Uri.file(path.join(base, "css", "importEnv.css")); @@ -1943,7 +1931,7 @@ async function installPreActivationEventHandlers( const newEnviroInProjectVCASTCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.newEnviroInProjectVCAST", async (_args: vscode.Uri, argList: vscode.Uri[]) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "newEnvProject", "Create Environment in Project", @@ -2023,7 +2011,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, argList: vscode.Uri[] ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); const cssOnDisk = vscode.Uri.file( path.join(base, "css", "newEnvProject.css") ); @@ -2078,7 +2066,7 @@ async function installPreActivationEventHandlers( } const workspaceRoot = workspaceFolders[0].uri.fsPath; - const baseDir = resolveWebviewBase(context); + const baseDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "newProject", "Create New Project", @@ -2168,7 +2156,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, workspaceRoot: string ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); const cssOnDisk = vscode.Uri.file(path.join(base, "css", "newProject.css")); const scriptOnDisk = vscode.Uri.file( path.join(base, "webviewScripts", "newProject.js") @@ -2215,7 +2203,7 @@ async function installPreActivationEventHandlers( } // Create webview panel - const baseDir = resolveWebviewBase(context); + const baseDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "newCompiler", "Create Compiler in Project", @@ -2285,7 +2273,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, projectPath: string ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); const cssOnDisk = vscode.Uri.file( path.join(base, "css", "newCompiler.css") ); diff --git a/src/manage/manageSrc/manageUtils.ts b/src/manage/manageSrc/manageUtils.ts index bbcdac8f..2bcc9087 100644 --- a/src/manage/manageSrc/manageUtils.ts +++ b/src/manage/manageSrc/manageUtils.ts @@ -101,72 +101,8 @@ function findTestItemRecursively( return found; } -/** - * Generates a cryptographically-strong random nonce string. - * - * We use this nonce to whitelist our injected `. + * Escapes `<`, `>`, and U+2028/U+2029 so the literal can't break out of the + * script tag (a requirement title containing `` would otherwise + * terminate the block early). The output remains valid JSON. + */ +function serializeStateForScriptTag(state: unknown): string { + return JSON.stringify(state) + .replace(//g, "\\u003e") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + /** * Render the grouped headings + cards for a bundle. Exported so the extension * can call it again after save/infer to refresh the headings — they reflect @@ -26,7 +41,7 @@ export function renderRequirementsBody( bundle: RGWBundle, unitsToFunctions: Record | null ): string { - const editable = bundle.origin.generated_by_reqs2x; + const policy = editPolicyFor(bundle); const flat: Array<{ source: string; @@ -62,7 +77,7 @@ export function renderRequirementsBody( for (const [group, entries] of Object.entries(groups)) { body += `

${escapeHtml(group)}

`; for (const entry of entries) { - body += renderCard(entry, editable, unitsToFunctions); + body += renderCard(entry, policy.bodiesEditable, unitsToFunctions); } } return body; @@ -81,7 +96,7 @@ export function generateRequirementsHtml( bundle: RGWBundle, unitsToFunctions: Record | null = null ): string { - const editable = bundle.origin.generated_by_reqs2x; + const policy = editPolicyFor(bundle); const cssUri = webview.asWebviewUri( vscode.Uri.file(path.join(webviewBaseDir, "css", "requirements.css")) @@ -92,7 +107,7 @@ export function generateRequirementsHtml( ) ); - const bannerHtml = editable + const bannerHtml = policy.bodiesEditable ? `` : ``; @@ -100,7 +115,7 @@ export function generateRequirementsHtml( const initialState = { gatewayPath: bundle.gatewayPath, - editable, + policy, mtimes: bundle.mtimes, requirements: bundle.requirements, traceability: bundle.traceability, @@ -122,7 +137,7 @@ export function generateRequirementsHtml( ${bannerHtml}
${body}
- + `; @@ -159,98 +174,59 @@ function renderCard(
- ${renderUnitField(entry, idAttrs, unitsToFunctions)} + ${renderTraceField({ + field: "unit", + current: entry.trace.unit ?? "", + options: unitsToFunctions ? Object.keys(unitsToFunctions) : null, + idAttrs, + })}
- ${renderFunctionField(entry, idAttrs, unitsToFunctions)} + ${renderTraceField({ + field: "function", + current: entry.trace.function ?? "", + options: unitsToFunctions + ? unitsToFunctions[entry.trace.unit ?? ""] ?? [] + : null, + idAttrs, + })}
`; } -function renderUnitField( - entry: { trace: RGWTraceabilityEntry }, - idAttrs: string, - unitsToFunctions: Record | null -): string { - const current = entry.trace.unit ?? ""; - if (!unitsToFunctions) { - return ``; - } - const units = Object.keys(unitsToFunctions); - const opts = [ - ``, - ]; - let found = current === ""; - for (const u of units) { - const sel = u === current ? " selected" : ""; - if (u === current) found = true; - opts.push(``); - } - if (!found) { - opts.push( - `` - ); - } - return ``; -} - -function renderFunctionField( - entry: { trace: RGWTraceabilityEntry }, - idAttrs: string, - unitsToFunctions: Record | null -): string { - const current = entry.trace.function ?? ""; - if (!unitsToFunctions) { - return ``; +/** + * Render a traceability field as either a free-text input (when `options` is + * null — env data wasn't available) or a ``; } - const unit = entry.trace.unit ?? ""; - const fns = unit && unitsToFunctions[unit] ? unitsToFunctions[unit] : []; const opts = [ ``, ]; let found = current === ""; - for (const f of fns) { - const sel = f === current ? " selected" : ""; - if (f === current) found = true; - opts.push(``); + for (const o of options) { + const sel = o === current ? " selected" : ""; + if (o === current) found = true; + opts.push(``); } if (!found) { opts.push( `` ); } - return ``; + return ``; } -/** - * Resolve the on-disk webview-resources directory. Mirrors the manage - * webviews' resolution: normal install path first, then an E2E fallback - * stripping the test-harness suffix. - */ -export function resolveRequirementsWebviewBase( - context: vscode.ExtensionContext -): string { - const fs = require("fs"); - const normal = path.join( - context.extensionPath, - "src", - "requirements", - "webviews" - ); - if (fs.existsSync(normal)) return normal; - - const marker = path.join("tests", "internal", "e2e", "test", "extension"); - const idx = context.extensionPath.indexOf(marker); - if (idx !== -1) { - const repoRoot = context.extensionPath.slice(0, idx); - const fallback = path.join(repoRoot, "src", "requirements", "webviews"); - if (fs.existsSync(fallback)) return fallback; - } - - throw new Error( - `Could not resolve requirements webview base. Tried:\n ${normal}` - ); -} diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js index 5920742b..4a76bd34 100644 --- a/src/requirements/webviews/webviewScripts/requirements.js +++ b/src/requirements/webviews/webviewScripts/requirements.js @@ -102,7 +102,7 @@ traceability: JSON.parse(JSON.stringify(state.traceability)), }; for (const [reqId, patch] of dirty.entries()) { - if (state.editable && Object.keys(patch.req).length > 0) { + if (state.policy.bodiesEditable && Object.keys(patch.req).length > 0) { for (const bucket of Object.keys(updates.requirements)) { if (updates.requirements[bucket][reqId]) { Object.assign(updates.requirements[bucket][reqId], patch.req); diff --git a/src/webviewUtils.ts b/src/webviewUtils.ts new file mode 100644 index 00000000..bfe37190 --- /dev/null +++ b/src/webviewUtils.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode"; + +const path = require("path"); +const fs = require("fs"); + +/** + * Generate a fresh 32-character alphanumeric nonce. Used to gate ` @@ -177,11 +177,15 @@ function renderCard( const reqDis = editable ? "" : "disabled"; const lastMod = entry.req.last_modified ?? ""; + const removeBtn = editable + ? `` + : ""; + return ` -
+
${escapeHtml(entry.id)}
-
${lastMod ? `modified: ${escapeHtml(lastMod)} · ` : ""}source: ${escapeHtml(entry.source)}
+
${lastMod ? `modified: ${escapeHtml(lastMod)} · ` : ""}source: ${escapeHtml(entry.source)}${removeBtn ? ` ${removeBtn}` : ""}
diff --git a/src/requirements/webviews/css/requirements.css b/src/requirements/webviews/css/requirements.css index e71f96a8..5eb54041 100644 --- a/src/requirements/webviews/css/requirements.css +++ b/src/requirements/webviews/css/requirements.css @@ -32,6 +32,20 @@ h2 { padding: 12px 14px; margin: 10px 0; background: var(--vscode-editor-background, #fff); + position: relative; +} +.req--pending-added { + border-color: var(--vscode-inputValidation-infoBorder, #4ec9b0); + border-style: dashed; +} +.req--pending-removal { + opacity: 0.45; +} +.req--pending-removal .req-id, +.req--pending-removal .field input, +.req--pending-removal .field textarea, +.req--pending-removal .field select { + text-decoration: line-through; } .req-header { display: flex; @@ -39,6 +53,30 @@ h2 { align-items: baseline; gap: 12px; } +.req-remove-btn, +.req-restore-btn { + background: transparent; + border: 1px solid transparent; + color: var(--vscode-descriptionForeground, #888); + cursor: pointer; + border-radius: 4px; + padding: 2px 8px; + font: inherit; + line-height: 1; +} +.req-remove-btn:hover, +.req-restore-btn:hover { + border-color: var(--vscode-input-border, #ccc); + color: var(--vscode-foreground, #000); +} +.field input.invalid { + outline: 2px solid var(--vscode-inputValidation-errorBorder, #be1100); +} +.field-error { + font-size: 0.8em; + color: var(--vscode-inputValidation-errorBorder, #be1100); + margin-top: 2px; +} .req-id { font-weight: 600; color: var(--vscode-textLink-foreground, #2980b9); @@ -120,7 +158,8 @@ h2 { } #save-btn, -#infer-btn { +#infer-btn, +#add-btn { padding: 8px 18px; font-size: 1em; background: var(--vscode-button-background, #0078d4); @@ -131,7 +170,8 @@ h2 { font-family: inherit; } #save-btn[disabled], -#infer-btn[disabled] { +#infer-btn[disabled], +#add-btn[disabled] { opacity: 0.5; cursor: default; } diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js index ddb3d357..edb37f03 100644 --- a/src/requirements/webviews/webviewScripts/requirements.js +++ b/src/requirements/webviews/webviewScripts/requirements.js @@ -3,8 +3,7 @@ // State (`window.__rgwState`) is injected as a JSON blob by the extension at // load time and refreshed on `saved` / `inferred` messages, which carry a // `groups` array (typed RequirementGroup[]). The cards section is rebuilt -// from that data using safe DOM APIs (createElement / textContent) — we -// deliberately don't accept pre-built HTML over postMessage. +// from that data using safe DOM APIs (createElement / textContent). // // Wire protocol (see src/requirements/webview/messages.ts): // FROM webview: { type: "save" | "infer-traceability" } @@ -13,23 +12,51 @@ (function () { const vscode = acquireVsCodeApi(); const state = window.__rgwState; - // reqId -> { req: {title?, description?}, trace: {unit?, function?} } + + // Pending edits split three ways: + // dirty — in-place edits to existing requirements (key -> {req, trace}) + // removed — keys of existing requirements marked for soft-deletion + // added — pending new requirements not yet on disk + // All three flush on Save and reset on a successful saved/inferred. const dirty = new Map(); + const removed = new Set(); + const added = []; // {tempId, key, title, description, unit, function, keyValid} + let nextTempId = 1; const saveBtn = document.getElementById("save-btn"); const inferBtn = document.getElementById("infer-btn"); + const addBtn = document.getElementById("add-btn"); // null when bodies aren't editable const reqsBody = document.getElementById("reqs-body"); + // ---------- State helpers ---------------------------------------------- + + function existingKeys() { + const keys = new Set(); + for (const bucket of Object.values(state.requirements)) { + for (const k of Object.keys(bucket)) keys.add(k); + } + return keys; + } + + function refreshButtonStates() { + const hasChanges = + dirty.size > 0 || removed.size > 0 || added.length > 0; + const hasInvalid = added.some((a) => !a.keyValid); + saveBtn.disabled = !hasChanges || hasInvalid; + if (addBtn) addBtn.disabled = false; + // Don't clobber unsaved local edits with an inference. + inferBtn.disabled = hasChanges; + } + function setDirty(reqId, scope, field, value) { if (!dirty.has(reqId)) dirty.set(reqId, { req: {}, trace: {} }); dirty.get(reqId)[scope][field] = value; - saveBtn.disabled = dirty.size === 0; - inferBtn.disabled = dirty.size > 0; // don't clobber unsaved local edits + refreshButtonStates(); } - // ---------- DOM builders ------------------------------------------------ + // ---------- DOM builders ----------------------------------------------- - function buildTraceField(field, current, options, reqId) { + function buildTraceField(field, current, options, refAttrs) { let el; if (!options) { el = document.createElement("input"); @@ -62,9 +89,9 @@ el.appendChild(opt); } } - el.dataset.reqId = reqId; el.dataset.scope = "trace"; el.dataset.field = field; + Object.assign(el.dataset, refAttrs); return el; } @@ -78,24 +105,72 @@ return div; } + function buildTraceRow(currentUnit, currentFn, refAttrs) { + const traceRow = document.createElement("div"); + traceRow.className = "field trace-row"; + + const unitOptions = state.unitsToFunctions + ? Object.keys(state.unitsToFunctions) + : null; + const unitField = buildTraceField( + "unit", + currentUnit ?? "", + unitOptions, + refAttrs + ); + const fnOptions = state.unitsToFunctions + ? state.unitsToFunctions[currentUnit ?? ""] ?? [] + : null; + const fnField = buildTraceField( + "function", + currentFn ?? "", + fnOptions, + refAttrs + ); + + const unitWrap = document.createElement("div"); + const unitLabel = document.createElement("label"); + unitLabel.textContent = "Traceability: unit"; + unitWrap.appendChild(unitLabel); + unitWrap.appendChild(unitField); + + const fnWrap = document.createElement("div"); + const fnLabel = document.createElement("label"); + fnLabel.textContent = "Traceability: function"; + fnWrap.appendChild(fnLabel); + fnWrap.appendChild(fnField); + + traceRow.appendChild(unitWrap); + traceRow.appendChild(fnWrap); + return traceRow; + } + function buildCard(entry) { const policy = state.policy; const lockedBodies = !policy.bodiesEditable; const card = document.createElement("div"); card.className = "req"; + card.dataset.reqId = entry.id; + if (removed.has(entry.id)) card.classList.add("req--pending-removal"); const header = document.createElement("div"); header.className = "req-header"; + const id = document.createElement("div"); id.className = "req-id"; id.textContent = entry.id; + header.appendChild(id); + const meta = document.createElement("div"); meta.className = "req-meta"; const lastMod = entry.req.last_modified ?? ""; meta.textContent = (lastMod ? "modified: " + lastMod + " · " : "") + "source: " + entry.source; - header.appendChild(id); + if (policy.bodiesEditable) { + meta.appendChild(document.createTextNode(" ")); + meta.appendChild(buildRemoveButton(entry.id)); + } header.appendChild(meta); card.appendChild(header); @@ -116,45 +191,80 @@ if (lockedBodies) descArea.disabled = true; card.appendChild(buildField("Description", descArea)); - const traceRow = document.createElement("div"); - traceRow.className = "field trace-row"; - - const unitOptions = state.unitsToFunctions - ? Object.keys(state.unitsToFunctions) - : null; - const unitField = buildTraceField( - "unit", - entry.trace.unit ?? "", - unitOptions, - entry.id + card.appendChild( + buildTraceRow(entry.trace.unit, entry.trace.function, { reqId: entry.id }) ); + return card; + } - const fnOptions = state.unitsToFunctions - ? state.unitsToFunctions[entry.trace.unit ?? ""] ?? [] - : null; - const fnField = buildTraceField( - "function", - entry.trace.function ?? "", - fnOptions, - entry.id - ); + function buildRemoveButton(reqId) { + const btn = document.createElement("button"); + btn.className = "req-remove-btn"; + btn.dataset.action = "remove"; + btn.dataset.reqId = reqId; + btn.title = "Remove this requirement"; + btn.textContent = "×"; + return btn; + } - const unitWrap = document.createElement("div"); - const unitLabel = document.createElement("label"); - unitLabel.textContent = "Traceability: unit"; - unitWrap.appendChild(unitLabel); - unitWrap.appendChild(unitField); + function buildPendingAddCard(pending) { + const card = document.createElement("div"); + card.className = "req req--pending-added"; + card.dataset.tempId = pending.tempId; - const fnWrap = document.createElement("div"); - const fnLabel = document.createElement("label"); - fnLabel.textContent = "Traceability: function"; - fnWrap.appendChild(fnLabel); - fnWrap.appendChild(fnField); + const header = document.createElement("div"); + header.className = "req-header"; - traceRow.appendChild(unitWrap); - traceRow.appendChild(fnWrap); - card.appendChild(traceRow); + const id = document.createElement("div"); + id.className = "req-id"; + id.textContent = "[New requirement]"; + header.appendChild(id); + const meta = document.createElement("div"); + meta.className = "req-meta"; + const discardBtn = document.createElement("button"); + discardBtn.className = "req-remove-btn"; + discardBtn.dataset.action = "discard-added"; + discardBtn.dataset.tempId = pending.tempId; + discardBtn.title = "Discard this new requirement"; + discardBtn.textContent = "×"; + meta.appendChild(discardBtn); + header.appendChild(meta); + card.appendChild(header); + + // Key field. Validates against existing + other pending keys on input. + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.dataset.tempId = pending.tempId; + keyInput.dataset.field = "key"; + keyInput.value = pending.key; + keyInput.placeholder = "Required: unique key under which this is stored"; + const keyField = buildField("Key", keyInput); + const keyError = document.createElement("div"); + keyError.className = "field-error"; + keyError.dataset.tempId = pending.tempId; + keyError.dataset.role = "key-error"; + keyField.appendChild(keyError); + card.appendChild(keyField); + + const titleInput = document.createElement("input"); + titleInput.type = "text"; + titleInput.dataset.tempId = pending.tempId; + titleInput.dataset.field = "title"; + titleInput.value = pending.title; + card.appendChild(buildField("Title", titleInput)); + + const descArea = document.createElement("textarea"); + descArea.dataset.tempId = pending.tempId; + descArea.dataset.field = "description"; + descArea.value = pending.description; + card.appendChild(buildField("Description", descArea)); + + card.appendChild( + buildTraceRow(pending.unit, pending.function, { tempId: pending.tempId }) + ); + + validatePendingKey(pending, keyInput, keyError); return card; } @@ -168,9 +278,43 @@ frag.appendChild(buildCard(entry)); } } + // Pending-added cards live below the persisted groups so the user + // sees them clearly until they save. + for (const pending of added) { + frag.appendChild(buildPendingAddCard(pending)); + } reqsBody.replaceChildren(frag); } + // ---------- Pending-key validation ------------------------------------- + + function validatePendingKey(pending, inputEl, errorEl) { + const key = pending.key.trim(); + let problem = ""; + if (!key) { + problem = "Key is required."; + } else if (existingKeys().has(key)) { + problem = "Collides with an existing requirement key."; + } else if (added.some((a) => a !== pending && a.key.trim() === key)) { + problem = "Two pending new requirements share this key."; + } + pending.keyValid = problem === ""; + inputEl.classList.toggle("invalid", !pending.keyValid); + if (errorEl) errorEl.textContent = problem; + } + + function revalidateAllPendingKeys() { + for (const pending of added) { + const card = reqsBody.querySelector( + `.req--pending-added[data-temp-id="${pending.tempId}"]` + ); + if (!card) continue; + const inputEl = card.querySelector('[data-field="key"]'); + const errorEl = card.querySelector('[data-role="key-error"]'); + if (inputEl) validatePendingKey(pending, inputEl, errorEl); + } + } + // ---------- Field-change handling -------------------------------------- function refreshFunctionOptions(card, selectedUnit, currentFunction) { @@ -182,7 +326,6 @@ const fns = selectedUnit && map[selectedUnit] ? map[selectedUnit] : []; const desired = currentFunction != null ? String(currentFunction) : ""; - // Rebuild via DOM API rather than innerHTML so we never mix in HTML. while (fnSelect.firstChild) fnSelect.removeChild(fnSelect.firstChild); const noneOpt = document.createElement("option"); noneOpt.value = ""; @@ -210,8 +353,44 @@ } } + function pendingForTempId(tempId) { + return added.find((a) => a.tempId === tempId); + } + function handleFieldChange(el) { + // Pending-added card: route the edit into the `added` entry directly. + if (el.dataset.tempId) { + const pending = pendingForTempId(el.dataset.tempId); + if (!pending) return; + const field = el.dataset.field; + let value = el.value; + if (field === "key") { + pending.key = value; + const card = el.closest(".req"); + const errorEl = card?.querySelector('[data-role="key-error"]'); + validatePendingKey(pending, el, errorEl); + } else if (field === "title" || field === "description") { + pending[field] = value; + } else if (field === "unit" || field === "function") { + pending[field] = value === "" ? null : value; + if (field === "unit") { + const card = el.closest(".req"); + refreshFunctionOptions(card, pending.unit, pending.function); + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (fnSelect) { + pending.function = fnSelect.value === "" ? null : fnSelect.value; + } + } + } + refreshButtonStates(); + return; + } + + // Existing card edit. const reqId = el.dataset.reqId; + if (removed.has(reqId)) return; // ignore edits to soft-deleted cards const scope = el.dataset.scope; const field = el.dataset.field; let value = el.value; @@ -230,7 +409,6 @@ : (state.traceability[reqId] && state.traceability[reqId].function) || null; refreshFunctionOptions(card, value, currentFn); - // function dropdown's value may have changed implicitly; record it const fnSelect = card.querySelector( '[data-scope="trace"][data-field="function"]' ); @@ -242,11 +420,14 @@ } } - // Event delegation on the cards container so listeners survive children - // replacement when the body is regrouped after save/infer. reqsBody.addEventListener("input", (e) => { const t = e.target; - if (t && t.matches && t.matches("[data-field]") && t.tagName !== "SELECT") { + if ( + t && + t.matches && + t.matches("[data-field]") && + t.tagName !== "SELECT" + ) { handleFieldChange(t); } }); @@ -257,14 +438,94 @@ } }); + // ---------- Add / remove ----------------------------------------------- + + function appendPendingAdd() { + const pending = { + tempId: "__pending_" + nextTempId++ + "__", + key: "", + title: "", + description: "", + unit: null, + function: null, + keyValid: false, + }; + added.push(pending); + reqsBody.appendChild(buildPendingAddCard(pending)); + refreshButtonStates(); + } + + function toggleRemoveExisting(reqId) { + const card = reqsBody.querySelector(`.req[data-req-id="${reqId}"]`); + if (!card) return; + if (removed.has(reqId)) { + removed.delete(reqId); + card.classList.remove("req--pending-removal"); + const btn = card.querySelector(".req-remove-btn"); + if (btn) { + btn.textContent = "×"; + btn.title = "Remove this requirement"; + } + } else { + removed.add(reqId); + card.classList.add("req--pending-removal"); + const btn = card.querySelector(".req-remove-btn"); + if (btn) { + btn.textContent = "↩"; + btn.title = "Restore this requirement"; + } + } + refreshButtonStates(); + } + + function discardPendingAdd(tempId) { + const idx = added.findIndex((a) => a.tempId === tempId); + if (idx === -1) return; + added.splice(idx, 1); + const card = reqsBody.querySelector( + `.req--pending-added[data-temp-id="${tempId}"]` + ); + if (card) card.remove(); + revalidateAllPendingKeys(); + refreshButtonStates(); + } + + if (addBtn) { + addBtn.addEventListener("click", appendPendingAdd); + } + + reqsBody.addEventListener("click", (e) => { + const t = e.target; + if (!t || !t.matches || !t.matches(".req-remove-btn")) return; + const action = t.dataset.action; + if (action === "remove") { + toggleRemoveExisting(t.dataset.reqId); + } else if (action === "discard-added") { + discardPendingAdd(t.dataset.tempId); + } + }); + // ---------- Save / infer ------------------------------------------------ - saveBtn.addEventListener("click", () => { + function buildSaveUpdates() { const updates = { requirements: JSON.parse(JSON.stringify(state.requirements)), traceability: JSON.parse(JSON.stringify(state.traceability)), }; + + // 1. Drop soft-removed entries from every bucket and the trace map. + for (const reqId of removed) { + for (const bucket of Object.keys(updates.requirements)) { + if (updates.requirements[bucket][reqId]) { + delete updates.requirements[bucket][reqId]; + } + } + delete updates.traceability[reqId]; + } + + // 2. Apply in-place dirty patches (skipping any that are also removed). for (const [reqId, patch] of dirty.entries()) { + if (removed.has(reqId)) continue; if (state.policy.bodiesEditable && Object.keys(patch.req).length > 0) { for (const bucket of Object.keys(updates.requirements)) { if (updates.requirements[bucket][reqId]) { @@ -282,8 +543,39 @@ updates.traceability[reqId] = { ...existing, ...patch.trace }; } } + + // 3. Append pending-added entries to the first bucket. The save flow on + // the extension side normalizes everything into a single CSV-keyed + // bucket anyway, so the choice here doesn't matter. + let bucketKey = Object.keys(updates.requirements)[0]; + if (!bucketKey) { + bucketKey = "[CSV] [" + state.gatewayPath + "]"; + updates.requirements[bucketKey] = {}; + } + for (const pending of added) { + const key = pending.key.trim(); + updates.requirements[bucketKey][key] = { + id: key, + title: pending.title, + description: pending.description, + }; + if (pending.unit != null || pending.function != null) { + updates.traceability[key] = { + unit: pending.unit ?? null, + function: pending.function ?? null, + lines: null, + }; + } + } + + return updates; + } + + saveBtn.addEventListener("click", () => { + const updates = buildSaveUpdates(); saveBtn.disabled = true; inferBtn.disabled = true; + if (addBtn) addBtn.disabled = true; vscode.postMessage({ type: "save", updates, @@ -294,29 +586,31 @@ inferBtn.addEventListener("click", () => { inferBtn.disabled = true; saveBtn.disabled = true; + if (addBtn) addBtn.disabled = true; vscode.postMessage({ type: "infer-traceability" }); }); function applyRefreshedBundle(msg) { dirty.clear(); + removed.clear(); + added.length = 0; state.mtimes = msg.mtimes; state.requirements = msg.requirements; state.traceability = msg.traceability; if (Array.isArray(msg.groups)) rebuildBody(msg.groups); - saveBtn.disabled = true; - inferBtn.disabled = false; + refreshButtonStates(); } window.addEventListener("message", (event) => { const msg = event.data; if (msg.type === "saved" || msg.type === "inferred") { applyRefreshedBundle(msg); - } else if (msg.type === "save-failed") { - saveBtn.disabled = false; - inferBtn.disabled = dirty.size > 0; - } else if (msg.type === "infer-failed" || msg.type === "infer-cancelled") { - saveBtn.disabled = dirty.size === 0; - inferBtn.disabled = dirty.size > 0; + } else if ( + msg.type === "save-failed" || + msg.type === "infer-failed" || + msg.type === "infer-cancelled" + ) { + refreshButtonStates(); } }); })(); From 74cf1233d5d5327b72cd674500cf155506be2f87 Mon Sep 17 00:00:00 2001 From: Patrick Bareiss Date: Tue, 28 Apr 2026 18:01:35 +0200 Subject: [PATCH 10/14] Offer one-shot migration of legacy reqs.xlsx / reqs.csv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-branch, the canonical requirements file was reqs-/reqs.xlsx (or reqs.csv). After upgrading the extension users still have those files lying around but no RGW set up — Generate / Show / Generate Tests all fail because the new flow expects the RGW to be the source of truth. Add a one-shot prompt at activation: - Walk every *.env in the workspace; collect those that have a legacy reqs.xlsx / reqs.csv but no VCAST_REPOSITORY-resolvable gateway. - If any are found and the user hasn't dismissed before, show: "Found N environment(s) with legacy requirements files (reqs.xlsx / reqs.csv). Requirements are now stored in a Requirements Gateway (RGW). Migrate now?" with Migrate / Not now / Don't show again. - "Don't show again" is persisted in workspaceState so the choice survives across sessions for this workspace. - "Migrate" runs panreq --target-format rgw per env via runReqs2xTool's standard cancellable progress notification, sets up the default reqs-/rgw/ location, writes VCAST_REPOSITORY to CCAST_.CFG, and refreshes availability so menus light up immediately. The legacy reqs.xlsx / reqs.csv files are left in place as a backup. Gated on the Reqs2X feature being enabled and PANREQ_EXECUTABLE_PATH being resolved; otherwise the helper exits silently. Fired fire-and-forget from activationLogic so it doesn't block the rest of extension start-up. --- src/extension.ts | 7 ++ src/requirements/legacyMigration.ts | 160 ++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/requirements/legacyMigration.ts diff --git a/src/extension.ts b/src/extension.ts index cca5f4d3..2526a37d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -157,6 +157,7 @@ import { importRequirements, initializeReqs2X, } from "./requirements/requirementsOperations"; +import { maybeOfferLegacyMigration } from "./requirements/legacyMigration"; import { generateNewCodedTestFile, @@ -383,6 +384,12 @@ async function activationLogic(context: vscode.ExtensionContext) { setupRequirementsFileWatchers(context); initializeReqs2X(context); + + // One-shot prompt: legacy reqs.xlsx / reqs.csv → RGW. Runs after + // initializeReqs2X so the panreq executable path is resolved (no-op + // otherwise). Fire and forget — we don't want migration to block the + // rest of activation. + void maybeOfferLegacyMigration(context); } function configureExtension(context: vscode.ExtensionContext) { diff --git a/src/requirements/legacyMigration.ts b/src/requirements/legacyMigration.ts new file mode 100644 index 00000000..9ad52fbc --- /dev/null +++ b/src/requirements/legacyMigration.ts @@ -0,0 +1,160 @@ +import * as vscode from "vscode"; +import { runReqs2xTool } from "./processRunner"; +import { + defaultRequirementGatewayPath, + findRelevantRequirementGateway, + setVcastRepositoryInConfig, +} from "./rgwPath"; +import { PANREQ_EXECUTABLE_PATH } from "./requirementsExecutables"; +import { updateRequirementsAvailability } from "./availability"; +import { logCliError, logCliOperation } from "./requirementsLog"; + +const path = require("path"); +const fs = require("fs"); + +// Persisted in workspaceState so a "Don't show again" choice survives +// across VS Code sessions for this workspace. +const SKIP_KEY = "vectorcastTestExplorer.reqs2x.skippedLegacyMigrationPrompt"; + +interface LegacyEnv { + enviroPath: string; + legacySource: string; +} + +/** + * Walk every `*.env` in the workspace looking for envs that have a legacy + * `reqs-/reqs.xlsx` (or `reqs.csv`) file but no RGW configured yet. + * Those are the candidates for migration to the new RGW-based storage. + */ +async function findEnvsWithLegacyStorage(): Promise { + const envFiles = await vscode.workspace.findFiles("**/*.env"); + const out: LegacyEnv[] = []; + for (const uri of envFiles) { + const envDir = path.dirname(uri.fsPath); + const envName = path.basename(uri.fsPath, ".env"); + const enviroPath = path.join(envDir, envName); + + // Already migrated (or otherwise has a working gateway): skip. + if (findRelevantRequirementGateway(enviroPath)) continue; + + const reqsDir = path.join(envDir, `reqs-${envName}`); + const xlsx = path.join(reqsDir, "reqs.xlsx"); + const csv = path.join(reqsDir, "reqs.csv"); + if (fs.existsSync(xlsx)) { + out.push({ enviroPath, legacySource: xlsx }); + } else if (fs.existsSync(csv)) { + out.push({ enviroPath, legacySource: csv }); + } + } + return out; +} + +/** + * Run panreq to import the legacy file into a freshly-set-up RGW under + * `reqs-/rgw/`, and write VCAST_REPOSITORY to CCAST_.CFG so subsequent + * operations find the gateway. Mirrors what the Import Requirements command + * does, just programmatic (no file picker) and tagged with a migration + * progress title. + */ +async function migrateOne(env: LegacyEnv): Promise { + const parentDir = path.dirname(env.enviroPath); + const envName = `${path.basename(env.enviroPath)}.env`; + const envPath = path.join(parentDir, envName); + + const gatewayPath = defaultRequirementGatewayPath(env.enviroPath); + fs.mkdirSync(path.dirname(gatewayPath), { recursive: true }); + setVcastRepositoryInConfig(env.enviroPath, gatewayPath); + + try { + const { cancelled } = await runReqs2xTool({ + exe: PANREQ_EXECUTABLE_PATH, + args: [ + env.legacySource, + gatewayPath, + "--target-format", + "rgw", + "--target-env", + envPath, + "--json-events", + ], + progress: { + title: `Migrating requirements for ${path.basename(env.enviroPath)}`, + logPrefix: "panreq", + }, + }); + if (cancelled) return false; + updateRequirementsAvailability(env.enviroPath); + return true; + } catch (err) { + logCliError( + `Failed to migrate ${env.legacySource}: ${err instanceof Error ? err.message : err}`, + true + ); + return false; + } +} + +/** + * Offer a one-shot migration of legacy reqs.xlsx / reqs.csv files into the + * new RGW format. Shown at most once per workspace per "Don't show again" + * choice; otherwise the prompt re-appears the next session if any envs + * still have legacy files but no gateway. Skipped entirely when Reqs2X + * isn't enabled (the executables aren't resolved in that case). + */ +export async function maybeOfferLegacyMigration( + context: vscode.ExtensionContext +): Promise { + if (context.workspaceState.get(SKIP_KEY)) return; + + // Reqs2X feature must be on (so the panreq path is resolved). + const enabled = vscode.workspace + .getConfiguration("vectorcastTestExplorer.reqs2x") + .get("enableReqs2xFeature"); + if (!enabled || !PANREQ_EXECUTABLE_PATH) return; + + const legacyEnvs = await findEnvsWithLegacyStorage(); + if (legacyEnvs.length === 0) return; + + logCliOperation( + `Legacy migration: ${legacyEnvs.length} env(s) have reqs.xlsx/reqs.csv but no RGW.` + ); + + const envWord = legacyEnvs.length === 1 ? "environment" : "environments"; + const message = + `Found ${legacyEnvs.length} ${envWord} with legacy requirements files ` + + `(reqs.xlsx / reqs.csv). Requirements are now stored in a Requirements ` + + `Gateway (RGW). Migrate now?`; + + const choice = await vscode.window.showInformationMessage( + message, + "Migrate", + "Not now", + "Don't show again" + ); + + if (choice === "Don't show again") { + await context.workspaceState.update(SKIP_KEY, true); + return; + } + if (choice !== "Migrate") return; + + let succeeded = 0; + let failed = 0; + for (const env of legacyEnvs) { + const ok = await migrateOne(env); + if (ok) succeeded++; + else failed++; + } + + if (failed === 0) { + vscode.window.showInformationMessage( + `Migrated ${succeeded} ${envWord} to the new RGW format. ` + + `The legacy reqs.xlsx / reqs.csv files were left in place as a backup.` + ); + } else { + vscode.window.showWarningMessage( + `Migrated ${succeeded}; ${failed} failed. See the ` + + `"VectorCAST Requirement Test Generation Operations" output channel for details.` + ); + } +} From 12c29161080d4d0538b6b50434faf5bdd3e0bd90 Mon Sep 17 00:00:00 2001 From: Patrick Bareiss Date: Tue, 28 Apr 2026 18:58:38 +0200 Subject: [PATCH 11/14] Fix two small bugs Co-authored-by: Copilot --- src/requirements/rgwIo.ts | 16 ++++++++++++++-- src/requirements/rgwPath.ts | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/requirements/rgwIo.ts b/src/requirements/rgwIo.ts index 67c1d75f..7c91d5af 100644 --- a/src/requirements/rgwIo.ts +++ b/src/requirements/rgwIo.ts @@ -351,10 +351,22 @@ export async function writeRGWBundle( JSON.stringify({ [`[CSV] [${csvPath}]`]: consolidated }, null, 4) ); - // 3. Write traceability.json directly. + // 3. Write traceability.json directly. RGW's invariant is one-to-one with + // requirements: every requirement key has an entry, even if it's all + // null. Without this normalization a partial save would leave a sparse + // traceability.json (or a stale one with entries for removed + // requirements), which downstream tools mis-handle. + const normalizedTraceability: RGWTraceabilityFile = {}; + for (const reqKey of Object.keys(consolidated)) { + normalizedTraceability[reqKey] = updates.traceability[reqKey] ?? { + unit: null, + function: null, + lines: null, + }; + } writeAtomic( files.traceability, - JSON.stringify(updates.traceability, null, 4) + JSON.stringify(normalizedTraceability, null, 4) ); return { diff --git a/src/requirements/rgwPath.ts b/src/requirements/rgwPath.ts index 32510587..c3f672ae 100644 --- a/src/requirements/rgwPath.ts +++ b/src/requirements/rgwPath.ts @@ -43,6 +43,8 @@ export function findRelevantRequirementGateway( const parentDir = path.dirname(enviroPath); const configPath = path.join(parentDir, "CCAST_.CFG"); + if (!fs.existsSync(configPath)) return null; + const configContent = fs.readFileSync(configPath, "utf-8"); const gatewayMatch = configContent.match(/VCAST_REPOSITORY:\s*(.+)\s*/); if (gatewayMatch == null) return null; From c1eb37e0f27aac22ced82f2a5e560da380b1a50d Mon Sep 17 00:00:00 2001 From: Patrick Bareiss Date: Mon, 4 May 2026 11:10:08 +0200 Subject: [PATCH 12/14] Open-source button + small follow-up cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-card "↗ Open source" button to the requirements editor and folds in four cleanups from the latest retrospective. Open-source button: - Per-requirement-card button below the trace dropdowns. Disabled when unit is "(none)"; enabled lazily as the user picks a unit. Click sends {type: "open-source", unit, function} which the extension resolves against envData.unitData and opens the source file at the function's startLine (or top-of-file when only the unit is set). - Tab-reuse logic: if the file is already open in any group *other than* the requirements webview's own column, focus that tab. Otherwise fall back to ViewColumn.Beside so the source pops out next to the webview instead of stacking inside it (which would hide the requirements view). - Wired through a new OpenSourceMessage in the FromWebview union. Cleanups: 1. Source-file resolution dedup: the existing openSourceFileFromTestpaneCommand and the new openSourceForTrace shared the unit-walk + functionList lookup. Extracted as resolveSourceLocation(envData, unitName, functionName) → {uri, lineNumber}; both call sites use it. 2. Wire types live in messages.ts: RequirementEntry / RequirementGroup moved out of webview/template.ts so the wire spec is self-contained. Removed the backwards messages → template import. 3. importRequirementsFromPath helper: extracted the panreq-import core (gateway-setup-if-missing, panreq with progress, refresh availability) from importRequirements. legacyMigration's migrateOne is now a one-liner that calls it with a different progress title. 4. existingKeys() in the webview script ignores soft-removed keys so a user can remove FR1 and add a fresh FR1 in the same save without collision validation flagging it. toggleRemoveExisting also calls revalidateAllPendingKeys() so the inverse case (restoring a key that a pending-add was filling) flips back to invalid. --- src/extension.ts | 202 ++++++++++++------ src/requirements/legacyMigration.ts | 58 +---- src/requirements/requirementsOperations.ts | 126 ++++++----- src/requirements/webview/messages.ts | 30 ++- src/requirements/webview/template.ts | 22 +- .../webviews/css/requirements.css | 21 ++ .../webviews/webviewScripts/requirements.js | 60 +++++- 7 files changed, 329 insertions(+), 190 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2526a37d..f75d5739 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -314,6 +314,106 @@ async function getEnvironmentListIncludingUnbuilt( * so we show a quick-pick of envs in the workspace. Returns null if the user * dismisses the picker or the workspace has no env files. */ +/** + * Open the source file backing a trace mapping. If `functionName` is given + * and the unit has a matching entry with a startLine, jump to that line; + * otherwise just open the file. Used by the requirements editor's "Open + * source" button. + */ +/** + * Resolve a source-file location from VectorCAST envData. Returns the URI + * for the unit's source file and the line of the named function (or 0 if + * `functionName` is null or its `startLine` isn't known). Returns null if + * envData lacks unit info or the unit isn't present in the env. + * + * Shared by the test-pane "Open Source File" command and the requirements + * editor's per-card "Open source" button — same matching rule (unit name + * == source-file basename, sans extension). + */ +function resolveSourceLocation( + envData: any, + unitName: string, + functionName: string | null +): { uri: vscode.Uri; lineNumber: number } | null { + const units = envData?.unitData; + if (!Array.isArray(units)) return null; + const unitInfo = units.find((u: any) => { + if (!u?.path) return false; + return path.basename(u.path, path.extname(u.path)) === unitName; + }); + if (!unitInfo) return null; + let lineNumber = 0; + if (functionName && Array.isArray(unitInfo.functionList)) { + const fn = unitInfo.functionList.find( + (f: any) => f?.name === functionName && f?.startLine !== undefined + ); + if (fn) lineNumber = fn.startLine; + } + return { uri: vscode.Uri.file(unitInfo.path), lineNumber }; +} + +async function openSourceForTrace( + enviroPath: string, + unitName: string, + functionName: string | null, + referenceColumn: vscode.ViewColumn | undefined +): Promise { + if (!unitName) return; + let envData: any; + try { + envData = await getEnvironmentData(enviroPath); + } catch { + vscode.window.showErrorMessage( + "Could not query the environment to resolve the source file." + ); + return; + } + if (!envData?.unitData) { + vscode.window.showErrorMessage( + "Environment data has no unit information." + ); + return; + } + + const located = resolveSourceLocation(envData, unitName, functionName); + if (!located) { + vscode.window.showErrorMessage( + `Unit "${unitName}" not found in this environment.` + ); + return; + } + const { uri, lineNumber } = located; + const position = new vscode.Position(Math.max(0, lineNumber - 1), 0); + const selection = new vscode.Range(position, position); + + // If the file is already open in a tab in some *other* column, reveal + // that tab. Tabs in the webview's own column are ignored — switching to + // them would hide the requirements view. If no other-column tab exists, + // fall back to Beside so a new editor opens next to the webview. + let targetColumn: vscode.ViewColumn | undefined; + for (const group of vscode.window.tabGroups.all) { + if (group.viewColumn === referenceColumn) continue; + const hit = group.tabs.find( + (tab) => + tab.input instanceof vscode.TabInputText && + tab.input.uri.fsPath === uri.fsPath + ); + if (hit) { + targetColumn = group.viewColumn; + break; + } + } + targetColumn ??= vscode.ViewColumn.Beside; + + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + selection, + viewColumn: targetColumn, + }); +} + async function resolveEnviroPathForCommand( args: any ): Promise { @@ -1293,70 +1393,41 @@ function configureExtension(context: vscode.ExtensionContext) { let openSourceFileFromTestpaneCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.openSourceFileFromTestpaneCommand", async (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - if (testNode) { - const enviroPath = testNode.enviroPath; - const unitName = testNode.unitName; - const functionName = testNode.functionName; - const envData = await getEnvironmentData(enviroPath); - - if (envData.unitData) { - for (const unitInfo of envData.unitData) { - // Extract unit name from path to match against unitName - const pathBasename = path.basename( - unitInfo.path, - path.extname(unitInfo.path) - ); - - if (pathBasename === unitName) { - const sourcePath = unitInfo.path; - const uri = vscode.Uri.file(sourcePath); - - // Determine the line number to open at (0 = top default) - let lineNumber = 0; - - // If functionName is defined, try to find it in the function list - if (functionName && unitInfo.functionList) { - for (const func of unitInfo.functionList) { - if ( - func.name === functionName && - func.startLine !== undefined - ) { - lineNumber = func.startLine; - break; - } - } - } - - // Open the document at the specified line - const document = await vscode.workspace.openTextDocument(uri); - const position = new vscode.Position( - Math.max(0, lineNumber - 1), - 0 - ); - const selection = new vscode.Range(position, position); - - await vscode.window.showTextDocument(document, { - preview: false, // open as a real tab - preserveFocus: false, - selection: selection, - }); + if (!args) return; + const testNode: testNodeType = getTestNode(args.id); + if (!testNode) { + vscode.window.showErrorMessage( + `Unable to open Source File for Node: ${args.id}` + ); + return; + } - break; - } - } - } else { - vscode.window.showErrorMessage( - `Could not find environment data for: ${enviroPath}` - ); - } - } else { - vscode.window.showErrorMessage( - `Unable to open Source File for Node: ${args.id}` - ); - } + const envData = await getEnvironmentData(testNode.enviroPath); + if (!envData?.unitData) { + vscode.window.showErrorMessage( + `Could not find environment data for: ${testNode.enviroPath}` + ); + return; } + + const located = resolveSourceLocation( + envData, + testNode.unitName, + testNode.functionName ?? null + ); + if (!located) return; + + const document = await vscode.workspace.openTextDocument(located.uri); + const position = new vscode.Position( + Math.max(0, located.lineNumber - 1), + 0 + ); + const selection = new vscode.Range(position, position); + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + selection, + }); } ); context.subscriptions.push(openSourceFileFromTestpaneCommand); @@ -1509,6 +1580,13 @@ function configureExtension(context: vscode.ExtensionContext) { vscode.window.showErrorMessage(message); post({ type: "infer-failed", message }); } + } else if (msg?.type === "open-source") { + await openSourceForTrace( + enviroPath, + msg.unit, + msg.function, + panel.viewColumn + ); } }, undefined, diff --git a/src/requirements/legacyMigration.ts b/src/requirements/legacyMigration.ts index 9ad52fbc..54c3a2ce 100644 --- a/src/requirements/legacyMigration.ts +++ b/src/requirements/legacyMigration.ts @@ -1,13 +1,8 @@ import * as vscode from "vscode"; -import { runReqs2xTool } from "./processRunner"; -import { - defaultRequirementGatewayPath, - findRelevantRequirementGateway, - setVcastRepositoryInConfig, -} from "./rgwPath"; +import { findRelevantRequirementGateway } from "./rgwPath"; import { PANREQ_EXECUTABLE_PATH } from "./requirementsExecutables"; -import { updateRequirementsAvailability } from "./availability"; -import { logCliError, logCliOperation } from "./requirementsLog"; +import { logCliOperation } from "./requirementsLog"; +import { importRequirementsFromPath } from "./requirementsOperations"; const path = require("path"); const fs = require("fs"); @@ -50,48 +45,15 @@ async function findEnvsWithLegacyStorage(): Promise { } /** - * Run panreq to import the legacy file into a freshly-set-up RGW under - * `reqs-/rgw/`, and write VCAST_REPOSITORY to CCAST_.CFG so subsequent - * operations find the gateway. Mirrors what the Import Requirements command - * does, just programmatic (no file picker) and tagged with a migration - * progress title. + * Migrate one env: import its legacy reqs.xlsx / reqs.csv into a fresh RGW + * under `reqs-/rgw/`. Just a thin wrapper around the shared + * importRequirementsFromPath helper with a migration-flavoured progress + * title. */ async function migrateOne(env: LegacyEnv): Promise { - const parentDir = path.dirname(env.enviroPath); - const envName = `${path.basename(env.enviroPath)}.env`; - const envPath = path.join(parentDir, envName); - - const gatewayPath = defaultRequirementGatewayPath(env.enviroPath); - fs.mkdirSync(path.dirname(gatewayPath), { recursive: true }); - setVcastRepositoryInConfig(env.enviroPath, gatewayPath); - - try { - const { cancelled } = await runReqs2xTool({ - exe: PANREQ_EXECUTABLE_PATH, - args: [ - env.legacySource, - gatewayPath, - "--target-format", - "rgw", - "--target-env", - envPath, - "--json-events", - ], - progress: { - title: `Migrating requirements for ${path.basename(env.enviroPath)}`, - logPrefix: "panreq", - }, - }); - if (cancelled) return false; - updateRequirementsAvailability(env.enviroPath); - return true; - } catch (err) { - logCliError( - `Failed to migrate ${env.legacySource}: ${err instanceof Error ? err.message : err}`, - true - ); - return false; - } + return importRequirementsFromPath(env.enviroPath, env.legacySource, { + progressTitle: `Migrating requirements for ${path.basename(env.enviroPath)}`, + }); } /** diff --git a/src/requirements/requirementsOperations.ts b/src/requirements/requirementsOperations.ts index 43be4f88..814a1e94 100644 --- a/src/requirements/requirementsOperations.ts +++ b/src/requirements/requirementsOperations.ts @@ -270,6 +270,58 @@ const EXT_TO_FORMAT: Record = { ".json": "json", }; +/** + * Import a requirements file (xlsx / csv / json) into the env's RGW. + * Sets up the default gateway location if VCAST_REPOSITORY is unset, runs + * panreq with the standard cancellable progress notification, then + * refreshes the test pane and the availability context key. + * + * Returns whether the import actually completed (false on user cancel of + * the progress, panreq failure, or other thrown errors). Does not show + * success messages — caller decides whether/how to celebrate. + */ +export async function importRequirementsFromPath( + enviroPath: string, + sourcePath: string, + options: { progressTitle: string } +): Promise { + const parentDir = path.dirname(enviroPath); + const envPath = path.join(parentDir, `${path.basename(enviroPath)}.env`); + + let gatewayPath = findRelevantRequirementGateway(enviroPath); + if (!gatewayPath) { + gatewayPath = defaultRequirementGatewayPath(enviroPath); + fs.mkdirSync(path.dirname(gatewayPath), { recursive: true }); + setVcastRepositoryInConfig(enviroPath, gatewayPath); + } + + try { + const { cancelled } = await runReqs2xTool({ + exe: PANREQ_EXECUTABLE_PATH, + args: [ + sourcePath, + gatewayPath, + "--target-format", + "rgw", + "--target-env", + envPath, + "--json-events", + ], + progress: { title: options.progressTitle, logPrefix: "panreq" }, + }); + if (cancelled) return false; + + await refreshAllExtensionData(); + updateRequirementsAvailability(enviroPath); + return true; + } catch (err) { + const message = `Error: ${err instanceof Error ? err.message : String(err)}`; + vscode.window.showErrorMessage(message); + logCliError(message, true); + return false; + } +} + export async function importRequirements(enviroPath: string) { const sourceUris = await vscode.window.showOpenDialog({ canSelectMany: false, @@ -292,68 +344,36 @@ export async function importRequirements(enviroPath: string) { return; } - const parentDir = path.dirname(enviroPath); - const lowestDirname = path.basename(enviroPath); - const envName = `${lowestDirname}.env`; - const envPath = path.join(parentDir, envName); - - // Resolve target gateway: existing or set up the default. Mirrors - // generateRequirements so import behaves consistently when no - // VCAST_REPOSITORY is configured yet. - let gatewayPath = findRelevantRequirementGateway(enviroPath); - if (gatewayPath) { + // If a gateway already exists we ask first — the user may have come here + // by mistake. The actual setup-and-run happens in the shared helper. + const existingGateway = findRelevantRequirementGateway(enviroPath); + if (existingGateway) { const choice = await vscode.window.showWarningMessage( - `Importing will overwrite the existing requirements gateway at ${gatewayPath}.`, + `Importing will overwrite the existing requirements gateway at ${existingGateway}.`, "Continue", "Cancel" ); if (choice !== "Continue") return; - } else { - gatewayPath = defaultRequirementGatewayPath(enviroPath); - fs.mkdirSync(path.dirname(gatewayPath), { recursive: true }); - setVcastRepositoryInConfig(enviroPath, gatewayPath); } - const args = [ - sourcePath, - gatewayPath, - "--target-format", - "rgw", - "--target-env", - envPath, - "--json-events", - ]; - - try { - const { cancelled } = await runReqs2xTool({ - exe: PANREQ_EXECUTABLE_PATH, - args, - progress: { - title: `Importing Requirements for ${lowestDirname}`, - logPrefix: "panreq", - }, - }); - if (cancelled) return; + const enviroName = path.basename(enviroPath); + const ok = await importRequirementsFromPath(enviroPath, sourcePath, { + progressTitle: `Importing Requirements for ${enviroName}`, + }); + if (!ok) return; - await refreshAllExtensionData(); - updateRequirementsAvailability(enviroPath); - vscode.window.showInformationMessage( - `Successfully imported requirements from ${path.basename(sourcePath)}` - ); + vscode.window.showInformationMessage( + `Successfully imported requirements from ${path.basename(sourcePath)}` + ); - // Same prompt as generateTestsFromRequirements: imported requirements - // typically lack code traceability. Skip-vs-infer; "Skip" leaves the user - // free to set traceability manually in the editor later. - await offerTraceabilityInferenceIfMissing(enviroPath, { - prompt: - "None of the imported requirements trace to a function. Would you like to infer traceability automatically?", - cancelMeansAbort: false, - }); - } catch (err) { - const message = `Error: ${err instanceof Error ? err.message : String(err)}`; - vscode.window.showErrorMessage(message); - logCliError(message, true); - } + // Same prompt as generateTestsFromRequirements: imported requirements + // typically lack code traceability. Skip-vs-infer; "Skip" leaves the user + // free to set traceability manually in the editor later. + await offerTraceabilityInferenceIfMissing(enviroPath, { + prompt: + "None of the imported requirements trace to a function. Would you like to infer traceability automatically?", + cancelMeansAbort: false, + }); } export async function exportRequirements(enviroPath: string) { diff --git a/src/requirements/webview/messages.ts b/src/requirements/webview/messages.ts index cdbdf960..e3176d8e 100644 --- a/src/requirements/webview/messages.ts +++ b/src/requirements/webview/messages.ts @@ -1,14 +1,29 @@ import type { RGWFileMtimes, + RGWRequirement, RGWRequirementsFile, + RGWTraceabilityEntry, RGWTraceabilityFile, } from "../rgwIo"; -import type { RequirementGroup } from "./template"; // Wire protocol for the requirements webview ↔ extension. // Discriminated unions on `type`. The webview script and extension handler // both branch on these — keep them in sync. +// --------- Card payloads carried by saved/inferred ------------------------- + +export interface RequirementEntry { + source: string; + id: string; + req: RGWRequirement; + trace: RGWTraceabilityEntry; +} + +export interface RequirementGroup { + name: string; + entries: RequirementEntry[]; +} + // --------- Webview → Extension --------------------------------------------- export interface SaveMessage { @@ -24,7 +39,18 @@ export interface InferTraceabilityMessage { type: "infer-traceability"; } -export type FromWebview = SaveMessage | InferTraceabilityMessage; +export interface OpenSourceMessage { + type: "open-source"; + /** Unit name as it appears in `envData.unitData[i]` (matches source basename). */ + unit: string; + /** Function name within the unit, or null to just open the file. */ + function: string | null; +} + +export type FromWebview = + | SaveMessage + | InferTraceabilityMessage + | OpenSourceMessage; // --------- Extension → Webview --------------------------------------------- diff --git a/src/requirements/webview/template.ts b/src/requirements/webview/template.ts index 945694b6..a14f608d 100644 --- a/src/requirements/webview/template.ts +++ b/src/requirements/webview/template.ts @@ -5,6 +5,7 @@ import { type RGWRequirement, type RGWTraceabilityEntry, } from "../rgwIo"; +import type { RequirementEntry, RequirementGroup } from "./messages"; const path = require("path"); @@ -31,24 +32,6 @@ function serializeStateForScriptTag(state: unknown): string { .replace(/\u2029/g, "\\u2029"); } -/** - * Render the grouped headings + cards for a bundle. Exported so the extension - * can call it again after save/infer to refresh the headings — they reflect - * the current `function || unit || source` grouping, which can change as the - * user edits traceability. - */ -export interface RequirementEntry { - source: string; - id: string; - req: RGWRequirement; - trace: RGWTraceabilityEntry; -} - -export interface RequirementGroup { - name: string; - entries: RequirementEntry[]; -} - /** * Flatten the bundle into ordered groups keyed by `function || unit || * source`. Used by both the TS-side initial render and (sent verbatim to @@ -217,6 +200,9 @@ function renderCard( })}
+
+ +
`; } diff --git a/src/requirements/webviews/css/requirements.css b/src/requirements/webviews/css/requirements.css index 5eb54041..4c02c064 100644 --- a/src/requirements/webviews/css/requirements.css +++ b/src/requirements/webviews/css/requirements.css @@ -77,6 +77,27 @@ h2 { color: var(--vscode-inputValidation-errorBorder, #be1100); margin-top: 2px; } +.open-source-row { + margin-top: 6px; + text-align: right; +} +.open-source-btn { + background: transparent; + border: 1px solid var(--vscode-input-border, #ccc); + color: var(--vscode-foreground, #000); + cursor: pointer; + border-radius: 4px; + padding: 2px 10px; + font-size: 0.85em; + font-family: inherit; +} +.open-source-btn:hover:not(:disabled) { + background: var(--vscode-button-secondaryBackground, var(--vscode-editorWidget-background, #eee)); +} +.open-source-btn:disabled { + opacity: 0.4; + cursor: default; +} .req-id { font-weight: 600; color: var(--vscode-textLink-foreground, #2980b9); diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js index edb37f03..25e9724a 100644 --- a/src/requirements/webviews/webviewScripts/requirements.js +++ b/src/requirements/webviews/webviewScripts/requirements.js @@ -31,9 +31,14 @@ // ---------- State helpers ---------------------------------------------- function existingKeys() { + // Soft-removed keys don't count: the user can add a fresh requirement + // with the same key in the same save (the Save flow drops the removed + // entry before inserting the new one). const keys = new Set(); for (const bucket of Object.values(state.requirements)) { - for (const k of Object.keys(bucket)) keys.add(k); + for (const k of Object.keys(bucket)) { + if (!removed.has(k)) keys.add(k); + } } return keys; } @@ -194,6 +199,7 @@ card.appendChild( buildTraceRow(entry.trace.unit, entry.trace.function, { reqId: entry.id }) ); + card.appendChild(buildOpenSourceRow(entry.trace.unit ?? "")); return card; } @@ -263,11 +269,25 @@ card.appendChild( buildTraceRow(pending.unit, pending.function, { tempId: pending.tempId }) ); + card.appendChild(buildOpenSourceRow(pending.unit ?? "")); validatePendingKey(pending, keyInput, keyError); return card; } + function buildOpenSourceRow(unit) { + const row = document.createElement("div"); + row.className = "open-source-row"; + const btn = document.createElement("button"); + btn.className = "open-source-btn"; + btn.dataset.action = "open-source"; + btn.textContent = "↗ Open source"; + btn.title = "Open the unit's source file at the function definition."; + btn.disabled = !unit; + row.appendChild(btn); + return row; + } + function rebuildBody(groups) { const frag = document.createDocumentFragment(); for (const group of groups) { @@ -382,6 +402,7 @@ if (fnSelect) { pending.function = fnSelect.value === "" ? null : fnSelect.value; } + updateOpenSourceForCard(card, pending.unit); } } refreshButtonStates(); @@ -417,9 +438,16 @@ setDirty(reqId, "trace", "function", newFn); fnSelect.classList.add("dirty"); } + updateOpenSourceForCard(card, value); } } + function updateOpenSourceForCard(card, unit) { + if (!card) return; + const btn = card.querySelector(".open-source-btn"); + if (btn) btn.disabled = !unit; + } + reqsBody.addEventListener("input", (e) => { const t = e.target; if ( @@ -475,6 +503,9 @@ btn.title = "Restore this requirement"; } } + // A soft-delete or restore can flip the validity of pending-add keys + // that were colliding/uncolliding with the toggled requirement. + revalidateAllPendingKeys(); refreshButtonStates(); } @@ -496,12 +527,27 @@ reqsBody.addEventListener("click", (e) => { const t = e.target; - if (!t || !t.matches || !t.matches(".req-remove-btn")) return; - const action = t.dataset.action; - if (action === "remove") { - toggleRemoveExisting(t.dataset.reqId); - } else if (action === "discard-added") { - discardPendingAdd(t.dataset.tempId); + if (!t || !t.matches) return; + if (t.matches(".req-remove-btn")) { + const action = t.dataset.action; + if (action === "remove") { + toggleRemoveExisting(t.dataset.reqId); + } else if (action === "discard-added") { + discardPendingAdd(t.dataset.tempId); + } + } else if (t.matches(".open-source-btn") && !t.disabled) { + const card = t.closest(".req"); + if (!card) return; + const unitEl = card.querySelector('[data-scope="trace"][data-field="unit"]'); + const fnEl = card.querySelector('[data-scope="trace"][data-field="function"]'); + const unit = unitEl ? unitEl.value : ""; + const fn = fnEl ? fnEl.value : ""; + if (!unit) return; + vscode.postMessage({ + type: "open-source", + unit, + function: fn === "" ? null : fn, + }); } }); From 6bd545043dab84b587d864ada03b0b7b9a140a0e Mon Sep 17 00:00:00 2001 From: Patrick Bareiss Date: Mon, 4 May 2026 14:39:29 +0200 Subject: [PATCH 13/14] Add search field + unit/function filter dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sticky bar above the cards section combines: - Free-text search across id/title/description/unit/function (case- insensitive, AND of whitespace-separated tokens; matches against a data-haystack attribute kept in sync with the inputs). - Unit dropdown: (Any) / (Not set) / each env unit. Sourced from state.unitsToFunctions so brand-new units show up before any requirement traces to them — that's exactly when filtering down to "(Not set)" is most useful. Unioned with state.traceability so any "(not in env)" stragglers in legacy data remain filterable. - Function dropdown: same shape. - "X of Y" match count when any filter is active. Other: - enableFindWidget on the webview so Ctrl+F gets the standard text-find overlay too. - Bar styled subtly: smaller padding, no border-bottom, 0.9em font. - Pending-add cards always shown regardless of filter (don't yank a half-typed new requirement under the user). Group headings collapse when all their non-pending cards are hidden. - Filter persists across saved/inferred — rebuilds run after every body refresh. - refreshCardHaystack updates the haystack on every field edit so a fresh edit is searchable without a save round-trip. The filter does not re-run on edits — that would yank a card the user is actively editing. - Changing a card's unit now clears the function instead of carrying it forward as "(not in env)". Stale function on a new unit would silently produce mismatched traceability. --- src/extension.ts | 1 + src/requirements/webview/template.ts | 16 +++++- .../webviews/css/requirements.css | 48 ++++++++++++++++++ .../webviews/webviewScripts/requirements.js | Bin 22009 -> 28510 bytes 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index f75d5739..11763e6a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1489,6 +1489,7 @@ function configureExtension(context: vscode.ExtensionContext) { vscode.ViewColumn.One, { enableScripts: true, + enableFindWidget: true, // Ctrl+F text-find inside the webview retainContextWhenHidden: true, localResourceRoots: [vscode.Uri.file(webviewBaseDir)], } diff --git a/src/requirements/webview/template.ts b/src/requirements/webview/template.ts index a14f608d..c898e5f1 100644 --- a/src/requirements/webview/template.ts +++ b/src/requirements/webview/template.ts @@ -139,6 +139,7 @@ export function generateRequirementsHtml(
RGW: ${escapeHtml(bundle.gatewayPath)}
${bannerHtml}
${policy.bodiesEditable ? `` : ""}
+
${body}
@@ -164,8 +165,21 @@ function renderCard( ? `` : ""; + // Search haystack: lowercased concat of fields the in-page filter + // matches against. Kept as a data attribute so the script doesn't have + // to read all the inputs on every keystroke. + const haystack = [ + entry.id, + entry.req.title ?? "", + entry.req.description ?? "", + entry.trace.unit ?? "", + entry.trace.function ?? "", + ] + .join(" ") + .toLowerCase(); + return ` -
+
${escapeHtml(entry.id)}
${lastMod ? `modified: ${escapeHtml(lastMod)} · ` : ""}source: ${escapeHtml(entry.source)}${removeBtn ? ` ${removeBtn}` : ""}
diff --git a/src/requirements/webviews/css/requirements.css b/src/requirements/webviews/css/requirements.css index 4c02c064..c60361fb 100644 --- a/src/requirements/webviews/css/requirements.css +++ b/src/requirements/webviews/css/requirements.css @@ -145,6 +145,54 @@ h2 { outline: 2px solid var(--vscode-inputValidation-infoBorder, #9cc); } +#search-bar { + position: sticky; + top: 0; + z-index: 50; + background: var(--vscode-editor-background, #fff); + padding: 4px 0 6px; + margin-bottom: 4px; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + font-size: 0.9em; +} +#search-input, +#filter-unit, +#filter-function { + background: var(--vscode-input-background, #fff); + color: var(--vscode-input-foreground, #000); + border: 1px solid var(--vscode-input-border, #ccc); + padding: 3px 8px; + font: inherit; + border-radius: 3px; +} +#search-input { + flex: 1; + min-width: 140px; +} +#search-input:focus, +#filter-unit:focus, +#filter-function:focus { + outline: 1px solid var(--vscode-focusBorder, #0078d4); +} +#filter-unit, +#filter-function { + max-width: 180px; +} +#search-count { + font-size: 0.85em; + color: var(--vscode-descriptionForeground, #888); + white-space: nowrap; + margin-left: auto; +} + +.req--hidden, +.h2--hidden { + display: none; +} + #rgw-pill { position: fixed; bottom: 16px; diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js index 25e9724a9c5f2858c03eecf6441315bdecc727fe..fd0a8c7996264cb3e73f1b37c4a555168da11e6c 100644 GIT binary patch delta 6404 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 Date: Mon, 4 May 2026 15:40:52 +0200 Subject: [PATCH 14/14] Unify card rendering, drop data-attr mirror, split webview script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three refactors from the latest retrospective, applied together because they overlap in the same files. 1. One renderer (was: TS-side renderCard + JS-side buildCard). The TS template now ships an empty
in the chrome; the script builds every card via createElement / textContent on init and on every saved/inferred refresh. renderCard, renderRequirementsBody, renderTraceField, groupRequirements and the RequirementGroup / RequirementEntry wire types are gone — the discriminated unions in messages.ts no longer carry a `groups` field either, so the wire protocol stays data-only and there's exactly one card renderer to evolve. Adding a new field is now a single edit in cards.js. Still no innerHTML on the postMessage path; the SonarQube finding stays addressed. 2. No data-attribute mirror. data-haystack / data-trace-unit / data-trace-function on cards (kept in sync via refreshCardHaystack / haystackFor) are dropped. applyFilter reads each card's live inputs directly — mid-edit values are searchable immediately without any per-edit bookkeeping. 3. Script split into 5 modules. state.js (63 lines) — pending collections (dirty / removed / added), DOM refs, refreshButtonStates, setDirty, existingKeys. cards.js (500 lines) — DOM builders, grouping, rebuildBody, edit handlers, add/remove/discard. filter.js (163 lines) — search + dropdowns + NOT_SET sentinel. save.js (108 lines) — buildSaveUpdates, postSave, postInfer, applyRefreshedBundle. requirements.js (116 lines) — entry: wires DOM events, dispatches inbound messages, kicks off the initial rebuildBody(). Loaded as - + `; } - -function renderCard( - entry: { - source: string; - id: string; - req: RGWRequirement; - trace: RGWTraceabilityEntry; - }, - editable: boolean, - unitsToFunctions: Record | null -): string { - const idAttrs = `data-req-id="${escapeHtml(entry.id)}"`; - const reqDis = editable ? "" : "disabled"; - const lastMod = entry.req.last_modified ?? ""; - - const removeBtn = editable - ? `` - : ""; - - // Search haystack: lowercased concat of fields the in-page filter - // matches against. Kept as a data attribute so the script doesn't have - // to read all the inputs on every keystroke. - const haystack = [ - entry.id, - entry.req.title ?? "", - entry.req.description ?? "", - entry.trace.unit ?? "", - entry.trace.function ?? "", - ] - .join(" ") - .toLowerCase(); - - return ` -
-
-
${escapeHtml(entry.id)}
-
${lastMod ? `modified: ${escapeHtml(lastMod)} · ` : ""}source: ${escapeHtml(entry.source)}${removeBtn ? ` ${removeBtn}` : ""}
-
-
- - -
-
- - -
-
-
- - ${renderTraceField({ - field: "unit", - current: entry.trace.unit ?? "", - options: unitsToFunctions ? Object.keys(unitsToFunctions) : null, - idAttrs, - })} -
-
- - ${renderTraceField({ - field: "function", - current: entry.trace.function ?? "", - options: unitsToFunctions - ? unitsToFunctions[entry.trace.unit ?? ""] ?? [] - : null, - idAttrs, - })} -
-
-
- -
-
- `; -} - -/** - * Render a traceability field as either a free-text input (when `options` is - * null — env data wasn't available) or a ``; - } - const opts = [ - ``, - ]; - let found = current === ""; - for (const o of options) { - const sel = o === current ? " selected" : ""; - if (o === current) found = true; - opts.push(``); - } - if (!found) { - opts.push( - `` - ); - } - return ``; -} - diff --git a/src/requirements/webviews/webviewScripts/cards.js b/src/requirements/webviews/webviewScripts/cards.js new file mode 100644 index 00000000..a3eb3a2d --- /dev/null +++ b/src/requirements/webviews/webviewScripts/cards.js @@ -0,0 +1,500 @@ +// DOM construction + grouping + edit handling for requirement cards. + +import { + state, + dirty, + removed, + added, + reqsBody, + setDirty, + refreshButtonStates, + existingKeys, + nextTempIdValue, +} from "./state.js"; +import { rebuildFilterDropdowns, applyFilter } from "./filter.js"; + +// ---------- Low-level DOM builders --------------------------------------- + +function buildField(label, child) { + const div = document.createElement("div"); + div.className = "field"; + const lbl = document.createElement("label"); + lbl.textContent = label; + div.appendChild(lbl); + div.appendChild(child); + return div; +} + +function buildTraceField(field, current, options, refAttrs) { + let el; + if (!options) { + el = document.createElement("input"); + el.type = "text"; + el.value = current ?? ""; + } else { + el = document.createElement("select"); + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "(none)"; + if (!current) noneOpt.selected = true; + el.appendChild(noneOpt); + + let found = !current; + for (const o of options) { + const opt = document.createElement("option"); + opt.value = o; + opt.textContent = o; + if (o === current) { + opt.selected = true; + found = true; + } + el.appendChild(opt); + } + if (!found) { + const opt = document.createElement("option"); + opt.value = current; + opt.textContent = current + " (not in env)"; + opt.selected = true; + el.appendChild(opt); + } + } + el.dataset.scope = "trace"; + el.dataset.field = field; + Object.assign(el.dataset, refAttrs); + return el; +} + +function buildTraceRow(currentUnit, currentFn, refAttrs) { + const traceRow = document.createElement("div"); + traceRow.className = "field trace-row"; + + const unitOptions = state.unitsToFunctions + ? Object.keys(state.unitsToFunctions) + : null; + const unitField = buildTraceField( + "unit", + currentUnit ?? "", + unitOptions, + refAttrs + ); + const fnOptions = state.unitsToFunctions + ? state.unitsToFunctions[currentUnit ?? ""] ?? [] + : null; + const fnField = buildTraceField( + "function", + currentFn ?? "", + fnOptions, + refAttrs + ); + + const unitWrap = document.createElement("div"); + const unitLabel = document.createElement("label"); + unitLabel.textContent = "Traceability: unit"; + unitWrap.appendChild(unitLabel); + unitWrap.appendChild(unitField); + + const fnWrap = document.createElement("div"); + const fnLabel = document.createElement("label"); + fnLabel.textContent = "Traceability: function"; + fnWrap.appendChild(fnLabel); + fnWrap.appendChild(fnField); + + traceRow.appendChild(unitWrap); + traceRow.appendChild(fnWrap); + return traceRow; +} + +function buildOpenSourceRow(unit) { + const row = document.createElement("div"); + row.className = "open-source-row"; + const btn = document.createElement("button"); + btn.className = "open-source-btn"; + btn.dataset.action = "open-source"; + btn.textContent = "↗ Open source"; + btn.title = "Open the unit's source file at the function definition."; + btn.disabled = !unit; + row.appendChild(btn); + return row; +} + +function buildRemoveButton(reqId) { + const btn = document.createElement("button"); + btn.className = "req-remove-btn"; + btn.dataset.action = "remove"; + btn.dataset.reqId = reqId; + btn.title = "Remove this requirement"; + btn.textContent = "×"; + return btn; +} + +// ---------- Card builders ------------------------------------------------ + +export function buildCard(entry) { + const policy = state.policy; + const lockedBodies = !policy.bodiesEditable; + + const card = document.createElement("div"); + card.className = "req"; + card.dataset.reqId = entry.id; + if (removed.has(entry.id)) card.classList.add("req--pending-removal"); + + const header = document.createElement("div"); + header.className = "req-header"; + + const id = document.createElement("div"); + id.className = "req-id"; + id.textContent = entry.id; + header.appendChild(id); + + const meta = document.createElement("div"); + meta.className = "req-meta"; + const lastMod = entry.req.last_modified ?? ""; + meta.textContent = + (lastMod ? "modified: " + lastMod + " · " : "") + + "source: " + + entry.source; + if (policy.bodiesEditable) { + meta.appendChild(document.createTextNode(" ")); + meta.appendChild(buildRemoveButton(entry.id)); + } + header.appendChild(meta); + card.appendChild(header); + + const titleInput = document.createElement("input"); + titleInput.type = "text"; + titleInput.dataset.reqId = entry.id; + titleInput.dataset.scope = "req"; + titleInput.dataset.field = "title"; + titleInput.value = entry.req.title ?? ""; + if (lockedBodies) titleInput.disabled = true; + card.appendChild(buildField("Title", titleInput)); + + const descArea = document.createElement("textarea"); + descArea.dataset.reqId = entry.id; + descArea.dataset.scope = "req"; + descArea.dataset.field = "description"; + descArea.value = entry.req.description ?? ""; + if (lockedBodies) descArea.disabled = true; + card.appendChild(buildField("Description", descArea)); + + card.appendChild( + buildTraceRow(entry.trace.unit, entry.trace.function, { reqId: entry.id }) + ); + card.appendChild(buildOpenSourceRow(entry.trace.unit ?? "")); + return card; +} + +export function buildPendingAddCard(pending) { + const card = document.createElement("div"); + card.className = "req req--pending-added"; + card.dataset.tempId = pending.tempId; + + const header = document.createElement("div"); + header.className = "req-header"; + + const id = document.createElement("div"); + id.className = "req-id"; + id.textContent = "[New requirement]"; + header.appendChild(id); + + const meta = document.createElement("div"); + meta.className = "req-meta"; + const discardBtn = document.createElement("button"); + discardBtn.className = "req-remove-btn"; + discardBtn.dataset.action = "discard-added"; + discardBtn.dataset.tempId = pending.tempId; + discardBtn.title = "Discard this new requirement"; + discardBtn.textContent = "×"; + meta.appendChild(discardBtn); + header.appendChild(meta); + card.appendChild(header); + + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.dataset.tempId = pending.tempId; + keyInput.dataset.field = "key"; + keyInput.value = pending.key; + keyInput.placeholder = "Required: unique key under which this is stored"; + const keyField = buildField("Key", keyInput); + const keyError = document.createElement("div"); + keyError.className = "field-error"; + keyError.dataset.tempId = pending.tempId; + keyError.dataset.role = "key-error"; + keyField.appendChild(keyError); + card.appendChild(keyField); + + const titleInput = document.createElement("input"); + titleInput.type = "text"; + titleInput.dataset.tempId = pending.tempId; + titleInput.dataset.field = "title"; + titleInput.value = pending.title; + card.appendChild(buildField("Title", titleInput)); + + const descArea = document.createElement("textarea"); + descArea.dataset.tempId = pending.tempId; + descArea.dataset.field = "description"; + descArea.value = pending.description; + card.appendChild(buildField("Description", descArea)); + + card.appendChild( + buildTraceRow(pending.unit, pending.function, { tempId: pending.tempId }) + ); + card.appendChild(buildOpenSourceRow(pending.unit ?? "")); + + validatePendingKey(pending, keyInput, keyError); + return card; +} + +// ---------- Grouping + body rebuild -------------------------------------- + +/** + * Flatten the bundle in `state` into ordered groups keyed by + * `function || unit || source`. Single source of truth for grouping — + * the wire protocol doesn't ship pre-grouped data, so this is what + * everyone (initial load + every saved/inferred refresh) uses. + */ +function groupRequirementsFromState() { + const flat = []; + for (const [source, bucket] of Object.entries(state.requirements)) { + for (const [id, req] of Object.entries(bucket)) { + flat.push({ + source, + id, + req, + trace: state.traceability[id] || { + unit: null, + function: null, + lines: null, + }, + }); + } + } + const buckets = {}; + const order = []; + for (const entry of flat) { + const key = + entry.trace.function || + entry.trace.unit || + entry.source || + "Unknown"; + if (!buckets[key]) { + buckets[key] = []; + order.push(key); + } + buckets[key].push(entry); + } + return order.map((name) => ({ name, entries: buckets[name] })); +} + +export function rebuildBody() { + const groups = groupRequirementsFromState(); + const frag = document.createDocumentFragment(); + for (const group of groups) { + const h2 = document.createElement("h2"); + h2.textContent = group.name; + frag.appendChild(h2); + for (const entry of group.entries) { + frag.appendChild(buildCard(entry)); + } + } + // Pending-added cards live below the persisted groups so the user + // sees them clearly until they save. + for (const pending of added) { + frag.appendChild(buildPendingAddCard(pending)); + } + reqsBody.replaceChildren(frag); + rebuildFilterDropdowns(); + applyFilter(); +} + +// ---------- Pending-key validation --------------------------------------- + +export function validatePendingKey(pending, inputEl, errorEl) { + const key = pending.key.trim(); + let problem = ""; + if (!key) { + problem = "Key is required."; + } else if (existingKeys().has(key)) { + problem = "Collides with an existing requirement key."; + } else if (added.some((a) => a !== pending && a.key.trim() === key)) { + problem = "Two pending new requirements share this key."; + } + pending.keyValid = problem === ""; + inputEl.classList.toggle("invalid", !pending.keyValid); + if (errorEl) errorEl.textContent = problem; +} + +export function revalidateAllPendingKeys() { + for (const pending of added) { + const card = reqsBody.querySelector( + `.req--pending-added[data-temp-id="${pending.tempId}"]` + ); + if (!card) continue; + const inputEl = card.querySelector('[data-field="key"]'); + const errorEl = card.querySelector('[data-role="key-error"]'); + if (inputEl) validatePendingKey(pending, inputEl, errorEl); + } +} + +// ---------- Field-change handling ---------------------------------------- + +function refreshFunctionOptions(card, selectedUnit, currentFunction) { + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (!fnSelect || fnSelect.tagName !== "SELECT") return; + const map = state.unitsToFunctions || {}; + const fns = selectedUnit && map[selectedUnit] ? map[selectedUnit] : []; + const desired = currentFunction != null ? String(currentFunction) : ""; + + while (fnSelect.firstChild) fnSelect.removeChild(fnSelect.firstChild); + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "(none)"; + if (!desired) noneOpt.selected = true; + fnSelect.appendChild(noneOpt); + + let found = !desired; + for (const fn of fns) { + const opt = document.createElement("option"); + opt.value = fn; + opt.textContent = fn; + if (fn === desired) { + opt.selected = true; + found = true; + } + fnSelect.appendChild(opt); + } + if (!found) { + const opt = document.createElement("option"); + opt.value = desired; + opt.textContent = desired + " (not in env)"; + opt.selected = true; + fnSelect.appendChild(opt); + } +} + +function pendingForTempId(tempId) { + return added.find((a) => a.tempId === tempId); +} + +function updateOpenSourceForCard(card, unit) { + if (!card) return; + const btn = card.querySelector(".open-source-btn"); + if (btn) btn.disabled = !unit; +} + +export function handleFieldChange(el) { + // Pending-added card: route the edit into the `added` entry directly. + if (el.dataset.tempId) { + const pending = pendingForTempId(el.dataset.tempId); + if (!pending) return; + const field = el.dataset.field; + const value = el.value; + if (field === "key") { + pending.key = value; + const card = el.closest(".req"); + const errorEl = card?.querySelector('[data-role="key-error"]'); + validatePendingKey(pending, el, errorEl); + } else if (field === "title" || field === "description") { + pending[field] = value; + } else if (field === "unit" || field === "function") { + pending[field] = value === "" ? null : value; + if (field === "unit") { + // Drop the function when the unit changes — keeping a stale + // function would create mismatched traceability the user almost + // certainly didn't intend. + const card = el.closest(".req"); + pending.function = null; + refreshFunctionOptions(card, pending.unit, null); + updateOpenSourceForCard(card, pending.unit); + } + } + refreshButtonStates(); + return; + } + + // Existing card edit. + const reqId = el.dataset.reqId; + if (removed.has(reqId)) return; // ignore edits to soft-deleted cards + const scope = el.dataset.scope; + const field = el.dataset.field; + let value = el.value; + if (field === "unit" || field === "function") { + value = value === "" ? null : value; + } + setDirty(reqId, scope, field, value); + el.classList.add("dirty"); + + if (field === "unit") { + // Same rule as for pending cards: drop the function on unit change. + const card = el.closest(".req"); + refreshFunctionOptions(card, value, null); + setDirty(reqId, "trace", "function", null); + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (fnSelect) fnSelect.classList.add("dirty"); + updateOpenSourceForCard(card, value); + } + + // The filter reads input values directly at apply time, so a fresh + // edit becomes searchable immediately without any per-edit bookkeeping + // here. We don't re-apply the filter on edit because doing so could + // yank a card the user is actively editing out from under them. +} + +// ---------- Add / remove ------------------------------------------------- + +export function appendPendingAdd() { + const pending = { + tempId: nextTempIdValue(), + key: "", + title: "", + description: "", + unit: null, + function: null, + keyValid: false, + }; + added.push(pending); + reqsBody.appendChild(buildPendingAddCard(pending)); + refreshButtonStates(); +} + +export function toggleRemoveExisting(reqId) { + const card = reqsBody.querySelector(`.req[data-req-id="${reqId}"]`); + if (!card) return; + if (removed.has(reqId)) { + removed.delete(reqId); + card.classList.remove("req--pending-removal"); + const btn = card.querySelector(".req-remove-btn"); + if (btn) { + btn.textContent = "×"; + btn.title = "Remove this requirement"; + } + } else { + removed.add(reqId); + card.classList.add("req--pending-removal"); + const btn = card.querySelector(".req-remove-btn"); + if (btn) { + btn.textContent = "↩"; + btn.title = "Restore this requirement"; + } + } + // A soft-delete or restore can flip the validity of pending-add keys + // that were colliding/uncolliding with the toggled requirement. + revalidateAllPendingKeys(); + refreshButtonStates(); +} + +export function discardPendingAdd(tempId) { + const idx = added.findIndex((a) => a.tempId === tempId); + if (idx === -1) return; + added.splice(idx, 1); + const card = reqsBody.querySelector( + `.req--pending-added[data-temp-id="${tempId}"]` + ); + if (card) card.remove(); + revalidateAllPendingKeys(); + refreshButtonStates(); +} diff --git a/src/requirements/webviews/webviewScripts/filter.js b/src/requirements/webviews/webviewScripts/filter.js new file mode 100644 index 00000000..d120cd4d --- /dev/null +++ b/src/requirements/webviews/webviewScripts/filter.js @@ -0,0 +1,163 @@ +// Search + unit/function filter for the requirements editor. + +import { + state, + filterUnit, + filterFunction, + searchInput, + searchCount, + reqsBody, +} from "./state.js"; + +// Sentinel value for "(Not set)" in the unit/function dropdowns. Real +// values are arbitrary strings; this one starts with NUL so it can't +// collide with a legitimate identifier. +export const NOT_SET = "\u0000not-set"; + +/** + * Populate the unit and function dropdowns. Options come from the env's + * canonical list (so a brand-new unit shows up before anything traces to + * it — that's exactly when filtering down to "(Not set)" is most useful) + * plus any "(not in env)" stragglers that legitimately appear in current + * traceability. Both dropdowns also offer "(Any)" and "(Not set)" — the + * latter is what to pick when looking for requirements that still need + * traceability. + */ +export function rebuildFilterDropdowns() { + if (!filterUnit || !filterFunction) return; + + const units = new Set(); + const fns = new Set(); + + const map = state.unitsToFunctions || {}; + for (const u of Object.keys(map)) { + units.add(u); + for (const f of map[u] || []) fns.add(f); + } + for (const trace of Object.values(state.traceability)) { + if (trace?.unit) units.add(trace.unit); + if (trace?.function) fns.add(trace.function); + } + + const fillSelect = (sel, label, items) => { + const previous = sel.value; + while (sel.firstChild) sel.removeChild(sel.firstChild); + + const any = document.createElement("option"); + any.value = ""; + any.textContent = `(Any ${label})`; + sel.appendChild(any); + + const none = document.createElement("option"); + none.value = NOT_SET; + none.textContent = "(Not set)"; + sel.appendChild(none); + + for (const v of [...items].sort()) { + const opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + sel.appendChild(opt); + } + + sel.value = [...sel.options].some((o) => o.value === previous) + ? previous + : ""; + }; + + fillSelect(filterUnit, "unit", units); + fillSelect(filterFunction, "function", fns); +} + +/** + * Hide cards that fail any of the active filters: the search text + * (substring AND on whitespace tokens, against the card's live inputs), + * the unit dropdown, and the function dropdown. "(Not set)" matches cards + * whose corresponding trace field is empty. Pending-add cards are always + * shown so a half-typed new requirement doesn't disappear under the user. + * Group headings collapse when all their cards are hidden. + */ +export function applyFilter() { + if (!searchInput) return; + const q = searchInput.value.trim().toLowerCase(); + const tokens = q ? q.split(/\s+/) : []; + const matchesText = (haystack) => + tokens.every((t) => haystack.includes(t)); + + const unitFilter = filterUnit ? filterUnit.value : ""; + const fnFilter = filterFunction ? filterFunction.value : ""; + const matchesField = (val, filter) => { + if (!filter) return true; + if (filter === NOT_SET) return !val; + return val === filter; + }; + + let currentHeader = null; + let currentHeaderHasVisible = false; + let totalReal = 0; + let visibleReal = 0; + + for (const el of reqsBody.children) { + if (el.tagName === "H2") { + if (currentHeader) { + currentHeader.classList.toggle( + "h2--hidden", + !currentHeaderHasVisible + ); + } + currentHeader = el; + currentHeaderHasVisible = false; + } else if (el.classList.contains("req")) { + if (el.classList.contains("req--pending-added")) { + el.classList.remove("req--hidden"); + continue; + } + totalReal++; + // Pull the searchable fields off the live inputs — that way a + // mid-edit value is searchable immediately without an extra + // bookkeeping step in the field-change path. + const reqId = el.dataset.reqId || ""; + const titleEl = el.querySelector('[data-field="title"]'); + const descEl = el.querySelector('[data-field="description"]'); + const unitEl = el.querySelector( + '[data-scope="trace"][data-field="unit"]' + ); + const fnEl = el.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + const unitVal = unitEl ? unitEl.value : ""; + const fnVal = fnEl ? fnEl.value : ""; + const haystack = ( + reqId + + " " + + (titleEl ? titleEl.value : "") + + " " + + (descEl ? descEl.value : "") + + " " + + unitVal + + " " + + fnVal + ).toLowerCase(); + const visible = + (tokens.length === 0 || matchesText(haystack)) && + matchesField(unitVal, unitFilter) && + matchesField(fnVal, fnFilter); + el.classList.toggle("req--hidden", !visible); + if (visible) { + currentHeaderHasVisible = true; + visibleReal++; + } + } + } + if (currentHeader) { + currentHeader.classList.toggle("h2--hidden", !currentHeaderHasVisible); + } + + if (searchCount) { + const filtersActive = + tokens.length > 0 || !!unitFilter || !!fnFilter; + searchCount.textContent = filtersActive + ? `${visibleReal} of ${totalReal}` + : ""; + } +} diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js index fd0a8c7996264cb3e73f1b37c4a555168da11e6c..38aee6194e325484095c073d4df678ac9132f16e 100644 GIT binary patch literal 3647 zcmcIn+iu%N5PjEIjQNmIh(*0SP>nd1Q5U(vcF~6-NLJ)h-jw1pyGuDjY@kp5fTCZR zFX@?CQlcH@0<`sucUQA#X3tzXozl5}-x{Y^x(=RnVS;sZtru&fuL);vlhLpE-P4aR zpOapTSg;hXG?lh>RB9Dkr$^H%t!&X&+8<%fTOuD+(AhhW<)=UXrbgF=sh6U~Q(71o zHZ&zDw`*NsrHVoqV|paIoGyKY$(1Yo4u%a`w5AfQN3?XdZ5Z~E0MnJKi%L6>J@BRx zJpz@c3fc)0+vBxz`4!~a+JtG@)_E|tro!2#u-A2`-K%x?26+T;sZpECssdMGCCItz zwRTUx;D=NC_S)1y%HF(j%WFA%J0|1F)bDg2@MPsl(a#qzU(lkm3tDW1SN$RA+QSsK zS_KkS!K`#fm!Mtffbr2P+%x)L{2ZddSU>Fi2w z_}rN$(Ap^Ss?_4K}wGrCw|DE(i0w z$eMN`HHBucmfB~*k2z2f8mH%QX!rXOeWb8y^bsXYbV4_jn7Y(%5}eAlTA0d&jT~Eq z)5IeLbBNtWsLPkY+YeRju)vONSi_`Lrt0=l(Psy_s&ie5(CuhsR*iLmK8}diKDUKF z#EOwlLd{D7>DGw9ZUrv_nLeV z=;nqFwnUGSE@($J;-d3I?}9YRK-46*gUKS)$+)LCdQruCI)m5PJzj?_9x>AqtQXW_NQ06!-Dd&Lc9jQp%i;9aWaJL5^;jiLq+PX+moOJ*nr}{!xc+=1~@+UurR2b zkp_-GFbM5xfhyPQjjV9qAiP7CSJrDEB8^G+5RUW%7TNL6T*a&0Rp%8hvq1K~MHjk2 zUgnrEQZbb1Vax>7(MHb_De=kcL3Yd%21#B&6!}hRL@(=qjoo+rz4)vcws(&>?iSfv zRjnqpCzPJuQ_C8nV$reu`%FxTVywGF#SW5j-FqGzV;VCa;S{^zz*`9yYyM-Le%-&} z2zp8KFrj0r+p2<*rF103fiM@V#l3oYX>GMo4wFo*%~W;u&VQjq4$CF#B|j{-xiODh zXm@)mXWpgs{jLPFy3YBPOMQEQaNk?MAuTNg;$mDJ@}%32yNORWdocO}Kj>Yo6ffNM z=<%Rm?}6AB+%IADoxR8D_ur3o&w=xPSfo#X{?*Su)Q?%;cH(H%4XK|uybeTaF}Kxw zZExk5hxS;0i!XT+>+6j$cCX_z7F=+rVoDO+#bO=1 zLGEUcglPL7k=?Kdl-{&GBUl>Ek_LRwBWFlYqbhVFp2WZHfjb#BVX(oRm5$@Lj*k-z z&qz&MVJgP}AE&GB=L&|V8lMyxybOND(8Exvd>r)AmQfrdJ%5^EijCh4#$gd{mTA&w Om?^fC9>4Sp1pEUfw1sW} literal 28510 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 diff --git a/src/requirements/webviews/webviewScripts/save.js b/src/requirements/webviews/webviewScripts/save.js new file mode 100644 index 00000000..20101ff8 --- /dev/null +++ b/src/requirements/webviews/webviewScripts/save.js @@ -0,0 +1,108 @@ +// Save-payload assembly + handler for refresh messages from the extension. + +import { + state, + dirty, + removed, + added, + vscode, + saveBtn, + inferBtn, + addBtn, + refreshButtonStates, +} from "./state.js"; +import { rebuildBody } from "./cards.js"; + +export function buildSaveUpdates() { + const updates = { + requirements: JSON.parse(JSON.stringify(state.requirements)), + traceability: JSON.parse(JSON.stringify(state.traceability)), + }; + + // 1. Drop soft-removed entries from every bucket and the trace map. + for (const reqId of removed) { + for (const bucket of Object.keys(updates.requirements)) { + if (updates.requirements[bucket][reqId]) { + delete updates.requirements[bucket][reqId]; + } + } + delete updates.traceability[reqId]; + } + + // 2. Apply in-place dirty patches (skipping any that are also removed). + for (const [reqId, patch] of dirty.entries()) { + if (removed.has(reqId)) continue; + if (state.policy.bodiesEditable && Object.keys(patch.req).length > 0) { + for (const bucket of Object.keys(updates.requirements)) { + if (updates.requirements[bucket][reqId]) { + Object.assign(updates.requirements[bucket][reqId], patch.req); + break; + } + } + } + if (Object.keys(patch.trace).length > 0) { + const existing = updates.traceability[reqId] || { + unit: null, + function: null, + lines: null, + }; + updates.traceability[reqId] = { ...existing, ...patch.trace }; + } + } + + // 3. Append pending-added entries to the first bucket. The save flow on + // the extension side normalizes everything into a single CSV-keyed + // bucket anyway, so the choice here doesn't matter. + let bucketKey = Object.keys(updates.requirements)[0]; + if (!bucketKey) { + bucketKey = "[CSV] [" + state.gatewayPath + "]"; + updates.requirements[bucketKey] = {}; + } + for (const pending of added) { + const key = pending.key.trim(); + updates.requirements[bucketKey][key] = { + id: key, + title: pending.title, + description: pending.description, + }; + if (pending.unit != null || pending.function != null) { + updates.traceability[key] = { + unit: pending.unit ?? null, + function: pending.function ?? null, + lines: null, + }; + } + } + + return updates; +} + +export function postSave() { + const updates = buildSaveUpdates(); + saveBtn.disabled = true; + inferBtn.disabled = true; + if (addBtn) addBtn.disabled = true; + vscode.postMessage({ + type: "save", + updates, + expectedMtimes: state.mtimes, + }); +} + +export function postInfer() { + inferBtn.disabled = true; + saveBtn.disabled = true; + if (addBtn) addBtn.disabled = true; + vscode.postMessage({ type: "infer-traceability" }); +} + +export function applyRefreshedBundle(msg) { + dirty.clear(); + removed.clear(); + added.length = 0; + state.mtimes = msg.mtimes; + state.requirements = msg.requirements; + state.traceability = msg.traceability; + rebuildBody(); + refreshButtonStates(); +} diff --git a/src/requirements/webviews/webviewScripts/state.js b/src/requirements/webviews/webviewScripts/state.js new file mode 100644 index 00000000..96a86d1a --- /dev/null +++ b/src/requirements/webviews/webviewScripts/state.js @@ -0,0 +1,63 @@ +// Shared mutable state for the requirements editor webview. +// Lives in one module so cards.js / filter.js / save.js / the entry script +// all reference the same singletons via ES-module live bindings. + +export const vscode = acquireVsCodeApi(); +export const state = window.__rgwState; + +// Pending edits split three ways: +// dirty — in-place edits to existing requirements (key -> {req, trace}) +// removed — keys of existing requirements marked for soft-deletion +// added — pending new requirements not yet on disk +// All three flush on Save and reset on a successful saved/inferred. +export const dirty = new Map(); +export const removed = new Set(); +export const added = []; // {tempId, key, title, description, unit, function, keyValid} + +let nextTempId = 1; +export function nextTempIdValue() { + return "__pending_" + nextTempId++ + "__"; +} + +// DOM refs the rest of the modules rely on. Looked up once at module load — +// `