tether-ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +124 -2
  2. package/dist/approvals/match.d.ts +14 -0
  3. package/dist/approvals/match.js +216 -0
  4. package/dist/approvals/storage.d.ts +4 -0
  5. package/dist/approvals/storage.js +44 -0
  6. package/dist/cli/approvals.d.ts +2 -0
  7. package/dist/cli/approvals.js +113 -0
  8. package/dist/cli/format.d.ts +2 -2
  9. package/dist/cli/format.js +25 -5
  10. package/dist/cli/index.js +30 -21
  11. package/dist/cli/json.d.ts +62 -3
  12. package/dist/cli/json.js +66 -40
  13. package/dist/contract/describe.d.ts +2 -0
  14. package/dist/contract/describe.js +6 -0
  15. package/dist/contract/infer.d.ts +2 -2
  16. package/dist/contract/infer.js +69 -2
  17. package/dist/contract/read.d.ts +3 -0
  18. package/dist/contract/read.js +67 -0
  19. package/dist/contract/scopes.d.ts +5 -0
  20. package/dist/contract/scopes.js +150 -0
  21. package/dist/drift/compare.d.ts +4 -1
  22. package/dist/drift/compare.js +97 -33
  23. package/dist/drift/evidence.js +1 -0
  24. package/dist/scanner/collect.d.ts +4 -2
  25. package/dist/scanner/collect.js +61 -4
  26. package/dist/scanner/scan.js +127 -61
  27. package/dist/types.d.ts +103 -2
  28. package/dist/utils/config.js +18 -2
  29. package/dist/utils/coverage.d.ts +3 -0
  30. package/dist/utils/coverage.js +28 -0
  31. package/dist/utils/scenarios.d.ts +24 -0
  32. package/dist/utils/scenarios.js +82 -0
  33. package/dist/utils/storage.js +8 -3
  34. package/package.json +4 -3
  35. package/report/out/404/index.html +1 -1
  36. package/report/out/404.html +1 -1
  37. package/report/out/__next.__PAGE__.txt +3 -3
  38. package/report/out/__next._full.txt +3 -3
  39. package/report/out/__next._tree.txt +2 -2
  40. package/report/out/_next/static/chunks/330-b092d894bdb2103c.js +1 -0
  41. package/report/out/_next/static/chunks/app/_not-found/{page-032313fc4524b472.js → page-172b410568146bc8.js} +1 -1
  42. package/report/out/_next/static/chunks/app/contract/{page-60f6d610ece63f59.js → page-f49364851a596fef.js} +1 -1
  43. package/report/out/_next/static/chunks/app/drift/{page-655683d6032a79a8.js → page-e0f2eda1e981d86f.js} +1 -1
  44. package/report/out/_next/static/chunks/app/layout-9a410c21fc0ba3c9.js +1 -0
  45. package/report/out/_next/static/chunks/app/{page-bdfcce47bd844e7e.js → page-e399a35da3e55f00.js} +1 -1
  46. package/report/out/_next/static/chunks/app/viewports/{page-6e44561586c30871.js → page-5a01227414ac4842.js} +1 -1
  47. package/report/out/_next/static/chunks/{main-app-e3082ce30f616b80.js → main-app-e7e077df7256067f.js} +1 -1
  48. package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-94732500710e6d55.js +1 -0
  49. package/report/out/_next/static/css/b754cdc60c7b43bb.css +1 -0
  50. package/report/out/_not-found/__next._full.txt +2 -2
  51. package/report/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  52. package/report/out/_not-found/__next._tree.txt +2 -2
  53. package/report/out/_not-found/index.html +1 -1
  54. package/report/out/_not-found/index.txt +2 -2
  55. package/report/out/contract/__next._full.txt +3 -3
  56. package/report/out/contract/__next._tree.txt +2 -2
  57. package/report/out/contract/__next.contract.__PAGE__.txt +3 -3
  58. package/report/out/contract/index.html +1 -1
  59. package/report/out/contract/index.txt +3 -3
  60. package/report/out/drift/__next._full.txt +3 -3
  61. package/report/out/drift/__next._tree.txt +2 -2
  62. package/report/out/drift/__next.drift.__PAGE__.txt +3 -3
  63. package/report/out/drift/index.html +1 -1
  64. package/report/out/drift/index.txt +3 -3
  65. package/report/out/index.html +1 -1
  66. package/report/out/index.txt +3 -3
  67. package/report/out/viewports/__next._full.txt +3 -3
  68. package/report/out/viewports/__next._tree.txt +2 -2
  69. package/report/out/viewports/__next.viewports.__PAGE__.txt +3 -3
  70. package/report/out/viewports/index.html +1 -1
  71. package/report/out/viewports/index.txt +3 -3
  72. package/report/out/_next/static/chunks/679-ea0b2616e7a83e6d.js +0 -1
  73. package/report/out/_next/static/chunks/app/layout-16ba70b55e65864f.js +0 -1
  74. package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-d142454d28d73f8d.js +0 -1
  75. package/report/out/_next/static/css/5f0345b3d96e2143.css +0 -1
  76. /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → TBRjO9Fdl2ToZdlLQ7gIf}/_buildManifest.js +0 -0
  77. /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → TBRjO9Fdl2ToZdlLQ7gIf}/_ssgManifest.js +0 -0
