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.
- package/README.md +124 -2
- package/dist/approvals/match.d.ts +14 -0
- package/dist/approvals/match.js +216 -0
- package/dist/approvals/storage.d.ts +4 -0
- package/dist/approvals/storage.js +44 -0
- package/dist/cli/approvals.d.ts +2 -0
- package/dist/cli/approvals.js +113 -0
- package/dist/cli/format.d.ts +2 -2
- package/dist/cli/format.js +25 -5
- package/dist/cli/index.js +30 -21
- package/dist/cli/json.d.ts +62 -3
- package/dist/cli/json.js +66 -40
- package/dist/contract/describe.d.ts +2 -0
- package/dist/contract/describe.js +6 -0
- package/dist/contract/infer.d.ts +2 -2
- package/dist/contract/infer.js +69 -2
- package/dist/contract/read.d.ts +3 -0
- package/dist/contract/read.js +67 -0
- package/dist/contract/scopes.d.ts +5 -0
- package/dist/contract/scopes.js +150 -0
- package/dist/drift/compare.d.ts +4 -1
- package/dist/drift/compare.js +97 -33
- package/dist/drift/evidence.js +1 -0
- package/dist/scanner/collect.d.ts +4 -2
- package/dist/scanner/collect.js +61 -4
- package/dist/scanner/scan.js +127 -61
- package/dist/types.d.ts +103 -2
- package/dist/utils/config.js +18 -2
- package/dist/utils/coverage.d.ts +3 -0
- package/dist/utils/coverage.js +28 -0
- package/dist/utils/scenarios.d.ts +24 -0
- package/dist/utils/scenarios.js +82 -0
- package/dist/utils/storage.js +8 -3
- package/package.json +4 -3
- package/report/out/404/index.html +1 -1
- package/report/out/404.html +1 -1
- package/report/out/__next.__PAGE__.txt +3 -3
- package/report/out/__next._full.txt +3 -3
- package/report/out/__next._tree.txt +2 -2
- package/report/out/_next/static/chunks/330-b092d894bdb2103c.js +1 -0
- package/report/out/_next/static/chunks/app/_not-found/{page-032313fc4524b472.js → page-172b410568146bc8.js} +1 -1
- package/report/out/_next/static/chunks/app/contract/{page-60f6d610ece63f59.js → page-f49364851a596fef.js} +1 -1
- package/report/out/_next/static/chunks/app/drift/{page-655683d6032a79a8.js → page-e0f2eda1e981d86f.js} +1 -1
- package/report/out/_next/static/chunks/app/layout-9a410c21fc0ba3c9.js +1 -0
- package/report/out/_next/static/chunks/app/{page-bdfcce47bd844e7e.js → page-e399a35da3e55f00.js} +1 -1
- package/report/out/_next/static/chunks/app/viewports/{page-6e44561586c30871.js → page-5a01227414ac4842.js} +1 -1
- package/report/out/_next/static/chunks/{main-app-e3082ce30f616b80.js → main-app-e7e077df7256067f.js} +1 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-94732500710e6d55.js +1 -0
- package/report/out/_next/static/css/b754cdc60c7b43bb.css +1 -0
- package/report/out/_not-found/__next._full.txt +2 -2
- package/report/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/report/out/_not-found/__next._tree.txt +2 -2
- package/report/out/_not-found/index.html +1 -1
- package/report/out/_not-found/index.txt +2 -2
- package/report/out/contract/__next._full.txt +3 -3
- package/report/out/contract/__next._tree.txt +2 -2
- package/report/out/contract/__next.contract.__PAGE__.txt +3 -3
- package/report/out/contract/index.html +1 -1
- package/report/out/contract/index.txt +3 -3
- package/report/out/drift/__next._full.txt +3 -3
- package/report/out/drift/__next._tree.txt +2 -2
- package/report/out/drift/__next.drift.__PAGE__.txt +3 -3
- package/report/out/drift/index.html +1 -1
- package/report/out/drift/index.txt +3 -3
- package/report/out/index.html +1 -1
- package/report/out/index.txt +3 -3
- package/report/out/viewports/__next._full.txt +3 -3
- package/report/out/viewports/__next._tree.txt +2 -2
- package/report/out/viewports/__next.viewports.__PAGE__.txt +3 -3
- package/report/out/viewports/index.html +1 -1
- package/report/out/viewports/index.txt +3 -3
- package/report/out/_next/static/chunks/679-ea0b2616e7a83e6d.js +0 -1
- package/report/out/_next/static/chunks/app/layout-16ba70b55e65864f.js +0 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-d142454d28d73f8d.js +0 -1
- package/report/out/_next/static/css/5f0345b3d96e2143.css +0 -1
- /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → TBRjO9Fdl2ToZdlLQ7gIf}/_buildManifest.js +0 -0
- /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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
67
|
+
version: 2,
|
|
15
68
|
summary: {
|
|
16
|
-
total: report.issues.length,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,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
|
+
}
|
package/dist/contract/infer.d.ts
CHANGED
|
@@ -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;
|
package/dist/contract/infer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
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,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
|
+
}
|
package/dist/drift/compare.d.ts
CHANGED
|
@@ -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;
|