@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 +21 -0
- package/README.md +104 -2
- package/oxlint.json +100 -0
- package/package.json +38 -3
- package/scripts/check-fidelity.mjs +28 -0
- package/scripts/check-modal-lifecycle.mjs +49 -0
- package/scripts/check-sidebar-artwork.mjs +20 -0
- package/scripts/check-webgpu-worker.mjs +144 -0
- package/src/checker.mjs +71 -0
- package/src/css-analysis.mjs +595 -0
- package/src/html-analysis.mjs +49 -0
- package/src/native-typescript.mjs +59 -0
- package/src/rules.mjs +276 -0
- package/src/typescript-analysis.mjs +1461 -0
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
|
-
#
|
|
1
|
+
# @satinhq/lint
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
)
|
package/src/checker.mjs
ADDED
|
@@ -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
|
+
}
|