@satinhq/lint 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Satin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # @satinhq/lint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fidelity lint preset for Satin apps. Extend it from an app's `.oxlintrc.json`:
4
+
5
+ ```json
6
+ { "extends": ["../../packages/pwakit-lint/oxlint.json"] }
7
+ ```
8
+
9
+ The preset exists to make Satin's invariants hard to violate. It bans browser-history routers and any `pushState` or `replaceState` property access in app code. The framework package does not extend the preset, so its future URL-projection module can own the single allowed `replaceState` call.
10
+
11
+ Oxlint cannot inspect CSS or determine whether a style expression contains viewport geometry. Wire the companion check into each app's lint script:
12
+
13
+ ```json
14
+ {
15
+ "scripts": {
16
+ "lint": "oxlint && node ../../packages/pwakit-lint/scripts/check-fidelity.mjs src"
17
+ }
18
+ }
19
+ ```
20
+
21
+ The companion check uses the TypeScript compiler AST for `.js`, `.jsx`, `.mjs`, `.ts`, and `.tsx`. It resolves local style/props members and spreads, React `createElement`/JSX-runtime aliases and simple wrappers, runtime imports and re-exports, known CSS-in-JS tags, and direct DOM style mutations. A tokenized CSS declaration parser covers `.css` without treating comments or strings as code.
22
+
23
+ It rejects raw `vh` units, `env(safe-area-inset-*)`, raw `overflow: auto|scroll`, inline `overflow`/`overflowX`/`overflowY` scrolling, consumer-owned `-webkit-overflow-scrolling`, raw `<button>` controls, `onClick` activation, direct `navigator.serviceWorker.register(...)` calls, direct runtime imports from liquid-glass or OpenGlass package families, and full-width bottom-pinned geometry that omits `--satin-inset-bottom`. App scrolling must come from `ScrollView`; app activation must come from `Button` and `onPress`; service-worker registration must come from `runApp`; persistent glass chrome must come from Satin's `GlassEffect` and `TabView`. Framework CSS is outside the app lint target and owns the actual native overflow, pointer, service-worker update, and sibling scene/chrome policies. The dev runtime assertion independently scans computed layout so dynamic styles and nested raw scrollers cannot hide behind a declared ancestor.
24
+
25
+ Static expression evaluation has a shared per-file work budget, including string combinations and object-spread branches. If the budget is exhausted, the CLI reports `static-evaluation-budget-exceeded` with “analysis incomplete” and exits 2. This is an analysis error, not a fidelity rule or a clean result; simplify the combinatorial expression before relying on the check. Fidelity violations continue to exit 1.
26
+
27
+ The machine-readable manifest is exported without enabling any unsafe autofixes:
28
+
29
+ ```js
30
+ import { ruleManifest } from '@satinhq/lint/rule-manifest'
31
+ ```
32
+
33
+ Run `pnpm --filter @satinhq/lint test` for the rule and CLI suite.
34
+
35
+ `no-raw-persistent-storage` flags direct app access to `localStorage` and `indexedDB`; use `defineDatabase` for durable app records. A legacy import bridge can opt out with a source-file comment `// satin-data-allow-raw-storage: <reason>`. Keep that file small because the comment exempts the whole file. Session-only UI state is outside this rule.
36
+
37
+ <!-- measured-reference -->
38
+ For an entry point using `defineApp` / `satinApp`, include its HTML in the checker
39
+ command: `check-fidelity src index.html`. `no-handwritten-app-head` reserves the
40
+ manifest, touch icon, viewport, appearance metadata and title for the generated
41
+ head. Comments are ignored; attribute order, casing and quoting do not bypass it.
42
+ Only pass definition-owned HTML entry points to this rule.
43
+ <!-- End measured-reference -->
44
+
45
+ measured-reference adds `no-page-zoom` for CSS/inline `zoom` and `touch-action: pinch-zoom`.
46
+ Use the package's `Zoomable` role for component magnification. Startup-image tags
47
+ are covered by `no-handwritten-app-head`, alongside viewport and manifest policy.
48
+
49
+ `no-direct-react-root` reserves the initial commit for `mountSatin`. Runtime imports, re-exports and literal dynamic imports/requires of `react-dom/client` are rejected; type-only imports remain allowed.
50
+
51
+ `no-custom-linear-easing` rejects custom `linear(...)` curves in CSS animation
52
+ and transition declarations, inline timing styles, and statically resolved
53
+ `animate` / `updateTiming` options. Use Satin motion or explicit property
54
+ keyframes with ordinary `linear` easing. Dynamically constructed options are
55
+ outside this static check; installed busy-thread recordings verify the runtime.
56
+
57
+ `no-raw-text-input` rejects raw JSX and React factory `input`/`textarea` controls.
58
+ Use `TextField`, `SecureField`, or `TextEditor`. For an intentional native control
59
+ experiment, put a nonempty reason directly on that control, for example
60
+ `data-satin-native-input="Date picker capability probe"`. This escape does not
61
+ waive installed-PWA acceptance or the 16px minimum. `no-small-input-font` checks
62
+ literal pixel sizes on input/textarea CSS selectors and field JSX inline styles.
63
+ Computed, inherited, variable and dynamically generated sizes still require the
64
+ runtime `input-font-size` assertion and installed focus recordings.
65
+
66
+ ## App contract checks
67
+
68
+ The template `AGENTS.md` maps its hard rules to the rule manifest. The checker includes
69
+ `no-browser-history` for browser history mutation/traversal, `no-app-reload` for
70
+ location reloads, `no-global-touch-listener` for document/window touch listeners,
71
+ and `no-global-selection-reset` for root/universal CSS selection declarations.
72
+ History/reload checks resolve simple aliases, destructuring and literal computed
73
+ properties and exclude shadowed local objects. Dynamic inter-module calls and
74
+ computed policies still need review; this checker is not a sandbox.
75
+
76
+ Intent cannot be linted: explain permission value, keep credentials server-side,
77
+ choose meaningful destinations, validate notification routes, preserve measured
78
+ motion, check icon licenses and record installed fidelity. The generated app
79
+ ships build/typecheck scripts. The lint checker is workspace-only and is not
80
+ installed by the starter. From a framework checkout, run it against the app
81
+ source, CSS and HTML; no standalone registry installation is available.
82
+
83
+ `no-hidden-focus-heading` rejects JSX headings with a `tabIndex` and a static
84
+ `hidden`, `aria-hidden={true}`, `sr-only` or `visually-hidden` declaration. Focus
85
+ must target a visible title or control with a real cursor frame. Dynamic CSS,
86
+ clipping and viewport visibility require the runtime geometry check and installed
87
+ VoiceOver acceptance; this static rule does not prove visibility.
88
+
89
+ `no-fixed-document` rejects fixed positioning on html, body, :root and #root CSS selectors. Satin owns document layout so an installed translucent viewport can grow to the physical bottom. Component-local fixed overlays remain allowed. Dynamic selectors/styles still require the installed viewport and bottom-marker check.
90
+
91
+ `no-internal-import` rejects value, type, dynamic and re-export access to `@satinhq/kit/internal`. The framework lab uses the explicit `--allow-internal` checker option; ordinary apps do not.
92
+
93
+ `no-internal-import` follows locally resolvable require aliases (including object/destructuring aliases) and constant module strings. It does not resolve tsconfig/bundler path aliases, imports hidden behind other modules, computed runtime strings, reassignment/control-flow effects or arbitrary loader wrappers. Keep app package aliases pointed at public entry points; review configuration and wrapper modules separately. This syntax rule is an authoring guard, not a module-loading security boundary.
94
+
95
+ Content components such as `Text` and `VStack` are the recommended default vocabulary for new content, but are optional. Custom React/HTML remains supported. No rule requires adoption; existing geometry and semantic-role invariants apply equally to either style.
96
+
97
+
98
+ App source must not read `--satin-*` CSS variables. `no-private-css-variable`
99
+ checks CSS values, inline styles and JavaScript strings. Use `Overlay`,
100
+ `SafeAreaInset`, public component props, or `useSafeAreaInsets` instead. The
101
+ framework lab can explicitly use `--allow-internal`; production apps must not.
102
+
103
+ `no-global-focus-reset` rejects document-wide removal of focus outlines. Satin
104
+ owns non-keyboard outline suppression on the active element; hardware-keyboard
105
+ focus remains available. Element-scoped role policies are not global resets.
package/oxlint.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "plugins": [
3
+ "react",
4
+ "typescript",
5
+ "oxc"
6
+ ],
7
+ "rules": {
8
+ "react/rules-of-hooks": "error",
9
+ "react/only-export-components": [
10
+ "warn",
11
+ {
12
+ "allowConstantExport": true
13
+ }
14
+ ],
15
+ "no-restricted-imports": [
16
+ "error",
17
+ {
18
+ "paths": [
19
+ {
20
+ "name": "react-router",
21
+ "message": "Satin owns navigation. Use the satin Navigator; browser-history routers break the edge-swipe invariant."
22
+ },
23
+ {
24
+ "name": "react-router-dom",
25
+ "message": "Satin owns navigation. Use the satin Navigator; browser-history routers break the edge-swipe invariant."
26
+ },
27
+ {
28
+ "name": "@tanstack/react-router",
29
+ "message": "Satin owns navigation. Use the satin Navigator; browser-history routers break the edge-swipe invariant."
30
+ },
31
+ {
32
+ "name": "@remix-run/react",
33
+ "message": "Satin owns navigation. Remix document routing is incompatible with Satin's persistent in-memory screen stack."
34
+ },
35
+ {
36
+ "name": "@remix-run/router",
37
+ "message": "Satin owns navigation. Browser-history router cores break the edge-swipe invariant."
38
+ },
39
+ {
40
+ "name": "next/navigation",
41
+ "message": "Satin owns navigation. Next.js document routing is not a supported Satin runtime."
42
+ },
43
+ {
44
+ "name": "next/router",
45
+ "message": "Satin owns navigation. Next.js document routing is not a supported Satin runtime."
46
+ },
47
+ {
48
+ "name": "wouter",
49
+ "message": "Satin owns navigation. Browser-history routers break the edge-swipe invariant."
50
+ },
51
+ {
52
+ "name": "wouter/use-browser-location",
53
+ "message": "Satin owns navigation. Browser history cannot back the internal screen stack."
54
+ },
55
+ {
56
+ "name": "liquid-glass-web-react",
57
+ "message": "Satin owns glass composition. Use GlassBar or TabBar from satin."
58
+ },
59
+ {
60
+ "name": "liquid-glass-react",
61
+ "message": "Satin owns glass composition. Use GlassBar or TabBar from satin."
62
+ },
63
+ {
64
+ "name": "openglass",
65
+ "message": "Satin owns glass composition. Use GlassBar or TabBar from satin."
66
+ },
67
+ {
68
+ "name": "pixi.js",
69
+ "message": "Satin owns the page GPU context and glass renderer. Use framework glass primitives."
70
+ },
71
+ {
72
+ "name": "apple-liquid-glass-webgl",
73
+ "message": "Satin owns glass composition and WebGL is out of scope for measured-reference. Use GlassBar or TabBar from satin."
74
+ }
75
+ ]
76
+ }
77
+ ],
78
+ "no-restricted-properties": [
79
+ "error",
80
+ {
81
+ "object": "navigator",
82
+ "property": "vibrate",
83
+ "message": "Use Satin sensoryFeedback tap affordances. Imperative vibration is unavailable on the target runtime."
84
+ },
85
+ {
86
+ "property": "pushState",
87
+ "message": "Satin internal navigation never grows browser history. Use the satin Navigator."
88
+ },
89
+ {
90
+ "property": "replaceState",
91
+ "message": "Only Satin's framework-owned URL projection module may call history.replaceState."
92
+ },
93
+ {
94
+ "object": "navigator",
95
+ "property": "mediaSession",
96
+ "message": "Satin owns system media. Use useMediaPlayer for metadata and remote playback commands."
97
+ }
98
+ ]
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@satinhq/lint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-beta.0",
4
+ "type": "module",
5
+ "description": "Fidelity lint preset for Satin apps: bans the web idioms that break the native illusion.",
6
+ "exports": {
7
+ "./oxlint": "./oxlint.json",
8
+ "./check-fidelity": "./scripts/check-fidelity.mjs",
9
+ "./rule-manifest": "./src/rules.mjs"
10
+ },
11
+ "dependencies": {
12
+ "typescript": "7.0.2",
13
+ "oxc-parser": "0.153.0"
14
+ },
15
+ "devDependencies": {
16
+ "oxlint": "^1.79.0"
17
+ },
18
+ "license": "MIT",
19
+ "files": [
20
+ "oxlint.json",
21
+ "scripts",
22
+ "src",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/",
29
+ "tag": "beta"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/satinhq/satin.git",
34
+ "directory": "packages/pwakit-lint"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "true",
38
+ "lint": "oxlint scripts src tests",
39
+ "test": "node --test"
40
+ }
6
41
  }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { checkTargets, formatDiagnostic } from '../src/checker.mjs'
