tether-ui 0.3.0 → 0.5.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 +137 -3
- 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 +52 -7
- package/dist/cli/index.js +32 -24
- package/dist/cli/json.d.ts +169 -1
- package/dist/cli/json.js +79 -41
- package/dist/contract/scopes.js +2 -2
- package/dist/drift/compare.d.ts +6 -1
- package/dist/drift/compare.js +95 -30
- package/dist/drift/evidence.js +1 -0
- package/dist/drift/layout-status.d.ts +3 -0
- package/dist/drift/layout-status.js +42 -0
- package/dist/scanner/collect.d.ts +2 -1
- package/dist/scanner/collect.js +16 -4
- package/dist/scanner/scan.js +127 -62
- package/dist/types.d.ts +61 -1
- package/dist/utils/config.js +15 -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 +7 -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/264-98a22092a2a439fb.js +1 -0
- package/report/out/_next/static/chunks/app/_not-found/{page-032313fc4524b472.js → page-bd7ad6814f9f2184.js} +1 -1
- package/report/out/_next/static/chunks/app/contract/{page-60f6d610ece63f59.js → page-35308576f338b58b.js} +1 -1
- package/report/out/_next/static/chunks/app/drift/{page-655683d6032a79a8.js → page-05c4ba0a3b384c21.js} +1 -1
- package/report/out/_next/static/chunks/app/layout-3c4d03405b91a1e8.js +1 -0
- package/report/out/_next/static/chunks/app/{page-bdfcce47bd844e7e.js → page-91a7575f09cd5dab.js} +1 -1
- package/report/out/_next/static/chunks/app/viewports/{page-6e44561586c30871.js → page-aaff424fc51d4e3c.js} +1 -1
- package/report/out/_next/static/chunks/{main-app-e3082ce30f616b80.js → main-app-73ea41a51858a055.js} +1 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-127cd29b1dce4f53.js +1 -0
- package/report/out/_next/static/css/2b0955e8507de202.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-a159f64a448d339d.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/7fbedb5502367c5e.css +0 -1
- /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → G4NOKg8i0CINpWi5hqaJY}/_buildManifest.js +0 -0
- /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → G4NOKg8i0CINpWi5hqaJY}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -127,6 +127,25 @@ Defaults: **1440 × 1000**, **1024 × 900**, **768 × 900**, **390 × 844**. Eac
|
|
|
127
127
|
|
|
128
128
|
Intentional horizontal scroll containers and descendants clipped by their parents are excluded from off-screen checks. Text ellipsis and line clamps are not treated as accidental clipping. Screenshots show the viewport; style collection also covers rendered elements below the fold, up to the configured element cap.
|
|
129
129
|
|
|
130
|
+
### Separate existing layout problems from new findings
|
|
131
|
+
|
|
132
|
+
**Available in tether-ui v0.5.0 and later.**
|
|
133
|
+
|
|
134
|
+
Layout findings now indicate whether the same problem was recorded in the baseline. The report keeps them visible and offers a **Baseline status** filter.
|
|
135
|
+
|
|
136
|
+
By default, `tether check` still fails on every active finding. To fail only on new or changed findings:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npx tether-ui check --fail-on new
|
|
140
|
+
npx tether-ui check --fail-on new --json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This excludes only verified unchanged layout findings from the failure decision. Token findings and unverified layout findings still block. Existing layout findings are not approvals: they remain in report totals, evidence, JSON, and a separate review section of the agent brief. With `--json`, `gate.blocking` gives the blocking count and `gate.existing` gives the retained, unapproved existing-layout count. The default `--fail-on all` preserves the existing exit-code policy.
|
|
144
|
+
|
|
145
|
+
Matching requires complete, unique captures with the same route, state, color scheme, and viewport name and dimensions. An element must match uniquely with the same tag, bounds, layout kind, measured value, and severity. Document overflow is compared by its exact measured value and severity. Missing or ambiguous matches, changed geometry, and partial scans remain blocking. An unchanged measurement does not establish that its underlying cause is unchanged.
|
|
146
|
+
|
|
147
|
+
Layout findings carry an additive `baselineStatus` field (`existing` or `new-or-changed`); the latter includes findings that cannot be verified unchanged. JSON schema versions 1 and 2 retain their existing fields and counts. With `--fail-on new`, an additive `gate` object records the policy and counts. Existing-layout finding IDs are distinguished from new/changed occurrences so the two cannot merge across viewports. Stored approval tuples remain valid; legacy reports without baseline status remain blocking until rescanned. No rebaseline is required.
|
|
148
|
+
|
|
130
149
|
## Configuration
|
|
131
150
|
|
|
132
151
|
`init` creates `tether.config.ts` when one does not exist. Config is executable local code; load only configs you trust.
|
|
@@ -192,17 +211,132 @@ export default {
|
|
|
192
211
|
Run `npx tether-ui init --force` after intentionally adding or changing scopes. This replaces the baseline, so review the current UI first. Scope configuration is saved in the contract; subsequent checks reject configuration changes instead of silently changing the rules. Component-aware contracts are available in tether-ui v0.3.0 and later.
|
|
193
212
|
|
|
194
213
|
- **Membership:** the nearest matching component root wins. Within it, the nearest matching named part wins; a part applies to its descendants until a nested component starts. The root uses the reserved `root` part. Unnamed descendants use global tokens, rather than inheriting container padding and radius rules. Two scopes matching the same root, or two parts matching the same node, are errors. Selectors are explicit; generated class names do not identify component families automatically.
|
|
195
|
-
- **Support:** each scope/category/property/value counts distinct component instances per route. Multiple parts or viewport captures of one instance do not inflate its count. The scope threshold defaults to the global threshold with a minimum of two; configure `minOccurrences` per scope for smaller families. Multiple responsive values can qualify independently.
|
|
214
|
+
- **Support:** each scope/category/property/value counts distinct component instances per route. Multiple parts or viewport captures of one instance do not inflate its count. The scope threshold defaults to the global threshold with a minimum of two; configure `minOccurrences` per scope for smaller families. Multiple responsive values can qualify independently. A single instance is not sufficient by default. Explicitly setting a scope’s `minOccurrences: 1` opts into trusting one reviewed instance (v0.5.0 and later; see below).
|
|
196
215
|
- **Precedence:** a supported scoped rule wins over the global pool. Missing or undersampled rules fall back to global tokens. Partial baselines disable inferred scoped rules, with the reason exposed in findings. Unchanged one-offs remain accepted only on the same matched element, property, and scope/part; they do not transfer to new instances or another component context.
|
|
197
216
|
- **Overrides:** explicit values are normalized and apply only to their scope, part, category, and property. They override inferred values and the unchanged-value exemption, so an intentional correction can expose a baseline inconsistency. Overrides can apply without samples; they are reviewed policy, not learned confidence. Existing layout checks remain independent.
|
|
198
217
|
- **Compatibility:** unscoped projects continue writing v1 contracts and retain their finding IDs and JSON behavior. Scoped projects write v2 contracts. New clients read both; old clients reject v2. Checks never migrate or rewrite baseline/contract files. Reports, agent output, and JSON explain the rule source, support counts, and fallback. The visual contract view includes component rules.
|
|
199
218
|
|
|
219
|
+
### Reuse a reviewed component with one baseline instance
|
|
220
|
+
|
|
221
|
+
**Available in tether-ui v0.5.0 and later.**
|
|
222
|
+
|
|
223
|
+
A page may contain only one instance of a reusable control. By default, that one instance does not establish a component rule, so adding another can trigger findings for its rare styles. After reviewing the control, opt in for its specific role:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
scopes: [
|
|
227
|
+
{
|
|
228
|
+
name: 'timer-toggle',
|
|
229
|
+
selector: 'button[aria-label="Toggle timer"]',
|
|
230
|
+
minOccurrences: 1
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
This accepts another matching control using the observed property values. A changed control is checked against this scope’s values, including when its new value is valid elsewhere on the page. Keep different roles and variants in separate scopes; a broad selector would explicitly authorize sharing their values.
|
|
236
|
+
|
|
237
|
+
The global threshold and other scopes are unchanged. Counts still represent independent instances: repeat states and viewports do not increase the count. Rules remain specific to scope, part, category, and property; explicit overrides take precedence. Partial baselines still disable inferred component rules. Values observed in different states or viewports are pooled within a scope, as with existing component rules; this setting does not introduce state-specific policy.
|
|
238
|
+
|
|
239
|
+
Changing this threshold requires reviewing the current UI and intentionally rebaselining with `tether init --force`. The single instance is your chosen reference, not statistical evidence of a convention. Use explicit overrides when you want to specify values instead of learning them. Older tether versions reject scoped contracts configured with a threshold of one.
|
|
240
|
+
|
|
200
241
|
### Accuracy benchmark
|
|
201
242
|
|
|
202
243
|
Run `npm run benchmark` from the development checkout. The [frozen corpus](tests/fixtures/scoped-benchmark.json) compares the legacy and scoped policies on 16 labeled scenarios across buttons, cards, status colors, and badges. Output includes TP/FP/FN/TN, precision, and recall per family and overall. Color/type families are reserved as held-out checks; no per-family thresholds are tuned.
|
|
203
244
|
|
|
204
245
|
Both policies use a global threshold of three. The scoped policy explicitly uses two independent component instances; this additional configuration is why legitimate new instances stop being false positives. Merely narrowing the global token pool cannot produce that improvement. On this synthetic corpus, false positives fall from 4 to 0 and detected drift rises from 4/8 to 8/8. [Raw results and benchmark limits](docs/scoped-benchmark.md) describe what these numbers establish; they are not an accuracy claim for arbitrary applications.
|
|
205
246
|
|
|
247
|
+
## REAL-WORLD BENCHMARK
|
|
248
|
+
|
|
249
|
+
Across **45 labeled scenarios** on [Pomo](https://github.com/krau5/pomo), [Focus Timer](https://github.com/mvareva/focus-timer), and [Kardinal](https://github.com/Andreeoak/Kardinal), tether measured **85.7% precision, 75.0% recall, and 80.0% F1** at the scenario level. All 45 evaluated; no infrastructure failures. The corpus includes controlled source mutations and legitimate variants, with pinned revisions and exact target matching.
|
|
250
|
+
|
|
251
|
+
[](docs/real-world/README.md)
|
|
252
|
+
|
|
253
|
+
Run `npm run benchmark:real` after `npm ci` and `npm run browser:install`. Regenerate reports from saved measurements with `npm run benchmark:real:report`.
|
|
254
|
+
|
|
255
|
+
[Full report](docs/real-world/README.md) · [Individual outcomes](docs/real-world/README.md#individual-outcomes) · [Methodology](docs/real-world/methodology.md) · [Interactive dashboard](docs/real-world/dashboard.html) · [Raw JSON](docs/real-world/results.json)
|
|
256
|
+
|
|
257
|
+
Download and open `docs/real-world/dashboard.html` locally for filters and diagnostic details; GitHub does not execute HTML dashboards. This small, purposive sample includes three false alarms and six missed violations. It is not a population accuracy claim; finding-level metrics and unjudged additional findings are reported separately.
|
|
258
|
+
|
|
259
|
+
## Interactive states and authenticated pages
|
|
260
|
+
|
|
261
|
+
Available in v0.4.0. Define named scenarios in `tether.config.ts` to capture a modal, menu, form, or authenticated view. Scenario IDs are stable baseline keys, not display descriptions.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
export default {
|
|
265
|
+
url: 'http://localhost:3000',
|
|
266
|
+
routes: ['/account'],
|
|
267
|
+
storageState: '.auth/session.json', // optional; relative to this config file
|
|
268
|
+
sensitiveSelectors: ['[data-private]', 'input[type="password"]'],
|
|
269
|
+
scenarios: [
|
|
270
|
+
{
|
|
271
|
+
id: 'default',
|
|
272
|
+
routes: ['/account'],
|
|
273
|
+
ready: async (page) => {
|
|
274
|
+
await page.locator('[data-testid="account"]')
|
|
275
|
+
.waitFor({ state: 'visible' });
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'open-settings',
|
|
280
|
+
routes: ['/account'],
|
|
281
|
+
colorScheme: 'light', // or 'dark'; independent of your browser
|
|
282
|
+
timeoutMs: 30000,
|
|
283
|
+
setup: async (page, context) => {
|
|
284
|
+
// Optional pre-navigation setup, such as an init script.
|
|
285
|
+
},
|
|
286
|
+
interact: async (page) => {
|
|
287
|
+
await page.getByRole('button', { name: 'Settings' }).click();
|
|
288
|
+
},
|
|
289
|
+
ready: async (page) => {
|
|
290
|
+
await page.getByRole('dialog').waitFor({ state: 'visible' });
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Each route/state/viewport starts in a fresh browser context. The lifecycle is **load storage state → setup → navigate → interact → ready → settle → collect and mask screenshot → close**. A route with scenarios captures only those scenarios; include `default` explicitly if you want it too. Routes without scenarios retain their initial-load capture. `ready` is required on every explicit scenario; for authentication, assert an element only present after successful sign-in. Every route using `storageState` must have an explicit scenario.
|
|
298
|
+
|
|
299
|
+
Create the Playwright storage-state file externally using a test account. Keep `.auth/` ignored by Git and outside `.tether/`; tether reads the file but never copies or rewrites it. It contains session credentials. Config hooks execute trusted local code and must not log secrets. They receive Playwright Page/BrowserContext objects; they are not a sandbox. Asynchronous work is bounded by `timeoutMs` (default 30 seconds, maximum 120 seconds), with action/navigation timeouts and no automatic retries. Synchronous blocking code inside a hook cannot be interrupted; avoid it.
|
|
300
|
+
|
|
301
|
+
Failed navigation, interactions, readiness, or capture exit **2**, identify the route/state/viewport/step without printing the original error, remove incomplete captures, and preserve the last complete baseline/report. Different state or color-scheme coverage requires `init --force`. Old snapshots remain the `default` state with their existing finding IDs. Named-state findings include optional `state` fields in JSON/evidence and state labels in the report and agent brief; they never merge across states. Approvals include the exact state ID.
|
|
302
|
+
|
|
303
|
+
All URL query **values** and fragments are removed from saved/displayed URLs; use non-secret paths, query names, scenario IDs and configuration labels. Raw URLs remain available only during navigation. If `init --url` contains query values, its generated config reads the URL from `TETHER_URL` instead of embedding those values. Hooks, authentication paths and storage contents are omitted from persisted configuration.
|
|
304
|
+
|
|
305
|
+
`sensitiveSelectors` masks matching screenshot rectangles and removes semantic identity signals from sensitive elements, their descendants, ancestors and elements labelled by them. Layout measurements remain available; masked targets use structural selectors. This is not automatic secret detection: use sanitized test data and mask every sensitive region, including any labels or duplicate values elsewhere. Review screenshots before sharing them. Masking does not add iframe or shadow-DOM coverage.
|
|
306
|
+
|
|
307
|
+
## Approving intentional changes
|
|
308
|
+
|
|
309
|
+
Available in tether-ui v0.4.0 and later.
|
|
310
|
+
|
|
311
|
+
An approval accepts one finding occurrence without changing the visual contract or baseline. Start with a fresh check and take the finding ID from its JSON output:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
npx tether-ui check --json
|
|
315
|
+
npx tether-ui approve <finding-id> --property padding-left --viewport desktop --reason "Reviewed compact card spacing"
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The second command prints a preview and writes nothing. Review the exact element, property, normalized value, route, viewport dimensions, and component context, then repeat with `--yes` to save it:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
npx tether-ui approve <finding-id> --property padding-left --viewport desktop --reason "Reviewed compact card spacing" --yes
|
|
322
|
+
npx tether-ui check
|
|
323
|
+
npx tether-ui approvals list
|
|
324
|
+
npx tether-ui approvals revoke <approval-id>
|
|
325
|
+
npx tether-ui check
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Commit `tether.approvals.json` in the project root. Its versioned records contain an ID, reason, route, exact state ID, target, category, individual property, exact normalized value, viewport name/dimensions, and optional component context. There are no timestamps, screenshot paths, wildcard matching rules, or baseline updates. Use `approvals list --json` for machine-readable records. If checks use a custom configuration, pass the same `--config` to `approve` or `approvals list`.
|
|
329
|
+
|
|
330
|
+
- A grouped finding can contain several properties and viewport occurrences. Both `--property` and `--viewport` are required; approving left padding on desktop never approves right padding or mobile. Each additional occurrence needs its own reviewed record.
|
|
331
|
+
- Unique, high-confidence semantic identities are preferred. A weak or legacy identity requires explicitly adding `--selector`; this pins both the exact saved selector and its identity. Ambiguous or partial snapshots cannot be approved. Changes to weak identity, target, value, viewport dimensions, route, or component context leave the new finding active.
|
|
332
|
+
- Values use normal CSS normalization, followed by exact matching—not the drift detector's near-color/subpixel tolerance. Layout approvals pin the problem kind and measured value, so worsening overflow remains active. Missing state in a hand-authored record means only `default`, never every future state.
|
|
333
|
+
- `approve` reads the saved scan; it does not inspect unsaved code or perform a new scan. Preview, creation, listing, and revocation leave baseline/contract/report files untouched. Run `check` after edits to refresh status. Listing without a saved report shows `unknown`; otherwise `matched`/`stale` refers to that saved scan.
|
|
334
|
+
- Unmatched approvals remain visible as stale; they are never deleted automatically. Revoke a stale record when it is no longer needed. Reasons are editable; duplicate tuples/IDs, overlapping matches, invalid files, and concurrent edits fail with an actionable error.
|
|
335
|
+
|
|
336
|
+
Checks exit **0** when no active findings remain, **1** for active drift, and **2** for invalid configuration/approvals or scan failures. Approved findings retain their evidence and reasons in the report's **Approved findings** filter and in agent output; they are not repair instructions. The overview and severity counts describe active findings, with separate approved/stale counts.
|
|
337
|
+
|
|
338
|
+
Without an approvals file, existing finding IDs and v1 machine output remain unchanged. With the file present (even empty), report/JSON output uses **version 2**: `report.issues` holds active findings; `report.approvals.approved` and `.stale` preserve reviewed and unmatched records. `check --json` includes `status` on every finding and `summary.active`/`summary.approved` totals, severity/category counts, and `summary.staleApprovals`. `summary.total` counts displayed active plus approved findings; splitting a previously grouped finding can increase this total. Consumers must branch on the schema version. Older clients do not apply approvals and should not be used for approval-aware CI.
|
|
339
|
+
|
|
206
340
|
## CI
|
|
207
341
|
|
|
208
342
|
The frontend **must already be running**. Commit reviewed `contract.json` and `baseline.json` to compare against the same contract on every pull request. Recreate the baseline on the same OS/browser/font environment as CI to avoid platform differences. Include its referenced baseline screenshots if you want baseline images in CI reports; comparison still works when those PNGs are absent.
|
|
@@ -239,8 +373,8 @@ The [Next.js static export](https://nextjs.org/docs/app/guides/static-exports) s
|
|
|
239
373
|
## Limitations
|
|
240
374
|
|
|
241
375
|
- This checks computed styles and basic geometry, not pixel-perfect appearance, semantic design intent, accessibility, or image content.
|
|
242
|
-
- No pseudo-elements, shadow DOM
|
|
243
|
-
- Screenshots use light mode with reduced motion. Browser defaults are filtered heuristically, not traced back to authored stylesheets.
|
|
376
|
+
- No pseudo-elements, shadow DOM or iframe traversal, multi-tab workflows, MFA automation, or automatic secret discovery. Named scenarios can capture hover/focus and lazy-rendered content through explicit interactions and readiness checks.
|
|
377
|
+
- Screenshots use light mode by default (scenarios can select dark mode), with reduced motion. Browser defaults are filtered heuristically, not traced back to authored stylesheets.
|
|
244
378
|
- Semantic identities survive small DOM refactors when unique stable signals remain. Dynamic labels, ambiguous elements, and structural fallbacks can still produce noisy matches. This is not a component identity guarantee.
|
|
245
379
|
- Legitimate new design tokens still need human review. Font metrics, OS rendering, dynamic content, and CSS values beyond RGB/hex can affect results.
|
|
246
380
|
- Large DOMs are capped and visibly marked as partial scans. A huge number of findings can reduce the heuristic score to zero.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Approval, type ApprovalFile, type Contract, type FindingOccurrence, type Scan } from "../types.js";
|
|
2
|
+
export declare function normalized(category: string, property: string, value: string): string;
|
|
3
|
+
/** Matching keys never use display finding IDs, merged properties, or screenshot paths. */
|
|
4
|
+
export declare function approvalKey(a: Omit<Approval, "id" | "reason">): string;
|
|
5
|
+
export declare function validateApprovals(input: unknown): ApprovalFile;
|
|
6
|
+
export declare function approvalFor(o: FindingOccurrence, reason: string, selector?: boolean): Approval;
|
|
7
|
+
export declare function matchApproval(o: FindingOccurrence, a: Approval): boolean;
|
|
8
|
+
export declare function applyApprovals(contract: Contract, baseline: Scan, current: Scan, file: ApprovalFile): {
|
|
9
|
+
issues: import("../types.js").Issue[];
|
|
10
|
+
approvals: {
|
|
11
|
+
approved: import("../types.js").Issue[];
|
|
12
|
+
stale: Approval[];
|
|
13
|
+
};
|
|
14
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { categories, } from "../types.js";
|
|
3
|
+
import { normalizeValue } from "../utils/normalize.js";
|
|
4
|
+
import { validProperty } from "../contract/scopes.js";
|
|
5
|
+
import { collectOccurrences, groupOccurrences } from "../drift/compare.js";
|
|
6
|
+
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
7
|
+
const text = (v) => typeof v === "string" && !!v.trim() && !/[\u0000-\u001f]/.test(v);
|
|
8
|
+
const fields = (v, keys) => Object.keys(v).every((k) => keys.includes(k));
|
|
9
|
+
export function normalized(category, property, value) {
|
|
10
|
+
return category === "layout" ? value : normalizeValue(property, value);
|
|
11
|
+
}
|
|
12
|
+
/** Matching keys never use display finding IDs, merged properties, or screenshot paths. */
|
|
13
|
+
export function approvalKey(a) {
|
|
14
|
+
return JSON.stringify([
|
|
15
|
+
a.route,
|
|
16
|
+
a.state,
|
|
17
|
+
a.target.kind,
|
|
18
|
+
a.target.value,
|
|
19
|
+
a.target.kind === "selector" ? a.target.identity : null,
|
|
20
|
+
a.category,
|
|
21
|
+
a.property,
|
|
22
|
+
normalized(a.category, a.property, a.current),
|
|
23
|
+
a.viewport.name,
|
|
24
|
+
a.viewport.width,
|
|
25
|
+
a.viewport.height,
|
|
26
|
+
a.context?.scope ?? null,
|
|
27
|
+
a.context?.part ?? null,
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
export function validateApprovals(input) {
|
|
31
|
+
const fail = (detail) => {
|
|
32
|
+
throw new Error(`Invalid tether.approvals.json: ${detail}`);
|
|
33
|
+
};
|
|
34
|
+
if (!object(input) ||
|
|
35
|
+
!fields(input, ["version", "approvals"]) ||
|
|
36
|
+
input.version !== 1 ||
|
|
37
|
+
!Array.isArray(input.approvals))
|
|
38
|
+
return fail("expected version 1 and an approvals array.");
|
|
39
|
+
const ids = new Set(), keys = new Set();
|
|
40
|
+
const approvals = [];
|
|
41
|
+
for (const a of input.approvals) {
|
|
42
|
+
if (!object(a) ||
|
|
43
|
+
!fields(a, [
|
|
44
|
+
"id",
|
|
45
|
+
"reason",
|
|
46
|
+
"route",
|
|
47
|
+
"state",
|
|
48
|
+
"target",
|
|
49
|
+
"category",
|
|
50
|
+
"property",
|
|
51
|
+
"current",
|
|
52
|
+
"viewport",
|
|
53
|
+
"context",
|
|
54
|
+
]) ||
|
|
55
|
+
!text(a.id) ||
|
|
56
|
+
!/^[\w-]{1,80}$/.test(a.id) ||
|
|
57
|
+
!text(a.reason) ||
|
|
58
|
+
a.reason.length > 1000 ||
|
|
59
|
+
!text(a.route) ||
|
|
60
|
+
!a.route.startsWith("/") ||
|
|
61
|
+
a.route.startsWith("//") ||
|
|
62
|
+
(a.state !== undefined &&
|
|
63
|
+
(typeof a.state !== "string" ||
|
|
64
|
+
!/^[a-zA-Z0-9_-]{1,64}$/.test(a.state))) ||
|
|
65
|
+
!text(a.current))
|
|
66
|
+
return fail("each record needs an ID, reason, local route, exact value, and valid state ID.");
|
|
67
|
+
if (!object(a.target) ||
|
|
68
|
+
!text(a.target.value) ||
|
|
69
|
+
!["identity", "selector"].includes(String(a.target.kind)) ||
|
|
70
|
+
(a.target.kind === "identity"
|
|
71
|
+
? !fields(a.target, ["kind", "value"])
|
|
72
|
+
: !fields(a.target, ["kind", "value", "identity"]) ||
|
|
73
|
+
(a.target.identity !== null && !text(a.target.identity))))
|
|
74
|
+
return fail(`invalid target in ${a.id}.`);
|
|
75
|
+
if (a.category === "layout"
|
|
76
|
+
? !["overflow", "offscreen", "clipped"].includes(String(a.property))
|
|
77
|
+
: !categories.includes(a.category) ||
|
|
78
|
+
!validProperty(String(a.category), a.property))
|
|
79
|
+
return fail(`invalid category/property in ${a.id}; properties must be individual, not grouped.`);
|
|
80
|
+
const v = a.viewport;
|
|
81
|
+
if (!object(v) ||
|
|
82
|
+
!fields(v, ["name", "width", "height"]) ||
|
|
83
|
+
typeof v.name !== "string" ||
|
|
84
|
+
!/^[a-zA-Z0-9_-]+$/.test(v.name) ||
|
|
85
|
+
!Number.isInteger(v.width) ||
|
|
86
|
+
!Number.isInteger(v.height) ||
|
|
87
|
+
Number(v.width) < 200 ||
|
|
88
|
+
Number(v.width) > 7680 ||
|
|
89
|
+
Number(v.height) < 200 ||
|
|
90
|
+
Number(v.height) > 7680)
|
|
91
|
+
return fail(`invalid viewport in ${a.id}.`);
|
|
92
|
+
if (a.context !== undefined &&
|
|
93
|
+
(!object(a.context) ||
|
|
94
|
+
!fields(a.context, ["scope", "part"]) ||
|
|
95
|
+
!text(a.context.scope) ||
|
|
96
|
+
!text(a.context.part)))
|
|
97
|
+
return fail(`invalid component context in ${a.id}.`);
|
|
98
|
+
const record = {
|
|
99
|
+
id: a.id,
|
|
100
|
+
reason: a.reason.trim(),
|
|
101
|
+
route: a.route,
|
|
102
|
+
state: a.state ?? "default",
|
|
103
|
+
target: a.target,
|
|
104
|
+
category: a.category,
|
|
105
|
+
property: a.property,
|
|
106
|
+
current: normalized(String(a.category), String(a.property), a.current),
|
|
107
|
+
viewport: {
|
|
108
|
+
name: v.name,
|
|
109
|
+
width: Number(v.width),
|
|
110
|
+
height: Number(v.height),
|
|
111
|
+
},
|
|
112
|
+
...(a.context ? { context: a.context } : {}),
|
|
113
|
+
};
|
|
114
|
+
const key = approvalKey(record);
|
|
115
|
+
if (ids.has(record.id) || keys.has(key))
|
|
116
|
+
return fail(`duplicate ID or matching tuple: ${record.id}.`);
|
|
117
|
+
ids.add(record.id);
|
|
118
|
+
keys.add(key);
|
|
119
|
+
approvals.push(record);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
version: 1,
|
|
123
|
+
approvals: approvals.sort((a, b) => a.id.localeCompare(b.id)),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function tupleFor(o, selector) {
|
|
127
|
+
const i = o.issue;
|
|
128
|
+
const target = selector
|
|
129
|
+
? { kind: "selector", value: i.selector, identity: i.identity ?? null }
|
|
130
|
+
: { kind: "identity", value: i.identity };
|
|
131
|
+
return {
|
|
132
|
+
route: i.route,
|
|
133
|
+
state: o.state,
|
|
134
|
+
target,
|
|
135
|
+
category: i.category,
|
|
136
|
+
property: i.property,
|
|
137
|
+
current: normalized(i.category, i.property, i.current),
|
|
138
|
+
viewport: o.viewport,
|
|
139
|
+
...(i.rule?.scope
|
|
140
|
+
? { context: { scope: i.rule.scope, part: i.rule.part } }
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function approvalFor(o, reason, selector = false) {
|
|
145
|
+
if (!reason.trim())
|
|
146
|
+
throw new Error("An approval requires a reason.");
|
|
147
|
+
if (selector ? !o.selectorUnique : !o.identityUnique)
|
|
148
|
+
throw new Error(selector
|
|
149
|
+
? "The selector is ambiguous in the saved snapshot; cannot approve."
|
|
150
|
+
: "This finding lacks a unique high-confidence identity. Review its selector and use --selector explicitly.");
|
|
151
|
+
const tuple = tupleFor(o, selector);
|
|
152
|
+
return {
|
|
153
|
+
id: `ap_${createHash("sha256").update(approvalKey(tuple)).digest("hex").slice(0, 16)}`,
|
|
154
|
+
reason: reason.trim(),
|
|
155
|
+
...tuple,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function matchApproval(o, a) {
|
|
159
|
+
if (a.target.kind === "identity" ? !o.identityUnique : !o.selectorUnique)
|
|
160
|
+
return false;
|
|
161
|
+
// This builds the occurrence's exact tuple, not a wildcard selector or identity query.
|
|
162
|
+
const candidate = tupleFor(o, a.target.kind === "selector");
|
|
163
|
+
return approvalKey(candidate) === approvalKey(a);
|
|
164
|
+
}
|
|
165
|
+
export function applyApprovals(contract, baseline, current, file) {
|
|
166
|
+
const active = [], approved = [], used = new Set();
|
|
167
|
+
file = validateApprovals(file);
|
|
168
|
+
const index = new Map(file.approvals.map((a) => [approvalKey(a), a]));
|
|
169
|
+
for (const o of collectOccurrences(contract, baseline, current)) {
|
|
170
|
+
const matches = [];
|
|
171
|
+
for (const selector of [false, true]) {
|
|
172
|
+
if (selector ? !o.selectorUnique : !o.identityUnique)
|
|
173
|
+
continue;
|
|
174
|
+
const match = index.get(approvalKey(tupleFor(o, selector)));
|
|
175
|
+
if (match)
|
|
176
|
+
matches.push(match);
|
|
177
|
+
}
|
|
178
|
+
if (matches.length > 1)
|
|
179
|
+
throw new Error(`Conflicting approvals match one occurrence: ${matches
|
|
180
|
+
.map((a) => a.id)
|
|
181
|
+
.sort()
|
|
182
|
+
.join(", ")}. Revoke the overlapping record.`);
|
|
183
|
+
if (matches[0]) {
|
|
184
|
+
const a = matches[0];
|
|
185
|
+
used.add(a.id);
|
|
186
|
+
approved.push({
|
|
187
|
+
...o,
|
|
188
|
+
issue: { ...o.issue, approval: { id: a.id, reason: a.reason } },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
active.push(o);
|
|
193
|
+
}
|
|
194
|
+
const sort = (items) => items.sort((a, b) => JSON.stringify([
|
|
195
|
+
a.issue.route,
|
|
196
|
+
a.state,
|
|
197
|
+
a.issue.identity,
|
|
198
|
+
a.issue.property,
|
|
199
|
+
a.issue.current,
|
|
200
|
+
a.viewport.name,
|
|
201
|
+
]).localeCompare(JSON.stringify([
|
|
202
|
+
b.issue.route,
|
|
203
|
+
b.state,
|
|
204
|
+
b.issue.identity,
|
|
205
|
+
b.issue.property,
|
|
206
|
+
b.issue.current,
|
|
207
|
+
b.viewport.name,
|
|
208
|
+
])));
|
|
209
|
+
return {
|
|
210
|
+
issues: groupOccurrences(contract, sort(active)),
|
|
211
|
+
approvals: {
|
|
212
|
+
approved: groupOccurrences(contract, sort(approved)),
|
|
213
|
+
stale: file.approvals.filter((a) => !used.has(a.id)),
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ApprovalFile } from "../types.js";
|
|
2
|
+
export declare function readApprovals(cwd: string): Promise<ApprovalFile | undefined>;
|
|
3
|
+
/** Serialize updates and reject a preview that became stale before the write. */
|
|
4
|
+
export declare function saveApprovals(cwd: string, before: ApprovalFile | undefined, after: ApprovalFile): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, open, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeJson } from "../utils/storage.js";
|
|
4
|
+
import { validateApprovals } from "./match.js";
|
|
5
|
+
const pathFor = (cwd) => join(cwd, "tether.approvals.json");
|
|
6
|
+
export async function readApprovals(cwd) {
|
|
7
|
+
let raw;
|
|
8
|
+
try {
|
|
9
|
+
raw = await readFile(pathFor(cwd), "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
if (e.code === "ENOENT")
|
|
13
|
+
return;
|
|
14
|
+
throw new Error("Could not read tether.approvals.json. Check file permissions.");
|
|
15
|
+
}
|
|
16
|
+
let input;
|
|
17
|
+
try {
|
|
18
|
+
input = JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error("Invalid JSON in tether.approvals.json. Fix it before checking or modifying approvals.");
|
|
22
|
+
}
|
|
23
|
+
return validateApprovals(input);
|
|
24
|
+
}
|
|
25
|
+
/** Serialize updates and reject a preview that became stale before the write. */
|
|
26
|
+
export async function saveApprovals(cwd, before, after) {
|
|
27
|
+
const lock = pathFor(cwd) + ".lock";
|
|
28
|
+
let handle;
|
|
29
|
+
try {
|
|
30
|
+
handle = await open(lock, "wx");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("Cannot lock tether.approvals.json. Another approval edit may be running; check permissions or remove a stale .lock file after verifying no edit is active.");
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (JSON.stringify(await readApprovals(cwd)) !== JSON.stringify(before))
|
|
37
|
+
throw new Error("Approvals changed since the preview. Retry against the latest file.");
|
|
38
|
+
await writeJson(pathFor(cwd), validateApprovals(after));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await handle.close();
|
|
42
|
+
await rm(lock, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { exists, loadConfig } from "../utils/config.js";
|
|
3
|
+
import { readJson } from "../utils/storage.js";
|
|
4
|
+
import { readContract } from "../contract/read.js";
|
|
5
|
+
import { coverage, validateCoverage } from "../utils/coverage.js";
|
|
6
|
+
import { collectOccurrences, groupOccurrences } from "../drift/compare.js";
|
|
7
|
+
import { applyApprovals, approvalFor, validateApprovals, } from "../approvals/match.js";
|
|
8
|
+
import { readApprovals, saveApprovals } from "../approvals/storage.js";
|
|
9
|
+
async function saved(cwd, configFile) {
|
|
10
|
+
const data = join(cwd, ".tether");
|
|
11
|
+
const report = await readJson(join(data, "report.json"));
|
|
12
|
+
const baseline = await readJson(join(data, "baseline.json"));
|
|
13
|
+
const contract = readContract(await readJson(join(data, "contract.json")));
|
|
14
|
+
if (![1, 2].includes(report.version) ||
|
|
15
|
+
report.scan?.version !== 1 ||
|
|
16
|
+
!Array.isArray(report.scan?.snapshots) ||
|
|
17
|
+
baseline.version !== 1 ||
|
|
18
|
+
!Array.isArray(baseline.snapshots) ||
|
|
19
|
+
report.baselineAt !== baseline.createdAt ||
|
|
20
|
+
JSON.stringify(report.contract) !== JSON.stringify(contract) ||
|
|
21
|
+
JSON.stringify(coverage(report.scan)) !== JSON.stringify(coverage(baseline)))
|
|
22
|
+
throw new Error("Saved report does not match the current baseline/contract coverage. Run tether check before approving.");
|
|
23
|
+
validateCoverage(await loadConfig(cwd, undefined, configFile), contract, baseline);
|
|
24
|
+
return { report, baseline, contract };
|
|
25
|
+
}
|
|
26
|
+
export function approvalCommands(program, cwd) {
|
|
27
|
+
program
|
|
28
|
+
.command("approve <finding-id>")
|
|
29
|
+
.description("Preview an exact approval from the saved scan; --yes writes it")
|
|
30
|
+
.requiredOption("--property <property>", "One individual CSS property or layout kind")
|
|
31
|
+
.requiredOption("--viewport <name>", "One saved viewport name (dimensions are pinned)")
|
|
32
|
+
.requiredOption("--reason <reason>", "Why this visual change is intentional")
|
|
33
|
+
.option("--selector", "Explicitly accept a selector-scoped target instead of semantic identity")
|
|
34
|
+
.option("--yes", "Persist the previewed approval")
|
|
35
|
+
.option("--config <path>", "Config file used by the saved scan")
|
|
36
|
+
.action(async (id, options) => {
|
|
37
|
+
const before = await readApprovals(cwd());
|
|
38
|
+
const { report, baseline, contract } = await saved(cwd(), options.config);
|
|
39
|
+
const classified = applyApprovals(contract, baseline, report.scan, before ?? { version: 1, approvals: [] });
|
|
40
|
+
if (!classified.issues.some((i) => i.id === id))
|
|
41
|
+
throw new Error("No active saved finding has that ID. Run tether check --json and inspect the finding; it may already be approved.");
|
|
42
|
+
const occurrences = collectOccurrences(contract, baseline, report.scan).filter((o) => groupOccurrences(contract, [o])[0].id === id &&
|
|
43
|
+
o.issue.property === options.property &&
|
|
44
|
+
o.viewport.name === options.viewport);
|
|
45
|
+
if (occurrences.length !== 1)
|
|
46
|
+
throw new Error("Expected exactly one occurrence for this finding/property/viewport. Inspect the saved scan for missing or ambiguous targets.");
|
|
47
|
+
if (report.scan.snapshots.some((s) => s.route === occurrences[0].issue.route &&
|
|
48
|
+
(s.state ?? "default") === occurrences[0].state &&
|
|
49
|
+
s.viewport.name === options.viewport &&
|
|
50
|
+
s.truncated))
|
|
51
|
+
throw new Error("Cannot approve a partial snapshot. Increase maxElements and run tether check first.");
|
|
52
|
+
const record = approvalFor(occurrences[0], options.reason, !!options.selector);
|
|
53
|
+
const after = validateApprovals({
|
|
54
|
+
version: 1,
|
|
55
|
+
approvals: [...(before?.approvals ?? []), record],
|
|
56
|
+
});
|
|
57
|
+
// Reject overlapping identity/selector approvals before any write.
|
|
58
|
+
applyApprovals(contract, baseline, report.scan, after);
|
|
59
|
+
console.log("Approval preview (saved scan only):\n" +
|
|
60
|
+
JSON.stringify(record, null, 2));
|
|
61
|
+
if (!options.yes) {
|
|
62
|
+
console.log("No files changed. Re-run with --yes to save this exact scope.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await saveApprovals(cwd(), before, after);
|
|
66
|
+
console.log("Saved tether.approvals.json. Run tether check to refresh findings and CI status.");
|
|
67
|
+
});
|
|
68
|
+
const command = program
|
|
69
|
+
.command("approvals")
|
|
70
|
+
.description("Inspect or revoke version-controlled visual approvals");
|
|
71
|
+
command
|
|
72
|
+
.command("list")
|
|
73
|
+
.option("--json", "Machine-readable records and saved-scan status")
|
|
74
|
+
.option("--config <path>", "Config file")
|
|
75
|
+
.action(async (options) => {
|
|
76
|
+
const file = (await readApprovals(cwd())) ?? {
|
|
77
|
+
version: 1,
|
|
78
|
+
approvals: [],
|
|
79
|
+
};
|
|
80
|
+
let stale;
|
|
81
|
+
if (file.approvals.length &&
|
|
82
|
+
(await exists(join(cwd(), ".tether/report.json")))) {
|
|
83
|
+
const { report, baseline, contract } = await saved(cwd(), options.config);
|
|
84
|
+
stale = new Set(applyApprovals(contract, baseline, report.scan, file).approvals.stale.map((a) => a.id));
|
|
85
|
+
}
|
|
86
|
+
const records = file.approvals.map((a) => ({
|
|
87
|
+
...a,
|
|
88
|
+
status: !stale ? "unknown" : stale.has(a.id) ? "stale" : "matched",
|
|
89
|
+
}));
|
|
90
|
+
if (options.json)
|
|
91
|
+
console.log(JSON.stringify({ version: 1, basis: "saved scan", approvals: records }, null, 2));
|
|
92
|
+
else {
|
|
93
|
+
console.log("Approvals (status reflects saved scan only):");
|
|
94
|
+
for (const a of records)
|
|
95
|
+
console.log(`${a.id} · ${a.status} · ${a.route} · ${a.state} · ${a.viewport.name} ${a.viewport.width}×${a.viewport.height} · ${a.target.value} · ${a.property} = ${a.current}\n ${a.reason}`);
|
|
96
|
+
if (!records.length)
|
|
97
|
+
console.log("No approvals.");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
command
|
|
101
|
+
.command("revoke <approval-id>")
|
|
102
|
+
.description("Remove one approval; the next check restores its finding")
|
|
103
|
+
.action(async (id) => {
|
|
104
|
+
const before = await readApprovals(cwd());
|
|
105
|
+
if (!before?.approvals.some((a) => a.id === id))
|
|
106
|
+
throw new Error(`Approval not found: ${id}`);
|
|
107
|
+
await saveApprovals(cwd(), before, {
|
|
108
|
+
version: 1,
|
|
109
|
+
approvals: before.approvals.filter((a) => a.id !== id),
|
|
110
|
+
});
|
|
111
|
+
console.log(`Revoked ${id}. Run tether check to refresh the report.`);
|
|
112
|
+
});
|
|
113
|
+
}
|
package/dist/cli/format.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { Issue, Report } from "../types.js";
|
|
2
|
-
export declare function agentMarkdown(issues: Issue[]): string;
|
|
1
|
+
import type { Issue, Report, ApprovalSummary } from "../types.js";
|
|
2
|
+
export declare function agentMarkdown(issues: Issue[], approvals?: ApprovalSummary, failOn?: "all" | "new"): string;
|
|
3
3
|
export declare function terminalReport(report: Report, color?: boolean): string;
|