diff --git a/.changeset/lucky-badgers-forage.md b/.changeset/lucky-badgers-forage.md new file mode 100644 index 000000000..9b95af400 --- /dev/null +++ b/.changeset/lucky-badgers-forage.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4807 +--- +**The UI plan gate now recognizes native UI projects** — a SwiftUI, Jetpack Compose, Flutter, or .NET MAUI project never tripped the static frontend-evidence check, so the gate that requires a UI-SPEC before planning a UI phase silently never fired for them. A `.xaml` file, or a `.swift`/`.kt`/`.dart` file importing its ecosystem's UI framework, now counts as evidence; non-UI native packages (a Swift CLI, a plain Kotlin server) stay silent. (#4658) diff --git a/scripts/lib/macos-conformance-tier.generated.cjs b/scripts/lib/macos-conformance-tier.generated.cjs index 51e2ccc83..612a8c0b3 100644 --- a/scripts/lib/macos-conformance-tier.generated.cjs +++ b/scripts/lib/macos-conformance-tier.generated.cjs @@ -32,6 +32,7 @@ module.exports = { "tests/changeset-parse.test.cjs", "tests/check-contract-drift.test.cjs", "tests/check-predicate.test.cjs", + "tests/check-ui-plan-gate.test.cjs", "tests/check-ui-safety-gate.test.cjs", "tests/check-update-config-dir.test.cjs", "tests/chunked-planning-parallel.test.cjs", diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 566c144bb..79dcb81b0 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -569,8 +569,10 @@ function findUiSpecInDir(phaseDir: string): string { * matches the token `dashboard` exactly like the real compound `micro-frontend` * (the boundary rule of #3718 is intentional and untouched). The gate therefore * blocks only when the token match is corroborated by static frontend evidence - * in the repo tree (hasStaticFrontendEvidence: package.json UI-framework dep or - * a component-framework file). This mirrors the sibling post-wave gate + * in the repo tree (hasStaticFrontendEvidence: package.json UI-framework dep, a + * component-framework file, or native UI evidence — a `.xaml` file or a + * `.swift`/`.kt`/`.dart` file carrying its ecosystem's UI import marker, + * #4658). This mirrors the sibling post-wave gate * computeUiSafetyGate, which requires `hasUiFiles` (git diff) before blocking. * matchedToken/matchedLine surface what tripped the sniffer so an operator can * judge the flag in one second instead of reaching for --skip-ui. diff --git a/src/ui-frontend-evidence.cts b/src/ui-frontend-evidence.cts index 693beca7f..45b68fdf4 100644 --- a/src/ui-frontend-evidence.cts +++ b/src/ui-frontend-evidence.cts @@ -22,6 +22,19 @@ * html, ...) are deliberately NOT static evidence: docs sites and * markdown/bash/config repos routinely carry stray `.html`/`.css`, which * is precisely the false-positive class #3312 reports. + * (c) any `*.xaml` file, or a `*.swift` / `*.kt` / `*.dart` file whose + * content carries its ecosystem's UI-framework import marker + * (`import SwiftUI` / `import UIKit`, `androidx.compose`, + * `package:flutter`) (#4658). Native UI projects (SwiftUI, Jetpack + * Compose, Flutter, .NET MAUI) carry neither (a) nor (b), which made the + * gate structurally unreachable for them. Source files match on the + * IMPORT, not the extension alone — the same unambiguity bar that + * justifies (b)'s subset and excludes (css, scss, html): a non-UI Swift + * package (a CLI, a server) imports Foundation, not SwiftUI, and must + * stay silent. `.xaml` is extension-alone for the same reason `.tsx` is — + * the extension itself is unambiguous. Marker matching is case-sensitive + * (imports are case-sensitive in all four ecosystems); extension + * matching is case-insensitive, mirroring `UI_COMPONENT_FILE_RE`. * * All I/O failures degrade to `false` (no evidence) — never throw. */ @@ -32,6 +45,25 @@ import path from 'node:path'; /** Component-framework file extensions — the static-evidence subset of UI_FILE_EXTENSIONS_RE. */ export const UI_COMPONENT_FILE_RE = /\.(tsx|jsx|vue|svelte)$/i; +/** Native UI source files whose CONTENT is scanned for an import marker (#4658). */ +export const NATIVE_UI_SOURCE_RE = /\.(swift|kt|dart)$/i; + +/** Native UI files that are evidence by extension alone — `.xaml`, the `.tsx` analogue. */ +export const NATIVE_UI_XAML_RE = /\.xaml$/i; + +/** + * Per-extension UI-framework import markers for `NATIVE_UI_SOURCE_RE` files + * (#4658). Every marker embeds its ecosystem's import keyword, so a bare + * framework-name mention in prose or a comment is not evidence; the Dart + * marker carries both legal quote styles. Case-sensitive: imports are + * case-sensitive in Swift, Kotlin and Dart. + */ +export const NATIVE_UI_CONTENT_MARKERS: Readonly> = { + '.swift': ['import SwiftUI', 'import UIKit'], + '.kt': ['import androidx.compose'], + '.dart': ["import 'package:flutter", 'import "package:flutter'], +}; + /** * UI-framework package.json dependencies (dependencies OR devDependencies). * Component frameworks/renderers only — deliberately excludes meta tooling @@ -106,9 +138,19 @@ function packageJsonHasUiFramework(projectDir: string): boolean { return false; } -function treeHasComponentFile(projectDir: string): boolean { - // Iterative BFS — bounded by MAX_WALK_ENTRIES so a pathological tree cannot - // stall the gate. Symlinks are never followed (withFileTypes + isDirectory). +/** + * Shared bounded BFS over the project tree — the single home of the walk + * semantics both evidence walks depend on: SKIP_DIRS pruning, the + * MAX_WALK_ENTRIES entry cap (cap-hit → `false`: the tree is treated as + * scanned and evidence stays undecided), symlinks never followed + * (withFileTypes Dirents), unreadable directories skipped. `visit` is called + * for every regular file with its name and full path; returning `true` stops + * the walk with `true` (evidence found). + */ +function walkProjectFiles( + projectDir: string, + visit: (name: string, fullPath: string) => boolean, +): boolean { const queue: string[] = [projectDir]; let visited = 0; while (queue.length > 0 && visited < MAX_WALK_ENTRIES) { @@ -124,7 +166,7 @@ function treeHasComponentFile(projectDir: string): boolean { if (visited >= MAX_WALK_ENTRIES) return false; if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) queue.push(path.join(dir, entry.name)); - } else if (entry.isFile() && UI_COMPONENT_FILE_RE.test(entry.name)) { + } else if (entry.isFile() && visit(entry.name, path.join(dir, entry.name))) { return true; } } @@ -132,16 +174,68 @@ function treeHasComponentFile(projectDir: string): boolean { return false; } +function treeHasComponentFile(projectDir: string): boolean { + return walkProjectFiles(projectDir, (name) => UI_COMPONENT_FILE_RE.test(name)); +} + +/** + * Read at most the first 64 KiB of `file` and report whether any of `markers` + * occurs in it (#4658). Import sections live at the top of a source file, so a + * bounded prefix read keeps the gate's plan-time cost profile without reading + * generated monsters in full. Any I/O failure degrades to false — never throw. + */ +function fileHasAnyMarker(file: string, markers: readonly string[]): boolean { + let fd: number; + try { + fd = fs.openSync(file, 'r'); + } catch { + return false; + } + try { + const bytes = Buffer.alloc(64 * 1024); + const read = fs.readSync(fd, bytes, 0, bytes.length, 0); + const prefix = bytes.toString('utf8', 0, read); + return markers.some((m) => prefix.includes(m)); + } catch { + return false; + } finally { + try { + fs.closeSync(fd); + } catch { + // already closed — nothing to degrade + } + } +} + +/** + * Native-UI walk over the shared bounded BFS (#4658): `.xaml` is evidence by + * extension alone; `.swift`/`.kt`/`.dart` are evidence only when the file's + * content carries its ecosystem's import marker. + */ +function treeHasNativeUiFile(projectDir: string): boolean { + return walkProjectFiles(projectDir, (name, fullPath) => { + if (NATIVE_UI_XAML_RE.test(name)) return true; + if (NATIVE_UI_SOURCE_RE.test(name)) { + const markers = NATIVE_UI_CONTENT_MARKERS[path.extname(name).toLowerCase()]; + return markers != null && fileHasAnyMarker(fullPath, markers); + } + return false; + }); +} + /** * Does the project tree carry static evidence of a frontend? * * @param projectDir - Absolute path to the project root (the gate's cwd). - * @returns true when package.json declares a UI-framework dependency or the - * tree contains a component-framework file; false otherwise (including - * on any I/O failure — evidence must be affirmative). + * @returns true when package.json declares a UI-framework dependency, the tree + * contains a component-framework file, or the tree contains native UI + * evidence (a `.xaml` file, or a `.swift`/`.kt`/`.dart` file carrying + * its ecosystem's UI import marker — #4658); false otherwise + * (including on any I/O failure — evidence must be affirmative). */ export function hasStaticFrontendEvidence(projectDir: string): boolean { if (typeof projectDir !== 'string' || projectDir === '') return false; if (packageJsonHasUiFramework(projectDir)) return true; - return treeHasComponentFile(projectDir); + if (treeHasComponentFile(projectDir)) return true; + return treeHasNativeUiFile(projectDir); } diff --git a/tests/check-ui-plan-gate.test.cjs b/tests/check-ui-plan-gate.test.cjs index 69a41960e..535529c61 100644 --- a/tests/check-ui-plan-gate.test.cjs +++ b/tests/check-ui-plan-gate.test.cjs @@ -23,8 +23,17 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, createTempDir } = require('./helpers.cjs'); +const fc = require('./helpers/fast-check-setup.cjs'); const { computeUiPlanGate } = require('../gsd-core/bin/lib/check-command-router.cjs'); +const { hasStaticFrontendEvidence } = require('../gsd-core/bin/lib/ui-frontend-evidence.cjs'); + +// Shared native fixture literals — kept in one place so the fixtures cannot +// drift from NATIVE_UI_CONTENT_MARKERS when a marker is tightened (#4658). +const COMPOSE_IMPORT = 'import androidx.compose.material3.Text\n'; +const FLUTTER_IMPORT = "import 'package:flutter/material.dart';\n"; +const FLUTTER_IMPORT_DOUBLE_QUOTED = 'import "package:flutter/material.dart";\n'; + // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -54,6 +63,20 @@ function makeProject({ phaseSection = '', hasUiSpec = false, frontendEvidence = JSON.stringify({ name: 'fixture', dependencies: { react: '^18.3.1' } }), 'utf8', ); + } else if (frontendEvidence === 'swiftui') { + fs.mkdirSync(path.join(tmpDir, 'Sources'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'Sources', 'ContentView.swift'), 'import SwiftUI\nstruct ContentView: View { var body: some View { Text("hi") } }\n', 'utf8'); + } else if (frontendEvidence === 'compose') { + fs.mkdirSync(path.join(tmpDir, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'lib', 'Main.kt'), COMPOSE_IMPORT, 'utf8'); + } else if (frontendEvidence === 'flutter') { + fs.mkdirSync(path.join(tmpDir, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'lib', 'main.dart'), `${FLUTTER_IMPORT}void main() {}\n`, 'utf8'); + } else if (frontendEvidence === 'xaml') { + fs.writeFileSync(path.join(tmpDir, 'MainPage.xaml'), '\n', 'utf8'); + } else if (frontendEvidence === 'swift-non-ui') { + fs.mkdirSync(path.join(tmpDir, 'Sources'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'Sources', 'TaskRunner.swift'), 'import Foundation\nstruct TaskRunner { }\n', 'utf8'); } // Minimal ROADMAP.md with one phase section @@ -460,3 +483,239 @@ describe('computeUiPlanGate — ui.plan-gate check logic (#1026)', () => { }); }); }); + +// ─── #4658 — native (non-JS) frontend evidence ──────────────────────────────── +// hasStaticFrontendEvidence recognised only JS-ecosystem evidence (a root +// package.json UI-framework dep, or a .tsx/.jsx/.vue/.svelte file), so +// computeUiPlanGate could never block for a SwiftUI / Compose / Flutter / XAML +// project — the #3312 gate was structurally unreachable for an entire class of +// project. The native branch keeps the same unambiguous-component bar as the +// .tsx subset: source files count only with their import marker (the reporter's +// 37-file Swift CLI with zero UI imports stays silent), while .xaml is +// extension-alone for the same reason .tsx is. + +describe('hasStaticFrontendEvidence — native evidence branch (#4658)', () => { + // relPath -> content; a `null` value creates a DIRECTORY at that path. + function nativeProject(files) { + const tmpDir = createTempDir('ui-evidence-4658-'); + for (const rel of Object.keys(files)) { + const target = path.join(tmpDir, rel); + if (files[rel] === null) { + fs.mkdirSync(target, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, files[rel], 'utf8'); + } + } + return tmpDir; + } + + test('a SwiftUI import counts as static frontend evidence (#4658)', (t) => { + const dir = nativeProject({ 'Sources/ContentView.swift': 'import SwiftUI\nstruct ContentView: View {}\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true, 'import SwiftUI is unambiguous native UI evidence'); + }); + + test('a UIKit import counts as static frontend evidence (#4658)', (t) => { + const dir = nativeProject({ 'AppDelegate.swift': 'import UIKit\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true); + }); + + test('a Compose import counts as static frontend evidence (#4658)', (t) => { + const dir = nativeProject({ 'lib/Main.kt': COMPOSE_IMPORT }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true); + }); + + test('a Flutter import counts as static frontend evidence (#4658)', (t) => { + const dir = nativeProject({ 'lib/main.dart': `${FLUTTER_IMPORT}void main() {}\n` }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true); + }); + + test('a double-quoted Flutter import also counts as evidence (#4658)', (t) => { + const dir = nativeProject({ 'lib/main.dart': `${FLUTTER_IMPORT_DOUBLE_QUOTED}void main() {}\n` }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true, 'both Dart quote styles are imports'); + }); + + test('a XAML page counts as static frontend evidence (#4658)', (t) => { + const dir = nativeProject({ 'MainPage.xaml': '\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true, '.xaml is extension-alone, like .tsx'); + }); + + test('a non-UI Swift package stays non-evidence (import marker required)', (t) => { + // The reporter's control case: 37 Swift files, zero UI imports — a CLI, a + // server. Extension-alone matching would misclassify exactly this class. + const dir = nativeProject({ 'Sources/TaskRunner.swift': 'import Foundation\nstruct TaskRunner { }\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), false); + }); + + test('plain Kotlin and Dart sources stay non-evidence', (t) => { + const dir = nativeProject({ + 'lib/Repo.kt': 'class Repo { fun get(): Int = 1 }\n', + 'bin/main.dart': 'void main() { print(1); }\n', + }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), false); + }); + + test('marker matching is exact — prose mentions and case changes are not imports', (t) => { + const mentioned = nativeProject({ 'Sources/Notes.swift': '// SwiftUI is nice, but this is a comment\nimport Foundation\n' }); + t.after(() => cleanup(mentioned)); + assert.strictEqual(hasStaticFrontendEvidence(mentioned), false, 'a prose/comment mention is not an import'); + + const lowercased = nativeProject({ 'Sources/A.swift': 'import swiftui\n' }); + t.after(() => cleanup(lowercased)); + assert.strictEqual(hasStaticFrontendEvidence(lowercased), false, 'imports are case-sensitive in all three marker-scanned languages'); + + const kotlinComment = nativeProject({ 'lib/Main.kt': '// TODO: migrate to androidx.compose\nfun main() {}\n' }); + t.after(() => cleanup(kotlinComment)); + assert.strictEqual(hasStaticFrontendEvidence(kotlinComment), false, 'a bare framework-name mention is not a Kotlin import'); + + const dartComment = nativeProject({ 'lib/main.dart': '// see package:flutter docs\nvoid main() {}\n' }); + t.after(() => cleanup(dartComment)); + assert.strictEqual(hasStaticFrontendEvidence(dartComment), false, 'a bare package-path mention is not a Dart import'); + }); + + test('native files are found below the project root', (t) => { + const dir = nativeProject({ 'lib/ui/deep/Chart.kt': 'import androidx.compose.foundation.Canvas\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true); + }); + + test('SKIP_DIRS still excludes vendored native matches', (t) => { + const dir = nativeProject({ 'node_modules/somepkg/Chart.kt': 'import androidx.compose.foundation.Canvas\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), false, 'vendored trees are not project evidence'); + }); + + test('extension matching is case-insensitive (.XAML)', (t) => { + const dir = nativeProject({ 'MainPage.XAML': '\n' }); + t.after(() => cleanup(dir)); + assert.strictEqual(hasStaticFrontendEvidence(dir), true, 'matching UI_COMPONENT_FILE_RE\'s existing /i semantics'); + }); + + test('unreadable directories degrade to false, never throw', (t) => { + const dir = nativeProject({ 'Sources/ContentView.swift': 'import SwiftUI\n' }); + const origReaddir = fs.readdirSync; + t.after(() => { cleanup(dir); }); + t.mock.method(fs, 'readdirSync', (p, opts) => { + if (String(p).endsWith('Sources')) throw new Error('EIO: simulated read failure'); + return origReaddir.call(fs, p, opts); + }); + assert.strictEqual(hasStaticFrontendEvidence(dir), false, 'I/O failure = no evidence, per the module contract'); + }); + + test('property: native evidence is exactly the marker-matched source content', () => { + // Deterministic: pinned seed, bounded runs, counterexample printed on + // failure (fc's default). Filler is filtered so it cannot carry a marker. + const markers = [ + ['.swift', 'import SwiftUI'], + ['.swift', 'import UIKit'], + ['.kt', 'import androidx.compose'], + ['.dart', "import 'package:flutter"], + ['.dart', 'import "package:flutter'], + ]; + const filler = fc.string({ minLength: 0, maxLength: 80 }) + .filter((s) => !s.includes('import') && !s.includes('androidx') && !s.includes('package:') && !s.includes('\n')); + // try/finally lives in this HELPER (no test context), per the exemption. + function withFixture(content, fn) { + const dir = createTempDir('ui-evidence-prop-4658-'); + try { + fs.writeFileSync(path.join(dir, `probe${content.ext}`), content.text, 'utf8'); + return fn(dir); + } finally { + cleanup(dir); + } + } + fc.assert( + fc.property( + fc.constantFrom(...markers), + filler, + ([ext, marker], noise) => withFixture({ ext, text: `${noise}\n${marker}\n${noise}\n` }, (dir) => { + assert.strictEqual(hasStaticFrontendEvidence(dir), true, `marker ${JSON.stringify(marker)} must be evidence`); + }), + ), + { seed: 4658, numRuns: 100 }, + ); + fc.assert( + fc.property(filler, (noise) => withFixture({ ext: '.swift', text: `${noise}\n${noise}\n` }, (dir) => { + assert.strictEqual(hasStaticFrontendEvidence(dir), false, 'filler-only content is not evidence'); + })), + { seed: 4659, numRuns: 100 }, + ); + }); +}); + +describe('computeUiPlanGate — native evidence fires the gate (#4658)', () => { + test('native frontend evidence fires the gate on a UI phase without a UI-SPEC (#4658)', (t) => { + const proj = makeProject({ + phaseSection: 'Build the SwiftUI dashboard list and detail views.', + hasUiSpec: false, + frontendEvidence: 'swiftui', + }); + t.after(() => cleanup(proj.tmpDir)); + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true, 'the vocabulary match already succeeded (#3718 untouched)'); + assert.strictEqual(r.hasFrontendEvidence, true, 'a SwiftUI project IS structural frontend evidence'); + assert.strictEqual(r.block, true, 'the issue title criterion: the gate must actually FIRE for native projects'); + }); + + test('native evidence respects the UI-SPEC branch (block:false when the spec exists)', (t) => { + const proj = makeProject({ + phaseSection: 'Build the SwiftUI dashboard list and detail views.', + hasUiSpec: true, + frontendEvidence: 'swiftui', + }); + t.after(() => cleanup(proj.tmpDir)); + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.hasFrontendEvidence, true); + assert.strictEqual(r.hasUiSpec, true); + assert.strictEqual(r.block, false, 'branch (b) is unchanged for native evidence'); + }); + + test('non-UI native source stays block:false (corroboration still required)', (t) => { + const proj = makeProject({ + phaseSection: 'Build the dashboard views for the settings screen.', + hasUiSpec: false, + frontendEvidence: 'swift-non-ui', + }); + t.after(() => cleanup(proj.tmpDir)); + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true, 'vocabulary match is unchanged'); + assert.strictEqual(r.hasFrontendEvidence, false, 'import Foundation is not UI evidence'); + assert.strictEqual(r.block, false, 'the control case: extension-alone would have blocked this'); + }); +}); + +// Review-pass additions (#4658): gate-level coverage for a second native +// ecosystem, and the unreadable-FILE degrade path of the bounded marker read. +describe('computeUiPlanGate — native evidence, additional ecosystems (#4658)', () => { + test('compose evidence fires the gate through the same block formula', (t) => { + const proj = makeProject({ + phaseSection: 'Migrate the dashboard list to Jetpack Compose components.', + hasUiSpec: false, + frontendEvidence: 'compose', + }); + t.after(() => cleanup(proj.tmpDir)); + const r = computeUiPlanGate(proj.tmpDir, '1'); + assert.strictEqual(r.frontend, true); + assert.strictEqual(r.hasFrontendEvidence, true, 'a Compose project IS structural frontend evidence'); + assert.strictEqual(r.block, true); + }); + + test('an unreadable candidate file degrades to no evidence, never throws', (t) => { + const dir = createTempDir('ui-evidence-io-4658-'); + t.after(() => { cleanup(dir); }); + fs.writeFileSync(path.join(dir, 'Main.kt'), COMPOSE_IMPORT, 'utf8'); + // t.mock.method auto-restores readSync at test end. + t.mock.method(fs, 'readSync', () => { + throw new Error('EIO: simulated read failure'); + }); + assert.strictEqual(hasStaticFrontendEvidence(dir), false, 'read failure = no evidence, per the module contract'); + }); +});