Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
- name: Typecheck supported source surfaces
run: >-
corepack pnpm
--workspace-concurrency=1
--filter './packages/**'
--filter @ling-stack/web
--filter @ling-stack/desktop
Expand Down
12 changes: 6 additions & 6 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,19 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@electron/osx-sign": "1.3.3",
"@fontsource-variable/inter": "5.2.8",
"@electron/asar": "4.2.1",
"@electron/osx-sign": "2.6.0",
"@fontsource-variable/inter": "5.3.0",
"@playwright/test": "^1.55.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"electron": "^43.1.1",
"electron-builder": "^26.15.3",
"esbuild": "^0.25.12",
"resedit": "1.7.2",
"vite": "^6.4.3"
"esbuild": "^0.28.1",
"resedit": "3.0.2",
"vite": "^8.2.1"
},
"build": {
"appId": "com.lingstack.desktop",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import { basename, join, resolve } from "node:path";

import { signAsync } from "@electron/osx-sign";
import { sign } from "@electron/osx-sign";

import {
SigningContractError,
Expand Down Expand Up @@ -101,7 +101,7 @@ export function createDarwinPlatformSigner(input = {}) {
const mainExecutable = await resolveBundleMainExecutable(filePath);
const beforeSha256 = await sha256File(mainExecutable);
const originalOptionsForFile = bundleSignOptions.optionsForFile;
await signAsync({
await sign({
...bundleSignOptions,
app: filePath,
identity,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@
"devDependencies": {
"expo-doctor": "1.20.1",
"react-types-mobile": "npm:@types/react@19.2.17",
"typescript": "~6.0.3"
"typescript": "~7.0.2"
}
}
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
"@playwright/test": "^1.55.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react": "^6.0.5",
"fflate": "^0.8.2",
"vite": "^6.4.3"
"vite": "^8.2.1"
}
}
80 changes: 31 additions & 49 deletions apps/web/scripts/check-browser-blocking-dialogs.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readFileSync, readdirSync } from "node:fs";
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { extname, join, relative, resolve } from "node:path";
import process from "node:process";
import ts from "typescript";
import { ts, visitTypeScriptNode, withTypeScriptSourceFiles } from "./typescript-ast.mjs";

const webRoot = process.cwd();
const repoRoot = resolve(webRoot, "../..");
Expand All @@ -14,40 +15,34 @@ const sourceRoots = [
const blockedMethods = new Set(["alert", "confirm", "prompt"]);
const browserGlobals = new Set(["globalThis", "window"]);

const selfTest = findViolations(
ts.createSourceFile(
"browser-dialog-guard-self-test.ts",
[
"window.alert('blocked');",
"globalThis.confirm('blocked');",
"window['prompt']('blocked');",
"function confirm() { return true; }",
"confirm();",
].join("\n"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
),
"browser-dialog-guard-self-test.ts",
);
if (selfTest.length !== 3) {
console.error(`Browser dialog guard self-test failed: expected 3 violations, found ${selfTest.length}.`);
process.exit(1);
const selfTestDir = mkdtempSync(join(tmpdir(), "ling-browser-dialog-guard-"));
const selfTestFile = join(selfTestDir, "browser-dialog-guard-self-test.ts");
writeFileSync(selfTestFile, [
"window.alert('blocked');",
"globalThis.confirm('blocked');",
"window['prompt']('blocked');",
"function confirm() { return true; }",
"confirm();",
].join("\n"));

const productionFiles = sourceRoots.flatMap((root) => collectSourceFiles(root));
let violations;
try {
violations = withTypeScriptSourceFiles([...productionFiles, selfTestFile], (sourceFiles) => {
const selfTest = findViolations(sourceFiles.get(resolve(selfTestFile)), "browser-dialog-guard-self-test.ts");
if (selfTest.length !== 3) {
throw new Error(`Browser dialog guard self-test failed: expected 3 violations, found ${selfTest.length}.`);
}
return productionFiles.flatMap((file) => (
findViolations(sourceFiles.get(resolve(file)), relative(repoRoot, file))
));
});
} finally {
rmSync(selfTestDir, { recursive: true, force: true });
}

const violations = [];
for (const root of sourceRoots) {
for (const file of collectSourceFiles(root)) {
const source = readFileSync(file, "utf8");
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true,
sourceKind(file),
);
violations.push(...findViolations(sourceFile, relative(repoRoot, file)));
}
if (!violations) {
throw new Error("Browser dialog guard produced no result.");
}

if (violations.length > 0) {
Expand All @@ -73,7 +68,7 @@ function collectSourceFiles(root) {

function findViolations(sourceFile, displayPath) {
const found = [];
visit(sourceFile, (node) => {
visitTypeScriptNode(sourceFile, (node) => {
if (!ts.isCallExpression(node)) return;
const target = readBrowserDialogTarget(node.expression);
if (!target) return;
Expand All @@ -92,21 +87,8 @@ function readBrowserDialogTarget(expression) {
if (ts.isElementAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
const global = expression.expression.text;
const argument = expression.argumentExpression;
const method = argument && ts.isStringLiteralLike(argument) ? argument.text : "";
const method = argument && ts.isStringLiteral(argument) ? argument.text : "";
return browserGlobals.has(global) && blockedMethods.has(method) ? { global, method } : undefined;
}
return undefined;
}

function sourceKind(file) {
const extension = extname(file);
if (extension === ".tsx") return ts.ScriptKind.TSX;
if (extension === ".jsx") return ts.ScriptKind.JSX;
if (extension === ".js" || extension === ".mjs") return ts.ScriptKind.JS;
return ts.ScriptKind.TS;
}

function visit(node, callback) {
callback(node);
ts.forEachChild(node, (child) => visit(child, callback));
}
42 changes: 18 additions & 24 deletions apps/web/scripts/check-i18n.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import ts from "typescript";
import { ts, visitTypeScriptNode, withTypeScriptSourceFiles } from "./typescript-ast.mjs";

const webRoot = process.cwd();
const srcRoot = path.join(webRoot, "src");
Expand All @@ -28,9 +28,7 @@ function unwrapExpression(expression) {
return current;
}

function parseLocale({ file, exportName }) {
const sourceText = fs.readFileSync(file, "utf8");
const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
function parseLocale({ file, exportName }, sourceFile) {
const keys = new Set();
const duplicates = [];

Expand All @@ -53,7 +51,10 @@ function parseLocale({ file, exportName }) {
}
}
}
ts.forEachChild(node, visit);
ts.visitEachChild(node, (child) => {
visit(child);
return child;
});
}

visit(sourceFile);
Expand All @@ -76,20 +77,12 @@ function collectSourceFiles(root) {
return files;
}

function collectLiteralTranslationKeys() {
function collectLiteralTranslationKeys(files, sourceFiles) {
const keys = new Set();
const dynamicCalls = [];
for (const file of collectSourceFiles(srcRoot)) {
const sourceText = fs.readFileSync(file, "utf8");
const sourceFile = ts.createSourceFile(
file,
sourceText,
ts.ScriptTarget.Latest,
true,
file.endsWith(".tsx") || file.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);

function visit(node) {
for (const file of files) {
const sourceFile = sourceFiles.get(path.resolve(file));
visitTypeScriptNode(sourceFile, (node) => {
if (ts.isCallExpression(node) && node.expression.getText(sourceFile) === "t") {
const argument = node.arguments[0];
if (argument) {
Expand All @@ -101,17 +94,18 @@ function collectLiteralTranslationKeys() {
}
}
}
ts.forEachChild(node, visit);
}

visit(sourceFile);
});
}
return { keys, dynamicCalls };
}

const english = parseLocale(localeFiles["en-US"]);
const chinese = parseLocale(localeFiles["zh-CN"]);
const literalTranslations = collectLiteralTranslationKeys();
const allSourceFiles = collectSourceFiles(srcRoot);
const audit = withTypeScriptSourceFiles(allSourceFiles, (sourceFiles) => ({
english: parseLocale(localeFiles["en-US"], sourceFiles.get(path.resolve(localeFiles["en-US"].file))),
chinese: parseLocale(localeFiles["zh-CN"], sourceFiles.get(path.resolve(localeFiles["zh-CN"].file))),
literalTranslations: collectLiteralTranslationKeys(allSourceFiles, sourceFiles),
}));
const { english, chinese, literalTranslations } = audit;
const missingChinese = [...literalTranslations.keys].filter((key) => !chinese.keys.has(key));
const englishOnlyKeys = [...english.keys].filter((key) => !chinese.keys.has(key));
const failures = [];
Expand Down
57 changes: 27 additions & 30 deletions apps/web/scripts/check-ui2-icons.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, readdirSync } from "node:fs";
import { relative, resolve } from "node:path";
import ts from "typescript";
import { ts, visitTypeScriptNode, withTypeScriptSourceFiles } from "./typescript-ast.mjs";

const typedActionOwners = [
"src/features/automations/AutomationDefinitionRow.tsx",
Expand Down Expand Up @@ -83,35 +83,35 @@ const violations = [];
const discoveredOwners = new Set();
const sourceRoot = resolve(process.cwd(), "src");

for (const absolutePath of collectTsxFiles(sourceRoot)) {
const sourcePath = relative(process.cwd(), absolutePath).replaceAll("\\", "/");
const source = readFileSync(absolutePath, "utf8");
const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let jsxIconCount = 0;
const sourceFiles = collectTsxFiles(sourceRoot);
withTypeScriptSourceFiles(sourceFiles, (parsedFiles) => {
for (const absolutePath of sourceFiles) {
const sourcePath = relative(process.cwd(), absolutePath).replaceAll("\\", "/");
const sourceFile = parsedFiles.get(resolve(absolutePath));
let jsxIconCount = 0;

const visit = (node) => {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
const tagName = node.tagName.getText(sourceFile);
if (iconButtonTags.has(tagName)) {
discoveredOwners.add(sourcePath);
const iconAttribute = node.attributes.properties.find((property) => (
ts.isJsxAttribute(property) && property.name.getText(sourceFile) === "icon"
));
if (iconAttribute?.initializer && ts.isJsxExpression(iconAttribute.initializer)) {
const expression = iconAttribute.initializer.expression;
if (expression && containsJsx(expression)) jsxIconCount += 1;
visitTypeScriptNode(sourceFile, (node) => {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
const tagName = node.tagName.getText(sourceFile);
if (iconButtonTags.has(tagName)) {
discoveredOwners.add(sourcePath);
const iconAttribute = node.attributes.properties.find((property) => (
ts.isJsxAttribute(property) && property.name.getText(sourceFile) === "icon"
));
if (iconAttribute?.initializer && ts.isJsxExpression(iconAttribute.initializer)) {
const expression = iconAttribute.initializer.expression;
if (expression && containsJsx(expression)) jsxIconCount += 1;
}
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
});

const budget = legacyReactNodeBudgets.get(sourcePath) ?? 0;
if (jsxIconCount !== budget) {
violations.push(`${sourcePath} legacy ReactNode IconButton budget is ${jsxIconCount}, expected ${budget}`);
const budget = legacyReactNodeBudgets.get(sourcePath) ?? 0;
if (jsxIconCount !== budget) {
violations.push(`${sourcePath} legacy ReactNode IconButton budget is ${jsxIconCount}, expected ${budget}`);
}
}
}
});

const expectedOwners = new Set(typedActionOwners);
for (const sourcePath of discoveredOwners) {
Expand Down Expand Up @@ -147,13 +147,10 @@ function collectTsxFiles(directory) {

function containsJsx(node) {
let found = false;
const visit = (current) => {
visitTypeScriptNode(node, (current) => {
if (ts.isJsxElement(current) || ts.isJsxSelfClosingElement(current) || ts.isJsxFragment(current)) {
found = true;
return;
}
ts.forEachChild(current, visit);
};
visit(node);
});
return found;
}
Loading