@m13v/s4l 1.7.6-rc.24 → 1.7.6-rc.26
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/feedback_digest.py +7 -6
- package/scripts/post_github.py +8 -0
- package/scripts/reddit_browser.py +16 -0
- package/scripts/release-mcpb.sh +9 -0
- package/scripts/score_twitter_candidates.py +4 -2
- package/scripts/test_cross_language_parity.py +185 -0
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.6-rc.
|
|
5
|
+
"version": "1.7.6-rc.26",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.26",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -290,9 +290,10 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
|
|
|
290
290
|
"before approving it, so every FINAL is a direct statement of the "
|
|
291
291
|
"voice they want. The drafting model NO LONGER sees these pairs "
|
|
292
292
|
"directly, so the ONLY way a rewrite's lesson reaches drafting is "
|
|
293
|
-
"if you distill it into a draft_style_notes entry.
|
|
294
|
-
"
|
|
295
|
-
"tone or length shifted)
|
|
293
|
+
"if you distill it into a draft_style_notes entry. Even a SINGLE "
|
|
294
|
+
"example with a clear correction (a phrase type removed, a "
|
|
295
|
+
"structure replaced, tone or length shifted) is enough to write a "
|
|
296
|
+
"draft_style_notes entry and "
|
|
296
297
|
"embed a short before/after fragment as an anchor (e.g. rewrote "
|
|
297
298
|
"'X' to 'Y') so the concrete rewrite travels with the rule. Ignore "
|
|
298
299
|
"lead-specific or cosmetic edits (typo fixes, one-off facts); learn "
|
|
@@ -314,9 +315,9 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}{edit_examples_bloc
|
|
|
314
315
|
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
|
|
315
316
|
{ev_lines}{overall_block}{platform_block}
|
|
316
317
|
|
|
317
|
-
Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair;
|
|
318
|
+
Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; even a single edit that shows a clear correction (a phrase type removed, a structure replaced, tone shifted, length cut) is enough to distill into draft_style_notes, and several agreeing only make it stronger. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
|
|
318
319
|
|
|
319
|
-
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft.
|
|
320
|
+
Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. A single pairwise choice is enough: diff the picked text against the not-picked one and distill WHAT differs (tone, structure, length, opener type, directness) into draft_style_notes, and several agreeing only make it stronger; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
|
|
320
321
|
|
|
321
322
|
"[platform_removed]" and "[platform_banned]" events are MACHINE-DERIVED moderation outcomes, not human card decisions; weigh them on their own track, never as a statement of the reviewer's taste. platform_removed means a moderator or platform filter removed one of our posts: the lesson is the CONTENT pattern (what reads as promotional, off-topic, or AI-written in that kind of venue), feeding thread_avoid or draft_style_notes. One removal is one community's judgment: treat it like a reasoned reject (it may justify one narrowly-scoped entry since the moderation context states the venue and outcome explicitly), and generalize only when 2+ removals share a pattern. platform_banned means a community blocked our account outright: the strongest platform-side evidence, but the specific venue is ALREADY hard-blocked by the pipeline, so never spend an entry naming an individual community; the only lesson worth an entry is a venue TYPE pattern (e.g. what kinds of communities keep rejecting us) once 2+ bans or removals show it. Platform events never justify block_authors (there is no author at fault) and never touch audience_prefer.
|
|
322
323
|
|
|
@@ -324,7 +325,7 @@ You can also block SPECIFIC authors via the plan's block_authors list. A block i
|
|
|
324
325
|
|
|
325
326
|
Propose changes to the block. RULES, in priority order:
|
|
326
327
|
1. Be conservative. Prefer NO changes over speculative ones. An empty plan is a good plan when the evidence is thin.
|
|
327
|
-
2.
|
|
328
|
+
2. A SINGLE reasoned event is enough to justify an entry; you do NOT need multiple events agreeing. When a reject carries a category, a note, a profile-click, or clear author/thread context, or an approval is loved, or the user edited the draft, or they made an explicit two-draft choice, distill the lesson right away into the most fitting list, generalized to the TYPE (never an individual handle or a one-off fact). More events agreeing only make the pattern stronger; they are not a precondition. OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one even from a single line, rewritten as a standing preference. The one exception is no_reason_given rejects, which stay weak (see below), because there the WHY is unknown, not because the count is low.
|
|
328
329
|
3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
|
|
329
330
|
4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
|
|
330
331
|
5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
|
package/scripts/post_github.py
CHANGED
|
@@ -710,6 +710,14 @@ def parse_claude_json(output):
|
|
|
710
710
|
# ---------- Posting + logging ------------------------------------------------
|
|
711
711
|
|
|
712
712
|
def post_comment(owner, repo, number, body):
|
|
713
|
+
# Identity gate (mirrors twitter_browser.reply_to_tweet /
|
|
714
|
+
# reddit_browser.post_comment): the gh CLI would happily post as whatever
|
|
715
|
+
# account it is authed as, but with no resolved accounts.github.username
|
|
716
|
+
# the row's attribution is blank and self-filtering breaks. Refuse loudly.
|
|
717
|
+
from account_resolver import resolve as _resolve_gh
|
|
718
|
+
if not _resolve_gh("github"):
|
|
719
|
+
return False, ("no_account_configured (set accounts.github.username in "
|
|
720
|
+
"config.json or AUTOPOSTER_GITHUB_USERNAME)")
|
|
713
721
|
try:
|
|
714
722
|
out = subprocess.check_output(
|
|
715
723
|
["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
|
|
@@ -536,6 +536,15 @@ def post_comment(thread_url, text):
|
|
|
536
536
|
|
|
537
537
|
Returns: {"ok": true, "permalink": "..."} or {"ok": false, "error": "..."}
|
|
538
538
|
"""
|
|
539
|
+
# Identity gate (mirrors twitter_browser.reply_to_tweet): refuse to post
|
|
540
|
+
# when no Reddit account resolves. Without it we cannot attribute the
|
|
541
|
+
# comment or find our own permalink afterwards, and posting with a blank
|
|
542
|
+
# identity pollutes the shared DB. Fail fast and loud instead.
|
|
543
|
+
if not OUR_USERNAME:
|
|
544
|
+
print("[reddit_browser] no reddit account configured "
|
|
545
|
+
"(accounts.reddit.username / reddit_account.username / "
|
|
546
|
+
"AUTOPOSTER_REDDIT_USERNAME); refusing to post.", file=sys.stderr)
|
|
547
|
+
return {"ok": False, "error": "no_account_configured"}
|
|
539
548
|
from playwright.sync_api import sync_playwright
|
|
540
549
|
|
|
541
550
|
with sync_playwright() as p:
|
|
@@ -837,6 +846,13 @@ def reply_to_comment(comment_permalink, text, dm_id=None):
|
|
|
837
846
|
Returns: {"ok": true, "applied_campaigns": [...], "reply_text": "..."}
|
|
838
847
|
or {"ok": false, "error": "..."}
|
|
839
848
|
"""
|
|
849
|
+
# Identity gate (mirrors twitter_browser.reply_to_tweet): no resolved
|
|
850
|
+
# account -> no post. See post_comment above for the rationale.
|
|
851
|
+
if not OUR_USERNAME:
|
|
852
|
+
print("[reddit_browser] no reddit account configured "
|
|
853
|
+
"(accounts.reddit.username / reddit_account.username / "
|
|
854
|
+
"AUTOPOSTER_REDDIT_USERNAME); refusing to post.", file=sys.stderr)
|
|
855
|
+
return {"ok": False, "error": "no_account_configured"}
|
|
840
856
|
from playwright.sync_api import sync_playwright
|
|
841
857
|
|
|
842
858
|
# Pre-flight: refuse to attempt a reply in a sub we know we can't comment in.
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -204,6 +204,15 @@ say "Regression gate: scripts/test_no_silent_fallbacks.py"
|
|
|
204
204
|
python3 "$REPO_ROOT/scripts/test_no_silent_fallbacks.py" \
|
|
205
205
|
|| die "regression gate failed -- a silent-fallback / bare-python3 issue was (re)introduced. See output above; do not release until it's clean or the finding is reviewed into the test's ALLOWLIST with a reason."
|
|
206
206
|
|
|
207
|
+
# Cross-language lockstep gate: candidate_state (s4l_state.py vs index.ts) and
|
|
208
|
+
# the short-link fallback host constant (dm_short_links.py vs bin/server.js)
|
|
209
|
+
# are duplicated across runtimes BY NECESSITY; this test is what turns their
|
|
210
|
+
# "keep in lockstep" comments into an enforced invariant. Divergence here has
|
|
211
|
+
# shipped real incidents (post_failed drain asymmetry 2026-07-17).
|
|
212
|
+
say "Regression gate: scripts/test_cross_language_parity.py"
|
|
213
|
+
python3 "$REPO_ROOT/scripts/test_cross_language_parity.py" \
|
|
214
|
+
|| die "cross-language parity gate failed -- a TS/Python lockstep pair diverged (candidate_state or fallback host). Fix BOTH sides before releasing."
|
|
215
|
+
|
|
207
216
|
# ---- 1. Resolve + WRITE version into the repo-root package.json -------------
|
|
208
217
|
# The repo-root package.json is the SINGLE source of truth: `npm pack` reads it
|
|
209
218
|
# to build the embedded pipeline.tgz, so the bundle shell and the bundled
|
|
@@ -538,8 +538,10 @@ def upsert_candidates(tweets, config, batch_id=None, attempts_map=None, scored_s
|
|
|
538
538
|
# composite unique gives each account its own candidate row.
|
|
539
539
|
# Without this, account A's 'posted' status on tweet X would lock
|
|
540
540
|
# account B out of the same tweet (ON CONFLICT preserved 'posted').
|
|
541
|
-
#
|
|
542
|
-
#
|
|
541
|
+
# If omitted, the server stamps a per-install 'unknown:<install_id>'
|
|
542
|
+
# sentinel (2026-07-18; it used to default to 'm13v_', silently
|
|
543
|
+
# impersonating the repo owner). Callers should always pass it —
|
|
544
|
+
# _resolve_twitter_handle() covers env -> config -> cookie mirror.
|
|
543
545
|
"our_account": _twitter_handle or "",
|
|
544
546
|
# Repost provenance (2026-06-04). The scan derives the author from
|
|
545
547
|
# the status URL, so author_handle/tweet_url already point at the
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-language parity gate: constants and state machines that exist in BOTH
|
|
3
|
+
the Python pipeline and the Node/TS MCP server MUST agree. Each pair here has a
|
|
4
|
+
"keep in lockstep" comment at both definition sites; this test is what makes
|
|
5
|
+
that comment enforceable instead of aspirational. Runs in the release gate
|
|
6
|
+
(scripts/release-mcpb.sh) right after test_no_silent_fallbacks.py.
|
|
7
|
+
|
|
8
|
+
Checks:
|
|
9
|
+
1. candidate_state parity — mcp/menubar/s4l_state.py::candidate_state (the
|
|
10
|
+
Python authority) vs mcp/src/index.ts::candidateState (the TS mirror),
|
|
11
|
+
evaluated with node on a fixture matrix covering every flag combination
|
|
12
|
+
and truthiness edge case. Divergence between hand-rolled raw-flag filters
|
|
13
|
+
and the state machine has produced real incidents (post_failed drain
|
|
14
|
+
asymmetry 2026-07-17, get_stats lane blindness); this pins the two
|
|
15
|
+
implementations to each other.
|
|
16
|
+
2. Short-link fallback host parity — scripts/dm_short_links.py
|
|
17
|
+
DEFAULT_FALLBACK_HOST vs bin/server.js SHORT_LINK_FALLBACK_HOST (CLAUDE.md:
|
|
18
|
+
"keep them in sync").
|
|
19
|
+
|
|
20
|
+
Zero third-party deps. Needs `node` on PATH (the release script already does).
|
|
21
|
+
Exit 0 = all pass; exit 1 with a FAIL line = parity broken, do not release.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
FAILURES = []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def report(ok, label, detail=""):
|
|
36
|
+
tag = "PASS" if ok else "FAIL"
|
|
37
|
+
line = f"{tag} {label}"
|
|
38
|
+
if detail:
|
|
39
|
+
line += f" -- {detail}"
|
|
40
|
+
print(line)
|
|
41
|
+
if not ok:
|
|
42
|
+
FAILURES.append(label)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# 1. candidate_state parity
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# Every flag combination that matters, plus truthiness edge cases: Python uses
|
|
50
|
+
# `is True` for posted/terminal/approved but bare truthiness for post_failed;
|
|
51
|
+
# TS uses `=== true` and bare truthiness respectively. The matrix locks in that
|
|
52
|
+
# BOTH implementations make the same call on non-boolean junk values too.
|
|
53
|
+
FIXTURES = [
|
|
54
|
+
{},
|
|
55
|
+
{"approved": True},
|
|
56
|
+
{"approved": True, "post_failed": True},
|
|
57
|
+
{"post_failed": True},
|
|
58
|
+
{"post_failed": "posted_0"},
|
|
59
|
+
{"post_failed": ""},
|
|
60
|
+
{"post_failed": 0},
|
|
61
|
+
{"terminal": True},
|
|
62
|
+
{"terminal": True, "approved": True},
|
|
63
|
+
{"terminal": True, "post_failed": True, "approved": True},
|
|
64
|
+
{"posted": True},
|
|
65
|
+
{"posted": True, "approved": True},
|
|
66
|
+
{"posted": True, "terminal": True, "post_failed": True, "approved": True},
|
|
67
|
+
{"posted": False, "terminal": False, "approved": False},
|
|
68
|
+
{"posted": "yes"},
|
|
69
|
+
{"approved": 1},
|
|
70
|
+
{"terminal": 1, "approved": True},
|
|
71
|
+
{"posted": 1, "terminal": True},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def python_states():
|
|
76
|
+
sys.path.insert(0, os.path.join(REPO_ROOT, "mcp", "menubar"))
|
|
77
|
+
from s4l_state import candidate_state # noqa: E402
|
|
78
|
+
return [candidate_state(dict(c)) for c in FIXTURES]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_ts_function():
|
|
82
|
+
"""Pull the candidateState function body out of mcp/src/index.ts and strip
|
|
83
|
+
the TS type annotations so plain node can evaluate it. Brittle on purpose:
|
|
84
|
+
if the signature changes shape, this test fails loudly and must be updated
|
|
85
|
+
alongside it -- which is exactly the review moment the lockstep needs."""
|
|
86
|
+
src_path = os.path.join(REPO_ROOT, "mcp", "src", "index.ts")
|
|
87
|
+
with open(src_path, encoding="utf-8") as f:
|
|
88
|
+
src = f.read()
|
|
89
|
+
m = re.search(r"function candidateState\(c: PlanCandidate\): CandidateState \{", src)
|
|
90
|
+
if not m:
|
|
91
|
+
return None
|
|
92
|
+
start = m.start()
|
|
93
|
+
depth = 0
|
|
94
|
+
for i in range(m.end() - 1, len(src)):
|
|
95
|
+
if src[i] == "{":
|
|
96
|
+
depth += 1
|
|
97
|
+
elif src[i] == "}":
|
|
98
|
+
depth -= 1
|
|
99
|
+
if depth == 0:
|
|
100
|
+
body = src[start : i + 1]
|
|
101
|
+
return body.replace(
|
|
102
|
+
"function candidateState(c: PlanCandidate): CandidateState {",
|
|
103
|
+
"function candidateState(c) {",
|
|
104
|
+
)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ts_states(fn_src):
|
|
109
|
+
node = shutil.which("node") or "/opt/homebrew/bin/node"
|
|
110
|
+
script = (
|
|
111
|
+
fn_src
|
|
112
|
+
+ "\nconst fixtures = JSON.parse(require('fs').readFileSync(0, 'utf-8'));"
|
|
113
|
+
+ "\nprocess.stdout.write(JSON.stringify(fixtures.map((c) => candidateState(c))));"
|
|
114
|
+
)
|
|
115
|
+
out = subprocess.run(
|
|
116
|
+
[node, "-e", script],
|
|
117
|
+
input=json.dumps(FIXTURES),
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
timeout=30,
|
|
121
|
+
)
|
|
122
|
+
if out.returncode != 0:
|
|
123
|
+
raise RuntimeError(f"node evaluation failed: {out.stderr.strip()[:300]}")
|
|
124
|
+
return json.loads(out.stdout)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_candidate_state():
|
|
128
|
+
fn_src = extract_ts_function()
|
|
129
|
+
if fn_src is None:
|
|
130
|
+
report(False, "candidateState() found in mcp/src/index.ts",
|
|
131
|
+
"signature changed? update extract_ts_function() alongside it")
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
ts = ts_states(fn_src)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
report(False, "candidateState() evaluates under node", str(e)[:200])
|
|
137
|
+
return
|
|
138
|
+
py = python_states()
|
|
139
|
+
for c, p, t in zip(FIXTURES, py, ts):
|
|
140
|
+
report(p == t, f"candidate_state parity for {json.dumps(c)}",
|
|
141
|
+
f"py={p} ts={t}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# 2. short-link fallback host parity
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def check_fallback_host():
|
|
149
|
+
py_path = os.path.join(REPO_ROOT, "scripts", "dm_short_links.py")
|
|
150
|
+
js_path = os.path.join(REPO_ROOT, "bin", "server.js")
|
|
151
|
+
py_m = js_m = None
|
|
152
|
+
try:
|
|
153
|
+
with open(py_path, encoding="utf-8") as f:
|
|
154
|
+
py_m = re.search(r"^DEFAULT_FALLBACK_HOST\s*=\s*['\"]([^'\"]+)['\"]",
|
|
155
|
+
f.read(), re.M)
|
|
156
|
+
with open(js_path, encoding="utf-8") as f:
|
|
157
|
+
js_m = re.search(r"^const SHORT_LINK_FALLBACK_HOST\s*=\s*['\"]([^'\"]+)['\"]",
|
|
158
|
+
f.read(), re.M)
|
|
159
|
+
except OSError as e:
|
|
160
|
+
report(False, "fallback-host files readable", str(e))
|
|
161
|
+
return
|
|
162
|
+
if not py_m or not js_m:
|
|
163
|
+
report(False, "fallback-host constants found",
|
|
164
|
+
f"py={'ok' if py_m else 'MISSING'} js={'ok' if js_m else 'MISSING'}")
|
|
165
|
+
return
|
|
166
|
+
report(py_m.group(1) == js_m.group(1),
|
|
167
|
+
"DEFAULT_FALLBACK_HOST (py) == SHORT_LINK_FALLBACK_HOST (js)",
|
|
168
|
+
f"py={py_m.group(1)} js={js_m.group(1)}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main():
|
|
172
|
+
print("-- check 1: candidate_state TS/Python parity --")
|
|
173
|
+
check_candidate_state()
|
|
174
|
+
print("-- check 2: short-link fallback host parity --")
|
|
175
|
+
check_fallback_host()
|
|
176
|
+
print()
|
|
177
|
+
if FAILURES:
|
|
178
|
+
print(f"{len(FAILURES)} FAILURE(S)")
|
|
179
|
+
return 1
|
|
180
|
+
print("ALL PASS")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main())
|