@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +237 -248
- package/dist/agent-src/commands/roadmap/process-full.md +1 -1
- package/dist/agent-src/commands/work.md +4 -0
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
- package/dist/agent-src/rules/ui-audit-gate.md +1 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
- package/dist/agent-src/skills/design-review/SKILL.md +30 -4
- package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +16 -1
- package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
- package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
- package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +23 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +30 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
- package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
- package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
- package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
- package/dist/cli/python/knowledge_ingest.js +1 -1
- package/dist/cli/python/workspace_drive.js +1 -1
- package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-4XFP2Y3E.js} +1 -1
- package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-7XGBX6ZH.js} +471 -387
- package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-FP36SWJD.js} +432 -144
- package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-IGD6ERX6.js} +31 -0
- package/dist/cli-delegate/{chunk-FXYUYU4N.js → chunk-JNMVLWLR.js} +42 -1
- package/dist/cli-delegate/{chunk-QJWAUMJW.js → chunk-YJ72UIAK.js} +730 -15
- package/dist/cli-delegate/cmd_conformance.js +902 -782
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +4 -4
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +10 -10
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +6666 -2581
- package/dist/hooks/dispatch.sha256 +1 -0
- package/dist/install/install.mjs +848 -733
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +2747 -1510
- package/dist/server/schemas/settings.js +23 -0
- package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
- package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
- package/dist/ui/index.html +2 -2
- package/docs/contracts/settings-classes.md +6 -4
- package/package.json +4 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
- package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
- package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
- package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
- package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
- package/src/config/agent-settings.template.yml +29 -0
- package/src/config/ci-local-parity.yml +18 -0
- package/src/config/continuity-surface.json +2 -2
- package/src/config/gate-coverage.yml +234 -3
- package/src/config/gate-violation-baselines.json +7 -3
- package/src/config/hook-latency-budget.json +21 -0
- package/src/config/hook-token-budget.json +2 -2
- package/src/config/metric-registry.yml +114 -0
- package/src/config/placeholder-drift-budget.json +10 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/scripts/_cli/cmd_doctor.ts +5 -19
- package/src/scripts/_cli/doctor_execution.ts +100 -17
- package/src/scripts/_cli/doctor_network_posture.ts +226 -0
- package/src/scripts/_lib/body_portable.ts +102 -0
- package/src/scripts/_lib/cc_transcript.ts +132 -1
- package/src/scripts/_lib/concern_sla_window.ts +200 -0
- package/src/scripts/_lib/forge_protection.ts +114 -0
- package/src/scripts/_lib/forge_reader.ts +558 -0
- package/src/scripts/_lib/host_env_write.ts +111 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
- package/src/scripts/_lib/obligations.ts +119 -8
- package/src/scripts/_lib/probe_inputs.ts +271 -0
- package/src/scripts/_lib/read_surface_scan.ts +322 -0
- package/src/scripts/_lib/release_highlights.ts +2 -1
- package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
- package/src/scripts/_lib/shell_write_shapes.ts +108 -0
- package/src/scripts/_lib/structural_hiding.ts +622 -0
- package/src/scripts/_lib/turn_end_refusals.ts +525 -4
- package/src/scripts/_lib/turn_end_transcript.ts +216 -0
- package/src/scripts/_lib/verification_command.ts +324 -0
- package/src/scripts/_lib/verification_evidence.ts +647 -0
- package/src/scripts/_lib/verify_clause.ts +280 -0
- package/src/scripts/ai_team/team_dispatch.ts +4 -0
- package/src/scripts/before_complete_hook.ts +340 -23
- package/src/scripts/bench_hook_latency.ts +412 -5
- package/src/scripts/cache_realization_report.ts +61 -1
- package/src/scripts/check_argument_hint.ts +287 -0
- package/src/scripts/check_claims.ts +125 -1
- package/src/scripts/check_enforcement_coverage.ts +38 -9
- package/src/scripts/check_enforcement_matrix.ts +245 -3
- package/src/scripts/check_finding_dispositions.ts +22 -3
- package/src/scripts/check_generator_sync.ts +71 -0
- package/src/scripts/check_host_docs_digest.ts +786 -0
- package/src/scripts/check_host_format_column.ts +559 -0
- package/src/scripts/check_input_references.ts +228 -0
- package/src/scripts/check_inputs_parity.ts +235 -0
- package/src/scripts/check_kernel_edit_ratified.ts +47 -5
- package/src/scripts/check_kill_switch_table.ts +294 -0
- package/src/scripts/check_placeholder_drift.ts +221 -0
- package/src/scripts/check_read_surface_coverage.ts +515 -0
- package/src/scripts/check_release_holds.ts +2 -1
- package/src/scripts/check_routing_coverage.ts +378 -11
- package/src/scripts/check_static_parity.ts +17 -0
- package/src/scripts/ci_settle.ts +145 -19
- package/src/scripts/closure_scan.ts +115 -1
- package/src/scripts/compile_hook_manifest.ts +60 -16
- package/src/scripts/external_sources_denylist.json +29 -4
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +42 -5
- package/src/scripts/hooks/block_config_weakening.ts +229 -4
- package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
- package/src/scripts/hooks/block_plumbing_writes.ts +314 -0
- package/src/scripts/hooks/bundle_integrity.ts +291 -0
- package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
- package/src/scripts/hooks/code_graph_context_hook.ts +1 -2
- package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/concern_timings.ts +94 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
- package/src/scripts/hooks/design_pass_hook.ts +100 -9
- package/src/scripts/hooks/design_slop_hook.ts +1 -2
- package/src/scripts/hooks/dispatch_hook.ts +25 -19
- package/src/scripts/hooks/edit_shape_hook.ts +1 -2
- package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
- package/src/scripts/hooks/evidence_independence.ts +1 -2
- package/src/scripts/hooks/exit_codes.ts +128 -0
- package/src/scripts/hooks/host_lowering.json +1 -0
- package/src/scripts/hooks/host_lowering.ts +128 -3
- package/src/scripts/hooks/host_lowering.yaml +262 -46
- package/src/scripts/hooks/host_semantics.ts +30 -2
- package/src/scripts/hooks/injection_budget.ts +9 -2
- package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
- package/src/scripts/hooks/journal_record_hook.ts +1 -2
- package/src/scripts/hooks/memory_recall_hook.ts +1 -2
- package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
- package/src/scripts/hooks/one_question_per_ask_hook.ts +25 -4
- package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
- package/src/scripts/hooks/permission_gate.ts +1 -2
- package/src/scripts/hooks/push_settle_hook.ts +1 -3
- package/src/scripts/hooks/replay_hook.ts +2 -1
- package/src/scripts/hooks/reread_guard_hook.ts +1 -2
- package/src/scripts/hooks/review_baseline_hook.ts +1 -2
- package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
- package/src/scripts/hooks/rule_inject_hook.ts +8 -5
- package/src/scripts/hooks/run_continuation_hook.ts +13 -15
- package/src/scripts/hooks/skill_route_hook.ts +1 -2
- package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
- package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
- package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
- package/src/scripts/hooks/table_fingerprint.ts +49 -0
- package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
- package/src/scripts/hooks/telemetry_usage_hook.ts +1 -2
- package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
- package/src/scripts/hooks/turn_end_gate_hook.ts +337 -324
- package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
- package/src/scripts/install.ts +3 -4
- package/src/scripts/lint_deny_text.ts +419 -0
- package/src/scripts/lint_exit_codes.ts +262 -0
- package/src/scripts/lint_hook_manifest.ts +55 -0
- package/src/scripts/lint_roadmap_blockers.ts +78 -12
- package/src/scripts/lint_skill_descriptions.ts +1 -1
- package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
- package/src/scripts/mcp_server/prompts.ts +142 -1
- package/src/scripts/measure_skill_ranker_baseline.ts +305 -51
- package/src/scripts/measure_turn_end_gate.ts +193 -0
- package/src/scripts/memory_lookup.ts +1 -1
- package/src/scripts/prepack-check.mjs +53 -0
- package/src/scripts/release_publication.ts +13 -1
- package/src/scripts/report_invocation_surface.ts +383 -0
- package/src/scripts/report_obligation_carriers.ts +699 -0
- package/src/scripts/report_obligation_writer_split.ts +236 -0
- package/src/scripts/report_skill_menu_census.ts +208 -0
- package/src/scripts/report_standing_payload_by_host.ts +1 -1
- package/src/scripts/roadmap_verify_share.ts +213 -0
- package/src/scripts/schemas/command.schema.json +44 -0
- package/src/scripts/schemas/review-findings.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +44 -0
- package/src/scripts/self_review_gate.ts +359 -10
- package/src/scripts/semantic_parity_census.ts +314 -0
- package/src/scripts/session_eol_report.ts +54 -1
- package/src/scripts/sweep_dead_scan_roots.ts +1 -1
- package/src/scripts/trigger_eval_grandfather.json +0 -2
- package/src/scripts/trigger_eval_rotation.ts +122 -27
- package/src/scripts/ui_conformance_probe.ts +156 -4
- package/src/scripts/update_prices.ts +85 -3
- package/src/scripts/write_bundle_digest.mjs +74 -0
- package/src/server/schemas/settings.ts +31 -0
- package/src/templates/consumer-settings/ONBOARDING.md +1 -0
- package/dist/ui/assets/index-DH1xc62v.js +0 -29
- package/dist/ui/assets/index-jSfMW8_t.css +0 -1
- /package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-7RQP4UDH.js} +0 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* check_host_docs_digest — the docs-drift watcher over `verified.docs_digest`.
|
|
4
|
+
*
|
|
5
|
+
* `host_lowering.yaml` records, per host, the page a row's answers were read
|
|
6
|
+
* off (`verified.docs_url`) and the day they were read (`docs_at`). Those two
|
|
7
|
+
* fields date the reading and nothing watches the page. A vendor can rewrite
|
|
8
|
+
* its hooks contract the day after a row is filled, and every gate in this tree
|
|
9
|
+
* stays green until the row's own `expires` lapses — up to a year later.
|
|
10
|
+
*
|
|
11
|
+
* `docs_digest` closes that window: the sha256 of the body as fetched by the
|
|
12
|
+
* last digest run. This script re-fetches each `docs_url` and compares.
|
|
13
|
+
*
|
|
14
|
+
* It is deliberately NOT "as fetched on `docs_at`". Those are two events — the
|
|
15
|
+
* reading a row's answers came from, and the hashing of a body — and the very
|
|
16
|
+
* first fill separated them (digests taken 2026-10-01 against rows read
|
|
17
|
+
* 2026-09-29). One sentence covering both asserted a provenance nobody had.
|
|
18
|
+
*
|
|
19
|
+
* WHAT A CHANGED DIGEST MEANS, AND WHAT IT DELIBERATELY DOES NOT.
|
|
20
|
+
* A changed body is evidence that the page moved, never a reading of what it
|
|
21
|
+
* now says. So the watcher adopts no upstream text, derives no slot, and
|
|
22
|
+
* changes no `block_exit`. The most it ever does is expire the row, which hands
|
|
23
|
+
* the question to `lint_hook_manifest._check_host_lowering` — a gate that
|
|
24
|
+
* already refuses an expired row carrying a blocking binding and only warns on
|
|
25
|
+
* one that does not. A red there opens a local finding for a human to read the
|
|
26
|
+
* page; it never encodes the page.
|
|
27
|
+
*
|
|
28
|
+
* "The most it EVER does", because in production it does less. The weekly job
|
|
29
|
+
* runs `--fetch` with no `--write`, so nothing is expired automatically: the
|
|
30
|
+
* job simply goes red. Expiry happens only when a human runs `--fetch --write`.
|
|
31
|
+
* An earlier version of this paragraph read as if escalation were automatic.
|
|
32
|
+
*
|
|
33
|
+
* WHY `expires` MOVES TO THE DAY BEFORE DETECTION, NOT TO THE DETECTION DATE.
|
|
34
|
+
* The table's own header states the rule and the reason: the gate tests
|
|
35
|
+
* `expires < today`, so `expires: <today>` is still valid TODAY and the refusal
|
|
36
|
+
* would not land until tomorrow. "Valid through yesterday" is how a row stops
|
|
37
|
+
* being current on the day the drift was seen. (The roadmap that asked for this
|
|
38
|
+
* watcher wrote `expires: <today>`; the table header is the later and correct
|
|
39
|
+
* statement, and this script follows the table.)
|
|
40
|
+
*
|
|
41
|
+
* WHY THE DIGEST IS NOT PART OF THE EXPIRY TEST ITSELF. A digest is evidence
|
|
42
|
+
* about the page; `expires` is this package's review-by date. Collapsing them
|
|
43
|
+
* would make a vendor's CSS rebuild read as a lapsed citation. The digest moves
|
|
44
|
+
* `expires`; it is never compared against a clock.
|
|
45
|
+
*
|
|
46
|
+
* MODES
|
|
47
|
+
* (no flags) Offline audit. Reports which rows can be watched, which
|
|
48
|
+
* carry no digest yet, and which carry no URL to watch.
|
|
49
|
+
* Touches the network never and exits 0 — it is a report,
|
|
50
|
+
* not a gate, so a CI job may run it with no egress.
|
|
51
|
+
* --fetch Re-fetch every `docs_url` and compare. Exit 1 if any
|
|
52
|
+
* digest CHANGED, 0 otherwise. This is the scheduled job's
|
|
53
|
+
* read-only mode: it NEVER touches the working tree. The
|
|
54
|
+
* table is written by exactly one `writeFileSync` call and
|
|
55
|
+
* it sits inside `if (doWrite)`, so `--fetch` alone cannot
|
|
56
|
+
* reach it; `--write` without `--fetch` is refused outright
|
|
57
|
+
* (exit 2) rather than silently doing nothing.
|
|
58
|
+
* --fetch --write The same, and write the result back into the YAML: fill a
|
|
59
|
+
* null digest, and on a CHANGED digest also pull `expires`
|
|
60
|
+
* back to the day before detection.
|
|
61
|
+
* --self-test Fixture round-trip over the pure functions. No network.
|
|
62
|
+
*
|
|
63
|
+
* A row whose `docs_url` is null is not a gap and is not an error: `cowork`
|
|
64
|
+
* has no public hooks page to cite, which the table says in its own comment.
|
|
65
|
+
* There is no body, so there is no digest, and the watcher reports it as
|
|
66
|
+
* `no-url` and moves on.
|
|
67
|
+
*
|
|
68
|
+
* UNREACHABLE IS NOT CHANGED. A fetch that fails — offline runner, 503, a
|
|
69
|
+
* timeout — establishes nothing about the page, so it never expires a row. It
|
|
70
|
+
* is reported, and with `--strict-fetch` it also fails the run, so a scheduled
|
|
71
|
+
* job can distinguish "nothing moved" from "nobody could look".
|
|
72
|
+
*
|
|
73
|
+
* GONE IS NOT UNREACHABLE. A 404/410 is the server answering authoritatively
|
|
74
|
+
* that the page is not there, which is evidence the citation is dead — the
|
|
75
|
+
* opposite of "nobody could look". It fails the run WITHOUT `--strict-fetch`,
|
|
76
|
+
* because the scheduled job omits that flag precisely so transient flakes stay
|
|
77
|
+
* quiet, and a dead citation keeping a stale digest and a year-long `expires`
|
|
78
|
+
* in silence is the failure this watcher exists to prevent. It still writes
|
|
79
|
+
* nothing: a human decides whether the page moved or the host dropped hooks.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
import * as crypto from 'node:crypto';
|
|
83
|
+
import * as fs from 'node:fs';
|
|
84
|
+
import * as os from 'node:os';
|
|
85
|
+
import * as path from 'node:path';
|
|
86
|
+
import { fileURLToPath } from 'node:url';
|
|
87
|
+
|
|
88
|
+
import { asOf } from './_lib/as_of.js';
|
|
89
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
90
|
+
import { sanitize_text } from './_lib/retrieval_sanitize.js';
|
|
91
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
92
|
+
import { HOST_LOWERING_PATH, parseHostLowering, type HostLowering } from './hooks/host_lowering.js';
|
|
93
|
+
|
|
94
|
+
/** sha256, hex — the digest shape `verified.docs_digest` carries. */
|
|
95
|
+
export function digestOf(body: string): string {
|
|
96
|
+
return crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type DigestState =
|
|
100
|
+
/** Row has a digest and the fetched body still hashes to it. */
|
|
101
|
+
| 'unchanged'
|
|
102
|
+
/** Row had a digest and the body now hashes to something else. */
|
|
103
|
+
| 'changed'
|
|
104
|
+
/** Row had no digest; this run establishes the first one. */
|
|
105
|
+
| 'filled'
|
|
106
|
+
/** Row cites no page — nothing to hash. Not a defect. */
|
|
107
|
+
| 'no-url'
|
|
108
|
+
/** The page could not be read — transient. Establishes nothing either way. */
|
|
109
|
+
| 'unreachable'
|
|
110
|
+
/**
|
|
111
|
+
* The page is GONE — the server answered, authoritatively, that it is not
|
|
112
|
+
* there (404/410).
|
|
113
|
+
*
|
|
114
|
+
* Split from `unreachable` because lumping them made a dead citation
|
|
115
|
+
* indistinguishable from a flaky one, and the scheduled job deliberately
|
|
116
|
+
* does not pass `--strict-fetch` so that flakes stay quiet. A removed page
|
|
117
|
+
* is the opposite of "nobody could look": it is strong evidence the
|
|
118
|
+
* citation is no longer valid, and a row whose page no longer exists must
|
|
119
|
+
* not keep a year-long `expires` and a stale digest in silence.
|
|
120
|
+
*/
|
|
121
|
+
| 'gone';
|
|
122
|
+
|
|
123
|
+
export interface DigestFinding {
|
|
124
|
+
readonly host: string;
|
|
125
|
+
readonly surface: string;
|
|
126
|
+
readonly url: string | null;
|
|
127
|
+
readonly state: DigestState;
|
|
128
|
+
/** Digest recorded in the table before this run. */
|
|
129
|
+
readonly was: string | null;
|
|
130
|
+
/** Digest computed this run, or null when nothing was read. */
|
|
131
|
+
readonly now: string | null;
|
|
132
|
+
/** Why a fetch failed, for `unreachable`. */
|
|
133
|
+
readonly detail?: string | undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one state that moves `expires`. */
|
|
137
|
+
export function isDrift(f: DigestFinding): boolean {
|
|
138
|
+
return f.state === 'changed';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The day before `today`, in ISO form — see the header for why. */
|
|
142
|
+
export function dayBefore(today: string): string {
|
|
143
|
+
const d = new Date(`${today}T00:00:00Z`);
|
|
144
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
145
|
+
return d.toISOString().slice(0, 10);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Classify one row against a body that was (or was not) read.
|
|
150
|
+
*
|
|
151
|
+
* Split from the fetching so the whole decision is testable with no network —
|
|
152
|
+
* the half that carries the risk is this one, and a test that could only reach
|
|
153
|
+
* it through a live HTTP call would be watching the wrong thing.
|
|
154
|
+
*/
|
|
155
|
+
export function classify(
|
|
156
|
+
host: string,
|
|
157
|
+
surface: string,
|
|
158
|
+
url: string | null,
|
|
159
|
+
recorded: string | null,
|
|
160
|
+
body: string | null,
|
|
161
|
+
detail?: string,
|
|
162
|
+
): DigestFinding {
|
|
163
|
+
if (url === null) {
|
|
164
|
+
return { host, surface, url, state: 'no-url', was: recorded, now: null };
|
|
165
|
+
}
|
|
166
|
+
if (body === null) {
|
|
167
|
+
// `gone` is decided by the CALLER (it saw the status code) and arrives
|
|
168
|
+
// as the detail prefix, because `classify` is pure and never fetches.
|
|
169
|
+
const state: DigestState = detail !== undefined && detail.startsWith('GONE ') ? 'gone' : 'unreachable';
|
|
170
|
+
return { host, surface, url, state, was: recorded, now: null, detail };
|
|
171
|
+
}
|
|
172
|
+
const now = digestOf(body);
|
|
173
|
+
if (recorded === null) {
|
|
174
|
+
return { host, surface, url, state: 'filled', was: null, now };
|
|
175
|
+
}
|
|
176
|
+
return { host, surface, url, state: recorded === now ? 'unchanged' : 'changed', was: recorded, now };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every (host, surface) the table carries, with the URL and digest it records. */
|
|
180
|
+
export function watchable(
|
|
181
|
+
table: HostLowering,
|
|
182
|
+
): ReadonlyArray<{ host: string; surface: string; url: string | null; digest: string | null }> {
|
|
183
|
+
const out: Array<{ host: string; surface: string; url: string | null; digest: string | null }> = [];
|
|
184
|
+
for (const [host, surfaces] of table) {
|
|
185
|
+
for (const [surface, row] of surfaces) {
|
|
186
|
+
if (row.verified === null) continue;
|
|
187
|
+
out.push({
|
|
188
|
+
host,
|
|
189
|
+
surface,
|
|
190
|
+
url: row.verified.docs_url,
|
|
191
|
+
digest: row.verified.docs_digest,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Write findings back into the YAML TEXT by editing the two lines that change.
|
|
200
|
+
*
|
|
201
|
+
* NOT a `parseDocument` round-trip, and the reason is measured rather than
|
|
202
|
+
* assumed. `parseDocument(...).toString()` on this table keeps every comment
|
|
203
|
+
* but renormalises the hand-aligned flow mappings in `slots:` — 361 bytes of
|
|
204
|
+
* deliberate column alignment across 32 rows, none of it this watcher's to
|
|
205
|
+
* touch. A scheduled job whose first write reformats a third of the file is a
|
|
206
|
+
* job whose diffs nobody reads. Editing the `docs_digest:` and `expires:` lines
|
|
207
|
+
* in place keeps the change to exactly the lines whose content changed, which
|
|
208
|
+
* is also what makes the resulting commit reviewable.
|
|
209
|
+
*
|
|
210
|
+
* The two lines are located structurally, not by a bare `grep`: the walk tracks
|
|
211
|
+
* the host (indent 2) and surface (indent 6) headers and only rewrites fields
|
|
212
|
+
* inside that row's `verified:` block, so a `docs_digest:` appearing in a
|
|
213
|
+
* comment or under a different host is never hit.
|
|
214
|
+
*/
|
|
215
|
+
export function applyFindings(
|
|
216
|
+
yamlText: string,
|
|
217
|
+
findings: readonly DigestFinding[],
|
|
218
|
+
today: string,
|
|
219
|
+
): { text: string; expired: string[]; written: string[]; missed: string[] } {
|
|
220
|
+
const wanted = new Map<string, DigestFinding>();
|
|
221
|
+
for (const f of findings) {
|
|
222
|
+
// `gone` belongs in this skip list and was missing from it, which made
|
|
223
|
+
// the one state that means "the citation is dead" the one state that
|
|
224
|
+
// ERASED the evidence: it fell through to the digest branch and wrote
|
|
225
|
+
// `docs_digest: null`. That is self-defeating as well as wrong — with a
|
|
226
|
+
// null digest and an untouched year-long `expires`, a page that later
|
|
227
|
+
// returned would classify as `filled` and the drift signal would be
|
|
228
|
+
// gone permanently. The module header already promised a `gone` row
|
|
229
|
+
// writes nothing; this is the line that makes the promise true.
|
|
230
|
+
if (f.state === 'no-url' || f.state === 'unreachable' || f.state === 'gone') continue;
|
|
231
|
+
wanted.set(`${f.host}/${f.surface}`, f);
|
|
232
|
+
}
|
|
233
|
+
if (wanted.size === 0) return { text: yamlText, expired: [], written: [], missed: [] };
|
|
234
|
+
|
|
235
|
+
const expired: string[] = [];
|
|
236
|
+
// Which rows a line was actually rewritten for. Without this the caller
|
|
237
|
+
// reported `filled` — "newly recorded" — for a row whose `verified:` block
|
|
238
|
+
// carries no `docs_digest:` key at all: the walk finds nothing to replace,
|
|
239
|
+
// writes nothing, and the run still claims success. A write claimed and not
|
|
240
|
+
// performed is worse than a loud failure, so the two sets are compared.
|
|
241
|
+
const written: string[] = [];
|
|
242
|
+
const lines = yamlText.split('\n');
|
|
243
|
+
let host: string | null = null;
|
|
244
|
+
let surface: string | null = null;
|
|
245
|
+
let inVerified = false;
|
|
246
|
+
|
|
247
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
248
|
+
const line = lines[i] as string;
|
|
249
|
+
const hostM = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
|
250
|
+
if (hostM) {
|
|
251
|
+
host = hostM[1] as string;
|
|
252
|
+
surface = null;
|
|
253
|
+
inVerified = false;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const surfaceM = /^ {6}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
|
257
|
+
if (surfaceM) {
|
|
258
|
+
surface = surfaceM[1] as string;
|
|
259
|
+
inVerified = false;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (/^ {8}verified:\s*$/.test(line)) {
|
|
263
|
+
inVerified = true;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// Any other KEY at the row's own indent closes the block — but a
|
|
267
|
+
// comment at that indent does not. The table puts long findings in
|
|
268
|
+
// `#`-comments at every depth, and treating one as the block end would
|
|
269
|
+
// silently stop rewriting the fields below it: a no-op write that
|
|
270
|
+
// reports success, which is the worst shape a watcher can have.
|
|
271
|
+
if (inVerified && /^ {8}\S/.test(line) && !/^\s*#/.test(line)) {
|
|
272
|
+
inVerified = false;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!inVerified || host === null || surface === null) continue;
|
|
276
|
+
|
|
277
|
+
const f = wanted.get(`${host}/${surface}`);
|
|
278
|
+
if (f === undefined) continue;
|
|
279
|
+
|
|
280
|
+
// A DRIFTED ROW KEEPS ITS RECORDED DIGEST. Two reasons, and the second
|
|
281
|
+
// is the one that matters more.
|
|
282
|
+
//
|
|
283
|
+
// 1. Provenance. The digest names the body a human ESTABLISHED for
|
|
284
|
+
// this row. Silently replacing it with a body nobody read makes the
|
|
285
|
+
// field assert an establishment that never happened — a false
|
|
286
|
+
// statement in a table whose whole purpose is that its statements
|
|
287
|
+
// are true, and a quiet form of exactly the "adopts no upstream
|
|
288
|
+
// text" this module promises not to do. (Two earlier versions of
|
|
289
|
+
// this comment were themselves wrong about the field's definition;
|
|
290
|
+
// see `host_lowering.ts` for the three retired wordings and why
|
|
291
|
+
// `docs_at` and `docs_digest` are independent rather than a pair.)
|
|
292
|
+
// 2. The drift signal would erase itself. Overwriting the digest means
|
|
293
|
+
// the NEXT run compares new-against-new and reports `unchanged`, so
|
|
294
|
+
// a human who misses the one red week never learns the page moved.
|
|
295
|
+
// Keeping the old digest holds the row red until a human re-reads
|
|
296
|
+
// the page and re-establishes `docs_at` + `docs_digest` together.
|
|
297
|
+
//
|
|
298
|
+
// So drift writes `expires` ONLY. A first fill (`filled`) still writes
|
|
299
|
+
// the digest, because there is no prior reading to contradict.
|
|
300
|
+
if (isDrift(f)) {
|
|
301
|
+
const expiresM = /^(\s+expires:\s*).*$/.exec(line);
|
|
302
|
+
if (expiresM) {
|
|
303
|
+
lines[i] = `${expiresM[1] as string}${dayBefore(today)}`;
|
|
304
|
+
expired.push(`${host}/${surface}`);
|
|
305
|
+
written.push(`${host}/${surface}`);
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const digestM = /^(\s+docs_digest:\s*).*$/.exec(line);
|
|
310
|
+
if (digestM) {
|
|
311
|
+
lines[i] = `${digestM[1] as string}${f.now ?? 'null'}`;
|
|
312
|
+
written.push(`${host}/${surface}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const missed = [...wanted.keys()].filter((k) => !written.includes(k));
|
|
316
|
+
return { text: lines.join('\n'), expired, written, missed };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function fetchBody(url: string, timeoutMs: number): Promise<{ body: string | null; detail?: string }> {
|
|
320
|
+
const ac = new AbortController();
|
|
321
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
|
324
|
+
// 404/410 is the server stating authoritatively that the page is not
|
|
325
|
+
// there. Everything else non-ok — 5xx, 429, a proxy error — may be
|
|
326
|
+
// transient and must not condemn a citation.
|
|
327
|
+
if (res.status === 404 || res.status === 410) {
|
|
328
|
+
return { body: null, detail: `GONE HTTP ${res.status}` };
|
|
329
|
+
}
|
|
330
|
+
if (!res.ok) return { body: null, detail: `HTTP ${res.status}` };
|
|
331
|
+
return { body: await res.text() };
|
|
332
|
+
} catch (exc) {
|
|
333
|
+
// The ONE fetch-derived string this script ever prints, so it is the one
|
|
334
|
+
// that gets the sanitize floor. The body is hashed and discarded — a
|
|
335
|
+
// sha256 cannot carry a bidi override — but an exception message can
|
|
336
|
+
// quote a server-supplied URL or header, and this text lands on stdout
|
|
337
|
+
// in a CI log a human reads. Stripping hidden-instruction vectors here
|
|
338
|
+
// is what makes this module's `covered` row in
|
|
339
|
+
// `docs/contracts/retrieval-read-surfaces.md` a true statement rather
|
|
340
|
+
// than an import that satisfies a generator.
|
|
341
|
+
return { body: null, detail: sanitize_text(exc instanceof Error ? exc.message : String(exc)) };
|
|
342
|
+
} finally {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const KNOWN_FLAGS: ReadonlySet<string> = new Set([
|
|
348
|
+
// `--as-of` is consumed by `asOf()` itself, not by this parser, but it MUST
|
|
349
|
+
// be listed: `asOf()` prints "Pass --as-of <iso> to pin it" on every
|
|
350
|
+
// unpinned run, and following that advice used to exit 2 on an unknown
|
|
351
|
+
// flag. A tool that refuses its own printed advice is worse than one that
|
|
352
|
+
// gives none.
|
|
353
|
+
'--as-of',
|
|
354
|
+
'--fetch',
|
|
355
|
+
'--write',
|
|
356
|
+
'--self-test',
|
|
357
|
+
'--strict-fetch',
|
|
358
|
+
'--quiet',
|
|
359
|
+
'--lowering',
|
|
360
|
+
'--today',
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
const STATE_ICON: Record<DigestState, string> = {
|
|
364
|
+
unchanged: '✅',
|
|
365
|
+
changed: '❌',
|
|
366
|
+
filled: '🆕',
|
|
367
|
+
'no-url': '—',
|
|
368
|
+
unreachable: '⚠️ ',
|
|
369
|
+
gone: '💀',
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function selfTest(): number {
|
|
373
|
+
const problems: string[] = [];
|
|
374
|
+
if (digestOf('abc') !== crypto.createHash('sha256').update('abc').digest('hex')) {
|
|
375
|
+
problems.push('digestOf disagrees with sha256');
|
|
376
|
+
}
|
|
377
|
+
if (dayBefore('2026-10-01') !== '2026-09-30') problems.push('dayBefore month boundary');
|
|
378
|
+
if (dayBefore('2026-01-01') !== '2025-12-31') problems.push('dayBefore year boundary');
|
|
379
|
+
|
|
380
|
+
const same = classify('h', 'any', 'u', digestOf('body'), 'body');
|
|
381
|
+
if (same.state !== 'unchanged') problems.push('equal body must be unchanged');
|
|
382
|
+
const moved = classify('h', 'any', 'u', digestOf('body'), 'other');
|
|
383
|
+
if (moved.state !== 'changed') problems.push('different body must be changed');
|
|
384
|
+
if (classify('h', 'any', 'u', null, 'body').state !== 'filled') problems.push('null digest must fill');
|
|
385
|
+
if (classify('h', 'any', null, null, null).state !== 'no-url') problems.push('null url must be no-url');
|
|
386
|
+
if (classify('h', 'any', 'u', 'd', null).state !== 'unreachable') problems.push('no body must be unreachable');
|
|
387
|
+
|
|
388
|
+
// An unreachable row must not expire anything — the property the whole
|
|
389
|
+
// "unreachable is not changed" paragraph rests on.
|
|
390
|
+
const src = fs.readFileSync(HOST_LOWERING_PATH, 'utf-8');
|
|
391
|
+
const untouched = applyFindings(src, [classify('claude', 'any', 'u', 'd', null)], '2026-10-01');
|
|
392
|
+
if (untouched.expired.length !== 0) problems.push('unreachable must not expire a row');
|
|
393
|
+
if (untouched.text !== src) problems.push('unreachable must not edit the table');
|
|
394
|
+
|
|
395
|
+
const drifted = applyFindings(src, [classify('claude', 'any', 'u', 'old', 'new-body')], '2026-10-01');
|
|
396
|
+
if (!drifted.text.includes('WHY THIS FILE EXISTS')) problems.push('write must preserve comments');
|
|
397
|
+
if (drifted.expired.join() !== 'claude/any') problems.push('drift must report the row it expired');
|
|
398
|
+
|
|
399
|
+
// The write touches EXACTLY the one line whose content changed — `expires`
|
|
400
|
+
// — and the hand-aligned `slots:` block is not it. Counting the differing
|
|
401
|
+
// lines is what keeps the "minimal diff" claim in the block comment above
|
|
402
|
+
// honest; asserting only that the new value is present would pass just as
|
|
403
|
+
// well on a full reformat. One line and not two: a drifted row keeps its
|
|
404
|
+
// recorded digest, see `applyFindings`.
|
|
405
|
+
const before = src.split('\n');
|
|
406
|
+
const after = drifted.text.split('\n');
|
|
407
|
+
const changedLines = before.length === after.length
|
|
408
|
+
? before.reduce((n, l, i) => (l === after[i] ? n : n + 1), 0)
|
|
409
|
+
: -1;
|
|
410
|
+
if (changedLines !== 1) {
|
|
411
|
+
problems.push(`drift write must change exactly 1 line, changed ${changedLines}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A sibling row must be untouched. Derived from the table rather than
|
|
415
|
+
// compared against a literal date: a hard-coded `expires` here reds the
|
|
416
|
+
// self-test whenever an unrelated row is legitimately renewed, which is a
|
|
417
|
+
// fixture asserting the calendar instead of the code.
|
|
418
|
+
const siblingBefore = parseHostLowering(src).get('gemini')?.get('any')?.verified;
|
|
419
|
+
const siblingAfter = parseHostLowering(drifted.text).get('gemini')?.get('any')?.verified;
|
|
420
|
+
if (siblingBefore?.expires !== siblingAfter?.expires) problems.push('a sibling row must not be expired');
|
|
421
|
+
if (siblingBefore?.docs_digest !== siblingAfter?.docs_digest) problems.push('a sibling digest must not move');
|
|
422
|
+
|
|
423
|
+
// The drifted row's OWN digest is unchanged, anchored at the row rather
|
|
424
|
+
// than by a bare `includes` that any row could satisfy.
|
|
425
|
+
const driftedRow = parseHostLowering(drifted.text).get('claude')?.get('any')?.verified;
|
|
426
|
+
if (driftedRow?.expires !== '2026-09-30') problems.push('drifted row must expire to the day before');
|
|
427
|
+
if (driftedRow?.docs_digest !== parseHostLowering(src).get('claude')?.get('any')?.verified?.docs_digest) {
|
|
428
|
+
problems.push('drift must not overwrite the recorded digest');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 404 is not a flake.
|
|
432
|
+
if (classify('h', 'any', 'u', 'd', null, 'GONE HTTP 404').state !== 'gone') problems.push('404 must be gone');
|
|
433
|
+
if (classify('h', 'any', 'u', 'd', null, 'HTTP 503').state !== 'unreachable') problems.push('503 must be unreachable');
|
|
434
|
+
|
|
435
|
+
// A `gone` row writes nothing — the high finding of round 3, asserted here
|
|
436
|
+
// as well as in the vitest suite because this is the block the
|
|
437
|
+
// `no_canary_reason` cites as the negative control.
|
|
438
|
+
const deadRow = applyFindings(src, [classify('claude', 'any', 'u', 'old', null, 'GONE HTTP 404')], '2026-10-01');
|
|
439
|
+
if (deadRow.text !== src) problems.push('a gone row must not edit the table');
|
|
440
|
+
if (deadRow.written.length !== 0) problems.push('a gone row must write nothing');
|
|
441
|
+
|
|
442
|
+
// `written` vs `missed`: a row resolved but not writable must be REPORTED,
|
|
443
|
+
// not counted as recorded. The `no_canary_reason` credits this block with
|
|
444
|
+
// covering that direction, so it has to actually be here.
|
|
445
|
+
const noKey = src.replace(/\n\s+docs_digest: [0-9a-f]{64}/, '');
|
|
446
|
+
const missedRun = applyFindings(noKey, [classify('claude', 'any', 'u', null, 'body')], '2026-10-01');
|
|
447
|
+
if (missedRun.missed.join() !== 'claude/any') problems.push('a row with no digest key must be reported as missed');
|
|
448
|
+
if (missedRun.written.length !== 0) problems.push('a missed row must not count as written');
|
|
449
|
+
|
|
450
|
+
for (const p of problems) process.stderr.write(`❌ ${p}\n`);
|
|
451
|
+
|
|
452
|
+
// The pure-function checks above prove the DECISIONS. These prove the
|
|
453
|
+
// BINARY — argv parsing and the entry guard included — by shelling out to
|
|
454
|
+
// the real CLI, which is the layer that has silently no-opped in this
|
|
455
|
+
// repository before. `gate-self-test:registered-non-adopters` requires it
|
|
456
|
+
// of any registered gate, and the requirement is right: an enforced
|
|
457
|
+
// `scanned:` floor only proves the gate READ something.
|
|
458
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
459
|
+
const rel = 'src/scripts/check_host_docs_digest.ts';
|
|
460
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chdd-selftest-'));
|
|
461
|
+
const emptyTable = path.join(tmp, 'empty.yaml');
|
|
462
|
+
fs.writeFileSync(emptyTable, 'schema_version: 1\nhosts: {}\n');
|
|
463
|
+
const malformed = path.join(tmp, 'bad.yaml');
|
|
464
|
+
fs.writeFileSync(malformed, 'schema_version: 1\nnot_hosts: []\n');
|
|
465
|
+
|
|
466
|
+
const cliCode = runSelfTest({
|
|
467
|
+
gate: 'check_host_docs_digest',
|
|
468
|
+
minCases: 6,
|
|
469
|
+
minRejectCases: 4,
|
|
470
|
+
cases: [
|
|
471
|
+
{
|
|
472
|
+
name: 'an unknown flag is refused, not ignored',
|
|
473
|
+
expect: 'reject',
|
|
474
|
+
run: () => runGateCli(repoRoot, rel, ['--not-a-flag'], repoRoot),
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: '`--flag=value` resolves instead of being silently dropped',
|
|
478
|
+
expect: 'reject',
|
|
479
|
+
run: () => runGateCli(repoRoot, rel, ['--lowering=/nonexistent/table.yaml'], repoRoot),
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: '`--write` without `--fetch` is refused',
|
|
483
|
+
expect: 'reject',
|
|
484
|
+
run: () => runGateCli(repoRoot, rel, ['--write'], repoRoot),
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
name: 'an empty table fails the scan-scope floor rather than reporting clean',
|
|
488
|
+
expect: 'reject',
|
|
489
|
+
run: () => runGateCli(repoRoot, rel, ['--lowering', emptyTable, '--quiet'], repoRoot),
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: 'a malformed table exits non-zero instead of throwing past the contract',
|
|
493
|
+
expect: 'reject',
|
|
494
|
+
run: () => runGateCli(repoRoot, rel, ['--lowering', malformed, '--quiet'], repoRoot),
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'the committed table passes the offline report',
|
|
498
|
+
expect: 'accept',
|
|
499
|
+
run: () => runGateCli(repoRoot, rel, ['--quiet'], repoRoot),
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
});
|
|
503
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
504
|
+
|
|
505
|
+
if (problems.length === 0 && cliCode === 0) {
|
|
506
|
+
process.stdout.write('✅ check_host_docs_digest: self-test passed.\n');
|
|
507
|
+
}
|
|
508
|
+
return problems.length === 0 && cliCode === 0 ? 0 : 1;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export async function main(argv: readonly string[]): Promise<number> {
|
|
512
|
+
for (const a of argv) {
|
|
513
|
+
if (a.startsWith('--') && !KNOWN_FLAGS.has(a.split('=')[0] ?? a)) {
|
|
514
|
+
process.stderr.write(
|
|
515
|
+
`check_host_docs_digest: unknown flag \`${a}\`. Known: ${[...KNOWN_FLAGS].sort().join(', ')}\n`,
|
|
516
|
+
);
|
|
517
|
+
return 2;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (argv.includes('--self-test')) return selfTest();
|
|
521
|
+
|
|
522
|
+
const quiet = argv.includes('--quiet');
|
|
523
|
+
const doFetch = argv.includes('--fetch');
|
|
524
|
+
const doWrite = argv.includes('--write');
|
|
525
|
+
const strictFetch = argv.includes('--strict-fetch');
|
|
526
|
+
/**
|
|
527
|
+
* Resolve `--flag value` AND `--flag=value`, and refuse a flag with no value.
|
|
528
|
+
*
|
|
529
|
+
* The first version accepted only the space form while the unknown-flag
|
|
530
|
+
* guard above split on `=` — so `--lowering=/nonexistent` passed validation
|
|
531
|
+
* and was then silently ignored, and the run read the REAL table and
|
|
532
|
+
* reported green. That is the precise fail-open this guard exists to
|
|
533
|
+
* prevent, and a sibling gate shipped it once already. A missing value is
|
|
534
|
+
* an error rather than a null, because `--today` as the last argument used
|
|
535
|
+
* to fall through to the wall clock and `--today --quiet` used to take
|
|
536
|
+
* `--quiet` as the date.
|
|
537
|
+
*/
|
|
538
|
+
const bad: string[] = [];
|
|
539
|
+
const valueOf = (flag: string): string | null => {
|
|
540
|
+
const inline = argv.find((a) => a.startsWith(`${flag}=`));
|
|
541
|
+
if (inline !== undefined) {
|
|
542
|
+
const v = inline.slice(flag.length + 1);
|
|
543
|
+
if (v === '') bad.push(`${flag}= has an empty value`);
|
|
544
|
+
return v === '' ? null : v;
|
|
545
|
+
}
|
|
546
|
+
const i = argv.indexOf(flag);
|
|
547
|
+
if (i < 0) return null;
|
|
548
|
+
const next = argv[i + 1];
|
|
549
|
+
if (next === undefined || next.startsWith('--')) {
|
|
550
|
+
bad.push(`${flag} needs a value`);
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return next;
|
|
554
|
+
};
|
|
555
|
+
const tablePath = valueOf('--lowering') ?? HOST_LOWERING_PATH;
|
|
556
|
+
const todayRaw = valueOf('--today');
|
|
557
|
+
if (todayRaw !== null) {
|
|
558
|
+
// Shape AND validity. Shape alone accepted `2026-13-45`, which then
|
|
559
|
+
// built an `Invalid Date` in `dayBefore` and threw `RangeError` out of
|
|
560
|
+
// `toISOString()` — surfacing as a bare stack trace from the catch-all,
|
|
561
|
+
// which is the outcome this parser exists to prevent. Checked here, at
|
|
562
|
+
// the boundary, rather than defended against at every use.
|
|
563
|
+
const shapeOk = /^\d{4}-\d{2}-\d{2}$/.test(todayRaw);
|
|
564
|
+
const realDate = shapeOk && !Number.isNaN(new Date(`${todayRaw}T00:00:00Z`).getTime())
|
|
565
|
+
// `new Date('2026-02-31')` does NOT produce Invalid Date in every
|
|
566
|
+
// engine — it can roll over into March — so the round-trip is what
|
|
567
|
+
// actually rejects a non-existent day.
|
|
568
|
+
&& new Date(`${todayRaw}T00:00:00Z`).toISOString().slice(0, 10) === todayRaw;
|
|
569
|
+
if (!realDate) {
|
|
570
|
+
bad.push(`--today ${todayRaw} is not a real ISO YYYY-MM-DD date`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (bad.length > 0) {
|
|
574
|
+
for (const b of bad) process.stderr.write(`check_host_docs_digest: ${b}.\n`);
|
|
575
|
+
return 2;
|
|
576
|
+
}
|
|
577
|
+
// `asOf()` rather than the wall clock: a verdict that depends on the hour it
|
|
578
|
+
// ran is not reproducible from the commit, and this one writes a date INTO
|
|
579
|
+
// the tree. It resolves --as-of, AC_AS_OF and the commit date under CI.
|
|
580
|
+
const today = todayRaw ?? asOf().toISOString().slice(0, 10);
|
|
581
|
+
|
|
582
|
+
if (strictFetch && !doFetch) {
|
|
583
|
+
process.stderr.write(
|
|
584
|
+
'check_host_docs_digest: `--strict-fetch` needs `--fetch` — it qualifies a fetch verdict, ' +
|
|
585
|
+
'and silently doing nothing is the failure this script argues against.\n',
|
|
586
|
+
);
|
|
587
|
+
return 2;
|
|
588
|
+
}
|
|
589
|
+
if (doWrite && !doFetch) {
|
|
590
|
+
process.stderr.write('check_host_docs_digest: `--write` needs `--fetch` — there is nothing to write without a read.\n');
|
|
591
|
+
return 2;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// The PARSE is inside the guard too, not only the read. A malformed table
|
|
595
|
+
// threw past the `return 2` contract and surfaced as an unhandled rejection
|
|
596
|
+
// — a stack trace on the scheduled job, which contradicts that job's own
|
|
597
|
+
// promise that a red there means "go read a page, nothing is broken here".
|
|
598
|
+
let text: string;
|
|
599
|
+
let table: HostLowering;
|
|
600
|
+
try {
|
|
601
|
+
text = fs.readFileSync(tablePath, 'utf-8');
|
|
602
|
+
table = parseHostLowering(text);
|
|
603
|
+
} catch (exc) {
|
|
604
|
+
process.stderr.write(
|
|
605
|
+
`check_host_docs_digest: cannot read or parse ${tablePath}: ` +
|
|
606
|
+
`${exc instanceof Error ? exc.message : String(exc)}\n`,
|
|
607
|
+
);
|
|
608
|
+
return 2;
|
|
609
|
+
}
|
|
610
|
+
const rows = watchable(table);
|
|
611
|
+
// Scan-scope hardening, the same contract every other gate here carries: a
|
|
612
|
+
// gate that silently walks an empty corpus reports "clean" and is
|
|
613
|
+
// indistinguishable from a gate that works. If the table is ever moved or
|
|
614
|
+
// its shape changes so `watchable` resolves nothing, this exits non-zero
|
|
615
|
+
// instead of reporting no drift across zero rows.
|
|
616
|
+
try {
|
|
617
|
+
reportScanned({
|
|
618
|
+
gate: 'check_host_docs_digest',
|
|
619
|
+
scanned: rows.length,
|
|
620
|
+
units: 'verified row(s)',
|
|
621
|
+
roots: ['src/scripts/hooks/host_lowering.yaml'],
|
|
622
|
+
});
|
|
623
|
+
} catch (exc) {
|
|
624
|
+
// An empty or moved corpus is an ANTICIPATED input condition, so it
|
|
625
|
+
// gets the house treatment — one line, exit 2 — rather than escaping to
|
|
626
|
+
// the module-entry catch-all as a stack trace. The catch-all is for the
|
|
627
|
+
// unanticipated; this header argues that twice and then let this one
|
|
628
|
+
// through, which a completion review caught.
|
|
629
|
+
if (exc instanceof DeadScopeError) {
|
|
630
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
631
|
+
return 2;
|
|
632
|
+
}
|
|
633
|
+
throw exc;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!doFetch) {
|
|
637
|
+
const withDigest = rows.filter((r) => r.digest !== null).length;
|
|
638
|
+
const noUrl = rows.filter((r) => r.url === null).length;
|
|
639
|
+
if (!quiet) {
|
|
640
|
+
process.stdout.write(
|
|
641
|
+
`check_host_docs_digest: ${rows.length} verified row(s) — ` +
|
|
642
|
+
`${withDigest} watched, ${rows.length - withDigest - noUrl} awaiting a first digest, ` +
|
|
643
|
+
`${noUrl} with no page to watch.\n`,
|
|
644
|
+
);
|
|
645
|
+
for (const r of rows) {
|
|
646
|
+
const mark = r.url === null ? '—' : r.digest === null ? '🆕' : '✅';
|
|
647
|
+
process.stdout.write(` ${mark} ${r.host}/${r.surface} ${r.url ?? '(no docs_url)'}\n`);
|
|
648
|
+
}
|
|
649
|
+
process.stdout.write(' Offline report — pass `--fetch` to compare against the live pages.\n');
|
|
650
|
+
}
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const findings: DigestFinding[] = [];
|
|
655
|
+
for (const r of rows) {
|
|
656
|
+
if (r.url === null) {
|
|
657
|
+
findings.push(classify(r.host, r.surface, null, r.digest, null));
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const { body, detail } = await fetchBody(r.url, 30_000);
|
|
661
|
+
findings.push(classify(r.host, r.surface, r.url, r.digest, body, detail));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (!quiet) {
|
|
665
|
+
for (const f of findings) {
|
|
666
|
+
// `gone` carries its status code in `detail` exactly as
|
|
667
|
+
// `unreachable` does, and printing it only for the latter meant a
|
|
668
|
+
// dead citation logged without saying whether it was a 404 or a 410.
|
|
669
|
+
const extra =
|
|
670
|
+
f.state === 'unreachable' || f.state === 'gone' ? ` (${f.detail ?? 'unreadable'})` : '';
|
|
671
|
+
process.stdout.write(` ${STATE_ICON[f.state]} ${f.host}/${f.surface} — ${f.state}${extra}\n`);
|
|
672
|
+
// A drift line names everything needed to act on it without a
|
|
673
|
+
// second run: which page, what it hashed to before, what it hashes
|
|
674
|
+
// to now, and when that was seen. A bare "changed" sends the reader
|
|
675
|
+
// back to the terminal to find out what changed about what.
|
|
676
|
+
if (isDrift(f)) {
|
|
677
|
+
process.stdout.write(
|
|
678
|
+
` url: ${f.url ?? '(none)'}\n` +
|
|
679
|
+
` was: ${f.was ?? 'null'}\n` +
|
|
680
|
+
` now: ${f.now ?? 'null'}\n` +
|
|
681
|
+
` seen: ${today}\n`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const drifted = findings.filter(isDrift);
|
|
688
|
+
const unreachable = findings.filter((f) => f.state === 'unreachable');
|
|
689
|
+
|
|
690
|
+
if (doWrite) {
|
|
691
|
+
const { text: next, expired, missed } = applyFindings(text, findings, today);
|
|
692
|
+
if (next !== text) {
|
|
693
|
+
fs.writeFileSync(tablePath, next);
|
|
694
|
+
process.stdout.write(`✍️ ${path.basename(tablePath)} updated.\n`);
|
|
695
|
+
// The compiled sibling is fingerprinted over the YAML TEXT, so any
|
|
696
|
+
// write here desyncs it and reds `task preflight` and CI. Both
|
|
697
|
+
// completion reviewers found this the same way: by watching it
|
|
698
|
+
// happen on this branch. Naming the command beats rediscovering it.
|
|
699
|
+
process.stdout.write(
|
|
700
|
+
' NEXT: ./scripts-run src/scripts/compile_hook_manifest --table host-lowering\n' +
|
|
701
|
+
' (the compiled sibling is hashed over the YAML text, so this write makes it stale)\n',
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
for (const e of expired) {
|
|
705
|
+
process.stdout.write(
|
|
706
|
+
` ${e}: docs body changed — \`expires\` pulled to ${dayBefore(today)}, digest left as recorded.\n` +
|
|
707
|
+
' This marks the row stale; it does NOT re-establish it. To re-establish: read the page, then set\n' +
|
|
708
|
+
' `docs_at` to today, `docs_digest` to the new body\'s hash, and `expires` FORWARD again — nothing\n' +
|
|
709
|
+
' moves `expires` forward automatically, so a row left half-fixed stays red in `lint_hook_manifest`.\n',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (missed.length > 0) {
|
|
713
|
+
// A row the walk could not write — most plausibly a `verified:`
|
|
714
|
+
// block carrying no `docs_digest:` key at all. Silence here would
|
|
715
|
+
// have the run report "newly recorded" for a row it never touched.
|
|
716
|
+
process.stderr.write(
|
|
717
|
+
`❌ check_host_docs_digest: ${missed.length} row(s) were resolved but NOT written: ` +
|
|
718
|
+
`${missed.join(', ')}. The \`verified:\` block is probably missing the key the walk rewrites. ` +
|
|
719
|
+
'Nothing was claimed for them.\n',
|
|
720
|
+
);
|
|
721
|
+
return 2;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (drifted.length > 0) {
|
|
726
|
+
process.stderr.write(
|
|
727
|
+
`❌ check_host_docs_digest: ${drifted.length} host doc page(s) changed since the row was established: ` +
|
|
728
|
+
`${drifted.map((f) => f.host).join(', ')}. This is a signal to READ the page, never to adopt it.\n`,
|
|
729
|
+
);
|
|
730
|
+
return 1;
|
|
731
|
+
}
|
|
732
|
+
const gone = findings.filter((f) => f.state === 'gone');
|
|
733
|
+
if (gone.length > 0) {
|
|
734
|
+
process.stderr.write(
|
|
735
|
+
`❌ check_host_docs_digest: ${gone.length} cited page(s) are GONE (404/410): ` +
|
|
736
|
+
`${gone.map((f) => `${f.host} ${f.url ?? ''}`).join(', ')}. ` +
|
|
737
|
+
'A removed page is not a flake — the citation is dead and the row still carries its digest and `expires`. ' +
|
|
738
|
+
'Find the current page, re-read it, and re-establish the row by hand.\n',
|
|
739
|
+
);
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
if (unreachable.length > 0 && strictFetch) {
|
|
743
|
+
process.stderr.write(
|
|
744
|
+
`❌ check_host_docs_digest: ${unreachable.length} page(s) could not be read ` +
|
|
745
|
+
`(${unreachable.map((f) => f.host).join(', ')}). Unreachable establishes nothing — re-run with egress.\n`,
|
|
746
|
+
);
|
|
747
|
+
return 1;
|
|
748
|
+
}
|
|
749
|
+
if (!quiet) {
|
|
750
|
+
const filled = findings.filter((f) => f.state === 'filled').length;
|
|
751
|
+
// "newly recorded" is only true when something was written. In the
|
|
752
|
+
// read-only mode the scheduled job runs, a row with a null digest was
|
|
753
|
+
// computed and discarded, and calling that "recorded" would have the
|
|
754
|
+
// weekly job claim, every week and forever, to have fixed something it
|
|
755
|
+
// did not touch.
|
|
756
|
+
const filledPhrase = doWrite
|
|
757
|
+
? `${filled} newly recorded`
|
|
758
|
+
: `${filled} with no digest yet (not written — this run is read-only)`;
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
`✅ check_host_docs_digest: no host documentation drift ` +
|
|
761
|
+
`(${findings.filter((f) => f.state === 'unchanged').length} unchanged, ` +
|
|
762
|
+
`${filledPhrase}, ${unreachable.length} unreachable).\n`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const _invokedDirectly =
|
|
769
|
+
process.argv[1] !== undefined && /check_host_docs_digest\.ts$/.test(process.argv[1]);
|
|
770
|
+
if (_invokedDirectly) {
|
|
771
|
+
// `.catch` as well as `.then`: without it any throw this module did not
|
|
772
|
+
// anticipate leaves an unhandled rejection rather than the documented
|
|
773
|
+
// exit-2 contract, and on a scheduled job the difference between "exit 2,
|
|
774
|
+
// something is wrong with the inputs" and a bare stack trace is the whole
|
|
775
|
+
// signal.
|
|
776
|
+
void main(process.argv.slice(2))
|
|
777
|
+
.then((code) => {
|
|
778
|
+
process.exitCode = code;
|
|
779
|
+
})
|
|
780
|
+
.catch((exc: unknown) => {
|
|
781
|
+
process.stderr.write(
|
|
782
|
+
`check_host_docs_digest: unexpected failure: ${exc instanceof Error ? exc.stack ?? exc.message : String(exc)}\n`,
|
|
783
|
+
);
|
|
784
|
+
process.exitCode = 2;
|
|
785
|
+
});
|
|
786
|
+
}
|