diff --git a/PRD.json b/PRD.json index c879e4f..8c3e099 100644 --- a/PRD.json +++ b/PRD.json @@ -186,7 +186,8 @@ "id": "F060", "phase": 2, "name": "Threadmark JSON Export", - "description": "Export structured bundle" + "description": "Export structured bundle", + "status": "passes" } ], "successMetrics": [ diff --git a/progress.txt b/progress.txt index fb218bb..9bf883d 100644 --- a/progress.txt +++ b/progress.txt @@ -57,4 +57,12 @@ F050 - Evidence Clipper [PASSES] - Content script handles CLIP_EVIDENCE message: captures window selection, creates clip, returns to popup - 21 new unit tests for clipper module (100 total passing) -Next task: F060 - Threadmark JSON Export (Phase 2) +F060 - Threadmark JSON Export [PASSES] +- threadmark.ts: pure functions for building and serializing Threadmark-compatible JSON bundles +- buildThreadmarkBundle: packages ScanResult into ThreadmarkBundle with version, generator, export timestamp, scan metadata, fields, claims, evidence, and risk summary +- serializeBundle: pretty-prints bundle as 2-space indented JSON string +- generateFilename: creates hostname+date based filename (e.g. threadmark-example.com-2026-02-28.json) +- ThreadmarkBundle type added to types/scan.ts with scan metadata, riskSummary (nullable), and all scan data +- 14 new unit tests for export module (114 total passing) + +All MVP features complete (F005-F060). diff --git a/src/export/threadmark.test.ts b/src/export/threadmark.test.ts new file mode 100644 index 0000000..374829f --- /dev/null +++ b/src/export/threadmark.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from "vitest"; +import type { ScanResult } from "../types/scan.js"; +import { + buildThreadmarkBundle, + serializeBundle, + generateFilename, + THREADMARK_VERSION, + THREADMARK_GENERATOR, +} from "./threadmark.js"; + +function makeScanResult(overrides: Partial = {}): ScanResult { + return { + url: "https://example.com/product/widget", + title: "Acme Widget", + category: "general", + timestamp: "2026-02-28T12:00:00.000Z", + fields: [], + claims: [], + evidence: [], + ...overrides, + }; +} + +describe("buildThreadmarkBundle", () => { + it("includes version and generator metadata", () => { + const bundle = buildThreadmarkBundle(makeScanResult()); + expect(bundle.version).toBe(THREADMARK_VERSION); + expect(bundle.generator).toBe(THREADMARK_GENERATOR); + }); + + it("sets exportedAt to current time when not provided", () => { + const before = new Date().toISOString(); + const bundle = buildThreadmarkBundle(makeScanResult()); + const after = new Date().toISOString(); + expect(bundle.exportedAt >= before).toBe(true); + expect(bundle.exportedAt <= after).toBe(true); + }); + + it("accepts custom exportedAt timestamp", () => { + const customTime = "2026-02-28T15:30:00.000Z"; + const bundle = buildThreadmarkBundle(makeScanResult(), customTime); + expect(bundle.exportedAt).toBe(customTime); + }); + + it("maps scan metadata from ScanResult", () => { + const bundle = buildThreadmarkBundle( + makeScanResult({ + url: "https://shop.example.com/item/42", + title: "Test Product", + category: "textiles", + timestamp: "2026-02-28T10:00:00.000Z", + }), + ); + expect(bundle.scan.url).toBe("https://shop.example.com/item/42"); + expect(bundle.scan.title).toBe("Test Product"); + expect(bundle.scan.category).toBe("textiles"); + expect(bundle.scan.scannedAt).toBe("2026-02-28T10:00:00.000Z"); + }); + + it("includes fields from scan result", () => { + const fields = [ + { + key: "product_name", + group: "Identity & Contacts", + required: true, + status: "found" as const, + value: "Widget X", + confidence: 0.9, + }, + { + key: "materials", + group: "Composition & Origin", + required: false, + status: "missing" as const, + confidence: 1.0, + }, + ]; + const bundle = buildThreadmarkBundle(makeScanResult({ fields })); + expect(bundle.fields).toEqual(fields); + }); + + it("includes claims from scan result", () => { + const claims = [ + { + claim: "eco-friendly", + riskLevel: "high" as const, + evidenceRequired: "Third-party certification", + source: "...eco-friendly product...", + }, + ]; + const bundle = buildThreadmarkBundle(makeScanResult({ claims })); + expect(bundle.claims).toEqual(claims); + }); + + it("includes evidence clips from scan result", () => { + const evidence = [ + { + id: "clip-1", + text: "100% organic cotton", + context: "...made from 100% organic cotton sourced...", + url: "https://example.com/product", + timestamp: "2026-02-28T12:00:00.000Z", + fieldKey: "materials", + }, + ]; + const bundle = buildThreadmarkBundle(makeScanResult({ evidence })); + expect(bundle.evidence).toEqual(evidence); + }); + + it("includes risk summary when breakdown is present", () => { + const bundle = buildThreadmarkBundle( + makeScanResult({ + riskBreakdown: { + score: 23, + maxScore: 50, + fieldPenalties: [ + { + key: "materials", + group: "Composition & Origin", + required: false, + penalty: 3, + reason: 'Optional field "materials" is missing', + }, + ], + claimPenalties: [ + { + claim: "eco-friendly", + riskLevel: "high", + penalty: 8, + reason: 'Unsubstantiated "eco-friendly" claim (high risk)', + }, + ], + }, + }), + ); + expect(bundle.riskSummary).toEqual({ + score: 23, + maxScore: 50, + fieldPenaltyCount: 1, + claimPenaltyCount: 1, + }); + }); + + it("sets riskSummary to null when no breakdown", () => { + const bundle = buildThreadmarkBundle(makeScanResult()); + expect(bundle.riskSummary).toBeNull(); + }); +}); + +describe("serializeBundle", () => { + it("produces valid JSON", () => { + const bundle = buildThreadmarkBundle(makeScanResult()); + const json = serializeBundle(bundle); + const parsed = JSON.parse(json); + expect(parsed.version).toBe(THREADMARK_VERSION); + expect(parsed.generator).toBe(THREADMARK_GENERATOR); + }); + + it("is pretty-printed with 2-space indent", () => { + const bundle = buildThreadmarkBundle(makeScanResult()); + const json = serializeBundle(bundle); + expect(json).toContain("\n "); + }); + + it("round-trips all data", () => { + const scanResult = makeScanResult({ + fields: [ + { + key: "brand", + group: "Identity & Contacts", + required: true, + status: "found", + value: "Acme", + confidence: 0.9, + }, + ], + claims: [ + { + claim: "sustainable", + riskLevel: "high", + evidenceRequired: "Certification", + source: "...sustainable...", + }, + ], + evidence: [ + { + id: "clip-1", + text: "certified organic", + context: "...certified organic...", + url: "https://example.com", + timestamp: "2026-02-28T12:00:00.000Z", + }, + ], + }); + const bundle = buildThreadmarkBundle(scanResult); + const json = serializeBundle(bundle); + const parsed = JSON.parse(json); + expect(parsed.fields).toEqual(bundle.fields); + expect(parsed.claims).toEqual(bundle.claims); + expect(parsed.evidence).toEqual(bundle.evidence); + }); +}); + +describe("generateFilename", () => { + it("generates filename with hostname and date", () => { + const bundle = buildThreadmarkBundle( + makeScanResult(), + "2026-02-28T15:30:00.000Z", + ); + const filename = generateFilename(bundle); + expect(filename).toBe("threadmark-example.com-2026-02-28.json"); + }); + + it("sanitizes unusual hostnames", () => { + const bundle = buildThreadmarkBundle( + makeScanResult({ url: "https://shop.example.co.uk/item" }), + "2026-03-01T00:00:00.000Z", + ); + const filename = generateFilename(bundle); + expect(filename).toBe("threadmark-shop.example.co.uk-2026-03-01.json"); + }); + + it("handles invalid URLs gracefully", () => { + const bundle = buildThreadmarkBundle( + makeScanResult({ url: "not-a-url" }), + "2026-02-28T00:00:00.000Z", + ); + const filename = generateFilename(bundle); + expect(filename).toBe("threadmark-unknown-2026-02-28.json"); + }); +}); diff --git a/src/export/threadmark.ts b/src/export/threadmark.ts new file mode 100644 index 0000000..19d998c --- /dev/null +++ b/src/export/threadmark.ts @@ -0,0 +1,56 @@ +import type { + ScanResult, + ThreadmarkBundle, + RiskSummary, +} from "../types/scan.js"; + +export const THREADMARK_VERSION = "1.0.0"; +export const THREADMARK_GENERATOR = "openthreads-trace"; + +export function buildThreadmarkBundle( + scanResult: ScanResult, + exportedAt?: string, +): ThreadmarkBundle { + const riskSummary: RiskSummary | null = scanResult.riskBreakdown + ? { + score: scanResult.riskBreakdown.score, + maxScore: scanResult.riskBreakdown.maxScore, + fieldPenaltyCount: scanResult.riskBreakdown.fieldPenalties.length, + claimPenaltyCount: scanResult.riskBreakdown.claimPenalties.length, + } + : null; + + return { + version: THREADMARK_VERSION, + generator: THREADMARK_GENERATOR, + exportedAt: exportedAt ?? new Date().toISOString(), + scan: { + url: scanResult.url, + title: scanResult.title, + category: scanResult.category, + scannedAt: scanResult.timestamp, + }, + fields: scanResult.fields, + claims: scanResult.claims, + evidence: scanResult.evidence, + riskSummary, + }; +} + +export function serializeBundle(bundle: ThreadmarkBundle): string { + return JSON.stringify(bundle, null, 2); +} + +export function generateFilename(bundle: ThreadmarkBundle): string { + const date = bundle.exportedAt.slice(0, 10); + const host = safeHostname(bundle.scan.url); + return `threadmark-${host}-${date}.json`; +} + +function safeHostname(url: string): string { + try { + return new URL(url).hostname.replace(/[^a-zA-Z0-9.-]/g, "_"); + } catch { + return "unknown"; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 7c9cc4c..d9c0b28 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,5 +7,8 @@ export type { ClaimPenalty, RiskScoreBreakdown, EvidenceClip, + ScanMetadata, + RiskSummary, + ThreadmarkBundle, ScanResult, } from "./scan.js"; diff --git a/src/types/scan.ts b/src/types/scan.ts index dc39583..6052321 100644 --- a/src/types/scan.ts +++ b/src/types/scan.ts @@ -55,6 +55,31 @@ export interface EvidenceClip { claimKeyword?: string; } +export interface ScanMetadata { + url: string; + title: string; + category: ProductCategory; + scannedAt: string; +} + +export interface RiskSummary { + score: number; + maxScore: number; + fieldPenaltyCount: number; + claimPenaltyCount: number; +} + +export interface ThreadmarkBundle { + version: string; + generator: string; + exportedAt: string; + scan: ScanMetadata; + fields: FieldResult[]; + claims: ClaimFlag[]; + evidence: EvidenceClip[]; + riskSummary: RiskSummary | null; +} + export interface ScanResult { url: string; title: string;