diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48bb16a..b59f9fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,16 +7,28 @@ on: jobs: gate: - runs-on: ubuntu-latest + # Local-first + cross-platform is a core promise, and Windows is the + # divergent platform (backslash paths, native better-sqlite3 build, no + # symlinks). Run the full gate on both Linux and Windows so path/native + # regressions are caught in CI, not on a user's machine. + strategy: + fail-fast: false + matrix: + # windows-2022 (not windows-latest): the newer windows-latest image ships + # a Visual Studio that node-gyp 10 (bundled with Node 20) can't detect + # ("unknown version undefined at ...\Visual Studio\18"), so better-sqlite3's + # source-build fallback fails. VS 2022 on windows-2022 is detected fine. + os: [ubuntu-latest, windows-2022] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - # npm install (not ci) so the Linux runner resolves its own platform- - # specific optional deps (e.g. @tailwindcss/oxide native/wasm variants), - # which a Windows-generated lockfile can't fully pin cross-platform. + # npm install (not ci) so each runner resolves its own platform-specific + # optional deps (e.g. @tailwindcss/oxide native/wasm variants), which a + # single committed lockfile can't pin across Linux + Windows. - run: npm install --no-audit --no-fund - run: npm run lint - run: npm run typecheck @@ -25,4 +37,6 @@ jobs: # Supply-chain gate: fail the build on CRITICAL advisories in prod deps. # High/moderate transitive advisories that can't be fixed until an # upstream release (e.g. next's bundled postcss) are tracked in SECURITY.md. + # Advisory data is platform-independent, so run it once (Linux). - run: npm audit --omit=dev --audit-level=critical + if: matrix.os == 'ubuntu-latest' diff --git a/docs/MEGASPRINT_ROADMAP.md b/docs/MEGASPRINT_ROADMAP.md index 45d04c7..3f56d89 100644 --- a/docs/MEGASPRINT_ROADMAP.md +++ b/docs/MEGASPRINT_ROADMAP.md @@ -20,7 +20,7 @@ free + BYOK path (`ANTHROPIC_API_KEY` optional; offline analyzer is the default) | **MS2** ✅ | Security | The app ingests users' private source code — it must be provably safe | **DONE** — symlink-confined walk, shell-free git, realpath+credential-dir blocks, prod CSP no `unsafe-eval`, `npm audit` critical-gate; self-scan security lens **A/100** | | **MS3** ✅ | Architecture & Data Integrity | Kill load-bearing hacks (description-as-path, no backup) | **DONE** — `sourcePath` column (migration 0008) retires the "Imported from:" hack; DB durability (`synchronous=NORMAL`, `busy_timeout`, boot integrity check, WAL checkpoint on exit) + downloadable backup; workspace error/loading boundaries; graph reconciliation already handled (`graphJson ?? buildKnowledgeGraph`). **#27/#28 module FKs deferred to MS4** (no analyzer-produced associations to populate them — REVIEW-012). Gate green (19/19) | | **MS4** ✅ | Algorithms & Analysis Depth | The analysis *is* the product — make it rigorous and calibrated | **DONE** — ✅ codeHealth calibrated to code *density* not project size (self-scan F→**B(76)**, overall **A(95)**); `computeCodeHealthScore` + relativity test. ✅ incremental re-analysis — `computeSourceFingerprint` (path\|size\|mtime sha256) + `learning_maps.source_fingerprint` (0009); reanalyze short-circuits `{unchanged:true}`. ✅ [benchmarks](BENCHMARKS.md) — 300-file analysis <200ms, flat ~4ms lens pass, incremental ~8× cheaper. ✅ #27/#28 module associations — soft `module_key` on questions/concepts (0010), deterministic tagging on both AI + offline paths, module cards link to their questions/concepts | -| **MS5** | Quality & Reliability (QA) | 16 tests is a foundation, not production confidence | Critical path e2e-covered; CI green on Win/mac/Linux from a clean clone | +| **MS5** 🔄 | Quality & Reliability (QA) | 16 tests is a foundation, not production confidence | **In progress** — ✅ E2E smoke of the critical path (import→analyze→deep-review→export), which also closed a real gap: the deep review now reaches the export. ✅ broadened coverage — BM25 search (indexing/ranking/rebuild). ✅ cross-platform CI — gate now runs on Windows + Linux (macOS deferred: fs/path-equivalent to Linux, 10× runner cost; Windows is the divergent platform). Suite 24/24. Remaining: watch first Windows CI run land green | | **MS6** | Framework & Performance | Production Next.js build quality | Standalone build runs; perf/a11y budgets met; no UI-blocking analysis | | **MS7** | Distribution & Release | Turn the repo into installable, versioned software — the actual "deploy" | `npx nirmiqcodesensei@latest` runs on a fresh machine; tagged v1.0.0 GitHub Release; CHANGELOG current; scaling-N/A ADR recorded | | MS8 | Launch (stretch) | Discoverability & polish | Onboarding, docs, MCP-directory submission, desktop-installer spike | diff --git a/lib/services/export.service.ts b/lib/services/export.service.ts index 5ded34c..3554690 100644 --- a/lib/services/export.service.ts +++ b/lib/services/export.service.ts @@ -6,6 +6,7 @@ import { getConceptLinksByWorkspaceId } from "@/lib/services/concept-link.servic import { getDailyLogsByWorkspaceId } from "@/lib/services/daily-log.service"; import { formatDate, parseExpectedPoints } from "@/lib/utils"; import type { ServiceResult } from "@/lib/types"; +import type { SeniorReview } from "@/lib/services/senior-review.service"; export type ExportPayload = { filename: string; @@ -42,6 +43,78 @@ function hr(): string { return "\n\n---\n\n"; } +const SEV_EMOJI: Record = { + critical: "🔴", + high: "🟠", + medium: "🟡", + low: "🔵", + info: "⚪", +}; + +// Minimal structural shape shared by every senior-review lens, so we can render +// them uniformly without importing eight distinct lens types. +type LensLike = { + score: { grade: string; score: number }; + findings: Array<{ severity: string; title: string }>; + present?: boolean; +}; + +// Render the stored Senior Review (deep review) as a compact export section: +// overall grade, a per-lens grade table, and the worst findings. Returns [] when +// there is no review (manual maps) or the JSON can't be parsed. +function renderSeniorReview(json: string | null): string[] { + if (!json) return []; + let review: SeniorReview; + try { + review = JSON.parse(json) as SeniorReview; + } catch { + return []; + } + + const lines: string[] = [hr(), "## 🔍 Senior Review", ""]; + lines.push(`**Overall: ${review.overall.grade} (${review.overall.score}/100)**`); + lines.push(""); + if (review.overall.summary) { + lines.push(`> ${review.overall.summary}`); + lines.push(""); + } + + const lenses: Array<[string, LensLike]> = [ + ["Security", review.security], + ["Testing", review.testing], + ["Code Health", review.codeHealth], + ["Architecture", review.architecture], + ["Frontend", review.frontend], + ["Backend", review.backend], + ["Dependencies", review.dependencies], + ["Feasibility", review.feasibility], + ]; + + lines.push("| Lens | Grade |"); + lines.push("|------|-------|"); + for (const [label, lens] of lenses) { + if (lens.present === false) continue; // frontend/backend absent for this project + lines.push(`| ${label} | ${lens.score.grade} (${lens.score.score}) |`); + } + lines.push(""); + + // Worst findings across every lens, most severe first. + const order = ["critical", "high", "medium", "low", "info"]; + const findings = lenses + .flatMap(([, lens]) => (lens.present === false ? [] : lens.findings)) + .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity)) + .slice(0, 5); + if (findings.length > 0) { + lines.push("**Top findings:**"); + for (const f of findings) { + lines.push(`- ${SEV_EMOJI[f.severity] ?? "•"} ${f.title}`); + } + lines.push(""); + } + + return lines; +} + export async function generateWorkspaceMarkdown( workspaceId: string ): Promise> { @@ -141,6 +214,11 @@ export async function generateWorkspaceMarkdown( } } + // ── Senior Review (deep review) ─────────────────────────────────────────── + if (map?.seniorReviewJson) { + lines.push(...renderSeniorReview(map.seniorReviewJson)); + } + // ── Explain-Back ────────────────────────────────────────────────────────── lines.push(hr()); lines.push("## 💬 Explain-Back"); diff --git a/tests/import-pipeline.test.mts b/tests/import-pipeline.test.mts index 599e5a3..9731524 100644 --- a/tests/import-pipeline.test.mts +++ b/tests/import-pipeline.test.mts @@ -520,6 +520,79 @@ test("createConceptLinkSchema: conceptType enum-enforced on the form path", asyn ); }); +// ── BM25 search ranking (MS5 coverage) ─────────────────────────────────────── +test("search: BM25 indexes, ranks by relevance, and rebuilds cleanly", async () => { + const { createWorkspace } = await import("@/lib/services/workspace.service"); + const { buildSearchIndex, searchWorkspace, hasSearchIndex } = await import( + "@/lib/services/search.service" + ); + + const wsRes = await createWorkspace({ title: "search-fixture", type: "project" }); + assert.ok(wsRes.ok); + if (!wsRes.ok) return; + const wsId = wsRes.data.id; + + assert.equal(await hasSearchIndex(wsId), false, "no index before build"); + + const built = await buildSearchIndex(wsId, [ + { filePath: "auth/login.ts", chunkType: "file", layer: "Services / Logic", + chunkText: "authenticate user login password session authentication token" }, + { filePath: "db/schema.ts", chunkType: "file", layer: "Data Layer", + chunkText: "database table column migration schema drizzle sqlite" }, + { filePath: "ui/Button.tsx", chunkType: "file", layer: "UI Components", + chunkText: "render button click handler component props state" }, + ]); + assert.ok(built.ok && built.data === 3, "index built with 3 chunks"); + assert.equal(await hasSearchIndex(wsId), true, "index present after build"); + + const res = await searchWorkspace(wsId, "authentication login"); + assert.ok(res.ok); + if (!res.ok) return; + assert.ok(res.data.length > 0, "auth query matches"); + assert.equal(res.data[0].filePath, "auth/login.ts", "most relevant chunk ranks first"); + assert.ok( + res.data.every((r, i) => i === 0 || r.score <= res.data[i - 1].score), + "results sorted by descending BM25 score" + ); + + // Stopwords + sub-3-char tokens are dropped entirely. + const empty = await searchWorkspace(wsId, "the a is to"); + assert.ok(empty.ok && empty.data.length === 0, "stopword-only query returns nothing"); + + // Rebuild replaces the index rather than accumulating. + const rebuilt = await buildSearchIndex(wsId, [ + { filePath: "solo.ts", chunkType: "file", layer: null, chunkText: "unrelated solo content" }, + ]); + assert.ok(rebuilt.ok && rebuilt.data === 1); + const after = await searchWorkspace(wsId, "authentication"); + assert.ok(after.ok && after.data.length === 0, "old chunks gone after rebuild"); +}); + +// ── E2E smoke: import → analyze → deep-review → export (MS5) ────────────────── +test("export: markdown captures the full pipeline (map, review, questions, concepts)", async () => { + const { generateWorkspaceMarkdown } = await import("@/lib/services/export.service"); + + const res = await generateWorkspaceMarkdown(workspaceId); + assert.ok(res.ok, res.ok ? "" : res.error); + if (!res.ok) return; + + const { filename, markdown } = res.data; + assert.match(filename, /^nirmiqcodesensei-.+-\d+\.md$/, "filename = slug + timestamp"); + + // The chain: each pipeline stage's output must reach the exported artifact. + assert.match(markdown, /^# .+/m, "title header"); + assert.ok(markdown.includes("Exported from NirmiqCodeSensei"), "export header line"); + assert.ok(markdown.includes("## 🗺️ Learning Map"), "learning-map section"); + assert.ok(markdown.includes("## 🔍 Senior Review"), "deep-review section reaches export"); + assert.match(markdown, /\*\*Overall: [A-F] \(\d+\/100\)\*\*/, "overall grade line"); + assert.ok(markdown.includes("| Lens | Grade |"), "per-lens grade table"); + assert.ok(markdown.includes("## 💬 Explain-Back"), "explain-back section"); + assert.match(markdown, /### Q1:/, "at least one generated question"); + assert.ok(markdown.includes("## ⚙️ DSA Bridge"), "DSA-bridge section"); + // The green answer submitted by the progress test flows through to the export. + assert.ok(markdown.includes("test answer"), "user answer captured in export"); +}); + // ── deleteWorkspace cascade ─────────────────────────────────────────────────── test("deleteWorkspace: cascade leaves zero orphaned child rows", async () => { const res = await deleteWorkspace(workspaceId);