Skip to content

skit: SyntaxKit codegen CLI for v0.0.5#156

Merged
leogdion merged 56 commits into
v0.0.5from
research/swift-manifest-codegen
Jun 10, 2026
Merged

skit: SyntaxKit codegen CLI for v0.0.5#156
leogdion merged 56 commits into
v0.0.5from
research/swift-manifest-codegen

Conversation

@leogdion

@leogdion leogdion commented May 12, 2026

Copy link
Copy Markdown
Member

Summary

This branch lands skit, a SyntaxKit CLI for config-driven Swift codegen. Two verbs:

skit run Input.swift            # render a SyntaxKit DSL file → Swift source
skit run InputDir/ -o OutDir/   # folder mode
skit parse < Input.swift        # parse Swift → JSON syntax tree

run is the default subcommand, so skit Input.swift works without a verb.

The PR unifies the previous skit (stdin → JSON parser, ~50 lines) and the POC skitrun (DSL renderer, ~700 lines) into one binary with ArgumentParser subcommands. Net replacement: one CLI, one product, declarative arg parsing, productized docs.

What's in

  • Unified skit CLI with run + parse subcommands (run is the default). Built on swift-argument-parser.
  • Wrap-and-spawn pipeline for run: parse the input with SwiftSyntax, hoist imports, wrap the rest in Group { … }, spawn script-mode swift, capture stdout, splice the output. #sourceLocation remaps body diagnostics back to the input file.
  • Helpers compilation cacheHelpers/ directories auto-discovered up-tree from the input, compiled once into libSyntaxKitHelpers.{dylib,so} and cached by content hash. --no-helpers / --helpers <dir> overrides.
  • Output cache — fully-rendered output cached by (input, helpers, libSyntaxKit stamp, swift version, sorted SKIT_*/SYNTAXKIT_* env) hash. Warm hit ≈ 0.14s, no swift spawn. --no-cache to skip.
  • Toolchain stamp + detect — bundle records lib/swift-version.txt at build time; skit run compares to local swift --version on startup and refuses to spawn swift on mismatch with a clear error and the rebuild command. --no-toolchain-check bypass.
  • Timeout watchdog — default 60s per-input timeout on the spawned swift. SIGTERM → 5s grace → SIGKILL. Exit code 124. --timeout 0 disables. Implementation uses DispatchSemaphore.wait(timeout:) to avoid Linux Foundation.Process.waitUntilExit() hangs.
  • Self-contained release bundleScripts/build-skit-release.sh produces .build/skit-release/{skit, lib/}. Portable: cp -r it anywhere, runs zero-config.
  • Linux verifiedswift:6.0-jammy/aarch64. swift-crypto replaces CryptoKit; the Mach-O install_name rewrite is skipped. Concurrent pipe drain via DispatchGroup to avoid the 64KB pipe-buffer deadlock.
  • Examples — 10 of 11 Examples/Completed/*/dsl.swift files render end-to-end through skit. The 11th (enum_generator) was a full demo project shape rather than single-file DSL; moved to Examples/Demos/enum_generator/ where it doesn't masquerade as a completed example.
  • DocsDocs/skit.md is the deep dive (architecture, caches, toolchain stamp, timeout, sharp edges, deferred items). Sources/skit/README.md is the per-target quick reference.

What's deferred

Tracked as follow-up issues, not blockers:

Explicitly out of scope for v0.0.5: multi-file output from a single input, sandboxing the spawned swift, an HTTP/web-server form. All discussed in Docs/skit.md under "What's deferred".

Sharp edges documented for users

  • @main and other top-level decl attributes don't work on DSL expressions (the wrapper places them inside a result builder where attributes can't bind to function calls). Users wanting @available etc. in the output should use the DSL's .attribute(…) method.
  • Comments in the input file don't carry through to the rendered output — they're for the input's author only. Emit output comments via .comment { Line(.doc, …) }.

Test plan

# Build the self-contained bundle.
Scripts/build-skit-release.sh

# Quick smoke test.
echo 'Struct("Hello") { Variable(.let, name: "name", type: "String") }' > /tmp/h.swift
.build/skit-release/skit /tmp/h.swift

# Sweep all 10 working examples.
for ex in card_game conditionals for_loops macro_tutorial attributes \
          concurrency blackjack errors_async protocols swiftui; do
  .build/skit-release/skit "Examples/Completed/$ex/dsl.swift" --no-cache >/dev/null
  echo "$ex: $?"
done

# Stdin → JSON.
echo 'struct Foo { let x: Int }' | .build/skit-release/skit parse

# Help text.
.build/skit-release/skit --help
.build/skit-release/skit run --help

Reviewer focus

  • Docs/skit.md — the design + as-shipped explainer. Best read first.
  • Sources/skit/Skit.swift — CLI surface via ArgumentParser.
  • Sources/skit/Runner.swift — the wrap-and-spawn pipeline + toolchain check.
  • Scripts/build-skit-release.sh — bundle layout and how the dylib + swiftmodules get staged.

The deferred items (#157, #158) and the documented sharp edges (@main, comments) are the natural discussion points; everything else should be a straight read.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef8f5069-f97a-4e0c-bb2c-210577b8832b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch research/swift-manifest-codegen

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@leogdion leogdion changed the base branch from main to v0.0.5 May 12, 2026 10:06
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: skitrun POC (PR #156)

Reviewing as draft/research — focusing on correctness bugs worth fixing before step 4, design issues worth locking in now, and POC-aware feedback. Not flagging things the PR explicitly defers (timeout, caching, Linux smoke test).


Overview

Solid research output. The Tuist analysis is thorough, the design doc is well-reasoned, and the three POC steps de-risk the approach incrementally. The wrap+spawn architecture is the right call — it keeps the host dead-simple and avoids an AST round-trip. The #sourceLocation trick for diagnostic fidelity is a nice find and worth preserving.

The main concerns below are one real bug that can deadlock and one Swift concurrency misuse that can exhaust the thread pool under load. Both are fixable before step 4.


Bug: Pipe deadlock in runSwift (high severity)

Sources/skitrun/Main.swiftrunSwift function (~line 1255)

try process.run()
process.waitUntilExit()

let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()

This is the classic Foundation.Process + Pipe deadlock. Pipe has a fixed kernel buffer (~64 KB on macOS). If the spawned swift process writes enough to stdout or stderr to fill the buffer before exiting, it will block waiting for the parent to drain — but the parent is blocked in waitUntilExit() and never drains. Deadlock.

For a compiler error on a large input or a large codegen output this is very reachable.

Fix: Read from both pipes concurrently with process execution:

let stdoutData = DispatchSemaphore(value: 0)
var stdout = Data()
var stderr = Data()
let stdoutQueue = DispatchQueue(label: "skitrun.stdout")
let stderrQueue = DispatchQueue(label: "skitrun.stderr")

stdoutPipe.fileHandleForReading.readabilityHandler = { fh in
    stdout.append(fh.availableData)
}
stderrPipe.fileHandleForReading.readabilityHandler = { fh in
    stderr.append(fh.availableData)
}

try process.run()
process.waitUntilExit()

stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
// drain any remaining bytes
stdout.append(stdoutPipe.fileHandleForReading.readDataToEndOfFile())
stderr.append(stderrPipe.fileHandleForReading.readDataToEndOfFile())

Or just kick each readDataToEndOfFile() onto a background Thread/DispatchQueue before calling waitUntilExit().


Bug: Blocking thread pool in withTaskGroup (medium severity)

Sources/skitrun/Main.swiftrunDirectory / runOne (~line 963)

runOne calls processFile which calls runSwift which calls process.waitUntilExit() — a blocking, non-returning call. Inside a Swift concurrency TaskGroup, all tasks share the cooperative thread pool. Blocking a pool thread with a syscall starves other tasks and, with many files, can exhaust the pool entirely (Swift 6 strict concurrency mode will warn about this).

The cap (activeProcessorCount) helps but doesn't eliminate the problem — the Swift runtime may not schedule enough OS threads to compensate.

Fix: Bridge the synchronous spawn to async with withCheckedThrowingContinuation + a detached Thread, or use DispatchQueue.global().async with a continuation. Alternatively, keep withTaskGroup but run each processFile on a DispatchQueue worker via a continuation so the cooperative pool isn't blocked:

func runSwiftAsync(wrappedPath: String, libPath: String) async throws -> ProcessResult {
    try await withCheckedThrowingContinuation { continuation in
        DispatchQueue.global().async {
            do {
                let result = try runSwift(wrappedPath: wrappedPath, libPath: libPath)
                continuation.resume(returning: result)
            } catch {
                continuation.resume(throwing: error)
            }
        }
    }
}

Design: _-prefix skip applies only to filenames, not parent directories

Sources/skitrun/Main.swiftcollectInputs (~line 1044)

guard !url.lastPathComponent.hasPrefix("_") else { continue }

dir/_Helpers/Foo.swift would NOT be skipped — only files whose own name starts with _. If the helpers convention (design §4) intends _-prefixed directories as private scopes, the skip rule needs to check all path components, not just the last one.

Suggest: check every path component between inputDir and the file, or document that _ applies to filenames only (and name shared helpers files _Shared.swift, not directories).


Fragile: path separator +1 in relative path computation

Sources/skitrun/Main.swiftrunDirectory (~line 980)

let relative = outcome.input.path.dropFirst(inputURL.path.count + 1)

+1 assumes the separator is exactly one character after the prefix, and that inputURL.path doesn't end with /. If inputURL.path ends with a slash (e.g. when the user passes ./dir/), the +1 eats the first character of the relative path.

Use URL-native relative path stripping instead:

let relative = outcome.input.path.dropFirst(
    inputURL.path.hasSuffix("/") ? inputURL.path.count : inputURL.path.count + 1
)

Or better, use URL(fileURLWithPath: outcome.input.path, relativeTo: inputURL).relativePath.


Missing: tests for wrap(source:originalPath:)

wrap is internal and is the only pure-function piece of the whole pipeline. It has no tests. This is the most testable thing in the codebase — it takes a String, returns a String, no I/O. A handful of unit tests (empty input, import-only input, body-only input, mixed imports + body, non-ASCII path) would catch regressions when the wrapping format changes.

The function's correctness is load-bearing for diagnostic fidelity (the #sourceLocation offset) — worth covering before it gets more complex.


Minor: ProcessResult type asymmetry

private struct ProcessResult {
  let exitCode: Int32
  let stdout: Data     // binary-safe
  let stderr: String   // decoded at capture site
}

Treating stdout as Data and stderr as String makes sense given usage (stdout is written verbatim, stderr is string-processed for path rewriting), but it's easy to trip over. A comment on each field stating why would help the next reader.


Minor: -framework SyntaxKit / -F flags missing from runSwift

The design doc (§3) shows both -lSyntaxKit and -framework SyntaxKit / -F flags; the implemented runSwift only passes -I/-L/-lSyntaxKit. This is likely intentional for the POC (dylib-only, no framework bundle), but it diverges from the design doc. Worth a note so step 4's bundled-binary work doesn't miss the framework-search-path case.


Minor: default --lib is POC-only, correct to flag early

The hardcoded /tmp/syntaxkit-poc/lib default will confuse anyone who runs skitrun without first running poc-step1.sh. Since this is a draft, it's fine — but step 4's ResourceLocator should make --lib optional (bundle-relative default) before any wider exposure.


Shell script

Docs/research/poc-step1.sh is well-structured: set -euo pipefail, cleanup trap on EXIT, Python3 for the Package.swift mutation. One nit: the python3 - heredoc uses string matching on Package.swift formatting — if SwiftFormat ever reflows the library product block, the script silently exits non-zero via sys.exit(...), which is fine, but a confirming echo after the patch would make failures easier to diagnose in CI.


Design questions (pre-step-4 bikeshed)

Per the PR's ask:

  • Naming: Weak preference for syntaxkit run as a subcommand on the existing skit binary rather than a new binary. The use cases don't overlap today, but both are "tooling that operates on SyntaxKit files" — a single entry point keeps installation simpler. That said, skit is currently stdin-driven JSON and that's a very different UX from a codegen runner, so the separate-binary argument is real.

  • _-prefix convention: Fine as a filename convention. The Tuist parallel (Helpers/ directory, not _Helpers/) is probably the cleaner split since it's directory-based rather than name-based, and avoids the question of whether the skip propagates through subdirectories. But _-prefix is simpler to implement and document.

  • Folder layout: Mirroring input paths into the output tree is the right default. The one gap is what happens when two input files produce the same output path (e.g. a/Foo.swift and b/Foo.swift both map to out/Foo.swift if the input root is chosen wrongly). Folder mode preserves relative paths so this isn't a problem given correct usage, but a clear error when destination paths collide would be good.


Summary: One deadlock bug, one thread pool issue, and one fragile path join to fix before step 4. Everything else is POC-appropriate. The design is sound.

@codecov

codecov Bot commented May 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.00000% with 147 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v0.0.5@508bcca). Learn more about missing BASE report.

Files with missing lines Patch % Lines
Sources/SyntaxKit/Execution/Runner+Session.swift 0.00% 44 Missing ⚠️
...es/SyntaxKit/Execution/Bundle+ResolveLibPath.swift 0.00% 25 Missing ⚠️
...es/SyntaxKit/Execution/FileManager+Execution.swift 12.00% 22 Missing ⚠️
Sources/SyntaxKit/Execution/Task+Timeout.swift 0.00% 16 Missing ⚠️
Sources/SyntaxKit/Execution/Runner.swift 86.66% 12 Missing ⚠️
Sources/SyntaxKit/Execution/RunInput.swift 0.00% 10 Missing ⚠️
Sources/SyntaxKit/Execution/Runner+Directory.swift 90.56% 5 Missing ⚠️
Sources/SyntaxKit/Execution/OutputCache.swift 94.11% 4 Missing ⚠️
Sources/SyntaxKit/Execution/CLIError.swift 0.00% 3 Missing ⚠️
...s/SyntaxKit/Execution/FileManager+FileSystem.swift 94.00% 3 Missing ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff            @@
##             v0.0.5     #156   +/-   ##
=========================================
  Coverage          ?   77.18%           
=========================================
  Files             ?      147           
  Lines             ?     4974           
  Branches          ?        0           
=========================================
  Hits              ?     3839           
  Misses            ?     1135           
  Partials          ?        0           
Flag Coverage Δ
spm 76.97% <69.87%> (?)
swift-6.1 77.12% <69.81%> (?)
swift-6.2 76.98% <69.81%> (?)
swift-6.3 76.98% <69.81%> (?)
ubuntu 77.16% <69.81%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — skitrun POC (#156)

This is a well-scoped research PR with a clean design. The "wrap into Group { … } and spawn swift" approach is the right call — it keeps the host process small and avoids any AST serialization round-trip. Notes below are ordered by impact.


Bugs / Correctness

1. Blocking waitUntilExit() inside Swift concurrency (withTaskGroup)

runSwift calls process.waitUntilExit(), which blocks the calling OS thread. Since runOne is called from withTaskGroup tasks, this runs on Swift's cooperative thread pool — blocking those threads starves other tasks and can cause deadlocks under high concurrency.

Fix options:

  • Wrap the Process lifecycle in withCheckedThrowingContinuation + NotificationCenter/terminationHandler:
    process.terminationHandler = { p in continuation.resume(returning: p.terminationStatus) }
    try process.run()
    let exitCode = await withCheckedContinuation { continuation in ... }
  • Or mark runSwift / processFile as nonisolated and dispatch to a global DispatchQueue via Task { await Task.detached(priority: .userInitiated) { ... }.value }.

For a POC this is low-risk at small batch sizes, but at activeProcessorCount concurrency the thread pool can still be depleted.

2. Non-atomic output write in single-file mode (Sources/skitrun/Main.swift, runSingleFile)

try result.stdout.write(to: URL(fileURLWithPath: outputPath))

Data.write(to:) is not atomic by default. A crash mid-write leaves a partial file. Use:

try result.stdout.write(to: URL(fileURLWithPath: outputPath), options: .atomic)

The folder-mode path (processResult.stdout.write(to: destination)) has the same issue.


Platform Portability

3. isLibDir is macOS-only

return fm.fileExists(atPath: "\(path)/libSyntaxKit.dylib")

.dylib is a macOS extension. On Linux the file would be libSyntaxKit.so. The poc-step1.sh script also exits early with "This reproducer is macOS-only." — fine for now, but isLibDir will silently reject a valid Linux lib dir when step 7 lands.

Suggestion: check for both extensions, or accept any file matching libSyntaxKit.*:

let candidates = ["libSyntaxKit.dylib", "libSyntaxKit.so"]
return candidates.contains { fm.fileExists(atPath: "\(path)/\($0)") }

Design / Edge Cases

4. wrap: imports are only hoisted when they appear before the first non-import statement

From the current logic, a user who writes:

Struct("Foo") { ... }
import MyHelpers    // after a DSL statement

will get import MyHelpers left in the body inside Group { … }, which Swift will reject (import declarations must appear at the top level). The failure message from swift will be cryptic.

Worth adding a runtime check in wrap: if any ImportDeclSyntax is found after firstBodyByte, emit a warning to stderr pointing the user at the hoisting rule.

5. Reserved variable name collision

The generated wrapper uses __skitrun_root. If the user happens to define a variable with that name in their DSL file, the compiler will emit a confusing duplicate-symbol error. A UUID suffix (__skitrun_\(UUID().uuidString.replacingOccurrences(of: "-", with: "_"))) would make collisions impossible, at the cost of reproducibility in diagnostics.

6. No timeout on spawned swift process

The design doc (§7) specifies 60 s default + SIGTERMSIGKILL. The implementation has no timeout at all. Not blocking for a POC, but worth a // TODO: add --timeout (see codegen-cli-design.md §7) comment so it doesn't get forgotten.


Minor / Style

7. --help writes to stderr

case "-h", "--help":
    FileHandle.standardError.write(Data(helpText.utf8))
    exit(0)

Convention for most Unix tools is --help → stdout (exit 0), --help on error → stderr (exit 2). Writing help to stderr makes it harder to pipe or page (skitrun --help | less won't work). Easy fix: use FileHandle.standardOutput.

8. @main on enum SkitRun

Valid Swift, and deliberately chosen to prevent accidental instantiation — that's a reasonable use. Worth a one-line comment explaining the choice (// enum prevents instantiation; @main entry point) so the next reader doesn't wonder.

9. poc-step1.sh mutates Package.swift in-place

The trap cleanup EXIT + backup/restore is solid. The one gap: if the script is killed with SIGKILL (not SIGTERM/EXIT), the backup isn't restored. A .bak file left at $PACKAGE_BACKUP makes recovery easy to document: add a note in the script header that $PACKAGE_BACKUP can be used to restore manually.


Design Questions (from PR's "what I'd value review on")

Naming: skit run as a subcommand on the existing skit binary makes more sense than a separate skitrun binary if the two executables share any infrastructure (flags, lib-path resolution, etc.). Right now they don't — skitrun doesn't depend on SyntaxKit at all, while skit is a stdin-driven parser. The use cases are orthogonal, so a separate syntaxkit binary or a skit run subcommand both work. My vote is skit run to reduce the binary surface area, but the cases for a standalone syntaxkit are reasonable.

_-prefix skip rule: Clear, precedented (Swift itself uses _ for private-by-convention). Lock it in — no objection.

Folder layout: The Helpers/ adjacent-to-input sketch is reasonable. The only risk is ambiguity when a folder contains both Helpers/ and _-prefixed files — document the precedence (helpers directory is not walked, _-prefix files inside a walked directory are skipped).


Summary

Core logic ✅ Correct and well-structured
Blocking async ⚠️ Should be addressed before prod
Atomic writes ⚠️ Easy fix, do it now
Linux portability ⚠️ Needed for step 7
Import hoisting edge case 📝 Worth a warning
Timeout 📝 Add TODO comment
Help to stderr 📝 Minor

Good research PR — the design is sound and the POC ladder gives a clear path forward. The blocking waitUntilExit and non-atomic writes are the two items worth fixing before step 4 (bundled release).

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156: Research + POC: skitrun codegen CLI

This is a draft/POC, so the review focuses on design soundness and the issues worth resolving before the code graduates to a real target, rather than nit-level style polish.


Overview

The PR delivers a working three-step pipeline: parse a pure-DSL input file with SwiftParser, splice it into a Group { … } + print(…generateCode()) wrapper, and spawn swift against a pre-staged libSyntaxKit.dylib. The helpers caching and folder-mode concurrency work end-to-end. The design is coherent and the POC status is clearly communicated — this is genuinely useful exploratory work.


Design Feedback

Wrap shape is correct. The import-hoisting + #sourceLocation remapping is the right approach. Diagnostic paths pointing at the input file is a first-class concern and it's handled from the start — good.

skitrun not depending on SyntaxKit is an important property worth preserving. The host is just a compiler driver; any coupling to SyntaxKit's type system would make version pinning painful. Keep it.

Naming. Agree with the author's weak preference for a separate syntaxkit binary over a run subcommand on the existing skit. The stdin-driven JSON-parser shape of skit is completely different from a compiler-driver shape, and sharing a binary would mean shipping SyntaxKit as a hard dependency of the CLI host — contradicting the "host is dependency-free" property above.

_-prefix as the private-helpers skip rule is non-standard Swift. Swift convention for "skip this" in other contexts is . (dotfiles) or out-of-tree placement, not underscore-prefix. Before locking in step 4–5, consider making it configurable via a .skitrunignore file or defaulting to the .-prefix rule that most tools already honour. The current approach will silently eat any file a user name-prefixes with _ for unrelated reasons (e.g. _generated_output.swift placed in the helpers directory by another tool).


Security Concerns

Process argument injection — low risk but worth noting.
Main.swift passes paths to the swift / swiftc invocations as [String] array elements (not shell-interpolated strings), which is safe. The risk would appear if a future change switches to Process's launchPath = "/bin/sh" + arguments = ["-c", "swift \(path)"] form — add a comment to runSwift and compileHelpers making the safe calling convention explicit so it isn't accidentally regressed.

Library path from user-controlled source.
--lib accepts an arbitrary directory and the dylib from that path is loaded into the spawned child, not the host. The risk is bounded to the child process, which already runs arbitrary user Swift code. Not a blocker, but worth documenting.

Cache directory permissions.
Helpers.swift uses ~/Library/Caches/com.brightdigit.SyntaxKit/helpers/<hash>/ without explicitly setting permissions. FileManager.createDirectory defaults to 0755 which is fine for a single-user developer tool, but the intention should be explicit.


Platform Support

~/Library/Caches/ is macOS-only.
Helpers.swift hard-codes FileManager.default.urls(for: .cachesDirectory, ...). On Linux this resolves to ~/.cache/ via Foundation emulation but the behaviour isn't guaranteed across Swift toolchain versions and the module directory may use .dylib extensions vs .so. Since POC step 7 (Linux smoke test) is pending, at minimum add a #if os(macOS) guard with a fatalError("Linux not yet supported") so CI doesn't silently produce broken artifacts.

.dylib extension is hard-coded in resolveLibPath. The validation check libSyntaxKit.dylib will fail on Linux where the artifact is libSyntaxKit.so. Abstract this behind a dynamicLibraryExtension computed property.


Error Handling

CLIArgs.parse exits on error via fputs + exit(1) rather than throwing. This is pragmatic for a CLI entry point but makes parse untestable in isolation. Consider keeping the throwing form internally and having main() catch and print — the pattern try CLIArgs.parse(...) catch { fputs(...); exit(1) } composes better with future tests.

Helpers build-race handling (concurrent invocations write to tmp.<pid>.<uuid>/ then atomic-rename) is a solid approach. One gap: if two processes hash to the same cache key but one crashes mid-compile, the partial temp directory is leaked. Add a cleanup on compileHelpers failure.


Performance

Concurrency cap at ProcessInfo.activeProcessorCount is the right instinct but can overwhelm CI machines where activeProcessorCount is reported as the container vCPU count while disk I/O is the actual bottleneck. Consider capping at min(ProcessInfo.activeProcessorCount, 8) as a safe upper bound.

dylib mtime as cache-key proxy for version is fragile — a touch or a cp -p that preserves mtime but changes content would produce a stale cache hit. Use the dylib's SHA-256 hash instead of mtime (the file is small; hashing it is fast).


Test Coverage

This is the main gap for graduation from POC. The shell scripts in Docs/research/ cover integration-level smoke tests but the following units are testable today and would catch regressions during refactor:

  1. wrap(source:originalPath:) output shape — given a two-line input, assert the generated wrapper has the correct #sourceLocation line numbers and the imports are hoisted.
  2. CLIArgs.parse — assert --lib, -o, --no-helpers, directory-input-requires-output, help text.
  3. helpersCacheKey() — assert that changing a helper source byte changes the hash.
  4. discoverHelpersDir(near:) — assert walk-up terminates correctly at the filesystem root.

Adding these under Tests/SyntaxKitTests/Unit/SkitRun/ would align with the existing test layout.


Minor Issues

  • internal access level on SkitRun and most helpers — correct for an executable target; no change needed, just confirming intent.
  • No timeout on spawned swift — acknowledged in PR. Before step 4 ships, add a 60-second SIGTERM → 5s → SIGKILL sequence (or use Task.sleep + process.terminate()). A runaway script will otherwise silently hang the caller.
  • Folder mode output tree creation is done per-file. If two concurrent tasks try to createDirectory for the same intermediate subdirectory the second will get an "already exists" error — ensure withIntermediateDirectories: true is set (standard FileManager call handles this, just confirm).
  • poc-step1.sh mutates Package.swift in-place with a sed-based flip and a trap revert. This is fragile under Ctrl-C before the trap fires and under parallel swift build invocations. For a reproducible benchmark script, copy Package.swift and restore from the copy rather than relying on a reverse-sed.

Summary

Area Status
Core design (wrap + spawn) ✅ sound
Helpers caching ✅ solid; dylib mtime → SHA-256 recommended
Folder-mode concurrency ✅ works; cap at min(cpu, 8) recommended
Platform (Linux) ⚠️ macOS-only; needs guard before step 4
Security ✅ low risk; add call-convention comment
Test coverage ⚠️ no unit tests yet; four units are ready to add
Naming / conventions ⚠️ _-prefix skip rule — reconsider before locking in
Timeout on spawned process ⚠️ acknowledged gap; block step 4 on this

The three POC steps that are done are genuinely solid. The above are the things I'd want resolved before step 4 (release bundle) ships, since they become harder to change once the CLI has users.

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156 (Research + POC: skitrun codegen CLI)

This is a well-documented research PR with solid POC implementation. The design decisions are clearly reasoned and the trade-offs are explicitly called out. Below is a detailed review across the areas you flagged and the things I noticed independently.


Overview

The PR delivers a working skitrun POC CLI that wraps pure-DSL SyntaxKit input files, splices them into a Group { … } wrapper, and spawns swift to produce rendered Swift source. Steps 1–6 of the ladder are complete. The research docs (Tuist comparison, step results) are unusually thorough and will be a valuable reference.


Design Correctness

Wrap-and-spawn is the right shape for v1. The key insight — that the host process only needs to assemble flags, not parse ASTs — keeps skitrun tiny and keeps SyntaxKit itself out of the CLI's dependency graph. The #sourceLocation trick for diagnostic remapping is elegant and avoids manual stderr arithmetic.

The departure from Tuist's token-delimited stdout is justified. SyntaxKit's output is Swift source, not a JSON descriptor that the host needs to reinterpret, so there's no need for the MANIFEST_START/END envelope. The design doc makes this explicit.

Naming. The skitrun POC name is fine for a branch. For the final shape, I'd lean toward a run subcommand on the existing skit binary rather than a new top-level binary. Rationale: skit already handles Swift parsing tasks, users install one binary, and discoverability is better (skit --help surfaces run). The concern about overlapping use cases is real but manageable via subcommand help text.


Code Quality

Helpers.swiftdiscoverHelpersDir(near:)

The walk-up-to-root logic is correct, but the loop invariant is subtle. An explicit comment explaining that parent.path == dir.path detects the filesystem root would help future readers:

// dir.deletingLastPathComponent() returns itself at the filesystem root
if parent.path == dir.path { return nil }

Package.swift modification in shell scripts

The inline Python that string-matches the exact library product block is fragile. Three separate scripts duplicate the same old/new block verbatim. If Package.swift is reformatted (SwiftFormat 602 will do this), all three scripts break silently at the if old not in src: sys.exit(...) guard. Suggest extracting this into a single shared Scripts/make-dynamic.py invoked from all three scripts. Better yet, leave a // MARK: dynamic-library-toggle comment in Package.swift and key the Python off the comment anchor rather than the exact whitespace.

Shell scripts — repeated staging boilerplate

poc-step1.sh, poc-step4-release.sh, poc-step5.sh, and poc-step6.sh all repeat the same 30-line pattern: backup Package.swift, flip library type, build, stage to /tmp/…. For a POC this is fine, but if any of these scripts graduate to CI they should be refactored.

OutputCache.swiftlibStamp proxy

The step 6 results note this: size + mtime is a coarse proxy that a deterministic rebuild can defeat. The correct long-term fix (embed a version constant in the bundle) is the right call. Worth leaving a // TODO: in the code to prevent future maintainers from treating the proxy as intentionally sufficient.


Potential Bugs / Issues

1. CryptoKit on Linux (blocking for step 7)

Helpers.swift imports CryptoKit, which is macOS-only. The step 6 results explicitly flag this:

"CryptoKit is macOS-only; Linux will need swift-crypto or a small fallback hash impl behind a #if canImport(CryptoKit) shim."

This needs to be resolved before step 7. The cleanest option is adding swift-crypto as a dependency and using it unconditionally rather than the shim approach, since swift-crypto is already in the SwiftSyntax/SyntaxKit ecosystem.

2. if-in-Group type-checker crash (#155)

Already filed separately. Worth noting for reviewers: this is a blocker for users wanting conditional codegen, which is a common pattern. The underlying issue (variadic any CodeBlock overloads + buildEither overload resolution) should be prioritized before skitrun ships publicly, since the error message ("failed to produce diagnostic for expression") will be deeply confusing to users.

3. Walk-up false positives for Helpers/

Step 5 results note this: an unrelated Helpers/ directory higher in the tree will be picked up silently. The --helpers <dir> escape hatch exists, but the failure mode is confusing (unexpected helpers compiled into the invocation). Consider requiring a sentinel file (Helpers/.syntaxkit) or at minimum logging which helpers directory was discovered at a verbose log level.

4. Import-line diagnostic offset

Step 5 results: #sourceLocation wraps the body but not the hoisted imports, so errors on user import lines are off by one. Low priority but worth a // FIXME: comment in the wrapper generation code.

5. No timeout on the spawned swift process

The design doc (§7) calls for a 60s default with SIGTERM → 5s grace → SIGKILL. This is not yet implemented. For a POC it's fine, but this should land before any public release — an infinite-looping codegen script will block a build step indefinitely.

6. Bundle includes SwiftSyntax twice (45 MB total)

Step 4 results identify this: SwiftSyntax is linked statically into skitrun (for input parsing) and dynamically via libSyntaxKit.dylib. The fix (make skitrun share a dynamic SwiftSyntax with the SyntaxKit dylib) is non-trivial but important for the "feels small" download goal. Could be mitigated in the near term by making skitrun's input parsing use the same dynamic SwiftSyntax rather than its own static copy.


Performance

The measured numbers are good:

  • Cold: ~720ms (dominated by dylib loading, acceptable for a generator)
  • Warm: ~110ms
  • Output cache hit: ~140ms (no swift spawn)

The parallelism in folder mode is working (3 files in 1.41s vs. 3×0.72s = 2.16s serial). The observation that per-file compile work isn't deduplicated across a batch is correct — the right long-term fix (a single long-lived swift process servicing all inputs) is architecturally complex and appropriately deferred to post-v1.


Security

No sandboxing for v1. The design doc acknowledges this and the Tuist comparison shows sandbox-exec is the right reference implementation. For v1 (user's own repo), this is acceptable — users who run skitrun Input.swift have the same trust model as running swift Input.swift directly. The concern rises if/when this runs in CI against third-party contributions.

Package.swift is modified at build time in scripts. The trap-based backup/restore is correct but fragile under power loss or hard kill. The cleanup() trap is registered before the modification, so ordinary exits (including signals caught by bash) are handled correctly. This is fine for a POC.


Test Coverage

No automated tests for skitrun itself. For a POC this is expected. When this graduates from research status, the following should be added:

  1. Unit tests for discoverHelpersDir(near:) — particularly the walk-up-to-root edge case and the directory vs. file input handling.
  2. Unit tests for the wrapper generation (#sourceLocation injection, import hoisting).
  3. Integration tests using the existing swift test harness: spawn skitrun against known inputs and assert stdout matches expected output.
  4. Tests for OutputCache cache key stability (same input → same key, mutated input → different key).

The bash POC scripts serve as manual integration tests today and are clearly written, which is helpful.


Folder Layout Conventions

The _-prefix skip rule is clean and consistent. The Helpers/ adjacency convention is intuitive. Both are worth locking in for steps 4–5. One bikeshed worth raising: the helpers module name SyntaxKitHelpers is hardcoded. If users want multiple helper modules or a project-specific name, this becomes a limitation. For v1 hardcoding is fine — just ensure it's documented as a known constraint.


Summary

The POC is solid and the design is well-reasoned. The key things to address before merging (or continuing to v1):

Priority Item
Blocking (step 7) CryptoKit → swift-crypto for Linux
High Process timeout (60s + SIGTERM/SIGKILL)
High if-in-Group crash (#155)
Medium Walk-up helpers false-positive mitigation
Medium Bundle size (duplicate SwiftSyntax)
Low Shell script Package.swift manipulation fragility
Low Import-line diagnostic offset fix

The design doc, step results, and POC shell scripts are all worth keeping as permanent research artifacts — they'll be the canonical reference when the feature is revisited.

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156: skitrun POC

This is a research/POC PR (explicitly not for merge as-is), so the review focuses on issues worth resolving before the design graduates to production code, plus a few things that are already well-done.


Overview

The PR introduces a skitrun CLI executable that wraps pure-DSL SyntaxKit input files in a Group { … } closure, spawns swift, and captures the rendered output. Three Swift source files are added (Main.swift, Helpers.swift, OutputCache.swift), a new executable target is wired up in Package.swift, and twelve research/results docs are added under Docs/research/.

The design property of not linking SyntaxKit into the host binary is smart — it keeps the CLI small and avoids the messy version-pinning problem that Tuist has with ProjectDescription.framework.


Code Quality & Correctness

captureSwiftVersion() / libStamp() are called twice per invocation.
Both helpersCacheKey() and outputCacheKey() call these independently. Each captureSwiftVersion() call spawns a swift --version subprocess; libStamp() does two attributesOfItem syscalls. Neither result changes during a single CLI run — memoize them (e.g., pass as String? parameters, or lazily compute once at startup).

No process timeout in runSwift() / compileHelpers().
process.waitUntilExit() blocks forever. The design doc already flags this as a known gap and suggests 60s / SIGTERM / 5s grace / SIGKILL. Worth adding before step 4 ships, because a hung swift invocation will hang the CLI with no user-visible feedback.

isLibDir() only checks for libSyntaxKit.dylib.
A partial installation (dylib present, .swiftmodule files or _SwiftSyntaxCShims-include/ missing) passes the check and fails later with a confusing missing required module '_SwiftSyntaxCShims' error. Adding a check for the C-shims directory would produce a much cleaner error:

private func isLibDir(_ path: String) -> Bool {
  let fm = FileManager.default
  var isDir: ObjCBool = false
  guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { return false }
  return fm.fileExists(atPath: "\(path)/libSyntaxKit.dylib")
    && fm.fileExists(atPath: "\(path)/_SwiftSyntaxCShims-include")
}

libStamp() uses mtime, which is fragile.
touching the dylib, restoring from a backup at the same mtime, or crossing a filesystem boundary can produce false cache hits or false misses. A SHA-256 of the first 1 KB of the dylib header (or even just the file size as a secondary check) would be more stable. The current approach is acceptable for a POC but worth hardening before the cache is used in CI.

__skitrun_root variable name in the generated wrapper.
Double-underscore names are reserved by the Swift compiler for internal use (__ prefixed names are generated for various compiler internals). A collision is unlikely in a function-level context, but _syntaxKitRoot or _skitrunOutput would be less risky and equally private.

wrap() doesn't strip a leading blank line from body.
If the input file has a blank line before the first non-import statement (common), the wrapper contains:

let __skitrun_root = Group {
#sourceLocation(file: "...", line: 3)

Struct("Person") {  }

The blank line is inside the Group closure but before the #sourceLocation, which is fine. However, any error on that blank line maps to line 3 of the original file (correct), but the blank line itself is at the wrong wrapper offset. Low priority, but worth testing explicitly.

runDirectory always prints "N/M succeeded" to stderr, even on full success.
For CI and piped usages this is noise. Consider only printing when failed > 0, or adding a --verbose flag that enables the summary in the success case.


Performance

Folder mode concurrency cap: ProcessInfo.activeProcessorCount.
Each swift spawn blocks a thread waiting on dylib loads (I/O-bound), not CPU. A cap of 2× or 3× activeProcessorCount might be more efficient, or letting the task group drain naturally without a manual semaphore (since each spawn blocks a thread, the Swift cooperative pool won't help here — you'd want actual OS threads or a hard cap via an AsyncSemaphore). The current sentinel-loop approach is correct and safe; the concurrency level is just a tuning knob to revisit.


Design

_-prefix skip rule vs. a _helpers or .skitignore convention.
The single underscore convention is clear for simple projects. Before locking it in at step 4, worth considering whether users will want to exclude test fixture files or generated files that happen to be in the same directory without renaming them. A .skitignore file or --exclude flag would be more ergonomic at scale, but is premature for v1.

helpersModuleName = "SyntaxKitHelpers" is hardcoded.
All projects share the same module name. If users ever have multiple helpers directories (e.g., per-subdirectory helpers), they'll collide at import time. Since the cache key isolates the compiled artefacts, there's no functional issue today, but the hardcoded name will become a constraint. At minimum, document this limitation.

Single-file output writes processResult.stdout with write(to:) (line ~1929) without atomicity — the output directory case uses FileManager directly. The single-file case (runSingleFile) uses result.stdout.write(to: URL(fileURLWithPath: outputPath)) which is also non-atomic. For consistency with the cache's staging + rename pattern, the output write should also be atomic (write to a temp file, rename). Concurrent skitrun invocations writing the same output path would race otherwise.


What's well-done

  • No SyntaxKit link in the host. This keeps cold-start at 0.72s instead of adding another framework to load.
  • #sourceLocation for diagnostic remapping — zero manual arithmetic, and it just works for the body range.
  • Atomic cache writes using staging + rename and the "loser drops their copy" semantics are solid for concurrent invocations.
  • resolveLibPath error message is genuinely helpful — it lists exactly what was searched and how to fix it.
  • The POC ladder table in the PR description makes it very easy to track what's production-ready vs. still WIP.
  • Research docs are thorough — the Tuist pipeline analysis in particular is a good reference for explaining design decisions.

Open issues worth filing before step 4 ships

  1. Process timeout on runSwift / compileHelpers.
  2. Atomic output write in runSingleFile and the folder-mode success path.
  3. captureSwiftVersion() / libStamp() called twice — easy perf win.
  4. isLibDir() should also verify the C-shims include directory.

The if-in-Group compiler crash (#155) is already filed and is independent of this PR.

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: PR #156skitrun POC CLI

Reviewed as a draft/research POC, not a merge-readiness review. Feedback is oriented toward the design questions raised in the PR description and the issues that should be addressed before step 4+ hardening begins.


Overview

This PR documents the Tuist manifest pipeline research and delivers a working skitrun POC that wraps pure-DSL .swift inputs into a Group { … } closure and spawns swift with a bundled SyntaxKit dylib. Steps 1–3 (hand-driven timing, CLI subcommand, folder mode + partial-failure semantics) are all implemented and appear to work end-to-end on macOS and Linux.

The documentation (tuist-manifest-pipeline.md, codegen-cli-design.md, and the three POC results files) is well-written and thorough. The key design decisions are well-motivated.


Design Correctness

The "pure-DSL input, wrap into Group { … }, spawn swift" shape is sound. Rejecting Tuist's Output(...) wrapper is the right call — it eliminates a deserialization layer that would add complexity without benefit, since the generated source is the output.

One concern: the Group { … } wrapping assumes the entire user input is a single CodeBlock-building expression sequence. If users write multi-file outputs via a single input script, the wrapper shape breaks. The PR already calls this out-of-scope, but it should be documented explicitly in the design doc as a constraint, not just an exclusion.

On naming: Strongly agree with separating skitrun from the existing skit binary. The use cases don't overlap — skit parses Swift→JSON, skitrun evaluates DSL→generated Swift. Merging them into a run subcommand would conflate two very different mental models. A standalone syntaxkit binary (or skit run as a clearly-scoped subcommand on a renamed top-level CLI) both seem reasonable; the important thing is the split.

Folder layout: The _-prefix skip rule is clear and conventional (mirrors Swift's _ protocol prefix for internal use). The Helpers/ adjacency convention is easy to discover. Both are fine to lock in at step 4.


Code Quality & Bugs

Main.swift

1. Process start failure leaves semaphore un-signaled (potential deadlock)

In the spawn function, terminationHandler is registered before process.run(). If process.run() throws, the handler is never called and the DispatchSemaphore.wait() below it blocks forever:

process.terminationHandler = { _ in semaphore.signal() }
try process.run()           // ← throws: handler never fires
// …
semaphore.wait()            // ← deadlocks

Fix: wrap process.run() in a do/catch and signal the semaphore in the catch block (or use a defer).

2. #sourceLocation path escaping is incomplete

The path is escaped for backslashes and quotes, but #sourceLocation(file:) also fails on paths containing \n or null bytes (unusual but possible on Linux). Use Foundation.URL.path percent-encoding, or simply reject input paths containing those characters with a clear error.

3. Import-line diagnostic offset

Hoisted imports aren't wrapped in their own #sourceLocation directive, so diagnostics in imports report the wrong line numbers. Low-severity for a POC, but easy to fix before users encounter it.

Helpers.swift

4. libStamp uses mtime + file size as a cache key proxy

This is a known weak proxy: deterministic rebuilds (same content, forced rebuild) produce the same mtime+size and won't invalidate the cache. The design doc acknowledges hashing the dylib bytes defeats cache efficiency, but the current proxy is silently incorrect in that case. Consider using the dylib's modification time truncated to second precision (which swiftc updates on any actual recompile) and document the known limitation explicitly in a comment.

5. Helpers directory walk-up has no depth limit

The walk-up from input path to find Helpers/ will traverse all the way to / if no helpers directory exists, stat-ing every ancestor. On a deep project tree this is harmless but slow. Cap the walk at a reasonable depth (e.g., 10 levels) with a diagnostic if the limit is hit.

6. swiftc invocation doesn't sanitize PATH

The helpers compiler spawns swiftc via resolved path, but the flags passed (e.g., -module-name) come from caller-controlled input (the helpers directory name). If the directory name contains shell metacharacters and the invocation path ever goes through a shell, this would be a command injection vector. Verify that Process arguments are passed directly (no shell interpolation) — if so, this is a non-issue; just worth confirming in a comment.

OutputCache.swift

7. Cache key omits output path

The SHA-256 key covers input source, helpers key, swift version, libStamp, and env vars — but not the output directory path. Two invocations writing to different -o directories with identical inputs will share a cache entry and the second will read the first's output (correct content, wrong path for path-dependent generated code). If the generated output ever embeds the output path (e.g., for #sourceLocation in the output), this will silently emit wrong paths. Recommend including the output path in the cache key.


Performance

  • Binary footprint: SwiftSyntax statically linked into skitrun (~17 MB) while also shipped as a dylib in lib/. For a developer tool this is acceptable, but it's worth noting in the release docs that users don't need to ship skitrun with their generated artifacts — only the generated .swift files.
  • Helpers cold-start (~2.96s): Unavoidable for first run; the cache makes subsequent runs fast. The step 5 design should document the warm/cold split prominently in the UX (e.g., a --verbose timing line).
  • Concurrent folder mode: The ProcessInfo.activeProcessorCount cap is sensible. Consider making it overridable via --jobs for constrained CI environments.

Security

  • No significant security concerns for a local developer tool. The lack of sandboxing is called out in the PR and is consistent with Tuist's own approach for v1.
  • The --lib flag and $SKITRUN_LIB_DIR env var accept arbitrary paths; document that pointing them at untrusted directories is equivalent to arbitrary code execution (standard disclaimer for plugin/dylib loaders).

Test Coverage

There are no automated Swift tests for skitrun — all coverage is via the shell script reproducers in Docs/research/. This is appropriate for a POC, but before step 4 the following should have unit tests:

  • wrapInput() (import hoisting + Group { } wrapping)
  • OutputCache key generation and atomic write/hit logic
  • Helpers directory walk-up and _-prefix filtering

The #155 bug (if-in-Group crashes the type checker) should have a regression test once fixed — it's exactly the kind of issue that would silently re-appear.


Summary

The POC is well-executed and the three open design questions (shape, naming, folder conventions) all have defensible answers. The main pre-step-4 blockers are:

Priority Issue
Must fix Process start failure → semaphore deadlock (#1 above)
Should fix Output cache key missing output path (#7)
Should fix Helpers walk-up depth limit (#5)
Nice to have Import-line diagnostic offset (#3)
Document libStamp weak-proxy caveat (#4), dylib loader trust boundary

Great research foundation — the Tuist pipeline doc in particular is a useful reference regardless of where the final CLI design lands.

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: skitrun POC CLI (PR #156)

This is a well-executed research spike. The POC ladder approach (retire one risk per step, measure, document) is exactly the right structure for this kind of architectural exploration. Notes below are calibrated to the draft/POC status and the explicit scope decisions called out in the PR description.


Overview

skitrun wraps a user's pure-DSL .swift file into a Group { … } closure, spawns swift with the bundled SyntaxKit dylib on the search path, and captures the generated code. Single-file and folder (concurrent) modes both work. The design deliberately avoids depending on SyntaxKit in the host process — the host only needs SwiftSyntax/SwiftParser for import-hoisting and swift-crypto for cache keying.


Strengths

  • Architecture is clean and minimal. The host does exactly three things: wrap, spawn, cache. No AST round-trip, no JSON envelope. The departure from Tuist's manifest framing is well-justified.
  • Cross-platform handling is thorough. The Process.waitUntilExit() hang on Linux, the pipe-buffer deadlock, the @rpath install_name Mach-O-only flag, and the CryptoKit → swift-crypto swap are all handled correctly and clearly commented.
  • Concurrency design is sound. withTaskGroup + activeProcessorCount cap + Sendable-clean FileOutcome is the right shape for bounded fork-join parallelism in Swift 6.
  • Partial-failure semantics in folder mode match what a user would expect from a build tool (all successes written, failures reported, non-zero exit).
  • #sourceLocation remapping is the right mechanism for diagnostic attribution. Good that it's validated end-to-end in step 2.
  • Documentation quality is high. The POC step results files read like proper engineering journals — timing measurements, observed vs. expected, follow-up actions. These are worth keeping even after the CLI ships.

Issues & Suggestions

1. Process timeout is a v1 blocker, not just future work

The PR description marks timeouts as "out of scope for v1," but I'd push back mildly: a hanging swift child (e.g., a DSL file that triggers an infinite type-checker loop — not hypothetical given issue #155) will silently hang skitrun forever with no feedback to the user. In folder mode, the withTaskGroup slot stays occupied, stalling all subsequent files.

A minimal timeout (e.g., 60s with a logged warning) using Task.sleep + task.cancel() or a DispatchWorkItem-based approach would make the tool usable in CI without a watchdog wrapper. Worth considering before step 4 (bundled-binary release) ships.

2. Helpers walk-up can silently pick the wrong directory

The current discovery walks up from the input file until it finds a Helpers/ directory. If the user's repo has an unrelated Helpers/ in a parent directory (common in many projects), skitrun will silently compile and link it. The PR notes this as a "false positive" risk in step 5 results, but the fix should land before users try it.

Suggested heuristic: only accept a Helpers/ that also contains at least one .swift file with import SyntaxKit at the top, or require the presence of a sentinel file (e.g., .skithelpers). Either approach is cheap to add and avoids confusing failures.

3. @unchecked Sendable on PipeDataBox — document the invariant more precisely

The comment says the box is needed because "raw local vars can't be Sendable." That's true, but the safety argument is incomplete: what makes this actually safe is that the box is only written from one DispatchGroup-leaf closure at a time (stdout reader vs. stderr reader), and the DispatchGroup.wait() provides the happens-before edge before the data is read. Worth adding that to the comment so future readers don't have to re-derive it.

4. String(decoding: stderrData, as: UTF8.self) silently replaces invalid bytes

String(decoding:as:) uses the Unicode replacement character (U+FFFD) for non-UTF-8 byte sequences rather than failing. In practice Swift's compiler output is always UTF-8, so this is low risk — but if a child process emits binary or latin-1 output on stderr, the diagnostic rewriting pass will produce garbled messages. Consider String(bytes: stderrData, encoding: .utf8) ?? "<non-UTF-8 stderr>" to make the degraded case explicit.

5. No automated tests for skitrun

The POC validation is entirely in shell repros + manual measurement. Before step 4 (release bundle), a small test suite would help:

  • Unit test wrap() with a known input → assert expected wrapper structure (imports hoisted, body preserved, #sourceLocation present at correct line).
  • Integration test: end-to-end processFile() with a trivial DSL input in a CI-available environment (macOS runner).

The wrap logic is pure and has no side effects, so unit testing it is straightforward without needing a live swift binary.

6. Cache eviction policy is absent

OutputCache writes entries and reads them but never evicts. Over time (many input files, multiple toolchain upgrades) the cache will accumulate stale entries. Cache keys include the toolchain version hash, so stale entries from old toolchains won't produce wrong output — but they will consume disk space indefinitely.

For v1, even a simple LRU eviction by mtime (drop entries not accessed in N days on cache write) or a --clean-cache flag would be sufficient.

7. Minor: .build-linux/ in .gitignore is fine, but document why it's separate

The .gitignore addition is correct, but without context a future contributor might wonder why the Linux build output lives outside .build/. Worth a one-line comment in the gitignore or a note in the README.


Design Questions (per the PR's explicit asks)

Naming — skit run vs standalone binary

The PR's weak preference is a separate syntaxkit binary. I'd agree: skit today is stdin-driven Swift-to-JSON (a different enough use case that sharing a binary would confuse the UX). A separate syntaxkit run subcommand on a new top-level binary feels cleaner. The skitrun POC name is clearly a placeholder, so no strong objection to either direction — just worth deciding before step 4 locks the binary name into a release artifact.

Folder layout — _-prefix skip rule

The _-prefix convention is clear and matches existing Swift ecosystem conventions (e.g., SwiftPM's _ prefix for internal modules). No objection. Make sure it's documented in the README (it may already be — didn't verify).

Wrap shape — Group { … } vs Output(...)

The Tuist-style Output(...) wrapper alternative was correctly rejected. The Group { … } wrap is natural for a @resultBuilder DSL and doesn't require the user to know anything about the CLI's execution model.


Summary

The design is sound and the POC execution is solid. The three things I'd prioritize before marking step 4 as done:

  1. A process timeout (even a rough one).
  2. A Helpers/ walk-up guard heuristic.
  3. A unit test for wrap().

Everything else is polish that can land incrementally. The Linux compatibility work in step 7 is particularly well-done.

🤖 Reviewed with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156: skitrun POC

This is a well-researched POC. The overall design (pure DSL input → wrap in Group { … } → spawn swift) is clean, the documentation is thorough and honest about gaps, and the Linux porting work (pipe drain order, waitUntilExit bug, install_name conditional) is correctly handled throughout. Observations below, ordered by severity.


Correctness Issues

1. captureSwiftVersion() spawns a subprocess on every outputCacheKey() call

In folder mode with N inputs, processFile is called N times, each computing a fresh outputCacheKey, each spawning swift --version. That's N extra subprocesses for something constant per invocation. Easy fix: compute it once at startup and thread it through as a parameter (or a lazy let at module scope using nonisolated(unsafe) let swiftVersionCache: String? = captureSwiftVersion()).

2. wrap() injects import SyntaxKit unconditionally — duplicate if user already imports it

If the user writes import SyntaxKit in their DSL file (to satisfy an IDE or linter), it'll be hoisted into hoisted[] and the wrapper preamble will inject a second one. Swift tolerates duplicate imports, but it produces a warning the user didn't write. Fix: filter "SyntaxKit" from the hoisted list before constructing the wrapper string, or only inject import SyntaxKit when it's not already in hoisted.

3. #sourceLocation path escaping is incomplete

let escapedPath = originalPath
  .replacingOccurrences(of: "\\", with: "\\\\")
  .replacingOccurrences(of: "\"", with: "\\\"")

A path containing a newline (unusual but legal on most filesystems) would break the #sourceLocation directive and produce a confusing compile error attributed to the wrapper. Worth adding a guard before the wrap:

guard !originalPath.contains("\n") else {
    throw CLIError(message: "input path contains a newline: \(originalPath)")
}

4. runOne blocks cooperative thread pool threads on semaphore

runDirectory uses withTaskGroup to schedule up to maxConcurrent runOne tasks. Each runOne calls processFilerunSwift, which blocks a cooperative thread pool thread on DispatchSemaphore.wait(). If all maxConcurrent tasks simultaneously reach the wait, every worker thread in the pool is blocked — Swift's cooperative scheduler can't preempt them. The concurrent pipe-drain further adds two DispatchQueue.global() async items per file.

For a POC this is fine. For productization, converting the inner Process launch to async via withCheckedContinuation (wrapping the semaphore signal) would let the structured concurrency scheduler actually breathe.


Minor Issues

5. __skitrun_root variable name collides with user-defined names (low risk)

let __skitrun_root = Group {

A user DSL file that defines let __skitrun_root = ... would shadow this. Using something like __skitrun_v1_output__ or using a UUID-derived suffix would eliminate the risk entirely.

6. discoverHelpersDir walks to filesystem root without bound

The loop terminates when parent.path == dir.path, which is the root. A deeply nested input in a project that has no Helpers/ directory will walk every ancestor all the way to /. Consider capping at a git root (git rev-parse --show-toplevel) or a fixed depth (e.g., 10 levels) and documenting the convention.

7. PipeDataBox uses @unchecked Sendable — subtle

The comment in poc-step7-results.md explains the reasoning well, but the class itself has no note about the invariant that makes it safe (only one writer, reads happen only after group.wait()). Adding a brief inline comment would help the next reader who considers adding a second mutation point.

8. swift-crypto for just SHA-256

On Apple platforms, CryptoKit.SHA256 is available in the SDK with no package dependency. swift-crypto is the right choice for Linux portability, but the step-7 results already call out the boringssl binary size hit. For the productized release, consider using #if canImport(CryptoKit) / #else to dispatch to the native framework on Apple and swift-crypto only on Linux. This keeps the macOS binary lean without breaking Linux.


Design Questions (per the PR's ask)

On naming (skit run subcommand vs separate binary)

My lean is skit run — both skit and skitrun are in the SyntaxKit DSL domain, and consolidating under one binary reduces the surface area users have to discover. The only argument for a separate binary is if the dylib/lib-path requirements make installation meaningfully different from the existing skit use case. If they do share users, a subcommand also makes skit --help self-documenting.

On the skip rule (_-prefix) and Helpers/ discovery

The _-prefix convention is consistent with Swift's own convention for "internal to this module" and is predictable. The Helpers/ upward-walk is powerful but could surprise users working in monorepos. Worth documenting that the search stops at the first Helpers/ found, not the nearest git root (users may expect git-root semantics). Consider explicitly calling this out in the README under a "How helpers are discovered" section.

On if-in-Group crash (issue #155)

This is the biggest user-visible hole. Any conditional codegen requires a workaround (computed vars in a helper, or Switch). Worth linking the issue from the Sources/skitrun/README.md "What won't work" section so users hit the issue before hitting the compiler's unhelpful diagnostic.


Test Coverage

There are no tests for the new code. For a POC that's fine, but wrap() in particular has enough branching (import hoisting, empty-body edge case, #sourceLocation injection, path escaping) that even a small table-driven @Test would catch regressions when the signature inevitably evolves. Same for CLIArgs.parse — the arg parsing is hand-rolled and has enough edge cases (flag order, missing values, unknown flags) to benefit from a few targeted tests.


API Changes in Examples

The examples show a cluster of breaking API changes that aren't explicitly called out in the PR description:

  • Variable(.var, ..., defaultValue:)equals: rename
  • ComputedProperty now requires explicit type: label
  • ParameterParameterExp rename
  • Call("print", "string")Call("print") { ParameterExp(unlabeled: Literal.string(...)) } (more explicit but more verbose)
  • Infix("lhs", "op", rhs)Infix("op", lhs:..., rhs:...) (operator moved to first position, arguments labeled)
  • Bare let structExample = Group {...}; print(...) → top-level expression (required for skitrun input shape)

These are the right moves for skitrun input compatibility, but they'll all be breaking for existing SyntaxKit users if merged to main. Worth a dedicated changelog entry or a migration note when this leaves draft.


Summary: The POC ladder is complete and the research deliverables are solid. The concurrency issue (#4) and the captureSwiftVersion O(N) cost (#1) are the two things I'd fix before productizing. Everything else is polish or documentation.

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156: skitrun POC CLI

This is a well-documented research PR with a solid design foundation. The POC ladder is clearly articulated, the research docs are thorough, and the 7-step build-up is a good way to de-risk the approach incrementally. The notes below are forward-looking for when this moves toward merge rather than blocking the research phase.


Architecture & Design

Overall shape is sound. The "wrap into Group { … }, spawn swift, capture stdout" model is simpler than Tuist's token-delimited payload and maps cleanly to SyntaxKit's DSL semantics. The decision to not depend on SyntaxKit in the host binary keeps the host small and avoids the version-pinning pain Tuist hits with ProjectDescription.

swift-crypto is now a package-level dependency. Adding it in Package.swift dependencies: pulls it in for all consumers of SyntaxKit who resolve the package graph, even those who never use skitrun. Before finalising the package structure, consider either scoping it to the target only (it already is in targets:, but SwiftPM still resolves the top-level dependencies for all clients) or gating it with #if canImport(CryptoKit) on Apple platforms so CryptoKit is used instead — the Crypto wrapper exists exactly for this cross-platform case, but the package-level dependency is still added on Apple platforms. Worth a comment explaining why Crypto is used even on macOS.


Code Quality

Main.swift at 819 lines is too big. The file contains argument parsing, process spawning, directory enumeration, wrapping logic, and the @main entry point. The conventions in CLAUDE.md ask for focused, single-responsibility files. The wrapping logic (wrap()), argument parser (CLIArgs), and the spawn/timeout code could each live in their own file the way Helpers.swift and OutputCache.swift are split out.

PipeDataBox: @unchecked Sendable is a workaround that passes Swift 6 strict-concurrency checking without actually being safe — two threads write to .value without synchronisation. It works here because writes happen in DispatchGroup async blocks and .value is read only after group.wait(), but the compiler can't verify that. A (sendableData: Data) -> Void callback closure passed into each DispatchQueue.global().async block — or using withCheckedThrowingContinuation on Pipe — would be both safe and checkable.

captureSwiftVersion() is called up to three times per invocation (toolchain check, helpers cache key, output cache key). The result should be memoized at startup into a let on CLIArgs or passed as a parameter.

Indentation inconsistency in runDirectory (for await outcome in group body):

group.addTask {
runOne(                        // ← missing indentation
  next, libPath: libPath, ...
)
}

processResult.stdout.write(to: destination) is not atomic in folder mode. Data.write(to:) without .atomic option can leave a half-written file if the process is interrupted mid-write. Use .write(to:, options: .atomic).


Bug Risks

wrap() only hoists leading imports. If a user writes:

import SyntaxKit
Struct("Person") { ... }
import Foundation     // ← NOT hoisted

the second import stays in the body and the spawned swift fails with error: imports are not allowed after top-level code. This is a real footgun for incremental file editing. The fix is either a second pass to hoist all imports regardless of position, or a clear error message that explains the constraint when non-leading imports are detected.

#sourceLocation path injection. The path escaping in wrap():

originalPath
  .replacingOccurrences(of: "\\", with: "\\\\")
  .replacingOccurrences(of: "\"", with: "\\\"")

...does not handle newlines. A path containing \n#sourceLocation() would close the source-location directive early and potentially inject compiler directives. Paths with newlines are unusual but allowed by POSIX. Consider replacing \n, \r, and NUL bytes as well, or rejecting input paths that contain them at startup.

helpersExcludePath only skips a Helpers/ dir directly under inputDir, but discoverHelpersDir can find one anywhere up the tree. In folder mode, if the helpers dir lives at a parent level (e.g. project/Helpers/ and inputDir = project/inputs/), collectInputs won't exclude it — helpers sources won't be enumerated as inputs anyway (they're not under inputDir), but the asymmetry is confusing and could bite edge cases (e.g. inputDir = project/ where Helpers/ is directly under project/). Worth a comment or an explicit test.

Missing timeout for compileHelpers. DispatchSemaphore.wait() in compileHelpers has no deadline. A hung swiftc blocks the caller indefinitely. The same 60-second watchdog pattern used for swift script-mode should apply here.


Performance

maxConcurrent = activeProcessorCount is likely too high for this workload. Spawning N swift interpreter processes in parallel is memory-intensive (each cold-start loads a 9 MB dylib plus all swiftmodule files into a fresh process). On a machine with 10 cores that's potentially 10 simultaneous 720 ms cold-starts each loading ~100 MB of shared libs. A default of 2–4 would be safer; the design doc could expose --concurrency <n> alongside --timeout so users can tune.

libStamp() uses mtime + size for cache invalidation. mtime is not reliable after git checkout, tar extraction, or Homebrew bottle installs, all of which can set arbitrary timestamps. A SHA-256 of the first N bytes of libSyntaxKit.dylib (or of its LC_UUID if on macOS) would be more robust. The current approach will cause spurious cache misses and unnecessary recompiles after common packaging operations.


Test Coverage

The PR adds ~1,200 lines of production code with zero automated tests. Before leaving research phase, the following pure-logic functions would benefit from unit tests under Tests/SyntaxKitTests/Unit/ (no process spawning required):

  • wrap(source:originalPath:) — import hoisting, body passthrough, #sourceLocation placement, edge cases (empty file, imports-only file, leading comment)
  • CLIArgs.parse(_:) — flag combinator correctness, missing-value errors, unknown-flag errors, directory vs. file mode detection
  • discoverHelpersDir(near:) — walks up correctly, stops at filesystem root, finds adjacent dir
  • collectHelperSources(in:)_-prefix skip, non-.swift skip
  • outputCacheKey determinism — same inputs → same key, changed input → different key

Minor / Bikeshed

  • The PR description mentions __syntaxkit_root but the code uses __skitrun_root. The description should be updated or vice versa.
  • helpText is a private let at file scope; it could move inside CLIArgs to reduce Main.swift's global surface.
  • The README.md under Sources/skitrun/ is good for discoverability, but CLAUDE.md asks to "prefer editing existing files" and "not create documentation files unless explicitly requested". Given this is a POC, the README is justified — just noting it for the production version.

Design Questions (as raised in the PR)

Naming. Agreed that skitrun is a placeholder. The strongest argument for a run subcommand on skit (rather than a separate binary) is that it reduces binary count in a Homebrew formula. The strongest argument against is that skit today is stdin/stdout-driven JSON parsing — a completely different use case — and conflating them produces a confusing --help. A separate syntaxkit or skit-run binary seems cleaner; the skit name could even be retired if syntaxkit subsumes it.

Folder skip convention. _-prefix is a recognisable Swift convention (private test helpers, _Internal targets). It's a good default. Would also suggest honouring a .skitrunignore or .gitignore-style file in a later step for finer control without renaming files.


Good research — the cold-start numbers (720ms cold / 110ms warm) are well inside acceptable latency for a codegen CLI, the design doc is thorough, and the identified blockers (#155 if-in-Group crash, toolchain ABI fragility) are called out honestly. The implementation is POC-quality as advertised; the notes above are mostly things to address before the done column on the POC ladder.

@leogdion leogdion changed the title Research + POC: skitrun codegen CLI driven by SyntaxKit (#154) skit: SyntaxKit codegen CLI for v0.0.5 May 12, 2026
@leogdion leogdion marked this pull request as ready for review May 12, 2026 19:28
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: skit CLI for v0.0.5

TL;DR: This is a well-thought-out, clearly documented MVP for the skit CLI. The two-layer caching strategy, import hoisting, and timeout watchdog are all solid engineering choices. The main gaps are test coverage, missing CI integration, and undocumented DSL API changes visible in the examples.


Overview

The PR unifies the old stub skit (parse/JSON) and the POC skitrun (DSL renderer) into a single binary with two ArgumentParser subcommands (run and parse). The run path parses DSL input, wraps it in Group { }, hoists import declarations, spawns swift, and caches the result. Very clean architecture for a v0.0.5 milestone.


Strengths

  • Two-layer content-addressed cache (helpers + output) mirrors what Tuist does; the atomic-rename write strategy in both Helpers.swift and OutputCache.swift handles concurrent skit invocations correctly.
  • Timeout watchdog (SIGTERM → 5s grace → SIGKILL via DispatchSemaphore) is the right fix for the Linux Foundation.Process.waitUntilExit() hang; matches POSIX convention with exit code 124.
  • Toolchain stamp (swift-version.txt burned in at build time, checked at startup) prevents silent dylib ABI mismatches and provides a clean error + rebuild command.
  • Docs/skit.md is reference-quality — architecture, caches, sharp edges, and deferred items are all clearly explained. Great first-read for reviewers.
  • build-skit-release.sh is smart: temp-flips Package.swift to dynamic, rewrites Mach-O install_name, stamps toolchain, produces a cp-able bundle.

Issues & Suggestions

🔴 No test coverage for the new skit target

Runner.swift is 611 lines covering subprocess spawning, #sourceLocation injection, pipe draining, timeout, path rewriting, and folder-mode parallelism — none of it has unit or integration tests. At minimum:

  • wrap(): test import hoisting order and #sourceLocation injection with a few fixture inputs.
  • OutputCache / Helpers cache key generation: verify determinism and that a single-byte change in input busts the key.
  • Timeout watchdog: a mock Process that never terminates should trigger SIGTERM → SIGKILL.

Even a single integration test that runs skit Examples/Completed/card_game/dsl.swift --no-cache and asserts non-empty stdout would catch regressions.

🔴 No CI for skit / release bundle

There are no GitHub Actions changes visible in this diff. The release script and Linux path both need coverage:

# Suggested job addition in CI
- name: Build skit release bundle
  run: Scripts/build-skit-release.sh
- name: Smoke test skit
  run: .build/skit-release/skit Examples/Completed/card_game/dsl.swift

🟡 DSL API breaking changes undocumented

The example diffs show widespread API changes that aren't mentioned in the PR description:

  • defaultValue:equals:
  • Parameter()ParameterExp()
  • .let()Pattern.let()
  • print() removed (now uses implicit Group output)

These are breaking changes for anyone on the current main. They need a migration note in CHANGELOG.md or at minimum a PR description section. Otherwise users upgrading from v0.0.4 will have silent build failures.

🟡 .vscode/launch.json references skitrun (deleted target)

{
    "name": "Debug skitrun",
    "target": "skitrun",   // ← this target no longer exists
    ...
}

The PR description says skitrun is unified into skit, but the launch config still references it. This will show an error for VS Code Swift extension users.

🟡 Environment variables missing from output cache key

OutputCache.swift only includes SKIT_* and SYNTAXKIT_* env vars in the cache key. DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, and SWIFT_EXEC can all affect the swift subprocess output but are excluded. Consider including them or at minimum documenting the limitation.

// Current:
let envSnapshot = ProcessInfo.processInfo.environment
    .filter { $0.key.hasPrefix("SKIT_") || $0.key.hasPrefix("SYNTAXKIT_") }

// Suggestion: also include DYLD_* and SWIFT_EXEC

🟡 swift-crypto version constraint is loose

Package.swift specifies from: "3.0.0" but Package.resolved pins 3.15.1. Tighten the lower bound to avoid pulling in a version known to be incompatible:

.package(url: "...", from: "3.15.0")

🟢 Minor: kill() return value unchecked

In Runner.swift, the SIGKILL fallback after grace period doesn't check the kill() return value. On a process that already exited (ESRCH), this is harmless, but a brief comment explaining the intentional ignore would help future readers.

🟢 Minor: Root README.md not updated

After this PR lands, skit is a major deliverable but nowhere in the root README. Add a brief "Usage" section pointing to Docs/skit.md and the release script so it's discoverable from GitHub.


Summary

Area Status
Architecture & caching ✅ Solid
Toolchain stamp + mismatch detection ✅ Well done
Timeout watchdog ✅ Correct
Documentation (Docs/skit.md) ✅ Excellent
Test coverage ❌ None for new code
CI integration ❌ Not added
DSL breaking changes documented ⚠️ Missing
VS Code config (skitrun reference) ⚠️ Stale
Cache key completeness ⚠️ Minor gap

The deferred items (#157 auto-rebuild, #158 if-in-Group crash) are reasonable and well-tracked. The two blocking items before merge are test coverage and documenting the DSL API changes.

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156: skit CLI for SyntaxKit codegen

Overview

This PR unifies skit (stdin→JSON parser) and the POC skitrun (DSL renderer) into a single binary with two ArgumentParser subcommands. The architecture is sound: wrap-and-spawn pipeline, two-layer content-addressed cache, toolchain stamp guard, and a Linux-verified DispatchSemaphore workaround for Foundation.Process bugs. The doc coverage is excellent.


Potential Bugs / Correctness

captureSwiftVersion() is called on every cold-cache path — multiple times

outputCacheKey calls captureSwiftVersion(), helpersCacheKey calls it independently, and the toolchain check calls it a third time at startup. Each call spawns a subprocess. For a single file these are serial and add meaningful latency on a cold run. More importantly, if swift --version output changes between invocations (e.g., a TOOLCHAINS= override mid-session) the helpers cache key and the output cache key would disagree. Memoize once per process:

private let _swiftVersion: String? = captureSwiftVersion()

PipeDataBox is @unchecked Sendable with unsynchronized mutation

private final class PipeDataBox: @unchecked Sendable {
  var value = Data()
}

value is written from DispatchQueue.global() and read on the calling thread after group.wait(). The group.wait() provides a happens-before edge so the read is safe in practice, but Swift's concurrency checker can't see through DispatchGroup barriers — hence the @unchecked. This is a known-safe pattern (Dispatch memory model), but fragile to refactor. Consider adding a brief comment explaining the ordering guarantee, or use nonisolated(unsafe) on the property (Swift 5.10+) to be more explicit.

Stale .vscode/launch.json entries reference skitrun

The two new launch configurations target skitrun:

"target": "skitrun",
"preLaunchTask": "swift: Build Debug skitrun"

Package.swift has no skitrun target — that product was removed in this PR. These will fail silently or break the VS Code Swift extension build task. Should reference skit.

wrap() body-offset slicing uses byte index into a String

let start = source.utf8.index(source.utf8.startIndex, offsetBy: firstBodyByte.utf8Offset)
body = String(source[start...])

firstBodyByte.utf8Offset is a byte offset in the UTF-8 view, but source[start...] indexes into the Character view via String.Index. utf8.index(offsetBy:) returns a valid String.Index for well-formed UTF-8 (which Swift source always is), so this is correct today. Still worth a comment since this isn't idiomatic Swift string slicing and a reviewer could easily mis-read it as a bug.


Missing Test Coverage

The new Runner.swift, Helpers.swift, ContentHasher.swift, and OutputCache.swift have no automated tests. The test plan is entirely manual. At minimum, unit tests for the following would be valuable and are easy to write:

  • wrap() — verify import hoisting, #sourceLocation line numbers, empty-body case
  • ContentHasher — determinism, avalanche behaviour on small changes
  • outputCacheKey — different inputs produce different keys
  • discoverHelpersDir — walk-up finds the right directory
  • toolchainCheck — match/mismatch/missing-stamp branches

These don't require spawning swift and could live in Tests/SyntaxKitTests/Unit/.


Performance

Helper sources read twicehelpersCacheKey reads each source file for hashing; compileHelpers then hands the paths to swiftc which reads them again. This is minor (helpers are small) but the source data is already in memory after hashing. Passing [URL: Data] through would avoid the second stat+read.

captureSwiftVersion() during output cache key computation — see the bug note above. On a cold miss for a single-file run the call graph is: processFileoutputCacheKeycaptureSwiftVersion (subprocess). Combined with the startup toolchain check, a cold run spawns swift --version at least twice. Caching the result is straightforward.


Code Quality

build-skit-release.sh — Package.swift mutation via Python string matching

old = '    .library(\n      name: "SyntaxKit",\n      targets: ["SyntaxKit"]\n    ),'
new = '    .library(\n      name: "SyntaxKit",\n      type: .dynamic,\n      targets: ["SyntaxKit"]\n    ),'
if old not in src:
    sys.exit("Package.swift: expected SyntaxKit library product block not found")

This is fragile — any formatting change to Package.swift silently breaks the build script. A sed-based replacement or a dedicated Package.swift variant (e.g., Package.release.swift) would be more maintainable. The trap cleanup EXIT + backup approach is good, but the detection would miss reformatted declarations.

libStamp uses mtime + size as dylib identity

return "\(size)/\(Int(mtime))"

This can produce false positives (same size/mtime across rebuilds on fast machines) or false negatives (genuinely different builds with the same mtime due to timestamp truncation). A SHA-256 of the dylib contents would be definitive. This is acknowledged in comments but worth calling out since stale cache hits would silently produce wrong output.

helpersExcludePath only excludes a Helpers/ directly under inputDir

let candidate = inputDir.appendingPathComponent("Helpers").standardizedFileURL

If a project has inputs/sub/Helpers/ it will be enumerated as input. The docs state helpers are searched "up-tree" so this is intentional, but the exclusion logic only covers one level. A user who puts helpers inside the input tree at a non-root level will get confusing compile errors.


Minor Nits

  • runDirectory returns Int32 (exit code) rather than throwing — inconsistent with runSingleFile which throws. A consistent error-handling strategy would make the caller in Run.run() cleaner.
  • "skit: \(outcomes.count - failed)/\(outcomes.count) succeeded\n" is always printed to stderr even on a fully-successful single-file run… actually this line is only reached from runDirectory, so it's fine. But note the trailing \n is redundant inside Data(…utf8) — the final newline in the string already provides it.
  • Example errors_async/dsl.swift now has a multi-line comment block explaining the TupleAssignment workaround. Per project conventions, comments should be brief (one line max explaining the WHY). Consider trimming to: // TupleAssignment is internal; emit equivalent single bindings.

Summary

The design is solid and the implementation handles the hard parts well (pipe-drain deadlock, timeout, atomic cache writes, toolchain stamp). The main gaps before merging:

  1. Fix stale skitrun VS Code launch targets
  2. Add unit tests for wrap(), ContentHasher, and cache-key functions
  3. Memoize captureSwiftVersion() to avoid redundant subprocess spawns
  4. Harden build-skit-release.sh dylib-stamp logic or at minimum document the mtime fragility

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: PR #156 — skit: SyntaxKit Codegen CLI for v0.0.5

Summary

This PR unifies the previous skit stub (stdin→JSON parser, ~50 lines) and a prototype skitrun renderer into a single skit binary with two ArgumentParser subcommands:

  • skit run <input.swift> — wraps a SyntaxKit DSL file in Group { … }, spawns the Swift script-mode interpreter, captures the rendered output. Supports folder mode, output caching, helpers compilation pipeline, toolchain-version stamping, and a timeout watchdog.
  • skit parse (stdin) — the original JSON syntax-tree emitter.

Net new Swift: ~1,000 lines across Runner.swift, Helpers.swift, OutputCache.swift, ContentHasher.swift, plus the expanded Skit.swift. The build script produces a self-contained portable bundle. Ten of eleven Examples/Completed/*/dsl.swift files are migrated.


