Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
2 changes: 1 addition & 1 deletion docs/MEGASPRINT_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
78 changes: 78 additions & 0 deletions lib/services/export.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,78 @@ function hr(): string {
return "\n\n---\n\n";
}

const SEV_EMOJI: Record<string, string> = {
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<ServiceResult<ExportPayload>> {
Expand Down Expand Up @@ -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");
Expand Down
73 changes: 73 additions & 0 deletions tests/import-pipeline.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading