From e6aa77000a8c71aae80ef430c49e2ad8ff490a85 Mon Sep 17 00:00:00 2001 From: kahboom Date: Sat, 28 Feb 2026 21:18:32 +0000 Subject: [PATCH 1/2] feat: add Threadmark JSON export for structured compliance bundles (F060) Co-Authored-By: Claude Opus 4.6 --- PRD.json | 3 +- progress.txt | 10 +- src/export/threadmark.test.ts | 221 ++++++++++++++++++++++++++++++++++ src/export/threadmark.ts | 51 ++++++++ src/types/index.ts | 1 + src/types/scan.ts | 21 ++++ 6 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 src/export/threadmark.test.ts create mode 100644 src/export/threadmark.ts 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..9118979 --- /dev/null +++ b/src/export/threadmark.test.ts @@ -0,0 +1,221 @@ +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", () => { + 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("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()); + bundle.exportedAt = "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" }), + ); + bundle.exportedAt = "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" })); + bundle.exportedAt = "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..dad6ce2 --- /dev/null +++ b/src/export/threadmark.ts @@ -0,0 +1,51 @@ +import type { ScanResult, ThreadmarkBundle } from "../types/scan.js"; + +export const THREADMARK_VERSION = "1.0.0"; +export const THREADMARK_GENERATOR = "openthreads-trace"; + +export function buildThreadmarkBundle( + scanResult: ScanResult, +): ThreadmarkBundle { + const riskSummary = 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: 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..c8f6d58 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,5 +7,6 @@ export type { ClaimPenalty, RiskScoreBreakdown, EvidenceClip, + ThreadmarkBundle, ScanResult, } from "./scan.js"; diff --git a/src/types/scan.ts b/src/types/scan.ts index dc39583..e129d02 100644 --- a/src/types/scan.ts +++ b/src/types/scan.ts @@ -55,6 +55,27 @@ export interface EvidenceClip { claimKeyword?: string; } +export interface ThreadmarkBundle { + version: string; + generator: string; + exportedAt: string; + scan: { + url: string; + title: string; + category: ProductCategory; + scannedAt: string; + }; + fields: FieldResult[]; + claims: ClaimFlag[]; + evidence: EvidenceClip[]; + riskSummary: { + score: number; + maxScore: number; + fieldPenaltyCount: number; + claimPenaltyCount: number; + } | null; +} + export interface ScanResult { url: string; title: string; From f8321c05dabf2779974b58e85728fb07fe248bef Mon Sep 17 00:00:00 2001 From: kahboom Date: Sat, 28 Feb 2026 22:40:05 +0000 Subject: [PATCH 2/2] refactor: improve threadmark export testability and type reusability Extract inline types ScanMetadata and RiskSummary from ThreadmarkBundle for better reusability across future export formats. Make timestamp injectable in buildThreadmarkBundle() to eliminate non-determinism and enable cleaner unit tests without bundle mutation. Changes: - Add ScanMetadata interface for scan metadata shape - Add RiskSummary interface for risk summary shape - Accept optional exportedAt parameter in buildThreadmarkBundle() - Update tests to inject timestamps instead of mutating bundles - Add test for custom timestamp injection - Export new types from types/index.ts All 115 tests pass. Addresses feedback items #2, #3, #5, #6. Co-Authored-By: Claude Sonnet 4.5 --- src/export/threadmark.test.ts | 22 ++++++++++++++++------ src/export/threadmark.ts | 11 ++++++++--- src/types/index.ts | 2 ++ src/types/scan.ts | 28 ++++++++++++++++------------ 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/export/threadmark.test.ts b/src/export/threadmark.test.ts index 9118979..374829f 100644 --- a/src/export/threadmark.test.ts +++ b/src/export/threadmark.test.ts @@ -28,7 +28,7 @@ describe("buildThreadmarkBundle", () => { expect(bundle.generator).toBe(THREADMARK_GENERATOR); }); - it("sets exportedAt to current time", () => { + it("sets exportedAt to current time when not provided", () => { const before = new Date().toISOString(); const bundle = buildThreadmarkBundle(makeScanResult()); const after = new Date().toISOString(); @@ -36,6 +36,12 @@ describe("buildThreadmarkBundle", () => { 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({ @@ -197,8 +203,10 @@ describe("serializeBundle", () => { describe("generateFilename", () => { it("generates filename with hostname and date", () => { - const bundle = buildThreadmarkBundle(makeScanResult()); - bundle.exportedAt = "2026-02-28T15:30:00.000Z"; + const bundle = buildThreadmarkBundle( + makeScanResult(), + "2026-02-28T15:30:00.000Z", + ); const filename = generateFilename(bundle); expect(filename).toBe("threadmark-example.com-2026-02-28.json"); }); @@ -206,15 +214,17 @@ describe("generateFilename", () => { it("sanitizes unusual hostnames", () => { const bundle = buildThreadmarkBundle( makeScanResult({ url: "https://shop.example.co.uk/item" }), + "2026-03-01T00:00:00.000Z", ); - bundle.exportedAt = "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" })); - bundle.exportedAt = "2026-02-28T00:00:00.000Z"; + 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 index dad6ce2..19d998c 100644 --- a/src/export/threadmark.ts +++ b/src/export/threadmark.ts @@ -1,12 +1,17 @@ -import type { ScanResult, ThreadmarkBundle } from "../types/scan.js"; +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 = scanResult.riskBreakdown + const riskSummary: RiskSummary | null = scanResult.riskBreakdown ? { score: scanResult.riskBreakdown.score, maxScore: scanResult.riskBreakdown.maxScore, @@ -18,7 +23,7 @@ export function buildThreadmarkBundle( return { version: THREADMARK_VERSION, generator: THREADMARK_GENERATOR, - exportedAt: new Date().toISOString(), + exportedAt: exportedAt ?? new Date().toISOString(), scan: { url: scanResult.url, title: scanResult.title, diff --git a/src/types/index.ts b/src/types/index.ts index c8f6d58..d9c0b28 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +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 e129d02..6052321 100644 --- a/src/types/scan.ts +++ b/src/types/scan.ts @@ -55,25 +55,29 @@ 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: { - url: string; - title: string; - category: ProductCategory; - scannedAt: string; - }; + scan: ScanMetadata; fields: FieldResult[]; claims: ClaimFlag[]; evidence: EvidenceClip[]; - riskSummary: { - score: number; - maxScore: number; - fieldPenaltyCount: number; - claimPenaltyCount: number; - } | null; + riskSummary: RiskSummary | null; } export interface ScanResult {