-
Notifications
You must be signed in to change notification settings - Fork 30
Sync integration/liveobjects with main #2232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
maratal
wants to merge
9
commits into
integration/liveobjects
Choose a base branch
from
integration/liveobjects-sync-with-main
base: integration/liveobjects
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
9a131a0
uts: align layout with ably-java and add integration/proxy infra
sacOO7 10ae406
uts: address review feedback, add integration base test cases, split …
sacOO7 539081b
uts: rewrite uts-to-swift skill to module-directory flow, mirroring u…
sacOO7 7e383be
uts: address skill review — split assert/await audit counts, pseudo-o…
sacOO7 455f4ca
uts: address second skill review round — mapping validation, duplicat…
sacOO7 58fa0f5
uts: replace infra smoke tests with spec-derived integration tests, d…
sacOO7 ddb80a3
Merge pull request #2223 from ably/uts-integration-infra
sacOO7 7abd04b
Merge pull request #2224 from ably/uts-to-swift-module-skill
sacOO7 c23859e
Merge remote-tracking branch 'origin/main' into integration/liveobjec…
maratal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # `objects` UTS → ably-cocoa: ably-js ⇄ ably-cocoa type/interface map | ||
|
|
||
| > **Placeholder — intentionally empty.** The `objects` UTS specs are written against the | ||
| > ably-js LiveObjects API; the mapping to the Swift SDK's surface is SDK-specific and has | ||
| > not been authored yet. Fill this in (mirroring the structure of ably-java's | ||
| > `uts-to-kotlin/references/objects-mapping.md`) before translating any `objects` module | ||
| > spec for ably-cocoa. |
285 changes: 285 additions & 0 deletions
285
.claude/skills/uts-to-swift/scripts/audit_translation.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| audit_translation.py — deterministic faithfulness audit of a UTS spec against its | ||
| generated Swift test, so the per-spec review (SKILL.md Step 7) reconciles a concrete | ||
| extracted ledger instead of eyeballing two files. | ||
|
|
||
| Usage: | ||
| python3 audit_translation.py <spec-file.md> <GeneratedTests.swift> | ||
|
|
||
| It does ZERO semantic judgement — only mechanical extraction with regex, so the same | ||
| inputs always give the same report: | ||
|
|
||
| 1. Test-ID coverage. Every `**Test ID**: <id>` marker in the spec vs every | ||
| `// UTS: <id>` comment in the Swift file. | ||
| - missingInSwift: a spec Test ID with no matching // UTS: tag → a whole test | ||
| case is absent; implement it (or consciously exclude it and explain why). | ||
| - orphanInSwift: a // UTS: tag with no matching spec Test ID → a stale or | ||
| hand-edited tag. Investigate. | ||
| - duplicateInSwift: a // UTS: tag carried by more than one @Test → the audit | ||
| only examines the last method with that tag, so deduplicate before trusting | ||
| the ledger. | ||
|
|
||
| 2. Per-test line ledger. Within each spec test block (from one Test ID to the next), | ||
| every non-blank, non-comment code line inside the ```pseudo fences (only pseudo — | ||
| other fenced languages like json fixtures are ignored) is extracted verbatim, | ||
| grouped by its section (Setup / Test Steps / Assertions / …) — so setup, | ||
| operations AND assertions are all enumerated, nothing escapes the ledger. Each line | ||
| is tagged: "assert" (ASSERT*), "await" (AWAIT* / EXPECT), or "step" (everything else | ||
| — setup, mock construction, operations). For convenience the ASSERT* and AWAIT* | ||
| lines are also surfaced flat as specAsserts / specAwaits. | ||
| Alongside them, the matching Swift method's assertion calls (#expect / #require) | ||
| and wait/poll calls are counted SEPARATELY — so a surplus of waits can't hide a | ||
| dropped assertion. Swift assertions < spec ASSERTs for a test (assertionShortfall) | ||
| is a strong signal an assertion was silently dropped; awaitShortfall is the softer | ||
| secondary signal (custom continuation-bridged helpers legitimately replace the | ||
| infra wait calls, so account for those rather than treating it as a gate). | ||
|
|
||
| Robustness contract: this tool must never crash mid-run on any spec/test pair. It is a | ||
| review aid — if it can't extract something it degrades to "couldn't verify" (fewer lines | ||
| in the ledger) rather than throwing. Whatever happens it emits ONE parseable JSON object, | ||
| never a traceback, so a caller can always rely on the output shape. | ||
|
|
||
| Exit status: | ||
| 0 — clean audit (no missing/orphan Test IDs) | ||
| 2 — audit ran, but there are missing/orphan Test IDs (gateable) | ||
| 64 — could not run: bad usage, unreadable file, or an internal error (JSON carries `error`) | ||
| """ | ||
|
|
||
| import json | ||
| import re | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| # Spec markers ------------------------------------------------------------- | ||
| TEST_ID_RE = re.compile(r"\*\*Test ID\*\*:\s*`([^`]+)`") | ||
| HEADING_RE = re.compile(r"^#{1,4}\s+(.*\S)\s*$") | ||
| # An opening fence carries its language label (```pseudo, ```json, …); the closer is a | ||
| # bare ```. Only pseudo blocks are spec code — other languages are fixtures/examples. | ||
| FENCE_RE = re.compile(r"^\s*```(\S*)") | ||
| # Imperative spec keywords. Order matters: longest / most specific first so AWAIT_STATE | ||
| # is classified before the bare AWAIT. | ||
| DIRECTIVE_RE = re.compile(r"\b(ASSERT_[A-Z_]+|ASSERT|AWAIT_STATE|AWAIT_ERROR|AWAIT_ALL|AWAIT|EXPECT)\b") | ||
|
|
||
| # Swift markers ------------------------------------------------------------ | ||
| UTS_TAG_RE = re.compile(r"//\s*UTS:\s*(\S+)") | ||
| # Assertions and waits are counted separately (mirroring the spec side's | ||
| # specAssertCount / specAwaitCount), so a surplus of waits can't mask a dropped | ||
| # assertion. Longest alternatives first so `pollUntil` isn't half-matched by `poll`. | ||
| SWIFT_ASSERT_RE = re.compile(r"(#expect|#require)") | ||
| SWIFT_AWAIT_RE = re.compile( | ||
| r"(\bawaitConnectionState\b|\bawaitChannelState\b|\bawaitState\b|" | ||
| r"\bpollUntil\b|\bpoll\b)" | ||
| ) | ||
|
|
||
|
|
||
| def fail(code, message, status=64, **extra): | ||
| """Emit a structured error (same JSON shape as resolve_uts.py) and exit. `status` | ||
| defaults to 64 — "couldn't run" — kept distinct from the audit's own 0 (clean) and | ||
| 2 (missing/orphan IDs) outcomes so callers can tell the two apart.""" | ||
| print(json.dumps({"ok": False, "error": code, "message": message, **extra}, indent=2)) | ||
| sys.exit(status) | ||
|
|
||
|
|
||
| def read_lines(path): | ||
| """Read a file into lines, tolerant of encoding issues — a stray non-UTF-8 byte in a | ||
| spec must never crash the audit, so undecodable bytes are replaced, not raised on.""" | ||
| return Path(path).read_text(encoding="utf-8", errors="replace").splitlines() | ||
|
|
||
|
|
||
| def classify(keyword): | ||
| if keyword.startswith("ASSERT"): | ||
| return "assert" | ||
| if keyword.startswith("AWAIT") or keyword == "EXPECT": | ||
| return "await" | ||
| return "other" | ||
|
|
||
|
|
||
| def line_kind(stripped): | ||
| """Tag a pseudocode line: assert / await / step.""" | ||
| m = DIRECTIVE_RE.search(stripped) | ||
| if m: | ||
| k = classify(m.group(1)) | ||
| if k in ("assert", "await"): | ||
| return k | ||
| return "step" | ||
|
|
||
|
|
||
| def parse_spec(path): | ||
| """Return an ordered list of test-id dicts, each: | ||
| {testId, title, sections:[{heading, lines:[{text, kind}]}], asserts[], awaits[], codeLineTotal}.""" | ||
| lines = read_lines(path) | ||
|
|
||
| # locate each Test ID line and the nearest preceding heading (its human title) | ||
| tests = [] | ||
| last_heading = "" | ||
| for i, line in enumerate(lines): | ||
| h = HEADING_RE.match(line) | ||
| if h: | ||
| last_heading = h.group(1) | ||
| m = TEST_ID_RE.search(line) | ||
| if m: | ||
| tests.append({"testId": m.group(1), "title": last_heading, "_start": i}) | ||
|
|
||
| # block boundaries: each test runs to the next test's start (or EOF) | ||
| for idx, t in enumerate(tests): | ||
| start = t["_start"] | ||
| end = tests[idx + 1]["_start"] if idx + 1 < len(tests) else len(lines) | ||
|
|
||
| sections = [] # [{heading, lines:[{text, kind}]}] | ||
| cur = None # current section being filled | ||
| fence = None # None = prose; else the open fence's label ("pseudo", "json", "") | ||
| for line in lines[start:end]: | ||
| h = HEADING_RE.match(line) | ||
| if h and fence is None: | ||
| cur = {"heading": h.group(1), "lines": []} | ||
| sections.append(cur) | ||
| continue | ||
| m = FENCE_RE.match(line) | ||
| if m: | ||
| fence = m.group(1) if fence is None else None # open with label / close | ||
| continue | ||
| if fence != "pseudo": # prose, or a non-pseudo fence (json fixtures, examples) | ||
| continue | ||
| stripped = line.strip() | ||
| if not stripped or stripped.startswith("#"): # blank / pseudocode comment | ||
| continue | ||
| if cur is None: # code before any heading in the block (rare) | ||
| cur = {"heading": "(preamble)", "lines": []} | ||
| sections.append(cur) | ||
| cur["lines"].append({"text": stripped, "kind": line_kind(stripped)}) | ||
|
|
||
| # drop sections with no code (prose-only headings, requirement tables, the title line) | ||
| sections = [s for s in sections if s["lines"]] | ||
| asserts = [ln["text"] for s in sections for ln in s["lines"] if ln["kind"] == "assert"] | ||
| awaits = [ln["text"] for s in sections for ln in s["lines"] if ln["kind"] == "await"] | ||
| t["sections"] = sections | ||
| t["asserts"] = asserts | ||
| t["awaits"] = awaits | ||
| t["codeLineTotal"] = sum(len(s["lines"]) for s in sections) | ||
| del t["_start"] | ||
| return tests | ||
|
|
||
|
|
||
| def parse_swift(path): | ||
| """Return (methods, duplicates). `methods` is a dict uts-id -> | ||
| {assertionCalls[], assertionCount, awaitCalls[], awaitCount}; a method block for a | ||
| tag runs from its // UTS: line to the next // UTS: line (or EOF). Assertions | ||
| (#expect / #require) and waits (await*/poll* helpers) are counted separately, | ||
| mirroring the spec side's assert/await split. | ||
|
|
||
| `duplicates` lists tags carried by more than one method — the dict keeps only the | ||
| LAST block for a duplicated tag, making earlier methods invisible, so duplicates are | ||
| reported (not merged: merging would paper over the defect) and gate the exit status | ||
| like missing/orphan IDs do. | ||
|
|
||
| Comment-only lines are skipped when counting — a commented-out `// #expect(...)` | ||
| (or an annotated-omission comment) is NOT an assertion, and counting it would | ||
| silently mask a dropped one. (The spec-side parser skips `#` pseudocode comments | ||
| for the same reason.)""" | ||
| lines = read_lines(path) | ||
| tags = [(i, m.group(1)) for i, line in enumerate(lines) for m in [UTS_TAG_RE.search(line)] if m] | ||
| tag_counts = {} | ||
| for _, tag in tags: | ||
| tag_counts[tag] = tag_counts.get(tag, 0) + 1 | ||
| duplicates = [tag for tag, n in tag_counts.items() if n > 1] | ||
| out = {} | ||
| for idx, (start, tag) in enumerate(tags): | ||
| end = tags[idx + 1][0] if idx + 1 < len(tags) else len(lines) | ||
| asserts, awaits = [], [] | ||
| for line in lines[start:end]: | ||
| if line.strip().startswith("//"): # comment-only line — not executable | ||
| continue | ||
| for m in SWIFT_ASSERT_RE.finditer(line): | ||
| asserts.append(m.group(1)) | ||
| for m in SWIFT_AWAIT_RE.finditer(line): | ||
| awaits.append(m.group(1)) | ||
| out[tag] = {"assertionCalls": asserts, "assertionCount": len(asserts), | ||
| "awaitCalls": awaits, "awaitCount": len(awaits)} | ||
| return out, duplicates | ||
|
|
||
|
|
||
| def main(): | ||
| if len(sys.argv) != 3: | ||
| fail("USAGE", "Usage: audit_translation.py <spec-file.md> <GeneratedTests.swift>") | ||
|
|
||
| spec_path, swift_path = sys.argv[1], sys.argv[2] | ||
| for p in (spec_path, swift_path): | ||
| if not Path(p).is_file(): | ||
| fail("FILE_NOT_FOUND", f"{p!r} not found.") | ||
|
|
||
| # Robustness backstop: never let an unexpected parsing/IO error surface as a traceback. | ||
| # fail() emits structured JSON and exits 64 (distinct from the 0/2 audit outcomes) so | ||
| # callers and the model can tell "couldn't run" apart from "ran, found gaps". | ||
| try: | ||
| spec_tests = parse_spec(spec_path) | ||
| swift, duplicates = parse_swift(swift_path) | ||
| report = build_report(spec_path, swift_path, spec_tests, swift, duplicates) | ||
| except Exception as exc: # noqa: BLE001 — deliberate catch-all; this tool must not crash | ||
| fail("INTERNAL_ERROR", f"{type(exc).__name__}: {exc}", spec=spec_path, swift=swift_path) | ||
|
|
||
| print(json.dumps(report, indent=2)) | ||
| cov = report["idCoverage"] | ||
| sys.exit(2 if (cov["missingInSwift"] or cov["orphanInSwift"] or cov["duplicateInSwift"]) else 0) | ||
|
|
||
|
|
||
| def build_report(spec_path, swift_path, spec_tests, swift, duplicates): | ||
| spec_ids = [t["testId"] for t in spec_tests] | ||
| swift_ids = list(swift.keys()) | ||
| spec_id_set, swift_id_set = set(spec_ids), set(swift_ids) | ||
| missing = [i for i in spec_ids if i not in swift_id_set] | ||
| orphan = [i for i in swift_ids if i not in spec_id_set] | ||
|
|
||
| per_test = [] | ||
| for t in spec_tests: | ||
| sw = swift.get(t["testId"]) | ||
| per_test.append({ | ||
| "testId": t["testId"], | ||
| "title": t["title"], | ||
| # every code line of the spec test, grouped by section (setup / steps / assertions), | ||
| # each tagged assert | await | step — the full "did I translate every line?" ledger | ||
| "sections": t["sections"], | ||
| "specCodeLineTotal": t["codeLineTotal"], | ||
| # flat convenience views of the observable lines | ||
| "specAsserts": t["asserts"], | ||
| "specAwaits": t["awaits"], | ||
| "specAssertCount": len(t["asserts"]), | ||
| "specAwaitCount": len(t["awaits"]), | ||
| "swiftPresent": sw is not None, | ||
| "swiftAssertionCalls": sw["assertionCalls"] if sw else [], | ||
| "swiftAssertionCount": sw["assertionCount"] if sw else 0, | ||
| "swiftAwaitCalls": sw["awaitCalls"] if sw else [], | ||
| "swiftAwaitCount": sw["awaitCount"] if sw else 0, | ||
| # primary tripwire: fewer Swift assertions than spec ASSERTs => likely a dropped assertion | ||
| "assertionShortfall": (len(t["asserts"]) - sw["assertionCount"]) if sw else len(t["asserts"]), | ||
| # secondary signal: fewer Swift waits than spec AWAITs — softer, since custom | ||
| # continuation-bridged helpers legitimately replace the infra wait calls | ||
| "awaitShortfall": (len(t["awaits"]) - sw["awaitCount"]) if sw else len(t["awaits"]), | ||
| }) | ||
|
|
||
| report = { | ||
| "ok": True, | ||
| "spec": spec_path, | ||
| "swift": swift_path, | ||
| "idCoverage": { | ||
| "specCount": len(spec_ids), | ||
| "swiftCount": len(swift_ids), | ||
| "missingInSwift": missing, | ||
| "orphanInSwift": orphan, | ||
| "duplicateInSwift": duplicates, | ||
| }, | ||
| "perTest": per_test, | ||
| "summary": { | ||
| "specAssertTotal": sum(len(t["asserts"]) for t in spec_tests), | ||
| "specAwaitTotal": sum(len(t["awaits"]) for t in spec_tests), | ||
| "swiftAssertionTotal": sum(k["assertionCount"] for k in swift.values()), | ||
| "swiftAwaitTotal": sum(k["awaitCount"] for k in swift.values()), | ||
| "testsWithShortfall": [p["testId"] for p in per_test if p["assertionShortfall"] > 0], | ||
| "testsWithAwaitShortfall": [p["testId"] for p in per_test if p["awaitShortfall"] > 0], | ||
| }, | ||
| } | ||
| return report | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Scope counts to the annotated test method, not the next UTS tag.
The final tagged test currently absorbs every bottom-of-file helper extension. Helper
#require/wait calls can inflate its counts and mask missing test assertions. Associate each tag with its following@Testfunction and stop at that function’s matching closing brace; reject unattached tags.The workflow guidance explicitly puts helpers at the bottom of the file and allows assertions inside them.
🤖 Prompt for AI Agents