package/dist/cli/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { persistedConfig, safeUrl } from "../utils/scenarios.js";
3
+ import { readContract } from "../contract/read.js";
4
+ import { validateCoverage } from "../utils/coverage.js";
5
+ import { approvalCommands } from "./approvals.js";
6
+ import { readApprovals } from "../approvals/storage.js";
7
+ import { applyApprovals } from "../approvals/match.js";
2
8
  import { Command } from "commander";
3
9
  import { join, resolve } from "node:path";
4
10
  import { createInterface } from "node:readline/promises";
@@ -16,21 +22,26 @@ import { categories, } from "../types.js";
16
22
  const program = new Command()
17
23
  .name("tether")
18
24
  .description("Visual linting for AI-coded interfaces.")
19
- .version("0.2.0");
25
+ .version("0.4.0");
20
26
  program.option("--cwd <directory>", "Project directory", process.cwd());
21
27
  const cwd = () => resolve(program.opts().cwd);
22
28
  const directory = () => join(cwd(), ".tether");
23
- function reportFor(contract, baseline, current) {
24
- const issues = compare(contract, baseline, current);
29
+ function reportFor(contract, baseline, current, file) {
30
+ const result = file
31
+ ? applyApprovals(contract, baseline, current, file)
32
+ : { issues: compare(contract, baseline, current) };
33
+ const { issues } = result;
34
+ const approvals = "approvals" in result ? result.approvals : undefined;
25
35
  return {
26
- version: 1,
36
+ version: approvals ? 2 : 1,
37
+ ...(approvals ? { approvals } : {}),
27
38
  baselineAt: baseline.createdAt,
28
39
  scan: current,
29
40
  contract,
30
41
  issues,
31
42
  score: consistencyScore(issues),
32
43
  elementCount: current.snapshots.reduce((n, s) => n + s.elements.length, 0),
33
- agentMarkdown: agentMarkdown(issues),
44
+ agentMarkdown: agentMarkdown(issues, approvals),
34
45
  };
35
46
  }
36
47
  function warnTruncated(report) {
@@ -62,23 +73,24 @@ program
62
73
  prompt.close();
63
74
  }
64
75
  }
76
+ const approvalFile = await readApprovals(cwd());
65
77
  const config = await loadConfig(cwd(), options.url, options.config);
66
- console.log(`\ntether\n\nScanning ${config.url}…`);
78
+ console.log(`\ntether\n\nScanning ${safeUrl(config.url)}…`);
67
79
  const baseline = await scan(config, directory(), `baseline-${randomUUID().slice(0, 8)}`);
68
- const contract = inferContract(baseline, config.minOccurrences);
80
+ const contract = inferContract(baseline, config.minOccurrences, config.scopes);
69
81
  // Save the exact scan configuration in the baseline so subsequent checks match it.
70
82
  await writeJson(join(directory(), "contract.json"), contract);
71
83
  await writeJson(join(directory(), "baseline.json"), {
72
84
  ...baseline,
73
- config,
85
+ config: persistedConfig(config),
74
86
  });
75
- const report = reportFor(contract, baseline, baseline);
87
+ const report = reportFor(contract, baseline, baseline, approvalFile);
76
88
  await writeJson(join(directory(), "report.json"), report);
77
89
  if (!(await exists(join(cwd(), "tether.config.ts"))) && !options.config)
78
- await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(config, null, 2)};\n`);
90
+ await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(persistedConfig(config), null, 2).replace(JSON.stringify(safeUrl(config.url)), safeUrl(config.url) !== config.url ? "process.env.TETHER_URL" : JSON.stringify(config.url))};\n`);
79
91
  console.log(`\n✓ Baseline saved\n✓ ${categories.reduce((n, c) => n + contract[c].length, 0)} tokens in your visual contract\n✓ ${report.elementCount} elements inspected across ${baseline.snapshots.length} snapshots\n\nRun: tether check\n`);