Code Quality and Best Practices ✅

Strengths:

  • Runner.swift is well-structured with clear MARK sections and tight function boundaries. The pipeline separation (resolve → wrap → spawn → drain → cache) reads cleanly.
  • ContentHasher correctly uses FNV-1a (deterministic across processes, unlike stdlib Hasher) and the tradeoff vs SHA-256 is explicitly justified.
  • PipeDataBox with @unchecked Sendable is scoped narrowly to a single class with clear ownership semantics — a pragmatic Swift 6 concurrency solution.
  • Atomic cache writes via tmp.<pid>.<uuid> staging + rename are correct and consistent across both buildHelpers and storeCachedOutput.
  • compileHelpers drain-before-wait ordering correctly avoids the Linux 64 KB pipe-buffer deadlock.
  • ArgumentParser integration is clean; validate() is used appropriately for --timeout.

Issues:

  • captureSwiftVersion() called 3× per invocation. It's called in toolchainCheck, helpersCacheKey, and outputCacheKey — three subprocess spawns before the user's input is touched. Cache the result at startup and thread it through as a parameter.

  • libStamp uses mtime+size instead of content hash. This produces false-positive cache hits when files are byte-for-byte identical but have different timestamps (e.g. after git checkout). Worth a comment acknowledging the intentional tradeoff vs the helper-source keying (which hashes content).

  • import SyntaxKit unconditionally prepended in wrap() even when the input already contains it. Swift tolerates duplicate imports but this is untidy. A one-line filter on hoisted to exclude an existing import SyntaxKit before prepending would be cleaner.

  • runSingleFile writes output non-atomically. result.stdout.write(to: URL(fileURLWithPath: outputPath)) writes directly to the destination — inconsistent with the staged cache writes. A mid-write kill leaves a partially-written file. Use the same staging + rename pattern.

  • discoverHelpersDir has no depth limit or filesystem boundary. A Helpers/ directory in a parent folder high in the tree (e.g., ~/Helpers/) can be silently picked up. A sentinel file (.skitroot) or a SKIT_HELPERS_BOUNDARY env var would address this.

  • HelpersOptions and CLIError ownership is unclear. Both are defined in Runner.swift while CompiledHelpers lives in Helpers.swift. A small Types.swift or Shared.swift would clarify the module structure.


