@event4u/agent-config 16.0.0 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +297 -137
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +74 -27
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  40. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  41. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  42. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  43. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  44. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  45. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  46. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  49. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  50. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  52. package/dist/agent-src/templates/agent-settings.md +1 -1
  53. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  54. package/dist/agent-src/templates/roadmaps.md +188 -0
  55. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  56. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  58. package/dist/cli-delegate/check_update_banner.js +40 -2
  59. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  60. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  61. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  62. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  63. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  64. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  65. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  66. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  67. package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
  68. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  69. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  70. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  71. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  72. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  73. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  74. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  75. package/dist/cli-delegate/cmd_conformance.js +90 -56
  76. package/dist/cli-delegate/cmd_doctor.js +7 -5
  77. package/dist/cli-delegate/cmd_explain.js +2 -2
  78. package/dist/cli-delegate/cmd_export.js +9 -7
  79. package/dist/cli-delegate/cmd_fleet.js +4 -2
  80. package/dist/cli-delegate/cmd_handoff.js +4 -4
  81. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  82. package/dist/cli-delegate/cmd_migrate.js +2 -2
  83. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  84. package/dist/cli-delegate/cmd_preflight.js +4 -2
  85. package/dist/cli-delegate/cmd_prune.js +10 -8
  86. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  87. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  88. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  89. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  90. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  91. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  92. package/dist/cli-delegate/cmd_sync.js +10 -8
  93. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  94. package/dist/cli-delegate/cmd_update.js +1 -1
  95. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  96. package/dist/cli-delegate/cmd_validate.js +12 -10
  97. package/dist/cli-delegate/cmd_versions.js +1 -1
  98. package/dist/discovery/deprecation-report.md +1 -1
  99. package/dist/discovery/discovery-manifest.json +85 -38
  100. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  101. package/dist/discovery/discovery-manifest.summary.md +4 -4
  102. package/dist/discovery/dormancy-report.md +1 -1
  103. package/dist/discovery/orphan-report.md +1 -1
  104. package/dist/discovery/packs.json +10 -8
  105. package/dist/discovery/trust-report.md +3 -3
  106. package/dist/discovery/workspaces.json +8 -6
  107. package/dist/hooks/dispatch.js +1832 -1007
  108. package/dist/install/claudeRuleRewrite.js +14 -3
  109. package/dist/install/conflict.js +60 -10
  110. package/dist/install/conflictTracker.js +218 -0
  111. package/dist/install/fsPrimitives.js +54 -0
  112. package/dist/install/install.mjs +2007 -1481
  113. package/dist/install/manifestFiles.js +259 -0
  114. package/dist/install/preflight.js +14 -2
  115. package/dist/install/preserve.js +157 -0
  116. package/dist/install/recordedOwnership.js +132 -0
  117. package/dist/install/txlog.js +11 -2
  118. package/dist/mcp/registry-manifest.json +3 -3
  119. package/dist/mcp/server.json +2 -2
  120. package/dist/mcp/server.mjs +398 -39
  121. package/dist/router.json +1 -1
  122. package/dist/scripts/_lib/agent_settings.js +3 -2
  123. package/dist/scripts/_lib/fs_atomic.js +167 -0
  124. package/dist/scripts/_lib/installed_tools.js +460 -0
  125. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  126. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  127. package/dist/server/routes/install.js +8 -0
  128. package/dist/server/routes/wizard.js +9 -3
  129. package/dist/server/schemas/settings.js +17 -5
  130. package/dist/shared/missionExecution.js +97 -0
  131. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  132. package/dist/ui/index.html +1 -1
  133. package/docs/MIGRATION.md +103 -0
  134. package/docs/contracts/settings-classes.md +26 -7
  135. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  136. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  137. package/docs/guidelines/design-fidelity-routing.md +29 -0
  138. package/package.json +1 -1
  139. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  140. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  141. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  142. package/src/config/agent-settings.template.yml +139 -27
  143. package/src/config/auto-rule-budget.json +16 -0
  144. package/src/config/ci-local-parity.yml +18 -5
  145. package/src/config/continuity-surface.json +21 -0
  146. package/src/config/gate-coverage.yml +146 -13
  147. package/src/config/gate-violation-baselines.json +6 -6
  148. package/src/config/hook-token-budget.json +2 -0
  149. package/src/config/loop-surfaces.yaml +215 -0
  150. package/src/config/platform-anchor.json +3 -2
  151. package/src/config/release-gate-locality.yml +11 -0
  152. package/src/config/routing-coverage-seed.json +18 -4
  153. package/src/config/rule-enforcement-baseline.json +0 -1
  154. package/src/install/claudeRuleRewrite.ts +16 -2
  155. package/src/install/conflict.ts +65 -9
  156. package/src/install/conflictTracker.ts +299 -0
  157. package/src/install/fsPrimitives.ts +54 -0
  158. package/src/install/manifestFiles.ts +312 -0
  159. package/src/install/preflight.ts +16 -2
  160. package/src/install/preserve.ts +205 -0
  161. package/src/install/recordedOwnership.ts +149 -0
  162. package/src/install/txlog.ts +11 -2
  163. package/src/install/types.ts +19 -4
  164. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  165. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  166. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  167. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  168. package/src/scripts/_cli/doctor_execution.ts +90 -0
  169. package/src/scripts/_lib/activation_payload.ts +247 -0
  170. package/src/scripts/_lib/agent_settings.ts +4 -2
  171. package/src/scripts/_lib/authority_path.ts +102 -0
  172. package/src/scripts/_lib/cascade_base.ts +94 -0
  173. package/src/scripts/_lib/changelog_eras.ts +7 -2
  174. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  175. package/src/scripts/_lib/council_transport.ts +129 -0
  176. package/src/scripts/_lib/delivery_ready.ts +112 -0
  177. package/src/scripts/_lib/forge_protection.ts +267 -0
  178. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  179. package/src/scripts/_lib/json_python_parity.ts +127 -0
  180. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  181. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  182. package/src/scripts/_lib/mission_record.ts +190 -0
  183. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  184. package/src/scripts/_lib/obligations.ts +402 -0
  185. package/src/scripts/_lib/orchestration_record.ts +11 -20
  186. package/src/scripts/_lib/release_highlights.ts +199 -10
  187. package/src/scripts/_lib/release_holds.ts +493 -0
  188. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  189. package/src/scripts/_lib/rides_along.ts +102 -0
  190. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  191. package/src/scripts/_lib/test_delta.ts +166 -0
  192. package/src/scripts/_lib/test_provenance.ts +171 -0
  193. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  194. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  195. package/src/scripts/ai_council/config.ts +8 -11
  196. package/src/scripts/ai_council/fallback_config.ts +5 -3
  197. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  198. package/src/scripts/ask_block_census.ts +285 -15
  199. package/src/scripts/check_enforcement_matrix.ts +624 -0
  200. package/src/scripts/check_gate_reachability.ts +173 -10
  201. package/src/scripts/check_generator_sync.ts +954 -0
  202. package/src/scripts/check_preamble_payload_budget.ts +75 -2
  203. package/src/scripts/check_release_highlights.ts +33 -1
  204. package/src/scripts/check_release_holds.ts +567 -0
  205. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  206. package/src/scripts/check_test_delta.ts +294 -0
  207. package/src/scripts/check_test_weakening.ts +299 -0
  208. package/src/scripts/closure_scan.ts +333 -0
  209. package/src/scripts/compile_hook_manifest.ts +43 -3
  210. package/src/scripts/council_record_shape.ts +209 -0
  211. package/src/scripts/hook_manifest.json +1 -1
  212. package/src/scripts/hook_manifest.yaml +88 -9
  213. package/src/scripts/hooks/concern_registry.ts +4 -0
  214. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  215. package/src/scripts/hooks/evidence_independence.ts +96 -0
  216. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  217. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  218. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  219. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  220. package/src/scripts/hooks_doctor.ts +85 -0
  221. package/src/scripts/hooks_status.ts +19 -0
  222. package/src/scripts/install.ts +106 -206
  223. package/src/scripts/janitor.ts +7 -0
  224. package/src/scripts/lint_decision_classes.ts +464 -0
  225. package/src/scripts/lint_hook_manifest.ts +11 -0
  226. package/src/scripts/lint_major_migration_sections.ts +450 -0
  227. package/src/scripts/lint_plan_risk_register.ts +47 -3
  228. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  229. package/src/scripts/lint_roadmap_producers.ts +202 -0
  230. package/src/scripts/measure_rule_budget.ts +101 -5
  231. package/src/scripts/new_roadmap.ts +37 -0
  232. package/src/scripts/orchestration_record.ts +0 -5
  233. package/src/scripts/release.ts +14 -98
  234. package/src/scripts/release_drill.ts +267 -1
  235. package/src/scripts/release_publication.ts +51 -0
  236. package/src/scripts/report_enforcement_drift.ts +566 -0
  237. package/src/scripts/report_host_injection_effect.ts +3 -11
  238. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  239. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  240. package/src/scripts/schemas/command.schema.json +4 -0
  241. package/src/scripts/scope_growth.ts +219 -0
  242. package/src/scripts/trigger_eval_grandfather.json +0 -1
  243. package/src/scripts/ui_conformance_probe.ts +622 -0
  244. package/src/server/schemas/settings.ts +31 -7
  245. package/src/shared/missionExecution.ts +126 -0
  246. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `ui_conformance_probe` — behavioural evidence for a built UI surface.
