styleproof 3.15.0 → 3.17.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/CHANGELOG.md +40 -0
- package/README.md +32 -0
- package/bin/styleproof-init.mjs +40 -1
- package/dist/affected-surfaces.d.ts +75 -0
- package/dist/affected-surfaces.js +220 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/report.d.ts +9 -0
- package/dist/report.js +48 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.17.0] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`report.md` is bounded so GitHub can always render it.** A large redesign used to
|
|
15
|
+
produce a `report.md` too big for GitHub's markdown viewer (it refuses to render past
|
|
16
|
+
~512 KB) — the reviewer clicked through and got _"we can't show files this big"_, so the
|
|
17
|
+
report was useless exactly when the change was biggest. The generator now holds
|
|
18
|
+
`report.md` to a byte budget (`maxReportBytes`, default ~400 KB): it emits full property
|
|
19
|
+
tables greedily, then lists any remaining changed surfaces as one-liners (name · change
|
|
20
|
+
count · crop link) under an announced banner. The exhaustive per-row detail is always in
|
|
21
|
+
`report.json` and every crop in `crops/`, so nothing is dropped from the certification —
|
|
22
|
+
only the inline detail is capped. A report a reviewer can't open isn't a source of truth.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`affectedSurfaces` — selective-remap core (opt-in, advisory).** Given the files a
|
|
27
|
+
change touched, a declared surface→entry map, and a module graph (any tool's output
|
|
28
|
+
in `{ from, to }` shape — dependency-cruiser maps directly), returns exactly the
|
|
29
|
+
surfaces that could have rendered differently, or the sentinel `'all'`. Sound by
|
|
30
|
+
construction: it over-approximates and resolves every uncertainty to `'all'` — global
|
|
31
|
+
stylesheets/tokens, vanilla (unscoped) stylesheets, `createGlobalStyle`, design-system
|
|
32
|
+
configs, unbounded `import(x)`, and unplaceable files all force a full re-capture;
|
|
33
|
+
computed `import(`../dir/${x}`)` is recovered as a bundler context module (directory-
|
|
34
|
+
level, never a miss). Ships with `classifyStyleChange`. Purely additive, adds no
|
|
35
|
+
dependency, and never touches the default gate — the gate still captures every surface
|
|
36
|
+
and lets the map be the oracle. New export; existing specs unaffected.
|
|
37
|
+
|
|
38
|
+
## [3.16.0] - 2026-07-05
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **The scaffolded PR workflow prunes its own report branch, out of the box.**
|
|
43
|
+
`styleproof-init`'s `.github/workflows/styleproof.yml` now also runs on
|
|
44
|
+
`pull_request: closed`: when a PR closes, a `prune` job removes that PR's `pr-<n>/`
|
|
45
|
+
folder from the report branch (`styleproof-reports`), so the branch no longer grows
|
|
46
|
+
without bound as PRs come and go — no adopter has to remember to garbage-collect it.
|
|
47
|
+
The report job is unchanged, only guarded to skip the close event. Covered by the
|
|
48
|
+
`styleproof-init` workflow test.
|
|
49
|
+
|
|
10
50
|
## [3.15.0] - 2026-07-05
|
|
11
51
|
|
|
12
52
|
### Changed
|
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
|
|
|
27
27
|
- [Forks and Dependabot](#forks-and-dependabot)
|
|
28
28
|
- [Optional: content layer](#optional-content-layer-advisory)
|
|
29
29
|
- [Optional: React component layer](#optional-react-component-layer-advisory)
|
|
30
|
+
- [Optional: selective remap](#optional-selective-remap-advisory)
|
|
30
31
|
- [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
|
|
31
32
|
- [Reference](#reference)
|
|
32
33
|
- [Blocking without branch protection](#blocking-without-branch-protection)
|
|
@@ -679,6 +680,37 @@ Capture reads the React fiber in-page (`__reactFiber$*`/`__reactProps$*` on Reac
|
|
|
679
680
|
|
|
680
681
|
Like the content layer it is **advisory**: never fed to the certification diff or the gate, so captures stay deterministic. Component names are mangled in minified production builds, so it's most useful against a dev / non-minified target; on a non-React page the fiber keys are absent and the field is simply omitted.
|
|
681
682
|
|
|
683
|
+
## Optional: selective remap (advisory)
|
|
684
|
+
|
|
685
|
+
On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its committed base map.
|
|
686
|
+
|
|
687
|
+
It is **opt-in and never part of the default gate** — the gate still captures every surface and lets the map be the oracle. This is a helper for wiring a faster pre-push/CI path yourself, and it is built to be wrong only in the safe direction: when it cannot _prove_ a surface is unaffected, it returns the sentinel `'all'` (re-capture everything). A global stylesheet or token, a vanilla (unscoped) stylesheet, a `createGlobalStyle`, a design-system config, an unbounded `import(x)`, or a file it can't place — all resolve to `'all'`.
|
|
688
|
+
|
|
689
|
+
The module graph is an **input**, so StyleProof stays framework-agnostic and adds no dependency. Produce it with any tool whose output you can shape into `{ from, to }` edges — [dependency-cruiser](https://www.npmjs.com/package/dependency-cruiser)'s `modules[].dependencies[]` maps directly:
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
import { affectedSurfaces } from 'styleproof';
|
|
693
|
+
import { readFileSync } from 'node:fs';
|
|
694
|
+
|
|
695
|
+
// A dependency-cruiser run: `depcruise src --no-config --output-type json`
|
|
696
|
+
const cruise = JSON.parse(readFileSync('dc.json', 'utf8'));
|
|
697
|
+
const graph = cruise.modules.flatMap((m) =>
|
|
698
|
+
(m.dependencies ?? []).map((d) => ({ from: m.source, to: d.resolved, dynamic: d.dynamic })),
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const result = affectedSurfaces({
|
|
702
|
+
changedFiles: ['src/components/PriceTable.tsx'], // e.g. `git diff --name-only origin/main`
|
|
703
|
+
surfaces: { home: 'src/pages/Home.tsx', pricing: 'src/pages/Pricing.tsx' },
|
|
704
|
+
graph,
|
|
705
|
+
files: cruise.modules.map((m) => m.source),
|
|
706
|
+
readFile: (p) => readFileSync(p, 'utf8'),
|
|
707
|
+
});
|
|
708
|
+
// → Set { 'pricing' } (capture only these; reuse the base map for the rest)
|
|
709
|
+
// → 'all' (some change couldn't be bounded — capture everything)
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an unrecognized global API is the one way a scoped verdict could be unsound, so treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
|
|
713
|
+
|
|
682
714
|
## Newly-added elements show their full style
|
|
683
715
|
|
|
684
716
|
When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -345,7 +345,9 @@ const CI_WORKFLOW = `name: StyleProof
|
|
|
345
345
|
# report without a browser;
|
|
346
346
|
# - on cache miss, CI recaptures both sides in one pinned environment so the
|
|
347
347
|
# comparison stays valid.
|
|
348
|
-
on:
|
|
348
|
+
on:
|
|
349
|
+
pull_request:
|
|
350
|
+
types: [opened, synchronize, reopened, closed]
|
|
349
351
|
|
|
350
352
|
permissions:
|
|
351
353
|
contents: write
|
|
@@ -355,6 +357,8 @@ permissions:
|
|
|
355
357
|
|
|
356
358
|
jobs:
|
|
357
359
|
styleproof:
|
|
360
|
+
# Report on open/update; the prune job below handles close.
|
|
361
|
+
if: github.event.action != 'closed'
|
|
358
362
|
runs-on: ubuntu-latest
|
|
359
363
|
steps:
|
|
360
364
|
- uses: actions/checkout@v4
|
|
@@ -408,6 +412,41 @@ ${PM.setup}
|
|
|
408
412
|
baseline-dir: __stylemaps__/base
|
|
409
413
|
fresh-dir: __stylemaps__/head
|
|
410
414
|
require-approval: true
|
|
415
|
+
|
|
416
|
+
prune:
|
|
417
|
+
# PR closed: drop its pr-<n>/ folder from the report branch so the branch
|
|
418
|
+
# never grows without bound. Keep BRANCH in sync with the report-branch
|
|
419
|
+
# input above (default: styleproof-reports).
|
|
420
|
+
if: github.event.action == 'closed'
|
|
421
|
+
runs-on: ubuntu-latest
|
|
422
|
+
permissions:
|
|
423
|
+
contents: write
|
|
424
|
+
steps:
|
|
425
|
+
- name: Prune this PR's report folder
|
|
426
|
+
shell: bash
|
|
427
|
+
env:
|
|
428
|
+
GH_TOKEN: \${{ github.token }}
|
|
429
|
+
BRANCH: styleproof-reports
|
|
430
|
+
PR: \${{ github.event.pull_request.number }}
|
|
431
|
+
run: |
|
|
432
|
+
set -euo pipefail
|
|
433
|
+
REMOTE="https://x-access-token:\${GH_TOKEN}@github.com/\${{ github.repository }}.git"
|
|
434
|
+
if ! git ls-remote --exit-code "$REMOTE" "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
|
435
|
+
echo "No $BRANCH branch yet — nothing to prune."; exit 0
|
|
436
|
+
fi
|
|
437
|
+
TMP="$(mktemp -d)"
|
|
438
|
+
# Blobless clone keeps this fast; a very large report branch may prefer
|
|
439
|
+
# a --no-checkout plumbing rewrite instead.
|
|
440
|
+
git clone --filter=blob:none --single-branch --branch "$BRANCH" "$REMOTE" "$TMP"
|
|
441
|
+
cd "$TMP"
|
|
442
|
+
if [ ! -d "pr-$PR" ]; then
|
|
443
|
+
echo "No pr-$PR/ folder — nothing to prune."; exit 0
|
|
444
|
+
fi
|
|
445
|
+
git config user.name "github-actions[bot]"
|
|
446
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
447
|
+
git rm -r --quiet "pr-$PR"
|
|
448
|
+
git commit -m "chore(styleproof): prune report for closed PR #$PR"
|
|
449
|
+
git push origin "$BRANCH"
|
|
411
450
|
`;
|
|
412
451
|
|
|
413
452
|
function writeFileSafe(file, contents, { force: f } = {}) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selective remap: given the files a change touched, which declared surfaces
|
|
3
|
+
* could have rendered differently?
|
|
4
|
+
*
|
|
5
|
+
* This is the sound core behind "capture only what a PR can affect, reuse the
|
|
6
|
+
* committed base map for the rest" — an OPT-IN speed-up, never the default gate.
|
|
7
|
+
* The default gate captures every surface and lets the map be the oracle; this
|
|
8
|
+
* function only decides which surfaces a caller *may* skip, and it is built to
|
|
9
|
+
* be wrong only in the safe direction.
|
|
10
|
+
*
|
|
11
|
+
* The hard constraint: in the committed-map model a wrong "unaffected" is silent
|
|
12
|
+
* and fatal — a stale committed map matches the base, the diff is empty, and the
|
|
13
|
+
* regression ships green. So this function OVER-APPROXIMATES. When it cannot
|
|
14
|
+
* prove a surface is unaffected, it returns the sentinel `'all'`, meaning
|
|
15
|
+
* "re-capture everything." Every uncertainty resolves to `'all'`:
|
|
16
|
+
*
|
|
17
|
+
* - a global style change (a reset, `:root`/theme token, `@tailwind`, a
|
|
18
|
+
* `createGlobalStyle`, a design-system config) cascades everywhere → `'all'`;
|
|
19
|
+
* - a vanilla (non-module) stylesheet has a global class namespace the import
|
|
20
|
+
* graph cannot bound → `'all'`;
|
|
21
|
+
* - a computed dynamic `import(x)` with no static prefix could load anything
|
|
22
|
+
* → `'all'`; with a static prefix (`import(`../dir/${x}`)`) it is treated as
|
|
23
|
+
* a bundler context module — every file under that dir is a possible target;
|
|
24
|
+
* - a changed file the graph cannot place at all → `'all'`.
|
|
25
|
+
*
|
|
26
|
+
* The module graph is an INPUT, not a dependency: pass any tool's output in the
|
|
27
|
+
* {@link ModuleEdge} shape (dependency-cruiser's `modules[].dependencies[]` maps
|
|
28
|
+
* directly). StyleProof stays framework-agnostic and adds no dependency; the
|
|
29
|
+
* caller owns graph production, which is where framework-specific resolution
|
|
30
|
+
* lives.
|
|
31
|
+
*
|
|
32
|
+
* Pure and side-effect-free (I/O is injected via `readFile`) so it is fully
|
|
33
|
+
* unit-testable and deterministic.
|
|
34
|
+
*/
|
|
35
|
+
/** One resolved import edge: `from` imports `to`. Mirrors a dependency-cruiser
|
|
36
|
+
* `modules[].dependencies[]` entry (use `module.source` as `from`, dependency
|
|
37
|
+
* `resolved` as `to`). `dynamic` is informational; resolution already happened. */
|
|
38
|
+
export type ModuleEdge = {
|
|
39
|
+
from: string;
|
|
40
|
+
to: string;
|
|
41
|
+
dynamic?: boolean;
|
|
42
|
+
};
|
|
43
|
+
export type AffectedSurfacesInput = {
|
|
44
|
+
/** Repo-relative paths the change touched (as they appear in the graph). */
|
|
45
|
+
changedFiles: Iterable<string>;
|
|
46
|
+
/** Declared surfaces: capture key → the surface's entry module path. */
|
|
47
|
+
surfaces: Record<string, string>;
|
|
48
|
+
/** Resolved import edges for the source tree (node_modules edges are ignored). */
|
|
49
|
+
graph: Iterable<ModuleEdge>;
|
|
50
|
+
/** Every candidate source file path — the universe a context-module glob can
|
|
51
|
+
* resolve within. Typically the graph's node set. */
|
|
52
|
+
files: Iterable<string>;
|
|
53
|
+
/** Read a source file's text (for style classification and dynamic-import
|
|
54
|
+
* recovery). Throwing/returning nothing is treated as "unknown" → `'all'`. */
|
|
55
|
+
readFile: (path: string) => string;
|
|
56
|
+
};
|
|
57
|
+
/** `'all'` means "re-capture everything" (some change could not be bounded). A
|
|
58
|
+
* `Set` of surface keys means exactly those surfaces can be affected; any not
|
|
59
|
+
* listed are provably unaffected and may reuse their committed base map. */
|
|
60
|
+
export type AffectedSurfaces = Set<string> | 'all';
|
|
61
|
+
/**
|
|
62
|
+
* Decide whether a single changed file's style scope is bounded to the files
|
|
63
|
+
* that import it (`'scope'` → follow the import graph) or escapes them
|
|
64
|
+
* (`'all'` → re-capture everything). Sound by construction: `'scope'` is
|
|
65
|
+
* returned only for provably-scoped changes (a CSS Module without escapes, or
|
|
66
|
+
* colocated CSS-in-JS with no global API); everything else, including anything
|
|
67
|
+
* unrecognized, is `'all'`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function classifyStyleChange(file: string, readFile: (p: string) => string): 'scope' | 'all';
|
|
70
|
+
/**
|
|
71
|
+
* Compute the set of declared surfaces a change could have altered, or `'all'`.
|
|
72
|
+
* See the module doc for the soundness contract. Any not in the returned set are
|
|
73
|
+
* provably unaffected and may reuse their committed base map.
|
|
74
|
+
*/
|
|
75
|
+
export declare function affectedSurfaces(input: AffectedSurfacesInput): AffectedSurfaces;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selective remap: given the files a change touched, which declared surfaces
|
|
3
|
+
* could have rendered differently?
|
|
4
|
+
*
|
|
5
|
+
* This is the sound core behind "capture only what a PR can affect, reuse the
|
|
6
|
+
* committed base map for the rest" — an OPT-IN speed-up, never the default gate.
|
|
7
|
+
* The default gate captures every surface and lets the map be the oracle; this
|
|
8
|
+
* function only decides which surfaces a caller *may* skip, and it is built to
|
|
9
|
+
* be wrong only in the safe direction.
|
|
10
|
+
*
|
|
11
|
+
* The hard constraint: in the committed-map model a wrong "unaffected" is silent
|
|
12
|
+
* and fatal — a stale committed map matches the base, the diff is empty, and the
|
|
13
|
+
* regression ships green. So this function OVER-APPROXIMATES. When it cannot
|
|
14
|
+
* prove a surface is unaffected, it returns the sentinel `'all'`, meaning
|
|
15
|
+
* "re-capture everything." Every uncertainty resolves to `'all'`:
|
|
16
|
+
*
|
|
17
|
+
* - a global style change (a reset, `:root`/theme token, `@tailwind`, a
|
|
18
|
+
* `createGlobalStyle`, a design-system config) cascades everywhere → `'all'`;
|
|
19
|
+
* - a vanilla (non-module) stylesheet has a global class namespace the import
|
|
20
|
+
* graph cannot bound → `'all'`;
|
|
21
|
+
* - a computed dynamic `import(x)` with no static prefix could load anything
|
|
22
|
+
* → `'all'`; with a static prefix (`import(`../dir/${x}`)`) it is treated as
|
|
23
|
+
* a bundler context module — every file under that dir is a possible target;
|
|
24
|
+
* - a changed file the graph cannot place at all → `'all'`.
|
|
25
|
+
*
|
|
26
|
+
* The module graph is an INPUT, not a dependency: pass any tool's output in the
|
|
27
|
+
* {@link ModuleEdge} shape (dependency-cruiser's `modules[].dependencies[]` maps
|
|
28
|
+
* directly). StyleProof stays framework-agnostic and adds no dependency; the
|
|
29
|
+
* caller owns graph production, which is where framework-specific resolution
|
|
30
|
+
* lives.
|
|
31
|
+
*
|
|
32
|
+
* Pure and side-effect-free (I/O is injected via `readFile`) so it is fully
|
|
33
|
+
* unit-testable and deterministic.
|
|
34
|
+
*/
|
|
35
|
+
// A stylesheet whose scope escapes the file that imports it. Any of these means
|
|
36
|
+
// a change cascades beyond the import graph's reach.
|
|
37
|
+
const GLOBAL_CSS = /(^|\})\s*(:root|html|body|\*)[\s,{]|@tailwind\b|@layer\s+base\b|@theme\b|@font-face\b/;
|
|
38
|
+
// CSS-in-JS global APIs. NOTE: soundness depends on this list being complete for
|
|
39
|
+
// the libraries in use — an unlisted global API in a .tsx would be misread as a
|
|
40
|
+
// scoped (local) change. Extend deliberately; when unsure, the caller should
|
|
41
|
+
// treat the styling system as unsupported and skip selective remap.
|
|
42
|
+
const CSSJS_GLOBAL = /\b(createGlobalStyle|injectGlobal|globalStyle|globalCss|createGlobalTheme)\b/;
|
|
43
|
+
// `:global(...)` escape hatch or cross-module composition pulls in outside scope.
|
|
44
|
+
const MODULE_ESCAPES = /:global\b|\bcompose[sd]?\b[^;]*\bfrom\b/;
|
|
45
|
+
const isConfig = (f) => /(?:^|\/)(?:tailwind|postcss|theme|tokens?|panda|uno)\.config\.[cm]?[jt]s$/.test(f) ||
|
|
46
|
+
/(?:^|\/)theme\.[cm]?[jt]s$/.test(f);
|
|
47
|
+
const isStyleSheet = (f) => /\.(css|scss|sass|less|styl)$/.test(f);
|
|
48
|
+
const isCssModule = (f) => /\.module\.(css|scss|sass|less|styl)$/.test(f);
|
|
49
|
+
const isCode = (f) => /\.[cm]?[jt]sx?$/.test(f);
|
|
50
|
+
/**
|
|
51
|
+
* Decide whether a single changed file's style scope is bounded to the files
|
|
52
|
+
* that import it (`'scope'` → follow the import graph) or escapes them
|
|
53
|
+
* (`'all'` → re-capture everything). Sound by construction: `'scope'` is
|
|
54
|
+
* returned only for provably-scoped changes (a CSS Module without escapes, or
|
|
55
|
+
* colocated CSS-in-JS with no global API); everything else, including anything
|
|
56
|
+
* unrecognized, is `'all'`.
|
|
57
|
+
*/
|
|
58
|
+
export function classifyStyleChange(file, readFile) {
|
|
59
|
+
if (isConfig(file))
|
|
60
|
+
return 'all'; // design-system config cascades to every surface
|
|
61
|
+
let src;
|
|
62
|
+
try {
|
|
63
|
+
src = readFile(file);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'all'; // cannot read → cannot prove local
|
|
67
|
+
}
|
|
68
|
+
if (src == null)
|
|
69
|
+
return 'all';
|
|
70
|
+
if (isCode(file))
|
|
71
|
+
return CSSJS_GLOBAL.test(src) ? 'all' : 'scope'; // .tsx: global CSS-in-JS or colocated scope
|
|
72
|
+
if (isStyleSheet(file)) {
|
|
73
|
+
if (isCssModule(file)) {
|
|
74
|
+
// Hashed per-file scope — but `:global`, cross-module `composes … from`, and
|
|
75
|
+
// genuinely global selectors (`:root`, `html`, `@font-face`, …) all escape it.
|
|
76
|
+
return MODULE_ESCAPES.test(src) || GLOBAL_CSS.test(src) ? 'all' : 'scope';
|
|
77
|
+
}
|
|
78
|
+
return 'all'; // vanilla stylesheet: global class namespace, import graph can't bound it
|
|
79
|
+
}
|
|
80
|
+
return 'all'; // unknown file kind → fail closed
|
|
81
|
+
}
|
|
82
|
+
// `import(` with a non-string-literal argument, capturing the argument text.
|
|
83
|
+
const DYNAMIC_IMPORT = /import\(\s*([^)]+?)\s*\)/g;
|
|
84
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
85
|
+
const isSource = (p) => !p.includes('node_modules');
|
|
86
|
+
function link(rev, to, from) {
|
|
87
|
+
let s = rev.get(to);
|
|
88
|
+
if (!s)
|
|
89
|
+
rev.set(to, (s = new Set()));
|
|
90
|
+
s.add(from);
|
|
91
|
+
}
|
|
92
|
+
/** Read a source file, mapping any throw/nullish result to `undefined`. */
|
|
93
|
+
function safeRead(readFile, path) {
|
|
94
|
+
try {
|
|
95
|
+
return readFile(path) ?? undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve one computed `import()` argument to the directory its bundler context
|
|
103
|
+
* module is rooted at: `null` for a plain string literal (the resolver already
|
|
104
|
+
* captured it), `'unbounded'` when there is no static directory prefix (target
|
|
105
|
+
* could be any file), else the normalized directory.
|
|
106
|
+
*/
|
|
107
|
+
function contextDir(arg, fromDir) {
|
|
108
|
+
if (/^['"]/.test(arg))
|
|
109
|
+
return null; // plain string literal
|
|
110
|
+
const prefix = arg.match(/^`([^$`]*)/)?.[1]; // static head of a template literal
|
|
111
|
+
if (!prefix || !prefix.includes('/'))
|
|
112
|
+
return 'unbounded'; // e.g. import(name)
|
|
113
|
+
return normalizeDir(fromDir, prefix);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Recover computed `import()`s the graph resolver dropped, as bundler context
|
|
117
|
+
* modules. Returns extra edges to add, or `'unbounded'` if any dynamic import
|
|
118
|
+
* has no static directory prefix (its target could be any file → the whole
|
|
119
|
+
* reachability is untrustworthy → caller must return `'all'`).
|
|
120
|
+
*/
|
|
121
|
+
function recoverContextEdges(from, src, files) {
|
|
122
|
+
const edges = [];
|
|
123
|
+
const fromDir = dirOf(from);
|
|
124
|
+
for (const m of src.matchAll(DYNAMIC_IMPORT)) {
|
|
125
|
+
const base = contextDir(m[1], fromDir);
|
|
126
|
+
if (base === 'unbounded')
|
|
127
|
+
return 'unbounded';
|
|
128
|
+
if (base === null)
|
|
129
|
+
continue;
|
|
130
|
+
for (const f of files)
|
|
131
|
+
if (dirOf(f) === base && isCode(f))
|
|
132
|
+
edges.push({ from, to: f, dynamic: true });
|
|
133
|
+
}
|
|
134
|
+
return edges;
|
|
135
|
+
}
|
|
136
|
+
// Resolve a `../a/b/` style prefix against a source file's directory, without fs.
|
|
137
|
+
function normalizeDir(fromDir, prefix) {
|
|
138
|
+
const parts = (fromDir ? fromDir.split('/') : []).concat(prefix.split('/'));
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const p of parts) {
|
|
141
|
+
if (p === '' || p === '.')
|
|
142
|
+
continue;
|
|
143
|
+
if (p === '..')
|
|
144
|
+
out.pop();
|
|
145
|
+
else
|
|
146
|
+
out.push(p);
|
|
147
|
+
}
|
|
148
|
+
return out.join('/');
|
|
149
|
+
}
|
|
150
|
+
/** True if any changed file is a style change that escapes its importers. */
|
|
151
|
+
function anyGlobalStyleChange(changed, readFile) {
|
|
152
|
+
return changed.some((f) => (isStyleSheet(f) || isCode(f) || isConfig(f)) && classifyStyleChange(f, readFile) === 'all');
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Build the reverse-import adjacency (`imported → importers`) from the graph plus
|
|
156
|
+
* recovered context-module edges. `'all'` when an unbounded dynamic import makes
|
|
157
|
+
* the whole reachability untrustworthy.
|
|
158
|
+
*/
|
|
159
|
+
function buildReverseGraph(input, files) {
|
|
160
|
+
const rev = new Map();
|
|
161
|
+
for (const e of input.graph)
|
|
162
|
+
if (isSource(e.from) && isSource(e.to))
|
|
163
|
+
link(rev, e.to, e.from);
|
|
164
|
+
for (const f of files) {
|
|
165
|
+
const src = isCode(f) ? safeRead(input.readFile, f) : undefined;
|
|
166
|
+
if (src === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const extra = recoverContextEdges(f, src, files);
|
|
169
|
+
if (extra === 'unbounded')
|
|
170
|
+
return 'all';
|
|
171
|
+
for (const e of extra)
|
|
172
|
+
link(rev, e.to, e.from);
|
|
173
|
+
}
|
|
174
|
+
return rev;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Compute the set of declared surfaces a change could have altered, or `'all'`.
|
|
178
|
+
* See the module doc for the soundness contract. Any not in the returned set are
|
|
179
|
+
* provably unaffected and may reuse their committed base map.
|
|
180
|
+
*/
|
|
181
|
+
export function affectedSurfaces(input) {
|
|
182
|
+
const changed = [...input.changedFiles];
|
|
183
|
+
const files = [...input.files];
|
|
184
|
+
// 1. A style change that escapes its importers forces a full re-capture.
|
|
185
|
+
if (anyGlobalStyleChange(changed, input.readFile))
|
|
186
|
+
return 'all';
|
|
187
|
+
// 2. Reverse reachability (+ recovered context edges; unbounded dynamic → all).
|
|
188
|
+
const rev = buildReverseGraph(input, files);
|
|
189
|
+
if (rev === 'all')
|
|
190
|
+
return 'all';
|
|
191
|
+
// 3. Map each changed file to the surfaces that transitively import it. A file
|
|
192
|
+
// the graph can't place (no importers, not a surface entry) → 'all'.
|
|
193
|
+
const entryToKey = new Map(Object.entries(input.surfaces).map(([k, f]) => [f, k]));
|
|
194
|
+
const hit = new Set();
|
|
195
|
+
for (const f of changed) {
|
|
196
|
+
const reach = reverseReach(f, rev);
|
|
197
|
+
if (reach.size === 0 && !entryToKey.has(f))
|
|
198
|
+
return 'all';
|
|
199
|
+
for (const src of [f, ...reach]) {
|
|
200
|
+
const key = entryToKey.get(src);
|
|
201
|
+
if (key)
|
|
202
|
+
hit.add(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return hit;
|
|
206
|
+
}
|
|
207
|
+
function reverseReach(file, rev) {
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const stack = [file];
|
|
210
|
+
while (stack.length) {
|
|
211
|
+
const cur = stack.pop();
|
|
212
|
+
for (const importer of rev.get(cur) ?? []) {
|
|
213
|
+
if (!seen.has(importer)) {
|
|
214
|
+
seen.add(importer);
|
|
215
|
+
stack.push(importer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return seen;
|
|
220
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -23,3 +23,5 @@ export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, find
|
|
|
23
23
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
24
24
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
25
25
|
export type { ReportOptions, ReportResult } from './report.js';
|
|
26
|
+
export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
|
|
27
|
+
export type { ModuleEdge, AffectedSurfacesInput, AffectedSurfaces } from './affected-surfaces.js';
|
package/dist/index.js
CHANGED
|
@@ -12,3 +12,4 @@ export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
|
12
12
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
13
13
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
14
14
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
15
|
+
export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
|
package/dist/report.d.ts
CHANGED
|
@@ -56,6 +56,15 @@ export type ReportOptions = {
|
|
|
56
56
|
* cause) for the reviewer's eye.
|
|
57
57
|
*/
|
|
58
58
|
includeContent?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Byte ceiling for report.md so GitHub can always render it (its markdown viewer
|
|
61
|
+
* refuses to render files past ~512 KB). Once the accumulated report would exceed
|
|
62
|
+
* this, the remaining changed surfaces are listed as one-liners (name · change
|
|
63
|
+
* count · crop link) instead of full property tables — the exhaustive per-row
|
|
64
|
+
* detail is always kept in report.json and every crop in crops/, so nothing is
|
|
65
|
+
* lost, just relocated. Default 400_000 (~0.4 MB). Set to Infinity to never cap.
|
|
66
|
+
*/
|
|
67
|
+
maxReportBytes?: number;
|
|
59
68
|
};
|
|
60
69
|
export type ReportResult = {
|
|
61
70
|
/** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
|
package/dist/report.js
CHANGED
|
@@ -1196,6 +1196,31 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1196
1196
|
md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
|
|
1197
1197
|
return { md, json, cropSeq };
|
|
1198
1198
|
}
|
|
1199
|
+
/** The one-time banner where report.md switches from full detail to one-line
|
|
1200
|
+
* summaries, so the reader knows nothing is missing — only relocated to report.json. */
|
|
1201
|
+
function cappedNoticeLines(budget) {
|
|
1202
|
+
return [
|
|
1203
|
+
'',
|
|
1204
|
+
'## … more changed surfaces (summarized to keep this report renderable)',
|
|
1205
|
+
'',
|
|
1206
|
+
`_This report reached its ~${Math.round(budget / 1000)} KB display budget (GitHub does not render ` +
|
|
1207
|
+
`markdown past ~512 KB), so the surfaces below are listed as one-liners. Their full property ` +
|
|
1208
|
+
`tables are in \`report.json\` and their crops in \`crops/\` — the certification above covers every ` +
|
|
1209
|
+
`surface; only the inline detail is capped._`,
|
|
1210
|
+
'',
|
|
1211
|
+
];
|
|
1212
|
+
}
|
|
1213
|
+
/** One-line summary for a changed surface whose full detail was budget-capped: its
|
|
1214
|
+
* name (and how many surfaces share the identical change) · change count · a crop
|
|
1215
|
+
* link so the reviewer can still see it without opening report.json. */
|
|
1216
|
+
function compactChangeSummary(cg, json, img) {
|
|
1217
|
+
const surface = cg.rep.sd.surface;
|
|
1218
|
+
const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
|
|
1219
|
+
const regions = json.regions ?? [];
|
|
1220
|
+
const composite = regions[0]?.images?.composite;
|
|
1221
|
+
const link = composite ? ` — [crop](${img(composite)})` : '';
|
|
1222
|
+
return `- \`${surface}\`${more} · ${cg.rep.findings.length} change(s)${link}`;
|
|
1223
|
+
}
|
|
1199
1224
|
export function generateStyleMapReport(opts) {
|
|
1200
1225
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '',
|
|
1201
1226
|
// Tighter than before (was 24) so the change fills the frame — the annotation
|
|
@@ -1203,7 +1228,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1203
1228
|
pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
|
|
1204
1229
|
// More, smaller crops before collapsing (was 6), so distinct changes get their
|
|
1205
1230
|
// own focused frame rather than one wide merged one.
|
|
1206
|
-
maxCrops = 8, foldDetailsAt = 0, } = opts;
|
|
1231
|
+
maxCrops = 8, foldDetailsAt = 0, maxReportBytes = 400_000, } = opts;
|
|
1207
1232
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
1208
1233
|
const includeContent = opts.includeContent ?? false;
|
|
1209
1234
|
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
@@ -1261,18 +1286,38 @@ export function generateStyleMapReport(opts) {
|
|
|
1261
1286
|
}));
|
|
1262
1287
|
let totalFindings = 0;
|
|
1263
1288
|
let cropSeq = 0;
|
|
1289
|
+
// report.md must stay renderable — GitHub refuses to render markdown past ~512 KB.
|
|
1290
|
+
// Emit full detail greedily until the byte budget is reached, then list any remaining
|
|
1291
|
+
// surfaces as one-liners. The exhaustive per-row detail is always in report.json and
|
|
1292
|
+
// every crop in crops/, so the cap changes what's shown inline, never what's certified.
|
|
1293
|
+
let reportBytes = md.join('\n').length;
|
|
1294
|
+
let capped = false;
|
|
1295
|
+
const emitDetail = (detail, summary) => {
|
|
1296
|
+
const cost = detail.join('\n').length + 1;
|
|
1297
|
+
if (!capped && reportBytes + cost <= maxReportBytes) {
|
|
1298
|
+
md.push(...detail);
|
|
1299
|
+
reportBytes += cost;
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (!capped) {
|
|
1303
|
+
md.push(...cappedNoticeLines(maxReportBytes));
|
|
1304
|
+
capped = true;
|
|
1305
|
+
}
|
|
1306
|
+
md.push(summary);
|
|
1307
|
+
reportBytes += summary.length + 1;
|
|
1308
|
+
};
|
|
1264
1309
|
for (const cg of changeGroups) {
|
|
1265
1310
|
const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
|
|
1266
|
-
md.push(...r.md);
|
|
1267
1311
|
json.push(r.json);
|
|
1268
1312
|
totalFindings += r.findingCount;
|
|
1269
1313
|
cropSeq = r.cropSeq;
|
|
1314
|
+
emitDetail(r.md, compactChangeSummary(cg, r.json, img));
|
|
1270
1315
|
}
|
|
1271
1316
|
for (const p of missing) {
|
|
1272
1317
|
const r = renderNewSurface(p, ctx, cropSeq);
|
|
1273
|
-
md.push(...r.md);
|
|
1274
1318
|
json.push(r.json);
|
|
1275
1319
|
cropSeq = r.cropSeq;
|
|
1320
|
+
emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`);
|
|
1276
1321
|
}
|
|
1277
1322
|
md.push(...contentSection.md);
|
|
1278
1323
|
const reportMdPath = path.join(outDir, 'report.md');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|