@event4u/agent-config 16.1.0 → 16.2.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 (217) 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 +237 -248
  5. package/dist/agent-src/commands/roadmap/process-full.md +1 -1
  6. package/dist/agent-src/commands/work.md +4 -0
  7. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
  8. package/dist/agent-src/rules/ui-audit-gate.md +1 -2
  9. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
  10. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
  11. package/dist/agent-src/skills/design-review/SKILL.md +30 -4
  12. package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
  13. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +16 -1
  14. package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
  15. package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
  16. package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
  17. package/dist/agent-src/skills/ui-component-architect/SKILL.md +23 -0
  18. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  19. package/dist/agent-src/templates/roadmaps.md +30 -0
  20. package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  21. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
  22. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  23. package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  24. package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
  25. package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  26. package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  27. package/dist/cli/python/knowledge_ingest.js +1 -1
  28. package/dist/cli/python/workspace_drive.js +1 -1
  29. package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-4XFP2Y3E.js} +1 -1
  30. package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-7XGBX6ZH.js} +471 -387
  31. package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-FP36SWJD.js} +432 -144
  32. package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-IGD6ERX6.js} +31 -0
  33. package/dist/cli-delegate/{chunk-FXYUYU4N.js → chunk-JNMVLWLR.js} +42 -1
  34. package/dist/cli-delegate/{chunk-QJWAUMJW.js → chunk-YJ72UIAK.js} +730 -15
  35. package/dist/cli-delegate/cmd_conformance.js +902 -782
  36. package/dist/cli-delegate/cmd_doctor.js +2 -2
  37. package/dist/cli-delegate/cmd_export.js +2 -2
  38. package/dist/cli-delegate/cmd_handoff.js +4 -4
  39. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  43. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +2 -2
  46. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  47. package/dist/cli-delegate/cmd_validate.js +2 -2
  48. package/dist/discovery/deprecation-report.md +1 -1
  49. package/dist/discovery/discovery-manifest.json +10 -10
  50. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  51. package/dist/discovery/discovery-manifest.summary.md +1 -1
  52. package/dist/discovery/dormancy-report.md +1 -1
  53. package/dist/discovery/orphan-report.md +1 -1
  54. package/dist/discovery/packs.json +2 -2
  55. package/dist/discovery/trust-report.md +1 -1
  56. package/dist/discovery/workspaces.json +2 -2
  57. package/dist/hooks/dispatch.js +6666 -2581
  58. package/dist/hooks/dispatch.sha256 +1 -0
  59. package/dist/install/install.mjs +848 -733
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +2747 -1510
  63. package/dist/server/schemas/settings.js +23 -0
  64. package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
  65. package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
  66. package/dist/ui/index.html +2 -2
  67. package/docs/contracts/settings-classes.md +6 -4
  68. package/package.json +4 -4
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  73. package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
  74. package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  75. package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  76. package/src/config/agent-settings.template.yml +29 -0
  77. package/src/config/ci-local-parity.yml +18 -0
  78. package/src/config/continuity-surface.json +2 -2
  79. package/src/config/gate-coverage.yml +234 -3
  80. package/src/config/gate-violation-baselines.json +7 -3
  81. package/src/config/hook-latency-budget.json +21 -0
  82. package/src/config/hook-token-budget.json +2 -2
  83. package/src/config/metric-registry.yml +114 -0
  84. package/src/config/placeholder-drift-budget.json +10 -0
  85. package/src/config/routing-coverage-seed.json +18 -4
  86. package/src/scripts/_cli/cmd_doctor.ts +5 -19
  87. package/src/scripts/_cli/doctor_execution.ts +100 -17
  88. package/src/scripts/_cli/doctor_network_posture.ts +226 -0
  89. package/src/scripts/_lib/body_portable.ts +102 -0
  90. package/src/scripts/_lib/cc_transcript.ts +132 -1
  91. package/src/scripts/_lib/concern_sla_window.ts +200 -0
  92. package/src/scripts/_lib/forge_protection.ts +114 -0
  93. package/src/scripts/_lib/forge_reader.ts +558 -0
  94. package/src/scripts/_lib/host_env_write.ts +111 -0
  95. package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
  96. package/src/scripts/_lib/obligations.ts +119 -8
  97. package/src/scripts/_lib/probe_inputs.ts +271 -0
  98. package/src/scripts/_lib/read_surface_scan.ts +322 -0
  99. package/src/scripts/_lib/release_highlights.ts +2 -1
  100. package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
  101. package/src/scripts/_lib/shell_write_shapes.ts +108 -0
  102. package/src/scripts/_lib/structural_hiding.ts +622 -0
  103. package/src/scripts/_lib/turn_end_refusals.ts +525 -4
  104. package/src/scripts/_lib/turn_end_transcript.ts +216 -0
  105. package/src/scripts/_lib/verification_command.ts +324 -0
  106. package/src/scripts/_lib/verification_evidence.ts +647 -0
  107. package/src/scripts/_lib/verify_clause.ts +280 -0
  108. package/src/scripts/ai_team/team_dispatch.ts +4 -0
  109. package/src/scripts/before_complete_hook.ts +340 -23
  110. package/src/scripts/bench_hook_latency.ts +412 -5
  111. package/src/scripts/cache_realization_report.ts +61 -1
  112. package/src/scripts/check_argument_hint.ts +287 -0
  113. package/src/scripts/check_claims.ts +125 -1
  114. package/src/scripts/check_enforcement_coverage.ts +38 -9
  115. package/src/scripts/check_enforcement_matrix.ts +245 -3
  116. package/src/scripts/check_finding_dispositions.ts +22 -3
  117. package/src/scripts/check_generator_sync.ts +71 -0
  118. package/src/scripts/check_host_docs_digest.ts +786 -0
  119. package/src/scripts/check_host_format_column.ts +559 -0
  120. package/src/scripts/check_input_references.ts +228 -0
  121. package/src/scripts/check_inputs_parity.ts +235 -0
  122. package/src/scripts/check_kernel_edit_ratified.ts +47 -5
  123. package/src/scripts/check_kill_switch_table.ts +294 -0
  124. package/src/scripts/check_placeholder_drift.ts +221 -0
  125. package/src/scripts/check_read_surface_coverage.ts +515 -0
  126. package/src/scripts/check_release_holds.ts +2 -1
  127. package/src/scripts/check_routing_coverage.ts +378 -11
  128. package/src/scripts/check_static_parity.ts +17 -0
  129. package/src/scripts/ci_settle.ts +145 -19
  130. package/src/scripts/closure_scan.ts +115 -1
  131. package/src/scripts/compile_hook_manifest.ts +60 -16
  132. package/src/scripts/external_sources_denylist.json +29 -4
  133. package/src/scripts/hook_manifest.json +1 -1
  134. package/src/scripts/hook_manifest.yaml +42 -5
  135. package/src/scripts/hooks/block_config_weakening.ts +229 -4
  136. package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
  137. package/src/scripts/hooks/block_plumbing_writes.ts +314 -0
  138. package/src/scripts/hooks/bundle_integrity.ts +291 -0
  139. package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
  140. package/src/scripts/hooks/code_graph_context_hook.ts +1 -2
  141. package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
  142. package/src/scripts/hooks/concern_registry.ts +2 -0
  143. package/src/scripts/hooks/concern_timings.ts +94 -0
  144. package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
  145. package/src/scripts/hooks/design_pass_hook.ts +100 -9
  146. package/src/scripts/hooks/design_slop_hook.ts +1 -2
  147. package/src/scripts/hooks/dispatch_hook.ts +25 -19
  148. package/src/scripts/hooks/edit_shape_hook.ts +1 -2
  149. package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
  150. package/src/scripts/hooks/evidence_independence.ts +1 -2
  151. package/src/scripts/hooks/exit_codes.ts +128 -0
  152. package/src/scripts/hooks/host_lowering.json +1 -0
  153. package/src/scripts/hooks/host_lowering.ts +128 -3
  154. package/src/scripts/hooks/host_lowering.yaml +262 -46
  155. package/src/scripts/hooks/host_semantics.ts +30 -2
  156. package/src/scripts/hooks/injection_budget.ts +9 -2
  157. package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
  158. package/src/scripts/hooks/journal_record_hook.ts +1 -2
  159. package/src/scripts/hooks/memory_recall_hook.ts +1 -2
  160. package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
  161. package/src/scripts/hooks/one_question_per_ask_hook.ts +25 -4
  162. package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
  163. package/src/scripts/hooks/permission_gate.ts +1 -2
  164. package/src/scripts/hooks/push_settle_hook.ts +1 -3
  165. package/src/scripts/hooks/replay_hook.ts +2 -1
  166. package/src/scripts/hooks/reread_guard_hook.ts +1 -2
  167. package/src/scripts/hooks/review_baseline_hook.ts +1 -2
  168. package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
  169. package/src/scripts/hooks/rule_inject_hook.ts +8 -5
  170. package/src/scripts/hooks/run_continuation_hook.ts +13 -15
  171. package/src/scripts/hooks/skill_route_hook.ts +1 -2
  172. package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
  173. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
  174. package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
  175. package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
  176. package/src/scripts/hooks/table_fingerprint.ts +49 -0
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
  178. package/src/scripts/hooks/telemetry_usage_hook.ts +1 -2
  179. package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +337 -324
  181. package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
  182. package/src/scripts/install.ts +3 -4
  183. package/src/scripts/lint_deny_text.ts +419 -0
  184. package/src/scripts/lint_exit_codes.ts +262 -0
  185. package/src/scripts/lint_hook_manifest.ts +55 -0
  186. package/src/scripts/lint_roadmap_blockers.ts +78 -12
  187. package/src/scripts/lint_skill_descriptions.ts +1 -1
  188. package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
  189. package/src/scripts/mcp_server/prompts.ts +142 -1
  190. package/src/scripts/measure_skill_ranker_baseline.ts +305 -51
  191. package/src/scripts/measure_turn_end_gate.ts +193 -0
  192. package/src/scripts/memory_lookup.ts +1 -1
  193. package/src/scripts/prepack-check.mjs +53 -0
  194. package/src/scripts/release_publication.ts +13 -1
  195. package/src/scripts/report_invocation_surface.ts +383 -0
  196. package/src/scripts/report_obligation_carriers.ts +699 -0
  197. package/src/scripts/report_obligation_writer_split.ts +236 -0
  198. package/src/scripts/report_skill_menu_census.ts +208 -0
  199. package/src/scripts/report_standing_payload_by_host.ts +1 -1
  200. package/src/scripts/roadmap_verify_share.ts +213 -0
  201. package/src/scripts/schemas/command.schema.json +44 -0
  202. package/src/scripts/schemas/review-findings.schema.json +4 -0
  203. package/src/scripts/schemas/skill.schema.json +44 -0
  204. package/src/scripts/self_review_gate.ts +359 -10
  205. package/src/scripts/semantic_parity_census.ts +314 -0
  206. package/src/scripts/session_eol_report.ts +54 -1
  207. package/src/scripts/sweep_dead_scan_roots.ts +1 -1
  208. package/src/scripts/trigger_eval_grandfather.json +0 -2
  209. package/src/scripts/trigger_eval_rotation.ts +122 -27
  210. package/src/scripts/ui_conformance_probe.ts +156 -4
  211. package/src/scripts/update_prices.ts +85 -3
  212. package/src/scripts/write_bundle_digest.mjs +74 -0
  213. package/src/server/schemas/settings.ts +31 -0
  214. package/src/templates/consumer-settings/ONBOARDING.md +1 -0
  215. package/dist/ui/assets/index-DH1xc62v.js +0 -29
  216. package/dist/ui/assets/index-jSfMW8_t.css +0 -1
  217. /package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-7RQP4UDH.js} +0 -0
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Structural-hiding pre-pass for fetched markup
3
+ * (road-to-a-sanitize-list-that-is-generated, Phase 3).
4
+ *
5
+ * WHAT THIS IS FOR
6
+ * ----------------
7
+ * `retrieval_sanitize.sanitize_text` is a CODEPOINT floor: it removes bidi
8
+ * controls, zero-width characters, the Unicode Tag block, control-char noise
9
+ * and invisible fillers. Every one of those is a property of a character.
10
+ *
11
+ * Fetched markup carries a channel class that no codepoint predicate can see.
12
+ * Text that is perfectly ordinary at the codepoint layer becomes invisible to a
13
+ * human reader — and stays fully visible to a model reading the source — when
14
+ * the MARKUP hides it: an HTML comment, a `<template>` block, a
15
+ * `style="display:none"` span. This module is that pre-pass. It runs AHEAD of
16
+ * the codepoint floor, on the model-facing copy of fetched markup only, and it
17
+ * never touches a repository file.
18
+ *
19
+ * WHY A TOKENIZER AND NOT A MATCHER
20
+ * ---------------------------------
21
+ * A first version of this module was BUILT and REFUSED by an independent
22
+ * two-provider council review on 2026-09-29. It scanned forward with regexes
23
+ * and `indexOf`, and a scanner cannot answer the only question that matters
24
+ * here — "is this `<` a tag?" — because the answer depends on the state the
25
+ * parser is in. Four shapes the review named, each of which defeats a scanner
26
+ * and each of which has a fixture in `tests/scripts/structural_hiding.test.ts`:
27
+ *
28
+ * 1. a comment containing a same-name opening or closing tag
29
+ * (`<!-- </div> -->` inside a hidden `<div>` closed it early);
30
+ * 2. tag-like text inside `<script>` / `<style>`, which are RAWTEXT elements
31
+ * where `<div>` is character data and not a tag at all;
32
+ * 3. nested same-name hidden and visible elements, which need a real open-
33
+ * element stack rather than a forward re-scan;
34
+ * 4. unterminated markup — an unclosed comment, tag or element at EOF.
35
+ *
36
+ * So this version tokenizes first ({@link tokenize}) and walks the token
37
+ * stream against an explicit open-element stack ({@link strip_structural_hiding}).
38
+ * A stripper that can be confused by the content it is defending against is
39
+ * worse than none, because it reports success.
40
+ *
41
+ * REMOVAL POLICY — REMOVAL IS NEVER SILENT
42
+ * ----------------------------------------
43
+ * The same review named silent irreversible deletion as a defect in its own
44
+ * right: a caller debugging a corrupted result has nothing to go on. Four
45
+ * properties, all asserted by the fixture suite:
46
+ *
47
+ * - **A sentinel is left in place.** Every removal emits
48
+ * `[removed: structural-hiding/<channel-id>]` where the content was.
49
+ * - **The sentinel vocabulary is closed.** `<channel-id>` always comes from
50
+ * {@link STRUCTURAL_HIDING_CHANNELS}; no byte of the input is ever echoed
51
+ * into it, so the marker cannot itself carry a payload.
52
+ * - **Every removal is reported structurally.** {@link StructuralStripResult}
53
+ * carries one {@link StructuralRemoval} per removed span with its offsets
54
+ * into the INPUT, so the caller — which still holds the input — can recover
55
+ * exactly what was dropped. The pre-pass is pure and writes nothing.
56
+ * - **Unterminated markup fails closed, and says so.** An unresolvable tail
57
+ * is dropped rather than emitted, and `truncated` is set.
58
+ *
59
+ * loss_class: recoverable-lossy
60
+ * loss_recovery: StructuralStripResult.removals — one {channel, start, end}
61
+ * record per removed span, whose offsets index the CALLER'S OWN input
62
+ * string. The pre-pass is pure and stores nothing, so the caller already
63
+ * holds the only copy it needs; the locator is the range, not a path.
64
+ *
65
+ * The class is `recoverable-lossy` and not `ephemeral-lossy` because that is
66
+ * the whole point of the removal policy: a reviewer refused the previous
67
+ * version partly for deleting content with no trace, and a caller that cannot
68
+ * reconstruct what went cannot debug a corrupted result. `ephemeral-lossy`
69
+ * would be the honest class for a version that returned only the string.
70
+ *
71
+ * WHAT IT DOES NOT CATCH — see {@link STRUCTURAL_HIDING_GAPS}
72
+ * ----------------------------------------------------------
73
+ * The register below is part of this module's contract rather than a footnote.
74
+ * An inline-style substring matcher cited as "the taxonomy" is how a detector
75
+ * like this becomes a false green, so every surface that claims this coverage
76
+ * cites the register alongside the claim.
77
+ *
78
+ * NO RECALL OR COVERAGE FIGURE IS PUBLISHED FOR THIS LAYER, ANYWHERE.
79
+ * A rate requires a frozen corpus of hiding techniques and none exists in this
80
+ * tree. The covered-channel list and the gap register are the honest statement
81
+ * of what this does; a number would be an invented denominator. The absence is
82
+ * machine-checked — `check_read_surface_coverage` refuses a percentage token in
83
+ * this file, and refuses any surface that claims this coverage without citing
84
+ * the gap register.
85
+ *
86
+ * WHAT THE MISSING CORPUS MEANS, since "none exists" reads two ways. It is not
87
+ * "we could build one and have not got to it". The space of hiding techniques
88
+ * is adversarial and not enumerable: a corpus would fix a denominator that the
89
+ * next technique invalidates, and the number computed against it would outlive
90
+ * its own validity. A corpus is a research artefact, not a module dependency,
91
+ * and none is in scope for this layer. What the module can honestly state is
92
+ * the list of channels it declares, which is enumerable, and what it knows it
93
+ * misses, which is the register — so that is what it states.
94
+ */
95
+
96
+ /**
97
+ * One structural-hiding channel this pre-pass removes.
98
+ *
99
+ * Exported so a caller can print exactly what it covers rather than
100
+ * paraphrasing it, and so the fixture suite can assert one case per channel
101
+ * without a second hand-written list to drift from.
102
+ */
103
+ export interface HidingChannel {
104
+ /** Stable id, used by the fixture suite and by the removal sentinel. */
105
+ readonly id: string;
106
+ /** What the channel looks like in markup. */
107
+ readonly detail: string;
108
+ }
109
+
110
+ /** The channels removed. Anything not here is not removed by this module. */
111
+ export const STRUCTURAL_HIDING_CHANNELS: readonly HidingChannel[] = [
112
+ { id: 'html-comment', detail: '<!-- … --> — invisible to a reader, plain text to a parser' },
113
+ { id: 'template-element', detail: '<template>…</template> — inert content the browser never renders' },
114
+ { id: 'inline-display-none', detail: 'style="display:none"' },
115
+ { id: 'inline-visibility-hidden', detail: 'style="visibility:hidden"' },
116
+ { id: 'inline-opacity-zero', detail: 'style="opacity:0"' },
117
+ { id: 'inline-font-size-zero', detail: 'style="font-size:0"' },
118
+ { id: 'inline-zero-box', detail: 'style="width:0" / "height:0"' },
119
+ { id: 'hidden-attribute', detail: 'the HTML5 boolean `hidden` attribute' },
120
+ ];
121
+
122
+ /**
123
+ * Deliberately NOT channels, with the reason.
124
+ *
125
+ * This list exists because its first entry was a shipped defect. A reviewer
126
+ * asking "why is `aria-hidden` missing?" must find the answer here rather than
127
+ * read the omission as an oversight and restore it.
128
+ */
129
+ export const STRUCTURAL_HIDING_NON_CHANNELS: readonly HidingChannel[] = [
130
+ {
131
+ id: 'aria-hidden',
132
+ detail:
133
+ 'aria-hidden="true" is NOT a hiding channel. Its content is VISIBLE to sighted ' +
134
+ 'readers — it is a screen-reader affordance that removes an element from the ' +
135
+ 'accessibility tree, not from the page. Stripping it deletes legitimate visible ' +
136
+ 'text. It was on the channel list of the version this module replaces, and the ' +
137
+ 'per-channel fixtures did not catch it because a fixture written from the same ' +
138
+ 'wrong list agrees with it.',
139
+ },
140
+ ];
141
+
142
+ /**
143
+ * What this pre-pass does NOT catch.
144
+ *
145
+ * Restricted to uncovered classes on purpose: a register printed beside a
146
+ * working detector invites the reading that the listed gaps are the only gaps.
147
+ * Every entry here is a hiding technique that reaches the same end — content a
148
+ * human never sees and a model does — through a route no per-element test over
149
+ * an inline `style` attribute can reach.
150
+ */
151
+ export const STRUCTURAL_HIDING_GAPS: readonly string[] = [
152
+ 'stylesheet-driven hiding — a rule in a <style> block or an external sheet, where the element carries only a class',
153
+ 'class-driven hiding — `class="sr-only"`, `class="hidden"`, any framework utility whose meaning lives elsewhere',
154
+ 'off-screen positioning — `position:absolute; left:-9999px`, `transform: translate(-100%, 0)`, `clip-path`',
155
+ 'background-coloured text — foreground equal to background, which needs a computed style to detect',
156
+ 'fragmentation — a payload split across elements or interleaved with visible text, invisible to any per-element test',
157
+ 'homoglyph and confusable obfuscation — a codepoint-layer channel, reported (never rewritten) by `scan_encoding_findings`',
158
+ 'image-borne text — an instruction rendered into a picture, unreachable without OCR',
159
+ 'script-constructed content — text assembled at runtime by JavaScript this pre-pass never executes',
160
+ 'tiny-but-nonzero sizing — `font-size:1px`, `opacity:0.01`; only exact-zero forms are matched, because a threshold would need a corpus to set',
161
+ ];
162
+
163
+ /* ------------------------------------------------------------------ *
164
+ * Tokenizer
165
+ * ------------------------------------------------------------------ */
166
+
167
+ /** A token kind. `comment` covers bogus comments and doctypes too. */
168
+ export type TokenKind = 'text' | 'comment' | 'start' | 'end';
169
+
170
+ /** One token, always carrying its exact span in the input. */
171
+ export interface Token {
172
+ readonly kind: TokenKind;
173
+ /** Offset of the token's first character in the input. */
174
+ readonly start: number;
175
+ /** Offset just past the token's last character. */
176
+ readonly end: number;
177
+ /** Lower-cased tag name — `start` and `end` tokens only. */
178
+ readonly name?: string;
179
+ /** Parsed attributes, lower-cased names — `start` tokens only. */
180
+ readonly attrs?: ReadonlyMap<string, string>;
181
+ /** True for `<br/>` and for void elements — `start` tokens only. */
182
+ readonly selfClosing?: boolean;
183
+ /** True when the token ran to EOF without its terminator. */
184
+ readonly unterminated?: boolean;
185
+ }
186
+
187
+ /** Result of one tokenizer pass. */
188
+ export interface TokenizeResult {
189
+ readonly tokens: readonly Token[];
190
+ /** True when any token ran to EOF without its terminator. */
191
+ readonly unterminated: boolean;
192
+ }
193
+
194
+ /** Void elements: never carry a closing tag, so they never hide a subtree. */
195
+ const VOID_TAGS: ReadonlySet<string> = new Set([
196
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
197
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
198
+ ]);
199
+
200
+ /**
201
+ * Elements whose content is character data, not markup.
202
+ *
203
+ * This is the state a scanner does not have, and finding #2 of the review is
204
+ * exactly its absence: inside `<script>`, the text `</div>` is three characters
205
+ * and a slash, not an end tag.
206
+ */
207
+ const RAWTEXT_TAGS: ReadonlySet<string> = new Set(['script', 'style', 'textarea', 'title']);
208
+
209
+ const ASCII_ALPHA = /[A-Za-z]/;
210
+ const TAG_NAME_CHAR = /[A-Za-z0-9:_.-]/;
211
+ const WHITESPACE = /[ \t\n\f\r]/;
212
+
213
+ /** Read a tag name starting at `i`; returns the name and the next offset. */
214
+ function readTagName(html: string, i: number): { name: string; next: number } {
215
+ let j = i;
216
+ while (j < html.length && TAG_NAME_CHAR.test(html[j] as string)) j += 1;
217
+ return { name: html.slice(i, j).toLowerCase(), next: j };
218
+ }
219
+
220
+ /**
221
+ * Parse a start tag's attribute list, beginning just past the tag name.
222
+ *
223
+ * Quoting is honoured, so a `>` inside `style="a>b"` does not end the tag —
224
+ * the failure that makes a naive `indexOf('>')` strip the wrong span. Returns
225
+ * `null` when the tag never closes (unterminated markup at EOF).
226
+ */
227
+ function readAttrs(
228
+ html: string,
229
+ from: number,
230
+ ): { attrs: Map<string, string>; selfClosing: boolean; end: number } | null {
231
+ const attrs = new Map<string, string>();
232
+ let i = from;
233
+ let selfClosing = false;
234
+
235
+ for (;;) {
236
+ while (i < html.length && WHITESPACE.test(html[i] as string)) i += 1;
237
+ if (i >= html.length) return null; // EOF inside the tag
238
+ const ch = html[i] as string;
239
+
240
+ if (ch === '>') return { attrs, selfClosing, end: i + 1 };
241
+ if (ch === '/') {
242
+ selfClosing = true;
243
+ i += 1;
244
+ continue;
245
+ }
246
+
247
+ // Attribute name.
248
+ const nameStart = i;
249
+ while (
250
+ i < html.length &&
251
+ !WHITESPACE.test(html[i] as string) &&
252
+ html[i] !== '=' &&
253
+ html[i] !== '>' &&
254
+ html[i] !== '/'
255
+ ) {
256
+ i += 1;
257
+ }
258
+ if (i === nameStart) {
259
+ // A character that starts no name and ends no tag — consume it so
260
+ // the loop always advances. Never spin on malformed input.
261
+ i += 1;
262
+ continue;
263
+ }
264
+ const name = html.slice(nameStart, i).toLowerCase();
265
+
266
+ while (i < html.length && WHITESPACE.test(html[i] as string)) i += 1;
267
+ if (html[i] !== '=') {
268
+ attrs.set(name, ''); // valueless attribute
269
+ selfClosing = false; // a `/` before a real attribute was not a close
270
+ continue;
271
+ }
272
+ i += 1; // past '='
273
+ while (i < html.length && WHITESPACE.test(html[i] as string)) i += 1;
274
+ if (i >= html.length) return null;
275
+
276
+ const quote = html[i] as string;
277
+ let value: string;
278
+ if (quote === '"' || quote === "'") {
279
+ const close = html.indexOf(quote, i + 1);
280
+ if (close === -1) return null; // unterminated attribute value
281
+ value = html.slice(i + 1, close);
282
+ i = close + 1;
283
+ } else {
284
+ const vs = i;
285
+ while (i < html.length && !WHITESPACE.test(html[i] as string) && html[i] !== '>') i += 1;
286
+ value = html.slice(vs, i);
287
+ }
288
+ attrs.set(name, value);
289
+ selfClosing = false;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Tokenize markup into text / comment / start / end tokens.
295
+ *
296
+ * Deterministic, pure, single pass, no backtracking. Every branch advances the
297
+ * cursor, so the loop terminates on any input including adversarial markup.
298
+ */
299
+ export function tokenize(html: string): TokenizeResult {
300
+ const tokens: Token[] = [];
301
+ let unterminated = false;
302
+ let i = 0;
303
+ let textStart = 0;
304
+
305
+ const flushText = (upTo: number): void => {
306
+ if (upTo > textStart) tokens.push({ kind: 'text', start: textStart, end: upTo });
307
+ };
308
+
309
+ while (i < html.length) {
310
+ const lt = html.indexOf('<', i);
311
+ if (lt === -1) break;
312
+
313
+ const next = html[lt + 1];
314
+
315
+ // Comment, doctype, CDATA, or bogus comment.
316
+ if (next === '!') {
317
+ flushText(lt);
318
+ if (html.startsWith('<!--', lt)) {
319
+ const close = html.indexOf('-->', lt + 4);
320
+ if (close === -1) {
321
+ tokens.push({ kind: 'comment', start: lt, end: html.length, unterminated: true });
322
+ unterminated = true;
323
+ textStart = html.length;
324
+ i = html.length;
325
+ break;
326
+ }
327
+ tokens.push({ kind: 'comment', start: lt, end: close + 3 });
328
+ i = close + 3;
329
+ } else {
330
+ const close = html.indexOf('>', lt + 2);
331
+ if (close === -1) {
332
+ tokens.push({ kind: 'comment', start: lt, end: html.length, unterminated: true });
333
+ unterminated = true;
334
+ textStart = html.length;
335
+ i = html.length;
336
+ break;
337
+ }
338
+ tokens.push({ kind: 'comment', start: lt, end: close + 1 });
339
+ i = close + 1;
340
+ }
341
+ textStart = i;
342
+ continue;
343
+ }
344
+
345
+ // End tag.
346
+ if (next === '/' && ASCII_ALPHA.test(html[lt + 2] ?? '')) {
347
+ const { name, next: afterName } = readTagName(html, lt + 2);
348
+ const parsed = readAttrs(html, afterName); // end tags may carry (ignored) attrs
349
+ if (parsed === null) {
350
+ flushText(lt);
351
+ unterminated = true;
352
+ textStart = html.length;
353
+ i = html.length;
354
+ break;
355
+ }
356
+ flushText(lt);
357
+ tokens.push({ kind: 'end', start: lt, end: parsed.end, name });
358
+ i = parsed.end;
359
+ textStart = i;
360
+ continue;
361
+ }
362
+
363
+ // Start tag.
364
+ if (ASCII_ALPHA.test(next ?? '')) {
365
+ const { name, next: afterName } = readTagName(html, lt + 1);
366
+ const parsed = readAttrs(html, afterName);
367
+ if (parsed === null) {
368
+ // Unterminated tag at EOF. Fail closed: the tail is dropped.
369
+ flushText(lt);
370
+ unterminated = true;
371
+ textStart = html.length;
372
+ i = html.length;
373
+ break;
374
+ }
375
+ flushText(lt);
376
+ const selfClosing = parsed.selfClosing || VOID_TAGS.has(name);
377
+ tokens.push({
378
+ kind: 'start',
379
+ start: lt,
380
+ end: parsed.end,
381
+ name,
382
+ attrs: parsed.attrs,
383
+ selfClosing,
384
+ });
385
+ i = parsed.end;
386
+ textStart = i;
387
+
388
+ // RAWTEXT: everything up to the matching close tag is character data.
389
+ if (!selfClosing && RAWTEXT_TAGS.has(name)) {
390
+ const closeAt = findRawtextClose(html, i, name);
391
+ if (closeAt === -1) {
392
+ tokens.push({ kind: 'text', start: i, end: html.length, unterminated: true });
393
+ unterminated = true;
394
+ textStart = html.length;
395
+ i = html.length;
396
+ break;
397
+ }
398
+ if (closeAt > i) tokens.push({ kind: 'text', start: i, end: closeAt });
399
+ const afterClose = html.indexOf('>', closeAt);
400
+ const endOff = afterClose === -1 ? html.length : afterClose + 1;
401
+ tokens.push({ kind: 'end', start: closeAt, end: endOff, name });
402
+ if (afterClose === -1) unterminated = true;
403
+ i = endOff;
404
+ textStart = i;
405
+ }
406
+ continue;
407
+ }
408
+
409
+ // A `<` that starts nothing — ordinary text. Advance past it only.
410
+ i = lt + 1;
411
+ }
412
+
413
+ flushText(html.length);
414
+ return { tokens, unterminated };
415
+ }
416
+
417
+ /** Offset of the `<` of the matching RAWTEXT close tag, or -1. */
418
+ function findRawtextClose(html: string, from: number, name: string): number {
419
+ let i = from;
420
+ for (;;) {
421
+ const lt = html.indexOf('</', i);
422
+ if (lt === -1) return -1;
423
+ const { name: candidate, next } = readTagName(html, lt + 2);
424
+ const after = html[next] ?? '>';
425
+ if (candidate === name && (after === '>' || after === '/' || WHITESPACE.test(after))) return lt;
426
+ i = lt + 2;
427
+ }
428
+ }
429
+
430
+ /* ------------------------------------------------------------------ *
431
+ * Hidden-element predicate
432
+ * ------------------------------------------------------------------ */
433
+
434
+ /** Inline-style declarations that hide an element outright. */
435
+ const HIDING_STYLE_RULES: readonly { readonly channel: string; readonly re: RegExp }[] = [
436
+ { channel: 'inline-display-none', re: /(?:^|;)\s*display\s*:\s*none\s*(?:!\s*important\s*)?(?:;|$)/i },
437
+ { channel: 'inline-visibility-hidden', re: /(?:^|;)\s*visibility\s*:\s*hidden\s*(?:!\s*important\s*)?(?:;|$)/i },
438
+ { channel: 'inline-opacity-zero', re: /(?:^|;)\s*opacity\s*:\s*0(?:\.0+)?\s*(?:!\s*important\s*)?(?:;|$)/i },
439
+ {
440
+ channel: 'inline-font-size-zero',
441
+ re: /(?:^|;)\s*font-size\s*:\s*0(?:\.0+)?(?:px|em|rem|pt|%)?\s*(?:!\s*important\s*)?(?:;|$)/i,
442
+ },
443
+ {
444
+ channel: 'inline-zero-box',
445
+ re: /(?:^|;)\s*(?:width|height)\s*:\s*0(?:\.0+)?(?:px|em|rem|pt|%)?\s*(?:!\s*important\s*)?(?:;|$)/i,
446
+ },
447
+ ];
448
+
449
+ /**
450
+ * The channel that hides this element, or `null` when it is visible.
451
+ *
452
+ * Reads the PARSED attribute map, never the raw tag text — an attribute value
453
+ * is already unquoted here, so no substring match can escape its own attribute.
454
+ */
455
+ export function hidingChannelFor(name: string, attrs: ReadonlyMap<string, string>): string | null {
456
+ if (name === 'template') return 'template-element';
457
+ if (attrs.has('hidden')) {
458
+ const v = (attrs.get('hidden') ?? '').trim().toLowerCase();
459
+ // `hidden`, `hidden=""`, `hidden="hidden"`, `hidden="true"` all hide.
460
+ // `hidden="until-found"` does NOT — the content is reachable by find-in-page.
461
+ if (v !== 'until-found') return 'hidden-attribute';
462
+ }
463
+ const style = attrs.get('style');
464
+ if (style !== undefined) {
465
+ for (const rule of HIDING_STYLE_RULES) {
466
+ if (rule.re.test(style)) return rule.channel;
467
+ }
468
+ }
469
+ return null;
470
+ }
471
+
472
+ /* ------------------------------------------------------------------ *
473
+ * Stripper
474
+ * ------------------------------------------------------------------ */
475
+
476
+ /** One removed span, reported so a caller can debug a corrupted result. */
477
+ export interface StructuralRemoval {
478
+ /** A {@link STRUCTURAL_HIDING_CHANNELS} id. Never derived from input. */
479
+ readonly channel: string;
480
+ /** Offset of the removed span's first character in the INPUT. */
481
+ readonly start: number;
482
+ /** Offset just past the removed span's last character in the INPUT. */
483
+ readonly end: number;
484
+ }
485
+
486
+ /** Result of one strip pass. */
487
+ export interface StructuralStripResult {
488
+ /** The model-facing copy, with hidden spans replaced by sentinels. */
489
+ readonly text: string;
490
+ /** Every removal, in document order. */
491
+ readonly removals: readonly StructuralRemoval[];
492
+ /** True when unterminated markup made the tail unresolvable. */
493
+ readonly truncated: boolean;
494
+ }
495
+
496
+ /**
497
+ * The sentinel left where content was removed.
498
+ *
499
+ * `channel` always comes from the closed vocabulary above, so no byte of the
500
+ * input ever reaches the output through this path.
501
+ */
502
+ export function removalSentinel(channel: string): string {
503
+ return `[removed: structural-hiding/${channel}]`;
504
+ }
505
+
506
+ interface OpenElement {
507
+ readonly name: string;
508
+ /** Set when this element is the root of a hidden subtree. */
509
+ readonly hiddenChannel: string | null;
510
+ /** Input offset where the hidden subtree began. */
511
+ readonly hiddenStart: number;
512
+ }
513
+
514
+ /**
515
+ * Remove structurally hidden content from a markup string.
516
+ *
517
+ * Covers exactly {@link STRUCTURAL_HIDING_CHANNELS} and deliberately not
518
+ * {@link STRUCTURAL_HIDING_NON_CHANNELS}; the classes it cannot reach at all
519
+ * are {@link STRUCTURAL_HIDING_GAPS}. Visible text is returned untouched,
520
+ * including the markup around it — this is a pre-pass, not a markup-to-text
521
+ * converter, so a caller that also wants the codepoint floor composes the two.
522
+ *
523
+ * Deterministic and pure: no network, no I/O, no model call.
524
+ */
525
+ export function strip_structural_hiding(html: string): StructuralStripResult {
526
+ const { tokens, unterminated } = tokenize(html);
527
+ const removals: StructuralRemoval[] = [];
528
+ const stack: OpenElement[] = [];
529
+ let out = '';
530
+ /** Index into `stack` of the hidden root, or -1 when nothing is hidden. */
531
+ let hiddenAt = -1;
532
+
533
+ for (const t of tokens) {
534
+ if (t.kind === 'start') {
535
+ const name = t.name as string;
536
+ const attrs = t.attrs as ReadonlyMap<string, string>;
537
+
538
+ if (hiddenAt !== -1) {
539
+ // Inside a hidden subtree: track depth, emit nothing.
540
+ if (!(t.selfClosing ?? false)) {
541
+ stack.push({ name, hiddenChannel: null, hiddenStart: 0 });
542
+ }
543
+ continue;
544
+ }
545
+
546
+ const channel = hidingChannelFor(name, attrs);
547
+ if (channel === null) {
548
+ out += html.slice(t.start, t.end);
549
+ if (!(t.selfClosing ?? false)) {
550
+ stack.push({ name, hiddenChannel: null, hiddenStart: 0 });
551
+ }
552
+ continue;
553
+ }
554
+
555
+ if (t.selfClosing ?? false) {
556
+ // No subtree to drop — just the tag itself.
557
+ removals.push({ channel, start: t.start, end: t.end });
558
+ out += removalSentinel(channel);
559
+ continue;
560
+ }
561
+ stack.push({ name, hiddenChannel: channel, hiddenStart: t.start });
562
+ hiddenAt = stack.length - 1;
563
+ continue;
564
+ }
565
+
566
+ if (t.kind === 'end') {
567
+ const name = t.name as string;
568
+ // Nearest matching open element. A stray end tag matches nothing
569
+ // and is ignored, exactly as a browser ignores it.
570
+ let idx = -1;
571
+ for (let k = stack.length - 1; k >= 0; k -= 1) {
572
+ if (stack[k]?.name === name) {
573
+ idx = k;
574
+ break;
575
+ }
576
+ }
577
+ if (idx === -1) {
578
+ if (hiddenAt === -1) out += html.slice(t.start, t.end);
579
+ continue;
580
+ }
581
+ const closingHidden = hiddenAt !== -1 && idx <= hiddenAt;
582
+ if (closingHidden) {
583
+ const root = stack[hiddenAt] as OpenElement;
584
+ removals.push({
585
+ channel: root.hiddenChannel as string,
586
+ start: root.hiddenStart,
587
+ end: t.end,
588
+ });
589
+ out += removalSentinel(root.hiddenChannel as string);
590
+ hiddenAt = -1;
591
+ } else if (hiddenAt === -1) {
592
+ out += html.slice(t.start, t.end);
593
+ }
594
+ stack.length = idx;
595
+ continue;
596
+ }
597
+
598
+ if (t.kind === 'comment') {
599
+ if (hiddenAt !== -1) continue; // already inside a removed span
600
+ removals.push({ channel: 'html-comment', start: t.start, end: t.end });
601
+ out += removalSentinel('html-comment');
602
+ continue;
603
+ }
604
+
605
+ // text
606
+ if (hiddenAt === -1) out += html.slice(t.start, t.end);
607
+ }
608
+
609
+ // An unclosed hidden element swallows the rest of the document. Fail
610
+ // closed: it is already excluded from `out`; record it and say so.
611
+ if (hiddenAt !== -1) {
612
+ const root = stack[hiddenAt] as OpenElement;
613
+ removals.push({
614
+ channel: root.hiddenChannel as string,
615
+ start: root.hiddenStart,
616
+ end: html.length,
617
+ });
618
+ out += removalSentinel(root.hiddenChannel as string);
619
+ }
620
+
621
+ return { text: out, removals, truncated: unterminated || hiddenAt !== -1 };
622
+ }