4
+ *
5
+ * Phase 2 of `road-to-behaviour-evidence-over-pixels`. A Playwright consumer
6
+ * that compares a target against a reference on the five dimensions a resting
7
+ * frame cannot contain, and writes `ui-conformance.json`.
8
+ *
9
+ * NOT A GATE. It has no `lint_` / `check_` / `audit_` prefix, emits no
10
+ * `scanned:` line and carries no `src/config/gate-coverage.yml` row, following
11
+ * the `annotate_r1_outcomes.ts` precedent. It is mounted in shadow only: the
12
+ * design-pass hook reads its artefact and reports a verdict that never blocks.
13
+ * Making it a gate is a separate decision with its own six surfaces.
14
+ *
15
+ * THE STRUCTURE GATE IS PER NODE, AND THAT IS THE WHOLE DESIGN.
16
+ * Nodes are keyed by `data-probe-id`. A reference node with no match in the
17
+ * target produces a `structure` finding and is then simply not style-compared;
18
+ * matched nodes are compared normally. The rejected alternative was a global
19
+ * halt — stop the entire run on the first unmatched node. It reads stricter and
20
+ * is worse: a target missing one element would report one finding and hide
21
+ * every behavioural defect behind it, which is precisely the "looked clean
22
+ * because it stopped early" failure. What the gate actually prevents is
23
+ * comparing an unmatched node against whatever sits in its position, because
24
+ * those style findings would be about the wrong element. Per-node matching
25
+ * prevents that by construction, without hiding anything.
26
+ *
27
+ * NO AGGREGATE, ANYWHERE. Counters per dimension, never a single number
28
+ * standing for conformance. A dimension that could not run reports
29
+ * `not_applicable` with a reason and carries `findings: null` — never 0, which
30
+ * would read as "ran and found nothing".
31
+ *
32
+ * THE NOISE CONTROL. Computed-style comparison over every property fires on
33
+ * font fallbacks and token indirection nobody cares about, so the compared set
34
+ * is curated (`COMPARED_PROPERTIES`) to the properties a design token actually
35
+ * lands in. A deviation that is intended is recorded in a sibling
36
+ * `conformance.declared.json` and suppressed with its reason retained, so an
37
+ * approved change is not re-litigated on every run.
38
+ *
39
+ * Exit codes: 0 written (with or without findings) · 1 usage error.
40
+ */
41
+ import fs from 'node:fs';
42
+ import { createRequire } from 'node:module';
43
+ import path from 'node:path';
44
+ import { fileURLToPath, pathToFileURL } from 'node:url';
45
+
46
+ // Type-only, and that is load-bearing rather than stylistic: these are erased
47
+ // at compile time, so they add no second runtime path to a module that refuses
48
+ // to be loaded twice (see `loadPlaywright`).
49
+ import type * as PlaywrightTest from '@playwright/test';
50
+ type Page = PlaywrightTest.Page;
51
+
52
+ export const DIMENSIONS = [
53
+ 'structure',
54
+ 'computed_style',
55
+ 'interaction',
56
+ 'viewport_matrix',
57
+ 'media_emulation',
58
+ ] as const;
59
+ export type Dimension = (typeof DIMENSIONS)[number];
60
+
61
+ /**
62
+ * Curated, not exhaustive. Every entry is a property a design token resolves
63
+ * into or a layout property a breakpoint changes; nothing here fires on a font
64
+ * fallback or on inherited text metrics.
65
+ */
66
+ export const COMPARED_PROPERTIES = [
67
+ 'background-color',
68
+ 'color',
69
+ 'font-size',
70
+ 'line-height',
71
+ 'padding-top',
72
+ 'padding-left',
73
+ 'display',
74
+ 'flex-direction',
75
+ 'align-items',
76
+ 'gap',
77
+ 'outline-color',
78
+ 'outline-width',
79
+ 'transition-duration',
80
+ ] as const;
81
+
82
+ /** Widest first. A divergence is attributed to the first width it appears at. */
83
+ export const VIEWPORTS = [1440, 768, 375, 320] as const;
84
+
85
+ export const INTERACTION_STATES = ['hover', 'focus', 'active', 'keyboard'] as const;
86
+
87
+ export type StyleMap = Record<string, string>;
88
+
89
+ export interface NodeObservation {
90
+ probe_id: string;
91
+ base: StyleMap;
92
+ hover: StyleMap;
93
+ focus: StyleMap;
94
+ active: StyleMap;
95
+ keyboard: StyleMap;
96
+ reduced_motion: StyleMap;
97
+ viewports: Record<string, StyleMap>;
98
+ /**
99
+ * A serialization of what a click did to the document, or null where the
100
+ * node is not clickable. Comparing the EFFECT rather than the presence of a
101
+ * listener is the point: a registered handler that mutates nothing and a
102
+ * missing handler are the same defect to a user.
103
+ */
104
+ click_effect: string | null;
105
+ }
106
+
107
+ export interface Observation {
108
+ variant: string;
109
+ source: string;
110
+ nodes: NodeObservation[];
111
+ }
112
+
113
+ export interface Declaration {
114
+ probe_id: string;
115
+ dimension: Dimension;
116
+ property: string;
117
+ reason: string;
118
+ }
119
+
120
+ export interface Finding {
121
+ dimension: Dimension;
122
+ probe_id: string;
123
+ /** The interaction state, the viewport width, or null for a flat compare. */
124
+ state: string | null;
125
+ property: string | null;
126
+ expected: string;
127
+ observed: string;
128
+ detail: string;
129
+ /** Viewport dimension only: every width at which the divergence holds. */
130
+ widths?: string[];
131
+ }
132
+
133
+ export interface DimensionRow {
134
+ dimension: Dimension;
135
+ status: 'exercised' | 'not_applicable';
136
+ /** `null` on a not-applicable row. Never 0 — see the header. */
137
+ findings: number | null;
138
+ reason?: string;
139
+ }
140
+
141
+ export interface SuppressedDeviation {
142
+ probe_id: string;
143
+ property: string;
144
+ reason: string;
145
+ expected: string;
146
+ observed: string;
147
+ }
148
+
149
+ export interface ProbeArtefact {
150
+ schema: 'ui-conformance/v1';
151
+ generated_at: string;
152
+ host_class: 'A' | 'B' | 'C' | 'unknown';
153
+ target: string;
154
+ reference: string | null;
155
+ structure_gate: 'passed' | 'stopped';
156
+ unmatched_nodes: string[];
157
+ dimensions: DimensionRow[];
158
+ findings: Finding[];
159
+ declared_suppressed: SuppressedDeviation[];
160
+ }
161
+
162
+ // ---------------------------------------------------------------- pure core
163
+
164
+ export function readObservation(file: string): Observation {
165
+ return JSON.parse(fs.readFileSync(file, 'utf-8')) as Observation;
166
+ }
167
+
168
+ export function loadDeclarations(file: string): Declaration[] {
169
+ try {
170
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8')) as { declared?: Declaration[] };
171
+ return raw.declared ?? [];
172
+ } catch {
173
+ // A missing declaration file means nothing is declared, which is the
174
+ // strict reading. It is never an error: most targets declare nothing.
175
+ return [];
176
+ }
177
+ }
178
+
179
+ const isDeclared = (d: readonly Declaration[], probeId: string, dimension: Dimension, property: string): Declaration | undefined =>
180
+ d.find((x) => x.probe_id === probeId && x.dimension === dimension && x.property === property);
181
+
182
+ /** Did driving this state change anything, relative to the given baseline? */
183
+ function stateDelta(base: StyleMap, state: StyleMap): string[] {
184
+ return COMPARED_PROPERTIES.filter((p) => base[p] !== state[p]);
185
+ }
186
+
187
+ /**
188
+ * Which reading each state is measured AGAINST, and `active` is the interesting
189
+ * one. A pointer cannot press an element without hovering it first, so the
190
+ * `:active` reading always carries the `:hover` styles too. Measured against
191
+ * `base`, removing a single hover rule therefore surfaces as TWO findings —
192
+ * hover lost and active lost — which counts one defect twice and was exactly
193
+ * what the fixture caught on the first run. Measuring `active` against `hover`
194
+ * isolates what pressing actually contributes, and a design that darkens
195
+ * further on press still reports correctly.
196
+ */
197
+ const STATE_BASELINE: Record<(typeof INTERACTION_STATES)[number], 'base' | 'hover'> = {
198
+ hover: 'base',
199
+ active: 'hover',
200
+ focus: 'base',
201
+ keyboard: 'base',
202
+ };
203
+
204
+ export function evaluate(
205
+ reference: Observation,
206
+ target: Observation,
207
+ declarations: readonly Declaration[],
208
+ ): ProbeArtefact {
209
+ const targetById = new Map(target.nodes.map((n) => [n.probe_id, n]));
210
+ const findings: Finding[] = [];
211
+ const suppressed: SuppressedDeviation[] = [];
212
+ const unmatched: string[] = [];
213
+
214
+ for (const ref of reference.nodes) {
215
+ const got = targetById.get(ref.probe_id);
216
+ if (!got) {
217
+ unmatched.push(ref.probe_id);
218
+ findings.push({
219
+ dimension: 'structure',
220
+ probe_id: ref.probe_id,
221
+ state: null,
222
+ property: null,
223
+ expected: 'present',
224
+ observed: 'absent',
225
+ detail:
226
+ `no node carries data-probe-id="${ref.probe_id}" in the target. ` +
227
+ 'Style comparison is skipped for this node rather than run against ' +
228
+ 'whatever occupies its position.',
229
+ });
230
+ continue;
231
+ }
232
+
233
+ // --- computed_style, at the base viewport
234
+ for (const p of COMPARED_PROPERTIES) {
235
+ if (ref.base[p] === got.base[p]) continue;
236
+ const declared = isDeclared(declarations, ref.probe_id, 'computed_style', p);
237
+ if (declared) {
238
+ suppressed.push({
239
+ probe_id: ref.probe_id,
240
+ property: p,
241
+ reason: declared.reason,
242
+ expected: ref.base[p] ?? '',
243
+ observed: got.base[p] ?? '',
244
+ });
245
+ continue;
246
+ }
247
+ findings.push({
248
+ dimension: 'computed_style',
249
+ probe_id: ref.probe_id,
250
+ state: null,
251
+ property: p,
252
+ expected: ref.base[p] ?? '',
253
+ observed: got.base[p] ?? '',
254
+ detail: `resolved ${p} differs at the base viewport`,
255
+ });
256
+ }
257
+
258
+ // --- interaction: a state the reference reacts to and the target does not
259
+ for (const state of INTERACTION_STATES) {
260
+ const baseline = STATE_BASELINE[state];
261
+ const refDelta = stateDelta(baseline === 'hover' ? ref.hover : ref.base, ref[state]);
262
+ const gotDelta = stateDelta(baseline === 'hover' ? got.hover : got.base, got[state]);
263
+ const lost = refDelta.filter((p) => !gotDelta.includes(p));
264
+ if (!lost.length) continue;
265
+ if (isDeclared(declarations, ref.probe_id, 'interaction', lost[0]!)) continue;
266
+ findings.push({
267
+ dimension: 'interaction',
268
+ probe_id: ref.probe_id,
269
+ state,
270
+ property: lost[0]!,
271
+ expected: `${state} changes ${lost.join(', ')}`,
272
+ observed: gotDelta.length ? `${state} changes ${gotDelta.join(', ')}` : `${state} changes nothing`,
273
+ detail:
274
+ `the reference responds to ${state} on this node and the target does not. ` +
275
+ 'No resting capture at any viewport contains this difference.',
276
+ });
277
+ }
278
+
279
+ // --- interaction: a handler whose effect disappeared
280
+ if (ref.click_effect !== null && ref.click_effect !== got.click_effect) {
281
+ findings.push({
282
+ dimension: 'interaction',
283
+ probe_id: ref.probe_id,
284
+ state: 'click',
285
+ property: null,
286
+ expected: ref.click_effect,
287
+ observed: got.click_effect ?? 'no effect',
288
+ detail:
289
+ 'clicking this node changes the document in the reference and does not in ' +
290
+ 'the target. The element renders identically in both.',
291
+ });
292
+ }
293
+
294
+ // --- viewport_matrix: ONE finding per breakpoint row, not per property.
295
+ // A single removed media rule usually changes several properties at once
296
+ // (this fixture's changes both `flex-direction` and `align-items`), and
297
+ // reporting one finding each would count one defect several times and
298
+ // make "exactly the matching breakpoint row is red" untestable. The row
299
+ // is the unit a reader acts on: this node does not change at this width.
300
+ const perWidth = new Map<string, string[]>();
301
+ for (const w of VIEWPORTS) {
302
+ const key = String(w);
303
+ const refV = ref.viewports[key];
304
+ const gotV = got.viewports[key];
305
+ if (!refV || !gotV) continue;
306
+ const diverging = COMPARED_PROPERTIES.filter(
307
+ (p) =>
308
+ refV[p] !== gotV[p] &&
309
+ // A divergence already visible at the base viewport is a flat
310
+ // style difference, not a breakpoint one. Reporting it in both
311
+ // dimensions would count one defect twice.
312
+ ref.base[p] === got.base[p] &&
313
+ !isDeclared(declarations, ref.probe_id, 'viewport_matrix', p),
314
+ );
315
+ if (diverging.length) perWidth.set(key, diverging);
316
+ }
317
+ if (perWidth.size) {
318
+ const widths = [...perWidth.keys()];
319
+ const first = widths[0]!;
320
+ const properties = perWidth.get(first)!;
321
+ findings.push({
322
+ dimension: 'viewport_matrix',
323
+ probe_id: ref.probe_id,
324
+ state: first,
325
+ property: properties.join(', '),
326
+ expected: properties.map((p) => `${p}: ${ref.viewports[first]?.[p] ?? ''}`).join('; '),
327
+ observed: properties.map((p) => `${p}: ${got.viewports[first]?.[p] ?? ''}`).join('; '),
328
+ detail:
329
+ `this node's layout diverges at ${widths.join('px, ')}px while matching at the base ` +
330
+ 'viewport — a breakpoint the target does not cross.',
331
+ widths,
332
+ });
333
+ }
334
+
335
+ // --- media_emulation
336
+ const refMotion = stateDelta(ref.base, ref.reduced_motion);
337
+ const gotMotion = stateDelta(got.base, got.reduced_motion);
338
+ const lostMotion = refMotion.filter((p) => !gotMotion.includes(p));
339
+ if (lostMotion.length && !isDeclared(declarations, ref.probe_id, 'media_emulation', lostMotion[0]!)) {
340
+ findings.push({
341
+ dimension: 'media_emulation',
342
+ probe_id: ref.probe_id,
343
+ state: 'reduced-motion',
344
+ property: lostMotion[0]!,
345
+ expected: `reduced motion changes ${lostMotion.join(', ')}`,
346
+ observed: 'reduced motion changes nothing',
347
+ detail: 'the reference presents a reduced-motion alternative on this node and the target does not',
348
+ });
349
+ }
350
+ }
351
+
352
+ const dimensions: DimensionRow[] = DIMENSIONS.map((d) => ({
353
+ dimension: d,
354
+ status: 'exercised',
355
+ findings: findings.filter((f) => f.dimension === d).length,
356
+ }));
357
+
358
+ return {
359
+ schema: 'ui-conformance/v1',
360
+ generated_at: new Date().toISOString(),
361
+ host_class: resolveHostClass(),
362
+ target: target.source,
363
+ reference: reference.source,
364
+ structure_gate: unmatched.length ? 'stopped' : 'passed',
365
+ unmatched_nodes: unmatched,
366
+ dimensions,
367
+ findings,
368
+ declared_suppressed: suppressed,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * The artefact a host that cannot run the probe must still produce. Emitting
374
+ * nothing is indistinguishable from a clean run to everything downstream.
375
+ */
376
+ export function unavailableArtefact(target: string, reason: string): ProbeArtefact {
377
+ return {
378
+ schema: 'ui-conformance/v1',
379
+ generated_at: new Date().toISOString(),
380
+ host_class: 'unknown',
381
+ target,
382
+ reference: null,
383
+ structure_gate: 'stopped',
384
+ unmatched_nodes: [],
385
+ dimensions: DIMENSIONS.map((d) => ({
386
+ dimension: d,
387
+ status: 'not_applicable' as const,
388
+ findings: null,
389
+ reason: `${d} needs a browser engine this host did not provide: ${reason}`,
390
+ })),
391
+ findings: [],
392
+ declared_suppressed: [],
393
+ };
394
+ }
395
+
396
+ export function resolveHostClass(): ProbeArtefact['host_class'] {
397
+ if (!chromiumAvailable()) return 'unknown';
398
+ return process.env['CI'] ? 'C' : 'A';
399
+ }
400
+
401
+ // ------------------------------------------------------------ browser layer
402
+
403
+ /**
404
+ * ONE loader, memoized, and that is not a style preference. Playwright throws
405
+ * `Requiring @playwright/test second time` when the same process reaches it
406
+ * through both CJS `require` and ESM `import`, so the availability probe and
407
+ * the capture must share a single path. `createRequire` rather than a bare
408
+ * `require`, because this file is ESM and a bare `require` is undefined
409
+ * under tsx.
410
+ */
411
+ let playwrightModule: typeof PlaywrightTest | null | undefined;
412
+ function loadPlaywright(): typeof PlaywrightTest | null {
413
+ if (playwrightModule !== undefined) return playwrightModule;
414
+ try {
415
+ const req = createRequire(import.meta.url);
416
+ playwrightModule = req('@playwright/test') as typeof PlaywrightTest;
417
+ } catch {
418
+ playwrightModule = null;
419
+ }
420
+ return playwrightModule;
421
+ }
422
+
423
+ export function chromiumAvailable(): boolean {
424
+ try {
425
+ // Resolving the module is not enough — the binaries are a separate
426
+ // install, and this package declares the runner without installing them.
427
+ const pw = loadPlaywright();
428
+ const exe = pw?.chromium?.executablePath?.();
429
+ return Boolean(exe && fs.existsSync(exe));
430
+ } catch {
431
+ return false;
432
+ }
433
+ }
434
+
435
+ const READ_STYLES = (el: Element, props: readonly string[]): Record<string, string> => {
436
+ const cs = getComputedStyle(el);
437
+ const out: Record<string, string> = {};
438
+ for (const p of props) out[p] = cs.getPropertyValue(p).trim();
439
+ return out;
440
+ };
441
+
442
+ export async function captureVariant(indexHtml: string): Promise<Observation> {
443
+ const pw = loadPlaywright();
444
+ if (!pw) throw new Error('captureVariant: @playwright/test is not resolvable on this host');
445
+ const browser = await pw.chromium.launch();
446
+ const url = pathToFileURL(indexHtml).href;
447
+ const props = [...COMPARED_PROPERTIES];
448
+ const nodes: NodeObservation[] = [];
449
+
450
+ try {
451
+ const page = await browser.newPage({ viewport: { width: VIEWPORTS[0], height: 900 } });
452
+ await page.goto(url);
453
+ const ids = await page.$$eval('[data-probe-id]', (els) =>
454
+ els.map((e) => e.getAttribute('data-probe-id') ?? ''),
455
+ );
456
+
457
+ for (const id of ids.filter(Boolean).sort()) {
458
+ const sel = `[data-probe-id="${id}"]`;
459
+ await page.setViewportSize({ width: VIEWPORTS[0], height: 900 });
460
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
461
+ await page.reload();
462
+
463
+ const base = await page.$eval(sel, READ_STYLES, props);
464
+
465
+ await page.hover(sel).catch(() => undefined);
466
+ const hover = await page.$eval(sel, READ_STYLES, props);
467
+ await page.mouse.move(0, 0);
468
+
469
+ await page.$eval(sel, (el) => (el as HTMLElement).focus?.());
470
+ const focus = await page.$eval(sel, READ_STYLES, props);
471
+ await page.$eval(sel, (el) => (el as HTMLElement).blur?.());
472
+
473
+ const box = await page.$eval(sel, (el) => {
474
+ const r = el.getBoundingClientRect();
475
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
476
+ });
477
+ await page.mouse.move(box.x, box.y);
478
+ await page.mouse.down();
479
+ const active = await page.$eval(sel, READ_STYLES, props);
480
+ await page.mouse.up();
481
+ await page.reload();
482
+
483
+ const keyboard = await captureKeyboardState(page, id, sel, props);
484
+
485
+ await page.emulateMedia({ reducedMotion: 'reduce' });
486
+ const reducedMotion = await page.$eval(sel, READ_STYLES, props);
487
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
488
+
489
+ const viewports: Record<string, StyleMap> = {};
490
+ for (const w of VIEWPORTS) {
491
+ await page.setViewportSize({ width: w, height: 900 });
492
+ viewports[String(w)] = await page.$eval(sel, READ_STYLES, props);
493
+ }
494
+ await page.setViewportSize({ width: VIEWPORTS[0], height: 900 });
495
+
496
+ const clickEffect = await captureClickEffect(page, sel);
497
+
498
+ nodes.push({
499
+ probe_id: id,
500
+ base,
501
+ hover,
502
+ focus,
503
+ active,
504
+ keyboard,
505
+ reduced_motion: reducedMotion,
506
+ viewports,
507
+ click_effect: clickEffect,
508
+ });
509
+ }
510
+ await page.close();
511
+ } finally {
512
+ await browser.close();
513
+ }
514
+
515
+ return { variant: path.basename(path.dirname(indexHtml)), source: indexHtml, nodes };
516
+ }
517
+
518
+ /**
519
+ * Keyboard focus, reached by actually pressing Tab. `element.focus()` does not
520
+ * set `:focus-visible` in Chromium when the last input was not a keyboard, so a
521
+ * programmatic focus would silently report the focus ring as absent.
522
+ */
523
+ async function captureKeyboardState(
524
+ page: Page,
525
+ id: string,
526
+ sel: string,
527
+ props: readonly string[],
528
+ ): Promise<StyleMap> {
529
+ await page.reload();
530
+ for (let i = 0; i < 12; i += 1) {
531
+ await page.keyboard.press('Tab');
532
+ const hit = await page.evaluate(
533
+ (probeId) => document.activeElement?.getAttribute('data-probe-id') === probeId,
534
+ id,
535
+ );
536
+ if (hit) break;
537
+ }
538
+ const styles = await page.$eval(sel, READ_STYLES, props);
539
+ await page.reload();
540
+ return styles;
541
+ }
542
+
543
+ /**
544
+ * Click, then serialize what CHANGED about the document — a delta, never an
545
+ * absolute snapshot.
546
+ *
547
+ * The absolute form was the first implementation and it was wrong in a way the
548
+ * fixture caught: the snapshot listed every `data-probe-id` on the page, so
549
+ * renaming ONE unrelated element changed the recorded click effect of EVERY
550
+ * node, and the renamed variant produced a spurious interaction finding on a
551
+ * button whose handler was untouched. A delta is scoped to the nodes the click
552
+ * actually moved, so an unrelated rename is invisible here and stays the
553
+ * structure dimension's business.
554
+ *
555
+ * A node whose click changes nothing reports null — the correct reading for a
556
+ * paragraph, and the defect reading for a button.
557
+ */
558
+ async function captureClickEffect(page: Page, sel: string): Promise<string | null> {
559
+ const snapshot = (): Promise<Record<string, string>> =>
560
+ page.evaluate(() =>
561
+ Object.fromEntries(
562
+ [...document.querySelectorAll('[data-probe-id]')].map((e) => [
563
+ e.getAttribute('data-probe-id') ?? '',
564
+ `${e.hasAttribute('hidden') ? 'hidden' : 'shown'}/${e.getAttribute('aria-expanded') ?? '-'}`,
565
+ ]),
566
+ ),
567
+ );
568
+ await page.reload();
569
+ const before = await snapshot();
570
+ await page.click(sel, { timeout: 2000 }).catch(() => undefined);
571
+ const after = await snapshot();
572
+ await page.reload();
573
+
574
+ const changed = Object.keys({ ...before, ...after })
575
+ .filter((id) => before[id] !== after[id])
576
+ .sort()
577
+ .map((id) => `${id}: ${before[id] ?? 'absent'} -> ${after[id] ?? 'absent'}`);
578
+ return changed.length ? changed.join('; ') : null;
579
+ }
580
+
581
+ // -------------------------------------------------------------------- CLI
582
+
583
+ const arg = (name: string): string | undefined => {
584
+ const i = process.argv.indexOf(`--${name}`);
585
+ return i >= 0 ? process.argv[i + 1] : undefined;
586
+ };
587
+
588
+ async function main(): Promise<number> {
589
+ const target = arg('target');
590
+ if (!target) {
591
+ process.stderr.write('usage: ui_conformance_probe --target <index.html> [--reference <index.html>] [--out <file>]\n');
592
+ return 1;
593
+ }
594
+ const out = arg('out') ?? path.join('agents', 'runtime', 'state', 'ui-conformance.json');
595
+ const referencePath = arg('reference');
596
+
597
+ let artefact: ProbeArtefact;
598
+ if (!chromiumAvailable()) {
599
+ artefact = unavailableArtefact(target, 'no Chromium binary resolved from @playwright/test');
600
+ } else if (!referencePath) {
601
+ artefact = unavailableArtefact(target, 'no --reference given; a comparison needs an intended implementation');
602
+ for (const row of artefact.dimensions) {
603
+ row.reason = `${row.dimension} needs a --reference to compare against; none was given`;
604
+ }
605
+ } else {
606
+ artefact = evaluate(await captureVariant(referencePath), await captureVariant(target), loadDeclarations(path.join(path.dirname(target), 'conformance.declared.json')));
607
+ }
608
+
609
+ fs.mkdirSync(path.dirname(out), { recursive: true });
610
+ fs.writeFileSync(out, `${JSON.stringify(artefact, null, 2)}\n`);
611
+ for (const row of artefact.dimensions) {
612
+ const count = row.findings === null ? `not-applicable — ${row.reason ?? ''}` : String(row.findings);
613
+ process.stdout.write(`${row.dimension}: ${count}\n`);
614
+ }
615
+ process.stdout.write(`structure_gate: ${artefact.structure_gate}\n`);
616
+ return 0;
617
+ }
618
+
619
+ export const _HERE = fileURLToPath(import.meta.url);
620
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
621
+ main().then((c) => process.exit(c));
622
+ }