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`
+ expect(getStreamingSummary(raw)).toBe('hello world')
+ })
+
+ it('returns full content before end tag when end tag is complete', () => {
+ 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',
+ },
+})