diff --git a/.claude/skills/fix-issue/references/orchestration.md b/.claude/skills/fix-issue/references/orchestration.md index 6bc0e1e82..9087fdbdb 100644 --- a/.claude/skills/fix-issue/references/orchestration.md +++ b/.claude/skills/fix-issue/references/orchestration.md @@ -155,7 +155,7 @@ ${DIGEST_RULES} () => agent(` You are the Platform Researcher on a TablePro fix investigation. TablePro is a native macOS -app (SwiftUI + AppKit, macOS 14+) built with the Xcode at /Applications/Xcode-beta.app. +app (SwiftUI + AppKit, macOS 13+) built with the Xcode that `xcode-select -p` names. Problem statement: ${PROBLEM} @@ -165,11 +165,11 @@ Establish what the correct behaviour and the right API are, from the authoritati If this is a UI or interaction problem, that source is Apple: 1. The relevant Human Interface Guidelines section, quoted and linked. 2. The right AppKit/SwiftUI API, named exactly, with its documented behaviour, its - availability against our macOS 14 target, and its gotchas. Prefer the modern API; if the + availability against our macOS 13 target, and its gotchas. Prefer the modern API; if the only option is deprecated, say so and name the replacement. 3. Any standard system control that already does this, so we do not reinvent it. 4. Confirm every symbol against the local SDK interface, which is exact for our toolchain: - /Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/.framework/Modules/.swiftmodule/arm64e-apple-macos.swiftinterface + $(xcrun --sdk macosx --show-sdk-path)/System/Library/Frameworks/.framework/Modules/.swiftmodule/arm64e-apple-macos.swiftinterface If this is a database driver or dependency problem, that source is the vendored header and the shipped binary, not the web docs: diff --git a/.claude/skills/fix-issue/references/research-sources.md b/.claude/skills/fix-issue/references/research-sources.md index 99df0b9e5..396b4ced8 100644 --- a/.claude/skills/fix-issue/references/research-sources.md +++ b/.claude/skills/fix-issue/references/research-sources.md @@ -17,7 +17,7 @@ There are no MCP servers configured in this repo. Everything below is a built-in ### The local SDK interface files ``` -/Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/.framework/Modules/.swiftmodule/arm64e-apple-macos.swiftinterface +$(xcrun --sdk macosx --show-sdk-path)/System/Library/Frameworks/.framework/Modules/.swiftmodule/arm64e-apple-macos.swiftinterface ``` `AppKit`, `SwiftUI`, `Foundation`, and the rest are all there. This is the ground truth for "does this API exist and what is its signature", because it is the interface the compiler will read. Web docs describe intent; the interface file settles facts. Use both: the interface for the signature, the docs for the behaviour. @@ -28,7 +28,7 @@ There are no MCP servers configured in this repo. Everything below is a built-in - **AppKit**: `https://developer.apple.com/documentation/appkit`. Native windows, sheets, `NSToolbar`, `NSTableView` and `NSOutlineView`, `NSWindow` tabbing, the responder chain, menus, `NSViewController`. - **SwiftUI**: `https://developer.apple.com/documentation/swiftui`. TablePro is SwiftUI-first with AppKit where SwiftUI falls short. Check whether a native SwiftUI modifier already does the job before dropping to AppKit, and check the reverse too: several TablePro views are AppKit precisely because the SwiftUI equivalent misbehaves, and `CLAUDE.md` records why. - **Deprecations matter.** Name the modern API. If the only documented option is deprecated, say so and note the replacement. -- **Availability matters.** TablePro targets macOS 14. An API introduced in 15 or 26 needs an `if #available` branch and a fallback, and the blueprint has to say what the fallback is. +- **Availability matters.** TablePro targets macOS 13 (`deploymentTarget` in `project.yml`). An API introduced in 14, 15 or 26 needs an `if #available` branch and a fallback, and the blueprint has to say what the fallback is. ## Competitor apps diff --git a/.claude/skills/fix-issue/references/verification.md b/.claude/skills/fix-issue/references/verification.md index 4858c7f0e..12f9e1ae0 100644 --- a/.claude/skills/fix-issue/references/verification.md +++ b/.claude/skills/fix-issue/references/verification.md @@ -22,14 +22,14 @@ Everything below is the underlying detail: read it when a verdict needs interpre ## Environment setup -`xcode-select` points at `/Library/Developer/CommandLineTools`, which has no `xcodebuild` and no `sourcekitd`. Both `xcodebuild` and `swiftlint` fail without the export below, which reads as "local builds are broken" and leads to shipping unverified code. They are not broken. +`xcodebuild` and `swiftlint` need a full Xcode. When `xcode-select -p` names `/Library/Developer/CommandLineTools` instead, which has no `xcodebuild` and no `sourcekitd`, both fail, which reads as "local builds are broken" and leads to shipping unverified code. They are not broken. + +`verify.sh` resolves `DEVELOPER_DIR` itself: the Xcode `xcode-select` names when it is a full Xcode, else `/Applications/Xcode.app`, else `/Applications/Xcode-beta.app`. For a command the wrapper does not cover, export the same, once per shell command chain or as a prefix: ```bash -export DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer +export DEVELOPER_DIR="$(xcode-select -p)" ``` -`/Applications/Xcode-beta.app` is the only Xcode installed. Export it once per shell command chain, or prefix each invocation. - ## Regenerate before building `TablePro.xcodeproj` is generated by XcodeGen from `project.yml` and is gitignored. XcodeGen globs sources at generation time, so a new `.swift` file is **not compiled** until you regenerate: diff --git a/.claude/skills/fix-issue/scripts/verify.sh b/.claude/skills/fix-issue/scripts/verify.sh index a82b6e5a2..9e25d64ab 100755 --- a/.claude/skills/fix-issue/scripts/verify.sh +++ b/.claude/skills/fix-issue/scripts/verify.sh @@ -161,13 +161,18 @@ report_errors() { # ---------------------------------------------------------------------------- environment setup_toolchain() { - if [ ! -x "${DEVELOPER_DIR:-/nonexistent}/usr/bin/xcodebuild" ]; then - if [ -d /Applications/Xcode-beta.app/Contents/Developer ]; then - export DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer - elif [ -d /Applications/Xcode.app/Contents/Developer ]; then - export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + [ -x "${DEVELOPER_DIR:-/nonexistent}/usr/bin/xcodebuild" ] && return 0 + # The Xcode that xcode-select names wins when it is a full Xcode. Command Line Tools has no + # xcodebuild and no sourcekitd, so fall back to whichever Xcode is installed. + local candidate + for candidate in "$(xcode-select -p 2> /dev/null)" \ + /Applications/Xcode.app/Contents/Developer \ + /Applications/Xcode-beta.app/Contents/Developer; do + if [ -x "$candidate/usr/bin/xcodebuild" ]; then + export DEVELOPER_DIR="$candidate" + return 0 fi - fi + done } wait_for_free_toolchain() { @@ -512,13 +517,13 @@ case "$STEP" in ;; docs) - # The two checks that actually read docs/. Neither runs anywhere else in this script, and - # CI runs them in the "Validate docs" job, so a local run is the only way to see a failure - # before the push. + # The three checks that actually read docs/. None runs anywhere else in this script, and + # CI runs all three in the "Validate docs" job, so a local run is the only way to see a + # failure before the push. log="$(new_log docs)" : > "$log" code=0 - for check in "check-writing-style.sh" "check-docs-against-source.py"; do + for check in "check-writing-style.sh" "check-docs-against-source.py" "check-links.py"; do script="$REPO_ROOT/docs/scripts/$check" if [ ! -f "$script" ]; then note "missing: docs/scripts/$check" @@ -533,12 +538,12 @@ case "$STEP" in if [ "$STATUS" != "INCONCLUSIVE" ]; then if [ $code -eq 0 ]; then STATUS=PASS - note "docs/: house style and source claims both agree" + note "docs/: house style, source claims and every link agree" else STATUS=FAIL # The scripts print one line per check, most of them "ok". Show the failing check # and the file:line under it, not the twenty passes above it. - note "$(grep -A 2 -E '^FAIL' "$log" 2> /dev/null | sed 's/^/ /' | head -15)" + note "$(grep -A 2 -E '^ *FAIL' "$log" 2> /dev/null | sed 's/^/ /' | head -15)" note "$(grep -E 'contradict|house style' "$log" 2> /dev/null | sed 's/^/ /' | head -3)" fi fi diff --git a/docs/scripts/check-links.py b/docs/scripts/check-links.py index 6cdb1a3ac..08bd1d7a1 100755 --- a/docs/scripts/check-links.py +++ b/docs/scripts/check-links.py @@ -10,6 +10,7 @@ import re import sys from pathlib import Path +from urllib.parse import unquote DOCS = Path(__file__).resolve().parent.parent LINK = re.compile(r"\]\((/[^)\s]*?)(?:\s+\"[^\"]*\")?\)") @@ -28,6 +29,34 @@ def nav_pages(node, out, collecting=False): out.add(node) +HEADING = re.compile(r"^#{2,4} +(.+?)\s*$", re.M) +DROPPED = re.compile(r"[()\[\]{}:,;?!`~*+=<>|\\^$%#]") +SEPARATORS = re.compile(r"[\s.]+") +QUOTES = str.maketrans({"\"": "'", "\u201c": "'", "\u201d": "'", "\u2018": "'", "\u2019": "'"}) + + +def heading_anchor(heading): + """The id Mintlify gives a heading, as the published site shows it. + + Measured against docs.tablepro.app on 2026-09-19 over every heading with punctuation in it: + `PL/SQL` is `pl/sql`, `SSL/TLS` is `ssl/tls`, `Users & Roles` is `users-&-roles`, + `Oracle Cloud (ADB)` is `oracle-cloud-adb`, `Breaking changes before 1.0` is + `breaking-changes-before-1-0` and `host:1433` is `host1433`. A slash, an ampersand, an at sign, + an underscore and an ellipsis stay; brackets, colons, commas, semicolons, question marks, + tildes and backticks go; a full stop separates words the way a space does. + + Mintlify also curls straight quotes, and which way it curls one depends on the text around the + heading rather than on the heading alone, so quotes are compared as one character. + """ + text = DROPPED.sub("", heading.lower()) + text = SEPARATORS.sub("-", text) + return re.sub(r"-{2,}", "-", text).strip("-").translate(QUOTES) + + +def link_anchor(anchor): + return unquote(anchor).lower().translate(QUOTES) + + IMPORT = re.compile(r'^import\s+(\w+)\s+from\s+"(/snippets/[^"]+)"', re.M) @@ -99,10 +128,7 @@ def main() -> int: continue slug = "/" + str(path.relative_to(DOCS).with_suffix("")) body = FENCE.sub("", path.read_text()) - anchors[slug] = { - "#" + re.sub(r"[^a-z0-9]+", "-", h.lower()).strip("-") - for h in re.findall(r"^#{2,4} +(.+?)\s*$", body, re.M) - } + anchors[slug] = {heading_anchor(h) for h in HEADING.findall(body)} for path in sorted(DOCS.rglob("*.mdx")): if "node_modules" in path.parts: @@ -124,7 +150,7 @@ def main() -> int: bare = page.lstrip("/") if bare not in on_disk: failures.append(f"{rel}:{line_no} links to {target}, which does not resolve") - elif anchor and "#" + anchor not in anchors.get(page, set()): + elif anchor and link_anchor(anchor) not in anchors.get(page, set()): failures.append(f"{rel}:{line_no} links to {target}, but that heading does not exist") for asset in SRC.findall(line): if not (DOCS / asset.lstrip("/")).exists():