Potential Bugs 🐛

Medium severity:

  • Import-hoisting breaks on leading comments. wrap() stops hoisting at the first non-ImportDeclSyntax node. A top-level comment before the first import (// Generated by skit\nimport SyntaxKit\n...) will cause all subsequent imports to remain inside Group { … }, producing a compile error. The loop needs to skip leading comment trivia.

  • Folder mode partial-failure is silent to callers. When some files fail in batch mode, the exit code is 1 but there's no indication of which files produced stale outputs. Callers checking only the exit code can't detect stale output files.

  • exit(result.exitCode) in runSingleFile bypasses ArgumentParser's normal termination. Prefer throw ExitCode(result.exitCode) for consistency and to allow deferred cleanup.

Low severity:

  • captureSwiftVersion() doesn't drain stderr. If swift --version emits to stderr (unusual but possible in non-standard toolchain setups), the pipe could fill and block the process. Redirect stderr to /dev/null.

  • Stale skitrun entries in .vscode/launch.json. If skitrun is no longer a Package.swift target after this unification, these VS Code launch configurations will produce extension errors for every developer opening the workspace. They should be removed.


Performance Considerations ⚡

  • swift --version spawns per invocation (see above). Cache the result.

  • Cache key computation in folder mode calls captureSwiftVersion() and libStamp() per file — N files means 2N+ subprocesses just for cache key computation. Hoist both to a single call in the caller and pass as parameters.

  • ContentHasher iterates bytes in pure Swift. FNV-1a over large helper sources in pure Swift is slower than CryptoKit.SHA256. Acceptable for v1 since helpers are hashed once per unique content, but worth noting.


Security Considerations 🔒

  • Arbitrary code execution is the designskit run compiles and runs user-provided Swift code. The "threat model: you ran your own code" framing is correct for a developer CLI.

  • FNV-1a is not collision-resistant against adversarial inputs. For the current single-user local-cache scope this is fine, but worth a one-line comment in ContentHasher.swift documenting the threat-model assumption.

  • SKIT_LIB_DIR env var controls dylib path — an attacker who controls the environment can redirect the dylib load. This is standard for CLI tools that load dylibs via env paths and is acceptable for a developer tool, but worth a sentence in the security documentation.

  • #sourceLocation path injection. wrap() correctly escapes backslashes and double-quotes in escapedPath. However, a newline in originalPath (unusual but possible) would break the directive. A newline strip or rejection would be defensive.

  • build-skit-release.sh mutates Package.swift in-place with trap cleanup EXIT. A SIGKILL or power loss mid-build leaves the modified Package.swift on disk. Consider using git stash or a named backup (Package.swift.bak) for more obvious recovery.


Test Coverage ⚠️

This is the weakest area of the PR.

  • Zero new unit tests for core pipeline. wrap(), discoverHelpersDir(), collectHelperSources(), toolchainCheck(), outputCacheKey(), and ContentHasher have no automated test coverage. These are straightforward to unit-test with the Swift Testing framework already used in this repo.
  • ContentHasher is trivially testable: a few known-input/known-output cases verify the FNV-1a constants and hex encoding.
  • wrap() is the most important function to cover: needs tests for no imports, only imports, comment-before-import, import SyntaxKit already present, and mixed cases.
  • The 10-example smoke test is manual. Consider adding at least one CI step that builds the bundle and runs a minimal DSL file end-to-end.

Actionable Suggestions

P1 — Fix before merge:

  1. Remove stale skitrun entries from .vscode/launch.json if skitrun is no longer a package target.
  2. Cache swift --version at startup and thread the result through helpersCacheKey, outputCacheKey, and toolchainCheck.
  3. Fix import-hoisting for inputs with leading comments in wrap() — test with a file that has a comment before import SyntaxKit.
  4. Use atomic (staged) writes for single-file output — replace result.stdout.write(to:) with the staging + rename pattern used in the cache.

P2 — Should do, not blocking:

  1. Add unit tests for wrap(), ContentHasher, and discoverHelpersDir() using Swift Testing. Even 3–5 test cases per function dramatically improves confidence.
  2. Add a comment in libStamp acknowledging the intentional mtime+size vs content-hash tradeoff.
  3. Add a depth limit or sentinel to discoverHelpersDir() to prevent accidentally picking up unrelated Helpers/ directories.
  4. Prefer throw ExitCode(...) over exit(...) in runSingleFile for ArgumentParser consistency.

P3 — Nice to have:

  1. Add a CI step that runs at least one example through the release bundle end-to-end.
  2. Consolidate CLIError and HelpersOptions into a Types.swift or Shared.swift.
  3. Consider whether the Linux cache root should use the binary name skit rather than syntaxkit for XDG clarity.

Overall: The architecture is well-considered, the documentation is unusually thorough, and the core pipeline is correct. The two main concerns are the absence of unit tests for non-trivial pipeline logic (especially wrap()) and the repeated swift --version subprocess spawns. Neither is a correctness blocker for v0.0.5, but both should be addressed before the next release.

🤖 Generated with Claude Code

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: PR #156skit: SyntaxKit codegen CLI for v0.0.5

Overview

This PR lands skit, a unified CLI that replaces two prior proof-of-concept tools (skit stdin parser + skitrun renderer) into one ArgumentParser-based binary. The implementation is a wrap-and-spawn pipeline with two cache layers and a toolchain compatibility guard.


✅ What's working well

Wrap-and-spawn pipeline (Runner.swift:wrap())
The #sourceLocation remap approach is the right call for keeping diagnostics coherent. Import hoisting via SwiftSyntax's ImportDeclSyntax is more robust than regex/string-split approaches. Path escaping for #sourceLocation (backslash + quote only) is appropriate for macOS/Linux.

Atomic helpers cache (Helpers.swift:buildHelpers())
The staging-directory → atomic moveItem pattern mirrors what SwiftPM and Tuist do for the same reason: safe under concurrent skit processes. The "if rename fails and destination already exists, keep theirs" logic is correct.

Toolchain stamp + detect
Exact-string comparison after whitespace trim is correct given that even patch-level Swift versions can produce incompatible swiftmodules. The error message includes the rebuild command, which is excellent UX.

Timeout watchdog
Using DispatchSemaphore.wait(timeout:) to avoid the Linux Foundation.Process.waitUntilExit() hang is the right workaround. The SIGTERM → 5s grace → SIGKILL escalation is standard.

Concurrent folder mode
The task-group pool pattern (maxConcurrent = activeProcessorCount, dynamic task refilling as completions come in) is correct Swift concurrency. Writing all successes even when some inputs fail matches the documented "Tuist-analog batch semantics".

Linux portability
#if os(Linux) guards for install_name, dylib vs .so, and the CryptoKit → swift-crypto substitution are all properly conditioned.

Documentation
Docs/skit.md is excellent — it covers the design, caches, sharp edges, and deferred items with enough depth to be a real decision log. The Tuist research doc (Docs/research/tuist-manifest-pipeline.md) is a thorough reference.


⚠️ Issues worth discussing

Unbounded upward traversal in discoverHelpersDir

while true {
    let candidate = dir.appendingPathComponent("Helpers")
    ...
    let parent = dir.deletingLastPathComponent()
    if parent.path == dir.path { return nil }
    dir = parent
}

This walks to the filesystem root. An input inside /Users/alice/work/proj-a/ could accidentally pick up /Users/alice/Helpers/ or /Users/Helpers/. Consider stopping at a project boundary heuristic (e.g., a Package.swift or .git directory in an ancestor) to prevent silent cross-project contamination.

stale skitrun launch targets in .vscode/launch.json
The PR adds Debug skitrun and Release skitrun launch configurations, but skitrun is the POC binary being replaced by this PR. These entries reference a target that presumably no longer exists in Package.swift. They'll produce confusing errors in VS Code.

maxConcurrent = activeProcessorCount for Swift spawns
Each swift invocation is a heavy JIT process. On a 32-core machine this spawns 32 Swift interpreters simultaneously, which will saturate RAM well before CPU. A conservative cap (e.g., min(activeProcessorCount, 8)) or a user-configurable --jobs flag would be more robust for large batches.

No unit/integration tests for the CLI
The test plan is entirely manual (Scripts/build-skit-release.sh + smoke invocations). The Runner.swift internals — wrap(), toolchainCheck(), cache key computation — are all pure/deterministic functions that are straightforward to unit-test without spawning swift. At minimum, wrap() should have tests covering import hoisting, #sourceLocation line numbers, and files with no imports. This is a gap that will make future refactors risky.

OutputCache.swift / processFile race on output writing
try result.stdout.write(to: URL(fileURLWithPath: outputPath)) in runSingleFile is not atomic. If two skit invocations target the same output file simultaneously, the result is partial/corrupt output. Consider writing to a temp file and renaming, consistent with the helpers cache strategy.

stderr from helpers compilation
In compileHelpers, the process's standardOutput is set to FileHandle.nullDevice, but standardError is set to a Pipe (visible from the partial diff). Confirm that the captured stderr is surfaced to the user on compile failure, not silently dropped. From the code visible, it appears the pipe is read and re-emitted — just flagging for explicit verification.


🔍 Minor observations

  • Skit.swift validate() checks timeoutSeconds >= 0 — correct, since 0 is documented as "disable the watchdog."
  • output cache key includes SKIT_*/SYNTAXKIT_* env vars but not PATH. A different swift on PATH could produce different output for the same input. Whether this is a real concern depends on whether users might have multiple toolchains installed — the toolchain stamp mitigates this, but once the stamp check passes, PATH differences could still cause stale cache hits.
  • The enum_generator move from Examples/Completed/ to Examples/Demos/ is correct and well-motivated.
  • Package.swift exclude: ["README.md"] for the skit target is the right fix to prevent SPM from complaining about the per-target readme.

🧪 Test coverage

The existing integration test migrations (PlusAssignInfix, Parameter(isUnnamed:)Parameter(unlabeled:), ParameterExp(name:value:String)ParameterExp(name:value:VariableExp)) are all correct. The library test suite passing is necessary but not sufficient coverage for the new CLI surface area.


Verdict

The architecture is sound and the implementation is production-quality in its core pipeline. Three items should be addressed before merge: (1) skitrun VS Code launch targets are stale, (2) discoverHelpersDir should bound upward traversal at a project boundary, and (3) the absence of unit tests for wrap() / toolchainCheck() is a maintenance risk worth tracking (even if not blocking).

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #156: skit CLI for v0.0.5

Overview

This is a substantial new feature: a unified skit CLI that replaces the previous stdin-parser-only binary with a two-verb tool (run + parse). The architecture is well thought-out, the doc is thorough, and the sharp edges are honestly documented. Notes below focus on actionable concerns.


Strengths

  • Clean separation of concernsRunner.swift (pipeline), Skit.swift (ArgumentParser surface), Helpers.swift (dylib compile + cache), OutputCache.swift, ContentHasher.swift. Each file has one job.
  • FNV-1a with proper rationaleContentHasher correctly explains why a non-cryptographic, deterministic hash is used (stdlib Hasher is process-seeded and can't be used as a disk key). The comment is useful.
  • Atomic cache writes — staging dir + rename(2) (via FileManager.moveItem) handles concurrent skit processes correctly. This is exactly the right pattern.
  • DispatchSemaphore-based wait + concurrent pipe drain — documents and solves the real Linux Foundation.Process.waitUntilExit() hang and 64KB pipe-deadlock problem. This is not obvious and the inline explanation earns its place.
  • #sourceLocation remapping — surfacing diagnostics pointing at the original input path rather than the temp wrapper is a user-experience win that takes real care to get right.
  • Toolchain stamp — catching swiftmodule version mismatches with a clear error + rebuild command is far better than letting users hit the cryptic compiler diagnostic. Good foundation for Hybrid bundle: rebuild SyntaxKit from sources on toolchain mismatch #157.
  • Examples migration is thorough — all deprecated APIs (Parameter(isUnnamed:), Infix(_:_:) builder closure, PlusAssign) updated to new signatures.

Concerns

1. .vscode/launch.json adds stale skitrun targets

{ "name": "Debug skitrun",   "target": "skitrun" },
{ "name": "Release skitrun", "target": "skitrun" }

The PR description says skitrun is replaced by the unified skit. If the skitrun product/target is being removed, these VS Code launch configs reference a target that no longer exists and will fail to build. Either remove them (if skitrun is gone) or update them to skit.

2. No automated tests for skit itself

The test plan is manual smoke tests. Runner.swift, the cache layer, the toolchain-check logic, and helpers discovery are all untested by the Swift Testing suite. Given the complexity (process spawning, caching, timeout watchdog), at least the deterministic parts deserve unit coverage:

  • ContentHasher hashing stability
  • outputCacheKey determinism for the same inputs
  • toolchainCheck returns the right enum arm for match/mismatch/missing
  • discoverHelpersDir walk-up logic (can be tested with a temp directory)

These don't require spawning swift and would catch regressions in cache invalidation logic.

3. Minor: ContentHasher collision probability comment is slightly off

~10⁻⁹ collision probability at 10⁶ cache entries

FNV-64's birthday-attack collision probability at N entries is approximately N²/2⁶⁵. At N = 10⁶: (10⁶)²/2⁶⁵ ≈ 2.7×10⁻⁸, not 10⁻⁹. Still negligibly small for this use case, but the comment is off by an order of magnitude.

4. isLibDir() only validates dylib presence

private func isLibDir(_ path: String) -> Bool {
  ...
  return fm.fileExists(atPath: "\(path)/\(dylibFilename(forLibrary: "SyntaxKit"))")
}

A directory that contains only the dylib (no .swiftmodule) would pass this check but fail at spawn time with a cryptic module-not-found error. Consider also checking for a SyntaxKit.swiftmodule (or the Modules/ directory) so the path validation catches partially-formed bundles early.

5. SKIT_LIB_DIR env var leaks into output cache key via env-var scan

outputCacheKey scans SKIT_* env vars:

let env = ProcessInfo.processInfo.environment
  .filter { $0.key.hasPrefix("SKIT_") || $0.key.hasPrefix("SYNTAXKIT_") }

SKIT_LIB_DIR matches this filter. Changing the lib path (e.g., between a debug and release bundle) will bust the output cache, which is arguably correct behavior — but it means a user who sets SKIT_LIB_DIR to an absolute path will never get a cache hit on a different machine, even with identical content. Worth documenting or filtering SKIT_LIB_DIR out of the key explicitly.


Minor notes

  • Run.validate() checks timeoutSeconds >= 0 — correct, 0 is the "disable watchdog" sentinel.
  • The resolveHelpersrunSingleFile / runDirectory pattern resolves helpers once and passes CompiledHelpers down — good, avoids redundant compilations in folder mode.
  • helpersModuleName = "SyntaxKitHelpers" hard-coded — acceptable for v1, but worth an issue tracking user-configurable names for multi-project setups.
  • Docs are unusually good for a v1 feature: the Docs/skit.md design doc + Sources/skit/README.md quick-ref split is a nice pattern.

Summary

The core pipeline design is solid, the Linux compatibility work is careful, and the user-facing experience (toolchain stamp, #sourceLocation, clear cache flags) is thoughtful. Main asks before merging: (1) resolve the stale skitrun VS Code configs, and (2) add even minimal unit tests for the deterministic parts (hash stability, toolchain-check enum, directory discovery). The isLibDir validation gap and the SKIT_LIB_DIR cache-key nuance are worth fixing or at least documenting in skit.md.

leogdion and others added 9 commits May 13, 2026 08:48
Phase 1 documents how Tuist evaluates user manifests via xcrun swift +
token-delimited JSON over stdout, with citations into tuist/tuist and
swiftlang/swift-package-manager. Phase 2 sketches the SyntaxKit
equivalent: pure-DSL input files wrapped into a Group { … } closure
before spawning swift, with a 7-step POC ladder that retires cold-start
cost first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran the hand-driven wrap+spawn flow against a temporarily-dynamic
SyntaxKit dylib. Pure-DSL input spliced into a Group {…} wrapper
compiles cleanly under xcrun swift with -I/-L/-l + an -Xcc include for
SwiftSyntax's C shims. Cold start 0.72s, warm 0.11s. Dylib weight 25MB
debug. Hoisted imports work; `if`-in-Group hits a type-checker crash
that's a separate SyntaxKit bug to file.

Design doc updated with the -Xcc requirement, the rpath flag, the
bundled-binary layout's new C-shims include directory, and the POC
findings in §7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-command reproduction: flips Package.swift to a dynamic SyntaxKit
library, builds, stages dylib + swiftmodules + _SwiftSyntaxCShims
headers into /tmp/syntaxkit-poc/, writes a pure-DSL input + hand-rolled
wrapper, and runs one cold + three warm timings. Restores Package.swift
on exit via trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Measured SyntaxKit dylib under -c release with strip -x: 25 MB debug →
18 MB release → 9.3 MB stripped. Warm execution timings identical to
debug. Distribution sizing is comfortable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New executable target `skitrun` (POC name) wraps the hand-driven step-1
flow in real code: parse input with SwiftSyntax, hoist top-level imports,
emit a Group { … } wrapper fenced in #sourceLocation directives, spawn
`swift` via Foundation.Process, pipe stdout through, forward stderr with
literal-path fix-up.

#sourceLocation does the heavy lifting for diagnostic fidelity — a
NonexistentType in InputError.swift:4 now reports as
InputError.swift:4:37 from the spawned swift, with no manual stderr
arithmetic. Verified default-stdout, -o file output, hoisted Foundation
import, and error path rewriting all work end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
skitrun InputDir/ -o OutDir/ walks **/*.swift (skipping `_`-prefixed
files), runs the per-file wrap+spawn over withTaskGroup capped at
activeProcessorCount, and writes successes into mirrored paths under
OutDir/. Failures don't abort the batch: successes are still written
and the CLI exits non-zero with a `skitrun: N/M succeeded` summary.

Verified happy path (3 files, 1.41s wall vs 0.72s cold baseline),
skip rule (deliberately-invalid `_HelperShouldBeSkipped.swift` is not
visited), partial failure (3/4 succeeded with exit 1), and single-file
regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
skitrun now resolves its lib/ directory automatically: --lib flag, then
$SKITRUN_LIB_DIR, then <binary-dir>/lib/, then <binary-dir>/../lib/skitrun/
(Homebrew layout). No more /tmp/syntaxkit-poc/lib fallback. Clear
diagnostic when no lib is found, naming all four search paths.

poc-step4-release.sh builds a portable bundle under .build/skitrun-release/:
release-config + stripped dylib (9.3 MB), modules, C-shims headers, and
the binary itself (17 MB — SwiftSyntax statically linked, follow-up to
deduplicate). Tested copying the bundle to unrelated directories, both
same-dir and Homebrew layouts work zero-config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
skitrun now walks up from the input looking for a Helpers/ directory.
On hit, helpers compile via swiftc into libSyntaxKitHelpers.dylib under
~/Library/Caches/com.brightdigit.SyntaxKit/helpers/<sha256>/, keyed on
helper-source hashes + swift --version + libSyntaxKit.dylib stamp +
cache schema version. Compile lands in a tmp.<pid>.<uuid>/ staging dir
then atomic-renames into the cache path so concurrent invocations are
safe. Inputs then `import SyntaxKitHelpers` and call into the compiled
module; runSwift splices -I/-L/-lSyntaxKitHelpers -Xlinker -rpath onto
the spawn.

Two new flags: --helpers <dir> overrides discovery, --no-helpers skips
it entirely. Folder mode's enumerator now also yields directories so it
can skipDescendants() on a Helpers/ directly under the input root —
without it the helpers would be reprocessed as inputs.

Verified end-to-end via Docs/research/poc-step5.sh: cold compile 2.96s,
warm cache hit 0.54s (matches step-1 warm baseline), folder mode 2/2
with Helpers/ excluded, --no-helpers errors with `no such module
'SyntaxKitHelpers'` as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
skitrun now hashes (input bytes + helpers cache key + swift --version +
libSyntaxKit stamp + sorted SKITRUN_*/SYNTAXKIT_* env vars) into a
SHA-256 key under ~/Library/Caches/com.brightdigit.SyntaxKit/outputs/.
On hit, processFile short-circuits — no temp wrapper, no swift spawn,
just return the cached bytes. On miss the normal compile path runs and
stores the rendered output. Only exit-0 runs are cached so failures
always re-spawn with fresh diagnostics.

Atomic write through a tmp.<pid>.<uuid>/ staging dir + rename mirrors
the helpers cache; concurrent writers race safely, the loser drops
their copy when the destination already exists.

--no-cache disables the lookup wholesale (debugging, after manual
cache deletion). Threads through runSingleFile + runDirectory so
folder mode can opt out batch-wide.

Helpers.swift's syntaxKitCacheRoot/captureSwiftVersion/libStamp are
now internal so OutputCache.swift can reuse them without duplication.

Verified via Docs/research/poc-step6.sh: cold 0.55s → warm 0.14s
(4× faster, no swift spawn), --no-cache 0.27s, post-mutation miss
0.41s then 0.14s on the new key, two cache entries persist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review: skit — Unified SyntaxKit Codegen CLI

This is a well-structured PR with clear separation of concerns, solid docs, and explicit acknowledgment of known limitations. The architecture is sound. Below are findings from most to least severe.


🔴 Critical

group.next()! force-unwrap invariant is fragile
Sources/SyntaxKit/Execution/Task+Timeout.swift (~line 29):

let first = try await group.next()!

This is only safe because exactly two tasks are always added before next() is called. A future refactor dropping one addTask call causes a silent fatal crash with no compiler warning. The same pattern appears in SkitSubprocessTimeoutTests.swift. Replace with:

guard let first = try await group.next() else {
    fatalError("invariant: two tasks always added to the timeout group")
}

🔴 High — Security

libPath from $SKIT_LIB_DIR env var is passed to swift without validation
Sources/skit/Subprocess.Configuration+Swift.swift — the -I, -L, -rpath arguments use libPath directly. An attacker controlling $SKIT_LIB_DIR can inject a malicious libSyntaxKit.dylib into the spawned swift process. The docs mention "Sandboxing — out of scope," but there's no validation that libPath is an absolute path or within expected bounds. At minimum: assert libPath is absolute, and log a warning to stderr when $SKIT_LIB_DIR overrides the bundle-relative path.

Missing -- separator before wrappedPath
Sources/skit/Subprocess.Configuration+Swift.swift (~line 79):

wrappedPath,   // last argument, no -- separator

If wrappedPath begins with - (unusual but possible on non-standard $TMPDIR setups), swift interprets it as a flag. Add "--" before wrappedPath.

#sourceLocation path interpolation doesn't escape newlines
Sources/SyntaxKit/Execution/WrappedSource.swift (rendered property):

let escapedPath = originalPath
    .replacingOccurrences(of: "\\", with: "\\\\")
    .replacingOccurrences(of: "\"", with: "\\\"")
    // ⚠️ missing: \n, \r

A path containing \n breaks the #sourceLocation directive and injects arbitrary Swift into the wrapper. Newline-in-path is technically valid on both Linux and macOS. Add .replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\r", with: "\\r").


🟡 Medium

WrappedSource silently drops post-body imports
The import hoisting loop breaks on the first non-import statement, leaving any subsequent import in the body. Inside Group { … } an import is not a valid result-builder expression, so this produces a confusing compile error in the spawned swift (even though #sourceLocation maps it back). Either detect-and-reject early with a clear error message, or document this prominently in the error path.

collectInputs throws on any single unreadable file, aborting the whole batch
Sources/SyntaxKit/Execution/Runner+Directory.swift — a broken symlink or permission error mid-enumeration kills the entire directory render. This is inconsistently strict vs. per-file render behavior. Consider skip-with-warning, or document as deliberate.

Path relativization uses byte-counting instead of URL API
Runner+Directory.swift:

let relative = result.input.path.dropFirst(inputBase.path.count + 1)

This breaks if inputBase.path has a trailing slash. Use URL(fileURLWithPath:relativeTo:) or add precondition(result.input.path.hasPrefix(inputBase.path)).

Silent cache-write failures give no diagnostics
Runner.swift (~line 93): try? cache.store(...) silently discards cache write errors. If the cache directory has wrong permissions or the disk is full, every invocation pays the full spawn cost indefinitely with no hint why. Add at least SKIT_DEBUG-gated stderr logging.

build-skit-release.sh exits on Linux, but Linux support is claimed
The PR description says "Linux verified on swift:6.0-jammy/aarch64," but there is no Linux release-bundle script — only a macOS-only script with a comment saying "Linux uses a parallel flow." Either ship the Linux script or revise the Linux verification claim to be debug/CI only.

Bundle.main.executableURL is unreliable on Linux when invoked via wrapper script
Bundle+ResolveLibPath.swift — on Linux this reads /proc/self/exe, which may return nil or wrong path with certain invocation patterns. Promote $SKIT_LIB_DIR more prominently in Linux docs as the reliable fallback.

install_name_tool failure is silently swallowed on macOS too
Scripts/build-skit-release.sh (~line 79): || true hides failures on macOS where install_name_tool should succeed. A failure leaves the dylib with the wrong install name, causing cryptic runtime load failures. Only suppress errors on Linux (where the tool is expected to be absent).

Docs describe DispatchSemaphore timeout; code uses Task+Timeout.swift
Docs/skit.md, "Timeout watchdog" section describes a GCD DispatchSemaphore.wait(timeout:) approach. The actual implementation uses withThrowingTaskGroup racing against Task.sleep. The doc describes an older design and needs to be reconciled.

renderFile has an unreachable catch RunError arm
Runner.swiftprocessFile never throws RunError directly (only Foundation/Subprocess errors). The catch let error as RunError arm is dead code. Either type processFile as throws(RunError), or add a comment explaining the defensive intent.


🔵 Low

  • Skit+Parse.swift blocks forever on TTY stdinreadDataToEndOfFile() hangs if skit parse is run interactively without piped input. Add a note to help text or detect TTY.
  • fileManager() closure called 4× in store() — bind let fm = fileManager() once per method.
  • renderBatch catch arm discards the error — print to stderr before re-throwing ExitCode(1).
  • FNV-1a hash has no NUL-termination between schema version and source bytes"A" + "BC" vs "AB" + "C" collision risk; NUL-terminate consistently after each field.
  • processFile is internal only because of the cross-file extension — document the intentional visibility or consider restructuring to allow private.
  • Docs/skit-internals.md references callAsFunction(input:output:) — the actual interface is renderFile/renderDirectory. Stale doc from an earlier design.
  • Signal exit codes above 127 produce negative Int32 in TerminationStatus+ExitCode.swift on platforms with real-time signals. Worth a comment.
  • TerminationStatus+ExitCode.swift Windows exhaustiveness — add a comment confirming Subprocess.TerminationStatus on Windows has no .signaled case, so the #if !os(Windows) is exhaustive.
  • mtime-based cache stamp has 1-second resolution — document that in-second rebuilds require --no-cache.
  • group.next()! also appears in SkitSubprocessTimeoutTests.swift — fix both.

Priority List for Merge Readiness

# Item File
1 group.next()! — add assertion + comment Task+Timeout.swift
2 libPath env-var — add absolute-path validation + warning Subprocess.Configuration+Swift.swift
3 -- before wrappedPath in swift args Subprocess.Configuration+Swift.swift
4 #sourceLocation — escape \n/\r in path WrappedSource.swift
5 Reconcile timeout docs with Task+Timeout.swift Docs/skit.md
6 Linux release script or remove Linux claim Scripts/, README.md

Items 1–4 are correctness/security fixes; 5–6 are doc/claim alignment. Everything else is polish or hardening that can land as follow-up.

🤖 Generated with Claude Code

- Relocate writeOutput to the FileManager extension beside collectInputs,
  since the directory-create + write are filesystem operations.
- Replace the success/failure switch with a functional fold:
  Result.flatMap maps the render outcome into the write outcome, and the
  write itself is captured via Result { ... }.mapError(.unexpected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review

This is a substantial PR — ~3400 lines, 67 files — that lands a well-thought-out CLI on top of a previously pure code-generation library. The architecture is clean and the design doc is unusually thorough. Notes below focus on real issues rather than nits.


Overview

The wrap-and-spawn pipeline is a sensible approach for script-mode DSL execution. The dependency injection of the run closure into Runner is good for testability. The package-visibility separation between the SDK engine and the CLI layer is clean.


Issues Worth Addressing Before Merge

1. WrappedSource.rendered — partial path escaping

let escapedPath =
  originalPath
  .replacingOccurrences(of: "\\", with: "\\\\")
  .replacingOccurrences(of: "\"", with: "\\\"")

This handles \ and " but not newlines or other control characters. On Linux, file paths can technically contain newlines — a path like /tmp/foo\nbar.swift would produce syntactically broken Swift in the #sourceLocation directive. Recommend using a proper string literal escaping utility or asserting/stripping control characters from the path before injection.

2. Task+Timeout.swift — force unwrap

let first = try await group.next()!

This is always safe in the current code (two tasks are always added before this call), but force-unwrapping an Optional from TaskGroup.next() is fragile — any future refactor that changes how tasks are added silently introduces a crash. A guard let with a preconditionFailure would be both safe and self-documenting.

3. No unit tests for the core engine

The only new test in this PR is the integration regression test for the subprocess timeout (SkitSubprocessTimeoutTests). The core components — ContentHasher, OutputCache, WrappedSource — have no unit tests. Of these, WrappedSource is most important: the import hoisting logic and #sourceLocation line-number calculation are subtle and exactly the kind of thing that breaks quietly in edge cases (inputs with only imports, inputs starting with comments, empty inputs). Even a small table-driven test over those cases would catch regressions.

4. Execution/ code living in the SyntaxKit library target

Sources/SyntaxKit/Execution/ adds process-spawning logic (Runner, OutputCache, SwiftInvocation, etc.) into what was previously a pure code-generation library. This is an architectural change worth noting explicitly: embedders who import SyntaxKit for DSL generation now pull in Foundation-heavy execution machinery they may not want. If this is intentional for v0.0.5, consider whether a separate SyntaxKitExecution (or similar) target would better serve the library/CLI split.


Suggestions (Non-blocking)

5. Comment density in Runner.swift and Skit+Run.swift

CLAUDE.md calls for no comments unless the why is non-obvious. Much of the inline commentary in processFile and Skit.Run.run() describes what the code does rather than why. Example:

// Load the input source. Anything past this point keys off these bytes.
let inputURL = URL(fileURLWithPath: inputPath).standardizedFileURL

The code already reads clearly without the comment. A pass to remove explanatory prose would tighten these files.

6. build-skit-debug.sh — brittle Python find-and-replace

The script patches Package.swift by searching for an exact multi-line string:

old_block = '    .library(\n      name: "SyntaxKit",\n      targets: ["SyntaxKit"]\n    ),'

If SwiftFormat or a routine edit changes the indentation, the script will silently no-op (it prints "already has type: .dynamic") or exit with an error. Consider using swift package edit or a small sed invocation that's less sensitive to whitespace.

7. Swift 6.0 removed from CI matrix

The PR removes Swift 6.0 from the Ubuntu matrix and bumps swift-tools-version to 6.1. This is a semver-relevant minimum version bump for anyone importing SyntaxKit from source. Worth mentioning in the PR body (it is, buried in the CI diff) and calling out in CHANGELOG / release notes if the project maintains one.

8. OutputCache.store — staging dir cleanup on error

try? fileManager().removeItem(at: staging)
if !fileManager().fileExists(atPath: final.path) {
    throw error
}

The try? swallows a cleanup failure. If removeItem fails and the destination is still missing, you rethrow the rename error but leave an orphaned staging dir. This is a minor leak in a cache directory so low-severity, but worth noting.


What's Working Well

  • The toolchain stamp + mismatch error (with rebuild command) is an excellent UX touch that prevents the confusing swiftmodule version errors from reaching users.
  • The timeout watchdog using withThrowingTaskGroup (rather than the DispatchSemaphore mentioned in the PR body) is the right concurrency-safe approach; Task+Timeout.swift is clean.
  • Atomic cache writes via stage+rename are correct for concurrent directory-mode invocations.
  • #if canImport(Subprocess) guards degrade gracefully; the unsupported-platform message is clear.
  • The regression test for the grandchild-holds-pipe-fd scenario (SkitSubprocessTimeoutTests) is specific and well-motivated.

🤖 Generated with Claude Code

…uire

- Remove .swift-version pin (unblocks swiftly toolchain resolution)
- Move CLI presentation helpers into Skit.Run+Render.swift to satisfy
  the 300-line file_length cap; add guarded `import Subprocess` back
- Drop assign-only `destination` from DirectoryRender.FileOutcome
- Use `try #require(...)` form in SkitSubprocessTimeoutTests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review: skit CLI for v0.0.5

Overview

This PR replaces the original single-purpose skit (stdin→JSON) with a full-featured CLI built on ArgumentParser. The wrap-and-spawn pipeline, output cache, toolchain stamp check, and timeout watchdog are all well-designed. The SDK/CLI split (SyntaxKit target holds the engine; skit target holds the ArgumentParser surface + Subprocess backend) is the right call for future build-plugin reuse.


Architecture & Code Quality

Strengths

  • Clean dependency injection: Runner accepts a run: @Sendable (SwiftInvocation) async throws -> ProcessResult closure, making it fully testable without Subprocess. The seam is principled.
  • Atomic cache writes: the staging-dir + moveItem pattern in OutputCache.store is correct for concurrent writers. The try? swallow on write failure with a fileExists guard is the right policy.
  • FNV-1a rationale: the choice of deterministic non-crypto hash over stdlib.Hasher (whose seed is randomized per-process) is well-explained and correct for on-disk keys.
  • NUL-terminated env keys: "AB=" + "C" vs "A=" + "BC" collision prevention is subtle and right.
  • Bounded concurrency: activeProcessorCount as the per-batch cap prevents spawning 200 simultaneous swift processes. The seed+refill pattern is standard and avoids the thundering herd.
  • Toolchain stamp check: refusing to spawn on a module-version mismatch with a clear error + rebuild command is a significant UX improvement over the cryptic diagnostic users would otherwise see.
  • Exit code semantics: exit 124 matching POSIX timeout(1) is the right convention.

Issues Found

1. No unit tests for Runner, OutputCache, or WrappedSource

The engine is designed for testability — Runner takes an injected run closure, OutputCache accepts an injected FileManager factory — but the only new test file exercises the Subprocess timeout regression. Core business logic goes untested:

  • Runner.processFile: cache hit/miss path, stderr path rewrite, temp-dir cleanup on failure
  • OutputCache.key: that env var sorting produces stable keys, that schema bumps invalidate
  • OutputCache.store: the concurrent-writer race (two stores for the same key)
  • WrappedSource: import hoisting, #sourceLocation line numbering, inputs with no imports

The injectable Runner makes these straightforward to add. Worth doing before or shortly after merge.

2. -suppress-warnings silently hides compiler feedback

In Subprocess.Configuration+Swift.swift, -suppress-warnings is included in the default argument list. This means users never see Swift compiler warnings from their DSL usage. During active DSL development — the primary skit use case — this hides useful information. At minimum make it opt-in via a --suppress-warnings flag rather than the default, or limit it to -suppress-remarks if the goal was only to quiet SIL remarks.

3. Output cache has no eviction / growth bound

The on-disk cache in ~/Library/Caches or ~/.cache can grow without bound. Each entry is one directory per 16-char hex key. For a project with many inputs compiled across many toolchain upgrades, this accumulates silently. Even a --clear-cache flag or a simple mtime-based sweep at startup would be valuable. This doesn't need to block the PR but isn't in the existing deferred list either.

4. Task+Timeout force-unwrap on group.next()

// Task+Timeout.swift
let first = try await group.next()!

The ! is safe today (two tasks were always added), but a future refactor or copy-paste could produce a crash. guard let first = try await group.next() else { return nil } is a two-line change with no functional difference in the happy path.

5. build-skit-release.sh Package.swift mutation is SIGKILL-fragile

trap cleanup EXIT correctly restores Package.swift on normal exit and SIGTERM, but not on SIGKILL. An OOM killer or CI timeout mid-build leaves the manifest with type: .dynamic. Adding git checkout -- Package.swift inside cleanup() as a belt-and-suspenders measure would help, and a comment acknowledging the limitation would prevent future confusion.

The Python substring match for the library block is also brittle if SwiftFormat ever reformats that exact block — it will sys.exit with a visible message so it's detectable, but worth noting.

6. captureSwiftVersion() could be static

captureSwiftVersion() in Skit.Run+Render.swift has no self dependency. static func (or a free function) would make the coupling clearer.

7. Swift tools version bump to 6.1 isn't mentioned in the PR body

Package.swift bumps swift-tools-version from 6.0 to 6.1, which silently raises the minimum required toolchain. The CI matrix change (removing Swift 6.0) is consistent, but downstream integrators should know. Worth a sentence in the PR body.


Security / Input Handling

WrappedSource.rendered escapes backslashes and double quotes in the file path but not newlines or other control characters. Paths with embedded newlines are technically valid on Linux and would produce syntactically broken Swift with confusing diagnostics. Easy fix: validate early in processFile that originalPath contains no newlines and surface a clear RunError.invalidInput.


Minor Nits

  • RunError is gated #if canImport(Subprocess) but Runner is not. On a platform without Subprocess, Runner compiles while RunError doesn't, which produces confusing errors. Consider aligning the guards or documenting the supported build matrix.
  • SingleFileRender.stderr is String but stdout is Data. The asymmetry is intentional (stdout → file/pipe, stderr → user-facing text) but a one-line comment explaining the rationale would help readers.
  • Numbered comments in Skit+Run.swift (steps 1–6) are useful for orientation, but several restate what the immediately-following code already expresses. Light trimming would bring the file in line with the project's low-comment style.

Summary

The core architecture is sound and the implementation is thorough. The two items I'd want addressed before merging are #2 (suppress-warnings default) and #4 (force-unwrap). The missing test coverage (#1) is the most significant long-term gap — the injectable Runner makes it straightforward to add, so at least a cache + wrapper test suite before or shortly after merge is strongly encouraged.

Everything else is either nit-level or a good candidate for the existing deferred issues (#157, #158).

🤖 Generated with Claude Code

…pers

- FileOutcome.result: Result<Void, RunError> -> (any Error)? (nil == success)
- Merge all SyntaxKit FileManager extensions into FileManager+Execution.swift
  (isLibDir, libStamp, collectInputs, writeOutput); drop the inline extension
  from Runner+Directory.swift (also clears its file_types_order warnings)
- runSwift backend returns SwiftRunOutcome; move .completed wrapping out of Runner
- collectInputs throws a dedicated CollectInputsError (.cliError /
  .resourceValuesFailure), mapped to RunError.unexpected by renderDirectory

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review — PR #156: skit CLI for v0.0.5

This is a substantial, well-documented PR. The design rationale in Docs/skit.md and Docs/skit-internals.md is excellent, and the wrap-and-spawn pipeline is a clean solution to the manifest-runner problem. Below is feedback organized by priority.


Architecture / Structural Concerns

Execution infrastructure lives in the wrong target.
Sources/SyntaxKit/Execution/ hosts Runner, OutputCache, ContentHasher, DirectoryRender, etc. — all of which are CLI-only concerns. Bundling them in the SyntaxKit library target means every consumer of the library pulls in CLI machinery. The package access modifier throughout confirms these are inter-target internal APIs, but this is exactly the pattern the library boundary is supposed to prevent. Consider a SyntaxKitExecution library target or moving these entirely into the skit executable target.

TupleAssignment unexpectedly promoted to public.
TupleAssignment.swift changes from internal to public with no explanation in the diff or commit message. Public API additions to the library are semver-significant. If this was needed to make some execution-layer code compile, that's a symptom of the architecture concern above. Please add a doc comment explaining the intended public use case, or revert to internal and fix the root cause.

public import SwiftSyntax in TupleAssignment.swift.
Changing the re-export visibility of SwiftSyntax in this file from module-internal to a transitive public re-export is a breaking-change candidate for downstream users. This needs explicit justification.


Testing Gaps

This is the most significant concern for a v0.0.5 release.

  • No unit tests for OutputCache. The atomic store + concurrent-writer race logic, the schema-version invalidation path, and the FNV-1a key derivation are all non-trivial and completely untested.
  • No unit tests for ContentHasher. A collision here would silently serve wrong cached output — worth at least a determinism test (same inputs → same key across invocations).
  • No unit tests for WrappedSource/import-hoisting. The #sourceLocation injection and import-peel logic are the core of the pipeline's correctness.
  • No unit tests for ToolchainCheckResult.
  • No end-to-end skit run integration tests. The 10 passing examples are described in the PR body as the test plan, but they require manually running build-skit-release.sh. They are not part of swift test and will not block a CI red. Consider at minimum a test that wraps a minimal input and checks the rendered output matches expectations.
  • The one added test (SkitSubprocessTimeoutTests) is a good regression guard for swift-subprocess #256, but it tests Subprocess cancellation mechanics, not skit's own pipeline.

Build Script Concerns

Package.swift mutation is fragile.
Both build-skit-release.sh and build-skit-debug.sh temporarily rewrite Package.swift to inject type: .dynamic. The trap cleanup EXIT handles normal exits and errors, but a SIGKILL (e.g., out-of-memory kill during swift build) bypasses the trap, leaving Package.swift in a mutated state in the working tree. The repo has no safeguard against this being accidentally committed. A safer pattern: maintain a separate Package-dynamic.swift or pass the library type via a build setting.

The Python string search is whitespace-sensitive.
The inline Python script that patches Package.swift searches for a verbatim multiline string (including indentation). Any reformatting of Package.swift by SwiftFormat or manual edits will silently break the script at the sys.exit("...not found") path. Consider a more robust transformation (e.g., sed with a regex, or a dedicated Package plugin).

Linux flow is not shipped.
build-skit-release.sh immediately exits on non-macOS with "macOS-only. Linux uses a parallel flow." but no Linux parallel flow is included in this PR. The PR body says Linux is verified — presumably the verification was done manually. Contributing docs or a CI step for Linux would close that gap.


Correctness and Edge Cases

Cache key collision → silent wrong output.
FNV-1a 64-bit is documented as non-cryptographic and acceptable for this use case. The 10⁻⁹ collision probability at 10⁶ entries is fine in practice. But a collision would serve a different input's cached output silently — no error, no warning. Worth at least a comment in OutputCache.lookup noting this.

OutputCache.store — staging directory naming.
The staging path includes processInfo.processIdentifier + UUID().uuidString. UUID() calls arc4random_buf internally and is fine; the combination is effectively unique. No real issue here, just noting it's correct.

collectInputs skips _-prefixed files.
The _-prefix convention to mark "not an input" is documented. But the filter is applied only at the filename level (lastPathComponent.hasPrefix("_")), so a file at _hidden/some.swift (a _-prefixed directory) is not skipped — its contents would still be enumerated. Worth checking whether directory-level _ prefix filtering is also intended.

For-with-where rewritten as For + If in conditionals/dsl.swift.
The where clause version filters at iteration time; the If inside the loop is conditional execution. Generated output would differ. If For-with-where doesn't work through skit, that's a bug worth tracking separately rather than silently changing the example's semantics.


Minor / Style

Stale skitrun entries in .vscode/launch.json.
The PR's stated goal is to unify skitrun into skit, but the .vscode/launch.json adds two new launch configurations for a skitrun target. If skitrun no longer exists as a product, these will fail to build. If it still exists transitionally, that should be noted.

extension [String] { static let defaultPathExtensions } in Validator.swift.
Putting a static constant on extension [String] is valid Swift but semantically surprising — this is a free-standing concept attached to the standard Array<String> type. A private enum Defaults { static let pathExtensions = ["md"] } or simply a private let in the file would be clearer. This may also confuse tools like Periphery that scan for unused code.

.swift-version deletion.
Deleting .swift-version without explanation. This file was presumably used to pin the toolchain for contributors (swiftenv, mise, etc.). The CLAUDE.md bump to Swift 6.1+ covers the floor, but the pin file served a different purpose (selecting an exact version). A comment in the PR body or a follow-up issue would help contributors understand the impact.


What's Working Well

  • The wrap-and-spawn pipeline design is sound and the #sourceLocation diagnostic remapping is thoughtful.
  • The DispatchSemaphore-based child-wait pattern is the right fix for the Linux waitUntilExit() hang.
  • The atomic stage+rename cache write is properly race-safe.
  • Sendable correctness is handled carefully (@Sendable () -> FileManager closure injection avoids an @unchecked escape hatch).
  • The toolchain stamp mechanism is a clean solution to the patch-level swiftmodule incompatibility problem.
  • Docs (skit.md, skit-internals.md) are thorough and would significantly reduce onboarding time.
  • fail-fast: false on the Ubuntu CI matrix is a good improvement.
  • The 10 working examples are a solid real-world regression suite (even if currently manual).

Summary

The core design is solid. The main blockers before merge are:

  1. Test coverage for OutputCache, ContentHasher, WrappedSource, and at least one automated end-to-end render test.
  2. Architecture clarification on Sources/SyntaxKit/Execution/ — why CLI machinery lives in the library target, and whether TupleAssignment's public promotion was intentional.

The build script fragility and the stale .vscode entries are nice-to-fix but not blockers.

🤖 Generated with Claude Code

Move hardcoded literals into named type-local constants (matching the
existing private static let convention), giving the CLI surface, swiftc
flag set, and cache/path/env conventions a single source of truth.

- skit: command/option/flag names, swift executable name, SKIT_LIB_DIR
  key, shared "skit: " stderr prefix, --version flag, release script path,
  and the swiftc flag set (-I/-L/-lSyntaxKit/-Xcc/-Xlinker/-rpath/etc).
- Execution: cache subpaths, outputs/output.swift names, staging prefix,
  SKIT_/SYNTAXKIT_ env prefixes, XDG_CACHE_HOME key, SyntaxKit product
  name, swift extension, lib/lib-skit dirs, %016x hash format, temp-dir
  prefix, wrapped-input filename, and dylib prefix/extensions.

Platform-only constants are #if-gated so neither is flagged unused.
CLI option names and all emitted strings are byte-for-byte unchanged.
Also adds Docs/string-literal-audit.md cataloguing the broader audit.

swift build + swift test (398 tests) + ./Scripts/lint.sh all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review: skit CLI for v0.0.5

Overall this is a well-designed PR. The SDK/CLI split (Runner in SyntaxKit library vs. presentation in skit target) is clean, typed errors are idiomatic Swift 6, and the content-addressed cache design is solid. Comments below in descending priority.


High Priority

1. build-skit-release.sh modifies Package.swift in place via exact-string matching (brittle)

The Python fragment does a literal string match to flip the library product to .dynamic:

old = '    .library(\n      name: "SyntaxKit",\n      targets: ["SyntaxKit"]\n    ),'

Any whitespace or formatting change to Package.swift silently causes sys.exit(...) to fire. Because the shell uses set -e, the script stops, but Package.swift is left in its original state and the error message is just the bare sys.exit string — not great UX. A less fragile approach is to add a dedicated .library(name: "SyntaxKit", type: .dynamic, ...) product (e.g. SyntaxKitDynamic) alongside the static one in Package.swift permanently, then build --product SyntaxKitDynamic. That removes the temp-mutation + trap dance entirely.

2. Test coverage for the core Runner pipeline is thin

The only new test I see is SkitSubprocessTimeoutTests.swift (the subprocess #256 regression). The render pipeline itself — processFile, cache hit/miss, WrappedSource output, toolchain check, directory batch — doesn't appear to have tests. Even stubs that inject a fake run closure and verify cache key derivation or #sourceLocation remapping would de-risk the cache logic. The existing timeout test is a good template.

3. Dead catch branches suggest the typed-throws contract isn't enforced

render() in Skit.Run+Render.swift:

do {
  resolved = try RunInput.resolve(input: input, output: output)
} catch .invalidInput(let message) {
  throw ValidationError(message)
} catch {
  // RunInput.resolve only throws .invalidInput; defensive.
  throw ExitCode(1)
}

If RunInput.resolve is typed-throws and only throws .invalidInput, the second catch is dead code — the compiler would tell you if you declared it throws(RunError). Same pattern appears in renderBatch. If typed throws is already in use for renderFile, using it here makes the dead-code intention explicit and compiler-enforced, eliminating the "defensive" comment + dead branch.

4. -suppress-warnings silences DSL author warnings

Subprocess.Configuration+Swift.swift adds -suppress-warnings to every swift invocation. The intent (avoid noise from the wrapper preamble) is sound, but it also suppresses warnings the user actually wrote in their DSL — a @discardableResult, deprecated API call, etc. A finer-grained approach is to suppress warnings only for the preamble lines (which are all before #sourceLocation) and let warnings through for lines inside the user body. One option: pass -suppress-warnings but then re-run with warnings enabled when exit code is non-zero, so diagnostics on failure are complete.


Medium Priority

5. swift --version capture has a silent failure mode

captureSwiftVersion() returns String? and the call site silently proceeds with swiftVersion = nil on spawn failure. With nil swiftVersion the toolchain check is skipped (ToolchainCheckResult.stampMissing) and the cache key omits the version shard. If swift isn't on PATH at all, spawning it in step 6 will fail with a confusing error rather than the clear "swift not found" message users expect. Consider surfacing a human-readable error at the captureSwiftVersion call site when the result is nil.

6. Exit codes are not documented in the CLI help text

skit run uses exit 2 for "can't find lib dir" and exit 1 for render failures, while POSIX timeout(1) exit 124 is used for watchdog kills. None of this is reflected in --help output or the README.md. A one-line note in the command's abstract or discussion field would help scripts that depend on these codes.

7. Docs/skit.md describes DispatchSemaphore but code uses task-group concurrency

Implementation uses DispatchSemaphore.wait(timeout:) to avoid Linux Foundation.Process.waitUntilExit() hangs.

The actual implementation in Task+Timeout.swift uses a TaskGroup race (operation vs. Task.sleep), which is cleaner. The DispatchSemaphore description is outdated and will confuse contributors. Update the docs to describe what's actually there.

8. Synchronous I/O in async context

In processFile, String(contentsOf:encoding:) and String.write(to:atomically:encoding:) are blocking calls made from an async function. For typical DSL files (< 100 KB) this is inconsequential, but it blocks the cooperative thread while it runs. Since there's already a concurrency investment, consider Data(contentsOf:) + FileHandle async reads, or at minimum a withCheckedContinuation wrapper to schedule blocking I/O off the main executor. (Low urgency given file sizes in practice.)


Low Priority / Nits

9. No Linux equivalent of build-skit-release.sh

The script exits immediately on non-Darwin. "Linux uses a parallel flow" is mentioned but the script doesn't exist. A minimal build-skit-release-linux.sh (or a unified script with uname branching that omits install_name_tool) would help Linux contributors without depending on the deferred #157.

10. captureSwiftVersion output cap is undocumented

The 4 KiB cap is fine, but if a future Swift toolchain emits more than 4 KiB on --version (unlikely), the cache key silently truncates. A brief // 4 KiB is >> any realistic swift --version output comment makes the intent clear.

11. renderBatch stderr fencing format

---- /path/to/file.swift ----

is readable but non-standard. Tools that parse diagnostic output (editors, CI formatters) expect error: /path/to/file.swift:line:col: message. This is cosmetic but worth noting for IDE integration later.

12. OutputCache uses FNV-1a (non-cryptographic)

Noted in docs, acceptable tradeoff. The cache lives in the user's home directory so a local attacker is already that user. The collision probability at realistic usage is negligible. No change needed, just confirming the tradeoff is intentional.


Summary

The architecture is well-thought-out, the Swift 6 typed-throws integration is correct, and the toolchain stamp + cache design are good engineering. The main asks before merge:

  1. Either remove the Package.swift mutation in the build script or document the failure modes more clearly.
  2. Add at least basic unit tests for Runner.processFile (cache hit path) and WrappedSource output using a stubbed run closure.
  3. Clean up the dead catch branches with typed throws or an XCTUnreachable-style annotation.
  4. Fix the DispatchSemaphore → task-group inaccuracy in Docs/skit.md.

Everything else is polish and can be tracked as follow-up.

Centralize the run command's failure handling and isolate the Subprocess
dependency behind an SDK-level protocol so the command logic is platform-clean.

- Add Skit.Run.CommandError: a self-describing typed error (diagnostic +
  terminalError) so run() maps every failure to its stderr + ExitCode/
  ValidationError in one place; the render chain is now throws(CommandError)
  end-to-end.
- Move the render-session setup into a SyntaxKit `Runner` initializer
  (Runner+Session.swift) that resolves the lib dir, gates the toolchain check,
  builds the cache, and assembles the Runner, throwing a typed Runner.SetupError.
  swiftVersion + the spawn closure are injected so SyntaxKit stays Subprocess-free.
- Add a SwiftBackend protocol in SyntaxKit (capture-version + spawn-swift) with
  a SubprocessBackend conformance in skit, held in an optional that's non-nil
  only when canImport(Subprocess). run() guards it (nil -> unsupportedPlatform)
  and no longer branches on #if.
- Ungate RunError/RunInput/DirectoryRender (Subprocess-free; the ungated Runner
  already depended on them) and the Skit.Run+Render extension; #if now survives
  only where Subprocess types are genuinely used.

Behavior (stderr text + exit codes) is unchanged. swift build, swift test
(398 tests), and ./Scripts/lint.sh all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review — PR #156: skit SyntaxKit codegen CLI

Overview

This PR lands a well-designed, production-minded CLI tool. The architecture is genuinely good: a clean SwiftBackend protocol seam keeps Runner free of any Subprocess dependency, typed throws propagate correctly through every layer, Sendable is applied consistently, and the output cache uses a proper atomic-rename strategy for concurrent writers. The documentation (PR body + Docs/skit.md) is among the most thorough I've seen for a v0.x feature. Suggestions below are mostly gaps rather than rewrites.


Correctness / Bugs

WrappedSource.rendered — newline in path breaks #sourceLocation
WrappedSource.swift:103–106 escapes \\ and \" in the path but not \n. POSIX allows newlines in file paths, and a user who passes skit "my\nfile.swift" would produce a syntactically invalid wrapper that emits a confusing parse error from the spawned swift rather than a diagnostic pointing at the real problem. A cheap guard:

let escapedPath = originalPath
    .replacingOccurrences(of: "\\", with: "\\\\")
    .replacingOccurrences(of: "\"", with: "\\\"")
    .replacingOccurrences(of: "\n", with: "\\n")

Or surface an invalidInput error early in processFile if absoluteInputPath contains a newline.

Task+Timeout — operation error collapses the timeout race
Task+Timeout.swift:38–49: if operation() throws (e.g. the Subprocess spawn itself fails), withThrowingTaskGroup propagates the throw and cancels both tasks — correct behaviour. However, the sleep watchdog's return nil can race with the operation's throw. In practice this is fine because a spawn failure is surfaced as a RunError, not silently swallowed — but it's worth a comment in the function that a throw from operation bypasses the nil/timeout path.


Design / Architecture

ToolchainCheckResult.init writes to stderr directly
ToolchainCheckResult.swift:56–67: the initializer unconditionally calls FileHandle.standardError.write(...) when the stamp is missing or swift --version couldn't be captured. Library code writing to a process file handle couples SyntaxKit (the library target) to CLI semantics and makes unit-testing this initializer impossible without intercepting the real stderr. Suggest returning a richer enum case or an associated diagnostic string instead, leaving the write to the CLI caller:

case stampMissing(diagnostic: String?)

Subprocess.Configuration+Swift.swift output limits produce opaque errors
Subprocess.Configuration+Swift.swift:40–41: hitting the 16 MiB stdout cap raises a SubprocessError that surfaces as CommandError.unexpected, printing an internal Subprocess message. A generated DSL producing a huge file is a realistic edge-case. Consider catching the limit error and converting it to a clear RunError (e.g. renderFailed with a human-readable message about output size).

build-skit-release.shinstall_name_tool || true masks hard failures
build-skit-release.sh:82: 2>/dev/null || true silences both "already set" (expected) and genuine errors (code-signing lock, missing tool). Prefer a targeted check:

current=$(otool -D "$OUTPUT_DIR/lib/libSyntaxKit.dylib" | tail -1)
if [[ "$current" != "@rpath/libSyntaxKit.dylib" ]]; then
  install_name_tool -id "@rpath/libSyntaxKit.dylib" "$OUTPUT_DIR/lib/libSyntaxKit.dylib"
fi

build-skit-release.sh — build dir discovery is fragile
build-skit-release.sh:64: ls -d .build/*-apple-macosx*/release | head -1 could match a stale arch triple if a developer has built for multiple targets. swift build --show-bin-path -c release --product skit is the stable API:

BUILD_DIR="$(swift build --show-bin-path -c release --product skit)"

Test Coverage

This is the most significant gap. The entire Sources/SyntaxKit/Execution/ layer — ~600 lines of new logic — has only one test (SkitSubprocessTimeoutTests.swift), which is a regression test for a Subprocess library bug rather than a unit test for the new code. Missing coverage:

Component Cases worth testing
OutputCache key() determinism; concurrent store() (verify no duplicate entries); lookup() miss; schema-version invalidation
WrappedSource Import hoisting (leading, mixed, no imports); #sourceLocation line numbers; empty input; imports-only input
ContentHasher Determinism across invocations; different inputs produce different keys
ToolchainCheckResult .match, .mismatch, .stampMissing (no file), .stampMissing (nil swiftVersion)
Bundle+ResolveLibPath Adjacent layout hit; Homebrew layout hit; all candidates fail
Runner.processFile Cache hit short-circuits spawn; non-zero exit → renderFailed; stderr path rewrite

None of these require a live swift process — they just need lightweight fakes for the run: closure and FileManager.


Minor Nits

  • Skit+Parse.swift:51print(json) appends \n, which is fine for human output, but programmatic consumers piping JSON will get a trailing newline after what may already be a compact single-line JSON object. FileHandle.standardOutput.write(data) avoids the Data → String → print round-trip and is more explicit.

  • CI matrix — Swift 6.0 removal (SyntaxKit.yml:68) — Dropping a Swift version from CI is a supported-versions change. It would be worth a line in the changelog / README so consumers know the floor moved from 6.0 → 6.1.

  • ContentHasher access levelinternal while the rest of Execution/ is package. Not wrong (it's an implementation detail), but worth confirming it's intentional given OutputCache (package) calls it.

  • Skit.Run.execute()libCandidates order (Skit+Run.swift:131): [self.libPath, envLibPath] — explicit --lib flag wins over the env var, which is the correct precedence. A comment noting the order might prevent a future "why does --lib override the env var?" question.


Summary

The architecture is solid and the PR description does an excellent job explaining the design trade-offs. The two things most worth addressing before merge are the missing unit tests for the Execution layer and the ToolchainCheckResult stderr side-effect in library code. Everything else is polish.

🤖 Generated with Claude Code

Comment thread Docs/string-literal-audit.md Outdated

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete this file

Comment thread Docs/skit-internals.md Outdated

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete this file

/// `throw` the case that describes *what* failed; `run()` catches these in
/// one place and converts each to its stderr diagnostic + process exit, so
/// the steps stay free of presentation/exit logic.
internal enum CommandError: Error {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be inside Skit.Run?

Comment thread Sources/skit/Skit.Run+Render.swift Outdated
/// stdout/stderr presentation and file IO, and translate `RunError`/batch
/// failures into the typed `CommandError` that `run()` maps to a process
/// exit. Success-path output is still written here.
internal func render(using runner: Runner, input: String, output: String?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this use anything inside the Run instance?

/// `result` is `nil` when the rendered output was written to its mirrored
/// destination, and carries the error when the input could not be rendered
/// or its output could not be written (typically a `RunError`).
package struct FileOutcome: Sendable {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need DirectoryRender or just use an Array?

/// change in these inputs produces a fresh key and forces a recompile.
/// See `ContentHasher` for the choice of FNV-1a over a cryptographic hash.
package func key(forInput source: String, libPath: String) -> String {
var hasher = ContentHasher()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an existing protocol or could we refactor into a protocol?

Comment thread Sources/skit/Skit+Run.swift Outdated
Comment on lines +49 to +52
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: input, isDirectory: &isDirectory) else {
throw RunError.invalidInput("input does not exist: \(input)")
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create an extension method on FileManager which returns an enumerated or struct for isDirectory and/or doesExist and refactor all fileExists to use it


// Phase 3: write successes and capture a per-input outcome for each.
let outcomes = renderResults.map {
FileManager.default.writeOutput(for: $0, inputBase: inputURL, outputBase: outputURL)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace all uses of FileManager.default with using an instance property or an argument

/// Why a render session couldn't be brought up. Decoupled from any caller:
/// the initializer reports *what* failed; the caller (CLI, build plugin,
/// in-process driver) decides how to present it and which exit code to use.
package enum SetupError: Error {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be inside Runner?

CI:
- Remove Android API level 36 from the build matrix (33/34/35 pass; 36 fails).
- Drop the `Duration` requirement in the timeout watchdog so the SyntaxKit
  library compiles on its minimum deployment targets. `Task.timeout` now takes
  `seconds: Int` and uses `Task.sleep(nanoseconds:)`, fixing the iOS 13 /
  tvOS 13 / watchOS 6 builds that failed on `'Duration' is only available in
  iOS 16 / tvOS 16 / watchOS 9`.
- Move iOS and visionOS simulators to osVersion 26.5 so all four Apple device
  platforms run on 26.5; drop the stale 26.4 pin comment.

Promote the Execution engine surface (Runner, RunInput, SwiftBackend, and
related types) from `package` to `public`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review: skit CLI for SyntaxKit v0.0.5

Overview

This PR unifies a stdin-to-JSON parser and a POC DSL renderer into a single skit CLI with run (default) and parse subcommands. The design is solid: clean separation between the SDK-shaped Runner and the CLI surface in Skit, typed throws throughout, injected backend for testability, and thoughtful caching/timeout infrastructure.


Architecture Concern: CLI infrastructure in the library target

The Sources/SyntaxKit/Execution/ directory lives in the SyntaxKit library target, not the skit executable.

Runner, OutputCache, WrappedSource, ToolchainCheckResult, and friends are all marked public, meaning subprocess management, output caching, and CLI-specific utilities become part of the public API of what is otherwise a lightweight DSL library. Compounding this, OutputCache.swift uses public import Foundation, which re-exports Foundation's entire public API to every downstream consumer of SyntaxKit — almost certainly unintentional.

Suggestion: Move Sources/SyntaxKit/Execution/ into the skit target or a new SyntaxKitCLI library target. At minimum, public import Foundation should become import Foundation.


Bug: Side effect in value type initializer

ToolchainCheckResult.init(libPath:swiftVersion:) writes directly to stderr inside the initializer (in the stampMissing path). Value type constructors should not have invisible side effects — this diagnostic fires at construction time rather than at the presentation layer. The SetupError enum in Runner+Session.swift is correctly decoupled; the stampMissing diagnostic should move to that call site.


Potential overflow in timeout conversion

In Task+Timeout.swift:

try await Task<Never, Never>.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)

validate() in Skit+Run.swift checks timeoutSeconds >= 0 but has no upper bound. Passing --timeout 18446744074 overflows the UInt64 multiplication silently. Adding timeoutSeconds <= Int(UInt64.max / 1_000_000_000) to the validation would close this.


Test coverage gaps

The single integration test (SkitSubprocessTimeoutTests) is excellent for its target regression, but several new components have no coverage:

  • ContentHasher: hash stability across runs (critical for cache invalidation correctness)
  • WrappedSource: import hoisting edge cases (source with only imports, comment before first import, empty source)
  • ToolchainCheckResult: mismatch logic, stamp-missing path
  • OutputCache.key(): key composition, env-var inclusion

Build script fragility

Scripts/build-skit-release.sh patches Package.swift in-place via string matching. The Python guard (sys.exit()) fires if the expected block is not found, and the trap cleanup EXIT handler restores the backup — so it is safe. But patching the canonical Package.swift in a CI environment is risky if whitespace or formatting changes. Consider a separate Package.skit-dynamic.swift or a Package plugin instead.


Minor observations

  • internal func processFile being internal rather than private is intentional and correctly commented.
  • Bounded concurrency in directory mode (max(1, activeProcessorCount)) is a thoughtful choice over unbounded task spawning.
  • Dropping Android API level 36 from the CI matrix is unexplained; a brief comment would help future reviewers.
  • The swift-tools-version: 6.1 bump is consistent with dropping Swift 6.0 from CI, but worth a note for users on 6.0.

Summary

The core design is well-executed and the code is clean. Three things worth addressing before landing:

  1. Move Execution/ out of the library target (or at minimum fix public import Foundation to import Foundation).
  2. Remove the stderr side effect from ToolchainCheckResult.init and move the diagnostic to the call site.
  3. Add an upper-bound check on timeoutSeconds before the nanosecond multiplication.

The deferred items (#157 auto-rebuild, #158 if-in-Group crash) are well-documented and reasonable to defer.

Generated with Claude Code

skit run was non-functional: the `swift` interpreter's JIT can't load the
dynamic libSyntaxKit symbols. Switch the render backend to compile the wrapped
DSL with `swiftc` to a temp executable and run it (Subprocess.Configuration+Swift).

Also addresses code-review findings and follow-ups:
- Guard --timeout against the UInt64 nanosecond overflow in validate().
- Make ToolchainCheckResult.init pure (no stderr). Record the outcome as a new
  Runner.ToolchainVerification, carry it on RunError.renderFailed, and have skit
  surface a one-line hint only on a render failure — keeping the SDK silent.
- ContentHasher: fix 32-bit truncation in finalize() (%016x consumed a 32-bit
  int, halving the 64-bit digest) by formatting via String(_:radix:).
- Replace the in-place Package.swift patch in build-skit-release.sh with a
  SYNTAXKIT_DYNAMIC_LIB env-var switch read by the manifest.
- Introduce a pluggable ContentHashing protocol; OutputCache derives keys through
  an injectable hasher factory defaulting to ContentHasher (FNV-1a).

Add unit tests for ContentHasher, WrappedSource, ToolchainCheckResult,
OutputCache (incl. injected-hasher), and Runner render-failure toolchain plumbing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review: skit — SyntaxKit codegen CLI for v0.0.5

Overview

This PR lands skit, a unified CLI that merges the previous stdin→JSON parser (~50 lines) and the skitrun DSL renderer (~700 lines) into a single binary with two subcommands (run / parse). The architecture is clean and the implementation is well-considered; the notes below are mostly about edge cases, a few API design choices, and gaps in test coverage.


Strengths

  • Clean layered architecture. Runner / SwiftBackend / OutputCache / WrappedSource separate concerns well, and the dependency injection of the backend closure, FileManager factory, and hasher factory makes unit testing practical without any global state.
  • Swift 6 concurrency done right. Sendable conformance is thorough and throws(RunError) typed throws on renderFile is idiomatic Swift 6.
  • Error hierarchy. RunError / SetupError / CommandError clearly separates the engine from CLI presentation, so wiring up a build plugin later shouldn't require touching Runner internals.
  • Atomic cache stores. The staging-dir + moveItem rename handles concurrent writers without a lock; try? on write failure is intentionally non-fatal. Good.
  • FNV-1a choice. Deterministic across processes/platforms, well-justified in the code; using String(_:radix:) instead of %x to avoid the 32-bit truncation is a subtle correct call.
  • Timeout watchdog. Practical fix for the Linux Foundation.Process.waitUntilExit() hang; regression test for swift-subprocess #256 is a nice touch.
  • #sourceLocation remapping. Wrapper-path rewrite in stderr and the #sourceLocation directive together give reasonable diagnostics, which is the right tradeoff for a wrap-and-spawn approach.

Issues

Potential Bug — Task.timeout can silently overflow

Task+Timeout.swift computes UInt64(seconds) * 1_000_000_000. This overflows for seconds > ~584 years, which is guarded at the CLI surface by Skit.Run.maxTimeoutSeconds / validate(). But Task.timeout(seconds:) is a public API with no internal guard. Any non-CLI caller (future build plugin, test harness) can trigger the overflow silently.

Suggestion: Add a precondition/clamp inside Task.timeout, or document the valid range in the docstring:

// seconds: in [0, Int(UInt64.max / 1_000_000_000)] — callers must validate

Design — extension [String] with fileprivate static in Validator.swift

The refactoring moves defaultPathExtensions from extension Validator to extension [String]. While the call site withPathExtensions: .defaultPathExtensions reads nicely, extending a stdlib type with a file-private static property is an unusual idiom that makes the property hard to discover and violates the principle of least surprise. The original Self.defaultPathExtensions on extension Validator was more explicit about what this default belongs to.

Suggestion: Keep the property on extension Validator or make it a top-level fileprivate constant in the file.

Design — Executable name constants on the wrong type

Skit.swiftExecutableName and Skit.swiftcExecutableName are declared on the top-level Skit struct but are only used by Skit.Run (and SubprocessBackend). Placing them on Skit couples the top-level argument-parser shell to the subprocess machinery.

Suggestion: Move to Skit.Run or SubprocessBackend.

API Surface — TupleAssignment visibility change

TupleAssignment and its syntax property are promoted from internal to public. This extends SyntaxKit's public API surface and is a semver-relevant change. Since this is v0.0.x it's fine, but it should be a conscious decision — was this required by the new execution path, or accidental?

Security — Document the execution model more clearly in the README

skit run compiles and runs arbitrary user-supplied .swift files. This is by design, but the current README/docs don't call out that running skit run Input.swift is functionally equivalent to executing swift Input.swift — the input has full access to the filesystem, network, and environment. For a tool that could end up in automated pipelines, a one-line note in the README would set appropriate expectations. (PR docs acknowledge sandboxing as deferred; just needs a visible callout.)

Performance — OutputCache.key() byte-by-byte iteration

ContentHasher.update(data:) iterates over every byte of the source and the swift --version string in a byte loop. For the expected workloads (small DSL files) this is fine. For directory mode with large inputs, SHA-256 via swift-crypto (which already appears in Package.resolved for Linux) would use SIMD/hardware acceleration and be faster. Not a blocker, but worth tracking as usage scales.

CI — Dropping Swift 6.0 from the test matrix

The previous swift-tools-version was 6.0; this PR bumps to 6.1. That's a deliberate minimum-version raise, but removing 6.0 from CI simultaneously means there's no regression signal if something accidentally compiles under 6.0. If the intent is to require 6.1+, the CLAUDE.md / README should say so explicitly.


Missing Test Coverage

  1. Skit.Run.validate() — the CLI validation logic (negative timeout, overflow timeout) has no tests. A quick parametric @Test with a few boundary values would pin the behaviour.
  2. Directory mode (Runner+Directory.swift) — no tests for the concurrent per-file dispatch, output-tree mirroring, or partial-failure handling. At minimum, a mock-backend test covering a two-file input where one fails would give confidence in the error-aggregation path.
  3. Runner session initialization (Runner+Session.swift) — the toolchain-mismatch and lib-resolution-failure paths aren't covered by the existing RunnerRenderFailureTests (which focuses on post-setup failures). Adding a test for SetupError.toolchainMismatch and SetupError.libResolutionFailed would complete the error hierarchy coverage.

Minor Nits

  • let first = try await group.next()! in Task+Timeout.swift: the force-unwrap is safe (exactly 2 tasks always), but a brief comment explaining why would prevent a future reader from flagging it.
  • build-skit-release.sh uses ls -d .build/*-apple-macosx*/release | head -1 to find the triple. A missing build or multiple triples (cross-compilation) silently picks one; a stricter glob or an explicit check would make failures noisier.
  • OutputCache.store assumes the staging dir and cache dir are on the same filesystem for moveItem to be atomic. This is a safe assumption for files under ~/Library/Caches, but worth a one-line comment since the atomicity guarantee depends on it.

Summary

The architecture is solid and the implementation handles the gnarly parts (atomic cache, timeout race, #sourceLocation remapping, Linux pipe-buffer deadlock) correctly. The main actionable items before merge are: a guard inside Task.timeout for the overflow edge case, a note in the README about the execution model, and a few test gaps for validate() and directory mode. Everything else is low-priority polish.

🤖 Generated with Claude Code

…ome.result

Extract domain-agnostic filesystem operations into generic FileManager
helpers returning clean Swift types instead of Foundation idioms:

- FileManager+FileSystem.swift: PathKind enum (replaces the fileExists +
  ObjCBool dance), FileFingerprint struct (size + mtime), FileEnumerationError,
  and pathKind/fingerprint/regularFiles(under:)/writeData methods. The
  recursive walk and path sort live here so deterministic ordering is
  guaranteed in one place.
- URL+Reroot.swift: rerooted(from:onto:), the input->output path mirroring
  pulled out of writeOutput.

FileManager+Execution.swift becomes thin domain wrappers: isLibDir/libStamp
over pathKind/fingerprint, collectInputs over regularFiles (mapping
FileEnumerationError -> CollectInputsError) plus the SyntaxKit .swift/_-prefix
input convention, and writeOutput keeping only Result-folding + rerooted +
writeData. Adopt pathKind at the other fileExists sites (RunInput.resolve,
OutputCache.store).

Tighten DirectoryRender.FileOutcome.result from (any Error)? to RunError?: the
only producer (the SDK) funnels everything through RenderTaskResult.writeOutput,
which is throws(RunError). The skit consumer drops its now-redundant
`as? RunError` downcast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review: skit CLI for v0.0.5

Overall: This is a well-crafted PR. The architecture is clean, the wrap-and-spawn pipeline is well-reasoned, and the execution layer has solid unit test coverage. The findings below are mostly low-severity hardening opportunities; nothing blocks merge.


Architecture & Design ✅

  • Unifying skit/skitrun into one ArgumentParser binary is the right call. The subcommand surface (run as default) is intuitive.
  • Typed throws (throws(RunError), throws(CollectInputsError)) throughout the execution layer is a meaningful improvement over any Error—good use of Swift 6 features.
  • Sendable conformance on Runner and related types is correct and forward-compatible with structured concurrency.
  • The FNV-1a hasher choice is well-justified in ContentHashing.swift—the comment explaining why Hasher is unsuitable (per-process seeding) is exactly the kind of non-obvious rationale worth keeping.
  • Timeout watchdog (SIGTERM → 5s grace → SIGKILL) is the right shape for a spawned swift process; using DispatchSemaphore.wait(timeout:) to dodge the Linux Foundation.Process.waitUntilExit() hang is a good platform workaround.

Issues Found

Medium

Scripts/build-skit-release.sh — unquoted path variables

Several variable uses are unquoted, which will silently break if any path segment contains a space:

# Examples from the script
cp $BUILD_DIR/skit $OUTPUT_DIR/           # line ~56
cp -r $BUILD_DIR/*.swiftmodule $LIB_DIR/  # line ~61

All path variables ($BUILD_DIR, $OUTPUT_DIR, $REPO_ROOT, $LIB_DIR) should be double-quoted. set -u catches unset variables but not word-splitting.

Scripts/build-skit-release.shhead -1 silently picks the first of multiple build dirs

BUILD_DIR=$(ls -d .build/*-apple-macosx*/release | head -1)

If a future build system or Xcode version produces multiple matching directories, this silently uses the wrong one. Consider:

mapfile -t dirs < <(ls -d .build/*-apple-macosx*/release 2>/dev/null)
[[ ${#dirs[@]} -eq 1 ]] || { echo "Expected exactly one build dir, got: ${dirs[*]}"; exit 1; }
BUILD_DIR="${dirs[0]}"

Low

Sources/SyntaxKit/Execution/FileManager+FileSystem.swift — symlinks silently followed in regularFiles()

FileManager.enumerator follows symbolic links by default. If a user's input directory contains a symlink pointing outside the intended tree (whether accidentally or intentionally), regularFiles() will include it. This is probably fine for the intended use case, but it's worth adding a comment:

// Note: symlinks are followed; callers with untrusted input should resolve
// the root URL first to confirm it stays within the expected boundary.

Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift — symlink resolution without prefix check

After resolvingSymlinksInPath() the resolved path is used without verifying it stays within the bundle prefix. For a developer tool using its own executable path the risk is negligible, but a single comment stating the trust assumption would help future readers:

// executableURL is this binary's own path — trusted, no prefix check needed.

Sources/skit/Skit+Parse.swift — silent empty output on invalid UTF-8 stdin

let source = String(data: inputData, encoding: .utf8) ?? ""

Invalid UTF-8 on stdin silently produces an empty parse result with no diagnostic. A one-line stderr message before the fallback would save confusion:

if let source = String(data: inputData, encoding: .utf8) {
    // ...
} else {
    fputs("skit parse: warning: stdin is not valid UTF-8; treating as empty\n", stderr)
}

Informational / Non-blocking

Test coverage gaps in batch/collection path

The execution unit tests cover ContentHasher, OutputCache, WrappedSource, ToolchainCheckResult, and RunnerRenderFailure well. Two areas lack direct coverage:

  • DirectoryRender (batch outcomes, failureCount logic) — the render path in Skit.Run+Render.swift batches through DirectoryRender, but there's no unit test exercising partial-failure scenarios (e.g., 3 inputs, 1 fails).
  • CollectInputsError / FileManager+Execution.collectInputs() — no test for the case where enumeration succeeds but attribute-reading fails on a specific file.

These aren't blockers, but they'd increase confidence in the batch mode that most users will hit first.

CI matrix changes worth confirming

Two matrix shrinks in SyntaxKit.yml:

  • Swift 6.0 removed from Ubuntu matrix — is this because Subprocess or another new dependency requires 6.1+? If so, worth calling out in the PR description or a comment in the workflow.
  • android-api-level shrinks from [33, 34, 35, 36] to [33, 34, 35] — intentional drop of API level 36, or oversight?

Summary

Area Severity Action
build-skit-release.sh unquoted paths Medium Quote $BUILD_DIR, $OUTPUT_DIR, etc.
build-skit-release.sh ambiguous dir match Low Validate exactly one match
regularFiles() follows symlinks silently Low Add documenting comment
Bundle+ResolveLibPath symlink trust Low Add trust comment
Skit+Parse silent invalid-UTF-8 fallback Low Emit stderr warning
DirectoryRender / collectInputs test gaps Low Nice-to-have follow-up tests
Swift 6.0 / Android API 36 CI drops Info Confirm intentional

The wrap-and-spawn pipeline, cache design, toolchain stamp, and timeout watchdog are all solid. The shell script hardening is the most actionable item before merge.

@leogdion leogdion mentioned this pull request Jun 10, 2026
9 tasks
- Replace OutputCache's concrete ProcessInfo dep with an EnvironmentProvider
  protocol so FakeEnvironment (struct) replaces the FakeProcessInfo subclass
  that broke on swift-corelibs-foundation (final class, environment on an
  extension). Clears the OutputCacheTests failure on Linux/Windows/Android.
- Add swift-system as a conditional dep + canImport(System)/SystemPackage
  shim so the skit target compiles on Linux/Windows.
- Hoist Runner.SetupError -> RunnerSetupError, Runner.ToolchainVerification
  -> ToolchainVerification, Skit.Run.CommandError -> RunCommandError,
  DirectoryRender.FileOutcome -> FileOutcome.
- Replace DirectoryRender with [FileOutcome] + a failureCount extension.
- Lift render/renderSingle/renderBatch out of extension Skit.Run into
  enum Render (none used self).
- Move SKIT_LIB_DIR lookup to ProcessInfo.skitLibPath.
- Inject FileManager via @sendable () -> FileManager on Runner; thread
  through Runner+Session and Bundle.resolveLibPath.
- Delete Docs/skit-internals.md + Docs/string-literal-audit.md.
- Unify Scripts/build-skit-{debug,release}.sh into Scripts/build-skit.sh
  (release default; --debug flag).
- Lint cleanups: split FileManager+FileSystem.swift into PathKind /
  FileFingerprint / FileEnumerationError; extract RenderTaskResult to
  its own file; reorder type contents (cases/properties/init/methods);
  fix conditional-returns-on-newline + multiline-arguments-brackets;
  add doc comment to ContentHasher.update(data:). Lint goes from 21
  violations to 5 (the remaining 5 are all pre-existing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review: skit SyntaxKit Codegen CLI

Overall this is a well-engineered PR. The architecture is clean — injectable dependencies, Sendable throughout, typed errors at every layer, and atomic cache writes. The docs in Docs/skit.md and inline comments are thorough. Below are specific findings, ordered by priority.


Bugs / Correctness

1. Force-unwrap in Task+Timeout.swift (line 54)

let first = try await group.next()!

Two tasks are added and neither is consumed before this line, so next() cannot return nil in practice — but the invariant isn't visible to the compiler or future readers. If the task group API ever changes, this silently becomes a crash. The timeout test (SkitSubprocessTimeoutTests) uses the guarded form try #require(await group.next()) — production should match:

guard let first = try await group.next() else {
    throw CancellationError()   // or a dedicated error
}

2. Runner+Session.swift: fileManager() factory called three times

libPath = try Bundle.main.resolveLibPath(candidates: libCandidates, fileManager: fileManager())
// ...
let cache: OutputCache? = useCache ? OutputCache(..., fileManager: fileManager()) : nil
// ...
self.init(..., fileManager: fileManager(), ...)

Each call invokes the autoclosure factory again. In production the factory returns FileManager.default (idempotent), but in tests that inject a mock factory this produces three separate fake instances, potentially with different state. Capture once at the top of the init:

let fm = fileManager()
// use fm everywhere

Architecture / Design

3. Execution/ lives inside the SyntaxKit library target

Sources/SyntaxKit/Execution/ contains Runner, OutputCache, SwiftBackend, ContentHasher, WrappedSource — all of which are skit-specific pipeline infrastructure with no business being exported from the DSL library. Today Runner, OutputCache, WrappedSource etc. are all public. A library consumer importing SyntaxKit to generate code has no use for them, but they're part of the public API surface.

Consider extracting these into a separate package target (SyntaxKitExecution or SyntaxKitRunner) that skit depends on but library consumers do not.

4. FNV-1a cache key: silent wrong-output risk

The comment in ContentHasher.swift justifies FNV-1a for non-security reasons, which is reasonable. However, a 64-bit FNV-1a hash means a birthday-attack collision probability of ~1-in-4 billion for a large input set — and a cache collision returns wrong rendered output silently. For a build-tool cache where the I/O cost dominates and correctness matters, SHA-256 (or even truncated SHA-256) is a better trade-off. The performance argument only holds if hashing is CPU-bound, which it won't be here.


Code Quality

5. build-skit.sh: Misleading error message for non-macOS

echo "macOS-only. Linux uses a parallel flow (build, then strip the" >&2
echo "Mach-O install_name step)." >&2

The PR description says Linux is verified (Swift 6.0-jammy/aarch64), but this script exits immediately on non-macOS with a message that implies a Linux build flow exists. Either add the Linux path here, or change the message to "macOS only; see CI for Linux build steps."

6. --suppress-warnings is unconditional

Subprocess.Configuration+Swift.swift always passes -suppress-warnings to the spawned swiftc. Users who write DSL inputs with legitimate warnings (deprecations, unused variables) won't see them. Consider making this opt-in via a --show-warnings flag, or at least documenting it as a current limitation.

7. OutputCache.schemaVersion has no lifecycle docs

private static let schemaVersion = "v1"

There's no comment on when to bump this, what happens to stale entries (they'll simply miss on every run and fill the cache), or any eviction policy. Old entries under v1/ will linger forever. Worth documenting the "bump = orphaned entries" contract and considering a skit cache clear command as a follow-up.


Test Coverage Gaps

8. No tests for Skit+Run / Skit+Parse argument validation

Skit+Run.swift has a validate() method with timeout bounds checking. There are no tests for this. Consider adding a unit test that feeds invalid --timeout values and confirms ValidationError is thrown.

9. No WrappedSource tests for special-character paths

WrappedSourceTests covers the baseline, but there are no tests for paths with embedded quotes or backslashes. These are the exact characters the escaping logic handles. Adding:

WrappedSource(source: source, originalPath: "/tmp/path with \"quotes\"/file.swift")

would catch any regressions in the escaping logic.

10. SkitSubprocessTimeoutTests missing a CI tag

The integration test spawns real processes with a 15-second wall-clock bound. In CI this will flake under load or in resource-constrained containers. Consider tagging it .serialized or adding a timeout annotation, and documenting it as a known flake risk.


Minor / CI

11. Swift 6.0 dropped from Ubuntu matrix without explanation

The CI matrix change removes {"version":"6.0"} from ubuntu-swift with no comment. If this is intentional (min Swift version raised) it should be called out in the PR description and ideally reflected in Package.swift's swift-tools-version or a changelog entry.


Summary

Priority Item File
Bug Force-unwrap in task group Task+Timeout.swift:54
Bug FileManager factory called 3× Runner+Session.swift
Architecture Execution/ in library target Sources/SyntaxKit/Execution/
Correctness FNV-1a silent collision risk ContentHasher.swift
Quality Misleading Linux error message build-skit.sh
Quality Unconditional warning suppression Subprocess.Configuration+Swift.swift
Quality Cache schema version lifecycle OutputCache.swift
Tests Argument validation tests missing Skit+Run.swift
Tests Special-char path escaping untested WrappedSourceTests.swift
Tests Integration test flake risk SkitSubprocessTimeoutTests.swift
CI Swift 6.0 drop unexplained .github/workflows/SyntaxKit.yml

The force-unwrap and triple fileManager() invocations are the two items I'd fix before merge. The rest are improvements but not blockers.

🤖 Generated with Claude Code

leogdion and others added 2 commits June 10, 2026 14:44
regularFiles relied solely on .skipsHiddenFiles, which keys off the
dot-prefix on Unix but the FILE_ATTRIBUTE_HIDDEN attribute on Windows,
so dot-prefixed entries leaked through there. Filter dot-prefixed path
components explicitly for consistent hidden-file semantics on every
platform.

The two RunnerRenderFailure render tests drive real temp-file IO that is
unreliable on WASI (and rendering can't spawn swift there anyway), so
disable them on WASI via a new Platform.isWASI test-trait helper rather
than #if.

Also clear adjacent lint findings surfaced along the way: drop the
group.next()! force-unwrap in Task.timeout for a for-await loop guarded
by assertionFailure, and bind operands separately in the determinism
tests to avoid identical_operands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…exity

Add RunCommandError(renderFileError:) and RunCommandError(renderDirectoryError:input:)
initializers that translate the RunError thrown by Runner.renderFile /
renderDirectory onto the CLI's exit policy, mirroring the existing
init(_:RunnerSetupError). Render.renderSingle/renderBatch now use a single
total catch each instead of an inline switch / multiple pattern-matched
catches, and the per-outcome batch diagnostics move into a
reportOutcomeDiagnostics helper. Clears both Render.swift cyclomatic_complexity
violations; behavior (error->exit mapping and stderr output) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review: skit CLI for v0.0.5

Overall assessment: This is a well-designed, production-quality PR. The architecture is clean, documentation is thorough, and the key engineering decisions (content-addressed caching, atomic writes, timeout watchdog, toolchain stamp) are all well-reasoned and correctly implemented. Comments below are mostly refinements and gap-fills rather than blockers.


Architecture & Design

Strengths:

  • Clean two-layer error model (RunError SDK layer → RunCommandError CLI presentation) makes the engine reusable by build plugins or in-process drivers without pulling in CLI baggage.
  • The SwiftBackend protocol (+ injectable fileManager / environmentProvider) means every non-trivial behaviour is testable without spawning real processes. Good discipline.
  • Atomic cache store via staging dir + rename handles concurrent writers correctly. The race-tolerant pattern in OutputCache.store() (loser's moveItem() fails silently if destination already exists) is the right approach.
  • defer { try? fileManager().removeItem(at: tmpDir) } in Runner.processFile prevents temp-dir leaks under every exit path.

Issues & Suggestions

🔴 Medium — Cache eviction not implemented

OutputCache entries accumulate forever with no TTL or eviction policy. On a machine that renders many inputs across Swift toolchain upgrades, the cache will grow unboundedly. Entries keyed on old toolchain versions are unreachable after swift --version changes but never cleaned up.

Suggestion: Add a --clear-cache flag to skit run, or at minimum document the cache path prominently so users know how to purge manually. A simple LRU sweep (remove entries with atime older than N days) could also be added as a follow-up.

🔴 Medium — WrappedSource path escaping is untested

WrappedSource.swift lines 63–64 escape \\\ and "\" in the #sourceLocation path. This is correct logic but there are zero tests exercising it. A path like /Users/leo/project/my"app/dsl.swift would previously have produced syntactically invalid wrapped source.

Suggested test:

let wrapped = try WrappedSource(source: "Struct(\"Foo\") {}", inputURL: URL(fileURLWithPath: "/tmp/my\"file.swift"))
#expect(wrapped.source.contains(#"file: "/tmp/my\"file.swift""#))

🟡 Medium — Missing tests for RunError.invalidInput and RunError.unexpected

RunnerRenderFailureTests only validates the .renderFailed path. The .invalidInput case (non-.swift extension, unreadable file, etc.) and .unexpected wrapper are never exercised. Add at least one test per missing case to close the gap.

🟡 Medium — Task.timeout has no direct unit tests

Task+Timeout.swift is tested only indirectly through the subprocess integration test. Edge cases aren't covered:

  • Operation completes before timeout fires (returns non-nil).
  • Operation throws — should the error propagate or be swallowed?
  • Timeout fires — returns nil (current contract), task group cancellation confirmed.

These are cheap to write and protect against accidental breakage during future refactors.

🟡 Medium — RunnerRenderFailureTests tests only failure paths

All three tests stub the backend to always fail. The success path (exit code 0, stdout content returned, cache populated) is not covered by unit tests. Consider adding one test that stubs a successful render outcome.

🟡 Medium — Build dir glob may match multiple targets

In build-skit.sh line 84:

BUILD_DIR="$(ls -d .build/*-apple-macosx*/"${CONFIG}" 2>/dev/null | head -1)"

On a developer machine with both a device and a simulator build present, this silently picks the first match. swift build --show-bin-path is the canonical way to locate the output directory and would be more robust:

BUILD_DIR="$(swift build --product skit --configuration "${CONFIG}" --show-bin-path 2>/dev/null)"

🟡 Low — OutputCacheTests silently discards write errors in test setup

OutputCacheTests.swift line 45 uses try? Data(...).write(to:) in a non-test helper, silently discarding write errors. A test that fails to set up its fixture will then report a spurious cache miss rather than a setup failure. Change to try data.write(to:) so failures surface clearly.

🟡 Low — No cache eviction on toolchain mismatch

When a toolchain mismatch is detected at startup (--no-toolchain-check is not set), skit run refuses to spawn but the cache still contains entries keyed to the old toolchain version. These will never be read again but also never cleaned. Minor, but worth noting for the follow-up #157 work.

🟢 Low — Commented-out strict concurrency checks in Package.swift

Lines 74–87 of Package.swift have StrictConcurrency and related settings commented out. If these are in-progress work, they're fine to leave. If they were enabling stricter data-race checking that was too noisy to ship, a short comment explaining why would help future contributors.


Caching & Performance

The warm-hit path (≈0.14s per the PR description) and the content-addressed key (input bytes + toolchain version + lib stamp + sorted env) look correct. One thing worth confirming: FileManager.libStamp(libPath:) is referenced in OutputCache.key(forInput:libPath:) but is defined in an extension. Make sure it returns a stable, deterministic string — if it returns modification time or inode, cache keys will invalidate unnecessarily on re-installs that produce bitwise-identical binaries.


Security

No injection risks found. Spawning uses structured APIs (SwiftBackend.runSwift()), not shell string concatenation. Timeout overflow is guarded at UInt64.max / 1_000_000_000. Temp directories are UUID-tagged to prevent collisions. Nothing concerning.


Minor Nits

  • WrappedSource.swift line 102: importDecl.description.trimmingCharacters(in:) — harmless, SwiftSyntax descriptions are stable, but a "\(importDecl)" string interpolation would be more idiomatic.
  • SubprocessBackend.swift: The 4 KiB cap on captureSwiftVersion() output is undocumented in user-facing error messages. If swift --version ever exceeds that (unlikely but possible in custom toolchains with long output), the truncated string won't match the stamp. Worth a comment.
  • The errors_async example is missing from the sweep loop in the PR description test plan — looks like a typo; 10 examples are listed but errors_async appears in the examples directory.

Summary

Category Rating
Architecture Excellent
Error handling Excellent
Concurrency safety Excellent
Test coverage Good (gaps noted above)
Documentation Excellent
Security No concerns
Build scripts Good

The deferred items (#157 auto-rebuild, #158 if-in-Group crash) are correctly scoped out. The medium-priority items above are worth addressing before or shortly after merge, but none are blockers for v0.0.5. Good work.

🤖 Generated with Claude Code

leogdion and others added 2 commits June 10, 2026 15:32
The public Runner render API no longer reads user inputs or writes user
outputs. `renderFile(input:)` becomes `render(source:originalPath:)` and
`renderDirectory(inputDir:outputDir:)` becomes
`render(sources: [RenderInput]) -> [FileOutcome]`, where FileOutcome now
carries the rendered `stdout`. The internal temp-wrapper write that swiftc
compiles stays in the SDK (purity is at the API boundary, not zero IO).

The reusable filesystem primitives stay as public SDK utilities the CLI
composes — `FileManager.collectInputs`, `URL.rerooted`, `FileManager.writeData`
are now public; the output-mirroring orchestration (`FileManager.writeOutput`,
`RenderTaskResult.writeOutput`/`OutputDestination`) is deleted. skit's
Render/Render+Batch now own walking, reading, and writing: collect inputs →
read into RenderInput → render in-memory → mirror each stdout via rerooting,
folding read/write failures into per-file outcomes so one bad input doesn't
abort the batch.

RunnerRenderFailureTests now drives in-memory source (no temp input file);
adds RunnerBatchRenderTests covering empty/all-success/isolated-failure batch
semantics with a stub backend. Verified end-to-end: single-file render to
stdout, directory render mirrors a nested tree (2/2 succeeded, exit 0), and a
bad input is diagnosed + skipped while peers are written (1/2 succeeded,
exit 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
originalPath is only a diagnostic label (#sourceLocation + stderr
path-rewriting), so default it to nil and resolve the no-path case to a
synthetic "source.swift" label in processFile. Lets a developer render a bare
string of SyntaxKit DSL without inventing a path. RunnerRenderFailureTests now
exercises the no-originalPath call. Follow-up #164 tracks revisiting the
synthetic-label choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review — PR #156: skit: SyntaxKit codegen CLI for v0.0.5

Thanks for the detailed PR description and docs — Docs/skit.md made understanding the architecture easy. This is a significant and well-thought-out feature. Below are observations across correctness, architecture, security, and test coverage.


Overview

This PR unifies the old stdin→JSON skit with a new DSL→Swift renderer into one binary (run / parse subcommands, run default). The wrap-and-spawn pipeline, output cache, toolchain stamp/check, and timeout watchdog are all solid ideas, clearly documented, and thoughtfully scoped.


Architecture

Sources/SyntaxKit/Execution/ belongs in a separate target or the skit target itself.

The most significant structural concern: the entire execution engine (Runner, OutputCache, CLIError, FileOutcome, EnvironmentProvider, ContentHasher, CollectInputsError, etc.) lives in the main SyntaxKit library target and is public. This means:

  • Users who depend on SyntaxKit for code generation will now transitively see and pay for CLI infrastructure types that are irrelevant to them.
  • The library's public API surface grows substantially with types that have no stable library use case.
  • CLIError, RunnerSetupError, OutputCache, CollectInputsError, and FileOutcome are inherently CLI concepts.

Consider moving Sources/SyntaxKit/Execution/ to either:

  1. A new SyntaxKitExecution library target (depended on by skit only), or
  2. Directly into Sources/skit/, since nothing outside the CLI currently uses it.

Bugs / Correctness

.vscode/launch.json adds stale skitrun launch configs (lines 80–101).
The diff adds launch configurations for a skitrun target that this very PR removes in favour of the unified skit binary. These entries will silently fail (the target no longer exists). Should reference skit instead.

Subprocess.Configuration.runSwift docstring says "Uses swift interpreter" but the implementation compiles with swiftc then runs the result (two separate subprocesses).
The comment at the top of the function (Renders the invocation by compiling...) is correct, but the broader runSwift(for:) doc inherited from the SwiftBackend protocol says "spawn swift", which is now inaccurate for this conformer. Update the docstring to describe the compile-then-run pattern explicitly.

ContentHasher.finalize() uses String(state, radix: 16) without accounting for state == 0.
When state is zero, String(0, radix: 16) returns "0", giving a 1-char string. The zero-padding logic on the next line (max(0, 16 - hex.count)) handles this correctly, so the output is always 16 chars. This is fine — just confirming the edge case is handled.

Android API level 36 dropped from CI matrix without explanation.
android-api-level: [33, 34, 35, 36] becomes [33, 34, 35]. If this is intentional (e.g. an infra availability issue), a comment in the workflow would clarify it.


API Surface

TupleAssignment is promoted from internal to public with no mention in the PR.
TupleAssignment, TupleAssignment.async(), TupleAssignment.throwing(), TupleAssignment.asyncSet(), and the SwiftSyntax re-export (public import SwiftSyntax in TupleAssignment.swift) are all new public API. This is semver-relevant for a library — was this intentional for v0.0.5?

extension [String] { static let defaultPathExtensions } in DocumentationHarness/Validator.swift is surprising.
The new pattern adds a static property to [String] just to avoid a file-scope global. A private enum namespace (like SyntaxKitCacheRootConstants used elsewhere in this PR) would be less surprising for a reader who encounters .defaultPathExtensions on an array literal. The fix is a one-liner.


Package.swift

import Foundation in Package.swift for ProcessInfo.processInfo.environment.
This is valid and works, but it's unusual enough to trip up contributors. The comment above it (// The SyntaxKit library product is normally built with automatic (static) linkage...) is helpful. Consider linking to the SPM manifest documentation or adding a brief note that import Foundation in Package.swift is explicitly supported.

SYNTAXKIT_DYNAMIC_LIB=1 pattern is ergonomic but bypasses explicit manifest control.
Configuring the library product type via an environment variable at build time is non-standard and invisible to tooling (e.g., SPM's resolved dependency graph won't show the type change). An explicit second .library(name: "SyntaxKitDynamic", type: .dynamic, targets: ["SyntaxKit"]) product would be more discoverable and wouldn't need the ProcessInfo dance. Acknowledging this is a v0.0.5-only concern.


Performance / Cache

Cache key doesn't mix in helper contents (by design, per the PR), but the --helpers flag path would silently share keys.
If the --helpers flag is added later (tracked as a follow-up), the cache key computation in OutputCache.key(forInput:libPath:) would need to include the helpers hash. The current design is fine for v0.0.5 (no helpers), but a // MARK: deferred: comment at the key derivation site (pointing to the deferred helpers work) would prevent a future contributor from caching stale outputs.

ContentHasher uses non-cryptographic FNV-1a by design, which is correct and well-motivated.
The documented rationale (determinism across processes/platforms, no security requirement) is sound. No action needed.


Security

The spawned swift / swiftc runs user code unsandboxed (acknowledged in Docs/skit.md).
This is consistent with swift Input.swift by hand and is documented under "What's deferred". No action needed beyond the existing documentation.

Cache keys are not adversarially secure (also acknowledged).
FNV-1a with a fixed offset basis means a crafted input could produce a cache collision. Since the threat model is "you ran your own code", this is fine.

Scripts/build-skit.sh uses ls -d .build/*-apple-macosx*/"${CONFIG}" to locate the build dir.
This glob is fragile on unusual machine configurations (e.g., a non-standard host triple or cross-compilation). It fails silently if multiple triples match (head -1 picks one arbitrarily). A more robust approach would be swift build --show-bin-path to get the canonical output path.


Test Coverage

The test suite is good. Specific observations:

Good:

  • WrappedSourceTests directly tests the wrapping/#sourceLocation logic — the most correctness-critical piece of the pipeline.
  • OutputCacheTests uses injected FileManager and EnvironmentProvider — the abstraction is paying off.
  • SkitSubprocessTimeoutTests is an excellent regression test for the swift-subprocess #256 grandchild-FD hang.
  • ToolchainCheckResultTests covers the three-way match/mismatch/missing-stamp logic.

Coverage gaps:

  • No test for Bundle+ResolveLibPath.swift's adjacent (<exec-dir>/lib) and Homebrew (<exec-dir>/../lib/skit) fallback paths.
  • Runner.processFile's replacingOccurrences(of:with:) stderr rewrite is untested. A test fixture with a synthetic wrapped-path prefix in stderr would catch regressions here.
  • Runner+Directory.swift (batch concurrency, FileOutcome.failureCount, partial-batch failure isolation) has a test in RunnerBatchRenderTests, but there's no test for the case where all inputs fail (ensuring the batch doesn't short-circuit before collecting all outcomes).

Minor / Style

  • The // MARK: - Library Linkage comment above the syntaxKitLibraryType global in Package.swift is good context. The swiftlint:disable:next is a consequence of the project's "no globals" rule — worth noting in the review that this is a deliberate exception.
  • Sources/skit/Skit+Run.swift:buildReleaseScriptPath = "Scripts/build-skit.sh" is a hardcoded relative path shown in the error message. It assumes the user's CWD is the repo root, which is only true if they ran skit from the repo. For a shipped binary this will be wrong. Consider removing the path or computing it relative to the binary's location.
  • Platform.isWASI is computed as a lazy static closure rather than a simple #if. On WASI there's no concurrency runtime anyway, so internal static let isWASI: Bool = { #if os(WASI) … }() is fine, but a direct #if static let (like #if os(WASI) static let isWASI = true #else static let isWASI = false #endif) is slightly more idiomatic in Swift 6.

Summary

This is well-engineered and the documentation (especially Docs/skit.md) is excellent. The primary concerns worth addressing before merging:

  1. Sources/SyntaxKit/Execution/ in the wrong target — inflates the library's public API with CLI-only types.
  2. Stale .vscode/launch.json skitrun entries — will confuse IDE users.
  3. Scripts/build-skit.sh glob for build dir — use swift build --show-bin-path instead.
  4. TupleAssignment public promotion — confirm this is intentional for v0.0.5.

The deferred items (#157 auto-rebuild, #158 if-in-Group crash) are appropriately scoped and well-documented.

🤖 Generated with Claude Code

@leogdion leogdion merged commit d311d6e into v0.0.5 Jun 10, 2026
42 checks passed
@leogdion leogdion deleted the research/swift-manifest-codegen branch June 10, 2026 23:10
leogdion added a commit that referenced this pull request Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant