session-orchestrator 4.1.0 → 5.0.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/.agents/skills/session-plan/SKILL.md +1 -1
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +8 -4
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/measures.mjs — Browser-side measurement scripts + pure classifiers.
|
|
3
|
+
*
|
|
4
|
+
* Leaf module: no imports at all (not even `./schema.mjs`) and no I/O. It ships
|
|
5
|
+
* two kinds of thing:
|
|
6
|
+
*
|
|
7
|
+
* 1. `*_EVAL` constants — JavaScript SOURCE STRINGS handed verbatim to
|
|
8
|
+
* `agent-browser eval` (0.37.1). They run in the PAGE, so they cannot
|
|
9
|
+
* import anything; every threshold they need is inlined and pinned against
|
|
10
|
+
* the exported constants by the measures test suite.
|
|
11
|
+
* 2. Pure functions — `classifyTargetSize`, `hasHorizontalOverflow`,
|
|
12
|
+
* `titleMatches`, `parseEvalOutput` — used by `collect.mjs` in node.
|
|
13
|
+
*
|
|
14
|
+
* This module NEVER builds findings. `collect.mjs` turns a measurement plus a
|
|
15
|
+
* classifier verdict into a finding via `makeFinding()` from `./schema.mjs`.
|
|
16
|
+
*
|
|
17
|
+
* ## `agent-browser eval` output contract (measured 2026-09-12, v0.37.1)
|
|
18
|
+
*
|
|
19
|
+
* The tool evaluates the script and prints the completion value **already
|
|
20
|
+
* JSON-serialised** (pretty-printed, multi-line):
|
|
21
|
+
*
|
|
22
|
+
* ```
|
|
23
|
+
* $ printf '(() => ({n: window.innerWidth}))()' | agent-browser eval --stdin
|
|
24
|
+
* {
|
|
25
|
+
* "n": 1280
|
|
26
|
+
* }
|
|
27
|
+
* $ agent-browser eval "document.title"
|
|
28
|
+
* ""
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Two consequences, both load-bearing for the shapes below:
|
|
32
|
+
* - The evals return plain values/objects, NOT `JSON.stringify(...)`. Returning
|
|
33
|
+
* a JSON string would print a *quoted, escaped* string (measured:
|
|
34
|
+
* `(() => JSON.stringify({n:1}))()` printed `"{\"n\":1}"`), forcing a double
|
|
35
|
+
* parse on every read.
|
|
36
|
+
* - Multi-line output is normal, so {@link parseEvalOutput} parses the WHOLE
|
|
37
|
+
* trimmed stdout, never the last line. It still tolerates the
|
|
38
|
+
* double-encoded shape, because a page that itself returns a JSON string is
|
|
39
|
+
* indistinguishable from the quoted form at the stdout boundary.
|
|
40
|
+
*
|
|
41
|
+
* Pass these strings via `eval --stdin` (heredoc) or `eval -b <base64>` — the
|
|
42
|
+
* tool's own guidance ("Inline `agent-browser eval "..."` works only for simple
|
|
43
|
+
* expressions"); {@link TARGET_SIZE_EVAL} contains quotes and brackets.
|
|
44
|
+
*
|
|
45
|
+
* Spec: docs/prd/2026-09-12-ux-grill.md § 2 S2/S3, § 3 AC "Stufe 1".
|
|
46
|
+
*
|
|
47
|
+
* Exports:
|
|
48
|
+
* TARGET_SIZE_FLOOR_PX, TARGET_SIZE_TARGET_PX, INTERACTIVE_TARGET_SELECTOR,
|
|
49
|
+
* TARGET_SIZE_MAX_ENTRIES, SELECTOR_MAX_DEPTH, SELECTOR_MAX_LENGTH,
|
|
50
|
+
* OVERFLOW_TOLERANCE_PX,
|
|
51
|
+
* TARGET_SIZE_EVAL, OVERFLOW_EVAL, VIEWPORT_WIDTH_EVAL,
|
|
52
|
+
* classifyTargetSize(), hasHorizontalOverflow(), titleMatches(),
|
|
53
|
+
* parseEvalOutput()
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* WCAG 2.2 SC 2.5.8 **Target Size (Minimum)**, AA: pointer targets must be at
|
|
58
|
+
* least 24 × 24 CSS px. Below this is `target-size-floor` → severity `high`
|
|
59
|
+
* (PRD § 2 S3).
|
|
60
|
+
* @type {number}
|
|
61
|
+
*/
|
|
62
|
+
export const TARGET_SIZE_FLOOR_PX = 24;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* WCAG 2.1 SC 2.5.5 **Target Size (Enhanced)**, AAA: 44 × 44 CSS px. A target
|
|
66
|
+
* at or above {@link TARGET_SIZE_FLOOR_PX} but below this is
|
|
67
|
+
* `target-size-target` → severity `medium` (PRD § 2 S3 row "24–43 px").
|
|
68
|
+
* @type {number}
|
|
69
|
+
*/
|
|
70
|
+
export const TARGET_SIZE_TARGET_PX = 44;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* THE population of interactive targets the size check runs over. Anything not
|
|
74
|
+
* matched here is not measured and therefore cannot produce a finding — state
|
|
75
|
+
* this list whenever a `target-size-*` count is reported (the count is
|
|
76
|
+
* meaningless without its denominator).
|
|
77
|
+
*
|
|
78
|
+
* Deliberately syntactic (tags + explicit ARIA roles), not "everything with a
|
|
79
|
+
* click handler": a listener attached in JS is invisible to a static query, so
|
|
80
|
+
* a handler-based population would be unreproducible run to run — and
|
|
81
|
+
* reproducibility is the AC ("Fingerprint-Mengen beider findings.jsonl
|
|
82
|
+
* identisch"). `input[type=hidden]` is excluded at the selector level because
|
|
83
|
+
* it has no box at all.
|
|
84
|
+
* @type {string}
|
|
85
|
+
*/
|
|
86
|
+
export const INTERACTIVE_TARGET_SELECTOR = [
|
|
87
|
+
'a[href]',
|
|
88
|
+
'button',
|
|
89
|
+
'input:not([type=hidden])',
|
|
90
|
+
'select',
|
|
91
|
+
'textarea',
|
|
92
|
+
'[role=button]',
|
|
93
|
+
'[role=link]',
|
|
94
|
+
'[role=menuitem]',
|
|
95
|
+
'[role=tab]',
|
|
96
|
+
'[role=checkbox]',
|
|
97
|
+
'[role=radio]',
|
|
98
|
+
'[role=switch]',
|
|
99
|
+
'summary',
|
|
100
|
+
'label[for]',
|
|
101
|
+
].join(', ');
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Ceiling on entries returned by {@link TARGET_SIZE_EVAL}. A page with more
|
|
105
|
+
* undersized targets than this has a systemic layout problem, not 200 separate
|
|
106
|
+
* ones, and every entry becomes a fingerprinted finding + an issue candidate.
|
|
107
|
+
* Revisit if a real route legitimately exceeds it — the eval reports
|
|
108
|
+
* `truncated: true` so the condition is observable rather than silent.
|
|
109
|
+
* @type {number}
|
|
110
|
+
*/
|
|
111
|
+
export const TARGET_SIZE_MAX_ENTRIES = 200;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Maximum ancestor levels walked when building a target's CSS path.
|
|
115
|
+
*
|
|
116
|
+
* BV-004 — deliberate simplification. CEILING: 5 `:nth-of-type()` steps
|
|
117
|
+
* uniquely identify a target on a normally-nested page and keep the generated
|
|
118
|
+
* selector inside {@link SELECTOR_MAX_LENGTH}; the walk also stops early at the
|
|
119
|
+
* first stable `id`. It does NOT guarantee uniqueness — two sibling subtrees
|
|
120
|
+
* that differ only ABOVE level 5 produce the same path, hence the same
|
|
121
|
+
* fingerprint, and collapse into one finding.
|
|
122
|
+
* REVISIT TRIGGER: a real run reporting fewer `target-size-*` findings than the
|
|
123
|
+
* eval's own `targets[]` length (the collapse becoming observable), or a target
|
|
124
|
+
* app whose interactive elements routinely sit deeper than 5 levels below their
|
|
125
|
+
* nearest `id`.
|
|
126
|
+
* @type {number}
|
|
127
|
+
*/
|
|
128
|
+
export const SELECTOR_MAX_DEPTH = 5;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hard cap on a generated selector. The selector is the third segment of a
|
|
132
|
+
* finding locator (`route|viewport|selector`) which `schema.mjs` truncates at
|
|
133
|
+
* `LOCATOR_MAX_LENGTH` (256) BEFORE fingerprinting; keeping the selector well
|
|
134
|
+
* under that leaves the route and viewport segments intact, so two findings on
|
|
135
|
+
* different routes cannot collapse onto one fingerprint.
|
|
136
|
+
*
|
|
137
|
+
* BV-004 — deliberate simplification. CEILING: 160 leaves ≥ 96 chars of the
|
|
138
|
+
* 256-char locator for `route|viewport|`, which covers every route/viewport
|
|
139
|
+
* label the manifest schema admits. Over-long paths are TRUNCATED FROM THE LEFT
|
|
140
|
+
* (the eval keeps the tail, i.e. the element and its nearest ancestors), so a
|
|
141
|
+
* selector longer than this loses its outermost context and two deeply-nested
|
|
142
|
+
* targets sharing a tail can fingerprint alike.
|
|
143
|
+
* REVISIT TRIGGER: a route or viewport label pushing the assembled locator past
|
|
144
|
+
* `LOCATOR_MAX_LENGTH` (256) in `schema.mjs`, which would start truncating the
|
|
145
|
+
* SELECTOR segment a second time — raise 256 there rather than lowering 160 here.
|
|
146
|
+
* @type {number}
|
|
147
|
+
*/
|
|
148
|
+
export const SELECTOR_MAX_LENGTH = 160;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Subpixel tolerance for the horizontal-overflow comparison, see
|
|
152
|
+
* {@link hasHorizontalOverflow}.
|
|
153
|
+
*
|
|
154
|
+
* BV-004 — deliberate simplification, stated AT the constant rather than only
|
|
155
|
+
* in `skills/ux-grill/rubric-v2.md` § horizontal-overflow (which now points
|
|
156
|
+
* here). CEILING: 1 CSS px absorbs fractional layout rounding (a 1439.5 px
|
|
157
|
+
* container in a 1440 px viewport rounds up into `scrollWidth` and shows no
|
|
158
|
+
* scrollbar) and nothing more — a real horizontal scrollbar is always several
|
|
159
|
+
* px wide, so no genuine overflow hides under this tolerance. It is a FIXED
|
|
160
|
+
* tolerance, not a ratio: on a hypothetical viewport measured in device pixels
|
|
161
|
+
* at a high DPR, 1 CSS px would no longer be the rounding unit.
|
|
162
|
+
* REVISIT TRIGGER: a run reporting `horizontal-overflow` on a page with no
|
|
163
|
+
* scrollbar (tolerance too small) or missing a visible scrollbar (too large) —
|
|
164
|
+
* both are observable from the recorded `scrollWidth`/`innerWidth` evidence.
|
|
165
|
+
* @type {number}
|
|
166
|
+
*/
|
|
167
|
+
export const OVERFLOW_TOLERANCE_PX = 1;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Classify a measured target box against the WCAG thresholds.
|
|
171
|
+
*
|
|
172
|
+
* BOTH axes are checked (PRD § 2 S3: "beide Achsen geprüft") — the smaller axis
|
|
173
|
+
* decides, because a 147 × 20 px button is as hard to hit as a 20 × 20 one.
|
|
174
|
+
*
|
|
175
|
+
* Population note: this classifies a box that the caller has ALREADY decided is
|
|
176
|
+
* a rendered, visible, interactive target. It knows nothing about visibility —
|
|
177
|
+
* feeding it a hidden element's rect yields a verdict for an element that must
|
|
178
|
+
* never produce a finding.
|
|
179
|
+
*
|
|
180
|
+
* @param {{width?: number, height?: number}} box - CSS-pixel box dimensions.
|
|
181
|
+
* @returns {'floor'|'target'|null} `'floor'` when either axis is below
|
|
182
|
+
* {@link TARGET_SIZE_FLOOR_PX}; `'target'` when either axis is below
|
|
183
|
+
* {@link TARGET_SIZE_TARGET_PX}; otherwise `null` (compliant). Non-finite or
|
|
184
|
+
* missing input returns `null`: an unmeasurable box is not evidence of a
|
|
185
|
+
* violation, and a NaN comparison would otherwise silently read as "not
|
|
186
|
+
* below" anyway.
|
|
187
|
+
*/
|
|
188
|
+
export function classifyTargetSize({ width, height } = {}) {
|
|
189
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
190
|
+
if (width < TARGET_SIZE_FLOOR_PX || height < TARGET_SIZE_FLOOR_PX) return 'floor';
|
|
191
|
+
if (width < TARGET_SIZE_TARGET_PX || height < TARGET_SIZE_TARGET_PX) return 'target';
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Page-side target-size measurement.
|
|
197
|
+
*
|
|
198
|
+
* Returns `{ targets: Array<{selector, tag, width, height, class, verdict}>,
|
|
199
|
+
* scanned: number, truncated: boolean }`, where `targets` holds ONLY the
|
|
200
|
+
* non-compliant ones (verdict `'floor'` or `'target'`) and `scanned` is the size
|
|
201
|
+
* of the population actually inspected — `collect.mjs` needs the denominator.
|
|
202
|
+
* When `truncated` is `true` the scan stopped at the cap, so `scanned` is a
|
|
203
|
+
* partial denominator and must be reported as such.
|
|
204
|
+
*
|
|
205
|
+
* Exclusions (an excluded element can never produce a finding):
|
|
206
|
+
* - zero-area rect, `display: none`, `visibility: hidden`
|
|
207
|
+
* - computed `opacity: 0` on the element or any ancestor (opacity does not
|
|
208
|
+
* inherit as a computed value, so the ancestor walk is required)
|
|
209
|
+
* - `aria-hidden="true"` or `inert` on the element or any ancestor
|
|
210
|
+
* - the custom-select pattern (PRD § 3 AC): a `select`/`input` that is ≤ 1 × 1
|
|
211
|
+
* px, sits off-screen, or is covered by another element at its centre point.
|
|
212
|
+
*
|
|
213
|
+
* The covered/off-screen/1×1 trio is scoped to `select`/`input` on purpose: it
|
|
214
|
+
* is the native-control-behind-a-custom-trigger idiom, and applying a
|
|
215
|
+
* hit-testing exclusion to every target would silently drop real violations
|
|
216
|
+
* that merely sit under a decorative overlay.
|
|
217
|
+
*
|
|
218
|
+
* `off-screen` deliberately means left/above/right-of the viewport, NOT below
|
|
219
|
+
* it: on any page taller than the viewport, "fully outside the viewport" would
|
|
220
|
+
* exclude every below-the-fold target — normal scroll content, not a hiding
|
|
221
|
+
* pattern.
|
|
222
|
+
*
|
|
223
|
+
* The thresholds 24/44, the depth 5, the length 160 and the cap 200 are inlined
|
|
224
|
+
* because a page script cannot import; they MUST equal
|
|
225
|
+
* {@link TARGET_SIZE_FLOOR_PX}, {@link TARGET_SIZE_TARGET_PX},
|
|
226
|
+
* {@link SELECTOR_MAX_DEPTH}, {@link SELECTOR_MAX_LENGTH} and
|
|
227
|
+
* {@link TARGET_SIZE_MAX_ENTRIES} (pinned by the measures test suite).
|
|
228
|
+
* @type {string}
|
|
229
|
+
*/
|
|
230
|
+
export const TARGET_SIZE_EVAL = `(() => {
|
|
231
|
+
const FLOOR = 24; // must equal TARGET_SIZE_FLOOR_PX
|
|
232
|
+
const TARGET = 44; // must equal TARGET_SIZE_TARGET_PX
|
|
233
|
+
const MAX_ENTRIES = 200; // must equal TARGET_SIZE_MAX_ENTRIES
|
|
234
|
+
const MAX_DEPTH = 5; // must equal SELECTOR_MAX_DEPTH
|
|
235
|
+
const MAX_SELECTOR = 160; // must equal SELECTOR_MAX_LENGTH
|
|
236
|
+
const SELECTOR = ${JSON.stringify(INTERACTIVE_TARGET_SELECTOR)};
|
|
237
|
+
|
|
238
|
+
const classify = (w, h) => {
|
|
239
|
+
if (!isFinite(w) || !isFinite(h)) return null;
|
|
240
|
+
if (w < FLOOR || h < FLOOR) return 'floor';
|
|
241
|
+
if (w < TARGET || h < TARGET) return 'target';
|
|
242
|
+
return null;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const cssPath = (el) => {
|
|
246
|
+
const parts = [];
|
|
247
|
+
let node = el;
|
|
248
|
+
for (let depth = 0; node && node.nodeType === 1 && depth < MAX_DEPTH; depth++) {
|
|
249
|
+
const tag = node.tagName.toLowerCase();
|
|
250
|
+
if (node.id && /^[A-Za-z][A-Za-z0-9_-]*$/.test(node.id)) {
|
|
251
|
+
parts.unshift('#' + node.id);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
let index = 1;
|
|
255
|
+
let sib = node.previousElementSibling;
|
|
256
|
+
while (sib) {
|
|
257
|
+
if (sib.tagName === node.tagName) index++;
|
|
258
|
+
sib = sib.previousElementSibling;
|
|
259
|
+
}
|
|
260
|
+
parts.unshift(tag + ':nth-of-type(' + index + ')');
|
|
261
|
+
node = node.parentElement;
|
|
262
|
+
}
|
|
263
|
+
const path = parts.join(' > ');
|
|
264
|
+
return path.length > MAX_SELECTOR ? path.slice(-MAX_SELECTOR) : path;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const hiddenByAncestor = (el) => {
|
|
268
|
+
let node = el;
|
|
269
|
+
while (node && node.nodeType === 1) {
|
|
270
|
+
if (node.getAttribute('aria-hidden') === 'true') return true;
|
|
271
|
+
if (node.hasAttribute('inert')) return true;
|
|
272
|
+
const cs = getComputedStyle(node);
|
|
273
|
+
if (cs.display === 'none' || cs.visibility === 'hidden') return true;
|
|
274
|
+
if (parseFloat(cs.opacity) === 0) return true;
|
|
275
|
+
node = node.parentElement;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const isCovered = (el, rect) => {
|
|
281
|
+
const x = rect.left + rect.width / 2;
|
|
282
|
+
const y = rect.top + rect.height / 2;
|
|
283
|
+
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return true;
|
|
284
|
+
const hit = document.elementFromPoint(x, y);
|
|
285
|
+
if (!hit) return true;
|
|
286
|
+
return !(hit === el || el.contains(hit));
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const els = Array.from(document.querySelectorAll(SELECTOR));
|
|
290
|
+
const targets = [];
|
|
291
|
+
let scanned = 0;
|
|
292
|
+
let truncated = false;
|
|
293
|
+
|
|
294
|
+
for (const el of els) {
|
|
295
|
+
const rect = el.getBoundingClientRect();
|
|
296
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
297
|
+
if (hiddenByAncestor(el)) continue;
|
|
298
|
+
|
|
299
|
+
const tag = el.tagName.toLowerCase();
|
|
300
|
+
if (tag === 'select' || tag === 'input') {
|
|
301
|
+
if (rect.width <= 1 || rect.height <= 1) continue;
|
|
302
|
+
const offscreen = rect.right <= 0 || rect.bottom <= 0 || rect.left >= innerWidth;
|
|
303
|
+
if (offscreen) continue;
|
|
304
|
+
if (isCovered(el, rect)) continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
scanned++;
|
|
308
|
+
// Classify on the RAW float and round only for reporting: rounding first
|
|
309
|
+
// promotes a 23.6 x 23.6 px box to 24 x 24 and reclassifies a 'floor'
|
|
310
|
+
// violation as 'target' (or as compliant at 43.6).
|
|
311
|
+
const verdict = classify(rect.width, rect.height);
|
|
312
|
+
const width = Math.round(rect.width);
|
|
313
|
+
const height = Math.round(rect.height);
|
|
314
|
+
if (verdict === null) continue;
|
|
315
|
+
if (targets.length >= MAX_ENTRIES) { truncated = true; break; }
|
|
316
|
+
targets.push({
|
|
317
|
+
selector: cssPath(el),
|
|
318
|
+
tag: tag,
|
|
319
|
+
width: width,
|
|
320
|
+
height: height,
|
|
321
|
+
class: typeof el.className === 'string' ? el.className : '',
|
|
322
|
+
verdict: verdict,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { targets: targets, scanned: scanned, truncated: truncated };
|
|
327
|
+
})()`;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Page-side horizontal-overflow measurement. Feed the result to
|
|
331
|
+
* {@link hasHorizontalOverflow}.
|
|
332
|
+
*
|
|
333
|
+
* `bodyScrollWidth` is reported alongside the documentElement value purely as
|
|
334
|
+
* diagnostic context for the operator (it localises the overflow to the body
|
|
335
|
+
* subtree); the verdict is computed from `scrollWidth`/`innerWidth` only.
|
|
336
|
+
* @type {string}
|
|
337
|
+
*/
|
|
338
|
+
export const OVERFLOW_EVAL = `(() => ({
|
|
339
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
340
|
+
innerWidth: window.innerWidth,
|
|
341
|
+
bodyScrollWidth: document.body ? document.body.scrollWidth : null,
|
|
342
|
+
}))()`;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Page-side viewport width — the device/viewport verification probe (PRD § 2
|
|
346
|
+
* S2: "`window.innerWidth` muss zur Vorgabe passen, sonst
|
|
347
|
+
* `skipped: device-mismatch`").
|
|
348
|
+
* @type {string}
|
|
349
|
+
*/
|
|
350
|
+
export const VIEWPORT_WIDTH_EVAL = 'window.innerWidth';
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Decide whether a page overflows horizontally.
|
|
354
|
+
*
|
|
355
|
+
* The `+ 1` is a subpixel tolerance: fractional layout widths (a 1439.5 px
|
|
356
|
+
* container in a 1440 px viewport) round up into `scrollWidth`, so a strict
|
|
357
|
+
* `>` reports overflow on pages that show no scrollbar. One CSS pixel is the
|
|
358
|
+
* smallest tolerance that absorbs that rounding without hiding a real
|
|
359
|
+
* horizontal scrollbar, which is always ≥ several px wide.
|
|
360
|
+
*
|
|
361
|
+
* @param {{scrollWidth?: number, innerWidth?: number}} m - {@link OVERFLOW_EVAL} result.
|
|
362
|
+
* @returns {boolean} `false` when either value is non-finite — an unmeasured
|
|
363
|
+
* page is not a violating page.
|
|
364
|
+
*/
|
|
365
|
+
export function hasHorizontalOverflow({ scrollWidth, innerWidth } = {}) {
|
|
366
|
+
if (!Number.isFinite(scrollWidth) || !Number.isFinite(innerWidth)) return false;
|
|
367
|
+
return scrollWidth > innerWidth + OVERFLOW_TOLERANCE_PX;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Test a page title against a manifest `title-pattern`.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} title - the measured `document.title`, read by
|
|
374
|
+
* `collect.mjs` via `agent-browser get title --json`.
|
|
375
|
+
* @param {string} [pattern] - a regex SOURCE string from the manifest (no
|
|
376
|
+
* delimiters, no flags), compiled with `new RegExp(pattern)`.
|
|
377
|
+
* @returns {{ok: true, matched: boolean} | {ok: false, reason: 'invalid-pattern'}}
|
|
378
|
+
* An absent or empty pattern yields `{ok: true, matched: true}`: a route
|
|
379
|
+
* without `title-pattern` declares no expectation, so it can never produce a
|
|
380
|
+
* `title-mismatch` finding. An uncompilable pattern yields
|
|
381
|
+
* `{ok: false, reason: 'invalid-pattern'}` — a manifest defect the caller
|
|
382
|
+
* must surface as such, never as a title violation of the page.
|
|
383
|
+
*/
|
|
384
|
+
export function titleMatches(title, pattern) {
|
|
385
|
+
if (pattern === undefined || pattern === null || pattern === '') {
|
|
386
|
+
return { ok: true, matched: true };
|
|
387
|
+
}
|
|
388
|
+
let re;
|
|
389
|
+
try {
|
|
390
|
+
re = new RegExp(pattern);
|
|
391
|
+
} catch {
|
|
392
|
+
return { ok: false, reason: 'invalid-pattern' };
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, matched: re.test(typeof title === 'string' ? title : '') };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Parse the stdout of `agent-browser eval`.
|
|
399
|
+
*
|
|
400
|
+
* Tolerant by design: the value is JSON, but it arrives pretty-printed over
|
|
401
|
+
* several lines and may be double-encoded when the page itself returned a JSON
|
|
402
|
+
* string. Both are handled; anything else returns the raw text so the caller can
|
|
403
|
+
* record the failure instead of crashing a whole route.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} stdout - raw process stdout.
|
|
406
|
+
* @returns {{ok: true, value: unknown} | {ok: false, raw: string}}
|
|
407
|
+
*/
|
|
408
|
+
export function parseEvalOutput(stdout) {
|
|
409
|
+
const raw = typeof stdout === 'string' ? stdout : '';
|
|
410
|
+
const text = raw.trim();
|
|
411
|
+
if (text === '') return { ok: false, raw };
|
|
412
|
+
let value;
|
|
413
|
+
try {
|
|
414
|
+
value = JSON.parse(text);
|
|
415
|
+
} catch {
|
|
416
|
+
return { ok: false, raw };
|
|
417
|
+
}
|
|
418
|
+
// Double-encoded case: the page returned a JSON *string*, so one parse yields
|
|
419
|
+
// a string that is itself JSON. Only retry when it plausibly is.
|
|
420
|
+
if (typeof value === 'string') {
|
|
421
|
+
const inner = value.trim();
|
|
422
|
+
if (inner.startsWith('{') || inner.startsWith('[')) {
|
|
423
|
+
try {
|
|
424
|
+
return { ok: true, value: JSON.parse(inner) };
|
|
425
|
+
} catch {
|
|
426
|
+
return { ok: true, value };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { ok: true, value };
|
|
431
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/paths.mjs — Pure path builders for ux-grill run artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Near-leaf module: node builtins (`node:path`, `node:crypto`) plus the repo's
|
|
5
|
+
* own `../crypto-digest-utils.mjs` (itself a `node:crypto` leaf) and
|
|
6
|
+
* `../test-runner/artifact-paths.mjs` (a `node:path` leaf), no I/O. All
|
|
7
|
+
* functions are side-effect free — callers do the mkdir. Every builder returns
|
|
8
|
+
* an ABSOLUTE path resolved against `repoRoot`.
|
|
9
|
+
*
|
|
10
|
+
* The run-id invariant ({@link RUN_ID_PATTERN}, `assertRunId`) is imported from
|
|
11
|
+
* `scripts/lib/test-runner/artifact-paths.mjs`, so the two artifact trees can
|
|
12
|
+
* never drift apart on what a safe run id is. Its path BUILDERS are not reused:
|
|
13
|
+
* they return REPO-RELATIVE paths under `.orchestrator/metrics/test-runs/` and
|
|
14
|
+
* take no `repoRoot`, so they are not substitutable here (PRD § 2 S2: "eigener
|
|
15
|
+
* Pfad-Helfer"). Its `makeRunId()` (`<pid>-<ms>`) is likewise not reused:
|
|
16
|
+
* a pid is not unique across hosts and ux-grill artefacts are meant to be
|
|
17
|
+
* committed/compared across machines — see {@link makeRunId}.
|
|
18
|
+
*
|
|
19
|
+
* Exports:
|
|
20
|
+
* UX_GRILL_METRICS_DIR, UX_GRILL_LEDGER, RUN_ID_PATTERN,
|
|
21
|
+
* makeRunId(), runDirPath(), screenshotsDir(), axeDir(), measuresDir(),
|
|
22
|
+
* findingsPath(), runRecordPath(), artefactStem(), screenshotName()
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
import { digestSha256Short } from '../crypto-digest-utils.mjs';
|
|
29
|
+
import { RUN_ID_PATTERN, assertRunId } from '../test-runner/artifact-paths.mjs';
|
|
30
|
+
|
|
31
|
+
// Re-exported so existing `ux-grill/paths.mjs` importers keep working; the
|
|
32
|
+
// definition lives in test-runner/artifact-paths.mjs (one run-id invariant).
|
|
33
|
+
export { RUN_ID_PATTERN };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Repo-relative directory holding one sub-directory per ux-grill run.
|
|
37
|
+
* @type {string}
|
|
38
|
+
*/
|
|
39
|
+
export const UX_GRILL_METRICS_DIR = '.orchestrator/metrics/ux-grill';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Repo-relative JSONL ledger of run-records (one line per run, PRD § 4).
|
|
43
|
+
* @type {string}
|
|
44
|
+
*/
|
|
45
|
+
export const UX_GRILL_LEDGER = '.orchestrator/metrics/ux-grill.jsonl';
|
|
46
|
+
|
|
47
|
+
const SCREENSHOT_NAME_MAX = 120;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Length of the raw-input digest every artefact name carries.
|
|
51
|
+
*
|
|
52
|
+
* The slug alone is LOSSY — `/a b` and `/a_b` both slugify to `_a_b`, and two
|
|
53
|
+
* routes differing only past {@link STEM_SLUG_MAX} truncate to the same prefix.
|
|
54
|
+
* Either collision silently overwrites one route's screenshot, axe JSON and
|
|
55
|
+
* measures JSON with another's, so a filed issue's `evidence` points at a
|
|
56
|
+
* different page than the finding describes.
|
|
57
|
+
*
|
|
58
|
+
* Named ceiling (BV-004): 6 hex chars = 24 bits. Fine for the tens-to-hundreds
|
|
59
|
+
* of route × viewport pairs one manifest declares; revisit if a manifest ever
|
|
60
|
+
* declares thousands.
|
|
61
|
+
* @type {number}
|
|
62
|
+
*/
|
|
63
|
+
const STEM_DIGEST_LENGTH = 6;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cap on the slug part of {@link artefactStem}, leaving room under
|
|
67
|
+
* {@link SCREENSHOT_NAME_MAX} for `-<digest>` and the `-<variant>` suffix
|
|
68
|
+
* {@link screenshotName} appends.
|
|
69
|
+
* @type {number}
|
|
70
|
+
*/
|
|
71
|
+
const STEM_SLUG_MAX = 96;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate a run id of the form `<unix-ms>-<6 hex>`.
|
|
75
|
+
*
|
|
76
|
+
* Millisecond prefix keeps runs lexically sortable; the 6 random hex chars
|
|
77
|
+
* disambiguate two runs started in the same millisecond (and, unlike a pid,
|
|
78
|
+
* stay meaningful when a run directory is compared across hosts). Not a
|
|
79
|
+
* security token — `randomBytes` is used because it is the node-native
|
|
80
|
+
* source (SEC-015 prefers it over `Math.random` regardless).
|
|
81
|
+
*
|
|
82
|
+
* @returns {string} e.g. `'1757635200123-9f3a01'`
|
|
83
|
+
*/
|
|
84
|
+
export function makeRunId() {
|
|
85
|
+
return `${Date.now()}-${randomBytes(3).toString('hex')}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a repo root.
|
|
90
|
+
* @param {string} repoRoot
|
|
91
|
+
* @returns {string} the same repoRoot
|
|
92
|
+
* @throws {TypeError} if absent or empty
|
|
93
|
+
*/
|
|
94
|
+
function assertRepoRoot(repoRoot) {
|
|
95
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
96
|
+
throw new TypeError('ux-grill paths: repoRoot must be a non-empty string');
|
|
97
|
+
}
|
|
98
|
+
return repoRoot;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Absolute path of one run's artifact directory.
|
|
103
|
+
* @param {string} repoRoot
|
|
104
|
+
* @param {string} runId
|
|
105
|
+
* @returns {string}
|
|
106
|
+
* @throws {TypeError} on an invalid repoRoot or runId (path-traversal guard)
|
|
107
|
+
*/
|
|
108
|
+
export function runDirPath(repoRoot, runId) {
|
|
109
|
+
return path.resolve(assertRepoRoot(repoRoot), UX_GRILL_METRICS_DIR, assertRunId(runId));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Absolute path of the run's screenshot directory (full + fold + journey steps).
|
|
114
|
+
* @param {string} repoRoot
|
|
115
|
+
* @param {string} runId
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
export function screenshotsDir(repoRoot, runId) {
|
|
119
|
+
return path.join(runDirPath(repoRoot, runId), 'screenshots');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Absolute path of the run's axe-JSON directory (one file per route × viewport).
|
|
124
|
+
* @param {string} repoRoot
|
|
125
|
+
* @param {string} runId
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function axeDir(repoRoot, runId) {
|
|
129
|
+
return path.join(runDirPath(repoRoot, runId), 'axe');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Absolute path of the run's measures directory (target-size / overflow / title).
|
|
134
|
+
* @param {string} repoRoot
|
|
135
|
+
* @param {string} runId
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
export function measuresDir(repoRoot, runId) {
|
|
139
|
+
return path.join(runDirPath(repoRoot, runId), 'measures');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Absolute path of the run's `findings.jsonl` (one finding per line).
|
|
144
|
+
* @param {string} repoRoot
|
|
145
|
+
* @param {string} runId
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function findingsPath(repoRoot, runId) {
|
|
149
|
+
return path.join(runDirPath(repoRoot, runId), 'findings.jsonl');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Absolute path of the cross-run run-record ledger ({@link UX_GRILL_LEDGER}).
|
|
154
|
+
* Takes no runId — it is shared by every run of the repo.
|
|
155
|
+
* @param {string} repoRoot
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
export function runRecordPath(repoRoot) {
|
|
159
|
+
return path.resolve(assertRepoRoot(repoRoot), UX_GRILL_LEDGER);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Filesystem-safe slug: every character outside `[A-Za-z0-9._-]` becomes `_`,
|
|
164
|
+
* runs of `_` collapse to one.
|
|
165
|
+
* @param {string} value
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
function slugify(value) {
|
|
169
|
+
return value.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Deterministic, collision-free stem shared by a route × viewport's three
|
|
174
|
+
* artefacts (screenshots, axe JSON, measures JSON).
|
|
175
|
+
*
|
|
176
|
+
* Shape: `<slug>-<6 hex of sha256(raw)>`. The digest is taken over the RAW
|
|
177
|
+
* `route-viewport` input, never over the slug — the slug is the lossy half, so
|
|
178
|
+
* digesting it would reproduce exactly the collisions it exists to break (see
|
|
179
|
+
* {@link STEM_DIGEST_LENGTH}).
|
|
180
|
+
*
|
|
181
|
+
* Same inputs always produce the same stem, so a re-run overwrites rather than
|
|
182
|
+
* accumulating near-duplicates.
|
|
183
|
+
*
|
|
184
|
+
* @param {object} opts
|
|
185
|
+
* @param {string} opts.route - route path, e.g. `/dashboard`, or a journey name
|
|
186
|
+
* @param {string} opts.viewport - viewport label, e.g. `desktop` or `mobile`
|
|
187
|
+
* @returns {string} e.g. `'_dashboard-desktop-3f1a9c'`
|
|
188
|
+
* @throws {TypeError} if any part is missing or empty
|
|
189
|
+
*/
|
|
190
|
+
export function artefactStem({ route, viewport } = {}) {
|
|
191
|
+
for (const [name, value] of [
|
|
192
|
+
['route', route],
|
|
193
|
+
['viewport', viewport],
|
|
194
|
+
]) {
|
|
195
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
196
|
+
throw new TypeError(`artefactStem: ${name} must be a non-empty string`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const raw = `${route}-${viewport}`;
|
|
200
|
+
return `${slugify(raw).slice(0, STEM_SLUG_MAX)}-${digestSha256Short(raw, { length: STEM_DIGEST_LENGTH })}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Deterministic, filesystem-safe screenshot file name:
|
|
205
|
+
* {@link artefactStem} plus the variant, which stays the LAST segment so a
|
|
206
|
+
* caller can derive the stem from the name (and the reverse: the stem is the
|
|
207
|
+
* name without its variant suffix).
|
|
208
|
+
*
|
|
209
|
+
* @param {object} opts
|
|
210
|
+
* @param {string} opts.route - route path, e.g. `/dashboard`
|
|
211
|
+
* @param {string} opts.viewport - viewport label, e.g. `desktop` or `mobile`
|
|
212
|
+
* @param {string} opts.variant - `'full'`, `'fold'` or `'step-<n>'`
|
|
213
|
+
* @returns {string} e.g. `'_dashboard-desktop-3f1a9c-full'` (no extension)
|
|
214
|
+
* @throws {TypeError} if any part is missing, or `variant` is not one of the three shapes
|
|
215
|
+
*/
|
|
216
|
+
export function screenshotName({ route, viewport, variant } = {}) {
|
|
217
|
+
if (typeof variant !== 'string' || variant.length === 0) {
|
|
218
|
+
throw new TypeError('screenshotName: variant must be a non-empty string');
|
|
219
|
+
}
|
|
220
|
+
if (variant !== 'full' && variant !== 'fold' && !/^step-\d+$/.test(variant)) {
|
|
221
|
+
throw new TypeError(`screenshotName: variant must be 'full', 'fold' or 'step-<n>', got ${JSON.stringify(variant)}`);
|
|
222
|
+
}
|
|
223
|
+
return `${artefactStem({ route, viewport })}-${variant}`.slice(0, SCREENSHOT_NAME_MAX);
|
|
224
|
+
}
|