@supportpages.io/wtfm 0.0.0-stage → 0.3.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/LICENSE +201 -0
- package/NOTICE +23 -0
- package/README.md +392 -2
- package/dist/actions.d.ts +98 -0
- package/dist/actions.js +86 -0
- package/dist/actions.js.map +1 -0
- package/dist/agent-settings.d.ts +22 -0
- package/dist/agent-settings.js +35 -0
- package/dist/agent-settings.js.map +1 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +159 -0
- package/dist/api.js.map +1 -0
- package/dist/article-link.d.ts +16 -0
- package/dist/article-link.js +25 -0
- package/dist/article-link.js.map +1 -0
- package/dist/artifacts.d.ts +45 -0
- package/dist/artifacts.js +144 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/brand.d.ts +4 -0
- package/dist/brand.js +9 -0
- package/dist/brand.js.map +1 -0
- package/dist/bridge.d.ts +2964 -0
- package/dist/bridge.js +1036 -0
- package/dist/bridge.js.map +1 -0
- package/dist/capacity.d.ts +5 -0
- package/dist/capacity.js +11 -0
- package/dist/capacity.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +88 -0
- package/dist/credentials.js.map +1 -0
- package/dist/development-tls.d.ts +8 -0
- package/dist/development-tls.js +38 -0
- package/dist/development-tls.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +11 -0
- package/dist/export.js +91 -0
- package/dist/export.js.map +1 -0
- package/dist/hosted-operations.d.ts +108 -0
- package/dist/hosted-operations.js +120 -0
- package/dist/hosted-operations.js.map +1 -0
- package/dist/hosting-benefits.d.ts +66 -0
- package/dist/hosting-benefits.js +67 -0
- package/dist/hosting-benefits.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/local-inventory.d.ts +22 -0
- package/dist/local-inventory.js +59 -0
- package/dist/local-inventory.js.map +1 -0
- package/dist/local-setup.d.ts +208 -0
- package/dist/local-setup.js +140 -0
- package/dist/local-setup.js.map +1 -0
- package/dist/pairing.d.ts +71 -0
- package/dist/pairing.js +235 -0
- package/dist/pairing.js.map +1 -0
- package/dist/preferences.d.ts +18 -0
- package/dist/preferences.js +44 -0
- package/dist/preferences.js.map +1 -0
- package/dist/progress.d.ts +205 -0
- package/dist/progress.js +224 -0
- package/dist/progress.js.map +1 -0
- package/dist/reminders.d.ts +31 -0
- package/dist/reminders.js +73 -0
- package/dist/reminders.js.map +1 -0
- package/dist/replace-connection.d.ts +6 -0
- package/dist/replace-connection.js +77 -0
- package/dist/replace-connection.js.map +1 -0
- package/dist/repository-actions.d.ts +18 -0
- package/dist/repository-actions.js +9 -0
- package/dist/repository-actions.js.map +1 -0
- package/dist/repository-benefits.d.ts +96 -0
- package/dist/repository-benefits.js +64 -0
- package/dist/repository-benefits.js.map +1 -0
- package/dist/run-update.d.ts +17 -0
- package/dist/run-update.js +27 -0
- package/dist/run-update.js.map +1 -0
- package/dist/runs.d.ts +541 -0
- package/dist/runs.js +146 -0
- package/dist/runs.js.map +1 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +58 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +277 -0
- package/dist/schema.js +66 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +1714 -0
- package/dist/session.js +619 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +9 -0
- package/dist/settings.js +21 -0
- package/dist/settings.js.map +1 -0
- package/dist/sync.d.ts +495 -0
- package/dist/sync.js +191 -0
- package/dist/sync.js.map +1 -0
- package/dist/telemetry-scrub.d.ts +13 -0
- package/dist/telemetry-scrub.js +51 -0
- package/dist/telemetry-scrub.js.map +1 -0
- package/dist/telemetry.d.ts +73 -0
- package/dist/telemetry.js +173 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/walkthroughs.d.ts +224 -0
- package/dist/walkthroughs.js +109 -0
- package/dist/walkthroughs.js.map +1 -0
- package/dist/workspace.d.ts +14 -0
- package/dist/workspace.js +127 -0
- package/dist/workspace.js.map +1 -0
- package/dist/writer-agent.d.ts +21 -0
- package/dist/writer-agent.js +27 -0
- package/dist/writer-agent.js.map +1 -0
- package/dist/writer-entry.d.ts +171 -0
- package/dist/writer-entry.js +233 -0
- package/dist/writer-entry.js.map +1 -0
- package/dist/writing-style.d.ts +7 -0
- package/dist/writing-style.js +52 -0
- package/dist/writing-style.js.map +1 -0
- package/engine/SYNC.json +4 -0
- package/engine/VERSION +1 -0
- package/engine/detect-project/README.md +141 -0
- package/engine/detect-project/SKILL.md +1421 -0
- package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
- package/engine/detect-project/assets/game/game-frame.css +132 -0
- package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
- package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/macosui/fonts.css +15 -0
- package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
- package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
- package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
- package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
- package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
- package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
- package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
- package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
- package/engine/detect-project/assets/mobileui/fonts.css +16 -0
- package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
- package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
- package/engine/detect-project/assets/mobileui/icons.css +31 -0
- package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
- package/engine/detect-project/assets/mobileui/platforms.json +46 -0
- package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
- package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
- package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
- package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
- package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
- package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
- package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
- package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
- package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
- package/engine/detect-project/assets/webtui/themes.json +37 -0
- package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
- package/engine/detect-project/assets/win32ui/7css.css +2 -0
- package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
- package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
- package/engine/detect-project/package-lock.json +12 -0
- package/engine/detect-project/package.json +10 -0
- package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
- package/engine/detect-project/scripts/check_css_health.js +412 -0
- package/engine/detect-project/scripts/check_project_map.js +150 -0
- package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
- package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
- package/engine/detect-project/scripts/classify_app_type.sh +246 -0
- package/engine/detect-project/scripts/classify_surface.sh +95 -0
- package/engine/detect-project/scripts/classify_workspace.js +39 -0
- package/engine/detect-project/scripts/compile_css.sh +447 -0
- package/engine/detect-project/scripts/detect_static.js +645 -0
- package/engine/detect-project/scripts/detect_structure.js +187 -0
- package/engine/detect-project/scripts/include_census.js +451 -0
- package/engine/detect-project/scripts/json_get.js +142 -0
- package/engine/detect-project/scripts/merge_json.js +52 -0
- package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
- package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
- package/engine/detect-project/scripts/run_css_build.sh +40 -0
- package/engine/detect-project/scripts/sanitize_css.js +83 -0
- package/engine/detect-project/scripts/test_classify_surface.js +108 -0
- package/engine/detect-project/scripts/test_node_helpers.js +142 -0
- package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
- package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
- package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
- package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
- package/engine/detect-project/scripts/theme_overrides.js +169 -0
- package/engine/detect-project/scripts/write_branding.js +129 -0
- package/engine/generate-illustrated-article/SKILL.md +461 -0
- package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
- package/engine/generate-illustrated-article/contracts/game.md +38 -0
- package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
- package/engine/generate-illustrated-article/contracts/macos.md +79 -0
- package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
- package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
- package/engine/generate-illustrated-article/contracts/win32.md +43 -0
- package/engine/generate-illustrated-article/package-lock.json +366 -0
- package/engine/generate-illustrated-article/package.json +15 -0
- package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
- package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
- package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
- package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
- package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
- package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
- package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
- package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
- package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
- package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
- package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
- package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
- package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
- package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
- package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
- package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
- package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
- package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
- package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
- package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
- package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
- package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
- package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
- package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
- package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
- package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
- package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
- package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
- package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
- package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
- package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
- package/install.sh +14 -0
- package/package.json +57 -4
- package/scripts/auth.mjs +33 -0
- package/scripts/auto-update.mjs +7 -0
- package/scripts/build-plugin.mjs +58 -0
- package/scripts/build-release.mjs +94 -0
- package/scripts/check-release.mjs +40 -0
- package/scripts/check-runtime.mjs +10 -0
- package/scripts/cli.mjs +8 -0
- package/scripts/install.mjs +64 -0
- package/scripts/lib/agent-runner.mjs +181 -0
- package/scripts/lib/agent-settings.mjs +215 -0
- package/scripts/lib/article-skills.mjs +82 -0
- package/scripts/lib/auto-update.mjs +50 -0
- package/scripts/lib/brand.mjs +9 -0
- package/scripts/lib/browser.mjs +12 -0
- package/scripts/lib/claude-connection.mjs +33 -0
- package/scripts/lib/claude-permissions.mjs +47 -0
- package/scripts/lib/claude-plugin.mjs +15 -0
- package/scripts/lib/claude-writer.mjs +10 -0
- package/scripts/lib/cli-main.mjs +85 -0
- package/scripts/lib/cli.mjs +888 -0
- package/scripts/lib/codex-config.mjs +51 -0
- package/scripts/lib/codex-integration.mjs +61 -0
- package/scripts/lib/codex-skill.mjs +34 -0
- package/scripts/lib/harness-models.mjs +109 -0
- package/scripts/lib/install.mjs +269 -0
- package/scripts/lib/managed-writer.mjs +38 -0
- package/scripts/lib/planning.mjs +263 -0
- package/scripts/lib/prepare-update.mjs +85 -0
- package/scripts/lib/refresh-writers.mjs +12 -0
- package/scripts/lib/remove.mjs +167 -0
- package/scripts/lib/renderer.mjs +34 -0
- package/scripts/lib/terminal.mjs +252 -0
- package/scripts/lib/uninit.mjs +72 -0
- package/scripts/lib/update.mjs +55 -0
- package/scripts/lib/writer-recovery.mjs +35 -0
- package/scripts/lib/yolo.mjs +362 -0
- package/scripts/plugin-session.mjs +65 -0
- package/scripts/prepare-update.mjs +12 -0
- package/scripts/publish-release.mjs +92 -0
- package/scripts/skills/supportpages/SKILL.md +120 -0
- package/scripts/smoke-release.mjs +101 -0
- package/server.json +27 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* polish_tickets.js — deterministic "where to look" tickets for the POLISH phase
|
|
4
|
+
* of generate-illustrated-article (Phase 4), and the matching --report.
|
|
5
|
+
*
|
|
6
|
+
* The model never looks at its own render. Every input-side gate (the fidelity
|
|
7
|
+
* lint, the copy lint) reads text, and a mockup can pass all of them while the
|
|
8
|
+
* PNG is missing whole regions: a sidebar copied faithfully from source with a
|
|
9
|
+
* `hidden lg:flex` class pair scores 4/4 shell labels and paints 0 px at the
|
|
10
|
+
* capture width. This script turns the structured signals nobody consumes —
|
|
11
|
+
* lint_report.json metrics, block diagnostics (render score, evidence and probe
|
|
12
|
+
* visibility), the project map's app_shell / layouts[].chrome, and the block's
|
|
13
|
+
* own source files — into per-block QUESTIONS that tell the model where to look
|
|
14
|
+
* in the PNG and which files to compare against. Tickets are where to look,
|
|
15
|
+
* never text to transcribe: the model answers each question against the PNG
|
|
16
|
+
* and the sources and repairs by ADDING what is missing.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* polish_tickets.js <article_dir> [project_root] → <article_dir>/polish_tickets.json
|
|
20
|
+
* polish_tickets.js <article_dir> [project_root] --restore → …and copy each ticketed block's
|
|
21
|
+
* .html.pre (raw authored HTML) back
|
|
22
|
+
* over its injected .html for editing
|
|
23
|
+
* polish_tickets.js <article_dir> [project_root] --report → <article_dir>/polish_report.json
|
|
24
|
+
*
|
|
25
|
+
* Always exits 0 and writes ONLY its own output file (plus the --restore
|
|
26
|
+
* copies, which only ever put the model's own authored HTML back). Missing or
|
|
27
|
+
* unparsable inputs degrade to fewer questions, never to a failure — the phase
|
|
28
|
+
* is additive and must not be able to break the pipeline.
|
|
29
|
+
*
|
|
30
|
+
* Gate (script-side, like watermark.js): RTFM_POLISH in off/0/false/no writes
|
|
31
|
+
* {"enabled": false} and prints `polish: disabled`. Caps: RTFM_POLISH_MAX_EDITS
|
|
32
|
+
* (per block, default 6) and RTFM_POLISH_MAX_BLOCKS (0 = all).
|
|
33
|
+
*
|
|
34
|
+
* Every question carries a machine re-checkable `check` (or {type:"manual"});
|
|
35
|
+
* --report re-evaluates them against the re-lint, the fresh diagnostics, and
|
|
36
|
+
* the edited HTML, and records per block whether the HTML/PNG changed and how
|
|
37
|
+
* the render score and chrome mean moved. `html_changed` (raw authored HTML
|
|
38
|
+
* sha) is the "the model edited this block" signal; `png_changed` is byte-level
|
|
39
|
+
* and a plain re-render is NOT always byte-identical (font/CDN timing), so it
|
|
40
|
+
* only says "re-rendered", never "visibly different". The chrome mean is the
|
|
41
|
+
* mean of the six chrome ratios that apply, matching the offline scorer. None of this feeds an error or an exit code.
|
|
42
|
+
*/
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
const fs = require('fs');
|
|
46
|
+
const path = require('path');
|
|
47
|
+
const crypto = require('crypto');
|
|
48
|
+
const includeCensus = require('./include_census.js');
|
|
49
|
+
|
|
50
|
+
const SCHEMA_VERSION = 1;
|
|
51
|
+
const CHROME_KEYS = ['shell_nav_labels', 'chrome_files_expanded', 'runtime_ui_strings',
|
|
52
|
+
'runtime_regions_rendered', 'root_classes_html', 'layout_root_classes'];
|
|
53
|
+
const T = {
|
|
54
|
+
shell_labels: 0.8, shell_labels_blocking: 0.5,
|
|
55
|
+
chrome_files: 1, runtime_strings: 0.8,
|
|
56
|
+
styled_coverage: 0.6, styled_min_total: 10,
|
|
57
|
+
render_score: 80,
|
|
58
|
+
};
|
|
59
|
+
const CAPS = { components: 6, labels: 12, form_fields: 12, nav: 12, read_order: 10, scan_files: 8 };
|
|
60
|
+
const FILE_READ_CAP = 400 * 1024;
|
|
61
|
+
const CONTENT_DEDUCTION_RE = /blank|few visible|cut off|geometry/i;
|
|
62
|
+
const LOCALE_PATH_RE = /(?:^|\/)(?:locales?|i18n|lang|translations?|messages|l10n)(?:\/|\.)/i;
|
|
63
|
+
const LOCALE_EXT_RE = /\.(?:json|ya?ml|arb)$/i;
|
|
64
|
+
// PascalCase tags that are UI primitives, not screen regions — a mockup never
|
|
65
|
+
// "corresponds" to a <Button>, so they never earn a component ticket.
|
|
66
|
+
const UI_PRIMITIVES = new Set(('Button Icon Tooltip Dialog Link Input Select Form Label Badge Avatar Skeleton Trans ' +
|
|
67
|
+
'Head Meta Fragment Suspense Provider Image Img Text Box Flex Grid Stack Card Container Divider Spinner ' +
|
|
68
|
+
'Loader Portal Popover Menu MenuItem DropdownMenu Checkbox Switch Radio Textarea TextField Alert Toast ' +
|
|
69
|
+
'Modal Sheet Tabs Tab Table Th Td Tr Slot Transition Motion ErrorBoundary Script Layout Component Title ' +
|
|
70
|
+
'Description Separator Toaster Helmet Router Routes Route Outlet NavLink Anchor Heading Paragraph Span ' +
|
|
71
|
+
'Center Group Row Col Column Section Main Header Footer Nav Aside Article Wrapper Root Content Trigger ' +
|
|
72
|
+
'Item List ListItem Field FormField FormItem FormLabel FormControl FormMessage Kbd Code Pre Strong Em Small').split(' '));
|
|
73
|
+
// Suffixes/prefixes that mark a PascalCase tag as a primitive, an icon, or a
|
|
74
|
+
// TypeScript type used in a generic (<Props>, <Params>) rather than a region.
|
|
75
|
+
const PRIMITIVE_SUFFIX_RE = /(?:Provider|Context|Boundary|Icon|Trigger|Portal|Props|Params|Button|Link)$/;
|
|
76
|
+
const ICON_PREFIX_RE = /^(?:Icon|Lucide|Tabler|Fa|Md|Hi|Bi|Ri|Io)[A-Z]/;
|
|
77
|
+
// Labels are short and un-punctuated; sentences ending in . ! ? are messages
|
|
78
|
+
// (errors, toasts, confirmations) that no screen shows in its default state.
|
|
79
|
+
const MESSAGE_LABEL_RE = /[.!?]$|^(?:sorry|error|invalid|please|you (?:are|have|need|do not|don't|can(?:not|'t))|are you sure|something went wrong|failed to|unable to)\b/i;
|
|
80
|
+
const MAX_LABEL_WORDS = 6;
|
|
81
|
+
// Paths the model sometimes lists as sources but which are never screen sources.
|
|
82
|
+
const NON_SOURCE_PATH_RE = /(?:^|\/)(?:\.rtfm(?:-branding)?|\.rtfm-trace|output|node_modules|\.git)\//;
|
|
83
|
+
// Only literal labels can be checked for presence; prose descriptions
|
|
84
|
+
// ("nav links: Home, Groups, …", "'Howdy, <name>' account menu") ride along
|
|
85
|
+
// as context for the compare question instead.
|
|
86
|
+
function isLiteralLabel(s) {
|
|
87
|
+
const t = norm(s);
|
|
88
|
+
if (t.length < 2 || t.length > 40) return false;
|
|
89
|
+
if (/[():<>"'—–\[\]{}|]/.test(t)) return false;
|
|
90
|
+
if (!/^[A-Z0-9]/.test(t)) return false; // descriptions start lowercase ("updates badge with count")
|
|
91
|
+
return t.split(' ').length <= 4;
|
|
92
|
+
}
|
|
93
|
+
// Overlays are hidden in a screen's default state; a block that depicts one
|
|
94
|
+
// names its file as primary_view, so an overlay reached through a layout or
|
|
95
|
+
// chrome file is never a missing region.
|
|
96
|
+
const OVERLAY_NAME_RE = /modal|dialog|drawer|toast|tooltip|popover|snackbar|lightbox|flash/i;
|
|
97
|
+
// A shell-carrying layout is one the map ties the shell to; a layout whose own
|
|
98
|
+
// description says it has no sidebar / is fullscreen does not grow one.
|
|
99
|
+
const SHELL_ABSENT_RE = /\bno (?:left |right |app |persistent )?(?:sidebar|side bar|side-bar|app shell|shell|navigation)\b|without (?:the |a )?(?:sidebar|shell)|\b(?:fullscreen|full-screen|full screen)\b|hides? the (?:app )?shell|shell(?: is)? hidden/i;
|
|
100
|
+
|
|
101
|
+
// ─── small utilities ─────────────────────────────────────────────────────────
|
|
102
|
+
function polishEnabled(env = process.env) {
|
|
103
|
+
const v = String(env.RTFM_POLISH ?? 'on').trim().toLowerCase();
|
|
104
|
+
return !(v === 'off' || v === '0' || v === 'false' || v === 'no');
|
|
105
|
+
}
|
|
106
|
+
function intEnv(name, dflt, env = process.env) {
|
|
107
|
+
const n = parseInt(String(env[name] ?? ''), 10);
|
|
108
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
109
|
+
}
|
|
110
|
+
function readJson(p) {
|
|
111
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
function readText(p, cap = FILE_READ_CAP) {
|
|
114
|
+
try {
|
|
115
|
+
const st = fs.statSync(p);
|
|
116
|
+
if (!st.isFile()) return '';
|
|
117
|
+
const fd = fs.openSync(p, 'r');
|
|
118
|
+
try {
|
|
119
|
+
const len = Math.min(st.size, cap);
|
|
120
|
+
const buf = Buffer.alloc(len);
|
|
121
|
+
fs.readSync(fd, buf, 0, len, 0);
|
|
122
|
+
return buf.toString('utf8');
|
|
123
|
+
} finally { fs.closeSync(fd); }
|
|
124
|
+
} catch { return ''; }
|
|
125
|
+
}
|
|
126
|
+
function sha256File(p) {
|
|
127
|
+
try { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
|
|
130
|
+
function norm(s) { return String(s).replace(/\s+/g, ' ').trim(); }
|
|
131
|
+
function uniq(arr) { return [...new Set(arr)]; }
|
|
132
|
+
function ratioOf(m) {
|
|
133
|
+
return m && typeof m === 'object' && typeof m.ratio === 'number' ? m.ratio : null;
|
|
134
|
+
}
|
|
135
|
+
function countOf(m) {
|
|
136
|
+
return m && typeof m === 'object' && typeof m.count === 'number' ? m.count : null;
|
|
137
|
+
}
|
|
138
|
+
function chromeMean(metrics) {
|
|
139
|
+
if (!metrics || typeof metrics !== 'object') return null;
|
|
140
|
+
const vals = CHROME_KEYS.map(k => ratioOf(metrics[k])).filter(v => v !== null);
|
|
141
|
+
if (!vals.length) return null;
|
|
142
|
+
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 1000) / 1000;
|
|
143
|
+
}
|
|
144
|
+
function extractVisibleText(html) {
|
|
145
|
+
let s = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
|
|
146
|
+
s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
|
|
147
|
+
s = s.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
148
|
+
s = s.replace(/<[^>]+>/g, ' ');
|
|
149
|
+
s = s.replace(/ /g, ' ').replace(/&/g, '&');
|
|
150
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
151
|
+
}
|
|
152
|
+
// Lower-cased tokens from id / class / data-* attribute values — the mockup's
|
|
153
|
+
// structural vocabulary (a region named after a component "corresponds").
|
|
154
|
+
function attrTokens(html) {
|
|
155
|
+
const out = new Set();
|
|
156
|
+
for (const m of html.matchAll(/\b(?:id|class|data-[a-z0-9-]+)\s*=\s*["']([^"']*)["']/gi)) {
|
|
157
|
+
for (const tok of m[1].split(/[\s,;|]+/)) {
|
|
158
|
+
const t = tok.trim().toLowerCase();
|
|
159
|
+
if (t) out.add(t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function nameTokens(name) {
|
|
165
|
+
const kebab = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
166
|
+
return uniq([name.toLowerCase(), kebab, kebab.replace(/-/g, '_'), kebab.replace(/-/g, '')]);
|
|
167
|
+
}
|
|
168
|
+
function countFormControls(html) {
|
|
169
|
+
let n = 0;
|
|
170
|
+
for (const m of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
|
|
171
|
+
if (m[1].toLowerCase() === 'input' && /\btype\s*=\s*["']?hidden\b/i.test(m[2])) continue;
|
|
172
|
+
n++;
|
|
173
|
+
}
|
|
174
|
+
for (const m of html.matchAll(/\brole\s*=\s*["'](combobox|switch|checkbox|radio|textbox|spinbutton|slider)["']/gi)) {
|
|
175
|
+
if (m) n++;
|
|
176
|
+
}
|
|
177
|
+
return n;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── inputs ──────────────────────────────────────────────────────────────────
|
|
181
|
+
function loadProjectMap(root) {
|
|
182
|
+
for (const d of ['.rtfm', '.rtfm-branding']) {
|
|
183
|
+
const pm = readJson(path.join(root, d, 'project_map.json'));
|
|
184
|
+
if (pm) return pm;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function shellLabels(items) {
|
|
189
|
+
const out = [];
|
|
190
|
+
const walk = list => {
|
|
191
|
+
for (const it of Array.isArray(list) ? list : []) {
|
|
192
|
+
if (!it) continue;
|
|
193
|
+
const label = typeof it === 'string' ? it : it.label;
|
|
194
|
+
if (typeof label === 'string' && label.trim().length >= 3) out.push(label.trim());
|
|
195
|
+
if (it && Array.isArray(it.children)) walk(it.children);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
walk(items);
|
|
199
|
+
return uniq(out);
|
|
200
|
+
}
|
|
201
|
+
function shellSummary(pm) {
|
|
202
|
+
const shell = pm && pm.app_shell;
|
|
203
|
+
if (!shell || typeof shell !== 'object') return null;
|
|
204
|
+
// The lint's shell_nav_labels metric uses TOP-LEVEL nav labels only; the
|
|
205
|
+
// probe and the tickets look one level deeper too, but the metric-mirroring
|
|
206
|
+
// list must match the lint so a ticket names the labels the metric counted.
|
|
207
|
+
const topLevel = (shell.nav_items || []).map(n => n && n.label)
|
|
208
|
+
.filter(l => typeof l === 'string' && l.length >= 3);
|
|
209
|
+
const topBar = shell.top_bar && typeof shell.top_bar === 'object' ? shell.top_bar : null;
|
|
210
|
+
return {
|
|
211
|
+
type: shell.type || null,
|
|
212
|
+
layout: shell.layout || null,
|
|
213
|
+
nav_labels: topLevel,
|
|
214
|
+
nav_labels_all: shellLabels(shell.nav_items),
|
|
215
|
+
footer_labels: shellLabels(shell.footer_items),
|
|
216
|
+
account_area: typeof shell.account_area === 'string' ? shell.account_area
|
|
217
|
+
: (shell.account_area ? JSON.stringify(shell.account_area) : null),
|
|
218
|
+
top_bar: topBar ? {
|
|
219
|
+
items_left: shellLabels(topBar.items_left), items_right: shellLabels(topBar.items_right),
|
|
220
|
+
} : null,
|
|
221
|
+
notes: typeof shell.notes === 'string' ? shell.notes : null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function layoutFor(pm, ref) {
|
|
225
|
+
if (!pm || !ref || !Array.isArray(pm.layouts)) return null;
|
|
226
|
+
const want = String(ref).trim();
|
|
227
|
+
const hit = pm.layouts.find(l => l && (l.name === want || l.path === want))
|
|
228
|
+
|| pm.layouts.find(l => l && typeof l.path === 'string' && (l.path.endsWith('/' + want) || want.endsWith('/' + l.path)));
|
|
229
|
+
if (!hit) return null;
|
|
230
|
+
return {
|
|
231
|
+
name: hit.name || null,
|
|
232
|
+
path: typeof hit.path === 'string' ? hit.path : null,
|
|
233
|
+
chrome: Array.isArray(hit.chrome) ? hit.chrome.filter(c => typeof c === 'string') : null,
|
|
234
|
+
runtime_chrome: hit.runtime_chrome && typeof hit.runtime_chrome === 'object' ? hit.runtime_chrome : null,
|
|
235
|
+
root_classes: hit.root_classes || null,
|
|
236
|
+
area: typeof hit.area === 'string' ? hit.area : null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function blockEntries(vs) {
|
|
240
|
+
const raw = (vs && (vs.blocks || vs.steps)) || [];
|
|
241
|
+
return raw.map((entry, position) => {
|
|
242
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
243
|
+
const legacy = entry.block_id === undefined;
|
|
244
|
+
const key = legacy ? String(entry.index ?? position) : String(entry.block_id);
|
|
245
|
+
const base = legacy ? `step_${key}` : `block_${key}`;
|
|
246
|
+
return {
|
|
247
|
+
key, legacy, position,
|
|
248
|
+
block_id: legacy ? null : String(entry.block_id),
|
|
249
|
+
index: Number.isInteger(entry.index) ? entry.index : position,
|
|
250
|
+
base,
|
|
251
|
+
html: `${base}.html`, pre: `${base}.html.pre`, png: `${base}.png`,
|
|
252
|
+
diagnostics: `${base}_diagnostics.json`,
|
|
253
|
+
entry,
|
|
254
|
+
};
|
|
255
|
+
}).filter(Boolean);
|
|
256
|
+
}
|
|
257
|
+
function lintEntryFor(lint, block) {
|
|
258
|
+
const list = (lint && (lint.blocks || lint.steps)) || [];
|
|
259
|
+
return list.find(e => e && (block.block_id !== null
|
|
260
|
+
? String(e.block_id) === block.block_id
|
|
261
|
+
: Number(e.index) === Number(block.index))) || null;
|
|
262
|
+
}
|
|
263
|
+
function evidenceStrings(entry) {
|
|
264
|
+
return (entry.verbatim_evidence || [])
|
|
265
|
+
.map(e => (typeof e === 'string' ? e : (e && e.string) || ''))
|
|
266
|
+
.map(norm).filter(Boolean);
|
|
267
|
+
}
|
|
268
|
+
function absenceStrings(entry) {
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const a of Array.isArray(entry.default_user_assumptions) ? entry.default_user_assumptions : []) {
|
|
271
|
+
for (const s of (a && a.markup_absence_check) || []) if (typeof s === 'string') out.push(s.toLowerCase());
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
function actionTargetsFor(vs, block) {
|
|
276
|
+
const keys = new Set([block.key]);
|
|
277
|
+
if (block.block_id !== null) keys.add(String(block.index));
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const e of Array.isArray(vs && vs.action_coverage) ? vs.action_coverage : []) {
|
|
280
|
+
if (!e) continue;
|
|
281
|
+
const k = e.screenshot_block_id ?? e.screenshot_step_index;
|
|
282
|
+
if (k !== undefined && keys.has(String(k)) && typeof e.target === 'string' && e.target.trim()) {
|
|
283
|
+
out.push({ target: norm(e.target), kind: e.kind || null });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── source scanners (generic, regex-only, bounded) ─────────────────────────
|
|
290
|
+
const TEMPLATE_NOISE_RE = /[{}<>]|<%|\$\{|#\{|%}|=>|\bt\(|\bI18n\b|__\(|\bthis\.|\bprops\.|\bstate\./;
|
|
291
|
+
function cleanLabel(s) {
|
|
292
|
+
let t = String(s).replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim();
|
|
293
|
+
if (t.length < 3 || t.length > 60) return null;
|
|
294
|
+
if (TEMPLATE_NOISE_RE.test(t)) return null;
|
|
295
|
+
if (!/[A-Za-z]/.test(t)) return null;
|
|
296
|
+
if (/^[a-z][A-Za-z0-9_]*$/.test(t) && /[A-Z_]/.test(t)) return null; // identifier, not copy
|
|
297
|
+
if (/^[\w.-]+\.[\w.-]+$/.test(t)) return null; // dotted key / filename
|
|
298
|
+
if (/%\d*\$?[sd]|&#|\\"|\{\{|\}\}|\$\w/.test(t)) return null; // printf / entity / mustache / $var placeholders
|
|
299
|
+
return t;
|
|
300
|
+
}
|
|
301
|
+
function isScreenLabel(s) {
|
|
302
|
+
return !MESSAGE_LABEL_RE.test(s) && s.split(' ').length <= MAX_LABEL_WORDS;
|
|
303
|
+
}
|
|
304
|
+
// opts.nav: also harvest link text and label:/title: object literals (layout and
|
|
305
|
+
// chrome files). opts.visibleOnly: drop aria-label/title/alt — attributes that
|
|
306
|
+
// never paint, so their absence is not something a PNG comparison can show.
|
|
307
|
+
function extractLabels(text, opts = {}) {
|
|
308
|
+
const out = [];
|
|
309
|
+
const push = s => { const c = cleanLabel(s); if (c) out.push(c); };
|
|
310
|
+
for (const m of text.matchAll(/<(label|button|th|h1|h2|h3|legend|summary|option)\b[^>]*>([\s\S]{0,400}?)<\/\1>/gi)) push(m[2]);
|
|
311
|
+
// Text children of components: <Button>Save</Button>, <Trans>…</Trans>, <Badge>New</Badge>.
|
|
312
|
+
for (const m of text.matchAll(/<([A-Z][A-Za-z0-9.]*)\b[^>]*>([^<{}]{3,60})<\/\1>/g)) push(m[2]);
|
|
313
|
+
for (const m of text.matchAll(/\bplaceholder\s*=\s*["']([^"'{}<>]{3,60})["']/gi)) push(m[1]);
|
|
314
|
+
if (!opts.visibleOnly) {
|
|
315
|
+
for (const m of text.matchAll(/\b(?:aria-label|title|alt)\s*=\s*["']([^"'{}<>]{3,60})["']/gi)) push(m[1]);
|
|
316
|
+
}
|
|
317
|
+
for (const m of text.matchAll(/\b(?:__|_e|esc_html__|esc_html_e|esc_attr__|esc_attr_e|_x|gettext|dgettext|ngettext)\(\s*['"]([^'"]{3,60})['"]/g)) push(m[1]);
|
|
318
|
+
// Rails helpers: link_to "Home", button_to "Save", f.submit "Create", f.label :x, "Label", submit_tag "Go".
|
|
319
|
+
for (const m of text.matchAll(/\b(?:link_to|button_to|submit_tag|button_tag|label_tag)\s*\(?\s*["']([^"'{}<>#]{3,60})["']/g)) push(m[1]);
|
|
320
|
+
for (const m of text.matchAll(/\b\w+\.(?:submit|button|label)\s*\(?\s*(?::\w+\s*,\s*)?["']([^"'{}<>#]{3,60})["']/g)) push(m[1]);
|
|
321
|
+
if (opts.nav) {
|
|
322
|
+
for (const m of text.matchAll(/<a\b[^>]*>([\s\S]{0,300}?)<\/a>/gi)) push(m[1]);
|
|
323
|
+
for (const m of text.matchAll(/\b(?:label|title|name|text)\s*:\s*["']([^"'{}<>]{3,60})["']/g)) push(m[1]);
|
|
324
|
+
}
|
|
325
|
+
return uniq(out);
|
|
326
|
+
}
|
|
327
|
+
function extractI18nKeys(text) {
|
|
328
|
+
const keys = [];
|
|
329
|
+
for (const re of [
|
|
330
|
+
/(?:^|[^\w$.])t\(\s*['"`]([\w.:-]+)['"`]/g,
|
|
331
|
+
/\{\{\s*\$?t\s*\(\s*['"]([\w.:-]+)['"]/g,
|
|
332
|
+
/\bI18n\.t\(\s*['"]([\w.:-]+)['"]/g,
|
|
333
|
+
/\$t\(\s*['"]([\w.:-]+)['"]/g,
|
|
334
|
+
/\btranslate\(\s*['"]([\w.:-]+)['"]/g,
|
|
335
|
+
]) for (const m of text.matchAll(re)) keys.push(m[1]);
|
|
336
|
+
return uniq(keys);
|
|
337
|
+
}
|
|
338
|
+
// Minimal locale index: JSON (nested → dotted keys) and simple indented YAML
|
|
339
|
+
// (`key: value` / `key:` lines). Enough for common.json / en.yml lookups; a key
|
|
340
|
+
// the index cannot resolve is simply not a candidate.
|
|
341
|
+
class LocaleIndex {
|
|
342
|
+
constructor() { this.map = new Map(); this.files = []; }
|
|
343
|
+
addJson(text, file) {
|
|
344
|
+
let obj; try { obj = JSON.parse(text); } catch { return; }
|
|
345
|
+
this.files.push(file);
|
|
346
|
+
const walk = (node, prefix) => {
|
|
347
|
+
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
|
348
|
+
for (const [k, v] of Object.entries(node)) walk(v, prefix ? `${prefix}.${k}` : k);
|
|
349
|
+
} else if (typeof node === 'string') this.map.set(prefix, node);
|
|
350
|
+
};
|
|
351
|
+
walk(obj, '');
|
|
352
|
+
}
|
|
353
|
+
addYaml(text, file) {
|
|
354
|
+
this.files.push(file);
|
|
355
|
+
const stack = []; // [{indent, key}]
|
|
356
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
357
|
+
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
|
358
|
+
const m = raw.match(/^(\s*)([\w.-]+)\s*:\s*(.*)$/);
|
|
359
|
+
if (!m) continue;
|
|
360
|
+
const indent = m[1].length;
|
|
361
|
+
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
362
|
+
const keyPath = [...stack.map(s => s.key), m[2]].join('.');
|
|
363
|
+
let val = m[3].trim();
|
|
364
|
+
if (val === '' || val === '|' || val === '>') { stack.push({ indent, key: m[2] }); continue; }
|
|
365
|
+
if (/^["'].*["']$/.test(val)) val = val.slice(1, -1);
|
|
366
|
+
if (val.startsWith('[') || val.startsWith('{') || val.startsWith('&') || val.startsWith('*')) continue;
|
|
367
|
+
this.map.set(keyPath, val);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
lookup(key, lazyPrefix) {
|
|
371
|
+
if (!key) return null;
|
|
372
|
+
const candidates = [];
|
|
373
|
+
if (key.startsWith('.') && lazyPrefix) candidates.push(`${lazyPrefix}${key}`, `en.${lazyPrefix}${key}`);
|
|
374
|
+
else candidates.push(key, `en.${key}`, key.replace(/:/g, '.'), `en.${key.replace(/:/g, '.')}`);
|
|
375
|
+
for (const c of candidates) { const v = this.map.get(c); if (typeof v === 'string') return v; }
|
|
376
|
+
// colon-namespaced keys (i18next "common:save") → try the tail alone
|
|
377
|
+
const tail = key.includes(':') ? key.split(':').pop() : null;
|
|
378
|
+
if (tail && this.map.has(tail)) return this.map.get(tail);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function isLocaleFile(rel) {
|
|
383
|
+
return LOCALE_EXT_RE.test(rel) && LOCALE_PATH_RE.test(rel);
|
|
384
|
+
}
|
|
385
|
+
function buildLocaleIndex(root, partials) {
|
|
386
|
+
const idx = new LocaleIndex();
|
|
387
|
+
for (const rel of partials) {
|
|
388
|
+
if (!isLocaleFile(rel)) continue;
|
|
389
|
+
const text = readText(path.join(root, rel));
|
|
390
|
+
if (!text) continue;
|
|
391
|
+
if (/\.json$/i.test(rel) || /\.arb$/i.test(rel)) idx.addJson(text, rel);
|
|
392
|
+
else idx.addYaml(text, rel);
|
|
393
|
+
}
|
|
394
|
+
return idx;
|
|
395
|
+
}
|
|
396
|
+
function lazyPrefixFor(rel) {
|
|
397
|
+
// Rails lazy lookup: app/views/users/reset_password.html.erb → users.reset_password
|
|
398
|
+
const m = rel.match(/(?:^|\/)app\/views\/(.+?)\.[a-z]+(?:\.[a-z]+)?$/);
|
|
399
|
+
if (!m) return null;
|
|
400
|
+
return m[1].split('/').map(seg => seg.replace(/^_/, '')).join('.');
|
|
401
|
+
}
|
|
402
|
+
function resolveModule(src, fromDir, root) {
|
|
403
|
+
const bases = [];
|
|
404
|
+
if (src.startsWith('.')) bases.push(path.resolve(fromDir, src));
|
|
405
|
+
else if (src.startsWith('@/') || src.startsWith('~/')) {
|
|
406
|
+
for (const pre of ['src', 'app', 'resources/js', '']) bases.push(path.join(root, pre, src.slice(2)));
|
|
407
|
+
} else if (src.startsWith('$lib/')) bases.push(path.join(root, 'src/lib', src.slice(5)));
|
|
408
|
+
else if (src.startsWith('/')) bases.push(path.join(root, src));
|
|
409
|
+
else return null; // bare package / workspace alias — not resolvable here
|
|
410
|
+
const exts = ['', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.vue', '.svelte', '.astro'];
|
|
411
|
+
for (const b of bases) {
|
|
412
|
+
for (const e of exts) if (isFile(b + e)) return b + e;
|
|
413
|
+
for (const e of exts.slice(1)) if (isFile(path.join(b, 'index' + e))) return path.join(b, 'index' + e);
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
function findUnder(root, relDirs, tail) {
|
|
418
|
+
for (const d of relDirs) {
|
|
419
|
+
const p = path.join(root, d, tail);
|
|
420
|
+
if (isFile(p)) return p;
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
function extractComponentRefs(text, relFile, root) {
|
|
425
|
+
const refs = new Map();
|
|
426
|
+
const abs = path.join(root, relFile);
|
|
427
|
+
const dir = path.dirname(abs);
|
|
428
|
+
const rel = p => path.relative(root, p).split(path.sep).join('/');
|
|
429
|
+
const add = (name, file) => { if (!refs.has(name)) refs.set(name, { name, file: file ? rel(file) : null }); };
|
|
430
|
+
const ext = path.extname(relFile).toLowerCase();
|
|
431
|
+
|
|
432
|
+
// JSX / TSX / Vue / Svelte / Astro: PascalCase tags resolved through imports.
|
|
433
|
+
if (['.tsx', '.jsx', '.js', '.ts', '.mjs', '.vue', '.svelte', '.astro'].includes(ext)) {
|
|
434
|
+
const imports = new Map();
|
|
435
|
+
for (const m of text.matchAll(/import\s+(?:type\s+)?(?:(\w+)|\{([^}]*)\}|(\w+)\s*,\s*\{([^}]*)\})\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
436
|
+
const names = [];
|
|
437
|
+
if (m[1]) names.push(m[1]);
|
|
438
|
+
if (m[3]) names.push(m[3]);
|
|
439
|
+
for (const g of [m[2], m[4]]) if (g) for (const part of g.split(',')) {
|
|
440
|
+
const n = part.trim().split(/\s+as\s+/).pop().trim();
|
|
441
|
+
if (n) names.push(n);
|
|
442
|
+
}
|
|
443
|
+
for (const n of names) imports.set(n, m[5]);
|
|
444
|
+
}
|
|
445
|
+
// JSX tags only: a generic (useState<Props>, FC<Params>) has an identifier
|
|
446
|
+
// or a closing bracket right before the `<`; a JSX tag never does.
|
|
447
|
+
for (const m of text.matchAll(/(?<![A-Za-z0-9_$\])])<([A-Z][A-Za-z0-9]*)(?:\.[A-Z][A-Za-z0-9]*)*(?=[\s/>])/g)) {
|
|
448
|
+
const name = m[1];
|
|
449
|
+
if (UI_PRIMITIVES.has(name) || PRIMITIVE_SUFFIX_RE.test(name) || ICON_PREFIX_RE.test(name)) continue;
|
|
450
|
+
const src = imports.get(name);
|
|
451
|
+
add(name, src ? resolveModule(src, dir, root) : null);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Rails partials + ViewComponent.
|
|
455
|
+
if (/\.(?:erb|haml|slim)$/.test(ext) || ext === '.rb') {
|
|
456
|
+
for (const m of text.matchAll(/render\s*\(?\s*(?:partial:\s*)?['"]([\w\/.-]+)['"]/g)) {
|
|
457
|
+
const name = m[1];
|
|
458
|
+
let file = null;
|
|
459
|
+
const base = name.split('/').pop();
|
|
460
|
+
const viewsRoot = abs.includes('/app/views/') ? abs.slice(0, abs.indexOf('/app/views/') + '/app/views/'.length) : null;
|
|
461
|
+
const dirs = name.includes('/') && viewsRoot ? [path.join(viewsRoot, path.dirname(name))] : [dir];
|
|
462
|
+
for (const d of dirs) for (const e of ['.html.erb', '.html.haml', '.html.slim', '.turbo_stream.erb']) {
|
|
463
|
+
const p = path.join(d, `_${base}${e}`);
|
|
464
|
+
if (isFile(p)) { file = p; break; }
|
|
465
|
+
}
|
|
466
|
+
add(name, file);
|
|
467
|
+
}
|
|
468
|
+
for (const m of text.matchAll(/render\s*\(?\s*([A-Z][A-Za-z0-9:]*Component)\.new/g)) {
|
|
469
|
+
const snake = m[1].replace(/::/g, '/').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
470
|
+
add(m[1], findUnder(root, ['app/components'], `${snake}.html.erb`) || findUnder(root, ['app/components'], `${snake}.rb`));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Laravel Blade / Livewire.
|
|
474
|
+
if (ext === '.php' && /\.blade\.php$/.test(relFile)) {
|
|
475
|
+
for (const m of text.matchAll(/@include(?:If|When|Unless)?\(\s*(?:[^,]+,\s*)?['"]([\w.-]+)['"]/g)) {
|
|
476
|
+
add(m[1], findUnder(root, ['resources/views'], m[1].replace(/\./g, '/') + '.blade.php'));
|
|
477
|
+
}
|
|
478
|
+
for (const m of text.matchAll(/<x-([\w.-]+)[\s/>]/g)) {
|
|
479
|
+
add(`x-${m[1]}`, findUnder(root, ['resources/views/components'], m[1].replace(/\./g, '/') + '.blade.php'));
|
|
480
|
+
}
|
|
481
|
+
for (const m of text.matchAll(/<livewire:([\w.-]+)[\s/>]/g)) {
|
|
482
|
+
add(`livewire:${m[1]}`, findUnder(root, ['resources/views/livewire'], m[1].replace(/\./g, '/') + '.blade.php'));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Plain PHP (WordPress admin): require/include of another admin file.
|
|
486
|
+
if (ext === '.php' && !/\.blade\.php$/.test(relFile)) {
|
|
487
|
+
for (const m of text.matchAll(/\b(?:require|include)(?:_once)?\s*\(?\s*(?:ABSPATH\s*\.\s*)?['"]([\w\/.-]+\.php)['"]/g)) {
|
|
488
|
+
const p = isFile(path.join(root, m[1])) ? path.join(root, m[1]) : (isFile(path.join(dir, m[1])) ? path.join(dir, m[1]) : null);
|
|
489
|
+
add(m[1], p);
|
|
490
|
+
}
|
|
491
|
+
for (const m of text.matchAll(/\bget_template_part\(\s*['"]([\w\/.-]+)['"]/g)) add(m[1], null);
|
|
492
|
+
}
|
|
493
|
+
// Django / Jinja / Twig / Nunjucks / Liquid includes.
|
|
494
|
+
for (const m of text.matchAll(/\{%-?\s*(?:include|extends|embed)\s+['"]([\w\/.-]+)['"]/g)) {
|
|
495
|
+
let p = null;
|
|
496
|
+
for (let d = dir; d.startsWith(root) && !p; d = path.dirname(d)) {
|
|
497
|
+
const cand = path.join(d, m[1]);
|
|
498
|
+
if (isFile(cand)) p = cand;
|
|
499
|
+
else if (isFile(path.join(d, 'templates', m[1]))) p = path.join(d, 'templates', m[1]);
|
|
500
|
+
if (d === root) break;
|
|
501
|
+
}
|
|
502
|
+
add(m[1], p);
|
|
503
|
+
}
|
|
504
|
+
// Razor partials.
|
|
505
|
+
if (ext === '.cshtml') {
|
|
506
|
+
for (const m of text.matchAll(/(?:<partial\s+name\s*=\s*["']([\w.-]+)["']|Partial(?:Async)?\(\s*["']([\w.-]+)["'])/g)) {
|
|
507
|
+
const name = m[1] || m[2];
|
|
508
|
+
add(name, findUnder(root, [rel(dir), 'Views/Shared', 'Areas/Admin/Views/Shared', 'Pages/Shared'], `${name}.cshtml`));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Angular custom elements and Phoenix function components: name-only.
|
|
512
|
+
if (ext === '.html' || ext === '.heex' || ext === '.eex') {
|
|
513
|
+
for (const m of text.matchAll(/<(app-[a-z0-9-]+|[a-z][a-z0-9]*-[a-z0-9-]+)[\s/>]/g)) add(m[1], null);
|
|
514
|
+
for (const m of text.matchAll(/<\.([a-z_][a-z0-9_]*)[\s/>]/g)) add(`.${m[1]}`, null);
|
|
515
|
+
for (const m of text.matchAll(/<([A-Z][\w.]*\.[a-z_][a-z0-9_]*)[\s/>]/g)) add(m[1], null);
|
|
516
|
+
}
|
|
517
|
+
return [...refs.values()];
|
|
518
|
+
}
|
|
519
|
+
function extractFormFields(text) {
|
|
520
|
+
const names = [];
|
|
521
|
+
for (const m of text.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
|
|
522
|
+
if (m[1].toLowerCase() === 'input' && /\btype\s*=\s*["']?hidden\b/i.test(m[2])) continue;
|
|
523
|
+
const n = m[2].match(/\bname\s*=\s*["']([^"']+)["']/i);
|
|
524
|
+
if (n) names.push(n[1]);
|
|
525
|
+
}
|
|
526
|
+
for (const re of [
|
|
527
|
+
/\bf(?:orm)?\.(?:text_field|email_field|password_field|number_field|text_area|select|collection_select|check_box|date_field|file_field|url_field|telephone_field|rich_text_area|search_field)\s*\(?\s*:(\w+)/g,
|
|
528
|
+
/\bregister\(\s*['"]([\w.\[\]]+)['"]/g,
|
|
529
|
+
/<(?:FormField|Field|Form\.Item|TextField|Input|Select|Textarea|Checkbox|Switch|InputField|SelectField|Controller)\b[^>]*\bname\s*=\s*["'{]([^"'}]+)["'}]/g,
|
|
530
|
+
/\bwire:model(?:\.\w+)*\s*=\s*["']([\w.]+)["']/g,
|
|
531
|
+
/\bv-model\s*=\s*["']([\w.]+)["']/g,
|
|
532
|
+
/\bformControlName\s*=\s*["']([\w.]+)["']/g,
|
|
533
|
+
/<\.input\b[^>]*\bfield=\{[^}]*\[:(\w+)\]/g,
|
|
534
|
+
/\basp-for\s*=\s*["']([\w.]+)["']/g,
|
|
535
|
+
]) for (const m of text.matchAll(re)) names.push(m[1]);
|
|
536
|
+
return uniq(names.map(n => n.replace(/\[\]$/, '')).filter(n => /^[\w.\[\]-]{2,60}$/.test(n)));
|
|
537
|
+
}
|
|
538
|
+
function humanize(name) {
|
|
539
|
+
const last = name.split(/[.\[\]]/).filter(Boolean).pop() || name;
|
|
540
|
+
return last.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
541
|
+
.replace(/^\w/, c => c.toUpperCase());
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─── ticket assembly ─────────────────────────────────────────────────────────
|
|
545
|
+
function buildTicket(ctx, block) {
|
|
546
|
+
const { root, articleDir, vs, lint, pm, shell, maxEdits } = ctx;
|
|
547
|
+
const entry = block.entry;
|
|
548
|
+
const htmlPath = path.join(articleDir, block.html);
|
|
549
|
+
const html = readText(htmlPath, 4 * 1024 * 1024);
|
|
550
|
+
const htmlText = extractVisibleText(html);
|
|
551
|
+
const htmlTextLower = htmlText.toLowerCase();
|
|
552
|
+
const tokens = attrTokens(html);
|
|
553
|
+
const htmlLower = html.toLowerCase();
|
|
554
|
+
const inHtml = s => html.includes(s);
|
|
555
|
+
// Presence for source-derived labels: visible text OR any attribute value
|
|
556
|
+
// (placeholders paint; a copied aria-label proves the markup was copied).
|
|
557
|
+
const inText = s => htmlLower.includes(String(s).toLowerCase());
|
|
558
|
+
const lintEntry = lintEntryFor(lint, block);
|
|
559
|
+
const metrics = (lintEntry && lintEntry.metrics) || {};
|
|
560
|
+
const errors = (lintEntry && lintEntry.errors) || [];
|
|
561
|
+
const warnings = (lintEntry && lintEntry.warnings) || [];
|
|
562
|
+
const diag = readJson(path.join(articleDir, block.diagnostics)) || {};
|
|
563
|
+
const dm = diag.metrics || {};
|
|
564
|
+
const quality = diag.qualityScore || {};
|
|
565
|
+
const evidenceVis = Array.isArray(dm.evidenceVisibility) ? dm.evidenceVisibility : [];
|
|
566
|
+
const probeVis = Array.isArray(dm.probeVisibility) ? dm.probeVisibility : [];
|
|
567
|
+
const visibility = new Map();
|
|
568
|
+
for (const v of [...evidenceVis, ...probeVis]) if (v && typeof v.string === 'string') visibility.set(norm(v.string), Boolean(v.visible));
|
|
569
|
+
const layout = layoutFor(pm, entry.layout);
|
|
570
|
+
const chromeFiles = ((layout && layout.chrome) || []).filter(f => !NON_SOURCE_PATH_RE.test(f));
|
|
571
|
+
const partials = Array.isArray(entry.partials_expanded)
|
|
572
|
+
? entry.partials_expanded.filter(p => typeof p === 'string' && !NON_SOURCE_PATH_RE.test(p)) : [];
|
|
573
|
+
const primary = typeof entry.primary_view === 'string' && !NON_SOURCE_PATH_RE.test(entry.primary_view) ? entry.primary_view : null;
|
|
574
|
+
const absence = absenceStrings(entry);
|
|
575
|
+
const targets = actionTargetsFor(vs, block);
|
|
576
|
+
|
|
577
|
+
const questions = [];
|
|
578
|
+
let qn = 0;
|
|
579
|
+
const ask = (kind, severity, source, text, check, extra) => {
|
|
580
|
+
questions.push({ id: `${block.key}#${++qn}`, kind, severity, source, text, check, ...(extra || {}) });
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// 1. Lint errors — verbatim; the Phase 3 loop should have cleared them, but a
|
|
584
|
+
// run that finished with a red lint still gets the pointer.
|
|
585
|
+
for (const e of errors) ask('lint_error', 'blocking', 'lint_report.json', e, { type: 'lint_errors_zero' });
|
|
586
|
+
|
|
587
|
+
// 2. Shell labels. The shell applies only to layouts the map ties it to: skip
|
|
588
|
+
// when the block claims a layout the map records as shell-hiding
|
|
589
|
+
// (chrome: [] + runtime_chrome — the fullscreen-editor case the lint's
|
|
590
|
+
// mode-contradiction check would reject anyway), a layout other than
|
|
591
|
+
// app_shell.layout when the map names one, or a layout whose own
|
|
592
|
+
// description says it has no sidebar. A block claiming a layout the map
|
|
593
|
+
// does not know is treated as shell-less too — a sidebar added on a
|
|
594
|
+
// guess is the wrong-mode failure, not polish.
|
|
595
|
+
let shellHidden = false;
|
|
596
|
+
let shellHiddenWhy = null;
|
|
597
|
+
if (layout && Array.isArray(layout.chrome) && layout.chrome.length === 0 && layout.runtime_chrome) {
|
|
598
|
+
shellHidden = true; shellHiddenWhy = 'layout records chrome: [] with a runtime_chrome recipe';
|
|
599
|
+
} else if (layout && shell && shell.layout && layout.name && shell.layout !== layout.name && shell.layout !== layout.path) {
|
|
600
|
+
shellHidden = true; shellHiddenWhy = `app_shell belongs to layout '${shell.layout}', this block claims '${layout.name}'`;
|
|
601
|
+
} else if (layout && SHELL_ABSENT_RE.test(`${layout.area || ''} ${layout.name || ''}`)) {
|
|
602
|
+
shellHidden = true; shellHiddenWhy = `layout '${layout.name || layout.path}' describes itself as shell-less`;
|
|
603
|
+
} else if (!layout && entry.layout) {
|
|
604
|
+
shellHidden = true; shellHiddenWhy = `claimed layout '${entry.layout}' is not in the project map`;
|
|
605
|
+
} else if (!layout && !entry.layout && (!pm || (pm.app_type || 'web') === 'web') && Array.isArray(pm && pm.layouts) && pm.layouts.length) {
|
|
606
|
+
// Web blocks name their layout; one that names none (a fullscreen editor,
|
|
607
|
+
// a modal-only state) did not claim the shell's layout. Non-web modes omit
|
|
608
|
+
// `layout` by contract, so their shell (tab bar, status bar) still applies.
|
|
609
|
+
shellHidden = true; shellHiddenWhy = 'block claims no layout in a web app whose shell is layout-bound';
|
|
610
|
+
}
|
|
611
|
+
const navRatio = ratioOf(metrics.shell_nav_labels);
|
|
612
|
+
if (shell && !shellHidden && shell.nav_labels.length >= 2) {
|
|
613
|
+
const missing = shell.nav_labels.filter(l => !inHtml(l));
|
|
614
|
+
const present = shell.nav_labels.filter(l => inHtml(l));
|
|
615
|
+
if (navRatio !== null && navRatio < T.shell_labels && missing.length) {
|
|
616
|
+
const severity = navRatio < T.shell_labels_blocking ? 'blocking' : 'advisory';
|
|
617
|
+
ask('shell_labels_missing', severity, 'lint_report.json metrics.shell_nav_labels',
|
|
618
|
+
`The map's app shell (${shell.type || 'shell'}) lists ${shell.nav_labels.length} navigation items; ` +
|
|
619
|
+
`${missing.length} are absent from this mockup's text: ${missing.map(s => JSON.stringify(s)).join(', ')}. ` +
|
|
620
|
+
`Open the PNG: if the real screen shows the shell here, add the missing items from the layout's chrome ` +
|
|
621
|
+
`files${chromeFiles.length ? ` (${chromeFiles.join(', ')})` : ''} — every item, with its icon and ` +
|
|
622
|
+
`badge where the map records one. If this surface genuinely hides the shell in this state, say so and leave it.`,
|
|
623
|
+
{ type: 'metric_ratio_min', key: 'shell_nav_labels', min: T.shell_labels },
|
|
624
|
+
{ missing, present });
|
|
625
|
+
}
|
|
626
|
+
// 2b. The render-visibility class: labels ARE in the HTML but paint nothing.
|
|
627
|
+
const measured = present.filter(l => visibility.has(l));
|
|
628
|
+
if (measured.length >= 2) {
|
|
629
|
+
const hidden = measured.filter(l => visibility.get(l) === false);
|
|
630
|
+
if (hidden.length * 2 > measured.length) {
|
|
631
|
+
ask('shell_not_painting', 'blocking', `${block.diagnostics} metrics.probeVisibility`,
|
|
632
|
+
`The shell is in the HTML but not in the screenshot: ${hidden.length}/${measured.length} navigation labels ` +
|
|
633
|
+
`(${hidden.map(s => JSON.stringify(s)).join(', ')}) paint 0 px in the PNG. A copied class pair like ` +
|
|
634
|
+
`"hidden lg:flex" or a fixed/absolute sidebar the content flow never accounts for hides it at the capture width. ` +
|
|
635
|
+
`Make the authored shell VISIBLE (drop the hiding class, add an inline display, give the content the sidebar's ` +
|
|
636
|
+
`offset) — do not re-author it.`,
|
|
637
|
+
{ type: 'probe_visible_majority', strings: measured }, { hidden });
|
|
638
|
+
} else if (hidden.length) {
|
|
639
|
+
ask('shell_item_hidden', 'advisory', `${block.diagnostics} metrics.probeVisibility`,
|
|
640
|
+
`${hidden.length} shell label(s) present in the HTML paint 0 px in the PNG: ` +
|
|
641
|
+
`${hidden.map(s => JSON.stringify(s)).join(', ')}. Check whether a class or clip hides them and make them visible.`,
|
|
642
|
+
{ type: 'strings_visible', strings: hidden }, { hidden });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// 2c. Footer / top-bar items the shell records — literal labels only, and
|
|
646
|
+
// only when the mockup renders the shell at all (majority of nav labels).
|
|
647
|
+
const rendersShell = present.length * 2 >= shell.nav_labels.length;
|
|
648
|
+
const footerLiteral = shell.footer_labels.filter(isLiteralLabel);
|
|
649
|
+
const footerMissing = footerLiteral.filter(l => !inHtml(l));
|
|
650
|
+
if (rendersShell && footerMissing.length) {
|
|
651
|
+
ask('shell_footer_missing', 'advisory', 'project_map.json app_shell.footer_items',
|
|
652
|
+
`The shell records ${footerLiteral.length} footer/bottom item(s); ${footerMissing.length} are absent: ` +
|
|
653
|
+
`${footerMissing.map(s => JSON.stringify(s)).join(', ')}. The mockup renders the shell, so its bottom section ` +
|
|
654
|
+
`should carry these too${shell.account_area ? ` (account area: ${shell.account_area})` : ''}.`,
|
|
655
|
+
{ type: 'html_contains_any', strings: footerMissing, min: Math.ceil(footerMissing.length / 2) });
|
|
656
|
+
}
|
|
657
|
+
if (shell.top_bar && rendersShell) {
|
|
658
|
+
const items = [...shell.top_bar.items_left, ...shell.top_bar.items_right];
|
|
659
|
+
const literal = items.filter(isLiteralLabel);
|
|
660
|
+
const tbMissing = literal.filter(l => !inHtml(l));
|
|
661
|
+
if (tbMissing.length) {
|
|
662
|
+
ask('shell_top_bar_missing', 'advisory', 'project_map.json app_shell.top_bar',
|
|
663
|
+
`The shell's top bar records ${items.length} item(s); ${tbMissing.length} literal label(s) are absent: ` +
|
|
664
|
+
`${tbMissing.map(s => JSON.stringify(s)).join(', ')}. Render the bar at item level (badges and bubbles included).`,
|
|
665
|
+
{ type: 'html_contains_any', strings: tbMissing, min: Math.ceil(tbMissing.length / 2) });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 3. Chrome files the layout records but this block never expanded.
|
|
671
|
+
const cfRatio = ratioOf(metrics.chrome_files_expanded);
|
|
672
|
+
if (cfRatio !== null && cfRatio < T.chrome_files && chromeFiles.length) {
|
|
673
|
+
const unexpanded = chromeFiles.filter(f => !partials.includes(f));
|
|
674
|
+
if (unexpanded.length) {
|
|
675
|
+
ask('chrome_file_unexpanded', errors.length ? 'advisory' : 'blocking', 'lint_report.json metrics.chrome_files_expanded',
|
|
676
|
+
`Layout '${layout.name || layout.path}' renders these chrome files around the content on every page, and this ` +
|
|
677
|
+
`block never expanded them: ${unexpanded.join(', ')}. Open each one, compare the region it emits with the PNG, ` +
|
|
678
|
+
`add what is missing, and list the file in partials_expanded.`,
|
|
679
|
+
{ type: 'metric_ratio_min', key: 'chrome_files_expanded', min: T.chrome_files }, { unexpanded });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 4. Runtime-widget chrome strings (fullscreen editors and other JS-drawn surfaces).
|
|
684
|
+
const rsRatio = ratioOf(metrics.runtime_ui_strings);
|
|
685
|
+
if (rsRatio !== null && rsRatio < T.runtime_strings && layout && layout.runtime_chrome) {
|
|
686
|
+
const rc = layout.runtime_chrome;
|
|
687
|
+
const strings = uniq([...(rc.ui_strings || []), ...((rc.regions || []).flatMap(r => (r && r.ui_strings) || []))]
|
|
688
|
+
.filter(s => typeof s === 'string' && s.length >= 2));
|
|
689
|
+
const missing = strings.filter(s => !inHtml(s));
|
|
690
|
+
ask('runtime_strings_missing', 'advisory', 'lint_report.json metrics.runtime_ui_strings',
|
|
691
|
+
`Layout '${layout.name || layout.path}' records the embedded app's chrome (${(rc.regions || []).length} region(s)); ` +
|
|
692
|
+
`${missing.length}/${strings.length} recorded UI strings are absent: ` +
|
|
693
|
+
`${missing.slice(0, 12).map(s => JSON.stringify(s)).join(', ')}${missing.length > 12 ? ', …' : ''}. ` +
|
|
694
|
+
`Compare the PNG against the recipe's regions in their recorded order and add the regions that are missing.`,
|
|
695
|
+
{ type: 'metric_ratio_min', key: 'runtime_ui_strings', min: T.runtime_strings }, { missing: missing.slice(0, 40) });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 5. Render diagnostics: content deductions only (failed external resources are not the model's).
|
|
699
|
+
const deductions = Array.isArray(quality.deductions) ? quality.deductions : [];
|
|
700
|
+
const contentDeductions = deductions.filter(d => CONTENT_DEDUCTION_RE.test(String(d)));
|
|
701
|
+
if (contentDeductions.length || quality.rating === 'poor') {
|
|
702
|
+
ask('render_quality', quality.rating === 'poor' ? 'blocking' : 'advisory', `${block.diagnostics} qualityScore`,
|
|
703
|
+
`The render scored ${quality.score ?? '?'} (${quality.rating || '?'}): ${contentDeductions.join('; ') || deductions.join('; ')}. ` +
|
|
704
|
+
`Open the PNG — the content is blank, collapsed, or cut off. Fix the layout so the subject paints inside the frame.`,
|
|
705
|
+
{ type: 'render_deductions_absent' });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 6. Evidence and action targets that paint nothing.
|
|
709
|
+
const evInvisible = evidenceVis.filter(v => v && v.visible === false).map(v => norm(v.string));
|
|
710
|
+
if (evInvisible.length) {
|
|
711
|
+
ask('evidence_invisible', evInvisible.length * 2 > evidenceVis.length ? 'blocking' : 'advisory',
|
|
712
|
+
`${block.diagnostics} metrics.evidenceVisibility`,
|
|
713
|
+
`${evInvisible.length}/${evidenceVis.length} evidence labels have no visible text or matching visible control in the PNG: ` +
|
|
714
|
+
`${evInvisible.map(s => JSON.stringify(s)).join(', ')}. Clipped out of frame or collapsed — depict the state where they are visible.`,
|
|
715
|
+
{ type: 'strings_visible', strings: evInvisible });
|
|
716
|
+
}
|
|
717
|
+
const targetInvisible = targets.filter(t => visibility.get(t.target) === false).map(t => t.target);
|
|
718
|
+
if (targetInvisible.length) {
|
|
719
|
+
ask('action_target_invisible', 'blocking', `${block.diagnostics} metrics.probeVisibility`,
|
|
720
|
+
`This screenshot must show where the reader acts, and the action target(s) ` +
|
|
721
|
+
`${targetInvisible.map(s => JSON.stringify(s)).join(', ')} have no visible matching control or text in the PNG. Make the control visible in its action-ready state.`,
|
|
722
|
+
{ type: 'strings_visible', strings: targetInvisible });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 7. Lint warnings that name concrete gaps.
|
|
726
|
+
const inventedCount = countOf(metrics.invented_copy);
|
|
727
|
+
if (inventedCount) {
|
|
728
|
+
const w = warnings.find(x => /do not appear in any listed source file/.test(x));
|
|
729
|
+
const quoted = w ? (w.match(/"([^"]+)"/g) || []).join(', ') : '';
|
|
730
|
+
ask('invented_copy', 'advisory', 'lint_report.json metrics.invented_copy',
|
|
731
|
+
`${inventedCount} multi-word phrase(s) in the mockup exist in no listed source file${quoted ? ` (${quoted})` : ''}. ` +
|
|
732
|
+
`Replace each with the real string from the source or locale file — copy is never paraphrased.`,
|
|
733
|
+
{ type: 'metric_count_max', key: 'invented_copy', max: 0 });
|
|
734
|
+
}
|
|
735
|
+
const ppRatio = ratioOf(metrics.partials_present);
|
|
736
|
+
if (ppRatio !== null && ppRatio < 1) {
|
|
737
|
+
const missing = partials.filter(p => !isFile(path.join(root, p)));
|
|
738
|
+
ask('partial_missing', 'advisory', 'lint_report.json metrics.partials_present',
|
|
739
|
+
`partials_expanded lists file(s) that do not exist: ${missing.join(', ')}. Correct the path(s) in view_sources.json ` +
|
|
740
|
+
`(and open the real file — a partial you could not open is a region you could not copy).`,
|
|
741
|
+
{ type: 'metric_ratio_min', key: 'partials_present', min: 1 });
|
|
742
|
+
}
|
|
743
|
+
const scRatio = ratioOf(metrics.styled_coverage);
|
|
744
|
+
const scTotal = metrics.styled_coverage && metrics.styled_coverage.total;
|
|
745
|
+
if (scRatio !== null && scRatio < T.styled_coverage && scTotal >= T.styled_min_total) {
|
|
746
|
+
const w = warnings.find(x => /unmatched:/.test(x));
|
|
747
|
+
const sample = w ? (w.match(/unmatched:\s*([^)]+)\)/) || [])[1] : null;
|
|
748
|
+
ask('styled_coverage_low', 'advisory', 'lint_report.json metrics.styled_coverage',
|
|
749
|
+
`Only ${Math.round(scRatio * 100)}% of this mockup's class/id tokens match a selector in branding.css/mockup.css` +
|
|
750
|
+
`${sample ? ` (unmatched sample: ${sample})` : ''}. Open the PNG: regions that look bare are usually classes the ` +
|
|
751
|
+
`bundle never styles. Where a region looks unstyled, prefer classes that exist in branding.css/mockup.css; ` +
|
|
752
|
+
`leave regions that already look right alone.`,
|
|
753
|
+
{ type: 'metric_ratio_min', key: 'styled_coverage', min: T.styled_coverage });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// 8. Source-derived questions — independent of the map's chrome inventory, so
|
|
757
|
+
// thin maps still get a pointer. Bounded file set, bounded output.
|
|
758
|
+
const scanFiles = uniq([primary, ...chromeFiles].filter(Boolean)).filter(f => isFile(path.join(root, f))).slice(0, CAPS.scan_files);
|
|
759
|
+
const componentFiles = uniq([primary, layout && layout.path, ...chromeFiles].filter(Boolean)).filter(f => isFile(path.join(root, f))).slice(0, CAPS.scan_files);
|
|
760
|
+
const locale = buildLocaleIndex(root, partials);
|
|
761
|
+
const sourceTexts = new Map();
|
|
762
|
+
const textOf = f => { if (!sourceTexts.has(f)) sourceTexts.set(f, readText(path.join(root, f))); return sourceTexts.get(f); };
|
|
763
|
+
|
|
764
|
+
// 8a. Components / partials the rendered files include that leave no trace in the mockup.
|
|
765
|
+
const unrendered = [];
|
|
766
|
+
const seenComponents = new Set();
|
|
767
|
+
for (const f of componentFiles) {
|
|
768
|
+
for (const ref of extractComponentRefs(textOf(f), f, root)) {
|
|
769
|
+
if (seenComponents.has(ref.name)) continue;
|
|
770
|
+
seenComponents.add(ref.name);
|
|
771
|
+
if (OVERLAY_NAME_RE.test(ref.name) || (ref.file && OVERLAY_NAME_RE.test(path.basename(ref.file)))) continue;
|
|
772
|
+
const toks = nameTokens(ref.name.replace(/^[.@]|^x-|^livewire:/, '').replace(/^_/, ''));
|
|
773
|
+
if (toks.some(t => t && (tokens.has(t) || [...tokens].some(x => x.includes(t) && t.length >= 5)))) continue;
|
|
774
|
+
let labels = [];
|
|
775
|
+
if (ref.file) {
|
|
776
|
+
const ft = textOf(ref.file);
|
|
777
|
+
labels = extractLabels(ft, { nav: true });
|
|
778
|
+
const keys = extractI18nKeys(ft).map(k => locale.lookup(k, lazyPrefixFor(ref.file))).filter(Boolean);
|
|
779
|
+
labels = uniq([...labels, ...keys]);
|
|
780
|
+
if (labels.some(l => inText(l))) continue;
|
|
781
|
+
} else if (!/[.\-_:]/.test(ref.name) && ref.name.length < 6) {
|
|
782
|
+
continue; // short unresolved PascalCase tags are too noisy to ticket
|
|
783
|
+
}
|
|
784
|
+
unrendered.push({ name: ref.name, file: ref.file, rendered_by: f, labels: labels.slice(0, 4) });
|
|
785
|
+
if (unrendered.length >= CAPS.components) break;
|
|
786
|
+
}
|
|
787
|
+
if (unrendered.length >= CAPS.components) break;
|
|
788
|
+
}
|
|
789
|
+
if (unrendered.length) {
|
|
790
|
+
ask('source_component_unrendered', 'advisory', 'source files',
|
|
791
|
+
`These components/partials are rendered by the files this screen is built from, and nothing in the mockup ` +
|
|
792
|
+
`corresponds to them: ${unrendered.map(u => `${u.name}${u.file ? ` (${u.file})` : ''} ← ${u.rendered_by}`).join('; ')}. ` +
|
|
793
|
+
`Open each one: if it draws a region the real screen shows in this state, add that region; if it is conditional ` +
|
|
794
|
+
`or empty for the default user, skip it.`,
|
|
795
|
+
{ type: 'html_contains_any', strings: uniq(unrendered.flatMap(u => [u.name, ...u.labels])), min: 1 }, { components: unrendered });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// 8a'. Screen closure: pieces the primary view renders UNCONDITIONALLY
|
|
799
|
+
// (includes + its own inline sections) that leave no trace in the mockup.
|
|
800
|
+
// Sharper than 8a because the census classifies by render condition —
|
|
801
|
+
// overlays and guarded pieces are exempt, so every item here is one the
|
|
802
|
+
// real screen shows in this state. Mirrors the lint's warn-only
|
|
803
|
+
// screen_closure check; modal steps (overlay primary_view) are skipped.
|
|
804
|
+
if (primary && !includeCensus.OVERLAY_NAME_RE.test(path.basename(primary))) {
|
|
805
|
+
const cen = includeCensus.census(root, primary);
|
|
806
|
+
const pieces = [];
|
|
807
|
+
for (const inc of cen.includes) if (inc.kind === 'unconditional' && inc.markers.length) pieces.push({ label: `${inc.name}${inc.file ? ` (${inc.file})` : ''}`, markers: inc.markers });
|
|
808
|
+
for (const sec of cen.inline_sections) if (!sec.guarded && sec.markers.length) pieces.push({ label: `inline section "${sec.heading}"`, markers: sec.markers });
|
|
809
|
+
const missing = pieces.filter(p => !p.markers.some(inText));
|
|
810
|
+
if (missing.length) {
|
|
811
|
+
// Blocking whenever ANY unconditional piece is missing: by construction every
|
|
812
|
+
// item here is something the real screen shows in this state (overlays and
|
|
813
|
+
// guarded pieces are already excluded), so there is no "advisory" reading.
|
|
814
|
+
ask('screen_piece_missing', 'blocking', `source ${primary}`,
|
|
815
|
+
`${primary} renders ${missing.length} piece(s) on every load that the mockup does not show: ` +
|
|
816
|
+
`${missing.map(p => `${p.label} [e.g. ${p.markers.slice(0, 2).map(s => JSON.stringify(s)).join(', ')}]`).join('; ')}. ` +
|
|
817
|
+
`The screen is the primary view plus every unconditional include and inline section; open the PNG and add each missing ` +
|
|
818
|
+
`piece where the source places it. Overlays and guarded pieces are already excluded from this list.`,
|
|
819
|
+
{ type: 'html_contains_any', strings: uniq(missing.flatMap(p => p.markers.slice(0, 3))), min: missing.length }, { pieces: missing });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// 8b. Labels the primary view and chrome files carry that the mockup lacks.
|
|
824
|
+
// Visible labels only, screen-shaped only (short, no sentence punctuation).
|
|
825
|
+
const labelCandidates = [];
|
|
826
|
+
for (const f of scanFiles) {
|
|
827
|
+
const ft = textOf(f);
|
|
828
|
+
const direct = extractLabels(ft, { nav: f !== primary, visibleOnly: true });
|
|
829
|
+
const resolved = extractI18nKeys(ft).map(k => locale.lookup(k, lazyPrefixFor(f))).map(v => v && cleanLabel(v)).filter(Boolean);
|
|
830
|
+
for (const l of uniq([...direct, ...resolved])) {
|
|
831
|
+
if (l.length < 3 || l.length > 60 || !isScreenLabel(l)) continue;
|
|
832
|
+
if (absence.some(a => l.toLowerCase().includes(a))) continue;
|
|
833
|
+
if (inText(l)) continue;
|
|
834
|
+
labelCandidates.push(l);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const labelsMissing = uniq(labelCandidates);
|
|
838
|
+
if (labelsMissing.length) {
|
|
839
|
+
const shown = labelsMissing.slice(0, CAPS.labels);
|
|
840
|
+
ask('source_labels_missing', 'advisory', 'source files',
|
|
841
|
+
`${labelsMissing.length} label(s) from the source files this screen renders do not appear in the mockup` +
|
|
842
|
+
`${labelsMissing.length > shown.length ? ` (first ${shown.length})` : ''}: ${shown.map(s => JSON.stringify(s)).join(', ')}. ` +
|
|
843
|
+
`Many belong to other states or branches — open the PNG and add only the ones the real screen shows in this block's ` +
|
|
844
|
+
`depicted state (headings, column headers, buttons, field labels, placeholders).`,
|
|
845
|
+
{ type: 'html_contains_any', strings: shown, min: Math.max(1, Math.ceil(shown.length / 2)) }, { labels: shown, total: labelsMissing.length });
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// 8c. Form fields: the primary view declares N controls, the mockup renders far fewer.
|
|
849
|
+
if (primary) {
|
|
850
|
+
const fields = extractFormFields(textOf(primary));
|
|
851
|
+
const mockupControls = countFormControls(html);
|
|
852
|
+
if (fields.length >= 3 && mockupControls < Math.ceil(fields.length / 2)) {
|
|
853
|
+
const missing = fields.filter(n => !inText(humanize(n)) && !tokens.has(n.toLowerCase())).slice(0, CAPS.form_fields);
|
|
854
|
+
ask('source_form_fields_missing', 'advisory', `source ${primary}`,
|
|
855
|
+
`${primary} declares ${fields.length} form field(s) and the mockup renders ${mockupControls} control(s). ` +
|
|
856
|
+
`Absent by name: ${missing.map(n => JSON.stringify(humanize(n))).join(', ')}. Open the PNG and add the fields the ` +
|
|
857
|
+
`real form shows in this state, each with its label, placeholder and control type from the source.`,
|
|
858
|
+
{ type: 'html_control_count_min', min: Math.ceil(fields.length / 2) }, { fields: missing });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// 8d. Navigation from the layout/chrome files when the map's shell is thin.
|
|
863
|
+
if ((!shell || shell.nav_labels.length < 2) && (layout || chromeFiles.length)) {
|
|
864
|
+
const navFiles = uniq([layout && layout.path, ...chromeFiles].filter(Boolean)).filter(f => isFile(path.join(root, f))).slice(0, CAPS.scan_files);
|
|
865
|
+
const navLabels = [];
|
|
866
|
+
for (const f of navFiles) {
|
|
867
|
+
const ft = textOf(f);
|
|
868
|
+
for (const m of ft.matchAll(/<nav\b[\s\S]{0,6000}?<\/nav>/gi)) navLabels.push(...extractLabels(m[0], { nav: true }));
|
|
869
|
+
navLabels.push(...extractI18nKeys(ft).map(k => locale.lookup(k, lazyPrefixFor(f))).filter(Boolean));
|
|
870
|
+
}
|
|
871
|
+
const missing = uniq(navLabels).filter(l => !inText(l)).slice(0, CAPS.nav);
|
|
872
|
+
if (missing.length >= 2) {
|
|
873
|
+
ask('source_nav_missing', 'advisory', 'layout / chrome source files',
|
|
874
|
+
`The map records no shell navigation, but the layout's own markup carries navigation labels the mockup lacks: ` +
|
|
875
|
+
`${missing.map(s => JSON.stringify(s)).join(', ')}. Open the PNG: if the real page shows this navigation, add it.`,
|
|
876
|
+
{ type: 'html_contains_any', strings: missing, min: Math.ceil(missing.length / 2) });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// 9. Always: the open comparison.
|
|
881
|
+
const readOrder = uniq([primary, layout && layout.path, ...chromeFiles, ...partials].filter(Boolean))
|
|
882
|
+
.filter(f => isFile(path.join(root, f)));
|
|
883
|
+
const ordered = [...readOrder.filter(f => !isLocaleFile(f)), ...readOrder.filter(isLocaleFile)].slice(0, CAPS.read_order);
|
|
884
|
+
ask('compare', 'advisory', 'PNG + source files',
|
|
885
|
+
`Open ${block.png}, then each file in sources.read_order. List what the real screen shows that the PNG does not — ` +
|
|
886
|
+
`regions, rows and their columns, controls, labels, badges, footers — and add it. If nothing is missing, say so.`,
|
|
887
|
+
{ type: 'manual' });
|
|
888
|
+
|
|
889
|
+
const blocking = questions.filter(q => q.severity === 'blocking').length;
|
|
890
|
+
return {
|
|
891
|
+
block_id: block.block_id, index: block.index, key: block.key,
|
|
892
|
+
html: block.html,
|
|
893
|
+
html_raw: isFile(path.join(articleDir, block.pre)) ? block.pre : null,
|
|
894
|
+
png: block.png,
|
|
895
|
+
sha256: {
|
|
896
|
+
html: sha256File(htmlPath),
|
|
897
|
+
html_raw: sha256File(path.join(articleDir, block.pre)),
|
|
898
|
+
png: sha256File(path.join(articleDir, block.png)),
|
|
899
|
+
},
|
|
900
|
+
url_or_route: entry.url_or_route || null,
|
|
901
|
+
depicted_state: typeof entry.depicted_state === 'string' ? entry.depicted_state : null,
|
|
902
|
+
sources: {
|
|
903
|
+
primary_view: primary,
|
|
904
|
+
layout: layout ? { name: layout.name, path: layout.path, area: layout.area, shell_hidden: shellHidden } : (entry.layout || null),
|
|
905
|
+
chrome_files: chromeFiles,
|
|
906
|
+
partials_expanded: partials,
|
|
907
|
+
read_order: ordered,
|
|
908
|
+
},
|
|
909
|
+
shell: shell && !shellHidden ? {
|
|
910
|
+
type: shell.type, nav_items: shell.nav_labels_all, footer_items: shell.footer_labels,
|
|
911
|
+
account_area: shell.account_area, top_bar: shell.top_bar, notes: shell.notes,
|
|
912
|
+
} : null,
|
|
913
|
+
action_targets: targets,
|
|
914
|
+
before: {
|
|
915
|
+
metrics: Object.keys(metrics).length ? metrics : null,
|
|
916
|
+
chrome: chromeMean(metrics),
|
|
917
|
+
render_score: typeof quality.score === 'number' ? quality.score : null,
|
|
918
|
+
render_rating: quality.rating || null,
|
|
919
|
+
deductions,
|
|
920
|
+
errors, warnings,
|
|
921
|
+
},
|
|
922
|
+
questions,
|
|
923
|
+
counts: { questions: questions.length, blocking, advisory: questions.length - blocking },
|
|
924
|
+
budget: { max_edits: maxEdits },
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function buildTickets(articleDir, root, env = process.env) {
|
|
929
|
+
const maxEdits = intEnv('RTFM_POLISH_MAX_EDITS', 6, env);
|
|
930
|
+
const maxBlocks = intEnv('RTFM_POLISH_MAX_BLOCKS', 0, env);
|
|
931
|
+
const vs = readJson(path.join(articleDir, 'view_sources.json'));
|
|
932
|
+
const lint = readJson(path.join(articleDir, 'lint_report.json'));
|
|
933
|
+
const pm = loadProjectMap(root);
|
|
934
|
+
const shell = shellSummary(pm);
|
|
935
|
+
const ctx = { root, articleDir, vs, lint, pm, shell, maxEdits };
|
|
936
|
+
const out = {
|
|
937
|
+
schema_version: SCHEMA_VERSION, enabled: true,
|
|
938
|
+
generated_at: new Date().toISOString(),
|
|
939
|
+
article_dir: path.resolve(articleDir), project_root: path.resolve(root),
|
|
940
|
+
app_type: (pm && pm.app_type) || 'web',
|
|
941
|
+
inputs: {
|
|
942
|
+
view_sources: Boolean(vs), lint_report: Boolean(lint), project_map: Boolean(pm),
|
|
943
|
+
app_shell: Boolean(shell),
|
|
944
|
+
},
|
|
945
|
+
budget: { max_edits: maxEdits, max_blocks: maxBlocks },
|
|
946
|
+
blocks: [], skipped: [],
|
|
947
|
+
};
|
|
948
|
+
if (!vs) {
|
|
949
|
+
out.skipped.push({ reason: 'no-view-sources' });
|
|
950
|
+
return out;
|
|
951
|
+
}
|
|
952
|
+
const tickets = [];
|
|
953
|
+
for (const block of blockEntries(vs)) {
|
|
954
|
+
if (block.entry.external_surface) { out.skipped.push({ block: block.key, reason: 'external-surface' }); continue; }
|
|
955
|
+
if (!isFile(path.join(articleDir, block.html))) { out.skipped.push({ block: block.key, reason: 'no-html' }); continue; }
|
|
956
|
+
if (!isFile(path.join(articleDir, block.png))) { out.skipped.push({ block: block.key, reason: 'no-png' }); continue; }
|
|
957
|
+
tickets.push(buildTicket(ctx, block));
|
|
958
|
+
}
|
|
959
|
+
// Worst first: blocking desc, advisory desc, then authored order.
|
|
960
|
+
const order = new Map(tickets.map((t, i) => [t.key, i]));
|
|
961
|
+
tickets.sort((a, b) => (b.counts.blocking - a.counts.blocking)
|
|
962
|
+
|| (b.counts.advisory - a.counts.advisory) || (order.get(a.key) - order.get(b.key)));
|
|
963
|
+
if (maxBlocks > 0 && tickets.length > maxBlocks) {
|
|
964
|
+
for (const t of tickets.slice(maxBlocks)) out.skipped.push({ block: t.key, reason: 'over-block-cap' });
|
|
965
|
+
out.blocks = tickets.slice(0, maxBlocks);
|
|
966
|
+
} else out.blocks = tickets;
|
|
967
|
+
out.totals = {
|
|
968
|
+
blocks_ticketed: out.blocks.length,
|
|
969
|
+
questions: out.blocks.reduce((n, t) => n + t.counts.questions, 0),
|
|
970
|
+
blocking: out.blocks.reduce((n, t) => n + t.counts.blocking, 0),
|
|
971
|
+
};
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ─── report ──────────────────────────────────────────────────────────────────
|
|
976
|
+
function evaluateCheck(check, after) {
|
|
977
|
+
if (!check || typeof check !== 'object') return null;
|
|
978
|
+
const m = after.metrics || {};
|
|
979
|
+
switch (check.type) {
|
|
980
|
+
case 'manual': return null;
|
|
981
|
+
case 'lint_errors_zero': return after.errors.length === 0;
|
|
982
|
+
case 'metric_ratio_min': { const r = ratioOf(m[check.key]); return r === null ? null : r >= check.min; }
|
|
983
|
+
case 'metric_count_max': { const c = countOf(m[check.key]); return c === null ? true : c <= check.max; }
|
|
984
|
+
case 'render_deductions_absent':
|
|
985
|
+
return after.rating !== 'poor' && !after.deductions.some(d => CONTENT_DEDUCTION_RE.test(String(d)));
|
|
986
|
+
case 'strings_visible':
|
|
987
|
+
return (check.strings || []).every(s => after.visibility.get(norm(s)) === true);
|
|
988
|
+
case 'probe_visible_majority': {
|
|
989
|
+
const vis = (check.strings || []).filter(s => after.visibility.get(norm(s)) === true).length;
|
|
990
|
+
return vis * 2 > (check.strings || []).length;
|
|
991
|
+
}
|
|
992
|
+
case 'html_contains_all': return (check.strings || []).every(s => after.html.includes(s));
|
|
993
|
+
case 'html_contains_any': {
|
|
994
|
+
const n = (check.strings || []).filter(s => after.html.includes(s) || after.textLower.includes(String(s).toLowerCase())).length;
|
|
995
|
+
return n >= (check.min || 1);
|
|
996
|
+
}
|
|
997
|
+
case 'html_control_count_min': return countFormControls(after.html) >= check.min;
|
|
998
|
+
default: return null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function buildReport(articleDir, root) {
|
|
1002
|
+
const tickets = readJson(path.join(articleDir, 'polish_tickets.json'));
|
|
1003
|
+
const report = { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), enabled: Boolean(tickets && tickets.enabled) };
|
|
1004
|
+
if (!tickets) { report.error = 'polish_tickets.json missing or unreadable'; report.blocks = []; report.totals = null; return report; }
|
|
1005
|
+
if (!tickets.enabled) { report.blocks = []; report.totals = null; return report; }
|
|
1006
|
+
const lint = readJson(path.join(articleDir, 'lint_report.json'));
|
|
1007
|
+
const vs = readJson(path.join(articleDir, 'view_sources.json'));
|
|
1008
|
+
const blocks = blockEntries(vs || {});
|
|
1009
|
+
report.blocks = [];
|
|
1010
|
+
for (const t of tickets.blocks || []) {
|
|
1011
|
+
const block = blocks.find(b => b.key === t.key) || {
|
|
1012
|
+
key: t.key, block_id: t.block_id, index: t.index, html: t.html, pre: `${t.html}.pre`, png: t.png,
|
|
1013
|
+
diagnostics: `${t.html.replace(/\.html$/, '')}_diagnostics.json`,
|
|
1014
|
+
};
|
|
1015
|
+
const lintEntry = lintEntryFor(lint, block);
|
|
1016
|
+
const diag = readJson(path.join(articleDir, block.diagnostics)) || {};
|
|
1017
|
+
const dm = diag.metrics || {};
|
|
1018
|
+
const visibility = new Map();
|
|
1019
|
+
for (const v of [...(dm.evidenceVisibility || []), ...(dm.probeVisibility || [])]) {
|
|
1020
|
+
if (v && typeof v.string === 'string') visibility.set(norm(v.string), Boolean(v.visible));
|
|
1021
|
+
}
|
|
1022
|
+
const html = readText(path.join(articleDir, block.html), 4 * 1024 * 1024);
|
|
1023
|
+
const after = {
|
|
1024
|
+
metrics: (lintEntry && lintEntry.metrics) || {},
|
|
1025
|
+
errors: (lintEntry && lintEntry.errors) || [],
|
|
1026
|
+
warnings: (lintEntry && lintEntry.warnings) || [],
|
|
1027
|
+
rating: (diag.qualityScore || {}).rating || null,
|
|
1028
|
+
score: typeof (diag.qualityScore || {}).score === 'number' ? diag.qualityScore.score : null,
|
|
1029
|
+
deductions: (diag.qualityScore || {}).deductions || [],
|
|
1030
|
+
visibility, html, textLower: extractVisibleText(html).toLowerCase(),
|
|
1031
|
+
};
|
|
1032
|
+
const results = (t.questions || []).map(q => ({
|
|
1033
|
+
id: q.id, kind: q.kind, severity: q.severity, check: q.check && q.check.type, resolved: evaluateCheck(q.check, after),
|
|
1034
|
+
}));
|
|
1035
|
+
const checkable = results.filter(r => r.resolved !== null);
|
|
1036
|
+
const rawSha = sha256File(path.join(articleDir, block.pre));
|
|
1037
|
+
const htmlSha = sha256File(path.join(articleDir, block.html));
|
|
1038
|
+
const beforeRaw = t.sha256 && t.sha256.html_raw;
|
|
1039
|
+
const htmlChanged = beforeRaw && rawSha ? rawSha !== beforeRaw : (t.sha256 ? htmlSha !== t.sha256.html : null);
|
|
1040
|
+
const pngSha = sha256File(path.join(articleDir, block.png));
|
|
1041
|
+
report.blocks.push({
|
|
1042
|
+
key: t.key, block_id: t.block_id, index: t.index,
|
|
1043
|
+
questions_total: results.length,
|
|
1044
|
+
blocking: results.filter(r => r.severity === 'blocking').length,
|
|
1045
|
+
checkable: checkable.length,
|
|
1046
|
+
resolved: checkable.filter(r => r.resolved === true).length,
|
|
1047
|
+
unresolved: checkable.filter(r => r.resolved === false).map(r => ({ id: r.id, kind: r.kind, severity: r.severity })),
|
|
1048
|
+
questions: results,
|
|
1049
|
+
html_changed: htmlChanged,
|
|
1050
|
+
png_changed: t.sha256 && t.sha256.png && pngSha ? pngSha !== t.sha256.png : null,
|
|
1051
|
+
render_before: (t.before || {}).render_score ?? null,
|
|
1052
|
+
render_after: after.score,
|
|
1053
|
+
chrome_before: (t.before || {}).chrome ?? null,
|
|
1054
|
+
chrome_after: chromeMean(after.metrics),
|
|
1055
|
+
errors_after: after.errors.length,
|
|
1056
|
+
warnings_after: after.warnings.length,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
const b = report.blocks;
|
|
1060
|
+
report.totals = {
|
|
1061
|
+
blocks_ticketed: b.length,
|
|
1062
|
+
questions: b.reduce((n, x) => n + x.questions_total, 0),
|
|
1063
|
+
blocking: b.reduce((n, x) => n + x.blocking, 0),
|
|
1064
|
+
checkable: b.reduce((n, x) => n + x.checkable, 0),
|
|
1065
|
+
resolved: b.reduce((n, x) => n + x.resolved, 0),
|
|
1066
|
+
blocks_html_changed: b.filter(x => x.html_changed === true).length,
|
|
1067
|
+
blocks_png_changed: b.filter(x => x.png_changed === true).length,
|
|
1068
|
+
blocks_with_errors_after: b.filter(x => x.errors_after > 0).length,
|
|
1069
|
+
};
|
|
1070
|
+
return report;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
1074
|
+
function main(argv = process.argv.slice(2), env = process.env) {
|
|
1075
|
+
const positional = argv.filter(a => !a.startsWith('--'));
|
|
1076
|
+
const reportMode = argv.includes('--report');
|
|
1077
|
+
const articleDir = positional[0];
|
|
1078
|
+
if (!articleDir || !fs.existsSync(articleDir)) {
|
|
1079
|
+
console.log('Usage: polish_tickets.js <article_dir> [project_root] [--report]');
|
|
1080
|
+
return 0;
|
|
1081
|
+
}
|
|
1082
|
+
const lintForRoot = readJson(path.join(articleDir, 'lint_report.json'));
|
|
1083
|
+
const rootArg = positional[1] || (lintForRoot && typeof lintForRoot.project_dir === 'string' && lintForRoot.project_dir !== '.' ? lintForRoot.project_dir : null);
|
|
1084
|
+
const root = path.resolve(rootArg || process.cwd());
|
|
1085
|
+
const outFile = path.join(articleDir, reportMode ? 'polish_report.json' : 'polish_tickets.json');
|
|
1086
|
+
|
|
1087
|
+
if (!polishEnabled(env)) {
|
|
1088
|
+
const disabled = reportMode
|
|
1089
|
+
? { schema_version: SCHEMA_VERSION, enabled: false, blocks: [], totals: null }
|
|
1090
|
+
: { schema_version: SCHEMA_VERSION, enabled: false, blocks: [], skipped: [] };
|
|
1091
|
+
fs.writeFileSync(outFile, JSON.stringify(disabled, null, 2));
|
|
1092
|
+
console.log('polish: disabled');
|
|
1093
|
+
return 0;
|
|
1094
|
+
}
|
|
1095
|
+
if (reportMode) {
|
|
1096
|
+
const report = buildReport(articleDir, root);
|
|
1097
|
+
fs.writeFileSync(outFile, JSON.stringify(report, null, 2));
|
|
1098
|
+
if (!report.enabled) { console.log('polish: disabled'); return 0; }
|
|
1099
|
+
const tt = report.totals || {};
|
|
1100
|
+
console.log(`polish: ${tt.blocks_ticketed || 0} block(s) ticketed, ${tt.questions || 0} question(s) ` +
|
|
1101
|
+
`(${tt.checkable || 0} checkable) — resolved ${tt.resolved || 0}/${tt.checkable || 0}; ` +
|
|
1102
|
+
`HTML changed ${tt.blocks_html_changed || 0}/${tt.blocks_ticketed || 0}, PNG changed ${tt.blocks_png_changed || 0}/${tt.blocks_ticketed || 0}` +
|
|
1103
|
+
`${tt.blocks_with_errors_after ? `; ${tt.blocks_with_errors_after} block(s) still have lint errors` : ''}`);
|
|
1104
|
+
for (const b of report.blocks) {
|
|
1105
|
+
const label = b.block_id !== null && b.block_id !== undefined ? `block_${b.block_id}` : `step_${b.index}`;
|
|
1106
|
+
console.log(` ${label}: resolved ${b.resolved}/${b.checkable}` +
|
|
1107
|
+
`${b.unresolved.length ? ` (open: ${b.unresolved.map(u => u.kind).join(', ')})` : ''}` +
|
|
1108
|
+
`; html ${b.html_changed === null ? '?' : (b.html_changed ? 'changed' : 'unchanged')}` +
|
|
1109
|
+
`, png ${b.png_changed === null ? '?' : (b.png_changed ? 'changed' : 'unchanged')}` +
|
|
1110
|
+
`; render ${b.render_before ?? '?'}→${b.render_after ?? '?'}; chrome ${b.chrome_before ?? '–'}→${b.chrome_after ?? '–'}`);
|
|
1111
|
+
}
|
|
1112
|
+
return 0;
|
|
1113
|
+
}
|
|
1114
|
+
const tickets = buildTickets(articleDir, root, env);
|
|
1115
|
+
fs.writeFileSync(outFile, JSON.stringify(tickets, null, 2));
|
|
1116
|
+
const tt = tickets.totals || { blocks_ticketed: 0, questions: 0, blocking: 0 };
|
|
1117
|
+
console.log(`polish: ${tt.blocks_ticketed} block(s) ticketed, ${tt.questions} question(s), ${tt.blocking} blocking ` +
|
|
1118
|
+
`(edit budget ${tickets.budget.max_edits}/block)${tickets.skipped.length ? `; skipped ${tickets.skipped.length}` : ''}`);
|
|
1119
|
+
for (const t of tickets.blocks) {
|
|
1120
|
+
console.log(` ${t.html}: ${t.counts.questions} question(s), ${t.counts.blocking} blocking — Read ${t.png}, then ` +
|
|
1121
|
+
`${t.sources.read_order.length ? t.sources.read_order.join(', ') : '(no readable sources)'}`);
|
|
1122
|
+
}
|
|
1123
|
+
for (const s of tickets.skipped) console.log(` skipped ${s.block || ''}: ${s.reason}`);
|
|
1124
|
+
if (argv.includes('--restore') && tickets.blocks.length) {
|
|
1125
|
+
let restored = 0;
|
|
1126
|
+
for (const t of tickets.blocks) {
|
|
1127
|
+
if (!t.html_raw) continue;
|
|
1128
|
+
try { fs.copyFileSync(path.join(articleDir, t.html_raw), path.join(articleDir, t.html)); restored++; } catch { /* keep injected */ }
|
|
1129
|
+
}
|
|
1130
|
+
console.log(`polish: ${restored} block(s) restored to their raw authored HTML (INJECT_CSS marker + {{img}} placeholders back) — ` +
|
|
1131
|
+
`edit those files; render_ready.js re-injects per block`);
|
|
1132
|
+
}
|
|
1133
|
+
if (!tickets.blocks.length) console.log('POLISH: nothing to do — skip the polish loop; the closing block below is still run.');
|
|
1134
|
+
else console.log(`POLISH: ${tickets.blocks.length} block(s) need a second look — for each, Read the PNG, then the sources, answer every question (blocking first), ADD what is missing, re-publish with render_ready.js.`);
|
|
1135
|
+
return 0;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (require.main === module) {
|
|
1139
|
+
try { process.exitCode = main(); } catch (err) {
|
|
1140
|
+
console.log(`polish: tickets unavailable (${err.message})`);
|
|
1141
|
+
process.exitCode = 0;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
module.exports = {
|
|
1146
|
+
buildTickets, buildReport, evaluateCheck, polishEnabled, chromeMean,
|
|
1147
|
+
extractLabels, extractI18nKeys, extractComponentRefs, extractFormFields, LocaleIndex, shellSummary, layoutFor,
|
|
1148
|
+
};
|