diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 5a8ac833d0..2cbed86605 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -3,7 +3,6 @@ paths: - ".github/workflows/**/*" - ".github/actions/**/*" - ".github/actionlint.yaml" - - ".github/*baseline*" - "scripts/ci/**/*" - "apple/scripts/ci/**/*" - "scripts/release/**/*" @@ -106,18 +105,15 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - PR, push to main or `release/**`, dispatch, no path filter; Ubuntu, 15 minutes. - Steps, in order: - - actionlint, which also shellchecks every inline `run:` block; + - actionlint, which also shellchecks every inline `run:` block at `SHELLCHECK_OPTS=--severity=warning`; - `shellcheck --severity=warning` on every `*.sh` under `scripts`, `apple/scripts` and `.claude/skills`; - the tooling tests: `scripts/ci/test_*.py`, `apple/scripts/ci/test_*.py`, `apple/scripts/lib/test_*.py`, `apple/scripts/test_localization.py` and `apple/scripts/ci/test_libs_release.sh`; - `apple/scripts/release/test_*.py` on Python 3.9 from `actions/setup-python`, in a venv that installs `scripts/release/requirements.txt` with `pip --require-hashes`; - in the same venv, `check-changelog.py` and `towncrier build --draft --version 0.0.0` with `--dir apple` and `--dir linux`, which fails on a fragment name it cannot parse; - - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-plugin-provides.py`, `check-deployment-target.sh`, `check-ios-shared-isolation.py`; + - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-plugin-provides.py`, `check-deployment-target.sh`; - `localization.py plugins` and `plugins --target ios`; - `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`; - - `apple/scripts/audit-refactor-health.sh --check`, `check-hub-ratchet.py`; - the Go checks on the HANA helper, `govulncheck` among them, when the `hana` detector is true. New advisories between changes arrive as Dependabot alerts. -- `audit-refactor-health.sh --check` fails a file defining a production `DatabaseType` outside `apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift`, unless `.github/duplicate-contract-baseline.txt` lists its path. Moving a listed file moves its baseline path in the same commit. Never add an entry to let a new copy pass. -- `check-hub-ratchet.py` fails when an app hub grows past `.github/hub-ratchet-baseline.json`. `--update` only lowers a value, so growing one is a hand edit a reviewer sees. ## Change detector (`scripts/ci/detect-changed-paths.sh`) diff --git a/.github/duplicate-contract-baseline.txt b/.github/duplicate-contract-baseline.txt deleted file mode 100644 index e18a19f531..0000000000 --- a/.github/duplicate-contract-baseline.txt +++ /dev/null @@ -1,10 +0,0 @@ -# Production DatabaseType definitions other than the TableProCore source. -# apple/scripts/audit-refactor-health.sh --check fails when one appears that is not listed here. -# Remove an entry when its definition moves into TableProCore; never add one to make a new copy pass. -# -# Format: databasetype:, relative to apple/ - -# The macOS DatabaseType moved out of DatabaseConnection.swift into a file of its own so that -# naming a database type stops costing the plugin registry at compile time. Same single -# definition, new path: this is a relocation of the entry below, not a fresh one. -databasetype:TablePro/Models/Connection/DatabaseType.swift diff --git a/.github/hub-ratchet-baseline.json b/.github/hub-ratchet-baseline.json deleted file mode 100644 index f0a7d87efd..0000000000 --- a/.github/hub-ratchet-baseline.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "MenuValidationContext stored properties": 86, - "menu validation `case #selector` arms": 115, - "ShortcutAction cases": 103, - "TabType cases": 9, - "SettingsPane cases": 12, - "ActiveSheet cases": 17, - "LaunchIntent cases": 15, - "shell extensions named for a module or engine": 11, - "MainContentCoordinator+ files": 67, - "app types AppDelegate names": 64, - "files under TablePro/ named for BigQuery": 0, - "files under TablePro/ named for Beancount": 0, - "files under TablePro/ named for Cassandra": 0, - "files under TablePro/ named for ClickHouse": 4, - "files under TablePro/ named for Cloudflare": 9, - "files under TablePro/ named for CockroachDB": 0, - "files under TablePro/ named for Dameng": 0, - "files under TablePro/ named for Databend": 0, - "files under TablePro/ named for DuckDB": 2, - "files under TablePro/ named for DynamoDB": 2, - "files under TablePro/ named for Elasticsearch": 1, - "files under TablePro/ named for etcd": 2, - "files under TablePro/ named for HANA": 1, - "files under TablePro/ named for Kafka": 1, - "files under TablePro/ named for libSQL": 0, - "files under TablePro/ named for MariaDB": 0, - "files under TablePro/ named for MongoDB": 5, - "files under TablePro/ named for MySQL": 7, - "files under TablePro/ named for OceanBase": 0, - "files under TablePro/ named for Oracle": 1, - "files under TablePro/ named for PostgreSQL": 8, - "files under TablePro/ named for Redis": 8, - "files under TablePro/ named for Redshift": 0, - "files under TablePro/ named for ScyllaDB": 0, - "files under TablePro/ named for Snowflake": 0, - "files under TablePro/ named for Spanner": 0, - "files under TablePro/ named for SQL Server": 2, - "files under TablePro/ named for SQLite": 8, - "files under TablePro/ named for SurrealDB": 1, - "files under TablePro/ named for Teradata": 0, - "files under TablePro/ named for TiDB": 0, - "files under TablePro/ named for Trino": 0, - "files under TablePro/ named for Turso": 1, - "files under TablePro/ named for Typesense": 2, - "files under TablePro/ named for Weaviate": 1 -} diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 7c2c6de3c3..1faf0984f2 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -34,6 +34,8 @@ jobs: # actionlint runs shellcheck over every inline run: block, which nothing else sees. - name: Lint workflows + env: + SHELLCHECK_OPTS: --severity=warning run: ./actionlint -color - name: Lint scripts @@ -95,10 +97,6 @@ jobs: - name: Check no build script hardcodes the deployment target run: bash apple/scripts/ci/check-deployment-target.sh - # iOS alone defaults to MainActor isolation, so a shared plugin file must state its own. - - name: Check shared plugin sources are explicitly isolated for iOS - run: python3 apple/scripts/ci/check-ios-shared-isolation.py - # A plugin's or package's String(localized:) resolves against the host app's bundle, so its # strings live in each app's catalog by hand. - name: Check plugin and package strings reach the string catalogs @@ -122,12 +120,6 @@ jobs: - name: Check every test target is in its test plan run: python3 apple/scripts/ci/check-test-plans.py - - name: Check the shared-contract drift gates - run: apple/scripts/audit-refactor-health.sh --check - - - name: Check no hub grew past its recorded size - run: python3 apple/scripts/ci/check-hub-ratchet.py - - name: Decide whether the SAP HANA helper changed id: native env: diff --git a/AGENTS.md b/AGENTS.md index e3821ff654..8aa7d911de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Claude Code loads a rule when it reads or edits a file the rule covers. Under Co | --- | --- | | `ai-mcp-security.md` | `apple/TablePro/Core/AI`, `apple/TablePro/Core/MCP`, `apple/Packages/TableProMacCore/Sources/TableProMCP` | | `changelog.md` | `**/CHANGELOG.md`, `**/changelog.d`, `**/towncrier.toml` | -| `ci.md` | `.github/workflows`, `.github/actions`, `.github/actionlint.yaml`, `.github/*baseline*`, `scripts/ci`, `apple/scripts/ci`, `scripts/release`, `apple/scripts/release`, `apple/.swiftlint.yml`, `**/.swiftlint.yml` | +| `ci.md` | `.github/workflows`, `.github/actions`, `.github/actionlint.yaml`, `scripts/ci`, `apple/scripts/ci`, `scripts/release`, `apple/scripts/release`, `apple/.swiftlint.yml`, `**/.swiftlint.yml` | | `connection-window.md` | `apple/TablePro/Core/Services/Infrastructure`, `apple/TablePro/Models/Connection`, `apple/TablePro/Core/Database/DatabaseManager*.swift`, `apple/Packages/TableProCore/Sources/TableProDatabase`, `apple/TablePro/Views/Main` | | `data-grid.md` | `apple/TablePro/Views/Results`, `apple/TablePro/Core/DataGrid`, `apple/TableProTests/Views/Results` | | `data-sync-storage.md` | `apple/TablePro/Core/**/*Storage*.swift`, `apple/TablePro/Core/**/*Sync*.swift`, `apple/TablePro/Core/Database`, `apple/TablePro/Core/Plugins/PluginMetadataRegistry*.swift`, `apple/TablePro/Core/Services/Query`, `apple/Packages/TableProCore/Sources/TableProSyncTransport`, `apple/CloudKit` | diff --git a/apple/AGENTS.md b/apple/AGENTS.md index ddb06b817f..97c3c050f9 100644 --- a/apple/AGENTS.md +++ b/apple/AGENTS.md @@ -76,7 +76,7 @@ apple/scripts/generate-project.sh [both|macos|ios] # needs the libs; rerun afte - **Storage resolves through `AppStorageEnvironment.shared`** (stores, defaults suite, keychain); the `storage_environment_*` SwiftLint rules fail other Application Support or standard-defaults use. - **An AppKit autosave name** (split view, window frame, table) **goes through `SplitViewAutosaveName.current(_:)`**: AppKit writes the record to the real defaults domain, which the sandbox does not redirect. - **No new test-only branch in production code**: tests use injection or `TABLEPRO_UI_TEST_SANDBOX`. -- **macOS files the iOS app compiles** (listed in `TableProMobile/project.yml`) give each top-level declaration explicit isolation, usually `nonisolated`, since iOS defaults to `MainActor` (`scripts/ci/check-ios-shared-isolation.py`). +- **macOS files the iOS app compiles** (listed in `TableProMobile/project.yml`) give each top-level declaration explicit isolation, usually `nonisolated`, since iOS defaults to `MainActor`. The iOS build is the check. - **No package product in a macOS binary depends on the `TableProPluginKit` package**: a plugin would carry its own PluginKit types (`.claude/rules/plugin-system.md`). - **A source-text rule is a SwiftLint rule or a `scripts/ci` check**, never a test reading source. diff --git a/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift b/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift index 65c2f1af1f..3282fa973e 100644 --- a/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift +++ b/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift @@ -4,7 +4,7 @@ // // Compiled into the macOS app and, by file reference, into the iOS app, which defaults to // MainActor isolation. Every top-level declaration states its own isolation for that reason; -// scripts/ci/check-ios-shared-isolation.py holds it. +// the iOS build is the check. // import Foundation diff --git a/apple/TablePro/Core/SSH/LibSSH2ForwardChannel.swift b/apple/TablePro/Core/SSH/LibSSH2ForwardChannel.swift index e24f823d45..cf137de58a 100644 --- a/apple/TablePro/Core/SSH/LibSSH2ForwardChannel.swift +++ b/apple/TablePro/Core/SSH/LibSSH2ForwardChannel.swift @@ -4,7 +4,7 @@ // // Compiled into the macOS app and, by file reference, into the iOS app, which defaults to // MainActor isolation. Every top-level declaration states its own isolation for that reason; -// scripts/ci/check-ios-shared-isolation.py holds it. +// the iOS build is the check. // import Foundation diff --git a/apple/TablePro/Core/SSH/SSHKeepAliveResult.swift b/apple/TablePro/Core/SSH/SSHKeepAliveResult.swift index 637e1ec66a..d22ea11684 100644 --- a/apple/TablePro/Core/SSH/SSHKeepAliveResult.swift +++ b/apple/TablePro/Core/SSH/SSHKeepAliveResult.swift @@ -4,7 +4,7 @@ // // Compiled into the macOS app and, by file reference, into the iOS app, which defaults to // MainActor isolation. Every top-level declaration states its own isolation for that reason; -// scripts/ci/check-ios-shared-isolation.py holds it. +// the iOS build is the check. // import CLibSSH2 diff --git a/apple/scripts/audit-refactor-health.sh b/apple/scripts/audit-refactor-health.sh deleted file mode 100755 index 12e8beeb07..0000000000 --- a/apple/scripts/audit-refactor-health.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# DatabaseType definition gate. -# -# DatabaseType is defined once in TableProCore, and the macOS app keeps one more definition that -# .github/duplicate-contract-baseline.txt lists. Any other `struct DatabaseType` or `enum -# DatabaseType` is a second contract that drifts from the first, so --check fails on it. -# -# Usage: -# scripts/audit-refactor-health.sh # list every DatabaseType definition -# scripts/audit-refactor-health.sh --check # also exit 1 on one the baseline does not list - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -CHECK_MODE=false -case "${1:-}" in - "") ;; - --check) CHECK_MODE=true ;; - *) - echo "usage: $0 [--check]" >&2 - exit 2 - ;; -esac - -BASELINE_FILE="../.github/duplicate-contract-baseline.txt" -AUTHORITATIVE="Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift" - -baseline_paths() { - [ -f "$BASELINE_FILE" ] || return 0 - sed -E 's/#.*//; s/^[[:space:]]+//; s/[[:space:]]+$//' "$BASELINE_FILE" | - sed -n 's/^databasetype://p' -} - -definitions() { - grep -rlE '^(public )?(struct|enum) DatabaseType[ :<]' --include='*.swift' \ - TablePro Plugins Packages TableProMobile 2> /dev/null | sort -u || true -} - -echo "DatabaseType definitions:" -found="$(definitions)" -if [ -z "$found" ]; then - echo " none found" - $CHECK_MODE && exit 1 - exit 0 -fi -printf '%s\n' "$found" | sed 's/^/ /' - -$CHECK_MODE || exit 0 - -allowed="$(printf '%s\n%s\n' "$AUTHORITATIVE" "$(baseline_paths)")" -unlisted=0 -while IFS= read -r path; do - [ -n "$path" ] || continue - if ! printf '%s\n' "$allowed" | grep -qxF "$path"; then - echo "FAIL: DatabaseType defined outside $AUTHORITATIVE and $BASELINE_FILE: $path" - unlisted=$((unlisted + 1)) - fi -done <<< "$found" - -if [ "$unlisted" -gt 0 ]; then - exit 1 -fi -echo "ok: every DatabaseType definition is the authoritative one or listed in $BASELINE_FILE" diff --git a/apple/scripts/ci/check-hub-ratchet.py b/apple/scripts/ci/check-hub-ratchet.py deleted file mode 100644 index 8e2af74c59..0000000000 --- a/apple/scripts/ci/check-hub-ratchet.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when a hub of the app grows past the size recorded in .github/hub-ratchet-baseline.json. - -A count may go down, never up. After a change lowers one, run with --update to record the new -floor. --update never raises a value: growing a hub means editing the baseline by hand, in a diff -a reviewer sees. - -Usage: - check-hub-ratchet.py [--root ] # compare with the baseline - check-hub-ratchet.py [--root ] --update # lower the baseline to what the tree holds - check-hub-ratchet.py [--root ] --print # print the counts as JSON - -The root is the apple/ tree; the baseline sits in the .github/ of the repository above it. -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - -BASELINE = ".github/hub-ratchet-baseline.json" -APP = "TablePro" - -SHELL_EXTENSION = re.compile(r"^(MainContentCoordinator|MainSplitViewController|MainContentCommandActions)\+(\w+)\.swift$") - -# Words that name a module of the modularization plan. A word matches at a camel-case boundary, -# with an optional plural "s", so MAINMenu does not match AI and Charter does not match Chart. -MODULE_WORDS = ( - "AI", "Agent", "AppleScript", "Backup", "Chart", "CodeFolding", "CompareSync", "Copilot", - "CopyObjects", "CredentialProfile", "DataFile", "DuplicateDatabase", "ERDiagram", - "HighlightRule", "InlineSuggestion", "LinkedFolder", "MCP", "NativeDump", "ObjectCopy", - "PasswordSource", "PlanBaseline", "PreConnect", "Proxy", "QueryInsight", "ResultMap", "Rewind", - "ServerDashboard", "SharedConnection", "SOCKS", "SQLFolder", "TableMaintenance", "Tunnel", - "UsersRoles", -) - -ENGINES = { - "BigQuery": r"BigQuery", - "Beancount": r"Beancount", - "Cassandra": r"Cassandra", - "ClickHouse": r"ClickHouse", - "Cloudflare": r"Cloudflare", - "CockroachDB": r"Cockroach", - "Dameng": r"Dameng", - "Databend": r"Databend", - "DuckDB": r"DuckDB", - "DynamoDB": r"DynamoDB", - "Elasticsearch": r"Elasticsearch", - "etcd": r"Etcd|etcd", - "HANA": r"HANA|Hana", - "Kafka": r"Kafka", - "libSQL": r"LibSQL|libSQL", - "MariaDB": r"MariaDB", - "MongoDB": r"Mongo", - "MySQL": r"MySQL", - "OceanBase": r"OceanBase", - "Oracle": r"Oracle", - "PostgreSQL": r"Postgre", - "Redis": r"Redis", - "Redshift": r"Redshift", - "ScyllaDB": r"Scylla", - "Snowflake": r"Snowflake", - "Spanner": r"Spanner", - "SQL Server": r"MSSQL|(? int: - if text.startswith("//", index): - newline = text.find("\n", index) - return len(text) if newline < 0 else newline - if text.startswith("/*", index): - depth = 0 - position = index - while position < len(text): - if text.startswith("/*", position): - depth += 1 - position += 2 - elif text.startswith("*/", position): - depth -= 1 - position += 2 - if depth == 0: - return position - else: - position += 1 - return len(text) - hashes = 0 - while text[index + hashes] == "#": - hashes += 1 - opening = index + hashes - multiline = text.startswith('"""', opening) - delimiter = ('"""' if multiline else '"') + "#" * hashes - escape = "\\" + "#" * hashes - position = opening + (3 if multiline else 1) - while True: - stop = STRING_STOP.search(text, position) - if stop is None: - return len(text) - position = stop.start() - if text.startswith(escape, position): - after = position + len(escape) - position = interpolation_end(text, after) if text.startswith("(", after) else after + 1 - elif text.startswith(delimiter, position): - return position + len(delimiter) - elif text[position] == "\n" and not multiline: - return position - else: - position += 1 - - -def interpolation_end(text: str, index: int) -> int: - depth = 0 - position = index - while True: - match = PAREN_OR_LITERAL.search(text, position) - if match is None: - return len(text) - token = match.group() - if token == "(": - depth += 1 - position = match.end() - elif token == ")": - depth -= 1 - position = match.end() - if depth == 0: - return position - else: - position = literal_end(text, match.start()) - - -def blank_literals(text: str) -> str: - """The source with every comment and string literal replaced by spaces, newlines kept.""" - parts: list[str] = [] - position = 0 - while True: - match = LITERAL_START.search(text, position) - if match is None: - parts.append(text[position:]) - return "".join(parts) - end = literal_end(text, match.start()) - parts.append(text[position:match.start()]) - parts.append(re.sub(r"[^\n]", " ", text[match.start():end])) - position = end - - -def depth_at(code: str, index: int) -> int: - return code.count("{", 0, index) - code.count("}", 0, index) - - -def closing_brace(code: str, opening: int) -> int: - depth = 0 - for position in range(opening, len(code)): - if code[position] == "{": - depth += 1 - elif code[position] == "}": - depth -= 1 - if depth == 0: - return position - raise RatchetError("unbalanced braces") - - -class Tree: - def __init__(self, root: Path) -> None: - self.root = root - self.sources = sorted((root / APP).rglob("*.swift")) - self._code: dict[Path, str] = {} - - def code(self, path: Path) -> str: - if path not in self._code: - self._code[path] = blank_literals(path.read_text(encoding="utf-8", errors="replace")) - return self._code[path] - - def relative(self, path: Path) -> str: - return path.relative_to(self.root).as_posix() - - def top_level_body(self, kind: str, name: str) -> str: - """The body of the one top-level `kind name` declared under TablePro/.""" - pattern = re.compile(rf"\b{kind}\s+{name}\b") - found: list[tuple[Path, str]] = [] - for path in self.sources: - if name not in path.read_text(encoding="utf-8", errors="replace"): - continue - code = self.code(path) - for match in pattern.finditer(code): - if depth_at(code, match.start()) != 0: - continue - opening = code.index("{", match.end()) - found.append((path, code[opening + 1:closing_brace(code, opening)])) - if len(found) != 1: - where = ", ".join(self.relative(path) for path, _ in found) or "nowhere" - raise RatchetError( - f"expected one top-level `{kind} {name}` under {APP}/, found {len(found)} ({where}). " - "If it moved or was removed on purpose, update scripts/ci/check-hub-ratchet.py and the baseline." - ) - return found[0][1] - - -def body_level(body: str) -> list[int]: - """The brace depth of every position in a declaration body, 0 for its own members.""" - depths = [] - depth = 0 - for character in body: - if character == "}": - depth -= 1 - depths.append(depth) - if character == "{": - depth += 1 - return depths - - -def enum_case_count(body: str) -> int: - depths = body_level(body) - count = 0 - for match in re.finditer(r"\bcase\b", body): - if depths[match.start()] != 0: - continue - nesting = 0 - names = 1 - last = "" - position = match.end() - while position < len(body): - character = body[position] - if character in "([": - nesting += 1 - elif character in ")]": - nesting -= 1 - elif nesting == 0 and character == ",": - names += 1 - elif nesting == 0 and character in ";{}": - break - elif nesting == 0 and character == "\n" and last != ",": - break - if not character.isspace(): - last = character - position += 1 - count += names - return count - - -def stored_property_count(body: str) -> int: - depths = body_level(body) - count = 0 - for match in PROPERTY.finditer(body): - modifiers = match.group(1).split() - if depths[match.start()] != 0 or "static" in modifiers or "class" in modifiers: - continue - count += stored_bindings(body, match.end(), is_constant=match.group(2) == "let") - return count - - -def is_observer_block(body: str, opening: int) -> bool: - return body[opening + 1:].lstrip().startswith(("willSet", "didSet")) - - -def continues_on_next_line(body: str, newline: int, state: str) -> bool: - following = body[newline:].lstrip() - if following.startswith("{"): - return state != "initializer" or is_observer_block(body, len(body) - len(following)) - return following.startswith(".") or (state != "initializer" and following.startswith("=")) - - -def stored_bindings(body: str, start: int, is_constant: bool) -> int: - """How many stored bindings the declaration starting at `start` declares, as in `var a = 1, b: Int`. - A binding is computed when it has no initializer and an accessor block other than willSet or didSet.""" - stored = 0 - state = "name" - computed = False - nesting: list[str] = [] - last = "" - position = start - while position < len(body): - character = body[position] - if body.startswith("->", position): - last = "-" - position += 2 - continue - if nesting and character == nesting[-1]: - nesting.pop() - elif character in "([": - nesting.append(")" if character == "(" else "]") - elif character == "<" and state == "type": - nesting.append(">") - elif nesting: - if character == "{": - nesting.append("}") - elif character == ":" and state == "name": - state = "type" - elif character == "=" and state != "initializer": - state = "initializer" - elif character == ",": - stored += 1 if is_constant or not computed else 0 - state, computed = "name", False - elif character == "{": - if not is_observer_block(body, position) and state == "initializer": - nesting.append("}") - else: - computed = computed or not is_observer_block(body, position) - position = closing_brace(body, position) + 1 - last = "}" - continue - elif character in ";}": - break - elif character == "\n" and last not in CONTINUATION and not continues_on_next_line(body, position, state): - break - if not character.isspace(): - last = character - position += 1 - return stored + (1 if is_constant or not computed else 0) - - -def names_module(suffix: str) -> bool: - for word in MODULE_WORDS: - if re.search(rf"(? dict[str, int]: - tree = Tree(root) - counts: dict[str, int] = {} - - counts["MenuValidationContext stored properties"] = stored_property_count( - tree.top_level_body("struct", "MenuValidationContext") - ) - counts["menu validation `case #selector` arms"] = sum( - len(SELECTOR_ARM.findall(tree.code(path))) - for path in tree.sources - if path.name.startswith("MainSplitViewController") - ) - for label, name in ENUMS.items(): - counts[label] = enum_case_count(tree.top_level_body("enum", name)) - - shell_extensions = [match for match in (SHELL_EXTENSION.match(path.name) for path in tree.sources) if match] - counts["shell extensions named for a module or engine"] = sum( - 1 for match in shell_extensions if names_module(match.group(2)) - ) - counts["MainContentCoordinator+ files"] = sum( - 1 for match in shell_extensions if match.group(1) == "MainContentCoordinator" - ) - - app_delegate = [path for path in tree.sources if path.name.startswith("AppDelegate")] - declared_elsewhere: set[str] = set() - declared_in_app_delegate: set[str] = set() - for path in tree.sources: - names = set(TYPE_DECLARATION.findall(tree.code(path))) - (declared_in_app_delegate if path in app_delegate else declared_elsewhere).update(names) - named = set() - for path in app_delegate: - named.update(IDENTIFIER.findall(tree.code(path))) - counts["app types AppDelegate names"] = len((named & declared_elsewhere) - declared_in_app_delegate) - - for engine, pattern in ENGINES.items(): - regex = re.compile(pattern) - counts[f"files under {APP}/ named for {engine}"] = sum(1 for path in tree.sources if regex.search(path.stem)) - return counts - - -def compare(baseline: dict[str, int], current: dict[str, int]) -> tuple[list[str], list[str]]: - """Failures, then notes about counts that went down.""" - failures: list[str] = [] - lowered: list[str] = [] - for key, value in current.items(): - if key not in baseline: - failures.append(f"{key}: {value}, and the baseline has no entry for it") - elif value > baseline[key]: - failures.append(f"{key}: {value}, up from {baseline[key]}") - elif value < baseline[key]: - lowered.append(f"{key}: {value}, down from {baseline[key]}") - for key in baseline: - if key not in current: - failures.append(f"{key}: in the baseline, but this script no longer counts it") - return failures, lowered - - -def lowered_baseline(baseline: dict[str, int], current: dict[str, int]) -> dict[str, int]: - return {key: min(value, baseline.get(key, value)) for key, value in current.items()} - - -def write_baseline(path: Path, counts: dict[str, int]) -> None: - path.write_text(json.dumps(counts, indent=2) + "\n", encoding="utf-8") - - -def run(root: Path, update: bool = False, show: bool = False) -> int: - try: - current = measure(root) - except RatchetError as error: - print(f"check-hub-ratchet: {error}") - return 1 - - if show: - print(json.dumps(current, indent=2)) - return 0 - - baseline_path = root.parent / BASELINE - baseline = json.loads(baseline_path.read_text(encoding="utf-8")) if baseline_path.exists() else {} - - if update: - write_baseline(baseline_path, lowered_baseline(baseline, current)) - print(f"wrote {BASELINE}") - return 0 - - failures, lowered = compare(baseline, current) - for line in lowered: - print(f"lower: {line}") - if lowered: - print("Record the lower counts with: python3 apple/scripts/ci/check-hub-ratchet.py --update") - if not failures: - return 0 - for line in failures: - print(f"FAIL: {line}") - print() - print( - "These counts may only go down. Put the new code behind the feature's own type instead of " - "the hub. If the growth is intended, raise the number in " - f"{BASELINE} by hand and say why in the pull request." - ) - return 1 - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description="Fail when a hub count grows past its baseline.") - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) - action = parser.add_mutually_exclusive_group() - action.add_argument("--update", action="store_true", help="lower the baseline to the current counts") - action.add_argument("--print", dest="show", action="store_true", help="print the current counts as JSON") - arguments = parser.parse_args(argv) - return run(arguments.root.resolve(), update=arguments.update, show=arguments.show) - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/apple/scripts/ci/check-ios-shared-isolation.py b/apple/scripts/ci/check-ios-shared-isolation.py deleted file mode 100755 index b8ec50b019..0000000000 --- a/apple/scripts/ci/check-ios-shared-isolation.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -"""Check every macOS-owned source the iOS app compiles is explicitly nonisolated. - -The iOS app target sets SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor, and it also compiles source -files that live under Plugins/ and TablePro/ and belong to a macOS target, which sets no such -thing. So one file has two default isolations: nonisolated on macOS, MainActor in the app. Every -declaration in it that is not marked nonisolated is therefore @MainActor on iOS alone, and the -first nonisolated caller that touches it fails to compile with - - main actor-isolated property 'hasRangeTypes' can not be referenced from a nonisolated context - -Nothing in the macOS build can see this: the plugin compiles the same file correctly. The iOS job -is the only thing that catches it, it runs only when a change touches an iOS path, and the error -arrives one symbol at a time, so fixing the reported symbol just uncovers the next one. That is -how main went red three times in a row over one missing keyword. - -This check reads the shared file list out of TableProMobile/project.yml rather than repeating it, -so a file added to the iOS target is covered the moment it is added. It runs on Ubuntu in under a -second and needs no Xcode. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -PROJECT = ROOT / "TableProMobile" / "project.yml" - -# A declaration that introduces a type, or a top-level function, constant or variable. Anything -# nested inside a type inherits that type's isolation, so only column-zero declarations matter. -DECLARATION = re.compile( - r"^(?P(?:public |internal |private |fileprivate |final |open |indirect )*)" - r"(?Penum|struct|class|actor|extension|func|let|var)\s" -) - -# Attributes that carry their own isolation, so the file is saying what it means. -ISOLATION_ATTRIBUTES = ("@MainActor", "@globalActor", "nonisolated") - - -def shared_sources() -> list[Path]: - """Every macOS-owned .swift file listed in the iOS project. - - Plugin sources were the first of these; the libssh2 half of the SSH transport joined them, - and it lives under ../TablePro/. The pattern is any source the iOS target reaches for outside - its own directory, which is exactly what carries two default isolations. - """ - text = PROJECT.read_text(encoding="utf-8") - paths = re.findall(r"^\s*-\s*(\.\./(?:Plugins|TablePro)/\S+\.swift)\s*$", text, re.MULTILINE) - return [(PROJECT.parent / path).resolve() for path in sorted(set(paths))] - - -def offending_declarations(path: Path) -> list[tuple[int, str]]: - """Top-level declarations in one file that state no isolation of their own.""" - found: list[tuple[int, str]] = [] - previous = "" - for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - stripped = line.rstrip() - if not stripped or stripped.startswith(("//", "/*", "*", "@", "import", "#")): - previous = stripped - continue - if line[:1].isspace(): - previous = stripped - continue - if not DECLARATION.match(stripped): - previous = stripped - continue - if stripped.startswith(ISOLATION_ATTRIBUTES) or previous.startswith(ISOLATION_ATTRIBUTES): - previous = stripped - continue - found.append((number, stripped)) - previous = stripped - return found - - -def main() -> int: - if not PROJECT.is_file(): - print(f"not found: {PROJECT}", file=sys.stderr) - return 3 - - sources = shared_sources() - if not sources: - print("no shared sources found in TableProMobile/project.yml", file=sys.stderr) - return 3 - - failures: list[str] = [] - for path in sources: - if not path.is_file(): - failures.append(f"{path.relative_to(ROOT)}: listed in TableProMobile/project.yml but missing") - continue - for number, declaration in offending_declarations(path): - failures.append(f"{path.relative_to(ROOT)}:{number}: {declaration}") - - if failures: - print("These declarations are compiled into the iOS app, which defaults to MainActor,") - print("and state no isolation of their own. Mark each one nonisolated:") - print() - for failure in failures: - print(f" {failure}") - print() - print(f"{len(failures)} declaration(s) across {len(sources)} shared file(s).") - return 1 - - print(f"{len(sources)} shared sources, every top-level declaration isolated explicitly") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/apple/scripts/ci/test_check_hub_ratchet.py b/apple/scripts/ci/test_check_hub_ratchet.py deleted file mode 100644 index 9e3f6ec2dc..0000000000 --- a/apple/scripts/ci/test_check_hub_ratchet.py +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env python3 -"""Fixture tests for check-hub-ratchet.py.""" - -from __future__ import annotations - -import importlib.util -import io -import json -import shutil -import sys -import tempfile -import unittest -from contextlib import redirect_stdout -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -SPEC = importlib.util.spec_from_file_location("check_hub_ratchet", Path(__file__).with_name("check-hub-ratchet.py")) -ratchet = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(ratchet) - -MINIMAL_TREE = { - "TablePro/Infrastructure/MainSplitViewController+MenuValidation.swift": ( - "struct MenuValidationContext: Equatable {\n" - " var isConnected = false\n" - "}\n\n" - "extension MainSplitViewController {\n" - " static func resolved(_ selector: Selector) -> Bool? {\n" - " switch selector {\n" - " case #selector(refresh(_:)),\n" - " #selector(close(_:)):\n" - " return true\n" - " default:\n" - " return nil\n" - " }\n" - " }\n" - "}\n" - ), - "TablePro/Models/KeyboardShortcutModels.swift": "enum ShortcutAction: String {\n case newConnection\n}\n", - "TablePro/Models/QueryTabState.swift": "enum TabType: Codable {\n case query\n}\n", - "TablePro/Views/SettingsView.swift": "enum SettingsPane: String {\n case general\n}\n", - "TablePro/Views/MainContentCoordinator.swift": "enum ActiveSheet {\n case sqlPreview\n}\n", - "TablePro/Core/LaunchIntent.swift": "enum LaunchIntent {\n case openConnection(UUID)\n}\n", - "TablePro/Views/MainContentCoordinator+Rewind.swift": "extension MainContentCoordinator {}\n", - "TablePro/Views/MainContentCoordinator+TabRestore.swift": "extension MainContentCoordinator {}\n", - "TablePro/AppDelegate.swift": ( - "class AppDelegate: NSObject {\n" - " func start() {\n" - " SoftwareUpdater.shared.start()\n" - " SoftwareUpdater.shared.check()\n" - " NSApp.activate()\n" - " }\n" - "}\n\n" - "private struct NotificationDelivery {}\n" - ), - "TablePro/Core/SoftwareUpdater.swift": "final class SoftwareUpdater {}\n", - "TablePro/Core/Unused.swift": "final class NotNamedByAppDelegate {}\n", - "TablePro/Core/Dashboard/MySQLDashboardProvider.swift": "struct MySQLDashboardProvider {}\n", -} - - -class Fixture(unittest.TestCase): - def setUp(self) -> None: - self.repository = Path(tempfile.mkdtemp()) - self.root = self.repository / "apple" - (self.repository / ".github").mkdir() - for path, text in MINIMAL_TREE.items(): - self.write(path, text) - - def tearDown(self) -> None: - shutil.rmtree(self.repository) - - def write(self, path: str, text: str) -> None: - target = self.root / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(text, encoding="utf-8") - - def run_check(self, **options: bool) -> tuple[int, str]: - output = io.StringIO() - with redirect_stdout(output): - status = ratchet.run(self.root, **options) - return status, output.getvalue() - - def counts(self) -> dict[str, int]: - return ratchet.measure(self.root) - - -class LexerTests(unittest.TestCase): - def test_comments_and_strings_are_blanked_and_newlines_kept(self) -> None: - source = ( - 'let a = "}{ \\(items.map { "}" }) {"\n' - 'let b = #"a "}" b"#\n' - 'let c = """\n }\n """\n' - "/* outer /* inner } */ { */\n" - "// case hidden, also\n" - "struct Kept {}\n" - ) - code = ratchet.blank_literals(source) - self.assertEqual(code.count("\n"), source.count("\n")) - self.assertEqual(code.count("{"), 1) - self.assertEqual(code.count("}"), 1) - self.assertIn("struct Kept {}", code) - self.assertNotIn("case", code) - - def test_enum_cases_count_names_not_lines(self) -> None: - body = ratchet.blank_literals( - "\n" - " case a, b, c\n" - " case d(x: Int, y: String)\n" - " case e(\n" - " first: Int,\n" - " second: String\n" - " )\n" - ' case f = "g, h"\n' - " // case commented, out\n" - " var title: String {\n" - " switch self {\n" - " case .a, .b: return \"\"\n" - " default: return \"\"\n" - " }\n" - " }\n" - " enum Nested {\n" - " case inner\n" - " }\n" - ) - self.assertEqual(ratchet.enum_case_count(body), 6) - - def test_stored_properties_skip_computed_static_and_locals(self) -> None: - body = ratchet.blank_literals( - "\n" - " var a = false\n" - " var b: Int?\n" - " let c: String\n" - " private(set) var d = 0\n" - " @Published var e = 1\n" - " nonisolated(unsafe) var f = 0\n" - " var g = 0 {\n" - " didSet { a = true }\n" - " }\n" - " var h = 0, i = false\n" - " var j: [String: Int] = [:], k: Dictionary = [:]\n" - " let l = { 1 }()\n" - " var m: () -> Void = {}\n" - " var n = items\n" - " .filter { $0 > 1 }\n" - " var computed: Bool { a && c.isEmpty }\n" - " var getter: Int {\n" - " get { 1 }\n" - " }\n" - " var braceOnNextLine: Bool\n" - " {\n" - " a\n" - " }\n" - " static let shared = 1\n" - ' let text = "var notAProperty = 1"\n' - " func work() {\n" - " var local = 0\n" - " let other = 1\n" - " }\n" - ) - self.assertEqual(ratchet.stored_property_count(body), 15) - - def test_module_and_engine_words_match_at_camel_case_boundaries(self) -> None: - for suffix in ("AIQueryActions", "AgentPanes", "AIConversations", "HighlightRules", "Redis", "ERDiagram"): - self.assertTrue(ratchet.names_module(suffix), suffix) - for suffix in ("TabRestore", "MAINMenu", "Charter", "TabSwitch", "Explain", "Favorites"): - self.assertFalse(ratchet.names_module(suffix), suffix) - - -class MeasureTests(Fixture): - def test_the_minimal_tree_is_counted(self) -> None: - counts = self.counts() - self.assertEqual(counts["MenuValidationContext stored properties"], 1) - self.assertEqual(counts["menu validation `case #selector` arms"], 1) - self.assertEqual(counts["TabType cases"], 1) - self.assertEqual(counts["shell extensions named for a module or engine"], 1) - self.assertEqual(counts["MainContentCoordinator+ files"], 2) - self.assertEqual(counts["app types AppDelegate names"], 1) - self.assertEqual(counts["files under TablePro/ named for MySQL"], 1) - self.assertEqual(counts["files under TablePro/ named for SQLite"], 0) - - def test_selector_arms_outside_the_main_split_view_controller_are_not_counted(self) -> None: - self.write( - "TablePro/Views/WelcomeWindowController.swift", - "func validate(_ s: Selector) -> Bool {\n switch s {\n case #selector(open(_:)): return true\n" - " default: return false\n }\n}\n", - ) - self.assertEqual(self.counts()["menu validation `case #selector` arms"], 1) - - def test_a_nested_namesake_is_not_the_hub(self) -> None: - self.write( - "TablePro/ViewModels/UsersRolesViewModel.swift", - "final class UsersRolesViewModel {\n enum ActiveSheet {\n case one, two, three\n }\n}\n", - ) - self.assertEqual(self.counts()["ActiveSheet cases"], 1) - - def test_a_feature_extension_on_any_shell_type_counts(self) -> None: - self.write("TablePro/Infrastructure/MainSplitViewController+DataFiles.swift", "extension MainSplitViewController {}\n") - self.write("TablePro/Views/MainContentCommandActions+Focus.swift", "extension MainContentCommandActions {}\n") - self.assertEqual(self.counts()["shell extensions named for a module or engine"], 2) - - def test_postgresql_server_version_is_not_a_sql_server_file(self) -> None: - self.write("TablePro/Core/PostgreSQLServerVersion.swift", "struct PostgreSQLServerVersion {}\n") - self.write("TablePro/Core/AutocommitOnlyStatement+SQLServer.swift", "extension AutocommitOnlyStatement {}\n") - counts = self.counts() - self.assertEqual(counts["files under TablePro/ named for PostgreSQL"], 1) - self.assertEqual(counts["files under TablePro/ named for SQL Server"], 1) - - def test_a_missing_hub_declaration_fails_loudly(self) -> None: - (self.root / "TablePro/Models/QueryTabState.swift").unlink() - status, output = self.run_check() - self.assertEqual(status, 1) - self.assertIn("expected one top-level `enum TabType`", output) - - -class RatchetTests(Fixture): - def test_a_grown_count_fails_and_a_lower_one_passes(self) -> None: - self.assertEqual(self.run_check(update=True)[0], 0) - self.assertEqual(self.run_check(), (0, "")) - - self.write("TablePro/Models/QueryTabState.swift", "enum TabType: Codable {\n case query, table\n}\n") - status, output = self.run_check() - self.assertEqual(status, 1) - self.assertIn("FAIL: TabType cases: 2, up from 1", output) - self.assertIn("may only go down", output) - - self.write("TablePro/Models/QueryTabState.swift", "enum TabType: Codable {\n case query\n}\n") - (self.root / "TablePro/Views/MainContentCoordinator+Rewind.swift").unlink() - status, output = self.run_check() - self.assertEqual(status, 0) - self.assertIn("lower: MainContentCoordinator+ files: 1, down from 2", output) - self.assertIn("--update", output) - - def test_update_lowers_the_baseline_and_never_raises_it(self) -> None: - self.assertEqual(self.run_check(update=True)[0], 0) - self.write("TablePro/Models/QueryTabState.swift", "enum TabType: Codable {\n case query, table\n}\n") - (self.root / "TablePro/Views/MainContentCoordinator+Rewind.swift").unlink() - self.assertEqual(self.run_check(update=True)[0], 0) - baseline = json.loads((self.repository / ratchet.BASELINE).read_text(encoding="utf-8")) - self.assertEqual(baseline["TabType cases"], 1) - self.assertEqual(baseline["MainContentCoordinator+ files"], 1) - self.assertEqual(self.run_check()[0], 1) - - def test_a_count_without_a_baseline_entry_and_a_stale_entry_both_fail(self) -> None: - failures, lowered = ratchet.compare({"gone": 1, "kept": 2}, {"kept": 2, "new": 0}) - self.assertEqual(lowered, []) - self.assertEqual(len(failures), 2) - self.assertTrue(any(line.startswith("new:") for line in failures)) - self.assertTrue(any(line.startswith("gone:") for line in failures)) - - -class RepositoryTests(unittest.TestCase): - def test_the_repository_is_within_its_baseline(self) -> None: - output = io.StringIO() - with redirect_stdout(output): - status = ratchet.run(ROOT) - self.assertEqual(status, 0, output.getvalue()) - - -if __name__ == "__main__": - sys.exit(unittest.main())