From 73036f44e79a9f815451adc708eed649295723b4 Mon Sep 17 00:00:00 2001 From: 1837669410 <1837669410@qq.com> Date: Sun, 10 May 2026 13:36:08 +0800 Subject: [PATCH] Add backend functional tests --- .github/workflows/ci.yml | 5 +- package-lock.json | 362 ++++++++++++++++++ package.json | 3 + .../backend/__tests__/codeBlueprint.test.ts | 256 +++++++++++++ src/main/backend/__tests__/exportCode.test.ts | 133 +++++++ .../backend/__tests__/paperAnalyzer.test.ts | 267 +++++++++++++ src/main/backend/codeBlueprint.ts | 213 +++++++++++ src/main/backend/paperAnalyzer.ts | 225 +---------- vitest.config.ts | 8 + 9 files changed, 1254 insertions(+), 218 deletions(-) create mode 100644 src/main/backend/__tests__/codeBlueprint.test.ts create mode 100644 src/main/backend/__tests__/exportCode.test.ts create mode 100644 src/main/backend/__tests__/paperAnalyzer.test.ts create mode 100644 src/main/backend/codeBlueprint.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 936233b..f448f5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: jobs: build: - name: Build (${{ matrix.os }}) + name: Test and build (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -34,5 +34,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Run tests + run: npm run test + - name: Build run: npm run build diff --git a/package-lock.json b/package-lock.json index deeddb5..0c36986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "electron-builder": "^26.8.1", "typescript": "^5.6.3", "vite": "^6.0.3", + "vitest": "^4.1.5", "wait-on": "^8.0.1" } }, @@ -2093,6 +2094,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2102,6 +2114,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2284,6 +2303,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -2565,6 +2697,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2926,6 +3068,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3841,6 +3993,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3954,6 +4113,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5099,6 +5278,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -6318,6 +6507,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6411,6 +6611,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pdf-parse": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", @@ -7089,6 +7296,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -7199,6 +7413,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -7209,6 +7430,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7409,6 +7637,23 @@ "semver": "bin/semver" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7426,6 +7671,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -7844,6 +8099,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/wait-on": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", @@ -7890,6 +8235,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 86bcbad..e8177ad 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "dist": "npm run build && electron-builder --publish never", "dist:win": "npm run build && electron-builder --win --publish never", "dist:mac": "npm run build && electron-builder --mac --publish never", + "test": "vitest run", + "test:watch": "vitest", "dist:linux": "npm run build && electron-builder --linux --publish never" }, "dependencies": { @@ -44,6 +46,7 @@ "electron-builder": "^26.8.1", "typescript": "^5.6.3", "vite": "^6.0.3", + "vitest": "^4.1.5", "wait-on": "^8.0.1" }, "build": { diff --git a/src/main/backend/__tests__/codeBlueprint.test.ts b/src/main/backend/__tests__/codeBlueprint.test.ts new file mode 100644 index 0000000..e719918 --- /dev/null +++ b/src/main/backend/__tests__/codeBlueprint.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from 'vitest' +import { AppError, ErrorCodes } from '../errors' +import { parseCodeBlueprint, validateFilesAgainstBlueprint, extractJsonObject, validateGeneratedFilePath } from '../codeBlueprint' + +function makeMinimalBlueprintJson(): string { + return JSON.stringify({ + coreContribution: 'a novel loss function', + minimalImplementationBoundary: 'only the loss function, no training code', + files: [ + { + path: 'core_code/losses.py', + purpose: 'implements the proposed loss function', + mainSymbols: ['CustomLoss'], + mustInclude: ['forward pass', 'loss computation'], + mustNotInclude: ['training loop', 'model definition'], + evidence: 'Section 3.1 defines the loss formulation', + }, + ], + omitted: [{ item: 'training script', reason: 'not part of core contribution' }], + minimalityCheck: { + whyTheseFilesAreMinimal: 'only one file is needed for the loss', + couldAnyFileBeRemoved: false, + overGenerationRisk: 'low', + }, + }) +} + +describe('extractJsonObject', () => { + it('extracts JSON from plain text', () => { + const result = extractJsonObject('{"a": 1}') + expect(result).toEqual({ a: 1 }) + }) + + it('extracts JSON from markdown-fenced code block', () => { + const result = extractJsonObject('```json\n{"a": 1}\n```') + expect(result).toEqual({ a: 1 }) + }) + + it('extracts JSON from fenced block without language', () => { + const result = extractJsonObject('```\n{"b": 2}\n```') + expect(result).toEqual({ b: 2 }) + }) + + it('throws on empty input', () => { + expect(() => extractJsonObject('')).toThrow(AppError) + }) + + it('throws on malformed JSON', () => { + expect(() => extractJsonObject('{bad')).toThrow(AppError) + }) +}) + +describe('validateGeneratedFilePath', () => { + it('accepts a normal relative path', () => { + expect(validateGeneratedFilePath('core_code/losses.py')).toBe('core_code/losses.py') + }) + + it('normalizes backslashes', () => { + expect(validateGeneratedFilePath('core_code\\losses.py')).toBe('core_code/losses.py') + }) + + it('rejects absolute paths starting with /', () => { + expect(() => validateGeneratedFilePath('/etc/passwd')).toThrow(AppError) + }) + + it('rejects absolute paths with drive letter', () => { + expect(() => validateGeneratedFilePath('C:\\bad\\file.py')).toThrow(AppError) + }) + + it('rejects paths with ..', () => { + expect(() => validateGeneratedFilePath('core_code/../outside.py')).toThrow(AppError) + }) + + it('rejects empty path parts', () => { + expect(() => validateGeneratedFilePath('core_code//file.py')).toThrow(AppError) + }) + + it('rejects README.md', () => { + expect(() => validateGeneratedFilePath('README.md')).toThrow(AppError) + }) + + it('rejects readme.md case insensitive', () => { + expect(() => validateGeneratedFilePath('readme.md')).toThrow(AppError) + }) + + it('rejects subfolder readme.md', () => { + expect(() => validateGeneratedFilePath('core_code/readme.md')).toThrow(AppError) + }) +}) + +describe('parseCodeBlueprint', () => { + it('parses a valid minimal blueprint', () => { + const blueprint = parseCodeBlueprint(makeMinimalBlueprintJson()) + expect(blueprint.coreContribution).toBe('a novel loss function') + expect(blueprint.files).toHaveLength(1) + expect(blueprint.files[0].path).toBe('core_code/losses.py') + expect(blueprint.files[0].mainSymbols).toEqual(['CustomLoss']) + expect(blueprint.omitted).toHaveLength(1) + expect(blueprint.omitted![0].item).toBe('training script') + expect(blueprint.minimalityCheck!.couldAnyFileBeRemoved).toBe(false) + }) + + it('accepts optional fields as undefined when absent', () => { + const core = makeMinimalBlueprintJson() + const parsed = JSON.parse(core) + delete parsed.paperDomain + delete parsed.files[0].inputs + delete parsed.files[0].outputs + delete parsed.files[0].assumptions + const input = JSON.stringify(parsed) + const blueprint = parseCodeBlueprint(input) + expect(blueprint.paperDomain).toBeUndefined() + expect(blueprint.files[0].inputs).toBeUndefined() + expect(blueprint.files[0].outputs).toBeUndefined() + expect(blueprint.files[0].assumptions).toBeUndefined() + }) + + it('throws when coreContribution is missing', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + delete parsed.coreContribution + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(AppError) + }) + + it('throws when minimalImplementationBoundary is missing', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + delete parsed.minimalImplementationBoundary + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(AppError) + }) + + it('throws when files array is empty', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.files = [] + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/缺少文件规划/) + }) + + it('throws when a file has no purpose', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + delete parsed.files[0].purpose + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(AppError) + }) + + it('throws when a file has empty mainSymbols', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.files[0].mainSymbols = [] + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(AppError) + }) + + it('throws when a file path is not under core_code/', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.files[0].path = 'outside.py' + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/必须位于 core_code/) + }) + + it('throws on duplicate file paths', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.files.push({ ...parsed.files[0] }) + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/重复/) + }) + + it('throws when a high-risk file lacks justification', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.files[0].path = 'core_code/train.py' + parsed.files[0].purpose = 'helper utility' + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/高风险/) + }) + + it('allows a high-risk file when purpose contains justification terms', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.files[0].path = 'core_code/train.py' + parsed.files[0].purpose = 'implements the proposed training algorithm' + parsed.files[0].evidence = 'the paper proposes this training procedure' + const blueprint = parseCodeBlueprint(JSON.stringify(parsed)) + expect(blueprint.files[0].path).toBe('core_code/train.py') + }) + + it('throws when omitted items are malformed', () => { + const parsed = JSON.parse(makeMinimalBlueprintJson()) + parsed.omitted = [{ bad: 'data' }] + expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(AppError) + }) + + it('throws when input is not valid JSON', () => { + expect(() => parseCodeBlueprint('not json')).toThrow(AppError) + }) +}) + +describe('validateFilesAgainstBlueprint', () => { + const blueprint = parseCodeBlueprint(makeMinimalBlueprintJson()) + + it('passes when generated files exactly match blueprint', () => { + const files = [{ path: 'core_code/losses.py', content: 'def forward(): pass\n' }] + expect(() => validateFilesAgainstBlueprint(files, blueprint)).not.toThrow() + }) + + it('throws when a generated file is not in blueprint', () => { + const files = [ + { path: 'core_code/losses.py', content: 'def forward(): pass\n' }, + { path: 'core_code/extra.py', content: 'x = 1\n' }, + ] + expect(() => validateFilesAgainstBlueprint(files, blueprint)).toThrow(/蓝图外/) + }) + + it('throws when a blueprint file is missing from generated output', () => { + const files: { path: string; content: string }[] = [] + expect(() => validateFilesAgainstBlueprint(files, blueprint)).toThrow(/未生成/) + }) + + it('throws when a generated file has empty content', () => { + const files = [{ path: 'core_code/losses.py', content: '' }] + expect(() => validateFilesAgainstBlueprint(files, blueprint)).toThrow(/空代码/) + }) + + it('throws when a generated file content is only whitespace', () => { + const files = [{ path: 'core_code/losses.py', content: ' \n ' }] + expect(() => validateFilesAgainstBlueprint(files, blueprint)).toThrow(/空代码/) + }) + + it('throws when there are duplicate generated file paths', () => { + const files = [ + { path: 'core_code/losses.py', content: 'def forward(): pass\n' }, + { path: 'core_code/losses.py', content: 'def backward(): pass\n' }, + ] + expect(() => validateFilesAgainstBlueprint(files, blueprint)).toThrow(/重复/) + }) + + it('passes when blueprint has multiple files and all are present', () => { + const multiBlueprint = parseCodeBlueprint(JSON.stringify({ + coreContribution: 'multi-file contribution', + minimalImplementationBoundary: 'two core files', + files: [ + { + path: 'core_code/losses.py', + purpose: 'loss function', + mainSymbols: ['Loss'], + mustInclude: ['forward'], + mustNotInclude: ['train'], + evidence: 'Section 3.1', + }, + { + path: 'core_code/model.py', + purpose: 'model definition', + mainSymbols: ['Model'], + mustInclude: ['forward'], + mustNotInclude: ['train'], + evidence: 'Section 3.2', + }, + ], + })) + + const files = [ + { path: 'core_code/losses.py', content: 'class Loss: pass\n' }, + { path: 'core_code/model.py', content: 'class Model: pass\n' }, + ] + expect(() => validateFilesAgainstBlueprint(files, multiBlueprint)).not.toThrow() + }) +}) diff --git a/src/main/backend/__tests__/exportCode.test.ts b/src/main/backend/__tests__/exportCode.test.ts new file mode 100644 index 0000000..0c77edb --- /dev/null +++ b/src/main/backend/__tests__/exportCode.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import * as fs from 'fs' +import * as path from 'path' +import { tmpdir } from 'os' +import { writeCodeFolder } from '../exportCode' +import { cacheCodeBundle, clearCache, CodeBlueprint } from '../codeCache' + +function tempDir(): string { + return path.join(tmpdir(), `p2cc-export-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) +} + +const dummyBlueprint: CodeBlueprint = { + coreContribution: 'A novel contrastive loss', + minimalImplementationBoundary: 'Only the loss function, no training pipeline', + files: [ + { + path: 'core_code/loss.py', + purpose: 'Implement the proposed contrastive loss', + mainSymbols: ['contrastive_loss'], + mustInclude: ['temperature scaling', 'positive pair aggregation'], + mustNotInclude: ['model definition', 'training loop'], + inputs: ['anchor embeddings', 'positive embeddings', 'negative embeddings'], + outputs: ['scalar loss value'], + assumptions: ['Embeddings are L2-normalized before input'], + evidence: 'Section 3.2, Eq. 7', + }, + ], + omitted: [ + { item: 'Training loop', reason: 'Not part of the proposed method' }, + { item: 'Dataset loader', reason: 'Standard vision dataset, not a contribution' }, + ], + minimalityCheck: { + whyTheseFilesAreMinimal: 'The loss function is the only novel component', + couldAnyFileBeRemoved: false, + overGenerationRisk: 'low', + }, +} + +describe('writeCodeFolder', () => { + beforeEach(() => { + clearCache() + }) + + it('returns error when no cached code bundle', () => { + const result = writeCodeFolder(tempDir()) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('No cached core code found') + } + }) + + it('exports files and README without blueprint', () => { + cacheCodeBundle({ + readme: '# Test Summary\n\nPaper content', + files: [{ path: 'core_code/loss.py', content: 'def loss(): pass' }], + }) + + const output = tempDir() + const result = writeCodeFolder(output) + expect(result.ok).toBe(true) + + const readmePath = path.join(output, 'README.md') + const codePath = path.join(output, 'core_code', 'loss.py') + + expect(fs.existsSync(readmePath)).toBe(true) + expect(fs.existsSync(codePath)).toBe(true) + expect(fs.readFileSync(readmePath, 'utf-8')).toBe('# Test Summary\n\nPaper content') + expect(fs.readFileSync(codePath, 'utf-8')).toBe('def loss(): pass') + + fs.rmSync(output, { recursive: true, force: true }) + }) + + it('exports files and README with blueprint', () => { + cacheCodeBundle({ + readme: '# Test Summary', + files: [{ path: 'core_code/loss.py', content: 'def loss(): pass' }], + blueprint: dummyBlueprint, + }) + + const output = tempDir() + const result = writeCodeFolder(output) + expect(result.ok).toBe(true) + + const readmePath = path.join(output, 'README.md') + const readmeContent = fs.readFileSync(readmePath, 'utf-8') + + expect(readmeContent).toContain('# Test Summary') + expect(readmeContent).toContain('## Core Code Scope') + expect(readmeContent).toContain('A novel contrastive loss') + expect(readmeContent).toContain('Only the loss function, no training pipeline') + expect(readmeContent).toContain('core_code/loss.py') + expect(readmeContent).toContain('contrastive_loss') + expect(readmeContent).toContain('Training loop') + expect(readmeContent).toContain('Dataset loader') + + fs.rmSync(output, { recursive: true, force: true }) + }) + + it('rejects file with unsafe path and returns error', () => { + cacheCodeBundle({ + readme: '# Unsafe', + files: [{ path: '../outside.py', content: 'bad' }], + }) + + const output = tempDir() + const result = writeCodeFolder(output) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Unsafe') + } + + fs.rmSync(output, { recursive: true, force: true }) + }) + + it('creates nested directories for generated files', () => { + cacheCodeBundle({ + readme: '# Nested', + files: [ + { path: 'core_code/loss.py', content: 'def l(): pass' }, + { path: 'core_code/utils/math.py', content: 'def add(): pass' }, + ], + }) + + const output = tempDir() + const result = writeCodeFolder(output) + expect(result.ok).toBe(true) + + expect(fs.existsSync(path.join(output, 'core_code', 'loss.py'))).toBe(true) + expect(fs.existsSync(path.join(output, 'core_code', 'utils', 'math.py'))).toBe(true) + + fs.rmSync(output, { recursive: true, force: true }) + }) +}) diff --git a/src/main/backend/__tests__/paperAnalyzer.test.ts b/src/main/backend/__tests__/paperAnalyzer.test.ts new file mode 100644 index 0000000..94bd676 --- /dev/null +++ b/src/main/backend/__tests__/paperAnalyzer.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest' +import { AppError, ErrorCodes } from '../errors' +import { + extractTaggedContent, + parseCodeDecision, + parseTaggedCodeFiles, + removePartialEndTagSuffix, + getStreamingSummary, + normalizeFileContent, +} from '../paperAnalyzer' + +// --------------------------------------------------------------------------- +// extractTaggedContent +// --------------------------------------------------------------------------- +describe('extractTaggedContent', () => { + const START = '' + const END = '' + const ERR = 'missing tag' + + it('extracts content between tags', () => { + const raw = `prefix ${START}hello world${END} suffix` + expect(extractTaggedContent(raw, START, END, ERR)).toBe('hello world') + }) + + it('trims whitespace around content', () => { + const raw = `${START}\n \ncontent \n${END}` + expect(extractTaggedContent(raw, START, END, ERR)).toBe('content') + }) + + it('throws if start tag is missing', () => { + expect(() => extractTaggedContent('no start', START, END, ERR)).toThrow(AppError) + expect(() => extractTaggedContent('no start', START, END, ERR)).toThrow(ERR) + }) + + it('throws if end tag is missing', () => { + const raw = `${START}content only` + expect(() => extractTaggedContent(raw, START, END, ERR)).toThrow(AppError) + expect(() => extractTaggedContent(raw, START, END, ERR)).toThrow(ERR) + }) + + it('finds first start tag even when end tag appears before it', () => { + const raw = `${END}before${START}after${END}` + expect(extractTaggedContent(raw, START, END, ERR)).toBe('after') + }) + + it('throws for empty content between tags', () => { + const raw = `${START}${END}` + expect(() => extractTaggedContent(raw, START, END, ERR)).toThrow(AppError) + }) +}) + +// --------------------------------------------------------------------------- +// parseCodeDecision +// --------------------------------------------------------------------------- +describe('parseCodeDecision', () => { + it('parses decision with needed: true', () => { + const result = parseCodeDecision('{"needed": true}') + expect(result.needed).toBe(true) + expect(result.reason).toBeUndefined() + }) + + it('parses decision with needed: false', () => { + const result = parseCodeDecision('{"needed": false}') + expect(result.needed).toBe(false) + expect(result.reason).toBeUndefined() + }) + + it('extracts reason when present', () => { + const result = parseCodeDecision('{"needed": true, "reason": "has pseudocode"}') + expect(result.needed).toBe(true) + expect(result.reason).toBe('has pseudocode') + }) + + it('ignores non-string reason', () => { + const result = parseCodeDecision('{"needed": false, "reason": 42}') + expect(result.needed).toBe(false) + expect(result.reason).toBeUndefined() + }) + + it('strips markdown fences before parsing', () => { + const raw = '```json\n{"needed": true, "reason": "fenced"}\n```' + const result = parseCodeDecision(raw) + expect(result.needed).toBe(true) + expect(result.reason).toBe('fenced') + }) + + it('throws for non-object JSON', () => { + expect(() => parseCodeDecision('"string"')).toThrow(AppError) + expect(() => parseCodeDecision('null')).toThrow(AppError) + expect(() => parseCodeDecision('42')).toThrow(AppError) + }) + + it('throws if needed field is missing', () => { + expect(() => parseCodeDecision('{}')).toThrow(AppError) + expect(() => parseCodeDecision('{"other": true}')).toThrow(AppError) + }) + + it('throws for unparseable input', () => { + expect(() => parseCodeDecision('not json at all')).toThrow(AppError) + }) + + it('throws for empty input', () => { + expect(() => parseCodeDecision('')).toThrow(AppError) + }) +}) + +// --------------------------------------------------------------------------- +// parseTaggedCodeFiles +// --------------------------------------------------------------------------- +describe('parseTaggedCodeFiles', () => { + it('parses a single file block', () => { + const raw = `print("hello")` + const files = parseTaggedCodeFiles(raw) + expect(files).toHaveLength(1) + expect(files[0].path).toBe('core_code/hello.py') + expect(files[0].content).toBe('print("hello")') + }) + + it('parses multiple file blocks', () => { + const raw = [ + 'aaa', + 'bbb', + ].join('\n') + const files = parseTaggedCodeFiles(raw) + expect(files).toHaveLength(2) + expect(files[0].path).toBe('core_code/a.py') + expect(files[1].path).toBe('core_code/b.py') + }) + + it('strips leading/trailing newline from content', () => { + const raw = '\ncontent\n' + const files = parseTaggedCodeFiles(raw) + expect(files[0].content).toBe('content') + }) + + it('throws on absolute path', () => { + const raw = 'bad' + expect(() => parseTaggedCodeFiles(raw)).toThrow(AppError) + }) + + it('throws on path with .. traversal', () => { + const raw = 'bad' + expect(() => parseTaggedCodeFiles(raw)).toThrow(AppError) + }) + + it('throws on README.md path', () => { + const raw = 'bad' + expect(() => parseTaggedCodeFiles(raw)).toThrow(AppError) + }) + + it('returns empty array for no file blocks', () => { + const files = parseTaggedCodeFiles('no blocks here') + expect(files).toHaveLength(0) + }) + + it('handles double-quoted path', () => { + const raw = 'x' + const files = parseTaggedCodeFiles(raw) + expect(files).toHaveLength(1) + expect(files[0].path).toBe('core_code/foo.py') + }) + + it('handles single-quoted path', () => { + const raw = "x" + const files = parseTaggedCodeFiles(raw) + expect(files).toHaveLength(1) + expect(files[0].path).toBe('core_code/bar.py') + }) +}) + +// --------------------------------------------------------------------------- +// removePartialEndTagSuffix +// --------------------------------------------------------------------------- +describe('removePartialEndTagSuffix', () => { + it('removes partial matching suffix', () => { + const endTag = '' + expect(removePartialEndTagSuffix('hello { + const endTag = '' + expect(removePartialEndTagSuffix('hello world', endTag)).toBe('hello world') + expect(removePartialEndTagSuffix('', endTag)).toBe('') + }) + + it('returns original when content is shorter than tag', () => { + expect(removePartialEndTagSuffix('ab', '')).toBe('ab') + }) + + it('handles empty content', () => { + expect(removePartialEndTagSuffix('', '')).toBe('') + }) + + it('only removes the minimum matching suffix', () => { + // At i=5, '')).toBe('content') + expect(removePartialEndTagSuffix('content')).toBe('content') + }) +}) + +// --------------------------------------------------------------------------- +// getStreamingSummary +// --------------------------------------------------------------------------- +describe('getStreamingSummary', () => { + const SUMMARY_START = '' + const SUMMARY_END = '' + + it('extracts complete summary between tags', () => { + const raw = `${SUMMARY_START}paper content${SUMMARY_END}` + expect(getStreamingSummary(raw)).toBe('paper content') + }) + + it('returns empty string when start tag is missing', () => { + expect(getStreamingSummary('no tag here')).toBe('') + }) + + it('strips partial end tag suffix when end tag is incomplete', () => { + const raw = `${SUMMARY_START}hello world { + const raw = `${SUMMARY_START}hello world${SUMMARY_END}trailing` + expect(getStreamingSummary(raw)).toBe('hello world') + }) + + it('returns content from start tag to end when end tag is complete', () => { + const raw = `prefix${SUMMARY_START}the summary${SUMMARY_END}suffix` + expect(getStreamingSummary(raw)).toBe('the summary') + }) + + it('handles empty summary', () => { + const raw = `${SUMMARY_START}${SUMMARY_END}` + expect(getStreamingSummary(raw)).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// normalizeFileContent +// --------------------------------------------------------------------------- +describe('normalizeFileContent', () => { + it('removes leading newline', () => { + expect(normalizeFileContent('\ncontent')).toBe('content') + expect(normalizeFileContent('\r\ncontent')).toBe('content') + }) + + it('removes trailing newline', () => { + expect(normalizeFileContent('content\n')).toBe('content') + expect(normalizeFileContent('content\r\n')).toBe('content') + }) + + it('removes both leading and trailing newlines', () => { + expect(normalizeFileContent('\ncontent\n')).toBe('content') + }) + + it('does not change content without leading/trailing newlines', () => { + expect(normalizeFileContent('content')).toBe('content') + }) + + it('handles empty string', () => { + expect(normalizeFileContent('')).toBe('') + }) +}) diff --git a/src/main/backend/codeBlueprint.ts b/src/main/backend/codeBlueprint.ts new file mode 100644 index 0000000..4004a25 --- /dev/null +++ b/src/main/backend/codeBlueprint.ts @@ -0,0 +1,213 @@ +import { CodeBlueprint, CodeBlueprintFile, GeneratedFile } from './codeCache' +import { AppError, ErrorCodes } from './errors' + +export const BLUEPRINT_START = '' +export const BLUEPRINT_END = '' + +const HIGH_RISK_FILE_NAMES = new Set([ + 'baseline.py', + 'config.py', + 'dataset.py', + 'dataloader.py', + 'experiment.py', + 'experiment_runner.py', + 'inference.py', + 'main.py', + 'pipeline.py', + 'requirements.txt', + 'train.py', + 'utils.py', +]) + +const HIGH_RISK_JUSTIFICATION_TERMS = [ + 'proposed', + 'novel', + 'core contribution', + 'method itself', + 'algorithm', + 'procedure', + '本文提出', + '提出的', + '核心贡献', + '方法本身', +] + +export function extractJsonObject(raw: string): unknown { + const trimmed = raw.trim() + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/) + const candidate = fenced ? fenced[1].trim() : trimmed + const start = candidate.indexOf('{') + const end = candidate.lastIndexOf('}') + + if (start === -1 || end === -1 || end <= start) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的 JSON 结构无效') + } + + try { + return JSON.parse(candidate.slice(start, end + 1)) + } catch (err) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的 JSON 不是可解析格式', (err as Error).message) + } +} + +export function validateGeneratedFilePath(filePath: string): string { + const normalizedPath = filePath.trim().replace(/\\/g, '/') + const lowerPath = normalizedPath.toLowerCase() + const parts = normalizedPath.split('/') + + if ( + normalizedPath.startsWith('/') || + /^[a-z]:/i.test(normalizedPath) || + parts.includes('..') || + parts.includes('') || + lowerPath === 'readme.md' || + lowerPath.endsWith('/readme.md') + ) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回了非法文件路径: ${filePath}`) + } + + return normalizedPath +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function requireNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图缺少 ${fieldName}`) + } + + return value.trim() +} + +function requireStringArray(value: unknown, fieldName: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图缺少 ${fieldName}`) + } + + const strings = value.map((item) => requireNonEmptyString(item, fieldName)) + return strings +} + +function optionalStringArray(value: unknown, fieldName: string): string[] | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图 ${fieldName} 格式无效`) + } + + return value.map((item) => requireNonEmptyString(item, fieldName)) +} + +function requiresHighRiskJustification(file: CodeBlueprintFile): boolean { + const fileName = file.path.split('/').pop()?.toLowerCase() || '' + if (!HIGH_RISK_FILE_NAMES.has(fileName)) return false + + const justification = [file.purpose, file.evidence, ...file.mustInclude].join(' ').toLowerCase() + return !HIGH_RISK_JUSTIFICATION_TERMS.some((term) => justification.includes(term)) +} + +export function parseCodeBlueprint(raw: string): CodeBlueprint { + const parsed = extractJsonObject(raw) + if (!isRecord(parsed)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的代码蓝图无效') + } + + const rawFiles = parsed.files + if (!Array.isArray(rawFiles) || rawFiles.length === 0) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的代码蓝图缺少文件规划') + } + + const paths = new Set() + const files = rawFiles.map((rawFile, index): CodeBlueprintFile => { + if (!isRecord(rawFile)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图文件 #${index + 1} 无效`) + } + + const path = validateGeneratedFilePath(requireNonEmptyString(rawFile.path, 'file.path')) + if (!path.startsWith('core_code/')) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `代码蓝图文件必须位于 core_code/ 目录: ${path}`) + } + if (paths.has(path)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `代码蓝图包含重复文件路径: ${path}`) + } + paths.add(path) + + const file: CodeBlueprintFile = { + path, + purpose: requireNonEmptyString(rawFile.purpose, 'file.purpose'), + mainSymbols: requireStringArray(rawFile.mainSymbols, 'file.mainSymbols'), + mustInclude: requireStringArray(rawFile.mustInclude, 'file.mustInclude'), + mustNotInclude: requireStringArray(rawFile.mustNotInclude, 'file.mustNotInclude'), + inputs: optionalStringArray(rawFile.inputs, 'file.inputs'), + outputs: optionalStringArray(rawFile.outputs, 'file.outputs'), + assumptions: optionalStringArray(rawFile.assumptions, 'file.assumptions'), + evidence: typeof rawFile.evidence === 'string' ? rawFile.evidence.trim() : undefined, + } + + if (requiresHighRiskJustification(file)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `高风险文件缺少核心贡献依据: ${path}`) + } + + return file + }) + + const omitted = Array.isArray(parsed.omitted) + ? parsed.omitted.map((item, index) => { + if (!isRecord(item)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `代码蓝图 omitted #${index + 1} 无效`) + } + return { + item: requireNonEmptyString(item.item, 'omitted.item'), + reason: requireNonEmptyString(item.reason, 'omitted.reason'), + } + }) + : undefined + + const minimalityCheck = isRecord(parsed.minimalityCheck) + ? { + whyTheseFilesAreMinimal: typeof parsed.minimalityCheck.whyTheseFilesAreMinimal === 'string' + ? parsed.minimalityCheck.whyTheseFilesAreMinimal.trim() + : undefined, + couldAnyFileBeRemoved: typeof parsed.minimalityCheck.couldAnyFileBeRemoved === 'boolean' + ? parsed.minimalityCheck.couldAnyFileBeRemoved + : undefined, + overGenerationRisk: typeof parsed.minimalityCheck.overGenerationRisk === 'string' + ? parsed.minimalityCheck.overGenerationRisk.trim() + : undefined, + } + : undefined + + return { + paperDomain: typeof parsed.paperDomain === 'string' ? parsed.paperDomain.trim() : undefined, + coreContribution: requireNonEmptyString(parsed.coreContribution, 'coreContribution'), + minimalImplementationBoundary: requireNonEmptyString(parsed.minimalImplementationBoundary, 'minimalImplementationBoundary'), + files, + omitted, + minimalityCheck, + } +} + +export function validateFilesAgainstBlueprint(files: GeneratedFile[], blueprint: CodeBlueprint): void { + const blueprintPaths = new Set(blueprint.files.map((file) => file.path)) + const generatedPaths = new Set() + + for (const file of files) { + if (file.content.trim() === '') { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型生成了空代码文件: ${file.path}`) + } + if (!blueprintPaths.has(file.path)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型生成了蓝图外文件: ${file.path}`) + } + if (generatedPaths.has(file.path)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型重复生成文件: ${file.path}`) + } + generatedPaths.add(file.path) + } + + for (const path of blueprintPaths) { + if (!generatedPaths.has(path)) { + throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型未生成蓝图声明的文件: ${path}`) + } + } +} diff --git a/src/main/backend/paperAnalyzer.ts b/src/main/backend/paperAnalyzer.ts index 80b3ad1..ab915e3 100644 --- a/src/main/backend/paperAnalyzer.ts +++ b/src/main/backend/paperAnalyzer.ts @@ -1,208 +1,23 @@ import { parsePDF } from './pdfParser' import { callDeepSeek } from './deepseekClient' import { buildCombinedAnalysisPrompt } from './promptBuilder' -import { cacheCodeBundle, clearCache, CodeBlueprint, CodeBlueprintFile, GeneratedFile } from './codeCache' +import { cacheCodeBundle, clearCache, CodeBlueprint, GeneratedFile } from './codeCache' import { AnalysisProgress, AnalysisResult, AppError, ErrorCodes } from './errors' import { getActiveSettings } from './settingsStore' +import { BLUEPRINT_START, BLUEPRINT_END, extractJsonObject, parseCodeBlueprint, validateFilesAgainstBlueprint, validateGeneratedFilePath } from './codeBlueprint' const SUMMARY_START = '' const SUMMARY_END = '' const DECISION_START = '' const DECISION_END = '' -const BLUEPRINT_START = '' -const BLUEPRINT_END = '' const CODE_BUNDLE_START = '' const CODE_BUNDLE_END = '' -const HIGH_RISK_FILE_NAMES = new Set([ - 'baseline.py', - 'config.py', - 'dataset.py', - 'dataloader.py', - 'experiment.py', - 'experiment_runner.py', - 'inference.py', - 'main.py', - 'pipeline.py', - 'requirements.txt', - 'train.py', - 'utils.py', -]) - -const HIGH_RISK_JUSTIFICATION_TERMS = [ - 'proposed', - 'novel', - 'core contribution', - 'method itself', - 'algorithm', - 'procedure', - '本文提出', - '提出的', - '核心贡献', - '方法本身', -] - -function extractJsonObject(raw: string): unknown { - const trimmed = raw.trim() - const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/) - const candidate = fenced ? fenced[1].trim() : trimmed - const start = candidate.indexOf('{') - const end = candidate.lastIndexOf('}') - - if (start === -1 || end === -1 || end <= start) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的 JSON 结构无效') - } - - try { - return JSON.parse(candidate.slice(start, end + 1)) - } catch (err) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的 JSON 不是可解析格式', (err as Error).message) - } -} - -function validateGeneratedFilePath(filePath: string): string { - const normalizedPath = filePath.trim().replace(/\\/g, '/') - const lowerPath = normalizedPath.toLowerCase() - const parts = normalizedPath.split('/') - - if ( - normalizedPath.startsWith('/') || - /^[a-z]:/i.test(normalizedPath) || - parts.includes('..') || - parts.includes('') || - lowerPath === 'readme.md' || - lowerPath.endsWith('/readme.md') - ) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回了非法文件路径: ${filePath}`) - } - - return normalizedPath -} - -function normalizeFileContent(content: string): string { +export function normalizeFileContent(content: string): string { return content.replace(/^\r?\n/, '').replace(/\r?\n$/, '') } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function requireNonEmptyString(value: unknown, fieldName: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图缺少 ${fieldName}`) - } - - return value.trim() -} - -function requireStringArray(value: unknown, fieldName: string): string[] { - if (!Array.isArray(value) || value.length === 0) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图缺少 ${fieldName}`) - } - - const strings = value.map((item) => requireNonEmptyString(item, fieldName)) - return strings -} - -function optionalStringArray(value: unknown, fieldName: string): string[] | undefined { - if (value === undefined) return undefined - if (!Array.isArray(value)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图 ${fieldName} 格式无效`) - } - - return value.map((item) => requireNonEmptyString(item, fieldName)) -} - -function requiresHighRiskJustification(file: CodeBlueprintFile): boolean { - const fileName = file.path.split('/').pop()?.toLowerCase() || '' - if (!HIGH_RISK_FILE_NAMES.has(fileName)) return false - - const justification = [file.purpose, file.evidence, ...file.mustInclude].join(' ').toLowerCase() - return !HIGH_RISK_JUSTIFICATION_TERMS.some((term) => justification.includes(term)) -} - -function parseCodeBlueprint(raw: string): CodeBlueprint { - const parsed = extractJsonObject(raw) - if (!isRecord(parsed)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的代码蓝图无效') - } - - const rawFiles = parsed.files - if (!Array.isArray(rawFiles) || rawFiles.length === 0) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, '模型返回的代码蓝图缺少文件规划') - } - - const paths = new Set() - const files = rawFiles.map((rawFile, index): CodeBlueprintFile => { - if (!isRecord(rawFile)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型返回的代码蓝图文件 #${index + 1} 无效`) - } - - const path = validateGeneratedFilePath(requireNonEmptyString(rawFile.path, 'file.path')) - if (!path.startsWith('core_code/')) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `代码蓝图文件必须位于 core_code/ 目录: ${path}`) - } - if (paths.has(path)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `代码蓝图包含重复文件路径: ${path}`) - } - paths.add(path) - - const file: CodeBlueprintFile = { - path, - purpose: requireNonEmptyString(rawFile.purpose, 'file.purpose'), - mainSymbols: requireStringArray(rawFile.mainSymbols, 'file.mainSymbols'), - mustInclude: requireStringArray(rawFile.mustInclude, 'file.mustInclude'), - mustNotInclude: requireStringArray(rawFile.mustNotInclude, 'file.mustNotInclude'), - inputs: optionalStringArray(rawFile.inputs, 'file.inputs'), - outputs: optionalStringArray(rawFile.outputs, 'file.outputs'), - assumptions: optionalStringArray(rawFile.assumptions, 'file.assumptions'), - evidence: typeof rawFile.evidence === 'string' ? rawFile.evidence.trim() : undefined, - } - - if (requiresHighRiskJustification(file)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `高风险文件缺少核心贡献依据: ${path}`) - } - - return file - }) - - const omitted = Array.isArray(parsed.omitted) - ? parsed.omitted.map((item, index) => { - if (!isRecord(item)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `代码蓝图 omitted #${index + 1} 无效`) - } - return { - item: requireNonEmptyString(item.item, 'omitted.item'), - reason: requireNonEmptyString(item.reason, 'omitted.reason'), - } - }) - : undefined - - const minimalityCheck = isRecord(parsed.minimalityCheck) - ? { - whyTheseFilesAreMinimal: typeof parsed.minimalityCheck.whyTheseFilesAreMinimal === 'string' - ? parsed.minimalityCheck.whyTheseFilesAreMinimal.trim() - : undefined, - couldAnyFileBeRemoved: typeof parsed.minimalityCheck.couldAnyFileBeRemoved === 'boolean' - ? parsed.minimalityCheck.couldAnyFileBeRemoved - : undefined, - overGenerationRisk: typeof parsed.minimalityCheck.overGenerationRisk === 'string' - ? parsed.minimalityCheck.overGenerationRisk.trim() - : undefined, - } - : undefined - - return { - paperDomain: typeof parsed.paperDomain === 'string' ? parsed.paperDomain.trim() : undefined, - coreContribution: requireNonEmptyString(parsed.coreContribution, 'coreContribution'), - minimalImplementationBoundary: requireNonEmptyString(parsed.minimalImplementationBoundary, 'minimalImplementationBoundary'), - files, - omitted, - minimalityCheck, - } -} - -function parseTaggedCodeFiles(raw: string): GeneratedFile[] { +export function parseTaggedCodeFiles(raw: string): GeneratedFile[] { const files: GeneratedFile[] = [] const fileRegex = /([\s\S]*?)<\/P2CC_FILE>/g let match: RegExpExecArray | null @@ -216,31 +31,7 @@ function parseTaggedCodeFiles(raw: string): GeneratedFile[] { return files } -function validateFilesAgainstBlueprint(files: GeneratedFile[], blueprint: CodeBlueprint): void { - const blueprintPaths = new Set(blueprint.files.map((file) => file.path)) - const generatedPaths = new Set() - - for (const file of files) { - if (file.content.trim() === '') { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型生成了空代码文件: ${file.path}`) - } - if (!blueprintPaths.has(file.path)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型生成了蓝图外文件: ${file.path}`) - } - if (generatedPaths.has(file.path)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型重复生成文件: ${file.path}`) - } - generatedPaths.add(file.path) - } - - for (const path of blueprintPaths) { - if (!generatedPaths.has(path)) { - throw new AppError(ErrorCodes.API_RESPONSE_INVALID, `模型未生成蓝图声明的文件: ${path}`) - } - } -} - -function removePartialEndTagSuffix(content: string, endTag: string): string { +export function removePartialEndTagSuffix(content: string, endTag: string): string { const max = Math.min(endTag.length - 1, content.length) for (let i = max; i > 0; i -= 1) { @@ -252,7 +43,7 @@ function removePartialEndTagSuffix(content: string, endTag: string): string { return content } -function getStreamingSummary(raw: string): string { +export function getStreamingSummary(raw: string): string { const start = raw.indexOf(SUMMARY_START) if (start === -1) return '' @@ -266,7 +57,7 @@ function getStreamingSummary(raw: string): string { return removePartialEndTagSuffix(raw.slice(contentStart), SUMMARY_END) } -function extractTaggedContent(raw: string, startTag: string, endTag: string, errorMessage: string): string { +export function extractTaggedContent(raw: string, startTag: string, endTag: string, errorMessage: string): string { const start = raw.indexOf(startTag) if (start === -1) { throw new AppError(ErrorCodes.API_RESPONSE_INVALID, errorMessage) @@ -281,7 +72,7 @@ function extractTaggedContent(raw: string, startTag: string, endTag: string, err return raw.slice(contentStart, end).trim() } -function parseCodeDecision(raw: string): { needed: boolean; reason?: string } { +export function parseCodeDecision(raw: string): { needed: boolean; reason?: string } { const parsed = extractJsonObject(raw) if (!parsed || typeof parsed !== 'object' || typeof (parsed as { needed?: unknown }).needed !== 'boolean') { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d133f7d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/__tests__/**/*.test.ts'], + environment: 'node', + }, +})