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
3 changes: 2 additions & 1 deletion PRD.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@
"id": "F060",
"phase": 2,
"name": "Threadmark JSON Export",
"description": "Export structured bundle"
"description": "Export structured bundle",
"status": "passes"
}
],
"successMetrics": [
Expand Down
10 changes: 9 additions & 1 deletion progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
231 changes: 231 additions & 0 deletions src/export/threadmark.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
56 changes: 56 additions & 0 deletions src/export/threadmark.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ export type {
ClaimPenalty,
RiskScoreBreakdown,
EvidenceClip,
ScanMetadata,
RiskSummary,
ThreadmarkBundle,
ScanResult,
} from "./scan.js";
25 changes: 25 additions & 0 deletions src/types/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading