Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
/**
* Open-core auth switch.
*
* When NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is unset the app runs in local/OSS
* mode: no ClerkProvider, pass-through middleware, a stubbed signed-in
* "local" user (matching the backend's LOCAL_DEV_USER), and all credits /
* billing UI hidden. Pairs with BILLING_ENABLED=false on the backend —
* mixed modes (key set but billing off, or the inverse) are unsupported.
*
* NEXT_PUBLIC_* vars are inlined at build time, so this is a build-time
* constant — changing it requires a rebuild / dev-server restart.
*/
export const authEnabled = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
+10
View File
@@ -0,0 +1,10 @@
// Open-core seam: third-party hosts allow-listed in the CSP
// (next.config.ts). The cloud/gateway build adds Clerk (auth), Stripe
// (billing), and its analytics hosts here; the open-source build needs
// none.
export const CSP_SCRIPT_HOSTS: string[] = [];
export const CSP_CONNECT_HOSTS: string[] = [];
export const CSP_FRAME_HOSTS: string[] = [];
+58
View File
@@ -0,0 +1,58 @@
import type { Project, PipelineStep } from "./types";
export const PROJECTS: Project[] = [
{
id: "simple_project",
name: "TI MSP Tutorial Board",
created: "2026-04-09T00:00:00Z",
status: "complete",
summary: { total: 8, ERROR: 2, WARNING: 4, INFO: 2 },
hasNetlist: true,
hasBom: true,
datasheetCount: 3,
},
];
export function createPipelineSteps(): PipelineStep[] {
return [
{
title: "IC Datasheet Extraction",
description: "Parse component datasheets and extract constraints",
status: "pending",
substeps: [
{ key: "SPX3819M5-L-3-3/TR", label: "SPX3819M5-L-3-3/TR (U1)", status: "pending" },
{ key: "CH340E", label: "CH340E (U2)", status: "pending" },
{ key: "MSPM0G3507SPTR", label: "MSPM0G3507SPTR (U3)", status: "pending" },
],
},
{
title: "Passive Pattern Extraction",
description: "Resolve passive component values from MPN patterns",
status: "pending",
substeps: [
{ key: "samsung", label: "Samsung capacitors", status: "pending" },
{ key: "uniroyal", label: "Uniroyal resistors", status: "pending" },
],
},
{
title: "Build Design Graph",
description: "Parse BOM and netlist into structured graph",
status: "pending",
substeps: [
{ key: "parse-bom", label: "Parse BOM (17 components)", status: "pending" },
{ key: "parse-netlist", label: "Parse netlist (36 nets)", status: "pending" },
{ key: "enrich", label: "Enrich with datasheet data", status: "pending" },
],
},
{
title: "Review Design",
description: "Review each IC against its datasheet",
status: "pending",
substeps: [
{ key: "U1", label: "Review U1 — LDO", status: "pending" },
{ key: "U2", label: "Review U2 — USB Bridge", status: "pending" },
{ key: "U3", label: "Review U3 — MCU", status: "pending" },
],
},
];
}
+60
View File
@@ -0,0 +1,60 @@
import * as XLSX from "xlsx";
import type { DesignGraph, Finding, ValidationReport } from "./types";
import { sortFindings } from "./utils";
const HEADER = [
"Designator",
"MPN",
"ID",
"Severity",
"Title",
"Description",
"Recommendation",
"Source",
];
// Column widths (in characters), aligned with HEADER order.
const COL_WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24];
// Fold the datasheet reference + page number into a single cell, mirroring the
// finding card's reference button + "Automated check" tag logic.
function formatSource(f: Finding): string {
if (f.source && f.source !== "review") return "Automated check";
const ds = f.source_designator || f.designator;
return `${ds} datasheet` + (f.source_page ? ` p.${f.source_page}` : "");
}
function slugify(name: string): string {
return name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "report";
}
/**
* Build and download the validation report as an .xlsx workbook (client-side).
* One "Findings" sheet, one row per finding, sorted ERROR -> WARNING -> INFO.
*/
export function exportReportToExcel(
report: ValidationReport,
graph: DesignGraph,
projectName?: string,
): void {
const rows = sortFindings(report.findings).map((f) => [
f.designator,
f.mpn || graph.components[f.designator]?.mpn || "",
f.finding_id ?? "",
f.status,
f.finding,
f.why ?? "",
f.recommendation ?? "",
formatSource(f),
]);
const ws = XLSX.utils.aoa_to_sheet([HEADER, ...rows]);
ws["!cols"] = COL_WIDTHS.map((wch) => ({ wch }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Findings");
const filename = `${slugify(projectName || report.project || "report")}-findings.xlsx`;
XLSX.writeFile(wb, filename);
}
+63
View File
@@ -0,0 +1,63 @@
export const SITE_URL = (
process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, "") ||
"https://pinscope.ai"
);
export const SITE_NAME = "Pinscope";
export const SITE_DESCRIPTION =
"Pinscope reviews your schematic against every datasheet and catches the errors that would otherwise surface at bring-up. Works with KiCad, Altium, OrCAD, Cadence, Siemens, EasyEDA, and EAGLE.";
export const SITE_TAGLINE = "Agentic schematic validation";
export const TWITTER_HANDLE = "@getFaradWorks";
import type { Metadata } from "next";
/**
* Build per-page metadata that preserves root openGraph/twitter fields.
* Next.js shallow-overwrites the openGraph and twitter keys, so any page
* that sets its own values must re-declare siteName/card/images/etc.
*/
export function pageMetadata({
title,
description,
path,
}: {
title: string;
description: string;
path: string;
}): Metadata {
const url = path.startsWith("/") ? path : `/${path}`;
const fullTitle = `${title} · ${SITE_NAME}`;
return {
title,
description,
alternates: { canonical: url },
openGraph: {
type: "website",
siteName: SITE_NAME,
locale: "en_US",
url,
title: fullTitle,
description,
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: `${SITE_NAME}${SITE_TAGLINE}`,
},
],
},
twitter: {
card: "summary_large_image",
site: TWITTER_HANDLE,
creator: TWITTER_HANDLE,
title: fullTitle,
description,
images: ["/twitter-image"],
},
};
}
+322
View File
@@ -0,0 +1,322 @@
export type FindingStatus = "ERROR" | "WARNING" | "INFO";
export interface Finding {
finding_id: string | null;
designator: string;
mpn: string;
aspect: string | null;
finding: string;
why: string;
source_page: number | null;
source_quote?: string;
source_designator?: string | null; // designator whose datasheet source_page refers to; null = this finding's own `designator` (evidence from a connected component's datasheet excerpt)
status: FindingStatus;
recommendation?: string;
reference: string;
source?: string | null; // "pin_mux_check"/"led_current_check" = deterministic; null/"review" = LLM
}
export interface FindingComment {
comment_id: string;
finding_id: string;
user_id: string;
user_name: string;
text: string;
mentions: string[];
created_at: string;
}
export interface ValidationReport {
project: string;
timestamp: string;
findings: Finding[];
summary: Record<string, number>;
coverage: Record<string, string[]>;
review_errors?: Record<string, string>;
not_reviewed?: { designator: string; reason: string }[];
comments?: Record<string, FindingComment[]>;
}
export type NetType = "power" | "ground" | "signal" | "unknown";
export type ComponentType =
| "resistor"
| "capacitor"
| "inductor"
| "ic"
| "connector"
| "crystal"
| "discrete"
| "transformer"
| "fuse"
| "switch"
| "test_point"
| "fiducial"
| "mechanical"
| "unknown";
export interface ComponentSpecs {
specs_type: string;
value_formatted?: string;
values?: Record<string, string | number | null>;
[key: string]: unknown;
}
export interface Component {
reference: string;
value: string;
footprint: string;
component_type: ComponentType;
component_subtype: string | null;
mpn: string | null;
pins: Record<string, string>;
specs: ComponentSpecs | null;
}
export interface PinConnection {
component_ref: string;
pin_number: string;
pin_name: string | null;
}
export interface Net {
name: string;
net_type: NetType;
voltage: number | null;
pins: PinConnection[];
}
export interface DesignGraph {
components: Record<string, Component>;
nets: Record<string, Net>;
}
export interface BomSummaryRow {
mpn: string | null;
designators: string[];
value: string;
category: string | null;
specs: Record<string, string | number> | null;
description: string | null;
hasDatasheet: boolean;
}
export type ProjectStatus =
| "draft"
| "running"
| "complete"
| "error"
| "cancelled"
| "paused_insufficient_credits"
| "paused_by_user";
export interface PauseCheckpoint {
paused_at?: string | null;
paused_stage?: string | null;
last_completed_label?: string | null;
completed_review_refs?: string[];
pending_review_refs?: string[];
}
export interface CreditSnapshot {
user_id: string;
balance: number;
plan: string; // always "payg" post-migration; kept for backward compat
last_entry_ts?: string | null;
next_expiry?: string | null;
}
export interface CreditGrant {
grant_id: string;
user_id: string;
amount_usd: number;
remaining: number;
granted_at: string;
expires_at: string | null;
source:
| "top_up"
| "trial_grant"
| "admin_adjust"
| "refund_system_error"
| "pre_migration";
stripe_event_id?: string | null;
expired?: boolean;
note?: string | null;
}
export interface AutoTopupConfig {
enabled: boolean;
threshold_credits: number;
amount_usd: number;
has_payment_method: boolean;
last_attempt_ts?: string | null;
last_attempt_status?: "ok" | "failed" | "pending" | "";
last_failure_reason?: string | null;
}
export interface CreditLedgerEntry {
user_id: string;
timestamp: string;
delta: number;
balance_after: number;
reason: string;
run_id?: string | null;
unit_id?: string | null;
stripe_event_id?: string | null;
note?: string | null;
}
export interface CostItem {
identifier: string;
kind: string;
api_cost_usd: number;
source: "cache_hit" | "api_call" | "api_call_estimated";
note?: string | null;
}
export interface CostEstimate {
api_cost_low: number;
api_cost_high: number;
api_cost_mid: number;
credits_low: number;
credits_high: number;
credits_mid: number;
breakdown: CostItem[];
ic_count: number;
simple_count: number;
passive_count: number;
cached_ic_count: number;
cached_simple_count: number;
cached_passive_count: number;
review_ic_count: number;
}
export interface SkippedComponent {
identifier: string;
stage: string;
error: string;
}
/**
* Cached purple-parts payload for a single LCSC id. Populated server-side at
* BOM upload when the MPN column was detected as entirely LCSC ids; consumed
* by the wizard's LCSC passive-resolve step to render a description summary
* and by the backend to synthesize the auto-resolve call.
*/
export interface LcscPayload {
mpn?: string | null;
manufacturer?: string | null;
package?: string | null;
description?: string | null;
category?: string | null;
subcategory?: string | null;
}
export interface ComponentMpnBuckets {
ic: string[];
passive: string[];
simple: string[];
}
export interface Project {
id: string;
name: string;
created: string;
status: ProjectStatus;
summary?: Record<string, number>;
hasNetlist: boolean;
hasBom: boolean;
datasheetCount: number;
skippedComponents?: SkippedComponent[];
userId?: string;
collaborators?: string[];
creditsSpent?: number;
pauseCheckpoint?: PauseCheckpoint | null;
pauseReason?: string | null;
bomColumns?: { reference: string; mpn: string } | null;
pipelineError?: string | null;
// LCSC mapping captured at BOM upload time. `lcscToMpn` is the resolved
// LCSC id → MPN map used by the wizard to render "C12044 → STM32F103C8T6";
// `lcscPayloads` is the full purple-parts payload keyed by LCSC id used by
// the per-row passive resolve step.
lcscToMpn?: Record<string, string> | null;
lcscPayloads?: Record<string, LcscPayload> | null;
componentMpns?: ComponentMpnBuckets | null;
pinscopeVersion?: string | null;
// "pads" | "edif" — what kind of netlist file the user uploaded. null on
// projects predating EDIF support; treat null as PADS for rendering.
netlistFormat?: "pads" | "edif" | null;
// Sub-design IDs (e.g. ["&0441"]) the user chose to include in the review.
// null means "include every sub-design found in the file" — the default
// for single-sub-design EDIFs and all PADS netlists.
netlistSubdesigns?: string[] | null;
}
// One entry per EDIF sub-design (`&NNNN` ID prefix). Returned by the upload
// endpoint and the subdesigns inspection endpoint; consumed by the wizard's
// sub-design picker step.
export interface EdifSubDesign {
id: string | null; // null → bare-named cells with no prefix
instance_count: number;
designators: string[];
}
export interface Collaborator {
user_id: string;
name: string | null;
email: string | null;
image_url: string | null;
role: "owner" | "collaborator";
}
export interface ApiLogEntry {
timestamp: string;
stage: string;
identifier: string;
model: string;
input_tokens: number;
output_tokens: number;
duration_ms: number;
stop_reason: string;
skill_id?: string | null;
turns?: number | null;
error?: string | null;
cost_usd?: number | null;
}
export interface DeratingRow {
designator: string;
mpn: string | null;
value_formatted: string | null;
rated_voltage_v: number | null;
operating_voltage_v: number | null;
operating_voltage_source: string | null;
net_plus: string | null;
net_minus: string | null;
dielectric_category: "ceramic" | "tantalum" | "electrolytic" | null;
}
export interface DeratingSettings {
ceramic: number;
tantalum: number;
electrolytic: number;
}
export interface NetlistPreviewDesignator {
ref: string;
pins: { number: string; net_name: string }[];
}
export interface PipelineSubstep {
key: string;
label: string;
status: "pending" | "running" | "complete";
cached?: boolean;
}
export interface PipelineStep {
title: string;
description: string;
substeps: PipelineSubstep[];
status: "pending" | "running" | "complete";
totalNew?: number;
}
+34
View File
@@ -0,0 +1,34 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { Finding, FindingStatus } from "./types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function groupBy<T>(items: T[], key: (item: T) => string): Record<string, T[]> {
const result: Record<string, T[]> = {};
for (const item of items) {
const k = key(item);
(result[k] ??= []).push(item);
}
return result;
}
const STATUS_ORDER: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
export function sortFindings(findings: Finding[]): Finding[] {
return [...findings].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
}
export function getFindingKey(finding: Finding, index: number): string {
return finding.finding_id ?? `${finding.designator}-idx-${index}`;
}
export function subtypeLabel(subtype: string | null): string {
if (!subtype) return "";
const parts = subtype.split(".");
return parts[parts.length - 1]
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
+4
View File
@@ -0,0 +1,4 @@
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
export const APP_VERSION = "2.6.0";
export const APP_VERSION_DATE = "2026-07-12";