80
92
  if (report.issues.length)
81
- console.log(`Note: ${report.issues.length} existing layout problems are visible in tether report.`);
93
+ console.log(`Note: ${report.issues.length} existing visual deviations are visible in tether report.`);
82
94
  warnTruncated(report);
83
95
  });
84
96
  program
@@ -94,28 +106,24 @@ program
94
106
  if (!(await exists(join(directory(), "baseline.json"))))
95
107
  throw new Error("No baseline found. Run tether init first.");
96
108
  const baseline = await readJson(join(directory(), "baseline.json"));
97
- const contract = await readJson(join(directory(), "contract.json"));
109
+ const contract = readContract(await readJson(join(directory(), "contract.json")));
98
110
  if (baseline.version !== 1 ||
99
111
  !Array.isArray(baseline.snapshots) ||
100
- contract.version !== 1 ||
112
+ ![1, 2].includes(contract.version) ||
101
113
  categories.some((c) => !Array.isArray(contract[c]) ||
102
114
  contract[c].some((t) => typeof t.value !== "string")))
103
115
  throw new Error("Unsupported or invalid baseline / contract. Run tether init --force to recreate it.");
116
+ const approvalFile = await readApprovals(cwd());
104
117
  const config = await loadConfig(cwd(), options.url, options.config);
105
118
  if (!options.url &&
106
119
  !options.config &&
107
120
  !(await exists(join(cwd(), "tether.config.ts"))))
108
121
  config.url = baseline.url;
109
- const coverage = (s) => `${s.route}|${s.viewport.name}|${s.viewport.width}|${s.viewport.height}`;
110
- const baselineCoverage = new Set(baseline.snapshots.map(coverage));
111
- const requested = config.routes.flatMap((route) => config.viewports.map((viewport) => coverage({ route, viewport })));
112
- if (requested.length !== baselineCoverage.size ||
113
- requested.some((k) => !baselineCoverage.has(k)))
114
- throw new Error("Routes or viewports changed since the baseline. Run tether init --force to establish the new coverage.");
122
+ validateCoverage(config, contract, baseline);
115
123
  if (!options.agent && !options.json)
116
- console.log(`\nScanning ${config.url}…\n`);
124
+ console.log(`\nScanning ${safeUrl(config.url)}…\n`);
117
125
  const current = await scan(config, directory(), `scan-${randomUUID().slice(0, 8)}`);
118
- const report = reportFor(contract, baseline, current);
126
+ const report = reportFor(contract, baseline, current, approvalFile);
119
127
  await writeJson(join(directory(), "report.json"), report);
120
128
  console.log(options.json
121
129
  ? JSON.stringify(jsonReport(report, baseline), null, 2)
@@ -137,6 +145,7 @@ program
137
145
  throw new Error("Port must be 0 or an integer between 1024 and 65535.");
138
146
  await serveReport(directory(), port, options.open);
139
147
  });
