644 lines
25 KiB
Python
644 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
"""Q13 fix pack - staged apply script for the 25 verified live-page clauses.
|
||
|
||
Generated by Merlin, 2026-10-10, from the verified clause table in
|
||
docs/q13-fix-pack.md. NOT RUN AS AN APPLY: every clause was exercised in CHECK
|
||
mode only during the 2026-10-10 run (see the pack's landing evidence). Nothing in
|
||
that run wrote to blocks.json.
|
||
|
||
Scope, inherited from Q24 and Q13: this script edits the build source
|
||
/root/stackchain-legends-build/blocks.json only. It does not rebuild, does not
|
||
publish, does not contact a third party, and does not touch decisions/.
|
||
|
||
python3 apply_fix_pack.py --list
|
||
python3 apply_fix_pack.py --check <id> # default: verify, write nothing
|
||
python3 apply_fix_pack.py --apply <id> # write blocks.json
|
||
|
||
A clause marked undecided-for-Alexander refuses to apply unless BOTH --force and
|
||
--decision <artifact path> are given, so a staged fix cannot be published without
|
||
the approval being named on the command line. The one decided clause (D1,
|
||
magazine-091) applies on its decision record alone - but see section 5 of
|
||
docs/q13-fix-pack.md: its decided note conflicts with its proposed_action, so
|
||
applying D1 publishes a live page Alexander's own note asked to hold.
|
||
|
||
Fails closed: every delta must resolve exactly once inside its entry's own field
|
||
span, otherwise nothing is written. Reverting: reverse the printed deltas, or
|
||
restore blocks.json from a backup taken before --apply.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
|
||
BASE = pathlib.Path(__file__).resolve().parents[3]
|
||
BLOCKS = BASE / "stackchain-legends-build" / "blocks.json"
|
||
CLAUSES = json.loads(r"""[
|
||
{
|
||
"id": "A1",
|
||
"slug": "block-25",
|
||
"kind": "excerpt-url",
|
||
"panel": "From the sources",
|
||
"authority": "recoveries/evidence-tier-audit.md §6.1 (2026-10-10 ~09:3xZ) + recoveries/excerpt-binding-audit.md finding 1 (2026-10-10T13:26:16Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "excerpts[0].url",
|
||
"old": "\"https://www.stackchainmagazine.net\"",
|
||
"new": "\"https://www.stackchainmagazine.net/a-conversation-with-burnchainers/\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Wanting block 25, and being forked twice\""
|
||
},
|
||
{
|
||
"id": "A2",
|
||
"slug": "block-88",
|
||
"kind": "excerpt-url",
|
||
"panel": "From the sources",
|
||
"authority": "recoveries/evidence-tier-audit.md §6.1 (2026-10-10 ~09:3xZ) + recoveries/excerpt-binding-audit.md finding 1 (2026-10-10T13:26:16Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "excerpts[0].url",
|
||
"old": "\"https://www.stackchainmagazine.net\"",
|
||
"new": "\"https://www.stackchainmagazine.net/a-conversation-with-burnchainers/\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Block 88, bought to secure the longer chain\""
|
||
},
|
||
{
|
||
"id": "A3",
|
||
"slug": "block-89",
|
||
"kind": "excerpt-url",
|
||
"panel": "From the sources",
|
||
"authority": "recoveries/evidence-tier-audit.md §6.1 (2026-10-10 ~09:3xZ) + recoveries/excerpt-binding-audit.md finding 1 (2026-10-10T13:26:16Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "excerpts[0].url",
|
||
"old": "\"https://www.stackchainmagazine.net\"",
|
||
"new": "\"https://www.stackchainmagazine.net/a-conversation-with-burnchainers/\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Block 89, part of the 88-90 run\""
|
||
},
|
||
{
|
||
"id": "A4",
|
||
"slug": "block-90",
|
||
"kind": "excerpt-url",
|
||
"panel": "From the sources",
|
||
"authority": "recoveries/evidence-tier-audit.md §6.1 (2026-10-10 ~09:3xZ) + recoveries/excerpt-binding-audit.md finding 1 (2026-10-10T13:26:16Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "excerpts[0].url",
|
||
"old": "\"https://www.stackchainmagazine.net\"",
|
||
"new": "\"https://www.stackchainmagazine.net/a-conversation-with-burnchainers/\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Block 90, and the chain held\""
|
||
},
|
||
{
|
||
"id": "B1",
|
||
"slug": "block-25",
|
||
"kind": "direction-word",
|
||
"panel": "Provenance",
|
||
"authority": "WORK-QUEUE.md §Q21 verification addendum, control plane, 2026-10-10 (offsets quoted there)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "body_provenance",
|
||
"old": "quoted below",
|
||
"new": "quoted above"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Wanting block 25, and being forked twice\""
|
||
},
|
||
{
|
||
"id": "B2",
|
||
"slug": "block-88",
|
||
"kind": "direction-word",
|
||
"panel": "Provenance",
|
||
"authority": "WORK-QUEUE.md §Q21 verification addendum, control plane, 2026-10-10 (offsets quoted there)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "body_provenance",
|
||
"old": "quoted below",
|
||
"new": "quoted above"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Block 88, bought to secure the longer chain\""
|
||
},
|
||
{
|
||
"id": "B3",
|
||
"slug": "block-89",
|
||
"kind": "direction-word",
|
||
"panel": "Provenance",
|
||
"authority": "WORK-QUEUE.md §Q21 verification addendum, control plane, 2026-10-10 (offsets quoted there)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "body_provenance",
|
||
"old": "quoted below",
|
||
"new": "quoted above"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Block 89, part of the 88-90 run\""
|
||
},
|
||
{
|
||
"id": "B4",
|
||
"slug": "block-90",
|
||
"kind": "direction-word",
|
||
"panel": "Provenance",
|
||
"authority": "WORK-QUEUE.md §Q21 verification addendum, control plane, 2026-10-10 (offsets quoted there)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "body_provenance",
|
||
"old": "quoted below",
|
||
"new": "quoted above"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Block 90, and the chain held\""
|
||
},
|
||
{
|
||
"id": "C1",
|
||
"slug": "event-2023-05-00-siggy-reaches-nostr",
|
||
"kind": "excerpt-url",
|
||
"panel": "From the sources",
|
||
"authority": "recoveries/excerpt-binding-audit.md fetch note (301 observed) + recoveries/summary-fidelity-nonmagazine.md ev-017 row 1 + recoveries/ledger-audit-pass2.md row 19; 301 re-observed this run",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "excerpts[0].url",
|
||
"old": "\"https://github.com/plebiolira/stackchainsiggy_nostr\"",
|
||
"new": "\"https://github.com/flav1000/stackchainsiggy_nostr\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Siggy reaches Nostr\""
|
||
},
|
||
{
|
||
"id": "D1",
|
||
"slug": "magazine-091",
|
||
"kind": "body-clause",
|
||
"panel": "What happened + Uncertainty",
|
||
"authority": "decisions/2026-09-26.md item 13, approved 2026-09-26T14:51:11.019Z; staged record /root/stackchain-legends-build/EDITS/2026-09-26-magazine-091-correction.md",
|
||
"decided": "DECIDED (approved) — decided note conflicts with proposed_action; see §5",
|
||
"ops": [
|
||
{
|
||
"field": "body",
|
||
"old": "Comparing Strategy, Twenty One, Nakamoto, Metaplanet, Coinsilium and Smarter Web returns and drawdowns against Bitcoin, they find none consistently outperform,",
|
||
"new": "Comparing Twenty One, Nakamoto, Metaplanet, Coinsilium and Smarter Web against Bitcoin — with earlier pioneers such as Strategy set aside as not a fair comparison — they find none consistently outperform,"
|
||
},
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context.",
|
||
"new": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context. Correction 2026-09-26: this summary previously listed Strategy among the firms compared. The article sets Strategy aside (“not a fair comp for these newcomers”) and excludes it from the comparison; the listing was the archive’s error, not the article’s. Strategy has been removed from the compared set."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Fact Check: Bitcoin vs. Treasury Stocks.Who performs better?\""
|
||
},
|
||
{
|
||
"id": "E1",
|
||
"slug": "event-2022-08-18-the-bitcoin-beach-campaign-closes-and-hodlon",
|
||
"kind": "uncertainty-append",
|
||
"panel": "Uncertainty",
|
||
"authority": "recoveries/named-source-liveness-unlinked.md finding 1 (2026-10-10T17:49:31Z) + recoveries/named-source-liveness.md row 6 (2026-10-10T15:39:39Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Event status as recorded by the history research: sourced. The date basis is the reported event date, not necessarily a verified post timestamp.",
|
||
"new": "Event status as recorded by the history research: sourced. The date basis is the reported event date, not necessarily a verified post timestamp. Note 2026-10-10: the named warrant “StackchainQuant” returns HTTP 404 at https://x.com/StackchainQuant (X: “User Profile Not Found”). The report it attributes is separately cited and live; the account itself is unreachable as of this date."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The Bitcoin Beach campaign closes and Hodlonaut support appears\""
|
||
},
|
||
{
|
||
"id": "E2",
|
||
"slug": "event-2022-08-28-the-fork-of-1492",
|
||
"kind": "uncertainty-append",
|
||
"panel": "Uncertainty",
|
||
"authority": "recoveries/named-source-liveness-unlinked.md finding 1 (2026-10-10T17:49:31Z) + recoveries/named-source-liveness.md row 6 (2026-10-10T15:39:39Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Event status as recorded by the history research: event corroborated; tactics from participant retrospective. The date basis is the reported event date, not necessarily a verified post timestamp.",
|
||
"new": "Event status as recorded by the history research: event corroborated; tactics from participant retrospective. The date basis is the reported event date, not necessarily a verified post timestamp. Note 2026-10-10: the named warrant “StackchainQuant” returns HTTP 404 at https://x.com/StackchainQuant (X: “User Profile Not Found”). The report it attributes is separately cited and live; the account itself is unreachable as of this date."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The fork of 1492\""
|
||
},
|
||
{
|
||
"id": "E3",
|
||
"slug": "event-2022-10-05-the-community-documents-its-scale-and-charit",
|
||
"kind": "uncertainty-append",
|
||
"panel": "Uncertainty",
|
||
"authority": "recoveries/named-source-liveness-unlinked.md finding 1 (2026-10-10T17:49:31Z) + recoveries/named-source-liveness.md row 6 (2026-10-10T15:39:39Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Event status as recorded by the history research: sourced. The date basis is the reported event date, not necessarily a verified post timestamp.",
|
||
"new": "Event status as recorded by the history research: sourced. The date basis is the reported event date, not necessarily a verified post timestamp. Note 2026-10-10: the named warrant “StackchainQuant” returns HTTP 404 at https://x.com/StackchainQuant (X: “User Profile Not Found”). The report it attributes is separately cited and live; the account itself is unreachable as of this date."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The community documents its scale and charitable turn\""
|
||
},
|
||
{
|
||
"id": "F1",
|
||
"slug": "magazine-020",
|
||
"kind": "uncertainty-append",
|
||
"panel": "Uncertainty",
|
||
"authority": "recoveries/named-source-liveness.md row 1 (2026-10-10T15:39:17Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context.",
|
||
"new": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context. Note 2026-10-10: the byline handle @_overflow__ returns HTTP 404 at https://x.com/_overflow__ (X: “User Profile Not Found”). The name is retained as printed and is not currently linkable."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The coming Occupy Wall St\""
|
||
},
|
||
{
|
||
"id": "F2",
|
||
"slug": "magazine-051",
|
||
"kind": "uncertainty-append",
|
||
"panel": "Uncertainty",
|
||
"authority": "recoveries/named-source-liveness.md row 2 (2026-10-10T15:39:33Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context.",
|
||
"new": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context. Note 2026-10-10: the byline handle @humblebitcoiner returns HTTP 404 at https://x.com/humblebitcoiner (X: “User Profile Not Found”). The name is retained as printed and is not currently linkable."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The Obstacle Is The Way – Finding Hope In Bitcoin\""
|
||
},
|
||
{
|
||
"id": "G1",
|
||
"slug": "magazine-035",
|
||
"kind": "contributor-anchor",
|
||
"panel": "Contributor",
|
||
"authority": "recoveries/named-source-liveness.md row 3 (2026-10-10T15:39:21Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "contributor.display_name",
|
||
"old": "\"@BTC_Freeborn and @AnthonyDessauer\"",
|
||
"new": "\"BTC_Freeborn and Anthony Dessauer\""
|
||
},
|
||
{
|
||
"field": "contributor.handle",
|
||
"old": "null",
|
||
"new": "\"BTC_Freeborn\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"WAHT Stackchain Done Dided: The King of StackChain™\""
|
||
},
|
||
{
|
||
"id": "G2",
|
||
"slug": "magazine-056",
|
||
"kind": "contributor-anchor",
|
||
"panel": "Contributor",
|
||
"authority": "recoveries/named-source-liveness.md row 4 (2026-10-10T15:39:33Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "contributor.display_name",
|
||
"old": "\"Red Tail Hawk (@RedTailHawk1923)\"",
|
||
"new": "\"Red Tail Hawk\""
|
||
},
|
||
{
|
||
"field": "contributor.handle",
|
||
"old": "null",
|
||
"new": "\"RedTailHawk1923\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Culture is Bitcoin\""
|
||
},
|
||
{
|
||
"id": "G3",
|
||
"slug": "magazine-039",
|
||
"kind": "contributor-anchor",
|
||
"panel": "Contributor + Uncertainty",
|
||
"authority": "recoveries/named-source-liveness.md row 5 (2026-10-10T15:39:22Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "contributor.handle",
|
||
"old": "null",
|
||
"new": "\"captainsiddh\""
|
||
},
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context.",
|
||
"new": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context. Note 2026-10-10: the handle @captainsiddh resolves, but its own profile description reads “Now at @captainsidd” and https://x.com/captainsidd resolves; the byline above is one handle behind the account’s current home."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Traveling on a Bitcoin Standard\""
|
||
},
|
||
{
|
||
"id": "H1",
|
||
"slug": "event-2022-07-18-the-5-invitation-becomes-a-chain",
|
||
"kind": "prose-handle-form",
|
||
"panel": "What happened + In plain English",
|
||
"authority": "recoveries/named-source-liveness-unlinked.md finding 2 (2026-10-10T17:49:31Z); https://x.com/AriZonanHODL 200 / 130,908 B",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "body",
|
||
"old": "AriZonanHODL posted a $5 bitcoin purchase",
|
||
"new": "@AriZonanHODL posted a $5 bitcoin purchase"
|
||
},
|
||
{
|
||
"field": "plain_summary.text",
|
||
"old": "AriZonanHODL posted a $5 bitcoin purchase",
|
||
"new": "@AriZonanHODL posted a $5 bitcoin purchase"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The $5 invitation becomes a chain\""
|
||
},
|
||
{
|
||
"id": "H2",
|
||
"slug": "event-2022-07-18-the-5-invitation-becomes-a-chain",
|
||
"kind": "prose-handle-form",
|
||
"panel": "What happened",
|
||
"authority": "WORK-QUEUE.md §Q24 addendum 2026-10-10 18:13Z (Satskeeper resolves as @satskeeper_, already linked on block-7)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "body",
|
||
"old": "and Satskeeper with $7",
|
||
"new": "and @satskeeper_ with $7"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The $5 invitation becomes a chain\""
|
||
},
|
||
{
|
||
"id": "H3",
|
||
"slug": "magazine-002",
|
||
"kind": "prose-handle-form",
|
||
"panel": "What happened",
|
||
"authority": "WORK-QUEUE.md §Q23 verification correction (rule 3b yields 8 bare-and-unlinked mentions across 6 entries) — CONDITIONAL on step 5’s filter",
|
||
"decided": "undecided-for-Alexander (conditional row)",
|
||
"ops": [
|
||
{
|
||
"field": "body",
|
||
"old": "ArizonanHODL's $5",
|
||
"new": "@AriZonanHODL's $5"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"The Art of GFY by Nobody (aka Seven)\""
|
||
},
|
||
{
|
||
"id": "H4",
|
||
"slug": "magazine-005",
|
||
"kind": "prose-handle-form",
|
||
"panel": "What happened",
|
||
"authority": "WORK-QUEUE.md §Q23 verification correction (rule 3b) — CONDITIONAL on step 5’s filter",
|
||
"decided": "undecided-for-Alexander (conditional row)",
|
||
"ops": [
|
||
{
|
||
"field": "body",
|
||
"old": "ArizonanHodl's July 18, 2022 tweet",
|
||
"new": "@AriZonanHODL's July 18, 2022 tweet"
|
||
}
|
||
],
|
||
"locator": "\"title\": \"A Brief History of Stackchain.\""
|
||
},
|
||
{
|
||
"id": "I1",
|
||
"slug": "magazine-026",
|
||
"kind": "contributor-anchor",
|
||
"panel": "Contributor",
|
||
"authority": "recoveries/evidence-tier-audit.md §6.3 (2026-10-10) — OUTSIDE the Q24 population rule, which names findings 1–2; included and flagged",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "contributor.handle",
|
||
"old": "null",
|
||
"new": "\"a_koby\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Stackchain for Newcomers: How I Infiltrated the Stackchain.\""
|
||
},
|
||
{
|
||
"id": "I2",
|
||
"slug": "magazine-070",
|
||
"kind": "contributor-anchor",
|
||
"panel": "Contributor",
|
||
"authority": "WORK-QUEUE.md §Q23 verification correction (rule 3a admits MidyReyes on magazine-070) — OUTSIDE the Q24 population rule; included and flagged",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "contributor.display_name",
|
||
"old": "\"@MidyReyes\"",
|
||
"new": "\"MidyReyes\""
|
||
},
|
||
{
|
||
"field": "contributor.handle",
|
||
"old": "null",
|
||
"new": "\"MidyReyes\""
|
||
}
|
||
],
|
||
"locator": "\"title\": \"From Struggles to Strength: My Health & Fitness Story\""
|
||
},
|
||
{
|
||
"id": "J1",
|
||
"slug": "magazine-024",
|
||
"kind": "uncertainty-append",
|
||
"panel": "Uncertainty",
|
||
"authority": "recoveries/named-source-liveness-unlinked.md finding 3 (2026-10-10T17:49:31Z)",
|
||
"decided": "undecided-for-Alexander",
|
||
"ops": [
|
||
{
|
||
"field": "uncertainty",
|
||
"old": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context.",
|
||
"new": "Archive summary of a published article, not the article itself. Quotes are short verbatim excerpts; read the original for full context. Note 2026-10-10: the tipping address bitcoinbekka@getalby.com resolves at its canonical LNURL-pay endpoint https://getalby.com/.well-known/lnurlp/bitcoinbekka (HTTP 200); the vanity web path https://getalby.com/bitcoinbekka returns 404. It is not linked here because the ledger build emits anchors only for contributor handles, body sources and excerpt URLs."
|
||
}
|
||
],
|
||
"locator": "\"title\": \"Bekka’s Sammie Corner: Schnitzel\""
|
||
}
|
||
]""")
|
||
|
||
|
||
def slug_for(b):
|
||
raw = b.get("slug") or f"block-{b['n']}"
|
||
return re.sub(r"[^a-z0-9-]+", "-", str(raw).lower()).strip("-")
|
||
|
||
|
||
def region_of(text, slug):
|
||
"""(start, end, region_text, locator) for one entry object, found by its unique title."""
|
||
by_slug = {slug_for(b): b for b in json.loads(text)["blocks"]}
|
||
b = by_slug[slug]
|
||
loc = '"title": ' + json.dumps(b["title"], ensure_ascii=False)
|
||
n = text.count(loc)
|
||
if n != 1:
|
||
raise SystemExit(f"ABORT {slug}: title locator occurs {n}x")
|
||
i = text.index(loc)
|
||
m = re.search(r'\{\s*"n":', text[i + len(loc):])
|
||
end = i + len(loc) + (m.start() if m else len(text) - i - len(loc))
|
||
return i, end, text[i:end], loc
|
||
|
||
|
||
def _scan_string(text, s):
|
||
"""End offset of the JSON string that starts at s (text[s] == '"')."""
|
||
j = s + 1
|
||
while j < len(text):
|
||
if text[j] == "\\":
|
||
j += 2
|
||
continue
|
||
if text[j] == '"':
|
||
return j + 1
|
||
j += 1
|
||
raise SystemExit("ABORT: unterminated string")
|
||
|
||
|
||
def _value_span(text, key, base=0):
|
||
"""(start, end, raw) of one key's JSON value; objects/arrays bracket-matched."""
|
||
m = re.search(r'"' + re.escape(key) + r'": ', text[base:])
|
||
if not m:
|
||
raise SystemExit(f"ABORT: key {key!r} not found")
|
||
s = base + m.end()
|
||
if text.startswith('"', s):
|
||
e = _scan_string(text, s)
|
||
return s, e, text[s:e]
|
||
m2 = re.match(r"(null|true|false|-?\d+(?:\.\d+)?)", text[s:])
|
||
if m2:
|
||
return s, s + m2.end(), text[s:s + m2.end()]
|
||
depth, j = 0, s
|
||
while j < len(text):
|
||
ch = text[j]
|
||
if ch == '"':
|
||
j = _scan_string(text, j)
|
||
continue
|
||
if ch in "{[":
|
||
depth += 1
|
||
elif ch in "}]":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return s, j + 1, text[s:j + 1]
|
||
j += 1
|
||
raise SystemExit(f"ABORT: unbalanced value at {key!r}")
|
||
|
||
|
||
def field_span(region, path):
|
||
"""(start, end, raw) of ONE field's JSON value inside the entry region."""
|
||
if path in ("body", "body_provenance", "uncertainty"):
|
||
return _value_span(region, path)
|
||
if path == "plain_summary.text":
|
||
cs, _, _ = _value_span(region, "plain_summary")
|
||
s, e, _ = _value_span(region, "text", base=cs)
|
||
return s, e, region[s:e]
|
||
if path.startswith("excerpts["):
|
||
idx = int(path[path.index("[") + 1:path.index("]")])
|
||
key = path.split(".")[-1]
|
||
es, _, _ = _value_span(region, "excerpts")
|
||
arr_end = es + 1
|
||
depth = 1
|
||
while depth:
|
||
if region[arr_end] == '"':
|
||
arr_end = _scan_string(region, arr_end)
|
||
continue
|
||
if region[arr_end] in "[{":
|
||
depth += 1
|
||
elif region[arr_end] in "]}":
|
||
depth -= 1
|
||
arr_end += 1
|
||
arr = region[es:arr_end]
|
||
pos = [m.start() for m in re.finditer(r'"' + key + r'": ', arr)]
|
||
if len(pos) <= idx:
|
||
raise SystemExit(f"ABORT: {path} not found ({len(pos)} occurrences)")
|
||
s = es + pos[idx] + len('"' + key + '": ')
|
||
e = _scan_string(region, s)
|
||
return s, e, region[s:e]
|
||
if path.startswith("contributor."):
|
||
key = path.split(".")[-1]
|
||
cs, _, _ = _value_span(region, "contributor")
|
||
return _value_span(region, key, base=cs)
|
||
raise SystemExit(f"ABORT: unknown field path {path!r}")
|
||
|
||
|
||
def plan(cid):
|
||
c = next((x for x in CLAUSES if x["id"] == cid), None)
|
||
if c is None:
|
||
raise SystemExit(f"ABORT: no clause {cid!r}; --list shows the ids")
|
||
text = BLOCKS.read_text(encoding="utf-8")
|
||
rstart, rend, reg, loc = region_of(text, c["slug"])
|
||
edits = []
|
||
for o in c["ops"]:
|
||
s, e, cur = field_span(reg, o["field"])
|
||
if cur.count(o["old"]) != 1:
|
||
raise SystemExit(
|
||
f"ABORT {cid} {o['field']}: old occurs {cur.count(o['old'])}x in the field span")
|
||
if o["old"] == o["new"]:
|
||
raise SystemExit(f"ABORT {cid} {o['field']}: old == new")
|
||
# absolute offsets into the full file
|
||
edits.append((s + rstart, e + rstart, cur, o))
|
||
return c, text, reg, loc, edits
|
||
|
||
|
||
def main() -> int:
|
||
argv = sys.argv[1:]
|
||
if "--list" in argv:
|
||
for c in CLAUSES:
|
||
print(f"{c['id']:4s} {c['slug'][:52]:52s} {c['kind']:20s} {c['decided'][:40]}")
|
||
print(f"\n{len(CLAUSES)} clauses, {sum(len(c['ops']) for c in CLAUSES)} deltas")
|
||
return 0
|
||
apply = "--apply" in argv
|
||
force = "--force" in argv
|
||
decision = None
|
||
if "--decision" in argv:
|
||
k = argv.index("--decision")
|
||
if k + 1 >= len(argv):
|
||
print("ABORT: --decision needs a value")
|
||
return 2
|
||
decision = argv[k + 1]
|
||
argv = argv[:k] + argv[k + 2:]
|
||
ids = [a for a in argv if not a.startswith("--")]
|
||
if len(ids) != 1:
|
||
print("usage: apply_fix_pack.py [--list] [--check|--apply] <id> "
|
||
"[--force --decision <path>]")
|
||
return 2
|
||
cid = ids[0]
|
||
|
||
c, text, reg, loc, edits = plan(cid)
|
||
print(f"clause {cid} entry {c['slug']} panel {c['panel']}")
|
||
print(f"authority: {c['authority']}")
|
||
print(f"decided: {c['decided']}")
|
||
for _s, _e, _cur, o in edits:
|
||
print(f" field {o['field']}: old occurs 1x, new differs OK")
|
||
|
||
if c["decided"].startswith("undecided"):
|
||
if apply and not (force and decision):
|
||
print("\nREFUSED: this clause is undecided-for-Alexander. Re-run with "
|
||
"--force --decision <artifact path> to publish it anyway.")
|
||
return 1
|
||
if apply:
|
||
print(f"\nFORCED with decision artifact: {decision}")
|
||
|
||
if not apply:
|
||
print("\nCHECK ONLY - blocks.json not written (pass --apply)")
|
||
return 0
|
||
|
||
out = text
|
||
for s, e, cur, o in reversed(edits):
|
||
out = out[:s] + cur.replace(o["old"], o["new"], 1) + out[e:]
|
||
json.loads(out)
|
||
BLOCKS.write_text(out, encoding="utf-8")
|
||
print(f"\nWROTE {BLOCKS}")
|
||
print("revert by reversing these deltas (or restore a pre-apply backup):")
|
||
for _s, _e, _cur, o in edits:
|
||
print(f" {o['field']}: {o['new'][:70]!r} -> {o['old'][:70]!r}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|