4
+
5
+ const allowInternal = process.argv.includes('--allow-internal')
6
+ const targets = process.argv.slice(2).filter(arg => arg !== '--allow-internal')
7
+ if (targets.length === 0) {
8
+ console.error('Usage: check-fidelity <source file or directory> [...]')
9
+ process.exitCode = 2
10
+ } else {
11
+ try {
12
+ const diagnostics = (await checkTargets(targets)).filter(
13
+ diagnostic =>
14
+ !allowInternal ||
15
+ !['no-internal-import', 'no-private-css-variable'].includes(diagnostic.ruleId),
16
+ )
17
+ for (const diagnostic of diagnostics) console.error(formatDiagnostic(diagnostic))
18
+
19
+ if (diagnostics.length > 0) {
20
+ console.error(`Satin fidelity check found ${diagnostics.length} violation(s).`)
21
+ process.exitCode = 1
22
+ }
23
+ } catch (error) {
24
+ if (error.code !== 'static-evaluation-budget-exceeded') throw error
25
+ console.error(error.message)
26
+ process.exitCode = 2
27
+ }
28
+ }
@@ -0,0 +1,49 @@
1
+ import { parseSync } from 'oxc-parser'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { join, relative } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ /** Modal entry belongs to one transaction; popover showPopover remains independent. */
7
+ export function modalLifecycleViolations(source, filename) {
8
+ if (filename.endsWith('modal/lifecycle.ts') || /\.(test|fixture)\./.test(filename)) return []
9
+ const { program } = parseSync(filename, source, { lang: 'tsx', preserveParens: true })
10
+ const violations = []
11
+ function visit(node) {
12
+ if (!node || typeof node !== 'object') return
13
+ if (
14
+ node.type === 'CallExpression' &&
15
+ node.callee.type === 'MemberExpression' &&
16
+ !node.callee.computed &&
17
+ node.callee.property.name === 'showModal'
18
+ ) {
19
+ const line = source.slice(0, node.start).split('\n').length
20
+ violations.push(`${filename}:${line}: use the shared openModal transaction`)
21
+ }
22
+ for (const child of Object.values(node)) {
23
+ if (Array.isArray(child)) child.forEach(visit)
24
+ else if (child && typeof child === 'object') visit(child)
25
+ }
26
+ }
27
+ visit(program)
28
+ return violations
29
+ }
30
+ async function check(directory, root = directory) {
31
+ const violations = []
32
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
33
+ const path = join(directory, entry.name)
34
+ if (entry.isDirectory()) violations.push(...(await check(path, root)))
35
+ else if (/\.tsx?$/.test(entry.name)) {
36
+ violations.push(
37
+ ...modalLifecycleViolations(await readFile(path, 'utf8'), relative(root, path)),
38
+ )
39
+ }
40
+ }
41
+ return violations
42
+ }
43
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
44
+ const violations = await check(process.argv[2])
45
+ if (violations.length) {
46
+ console.error(violations.join('\n'))
47
+ process.exitCode = 1
48
+ }
49
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+
4
+ // A vector-only boundary prevents the previous UIImage PNG export from
5
+ // returning. Provenance still requires source review; this is not a license test.
6
+ const source = await readFile(
7
+ resolve(process.argv[2] ?? 'src/navigation/SidebarSymbol.tsx'),
8
+ 'utf8',
9
+ )
10
+ if (/data:image|base64|<image\b|<img\b|url\s*\(/i.test(source))
11
+ throw Error(
12
+ 'SidebarSymbol must use the shared Symbol renderer, never embedded/exported image assets.',
13
+ )
14
+ if (
15
+ !source.includes('<Symbol') ||
16
+ !source.includes('Framework7 Icons (MIT)') ||
17
+ /<svg|<path/.test(source)
18
+ )
19
+ throw Error('SidebarSymbol must delegate to Symbol and retain its Framework7 MIT provenance.')
20
+ console.log('Sidebar symbol vector/provenance boundary passed.')
@@ -0,0 +1,144 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve, dirname, extname } from 'node:path'
3
+ import { syntax as ts, parseSourceFile } from '../src/native-typescript.mjs'
4
+ // Follow runtime edges, including re-exports and dynamic imports. ImportType
5
+ // nodes and mixed `type` specifiers are erased and must not enter this graph.
6
+ const seen = new Set()
7
+ async function visit(path) {
8
+ path = resolve(path)
9
+ if (seen.has(path) || extname(path) === '.json') return
10
+ seen.add(path)
11
+ const source = await readFile(path, 'utf8')
12
+ const ast = parseSourceFile(path, source).file
13
+ const edges = []
14
+ const property = node =>
15
+ ts.isPropertyAccessExpression(node)
16
+ ? node.name.text
17
+ : ts.isElementAccessExpression(node) &&
18
+ node.argumentExpression &&
19
+ ts.isStringLiteral(node.argumentExpression)
20
+ ? node.argumentExpression.text
21
+ : undefined
22
+ function inspect(node) {
23
+ if (ts.isTypeNode(node)) return
24
+ if (
25
+ ts.isCallExpression(node) &&
26
+ ['setTimeout', 'setInterval'].includes(node.expression.getText(ast)) &&
27
+ node.arguments[0]?.getText(ast) === 'tick'
28
+ )
29
+ throw Error(`${path}: glass tick must use the capped page frame clock, never a timer`)
30
+
31
+ const renderFunction =
32
+ ts.isFunctionDeclaration(node) && node.name?.text === 'render'
33
+ ? node
34
+ : ts.isVariableDeclaration(node) &&
35
+ ts.isIdentifier(node.name) &&
36
+ node.name.text === 'render' &&
37
+ node.initializer &&
38
+ (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
39
+ ? node.initializer
40
+ : undefined
41
+ if (renderFunction?.body) {
42
+ const body = renderFunction.body
43
+ const guard = ts.isBlock(body) ? body.statements[0] : undefined
44
+ if (
45
+ !guard ||
46
+ !ts.isIfStatement(guard) ||
47
+ !ts.isBinaryExpression(guard.expression) ||
48
+ guard.expression.operatorToken.kind !== ts.SyntaxKind.BarBarToken ||
49
+ guard.expression.left.getText(ast) !== '!visible' ||
50
+ guard.expression.right.getText(ast) !== '!submissionAllowed()' ||
51
+ !ts.isReturnStatement(guard.thenStatement) ||
52
+ guard.thenStatement.expression
53
+ ) {
54
+ throw Error(
55
+ `${path}: render must reject hidden frames and closed submission gates before GPU work`,
56
+ )
57
+ }
58
+ }
59
+ if (
60
+ ts.isCaseClause(node) &&
61
+ ts.isStringLiteral(node.expression) &&
62
+ node.expression.text === 'init'
63
+ ) {
64
+ const guard = node.statements[0]
65
+ const rejects = (expression, value) =>
66
+ ts.isBinaryExpression(expression) &&
67
+ expression.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken &&
68
+ ts.isPropertyAccessExpression(expression.left) &&
69
+ ts.isIdentifier(expression.left.expression) &&
70
+ expression.left.expression.text === 'request' &&
71
+ expression.left.name.text === 'appearance' &&
72
+ ts.isStringLiteral(expression.right) &&
73
+ expression.right.text === value
74
+ const condition = guard && ts.isIfStatement(guard) ? guard.expression : undefined
75
+ if (
76
+ !condition ||
77
+ !ts.isBinaryExpression(condition) ||
78
+ condition.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken ||
79
+ !rejects(condition.left, 'light') ||
80
+ !rejects(condition.right, 'dark') ||
81
+ !ts.isThrowStatement(guard.thenStatement)
82
+ )
83
+ throw Error(`${path}: WebGPU init must reject unknown appearance before drawing`)
84
+ }
85
+ if (ts.isImportDeclaration(node)) {
86
+ const clause = node.importClause
87
+ const bindings = clause?.namedBindings
88
+ const erased =
89
+ clause?.isTypeOnly ||
90
+ (!clause?.name &&
91
+ bindings &&
92
+ ts.isNamedImports(bindings) &&
93
+ bindings.elements.length > 0 &&
94
+ bindings.elements.every(e => e.isTypeOnly))
95
+ if (!erased) edges.push(node.moduleSpecifier.text)
96
+ }
97
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && !node.isTypeOnly) {
98
+ const clause = node.exportClause
99
+ if (!clause || !ts.isNamedExports(clause) || !clause.elements.every(e => e.isTypeOnly))
100
+ edges.push(node.moduleSpecifier.text)
101
+ }
102
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
103
+ if (
104
+ (ts.isIdentifier(node.expression) &&
105
+ ['window', 'document'].includes(node.expression.text)) ||
106
+ (ts.isIdentifier(node.expression) &&
107
+ node.expression.text === 'globalThis' &&
108
+ ['window', 'document'].includes(property(node)))
109
+ )
110
+ throw Error(`${path}: DOM access in glass worker`)
111
+ }
112
+ if (ts.isCallExpression(node)) {
113
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
114
+ if (!ts.isStringLiteral(node.arguments[0]))
115
+ throw Error(`${path}: nonliteral dynamic worker dependency`)
116
+ edges.push(node.arguments[0].text)
117
+ }
118
+ const name = property(node.expression)
119
+ if (name === 'requestDevice' && !path.endsWith('/webgpu/layers.ts'))
120
+ throw Error(`${path}: only LayerDevice may request a GPU device`)
121
+ if (name === 'createTexture' && !path.endsWith('/webgpu/frameGraph.ts'))
122
+ throw Error(`${path}: only FrameGraph may allocate glass textures`)
123
+ if (
124
+ name === 'getContext' &&
125
+ node.arguments[0] &&
126
+ ts.isStringLiteral(node.arguments[0]) &&
127
+ node.arguments[0].text.startsWith('webgl')
128
+ )
129
+ throw Error(`${path}: WebGL context in WebGPU worker`)
130
+ }
131
+ ts.forEachChild(node, inspect)
132
+ }
133
+ inspect(ast)
134
+ for (const specifier of edges) {
135
+ if (specifier === 'pixi.js' || specifier.startsWith('pixi.js/'))
136
+ throw Error(`${path}: Pixi dependency in WebGPU worker`)
137
+ if (specifier.startsWith('.')) await visit(resolve(dirname(path), specifier))
138
+ else throw Error(`${path}: external runtime worker dependency ${specifier}`)
139
+ }
140
+ }
141
+ await visit(process.argv[2] ?? 'src/glass/webgpu/entry.worker.ts')
142
+ console.log(
143
+ `WebGPU worker ownership: ${seen.size} modules checked; no Pixi/DOM, one device owner, one texture graph.`,
144
+ )
@@ -0,0 +1,71 @@
1
+ import { analyzeHtml } from './html-analysis.mjs'
2
+ import { readdir, readFile, stat } from 'node:fs/promises'
3
+ import { extname, resolve } from 'node:path'
4
+
5
+ import { analyzeCss } from './css-analysis.mjs'
6
+ import { ruleManifest } from './rules.mjs'
7
+ import { analyzeTypeScript } from './typescript-analysis.mjs'
8
+
9
+ export const SOURCE_EXTENSIONS = new Set(['.html', '.css', '.js', '.jsx', '.mjs', '.ts', '.tsx'])
10
+
11
+ const ruleOrder = new Map(ruleManifest.map((rule, index) => [rule.id, index]))
12
+
13
+ function lineAndColumn(source, offset) {
14
+ let line = 1
15
+ let lineStart = 0
16
+ for (let index = 0; index < offset; index += 1) {
17
+ if (source[index] !== '\n') continue
18
+ line += 1
19
+ lineStart = index + 1
20
+ }
21
+ return { line, column: offset - lineStart + 1 }
22
+ }
23
+
24
+ export async function sourceFiles(target) {
25
+ const metadata = await stat(target)
26
+ if (metadata.isFile()) return SOURCE_EXTENSIONS.has(extname(target)) ? [target] : []
27
+
28
+ const entries = await readdir(target, { withFileTypes: true })
29
+ const nested = await Promise.all(
30
+ entries
31
+ .filter(entry => entry.name !== 'dist' && entry.name !== 'node_modules')
32
+ .map(entry => sourceFiles(resolve(target, entry.name))),
33
+ )
34
+ return nested.flat()
35
+ }
36
+
37
+ export function analyzeSource(fileName, source) {
38
+ const violations =
39
+ extname(fileName) === '.html'
40
+ ? analyzeHtml(source)
41
+ : extname(fileName) === '.css'
42
+ ? analyzeCss(source)
43
+ : analyzeTypeScript(fileName, source)
44
+
45
+ return violations.toSorted(
46
+ (left, right) =>
47
+ (ruleOrder.get(left.ruleId) ?? Number.MAX_SAFE_INTEGER) -
48
+ (ruleOrder.get(right.ruleId) ?? Number.MAX_SAFE_INTEGER) || left.offset - right.offset,
49
+ )
50
+ }
51
+
52
+ export async function analyzeFile(file) {
53
+ const source = await readFile(file, 'utf8')
54
+ return analyzeSource(file, source).map(violation => ({
55
+ ...violation,
56
+ file,
57
+ ...lineAndColumn(source, violation.offset),
58
+ }))
59
+ }
60
+
61
+ export async function checkTargets(targets, cwd = process.cwd()) {
62
+ const files = (await Promise.all(targets.map(target => sourceFiles(resolve(cwd, target))))).flat()
63
+ const diagnostics = []
64
+
65
+ for (const file of files) diagnostics.push(...(await analyzeFile(file)))
66
+ return diagnostics
67
+ }
68
+
69
+ export function formatDiagnostic(diagnostic) {
70
+ return `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.message}`
71
+ }