diff --git a/scripts/__tests__/strip-swift-comments.test.ts b/scripts/__tests__/strip-swift-comments.test.ts new file mode 100644 index 000000000..dbbcad8dc --- /dev/null +++ b/scripts/__tests__/strip-swift-comments.test.ts @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { stripSwiftComments } from '../strip-swift-comments.mjs'; + +function strip(source: string, filePath = 'Fixture.swift'): string { + return stripSwiftComments(source, filePath).contents; +} + +function swift(...lines: string[]): string { + return `${lines.join('\n')}\n`; +} + +test('drops comment-only lines and trailing comments, keeping blank lines', () => { + const source = swift( + '// header note', + '/// doc comment', + '', + 'let answer = 42 // why', + '', + ' // indented note', + 'let next = answer', + ); + + assert.equal(strip(source), swift('', 'let answer = 42', '', 'let next = answer')); +}); + +test('a file with no comments is returned byte for byte', () => { + const source = swift('import XCTest', '', 'let trailing = " spaces " ', 'let last = 1'); + + assert.equal(strip(source), source); + assert.equal(stripSwiftComments(source).removedComments, 0); +}); + +test('leaves // inside string literals alone', () => { + const source = swift( + 'let url = "https://example.com/path" // real comment', + 'let format = "%@ // %@"', + String.raw`let escaped = "quote \" then // not a comment"`, + 'let empty = "" // after an empty literal', + ); + + assert.equal( + strip(source), + swift( + 'let url = "https://example.com/path"', + 'let format = "%@ // %@"', + String.raw`let escaped = "quote \" then // not a comment"`, + 'let empty = ""', + ), + ); +}); + +test('leaves /* inside string literals alone', () => { + const source = swift('let glob = "/* not a comment */"', 'let real = 1 /* is a comment */'); + + assert.equal(strip(source), swift('let glob = "/* not a comment */"', 'let real = 1')); +}); + +test('preserves raw string literals and their comment-shaped contents', () => { + const source = swift( + 'let json = #"{"href":"https://example.com//x"}"# // trailing', + 'let pounded = ##"a "# b // c"##', + String.raw`let literalEscape = #"a \(notInterpolated) // still text"#`, + ); + + assert.equal( + strip(source), + swift( + 'let json = #"{"href":"https://example.com//x"}"#', + 'let pounded = ##"a "# b // c"##', + String.raw`let literalEscape = #"a \(notInterpolated) // still text"#`, + ), + ); +}); + +test('preserves multi-line string literals verbatim, blank and comment-shaped lines included', () => { + const source = swift( + 'let usage = """', + ' // not a comment', + '', + ' /* also not a comment */', + ' trailing spaces kept ', + ' """ // trailing comment on the closing line', + 'let after = 1', + ); + + assert.equal( + strip(source), + swift( + 'let usage = """', + ' // not a comment', + '', + ' /* also not a comment */', + ' trailing spaces kept ', + ' """', + 'let after = 1', + ), + ); +}); + +test('preserves a multi-line raw literal and its line continuations', () => { + const source = swift( + 'let raw = #"""', + String.raw` keep "# and // and \(this)`, + ' """#', + 'let plain = """', + ' joined \\', + ' lines', + ' """', + ); + + assert.equal(strip(source), source); +}); + +// Every Swift snippet in the regex-literal tests below parses clean under `xcrun swiftc -parse` +// (Swift 6.2), and so does what the scanner leaves of it. `#/foo//bar/#` has no comment in it at +// all: before the scanner knew the delimiter, it shipped `let pattern = #/foo`. +test('preserves extended regex literals whose contents are comment-shaped', () => { + const source = swift( + 'let pattern = #/foo//bar/#', + 'let pounded = ##/a//b/#c/##', + 'let blockish = #/x/*y/#', + String.raw`let escaped = #/a\/#b/#`, + ); + + assert.equal(strip(source), source); + assert.equal(stripSwiftComments(source).removedComments, 0); +}); + +test('strips a real comment that trails an extended regex literal', () => { + const source = swift( + 'let trailing = #/a//b/# // trailing', + 'let blocked = ##/c/*d*/## /* block */', + 'let next = 1', + ); + + assert.equal( + strip(source), + swift('let trailing = #/a//b/#', 'let blocked = ##/c/*d*/##', 'let next = 1'), + ); + assert.equal(stripSwiftComments(source).removedComments, 2); +}); + +test('preserves a multi-line extended regex literal verbatim, comment-shaped lines included', () => { + const source = swift( + 'let multi = #/', + ' foo//bar', + ' /*e*/', + String.raw` a\/#b`, + '', + ' /#', + 'let after = 1 // note', + ); + + assert.equal( + strip(source), + swift( + 'let multi = #/', + ' foo//bar', + ' /*e*/', + String.raw` a\/#b`, + '', + ' /#', + 'let after = 1', + ), + ); +}); + +test('reads an unspaced division as an operator, not as a bare regex literal', () => { + const source = swift( + '#!/usr/bin/env swift', + 'let half = width/2 // note', + 'let ratio = Double(3)/Double(4)', + 'let spaced = width / 2 // also fine', + 'let divide: (Int, Int) -> Int = (/)', + ); + + assert.equal( + strip(source), + swift( + '#!/usr/bin/env swift', + 'let half = width/2', + 'let ratio = Double(3)/Double(4)', + 'let spaced = width / 2', + 'let divide: (Int, Int) -> Int = (/)', + ), + ); +}); + +test('reads interpolation segments as code without losing their nested literals', () => { + const source = swift( + String.raw`let line = "prefix \(makeURL("https://example.com")) suffix" // trailing`, + String.raw`let nested = "\(count(of: (a, b))) items"`, + String.raw`let rawInterpolated = #"\#(value) // text"#`, + ); + + assert.equal( + strip(source), + swift( + String.raw`let line = "prefix \(makeURL("https://example.com")) suffix"`, + String.raw`let nested = "\(count(of: (a, b))) items"`, + String.raw`let rawInterpolated = #"\#(value) // text"#`, + ), + ); +}); + +test('removes nested block comments as one comment', () => { + const source = swift( + '/* outer', + ' /* inner // with a line comment */', + ' still outer */', + 'let after = 1', + ); + + const result = stripSwiftComments(source); + assert.equal(result.contents, swift('let after = 1')); + assert.equal(result.removedComments, 1); +}); + +test('keeps flanking tokens apart when a block comment is removed', () => { + assert.equal(strip('let sum = a/*gap*/+b\n'), 'let sum = a +b\n'); + assert.equal(strip('call(/*label*/value)\n'), 'call( value)\n'); +}); + +test('keeps statements on separate lines when a block comment spans lines', () => { + const source = swift('let a = 1 /* spans', 'the newline */ let b = 2'); + + assert.equal(strip(source), swift('let a = 1', ' let b = 2')); +}); + +test('preserves conditional compilation directives and strips their trailing comments', () => { + const source = swift( + '#if AGENT_DEVICE_RUNNER_UNIT_TESTS // only in unit-test builds', + ' #if os(iOS)', + ' let platform = "ios"', + ' #else', + ' // macOS has no equivalent', + ' let platform = "macos"', + ' #endif', + '#endif', + '#if canImport(UIKit)', + 'import UIKit', + '#endif', + ); + + assert.equal( + strip(source), + swift( + '#if AGENT_DEVICE_RUNNER_UNIT_TESTS', + ' #if os(iOS)', + ' let platform = "ios"', + ' #else', + ' let platform = "macos"', + ' #endif', + '#endif', + '#if canImport(UIKit)', + 'import UIKit', + '#endif', + ), + ); +}); + +test('does not mistake pound directives or a shebang for a raw literal', () => { + const source = swift( + '#!/usr/bin/env swift', + 'if #available(iOS 15, *) {', + ' print(#function) // note', + '}', + ); + + assert.equal( + strip(source), + swift('#!/usr/bin/env swift', 'if #available(iOS 15, *) {', ' print(#function)', '}'), + ); +}); + +test('drops a trailing comment on a final line without a newline', () => { + assert.equal(strip('let a = 1 // note'), 'let a = 1'); + assert.equal(strip('// whole file is a comment'), ''); + assert.equal(strip('let a = 1'), 'let a = 1'); +}); + +test('counts every removed comment', () => { + const result = stripSwiftComments(swift('// one', 'let a = 1 // two', 'let b = /* three */ 2')); + + assert.equal(result.removedComments, 3); +}); + +test('throws on an unterminated block comment rather than shipping the rest of the file', () => { + assert.throws( + () => strip(swift('let a = 1', '/* never closed', 'let b = 2')), + /Unterminated block comment in Fixture\.swift:2/, + ); +}); + +test('throws on an unterminated string literal rather than guessing where it ends', () => { + assert.throws( + () => strip(swift('let a = 1', 'let broken = "no closing quote', 'let b = 2 // note')), + /Unterminated string literal in Fixture\.swift:2/, + ); +}); + +test('throws when an interpolation segment never closes', () => { + assert.throws( + () => strip(swift(String.raw`let a = "\(value`)), + /Unterminated interpolation in Fixture\.swift/, + ); +}); + +// A bare `/…/` is the one construct a scanner cannot resolve: Swift lexes a comment, a division +// and a regex literal from the same `/`, and only the parse tells them apart. Packaging fails +// rather than rewrite bytes it cannot prove are code. +test('throws on a bare regex literal instead of reading its contents as a comment', () => { + assert.throws( + () => strip(swift('let a = 1', 'let pattern = /foo//bar/')), + /Ambiguous bare regex literal or division in Fixture\.swift:2/, + ); + assert.throws( + () => strip(swift('func f() -> Regex {', String.raw` return /x\/y/`, '}')), + /Ambiguous bare regex literal or division in Fixture\.swift:2/, + ); +}); + +test('throws on an unterminated extended regex literal', () => { + assert.throws( + () => strip(swift('let a = 1', 'let pattern = #/no closing', 'let b = 2 // note')), + /Unterminated regex literal in Fixture\.swift:2/, + ); + assert.throws( + () => strip(swift('let a = 1', 'let pattern = #/', ' never closed')), + /Unterminated regex literal in Fixture\.swift \(started at line 2\)/, + ); +}); + +test('throws when a multi-line regex literal closes mid-line', () => { + assert.throws( + () => strip(swift('let multi = #/', ' mid/#line stays', ' /#')), + /Multi-line regex literal in Fixture\.swift:1 closes mid-line at line 2/, + ); +}); diff --git a/scripts/package-apple-runner-source.mjs b/scripts/package-apple-runner-source.mjs index 5df0c7318..4cdbd02a2 100644 --- a/scripts/package-apple-runner-source.mjs +++ b/scripts/package-apple-runner-source.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { stripSwiftComments } from './strip-swift-comments.mjs'; const UNIT_TEST_CONDITION = 'AGENT_DEVICE_RUNNER_UNIT_TESTS'; const SOURCE_DIR = path.join('apple', 'runner'); @@ -52,6 +53,8 @@ function packageAppleRunnerSource(options = {}) { copiedFiles: 0, strippedFiles: 0, strippedBlocks: 0, + strippedComments: 0, + strippedCommentBytes: 0, }; processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary); @@ -210,15 +213,22 @@ function validateFile(sourcePath, relativePath, summary, options) { return validateSwiftFile(sourcePath, relativePath, summary); } +// The unit-test strip runs first and stays line-based, so which blocks it removes does not depend +// on comment removal. The comment scanner then reads Swift that is already in its shipped shape, +// and the shipped-test-method guard sees exactly the text the package will contain. function validateSwiftFile(sourcePath, relativePath, summary) { const source = fs.readFileSync(sourcePath, 'utf8'); const stripped = stripRunnerUnitTestBlocks(source, sourcePath); - assertNoShippedTestMethods(stripped.contents, relativePath); + const withoutComments = stripSwiftComments(stripped.contents, sourcePath); + assertNoShippedTestMethods(withoutComments.contents, relativePath); if (stripped.strippedBlocks > 0) { summary.strippedFiles += 1; summary.strippedBlocks += stripped.strippedBlocks; } - return stripped; + summary.strippedComments += withoutComments.removedComments; + summary.strippedCommentBytes += + Buffer.byteLength(stripped.contents) - Buffer.byteLength(withoutComments.contents); + return { contents: withoutComments.contents, strippedBlocks: stripped.strippedBlocks }; } function assertNoShippedTestMethods(strippedContents, relativePath) { @@ -323,7 +333,8 @@ if (isMainModule()) { const relativeOutput = path.relative(path.resolve(options.root), summary.outputRoot); console.log( `Packaged Apple runner source at ${relativeOutput} ` + - `(${summary.copiedFiles} files, stripped ${summary.strippedBlocks} unit-test blocks).`, + `(${summary.copiedFiles} files, stripped ${summary.strippedBlocks} unit-test blocks ` + + `and ${summary.strippedComments} comments worth ${summary.strippedCommentBytes} bytes).`, ); } } diff --git a/scripts/strip-swift-comments.mjs b/scripts/strip-swift-comments.mjs new file mode 100644 index 000000000..f2a9d9cd1 --- /dev/null +++ b/scripts/strip-swift-comments.mjs @@ -0,0 +1,425 @@ +// Comment removal for the Swift that the npm package ships as source (#2461). `apple/runner/**` +// is copied into `dist/` as-is apart from its unit-test `#if` blocks, so every doc comment and +// every design note is downloaded on every install — 74 kB of the 446 kB measured on v0.21.1. +// +// This is a lexical scanner, not a regex pass, because `//` and `/*` open a comment only in code +// position. String literals may contain either; a raw literal (`#"…"#`) moves its own closing +// delimiter and its interpolation opener with the `#` count, so what counts as an escape changes +// per literal; interpolation segments hold code, including further literals; extended regex +// literals (`#/…/#`) are a second delimiter family that also starts with a `#` run; and Swift +// block comments nest. A regex sees none of that, and the failure mode is a package that does not +// compile on a user's machine. Anything the scanner cannot account for therefore throws here, at +// packaging time, rather than shipping. +// +// Bare `/…/` regex literals are the one construct no scanner can resolve: the same `/` opens a +// comment, divides, and starts a regex literal, and which it is depends on the parse. Where one +// could start, packaging fails instead of rewriting bytes the scanner cannot prove are code. + +/** A `"`/`"""` literal opener with its optional raw `#` delimiters. */ +const STRING_OPENER = /(#*)("""|")/y; +/** An extended regex literal opener: a `#` run, then `/`. The `#` count sets the terminator. */ +const EXTENDED_REGEX_OPENER = /(#+)\//y; +/** How much emitted output `isExpressionPosition` may look back over. */ +const CODE_TAIL_LENGTH = 128; +/** + * Swift bars a bare regex literal from opening on one of these, so a `/` in front of one — `a / b`, + * `reduce(/)` — is an operator whatever the parse says. + */ +const NON_REGEX_START = new Set([' ', '\t', '\n', ')']); +/** A `/` directly after one of these ends an operand, so it divides rather than opening a regex. */ +const OPERAND_END = /[A-Za-z0-9_$)\]`"?!]$/; +/** The identifier a lookbehind ends on, when it ends on one. */ +const TRAILING_IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*$/; +/** + * Keywords a `/` can follow while still being at the start of an expression. Value keywords + * (`self`, `super`, `nil`, `true`, `false`) are operands and so are deliberately absent. + */ +const EXPRESSION_KEYWORDS = new Set([ + 'as', + 'await', + 'borrowing', + 'case', + 'catch', + 'consume', + 'consuming', + 'copy', + 'default', + 'defer', + 'do', + 'each', + 'else', + 'for', + 'guard', + 'if', + 'in', + 'is', + 'let', + 'repeat', + 'return', + 'switch', + 'throw', + 'try', + 'var', + 'where', + 'while', + 'yield', +]); +/** How an unterminated frame is named in the error that refuses to ship the file. */ +const FRAME_DESCRIPTIONS = { + literal: 'string literal', + regex: 'regex literal', + interpolation: 'interpolation', +}; + +/** + * `source` with its comments removed. A line whose only content was a comment disappears; + * pre-existing blank lines, and every byte inside a literal, survive untouched. + * + * @param {string} source Swift source text. + * @param {string} filePath Reported in errors, so an unreadable construct names its file. + * @returns {{ contents: string, removedComments: number }} + */ +export function stripSwiftComments(source, filePath = '') { + const state = { + source, + filePath, + index: 0, + sourceLine: 1, + /** Completed output lines, each still carrying its newline. */ + lines: [], + /** The output line being built. */ + line: '', + /** The tail of everything emitted so far, for the scanner's one lookbehind. */ + codeTail: '', + lineHasComment: false, + /** Literal, regex-literal and interpolation nesting, innermost last. */ + frames: [], + removedComments: 0, + }; + + while (state.index < source.length) { + const frame = state.frames.at(-1); + if (frame?.kind === 'literal') scanStringLiteralCharacter(state, frame); + else if (frame?.kind === 'regex') scanRegexLiteralCharacter(state, frame); + else scanCodeCharacter(state); + } + finishFile(state); + + return { contents: state.lines.join(''), removedComments: state.removedComments }; +} + +/** The literal whose bytes are being copied through verbatim, if the scanner is inside one. */ +function currentLiteral(state) { + const frame = state.frames.at(-1); + return frame !== undefined && (frame.kind === 'literal' || frame.kind === 'regex') + ? frame + : undefined; +} + +/** Appends to the output line, keeping the lookbehind tail in step with it. */ +function emit(state, text) { + state.line += text; + state.codeTail = (state.codeTail + text).slice(-CODE_TAIL_LENGTH); +} + +function scanCodeCharacter(state) { + const char = state.source[state.index]; + if (char === '/' && consumeSlash(state)) { + return; + } + if (char === '\n') { + state.index += 1; + endLine(state); + return; + } + if ((char === '"' || char === '#') && pushLiteral(state)) { + return; + } + trackInterpolationParenthesis(state, char); + emit(state, char); + state.index += 1; +} + +/** + * Resolves the `/` at the cursor: it opens a comment, or it is an operator, or — where the scanner + * cannot prove which — it fails the file. `false` leaves the `/` to be emitted as an operator. + */ +function consumeSlash(state) { + const next = state.source[state.index + 1]; + if (next === '/') { + consumeLineComment(state); + return true; + } + if (next === '*') { + consumeBlockComment(state); + return true; + } + rejectAmbiguousBareRegexLiteral(state, next); + return false; +} + +/** + * Opens a literal frame when the `"`/`#` at the cursor really starts one. `#` also leads every + * Swift directive (`#if`, `#available`, `#!` in the recording scripts), so only a `#`-run followed + * by a quote is a raw string literal, and only a `#`-run followed by `/` is an extended regex + * literal. + */ +function pushLiteral(state) { + return pushStringLiteral(state) || pushExtendedRegexLiteral(state); +} + +function pushStringLiteral(state) { + STRING_OPENER.lastIndex = state.index; + const opener = STRING_OPENER.exec(state.source); + if (opener === null) return false; + + const pounds = '#'.repeat(opener[1].length); + state.frames.push({ + kind: 'literal', + multiline: opener[2] === '"""', + terminator: `${opener[2]}${pounds}`, + escape: `\\${pounds}`, + startLine: state.sourceLine, + }); + emit(state, opener[0]); + state.index += opener[0].length; + return true; +} + +/** + * Opens an extended regex literal (`#/…/#`, `##/…/##`). Its contents are regex syntax, where `//` + * and `/*` are ordinary characters, so the frame exists only to keep the comment scanner out. A + * newline straight after the opener selects Swift's multi-line form, whose closing delimiter has + * to stand on its own line — everywhere else `/` plus the `#` run is regex content. + */ +function pushExtendedRegexLiteral(state) { + EXTENDED_REGEX_OPENER.lastIndex = state.index; + const opener = EXTENDED_REGEX_OPENER.exec(state.source); + if (opener === null) return false; + + state.frames.push({ + kind: 'regex', + multiline: state.source[state.index + opener[0].length] === '\n', + terminator: `/${opener[1]}`, + startLine: state.sourceLine, + }); + emit(state, opener[0]); + state.index += opener[0].length; + return true; +} + +function scanRegexLiteralCharacter(state, regex) { + if (state.source.startsWith(regex.terminator, state.index)) { + closeRegexLiteral(state, regex); + return; + } + const char = state.source[state.index]; + // A regex escape is copied as a pair, so `\/` never reads as the closing delimiter. + if (char === '\\' && isEscapableRegexCharacter(state.source[state.index + 1])) { + emit(state, state.source.slice(state.index, state.index + 2)); + state.index += 2; + return; + } + if (char === '\n') { + if (!regex.multiline) { + throw new Error(`Unterminated regex literal in ${state.filePath}:${regex.startLine}`); + } + state.index += 1; + endLine(state); + return; + } + emit(state, char); + state.index += 1; +} + +/** + * Closes the literal at its delimiter. Swift closes a multi-line regex literal at the first + * unescaped `/` plus its `#` run too, but then requires that delimiter to start its own line — + * so a mid-line one is a file that does not compile either way, and stripping it is refused + * rather than guessed at. + */ +function closeRegexLiteral(state, regex) { + if (regex.multiline && state.line.trim() !== '') { + throw new Error( + `Multi-line regex literal in ${state.filePath}:${regex.startLine} closes mid-line at ` + + `line ${state.sourceLine}; its ${regex.terminator} delimiter must start its own line`, + ); + } + emit(state, regex.terminator); + state.index += regex.terminator.length; + state.frames.pop(); +} + +function isEscapableRegexCharacter(char) { + return char !== undefined && char !== '\n'; +} + +/** + * Refuses a `/` that could open a bare regex literal. Swift lexes `/…/`, a division and a comment + * from the same character, and only the parse separates them, so rewriting the bytes after it + * would be a guess: `let p = /foo//bar/` has no comment in it at all. Packaging fails instead. + */ +function rejectAmbiguousBareRegexLiteral(state, next) { + if (next === undefined || NON_REGEX_START.has(next)) return; + if (!isExpressionPosition(state)) return; + throw new Error( + `Ambiguous bare regex literal or division in ${state.filePath}:${state.sourceLine}; ` + + 'write the pattern as an extended regex literal (#/…/#), or space the operator (a / b), ' + + 'so packaging can tell them apart', + ); +} + +/** + * Whether an expression could start at the cursor, which is where — and only where — Swift reads + * a `/` as a bare regex literal. Anywhere else the `/` follows an operand and divides it. + */ +function isExpressionPosition(state) { + const tail = state.codeTail.replace(/\s+$/u, ''); + if (tail === '') return true; + if (!OPERAND_END.test(tail)) return true; + // `return /x/` ends on an identifier yet still starts an expression. + const identifier = TRAILING_IDENTIFIER.exec(tail)?.[0]; + return identifier !== undefined && EXPRESSION_KEYWORDS.has(identifier); +} + +/** Closes an interpolation segment at its matching `)`, so its own parentheses do not end it. */ +function trackInterpolationParenthesis(state, char) { + const frame = state.frames.at(-1); + if (frame === undefined || frame.kind !== 'interpolation') return; + if (char === '(') frame.depth += 1; + if (char !== ')') return; + frame.depth -= 1; + if (frame.depth === 0) state.frames.pop(); +} + +function scanStringLiteralCharacter(state, literal) { + if (state.source.startsWith(literal.terminator, state.index)) { + emit(state, literal.terminator); + state.index += literal.terminator.length; + state.frames.pop(); + return; + } + if (state.source.startsWith(literal.escape, state.index) && consumeEscape(state, literal)) { + return; + } + const char = state.source[state.index]; + if (char === '\n') { + consumeLiteralNewline(state, literal); + return; + } + emit(state, char); + state.index += 1; +} + +/** + * Consumes one escape sequence and, for `\(`, enters its interpolation. Copying the escaped + * character verbatim is what keeps `\"` and `\\` from being read as a delimiter. + */ +function consumeEscape(state, literal) { + const escapedIndex = state.index + literal.escape.length; + const char = state.source[escapedIndex]; + if (char === undefined) return false; + + if (char === '\n') { + // A multiline literal's line continuation: the newline belongs to the literal, but the + // output still breaks its line here so line accounting stays on the source. + emit(state, literal.escape); + state.index = escapedIndex + 1; + endLine(state); + return true; + } + + emit(state, state.source.slice(state.index, escapedIndex + 1)); + state.index = escapedIndex + 1; + if (char === '(') state.frames.push({ kind: 'interpolation', depth: 1 }); + return true; +} + +function consumeLiteralNewline(state, literal) { + if (!literal.multiline) { + throw new Error(`Unterminated string literal in ${state.filePath}:${literal.startLine}`); + } + state.index += 1; + endLine(state); +} + +function consumeLineComment(state) { + while (state.index < state.source.length && state.source[state.index] !== '\n') { + state.index += 1; + } + state.lineHasComment = true; + state.removedComments += 1; +} + +function consumeBlockComment(state) { + const startLine = state.sourceLine; + state.index += 2; + let depth = 1; + while (depth > 0) { + if (state.index >= state.source.length) { + throw new Error(`Unterminated block comment in ${state.filePath}:${startLine}`); + } + depth += consumeBlockCommentCharacter(state); + } + state.lineHasComment = true; + // One space in place of the comment keeps the tokens that flanked it apart: Swift reads + // `a/*x*/b` as `a b`, not as `ab`. + emit(state, ' '); + state.removedComments += 1; +} + +/** The nesting delta for one character of a block comment. */ +function consumeBlockCommentCharacter(state) { + const char = state.source[state.index]; + const next = state.source[state.index + 1]; + if (char === '/' && next === '*') { + state.index += 2; + return 1; + } + if (char === '*' && next === '/') { + state.index += 2; + return -1; + } + state.index += 1; + if (char === '\n') { + // Both the line being closed and the line being opened are inside the comment, and + // `endLine` clears the flag between them. + state.lineHasComment = true; + endLine(state); + state.lineHasComment = true; + } + return 0; +} + +/** + * Commits the line whose newline was just consumed. A line inside a literal is committed + * verbatim: its trailing spaces and its emptiness are string content, not layout. + */ +function endLine(state) { + state.sourceLine += 1; + if (currentLiteral(state) !== undefined || !state.lineHasComment) { + state.lines.push(`${state.line}\n`); + } else if (state.line.trim() !== '') { + state.lines.push(`${state.line.trimEnd()}\n`); + } + state.line = ''; + state.codeTail = (state.codeTail + '\n').slice(-CODE_TAIL_LENGTH); + state.lineHasComment = false; +} + +/** The trailing line of a source that does not end in a newline, plus the balance check. */ +function finishFile(state) { + const unterminated = state.frames.at(-1); + if (unterminated !== undefined) { + throw new Error( + `Unterminated ${FRAME_DESCRIPTIONS[unterminated.kind]} in ${state.filePath} ` + + `(started at line ${unterminated.startLine ?? state.sourceLine})`, + ); + } + if (state.line === '') return; + if (state.lineHasComment) { + if (state.line.trim() !== '') state.lines.push(state.line.trimEnd()); + return; + } + state.lines.push(state.line); +} diff --git a/src/__tests__/apple-runner-package-source.test.ts b/src/__tests__/apple-runner-package-source.test.ts index f2477b3b6..75b520400 100644 --- a/src/__tests__/apple-runner-package-source.test.ts +++ b/src/__tests__/apple-runner-package-source.test.ts @@ -36,6 +36,11 @@ test('package apple runner source strips unit-test blocks without mutating check assert.doesNotMatch(packagedSwift, /unitOnlyHelper/); assert.match(packagedSwift, /runtimeHelper/); assert.match(packagedSwift, /#if os\(macOS\)/); + assert.match(sourceSwift, /Doc comment/); + assert.doesNotMatch(packagedSwift, /Doc comment/); + assert.doesNotMatch(packagedSwift, /Packaged source carries no prose/); + assert.doesNotMatch(packagedSwift, /trailing note/); + assert.match(packagedSwift, /let endpoint = "https:\/\/example\.com\/path"\n/); assert.ok( fs.existsSync( path.join(root, 'dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj'), @@ -186,6 +191,76 @@ test('package apple runner source allows only the runner entrypoint test method' assert.match(rejected.stderr, /testExtraEntrypoint/); }); +test('package apple runner source ships regex literals whole and refuses ambiguous ones', async () => { + const root = mkdtempForTestSync('agent-device-runner-package-regex-'); + onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); + writeFixtureFile(root, 'apple/snapshot-presentation/Package.runner.swift', 'runner package\n'); + const relativePath = + 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Feature.swift'; + + // An extended regex literal's contents are regex syntax, never comments, so they ship whole. + writeFixtureFile( + root, + relativePath, + ['extension RunnerTests {', ' let pattern = #/foo//bar/# // note', '}', ''].join('\n'), + ); + + const allowed = await runCmd(process.execPath, [packageScript, '--root', root, '--quiet']); + assert.equal(allowed.exitCode, 0); + assert.equal( + fs.readFileSync( + path.join( + root, + 'dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Feature.swift', + ), + 'utf8', + ), + 'extension RunnerTests {\n let pattern = #/foo//bar/#\n}\n', + ); + + // A bare `/…/` is a regex literal, a division and a comment opener at once, so packaging fails + // by name and line instead of shipping Swift whose literal it silently truncated. + writeFixtureFile( + root, + relativePath, + ['extension RunnerTests {', ' let pattern = /foo//bar/', '}', ''].join('\n'), + ); + + const rejected = await runCmd(process.execPath, [packageScript, '--root', root, '--quiet'], { + allowFailure: true, + }); + assert.notEqual(rejected.exitCode, 0); + assert.match(rejected.stderr, /Ambiguous bare regex literal or division/); + assert.match(rejected.stderr, /RunnerTests\+Feature\.swift:2/); +}); + +test('package apple runner source judges shipped test methods after comments are removed', async () => { + const root = mkdtempForTestSync('agent-device-runner-package-commented-test-'); + onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); + writeFixtureFile(root, 'apple/snapshot-presentation/Package.runner.swift', 'runner package\n'); + writeFixtureFile( + root, + 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Feature.swift', + ['extension RunnerTests {', ' // func testCommentedOut() {}', '}', ''].join('\n'), + ); + + // The guard asks what the npm package contains, so a method that only exists in prose is not + // a shipped test method — the prose does not reach the package either. + const result = await runCmd(process.execPath, [packageScript, '--root', root, '--quiet']); + + assert.equal(result.exitCode, 0); + assert.equal( + fs.readFileSync( + path.join( + root, + 'dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Feature.swift', + ), + 'utf8', + ), + 'extension RunnerTests {\n}\n', + ); +}); + test('package apple runner source removes legacy dist/apple-runner output before shipping', async () => { const root = mkdtempForTestSync('agent-device-runner-package-legacy-'); onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); @@ -320,8 +395,11 @@ function writeStripFixtureTree(root: string): void { root, 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Feature.swift', [ + '// Packaged source carries no prose.', 'extension RunnerTests {', + ' /// Doc comment.', ' func runtimeHelper() {}', + ' let endpoint = "https://example.com/path" // trailing note', '#if AGENT_DEVICE_RUNNER_UNIT_TESTS', ' func unitOnlyHelper() {', ' #if os(iOS)', diff --git a/vitest.config.ts b/vitest.config.ts index d9c7be863..9df2d1709 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -147,6 +147,10 @@ export default defineConfig({ // Publish preparation spawns only fixture-owned scripts and proves both Android // helper families are rebuilt through the shared release/size-report owner. 'scripts/__tests__/prepare-publish-assets.test.ts', + // The packager's Swift comment scanner: pure string transform, and the only place a + // literal that looks like a comment (a URL, a raw or multi-line literal) is proven + // to survive packaging before the npm package ships unbuildable Swift. + 'scripts/__tests__/strip-swift-comments.test.ts', // Parse-only guard on the checked-in registry entry: the npm package must declare // the fixed mcp subcommand, or registry-format launchers run the bare CLI. 'scripts/__tests__/mcp-metadata.test.ts',