Sfoglia il codice sorgente

Neo lint typecheck (#5944)

* Fail a PR when it adds NEO lint or typecheck errors.

yarn lint and yarn typecheck are not clean on master, so CI compares the PR with its base and fails only on new errors.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Neo: drop the dead tabs-more event

Nothing emits it any more, in core or in the forks: the native "More" tab opens
its menu from its own tabPress handler. Removes the listener in expoui-tabs and
the registry entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: retry yarn install in the NEO job

The first run of "NEO lint and typecheck" failed before checking anything:
registry.yarnpkg.com answered 502 Bad Gateway for one tarball. Retry the install
up to three times so a registry hiccup doesn't fail the PR.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* CI: install NEO with --ignore-scripts, apply patches explicitly

SonarCloud flagged `yarn install` in the NEO job (githubactions:S6505, lifecycle
scripts run by default). The install now skips dependency scripts and runs NEO's
own postinstall, patch-package, as a separate step. On a clean copy of the branch
both ways give identical results: 347 lint errors and the same 3 type errors.

check-new-errors.js: use node:fs and replaceAll (S7772, S7781).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 5 ore fa
parent
commit
64db9860a8

+ 79 - 0
.github/workflows/ci.yml

@@ -141,6 +141,85 @@ jobs:
         path: packages/*.zip
         if-no-files-found: error
 
+  neo-checks:
+    # The jobs above don't look at neo/. `yarn lint` and `yarn typecheck` aren't clean on master
+    # yet, so this fails only on errors the PR adds: both run on the PR and on its base, and
+    # neo/scripts/check-new-errors.js compares them per file and rule (line numbers ignored).
+    name: NEO lint and typecheck
+    if: github.event_name == 'pull_request' && github.event.action != 'closed'
+    runs-on: ubuntu-latest
+    env:
+      BASE_REF: ${{ github.event.pull_request.base.ref }}
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        fetch-depth: 0
+
+    - name: Check for NEO changes
+      id: changes
+      run: |
+        if git diff --quiet "origin/$BASE_REF...HEAD" -- neo/; then
+          echo "neo=false" >> "$GITHUB_OUTPUT"
+          echo "No changes under neo/, nothing to check."
+        else
+          echo "neo=true" >> "$GITHUB_OUTPUT"
+        fi
+
+    - name: Setup Node
+      if: steps.changes.outputs.neo == 'true'
+      uses: actions/setup-node@v4
+      with:
+        node-version: '24'
+
+    - name: Install NEO dependencies
+      if: steps.changes.outputs.neo == 'true'
+      working-directory: neo
+      # yarn.lock isn't committed (see neo/AGENTS.md), so this resolves the package.json ranges.
+      # The npm registry now and then answers 502 for one tarball; retry instead of failing the PR.
+      run: |
+        # --ignore-scripts keeps dependencies' install scripts from running in CI. NEO's own
+        # postinstall is only patch-package, so run that one explicitly: the result is the
+        # same lint and typecheck output as a plain install.
+        for attempt in 1 2 3; do
+          if yarn install --ignore-scripts --non-interactive --network-timeout 600000; then
+            yarn patch-package
+            exit 0
+          fi
+          echo "yarn install failed (attempt $attempt of 3)"
+          sleep 15
+        done
+        exit 1
+
+    - name: Lint and typecheck the PR
+      if: steps.changes.outputs.neo == 'true'
+      working-directory: neo
+      run: |
+        yarn --silent lint -f json -o "$RUNNER_TEMP/lint-head.json" || true
+        node scripts/typecheck.js > "$RUNNER_TEMP/typecheck-head.txt" 2>&1 || true
+
+    - name: Lint and typecheck the base branch
+      if: steps.changes.outputs.neo == 'true'
+      run: |
+        git worktree add "$RUNNER_TEMP/base" "origin/$BASE_REF"
+        cd "$RUNNER_TEMP/base/neo"
+        # Reuse the PR's install instead of a second `yarn install`.
+        ln -s "$GITHUB_WORKSPACE/neo/node_modules" node_modules
+        for app in apps/next apps/expo; do
+          if [ -d "$GITHUB_WORKSPACE/neo/$app/node_modules" ]; then
+            ln -s "$GITHUB_WORKSPACE/neo/$app/node_modules" "$app/node_modules"
+          fi
+        done
+        yarn --silent lint -f json -o "$RUNNER_TEMP/lint-base.json" || true
+        node scripts/typecheck.js > "$RUNNER_TEMP/typecheck-base.txt" 2>&1 || true
+
+    - name: Fail on new errors
+      if: steps.changes.outputs.neo == 'true'
+      working-directory: neo
+      run: >-
+        node scripts/check-new-errors.js
+        --lint "$RUNNER_TEMP/lint-head.json" "$RUNNER_TEMP/lint-base.json"
+        --typecheck "$RUNNER_TEMP/typecheck-head.txt" "$RUNNER_TEMP/typecheck-base.txt"
+
   preview-env:
     # Same-repo PRs only: forks and Dependabot get no secrets, and Dependabot PRs need no preview.
     name: Prepare PR environment

+ 1 - 1
AGENTS.md

@@ -89,7 +89,7 @@ The pull request description must include the database migration SQL when the ch
 - Run NEO's yarn scripts from `neo/`, not from the UNA root.
 - Branch from `master` and open the PR here. Start the title of a NEO-only PR with `Neo:`.
 - Never commit to unacms/neo. It is a mirror that `.github/workflows/sync-neo.yml` pushes on every push to `master`, and client projects fork it.
-- UNA CI doesn't check `neo/`. Run `yarn typecheck` and `yarn lint` in `neo/` before you push.
+- Run `yarn typecheck` and `yarn lint` in `neo/` before you push. On a PR that touches `neo/`, the CI job `NEO lint and typecheck` runs both on the PR and on its base and fails on any new error.
 - CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `neo-pr-<n>.up.railway.app` (NEO) and `una-pr-<n>.up.railway.app` (UNA), Railway's own domains, so previews need no certificates of their own; master deploys production (`api.unacms.app`). See `.github/workflows/ci.yml` and `scripts/railway/`.
 - A UNA service that NEO calls must return JSON for guests too; see [`neo/.agents/skills/una-api/SKILL.md`](neo/.agents/skills/una-api/SKILL.md).
 

+ 1 - 1
neo/AGENTS.md

@@ -20,7 +20,7 @@ If a fork needs a change outside `customization/`, make it upstream as a PR to u
 - NEO is a git subtree of the UNA repo since October 2026. The rest of that repo is the UNA backend (PHP), so one PR can change an API and the NEO code that calls it. UNA's own agent notes are in the root `AGENTS.md`; the [`una-api` skill](.agents/skills/una-api/SKILL.md) covers what NEO needs from UNA services.
 - Branch from UNA `master` and open the PR against unacms/UNA. Start the title of a NEO-only PR with `Neo:`, for example "Neo: allow only http(s) URLs in inline post embeds" ([#5787](https://github.com/unacms/UNA/pull/5787)).
 - unacms/neo is a mirror: on every push to UNA `master`, [`.github/workflows/sync-neo.yml`](https://github.com/unacms/UNA/blob/master/.github/workflows/sync-neo.yml) runs `git subtree push --prefix neo` to unacms/neo `main`. Never commit, merge or open PRs there; one commit on the mirror makes the next push non-fast-forward and stops the sync. There is nothing to `git subtree pull` from it either: it only holds what UNA already has, and pulling an outdated copy reverts newer UNA changes.
-- Run `yarn typecheck` and `yarn lint` from `neo/` before you push. UNA CI has no NEO job and the Next build ignores type errors. Neither command is clean on `master` yet, so compare with `master` and add no new errors.
+- Run `yarn typecheck` and `yarn lint` from `neo/` before you push. The Next build ignores type errors. Neither command is clean on `master` yet, so the rule is: add no new errors. On a PR that touches `neo/`, the CI job `NEO lint and typecheck` enforces it: it runs both commands on the PR and on its base and fails when a file gains errors for a rule (`scripts/check-new-errors.js`; run it yourself the same way).
 
 Upstream changes to `packages/app/customization/` reach every fork, so treat that folder as an interface:
 

+ 0 - 6
neo/apps/next/tsconfig.json

@@ -2,7 +2,6 @@
   "compilerOptions": {
     "allowJs": true,
     "allowImportingTsExtensions": true,
-    "baseUrl": ".",
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
     "incremental": true,
@@ -16,11 +15,6 @@
     "module": "ESNext",
     "moduleResolution": "bundler",
     "noEmit": true,
-    "paths": {
-      "react-native": [
-        "react-native-web"
-      ]
-    },
     "plugins": [
       {
         "name": "next"

+ 0 - 9
neo/packages/app/components/nav/tabs/expoui-tabs.js

@@ -38,7 +38,6 @@ import { useTabBarColors } from './tab-colors';
 import { selectOverflowTab } from './tab-press';
 import { getWindowSafeAreaInsets, useGlobalSearchParams, useRouter, usePathname, useSafeAreaInsets } from 'app/lib/hooks/router';
 import { rememberSelectedTab } from 'app/lib/navigation/tab-history';
-import emitter, { EVENTS } from 'app/context/emitter';
 import { useBottomSheetData } from 'app/context/bottomsheet';
 import { useCurrentUser } from 'app/context/user';
 
@@ -358,14 +357,6 @@ export default function ExpoUITabNavigator({
         ? tabBarHeight
         : IOS_TAB_BAR.height + (getWindowSafeAreaInsets().bottom || 0));
 
-    useEffect(() => {
-        const subscription = emitter.addListener(EVENTS.tabsMore, (data) => {
-            if (data?.action === 'open') setMoreMenuOpen(true);
-            if (data?.action === 'close') setMoreMenuOpen(false);
-        });
-        return () => subscription.remove();
-    }, []);
-
     const overflowMenuItems = useMemo(
         () => buildTabBarMoreMenuItems({
             visible,

+ 0 - 4
neo/packages/app/context/emitter.ts

@@ -81,10 +81,6 @@ export const EVENTS = {
     wiki: 'wiki',
         /* reload */
 
-    tabsMore: 'tabs-more',
-        /* open | close — native "More" menu. Core no longer emits it (the tab's own
-         * tabPress opens it); kept for forks still on the old native-tab-press-sync. */
-
     // ************************ content ***************************************
 
     feed: 'feed',

+ 97 - 0
neo/scripts/check-new-errors.js

@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// Fails when a branch adds lint or typecheck errors compared with its base.
+// `yarn lint` and `yarn typecheck` aren't clean on master yet, so CI can't ask
+// for zero errors; it asks for no new ones (see AGENTS.md "Before you push").
+//
+//   node scripts/check-new-errors.js \
+//     --lint <head-eslint.json> <base-eslint.json> \
+//     --typecheck <head-typecheck.txt> <base-typecheck.txt>
+//
+// ESLint input is `eslint -f json` output; typecheck input is the text printed by
+// scripts/typecheck.js. Errors are compared per file + rule (lint) or file + TS code
+// (typecheck), ignoring line numbers, so unrelated edits that shift lines don't count.
+// Warnings are ignored.
+
+const fs = require('node:fs');
+
+function args() {
+    const out = {};
+    const argv = process.argv.slice(2);
+    for (let i = 0; i < argv.length; i++) {
+        if (argv[i] === '--lint' || argv[i] === '--typecheck') {
+            out[argv[i].slice(2)] = [argv[i + 1], argv[i + 2]];
+            i += 2;
+        }
+    }
+    return out;
+}
+
+/** Path relative to the NEO root, whatever checkout the report came from. */
+function rel(file) {
+    const p = file.replaceAll('\\', '/');
+    const m = p.match(/(?:^|\/)((?:apps|packages|scripts|crypto-shim)\/.*)$/);
+    return m ? m[1] : p;
+}
+
+function lintCounts(file) {
+    const counts = new Map();
+    const where = new Map();
+    for (const f of JSON.parse(fs.readFileSync(file, 'utf8'))) {
+        for (const m of f.messages) {
+            if (m.severity !== 2) continue;
+            const key = `${rel(f.filePath)}\t${m.ruleId || 'parse-error'}`;
+            counts.set(key, (counts.get(key) || 0) + 1);
+            if (!where.has(key)) where.set(key, { line: m.line, message: m.message.split('\n')[0] });
+        }
+    }
+    return { counts, where };
+}
+
+// tsc prints `file(12,5): error TS2532: ...` without a TTY and `file:12:5 - error TS2532: ...` with one.
+const TSC_LINE = /^(.+?)(?:\((\d+),\d+\)|:(\d+):\d+)(?::| -) error (TS\d+): (.*)$/;
+
+function typecheckCounts(file) {
+    const counts = new Map();
+    const where = new Map();
+    const text = fs.readFileSync(file, 'utf8').replace(/\x1b\[[0-9;]*m/g, '');
+    for (const raw of text.split(/\r?\n/)) {
+        const m = raw.trim().match(TSC_LINE);
+        if (!m) continue;
+        const key = `${rel(m[1])}\t${m[4]}`;
+        counts.set(key, (counts.get(key) || 0) + 1);
+        if (!where.has(key)) where.set(key, { line: Number(m[2] || m[3]), message: m[5] });
+    }
+    return { counts, where };
+}
+
+function compare(label, head, base) {
+    const added = [];
+    for (const [key, n] of head.counts) {
+        const before = base.counts.get(key) || 0;
+        if (n > before) added.push({ key, n, before, ...head.where.get(key) });
+    }
+    const total = (c) => [...c.counts.values()].reduce((s, n) => s + n, 0);
+    console.log(`${label}: ${total(base)} errors on base, ${total(head)} on this branch`);
+    for (const a of added) {
+        const [file, rule] = a.key.split('\t');
+        const msg = `${rule}: ${a.n - a.before} new (${a.before} → ${a.n}). First: ${a.message}`;
+        // GitHub annotation; paths are relative to the repo root, where NEO lives in neo/.
+        console.log(`::error file=neo/${file},line=${a.line || 1},title=${label}::${msg}`);
+        console.log(`  ${file}  ${msg}`);
+    }
+    return added.length;
+}
+
+const opts = args();
+let failures = 0;
+if (opts.lint) failures += compare('lint', lintCounts(opts.lint[0]), lintCounts(opts.lint[1]));
+if (opts.typecheck) failures += compare('typecheck', typecheckCounts(opts.typecheck[0]), typecheckCounts(opts.typecheck[1]));
+if (!opts.lint && !opts.typecheck) {
+    console.error('usage: check-new-errors.js --lint <head.json> <base.json> --typecheck <head.txt> <base.txt>');
+    process.exit(2);
+}
+if (failures) {
+    console.log(`\n${failures} file/rule pair(s) gained errors. Fix them, or compare with master locally: yarn lint, yarn typecheck.`);
+    process.exit(1);
+}
+console.log('\nno new errors');

+ 0 - 2
neo/scripts/typecheck.js

@@ -90,8 +90,6 @@ function run(name) {
         compilerOptions: {
             noEmit: true,
             moduleSuffixes: [platform.suffix, ''],
-            // apps/next still sets `baseUrl`, deprecated since TS 6.
-            ...(parseInt(ts.version, 10) >= 6 ? { ignoreDeprecations: '6.0' } : {}),
             ...platform.compilerOptions,
         },
         include: [],