148
+ approvalCommands(program, cwd);
140
149
  program.parseAsync().catch((error) => {
141
150
  console.error(`\ntether: ${error.message}\n`);
142
151
  process.exitCode = 2;
@@ -2,7 +2,7 @@ import { type Report, type Scan } from "../types.js";
2
2
  /** Screenshot references are local JSON pointers, not random scan directory names. */
3
3
  export declare function jsonReport(report: Report, baseline: Scan): {
4
4
  schema: string;
5
- version: number;
5
+ version: 1;
6
6
  summary: {
7
7
  total: number;
8
8
  severity: {
@@ -13,7 +13,23 @@ export declare function jsonReport(report: Report, baseline: Scan): {
13
13
  };
14
14
  };
15
15
  findings: {
16
- id: string;
16
+ viewports: string[];
17
+ property: string;
18
+ current: string;
19
+ expected: string[];
20
+ evidence: {
21
+ baseline?: {
22
+ bounds?: import("../types.js").Bounds | undefined;
23
+ screenshotRef: string | null;
24
+ } | undefined;
25
+ current: {
26
+ bounds?: import("../types.js").Bounds | undefined;
27
+ screenshotRef: string | null;
28
+ };
29
+ state?: string | undefined;
30
+ viewport: import("../types.js").Viewport;
31
+ }[];
32
+ state?: string | undefined;
17
33
  identity: string;
18
34
  identityConfidence: import("../types.js").IdentityConfidence;
19
35
  selector: string;
@@ -21,6 +37,37 @@ export declare function jsonReport(report: Report, baseline: Scan): {
21
37
  category: "colors" | "fontFamilies" | "fontSizes" | "fontWeights" | "lineHeights" | "spacing" | "radii" | "shadows" | "borders" | "elementHeights" | "layout";
22
38
  severity: import("../types.js").Severity;
23
39
  route: string;
40
+ rule?: import("../types.js").RuleProvenance | undefined;
41
+ id: string;
42
+ }[];
43
+ staleApprovals?: undefined;
44
+ } | {
45
+ schema: string;
46
+ version: 2;
47
+ summary: {
48
+ total: number;
49
+ active: {
50
+ total: number;
51
+ severity: {
52
+ [k: string]: number;
53
+ };
54
+ category: {
55
+ [k: string]: number;
56
+ };
57
+ };
58
+ approved: {
59
+ total: number;
60
+ severity: {
61
+ [k: string]: number;
62
+ };
63
+ category: {
64
+ [k: string]: number;
65
+ };
66
+ };
67
+ staleApprovals: number;
68
+ };
69
+ findings: {
70
+ status: string;
24
71
  viewports: string[];
25
72
  property: string;
26
73
  current: string;
@@ -30,11 +77,23 @@ export declare function jsonReport(report: Report, baseline: Scan): {
30
77
  bounds?: import("../types.js").Bounds | undefined;
31
78
  screenshotRef: string | null;
32
79
  } | undefined;
33
- viewport: import("../types.js").Viewport;
34
80
  current: {
35
81
  bounds?: import("../types.js").Bounds | undefined;
36
82
  screenshotRef: string | null;
37
83
  };
84
+ state?: string | undefined;
85
+ viewport: import("../types.js").Viewport;
38
86
  }[];
87
+ state?: string | undefined;
88
+ identity: string;
89
+ identityConfidence: import("../types.js").IdentityConfidence;
90
+ selector: string;
91
+ title: string;
92
+ category: "colors" | "fontFamilies" | "fontSizes" | "fontWeights" | "lineHeights" | "spacing" | "radii" | "shadows" | "borders" | "elementHeights" | "layout";
93
+ severity: import("../types.js").Severity;
94
+ route: string;
95
+ rule?: import("../types.js").RuleProvenance | undefined;
96
+ id: string;
39
97
  }[];
98
+ staleApprovals: import("../types.js").Approval[];
40
99
  };
package/dist/cli/json.js CHANGED
@@ -9,50 +9,76 @@ export function jsonReport(report, baseline) {
9
9
  (file === "report.json" ? "scan/" : "") +
10
10
  `snapshots/${index}/screenshot`;
11
11
  };
12
+ const finding = (i) => ({
13
+ id: i.id,
14
+ ...(i.rule ? { rule: i.rule } : {}),
15
+ identity: i.identity ?? `selector:${i.selector}`,
16
+ identityConfidence: i.identityConfidence ?? "low",
17
+ selector: i.selector,
18
+ title: i.title,
19
+ category: i.category,
20
+ severity: i.severity,
21
+ route: i.route,
22
+ ...(i.state ? { state: i.state } : {}),
23
+ viewports: [...i.viewports].sort(),
24
+ property: i.property,
25
+ current: i.current,
26
+ expected: [...i.expected].sort(),
27
+ evidence: [...(i.evidence ?? [])]
28
+ .sort((a, b) => a.viewport.name.localeCompare(b.viewport.name))
29
+ .map((e) => ({
30
+ viewport: e.viewport,
31
+ ...(e.state ? { state: e.state } : {}),
32
+ current: {
33
+ screenshotRef: reference(report.scan, "report.json", e.current.screenshot),
34
+ ...(e.current.bounds ? { bounds: e.current.bounds } : {}),
35
+ },
36
+ ...(e.baseline
37
+ ? {
38
+ baseline: {
39
+ screenshotRef: reference(baseline, "baseline.json", e.baseline.screenshot),
40
+ ...(e.baseline.bounds ? { bounds: e.baseline.bounds } : {}),
41
+ },
42
+ }
43
+ : {}),
44
+ })),
45
+ });
46
+ const summary = (issues) => ({
47
+ total: issues.length,
48
+ severity: Object.fromEntries(["high", "medium", "low"].map((s) => [
49
+ s,
50
+ issues.filter((i) => i.severity === s).length,
51
+ ])),
52
+ category: Object.fromEntries([...categories, "layout"].map((c) => [
53
+ c,
54
+ issues.filter((i) => i.category === c).length,
55
+ ])),
56
+ });
57
+ if (!report.approvals)
58
+ return {
59
+ schema: "tether.check",
60
+ version: 1,
61
+ summary: summary(report.issues),
62
+ findings: report.issues.map(finding),
63
+ };
64
+ const approved = report.approvals.approved;
12
65
  return {
13
66
  schema: "tether.check",
14
- version: 1,
67
+ version: 2,
15
68
  summary: {
16
- total: report.issues.length,
17
- severity: Object.fromEntries(["high", "medium", "low"].map((s) => [
18
- s,
19
- report.issues.filter((i) => i.severity === s).length,
20
- ])),
21
- category: Object.fromEntries([...categories, "layout"].map((c) => [
22
- c,
23
- report.issues.filter((i) => i.category === c).length,
24
- ])),
69
+ total: report.issues.length + approved.length,
70
+ active: summary(report.issues),
71
+ approved: summary(approved),
72
+ staleApprovals: report.approvals.stale.length,
25
73
  },
26
- findings: report.issues.map((i) => ({
27
- id: i.id,
28
- identity: i.identity ?? `selector:${i.selector}`,
29
- identityConfidence: i.identityConfidence ?? "low",
30
- selector: i.selector,
31
- title: i.title,
32
- category: i.category,
33
- severity: i.severity,
34
- route: i.route,
35
- viewports: [...i.viewports].sort(),
36
- property: i.property,
37
- current: i.current,
38
- expected: [...i.expected].sort(),
39
- evidence: [...(i.evidence ?? [])]
40
- .sort((a, b) => a.viewport.name.localeCompare(b.viewport.name))
41
- .map((e) => ({
42
- viewport: e.viewport,
43
- current: {
44
- screenshotRef: reference(report.scan, "report.json", e.current.screenshot),
45
- ...(e.current.bounds ? { bounds: e.current.bounds } : {}),
46
- },
47
- ...(e.baseline
48
- ? {
49
- baseline: {
50
- screenshotRef: reference(baseline, "baseline.json", e.baseline.screenshot),
51
- ...(e.baseline.bounds ? { bounds: e.baseline.bounds } : {}),
52
- },
53
- }
54
- : {}),
74
+ findings: [
75
+ ...report.issues.map((i) => ({ ...finding(i), status: "active" })),
76
+ ...approved.map((i) => ({
77
+ ...finding(i),
78
+ status: "approved",
79
+ approval: i.approval,
55
80
  })),
56
- })),
81
+ ],
82
+ staleApprovals: report.approvals.stale,
57
83
  };
58
84
  }
@@ -0,0 +1,2 @@
1
+ import type { RuleProvenance } from "../types.js";
2
+ export declare function ruleDescription(rule: RuleProvenance): string;
@@ -0,0 +1,6 @@
1
+ export function ruleDescription(rule) {
2
+ const context = rule.scope
3
+ ? `${rule.scope}${rule.variant ? ` (${rule.variant})` : ""}/${rule.part}`
4
+ : "unscoped";
5
+ return `${rule.source} · ${context}${rule.reason ? ` · ${rule.reason}` : ""} · ${rule.samples} component instances · tokens: ${rule.tokens.map((t) => `${t.value} (${t.count})`).join(", ") || "none"}`;
6
+ }
@@ -1,2 +1,2 @@
1
- import { type Contract, type Scan } from "../types.js";
2
- export declare function inferContract(scan: Scan, minOccurrences?: number): Contract;
1
+ import { type Contract, type Scan, type ScopeDefinition } from "../types.js";
2
+ export declare function inferContract(scan: Scan, minOccurrences?: number, scopes?: ScopeDefinition[]): Contract;
@@ -1,5 +1,6 @@
1
- import { categories } from "../types.js";
2
- export function inferContract(scan, minOccurrences = 2) {
1
+ import { validateScopes } from "./scopes.js";
2
+ import { categories, } from "../types.js";
3
+ export function inferContract(scan, minOccurrences = 2, scopes = []) {
3
4
  const contract = {
4
5
  version: 1,
5
6
  createdAt: scan.createdAt,
@@ -23,5 +24,71 @@ export function inferContract(scan, minOccurrences = 2) {
23
24
  // A small page still gets one representative token per observed category.
24
25
  contract[category] = recurring.length ? recurring : ranked.slice(0, 1);
25
26
  }
27
+ if (scopes.length) {
28
+ const definitions = validateScopes(scopes);
29
+ contract.version = 2;
30
+ contract.scopes = definitions;
31
+ contract.partial = scan.snapshots.some((s) => s.truncated);
32
+ const rules = new Map();
33
+ const key = (scope, part, category, property) => JSON.stringify([scope, part, category, property]);
34
+ for (const snapshot of scan.snapshots)
35
+ for (const element of snapshot.elements) {
36
+ const scope = element.scope;
37
+ if (!scope || !definitions.some((d) => d.name === scope.name))
38
+ continue;
39
+ const instance = JSON.stringify([snapshot.route, scope.instance]);
40
+ for (const o of element.observations) {
41
+ const k = key(scope.name, scope.part, o.category, o.property);
42
+ const entry = rules.get(k) ?? {
43
+ rule: {
44
+ scope: scope.name,
45
+ part: scope.part,
46
+ category: o.category,
47
+ property: o.property,
48
+ samples: 0,
49
+ tokens: [],
50
+ source: "scope",
51
+ },
52
+ samples: new Set(),
53
+ values: new Map(),
54
+ };
55
+ entry.samples.add(instance);
56
+ const values = entry.values.get(o.value) ?? new Set();
57
+ values.add(instance);
58
+ entry.values.set(o.value, values);
59
+ rules.set(k, entry);
60
+ }
61
+ }
62
+ for (const scope of definitions)
63
+ for (const override of scope.overrides ?? []) {
64
+ const k = key(scope.name, override.part ?? "root", override.category, override.property);
65
+ const old = rules.get(k);
66
+ rules.set(k, {
67
+ rule: {
68
+ scope: scope.name,
69
+ part: override.part ?? "root",
70
+ category: override.category,
71
+ property: override.property,
72
+ samples: 0,
73
+ tokens: [],
74
+ source: "override",
75
+ },
76
+ samples: old?.samples ?? new Set(),
77
+ values: new Map(override.values.map((v) => [
78
+ v,
79
+ old?.values.get(v) ?? new Set(),
80
+ ])),
81
+ });
82
+ }
83
+ contract.rules = [...rules.entries()]
84
+ .sort(([a], [b]) => a.localeCompare(b))
85
+ .map(([, entry]) => ({
86
+ ...entry.rule,
87
+ samples: entry.samples.size,
88
+ tokens: [...entry.values]
89
+ .map(([value, instances]) => ({ value, count: instances.size }))
90
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)),
91
+ }));
92
+ }
26
93
  return contract;
27
94
  }
@@ -0,0 +1,3 @@
1
+ import { type Contract } from "../types.js";
2
+ /** Validate without rewriting legacy data or changing its ordering. */
3
+ export declare function readContract(input: unknown): Contract;
@@ -0,0 +1,67 @@
1
+ import { categories } from "../types.js";
2
+ import { validateScopes, validProperty } from "./scopes.js";
3
+ /** Validate without rewriting legacy data or changing its ordering. */
4
+ export function readContract(input) {
5
+ const c = input;
6
+ const fail = () => {
7
+ throw new Error("Unsupported or invalid contract. Supported versions: 1 (global), 2 (scoped). Restore a valid contract or run tether init --force.");
8
+ };
9
+ const tokens = (t) => Array.isArray(t) &&
10
+ t.every((v) => v &&
11
+ typeof v.value === "string" &&
12
+ Number.isInteger(v.count) &&
13
+ v.count >= 0);
14
+ if (!c ||
15
+ ![1, 2].includes(c.version) ||
16
+ !Number.isInteger(c.minOccurrences) ||
17
+ c.minOccurrences < 1 ||
18
+ c.minOccurrences > 1000 ||
19
+ categories.some((k) => !tokens(c[k])))
20
+ fail();
21
+ if (c.version === 2) {
22
+ const scopes = validateScopes(c.scopes);
23
+ if (!scopes.length ||
24
+ !Array.isArray(c.rules) ||
25
+ typeof c.partial !== "boolean")
26
+ fail();
27
+ const keys = new Set();
28
+ for (const rule of c.rules) {
29
+ if (!rule || typeof rule !== "object")
30
+ fail();
31
+ const scope = scopes.find((s) => s.name === rule.scope);
32
+ if (!scope ||
33
+ (rule.part !== "root" &&
34
+ !Object.hasOwn(scope.parts ?? {}, rule.part)) ||
35
+ !categories.includes(rule.category) ||
36
+ !validProperty(rule.category, rule.property) ||
37
+ !["scope", "override"].includes(rule.source) ||
38
+ !Number.isInteger(rule.samples) ||
39
+ rule.samples < 0 ||
40
+ !tokens(rule.tokens) ||
41
+ rule.tokens.some((t) => t.count > rule.samples))
42
+ fail();
43
+ const key = JSON.stringify([
44
+ rule.scope,
45
+ rule.part,
46
+ rule.category,
47
+ rule.property,
48
+ ]);
49
+ if (keys.has(key))
50
+ fail();
51
+ keys.add(key);
52
+ const override = scope.overrides?.find((o) => o.part === rule.part &&
53
+ o.category === rule.category &&
54
+ o.property === rule.property);
55
+ if ((rule.source === "override") !== !!override ||
56
+ (override &&
57
+ JSON.stringify([...override.values].sort()) !==
58
+ JSON.stringify(rule.tokens.map((t) => t.value).sort())))
59
+ fail();
60
+ }
61
+ for (const scope of scopes)
62
+ for (const o of scope.overrides ?? [])
63
+ if (!keys.has(JSON.stringify([scope.name, o.part, o.category, o.property])))
64
+ fail();
65
+ }
66
+ return c;
67
+ }
@@ -0,0 +1,5 @@
1
+ import { type ScopeDefinition, type Contract, type ElementRecord, type Observation, type RuleProvenance } from "../types.js";
2
+ export declare function validProperty(category: string, property: unknown): property is string;
3
+ /** Canonical, serializable declarations. CSS syntax is checked in the browser. */
4
+ export declare function validateScopes(input: unknown): ScopeDefinition[];
5
+ export declare function resolveRule(contract: Contract, element: ElementRecord, observation: Observation): RuleProvenance | undefined;
@@ -0,0 +1,150 @@
1
+ import { categories, } from "../types.js";
2
+ import { normalizeValue } from "../utils/normalize.js";
3
+ const name = (s) => typeof s === "string" && /^[a-zA-Z][\w-]{0,63}$/.test(s);
4
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5
+ const selector = (v) => typeof v === "string" && !!v.trim();
6
+ export function validProperty(category, property) {
7
+ if (typeof property !== "string")
8
+ return false;
9
+ const properties = {
10
+ colors: /^(color|background-color|border-color)$/,
11
+ fontFamilies: /^font-family$/,
12
+ fontSizes: /^font-size$/,
13
+ fontWeights: /^font-weight$/,
14
+ lineHeights: /^line-height$/,
15
+ spacing: /^(padding|margin)-(top|right|bottom|left)$|^(row|column)-gap$/,
16
+ radii: /^border-radius$/,
17
+ shadows: /^box-shadow$/,
18
+ borders: /^border$/,
19
+ elementHeights: /^height$/,
20
+ };
21
+ return !!properties[category]?.test(property);
22
+ }
23
+ /** Canonical, serializable declarations. CSS syntax is checked in the browser. */
24
+ export function validateScopes(input) {
25
+ if (!Array.isArray(input))
26
+ throw new Error("Config scopes must be an array.");
27
+ const names = new Set();
28
+ const selectors = new Set();
29
+ const result = [];
30
+ for (const s of input) {
31
+ if (!object(s) ||
32
+ !name(s.name) ||
33
+ !selector(s.selector) ||
34
+ names.has(s.name) ||
35
+ selectors.has(s.selector.trim()))
36
+ throw new Error("Scopes need unique names and distinct, nonempty CSS selectors.");
37
+ if (Object.keys(s).some((k) => ![
38
+ "name",
39
+ "selector",
40
+ "variant",
41
+ "minOccurrences",
42
+ "parts",
43
+ "overrides",
44
+ ].includes(k)))
45
+ throw new Error(`Unknown setting in scope ${s.name}.`);
46
+ if (s.variant !== undefined && !name(s.variant))
47
+ throw new Error(`Invalid variant for scope ${s.name}.`);
48
+ if (s.minOccurrences !== undefined &&
49
+ (!Number.isInteger(s.minOccurrences) ||
50
+ Number(s.minOccurrences) < 2 ||
51
+ Number(s.minOccurrences) > 1000))
52
+ throw new Error(`Scope ${s.name} minOccurrences must be between 2 and 1000; use an explicit override for a singleton.`);
53
+ if (s.parts !== undefined &&
54
+ (!object(s.parts) ||
55
+ Object.entries(s.parts).some(([k, v]) => !name(k) || k === "root" || !selector(v))))
56
+ throw new Error(`Scope ${s.name} parts need names (other than root) and CSS selectors.`);
57
+ const parts = Object.fromEntries(Object.entries(s.parts ?? {}).sort(([a], [b]) => a.localeCompare(b)));
58
+ if (new Set(Object.values(parts).map((v) => v.trim())).size !==
59
+ Object.keys(parts).length)
60
+ throw new Error(`Duplicate part selectors in ${s.name}.`);
61
+ if (s.overrides !== undefined && !Array.isArray(s.overrides))
62
+ throw new Error(`Scope ${s.name} overrides must be an array.`);
63
+ const keys = new Set();
64
+ const overrides = [];
65
+ for (const o of (s.overrides ?? [])) {
66
+ if (!object(o) ||
67
+ !categories.includes(o.category) ||
68
+ !validProperty(String(o.category), o.property) ||
69
+ !Array.isArray(o.values) ||
70
+ !o.values.length ||
71
+ o.values.some((v) => typeof v !== "string" || !v.trim()))
72
+ throw new Error(`Invalid property, category or values in ${s.name} override.`);
73
+ if (Object.keys(o).some((k) => !["part", "category", "property", "values"].includes(k)))
74
+ throw new Error(`Unknown override setting in ${s.name}.`);
75
+ const part = o.part ?? "root";
76
+ if (typeof part !== "string" ||
77
+ (part !== "root" && !Object.hasOwn(parts, part)))
78
+ throw new Error(`Unknown override part in ${s.name}.`);
79
+ const key = JSON.stringify([part, o.category, o.property]);
80
+ if (keys.has(key))
81
+ throw new Error(`Duplicate override for ${s.name}/${part}/${o.property}.`);
82
+ keys.add(key);
83
+ const values = [
84
+ ...new Set(o.values.map((v) => normalizeValue(o.property, v))),
85
+ ].sort();
86
+ if (values.some((v) => ["transparent", "0px", "none", "normal", "auto"].includes(v)))
87
+ throw new Error(`Override for ${s.name} contains a value the scanner does not collect.`);
88
+ overrides.push({
89
+ part,
90
+ category: o.category,
91
+ property: o.property,
92
+ values,
93
+ });
94
+ }
95
+ names.add(s.name);
96
+ selectors.add(s.selector.trim());
97
+ result.push({
98
+ name: s.name,
99
+ selector: s.selector.trim(),
100
+ ...(s.variant ? { variant: s.variant } : {}),
101
+ ...(s.minOccurrences !== undefined
102
+ ? { minOccurrences: Number(s.minOccurrences) }
103
+ : {}),
104
+ parts,
105
+ overrides: overrides.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
106
+ });
107
+ }
108
+ return result.sort((a, b) => a.name.localeCompare(b.name));
109
+ }
110
+ export function resolveRule(contract, element, observation) {
111
+ if (contract.version === 1)
112
+ return;
113
+ const membership = element.scope;
114
+ const definition = contract.scopes?.find((s) => s.name === membership?.name);
115
+ const rule = contract.rules?.find((r) => r.scope === membership?.name &&
116
+ r.part === membership?.part &&
117
+ r.category === observation.category &&
118
+ r.property === observation.property);
119
+ const context = membership
120
+ ? {
121
+ scope: membership.name,
122
+ ...(definition?.variant ? { variant: definition.variant } : {}),
123
+ part: membership.part,
124
+ }
125
+ : {};
126
+ const threshold = definition?.minOccurrences ?? Math.max(2, contract.minOccurrences);
127
+ const supported = rule?.tokens.filter((t) => rule.source === "override" || t.count >= threshold) ?? [];
128
+ if (rule &&
129
+ supported.length &&
130
+ (rule.source === "override" || !contract.partial))
131
+ return {
132
+ ...context,
133
+ source: rule.source,
134
+ samples: rule.samples,
135
+ tokens: supported,
136
+ };
137
+ return {
138
+ ...context,
139
+ source: "global",
140
+ reason: !membership
141
+ ? "unscoped"
142
+ : contract.partial
143
+ ? "partial-baseline"
144
+ : !rule
145
+ ? "missing-rule"
146
+ : "insufficient-support",
147
+ samples: rule?.samples ?? 0,
148
+ tokens: contract[observation.category],
149
+ };
150
+ }
@@ -1,5 +1,8 @@
1
- import type { Category, Contract, Issue, Scan, Severity } from "../types.js";
1
+ import type { Category, Contract, Issue, Scan, Severity, FindingOccurrence } from "../types.js";
2
2
  export declare const labels: Record<Category | "layout", string>;
3
3
  export declare function severityFor(category: Category, value: string, expected: string[]): Severity;
4
+ /** Atomic observations are retained until approval matching is complete. */
5
+ export declare function collectOccurrences(contract: Contract, baseline: Scan, current: Scan): FindingOccurrence[];
6
+ export declare function groupOccurrences(contract: Contract, occurrences: FindingOccurrence[]): Issue[];
4
7
  export declare function compare(contract: Contract, baseline: Scan, current: Scan): Issue[];
5
8
  export declare function consistencyScore(issues: Issue[]): number;