Files
callstack__agent-device/.github/workflows/ci.yml
Michał Pierzchała d97a628e38 fix(ci): make the two rg-based static checks actually run (#2006)
* fix(ci): make the two rg-based static checks actually run

ripgrep is never installed on ubuntu-latest, so both `rg` assertions in
the Lint & Format job failed with "command not found" (exit 127) on
every run. `if rg ...; then ... fi` cannot distinguish that from "no
matches" (exit 1) — both read as false, so each step silently passed
without its assertion ever executing. The DI-seams check had 7 live
violations it never reported.

Rewrite both against `grep`, which every runner ships, with match/
no-match/error exit codes handled explicitly so a broken scan fails
the lane instead of reading as a pass, plus a zero-tracked-files guard
so a renamed directory can't quietly go uncovered.

The DI-seam pattern also gets narrower to drop two classes of false
positive surfaced by actually running it: `typeof fetch` (fetchImpl?/
fetch? seams inject the one global with no module boundary vi.mock can
intercept; auth-session.ts/cloud-profile.ts/daemon-proxy.ts exercise
the seam directly in their unit tests, while CLI-level tests use
vi.stubGlobal('fetch', ...) where the seam isn't reachable — a
deliberate, exercised seam) and `typeof SOME_CONSTANT` in
SCREAMING_SNAKE_CASE (derives a literal union type from a constant,
e.g. interaction-touch-response.ts's dispatchPath field — not an
injectable seam at all).

Fixes #1976

* fix(ci): replace the DI-seam name-based allowlist with an explicit per-site one

Review on PR #2006 (#1976): the previous revision fixed the exit-code
handling but decided which `?: typeof X` matches to ban with a regex
that exempted matches by the *spelling* of the typeof target
(`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always
passed). That's a name-based semantic allowlist, not ownership: a new,
genuinely test-only `typeof fetch` seam anywhere in the tree would
have silently passed, while an equally legitimate seam under any
other name would still fail.

Add scripts/di-seams: a small, tested TypeScript checker that judges
each match against an explicit, typed, per-site allowlist
(scripts/di-seams/approved.ts) keyed by (file, field name, typeof
target) rather than by name. A triple is exempt only because it was
individually reviewed and named — never because of how it's spelled —
and the gate fails just as hard on a stale approval (one whose triple
no longer matches anything, e.g. after a rename) as on an unapproved
seam, so the list can't silently drift out of sync with the code it
describes.

Moves the DI-seams step in ci.yml to run after Setup toolchain (it's
no longer a toolchain-free text scan); the Swift trailing-comma check
stays where it was.

* fix(ci): register di-seams as a real gate and route it through the tmpdir wrapper

CI caught two things the local (dependency-free) run couldn't:

- oxfmt formatting on the two new files.
- scripts/node-test-tmpdir.test.ts's repo-wide audit: every package.json
  script that invokes `node --test` directly must route through
  scripts/node-test-tmpdir.ts, or a crash/timeout mid-run leaks its
  scratch TMPDIR. check:di-seams now does.
- check:gate-manifest: a package.json script that runs `node --test`
  must be covered by a registered CHECK_CATALOG gate, or the audit
  reports the test suite as run by no lane. Registered 'di-seams' in
  scripts/check-affected/{model,checks}.ts and wired the CI step
  through run-gate like every other structural guard in this job,
  instead of invoking pnpm directly.

Verified locally with node_modules installed: check:di-seams,
check:gate-manifest, check:gate-manifest:test, check:affected:test,
check:layering, check:fallow (scoped to the changed files), format,
lint, and typecheck all pass.

* fix(ci): close the multiline and duplicate-site gaps in the DI-seam scanner

Review round 2 on PR #2006 (#1976):

- findSeamMatches scanned line by line, so a declaration split across
  lines (`field?:` on one line, `typeof X` on the next) was invisible.
  Matching now runs against each file's whole source in one pass —
  `\s` matches a real newline in JavaScript regexes with no extra flag
  needed — with the line number derived from the match's character
  offset.

- checkSeams keyed approval by (file, field, target) alone, so once
  one occurrence of a triple was approved, any further occurrence of
  that same triple anywhere in the file passed too. The key now
  includes the line the match starts on, so an approval names one
  specific declaration, not a recurring pattern. approved.ts expands
  from 5 collapsed entries to the 7 exact sites this closes down to.

Added regression tests planting both gaps directly (a cross-line
declaration, and a second unreviewed fetchImpl?: typeof fetch at a
different line in an already-approved file) and verified both against
the real tree with injected violations, restored cleanly afterward.
Re-ran the full local gate suite (di-seams, gate-manifest, layering,
fallow, format, lint, typecheck) — all green.

* fix(ci): resync approved DI-seam line after merging main

Merging main (#2002) removed an unused import above the approved
dispatchPath?: typeof MAESTRO_COORDINATE_FALLBACK_PATH declaration in
interaction-touch-response.ts, shifting it from line 61 to line 60 —
exactly the location-specific-approval staleness the gate is designed
to catch, just triggered by an unrelated upstream edit rather than a
change in this PR. Updated the approved line to match.

* fix(ci): replace the DI-seam positional table with a code-local approval marker

Review round 3 on PR #2006 (#1976): CI proved the round-2 fix's core
assumption wrong within one push. Keying approval by (file, line,
field, target) made a line number the identity — an unrelated edit
anywhere earlier in a file shifts every approval below it, and that's
exactly what happened: merging main removed an unused import above
the approved dispatchPath declaration, and the gate rejected an
unchanged, already-reviewed line.

Detection is now AST-based (oxc-parser, the same tool
scripts/layering/*.ts already uses) instead of a source-text regex:
any `{ optional: true, typeAnnotation: TSTypeQuery }` node — a
property signature or a bare parameter — is a candidate, which finds
a multiline `field?:\n  typeof X` declaration for free instead of
needing a special case for it.

Approval is a `// di-seam-approved: <reason>` comment immediately
above the declaration, matching this repo's own `//
fallow-ignore-next-line complexity` convention: the marker precedes
what it exempts. approved.ts (the external table) is deleted — there
is nothing left to keep in sync, since the approval travels with the
code it approves. A second, unmarked seam under the same field/target
elsewhere still fails; reordering unrelated code around an approved
declaration no longer touches it.

Added the marker to the 7 real approved sites (fetch-global
injection seams in auth-session.ts/cloud-profile.ts/daemon-proxy.ts;
the literal-type-derivation false positive in
interaction-touch-response.ts) and regression tests proving: a
cross-line declaration is still found, a second unmarked occurrence
of an approved field/target pair still fails, and an unrelated
insertion above an approved declaration no longer breaks it. Verified
against the real tree with an injected multi-line unrelated insertion
before an approved site — still green. Re-ran the full local gate
suite (di-seams, gate-manifest, layering, fallow, format, lint,
typecheck, auth-session unit tests) — all green.

* fix(ci): reject a di-seam-approved marker with no reason text

Review round 4 on PR #2006 (#1976): approvalReason() returned '' (not
null) for a bare `// di-seam-approved:` comment with nothing after
it, and checkSeams() only filtered out null, so an empty marker
silently approved a seam with zero justification — exactly the kind
of unreviewed bypass this gate exists to prevent.

approvalReason() now returns null when the joined reason text is
empty after trimming, so a bare or whitespace-only marker is treated
the same as no marker at all. Added tests for both the model-level
behavior and the end-to-end checkSeams() result, plus verified
against the real tree by injecting a bare-marker declaration and
confirming it's flagged, then restored cleanly.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 21:31:24 +02:00

456 lines
19 KiB
YAML

name: CI
on:
pull_request:
paths-ignore:
- 'docs/**'
- 'website/**'
- 'README.md'
- 'AGENTS.md'
- 'CHANGELOG.md'
- 'CONTEXT.md'
- 'CONTRIBUTING.md'
- 'LICENSE'
- 'SECURITY.md'
- '.github/actions/build-docs/action.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-preview.yml'
- '.github/workflows/pr-preview-cleanup.yml'
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# The Swift trailing-comma assertion is text-only and runs before the toolchain setup, so a
# grep failure does not wait on an install. The DI-seams check below needs a real TypeScript
# runtime (#1976 / PR #2006), so it runs after Setup toolchain instead.
lint:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# #1976: ripgrep is never installed on ubuntu-latest, so `rg` failed with "command not
# found" (exit 127) on every run, and `if rg ...; then ... fi` cannot distinguish that
# from "no matches" (exit 1) — both read as false, so the step passed without the
# assertion ever executing. Rewritten against `grep`, which every runner ships, with the
# match/no-match/error exit codes handled explicitly so a broken scan fails loudly instead
# of silently passing.
- name: Disallow trailing commas before closing parenthesis in Swift
run: |
mapfile -d '' -t swift_files < <(git ls-files -z -- 'apple/runner' | grep -z '\.swift$')
if [ "${#swift_files[@]}" -eq 0 ]; then
echo "No apple/runner/*.swift files are tracked; the trailing-comma check has nothing to scan." >&2
exit 1
fi
set +e
grep -PzoH ',\s*\n\s*\)' "${swift_files[@]}"
status=$?
set -e
if [ "$status" -eq 0 ]; then
echo "Found trailing commas before ')' in Swift files. This syntax requires Swift 6.1+ and breaks older Xcode toolchains."
exit 1
elif [ "$status" -ne 1 ]; then
echo "grep exited $status while scanning apple/runner for trailing commas; treating an unreadable scan as a failure instead of a silent pass."
exit 1
fi
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
# Same false-green shape as the Swift check above (#1976). An earlier revision of this
# gate (PR #2006, first review pass) fixed the exit-code handling but kept the ban/allow
# decision as a regex that exempted matches by the *spelling* of the typeof target
# (`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always passed) — a name-based
# semantic allowlist that would silently pass a new, genuinely test-only `typeof fetch` seam
# anywhere in the tree while banning an equally legitimate seam under any other name.
# scripts/di-seams instead checks each match against an explicit, typed, per-site allowlist
# (scripts/di-seams/approved.ts) keyed by (file, field, typeof-target): a triple is exempt
# only because it was individually reviewed and named, never because of how it is spelled.
# The gate fails just as hard on a stale approval (one whose triple no longer matches
# anything) as on an unapproved seam, so the allowlist can't drift out of sync with the code
# it describes. See scripts/di-seams/model.ts and its tests.
- name: Fail if test-only DI seams reappear in production code
uses: ./.github/actions/run-gate
with: { gate: di-seams }
- name: Run oxlint
uses: ./.github/actions/run-gate
with: { gate: lint }
- name: Check formatting
uses: ./.github/actions/run-gate
with: { gate: format }
# Structural guards share one checkout and install. Every gate stays an
# independently named step with its own failure message; the gate manifest
# derives lane ownership from these declarations, so merging jobs changes
# nothing it asserts.
repo-guards:
name: Repo Guards
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The layering gate parses production sources with `oxc-parser`, so
# dependencies are required; keep install-deps enabled.
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Check import-direction DAG
# Structured import-direction lint over the resolved graph. See
# scripts/layering/check.ts and CONTEXT.md (Architecture: folder DAG +
# layering lint).
uses: ./.github/actions/run-gate
with: { gate: layering }
- name: Check the depgraph report agrees with the gate
# scripts/depgraph reads the same model as the gate, so its inversion count must
# reproduce TYPE_INVERSION_BASELINE. Free two-sources check: if the tree changes
# and only one side is updated, this fails and names the difference. Runs beside
# the layering gate so the two can never be green independently.
uses: ./.github/actions/run-gate
with: { gate: depgraph }
# Tests for the TMPDIR redirection itself, hidden the same way as the check above.
#
# Deliberately NOT in Coverage next to `check:tmpdir-leaks`, where the subject matter
# would put it: vitest-tmpdir-global-setup.test.ts proves the lifecycle by spawning a
# real nested `vitest run`, and the Coverage lane already loses runs to
# `[vitest-pool]: Worker forks emitted error` when a fork is slow to terminate.
# Starting a nested Vitest seconds before the full instrumented suite is a contention
# risk with nothing to gain.
- name: Check the tmpdir redirection model
uses: ./.github/actions/run-gate
with: { gate: tmpdir-leaks-model }
# The selector is fail-open and advisory (GitHub CI stays authoritative),
# so the gate only guards the derivation model.
- name: Check affected-selector model
uses: ./.github/actions/run-gate
with: { gate: affected-selector }
# The gate-of-gates (#1429). It shares this job because it validates the
# same artifact the selector is built on — CHECK_CATALOG's `ciJobs` — and
# because a gate that proves the other gates are wired must not be the one
# gate sitting in its own job, green on its own. Deterministic and
# network-free: every input is a file in the checkout.
- name: Check the gate manifest model
uses: ./.github/actions/run-gate
with: { gate: gate-manifest-model }
- name: Check every gate is owned, wired, and reachable
uses: ./.github/actions/run-gate
with: { gate: gate-manifest }
# Same family as the manifest above — a CI selection that has stopped selecting what
# it claims. ios.yml runs a hand-written subset of the runner XCTest methods through an
# `-only-testing:` list, and xcodebuild treats an identifier that matches nothing as
# an empty selection rather than an error, so a rename drops a test with no signal —
# in both directions, since a typo in xctest-nightly.yml's `-skip-testing:` entry
# re-arms a 24-hour hang. Parse-only, no Xcode (#1781 A7).
- name: Check the PR XCTest selection still names real tests
uses: ./.github/actions/run-gate
with: { gate: xctest-selection }
# Layers 1-2 of the conformance oracle: replay the JVM-generated fixtures
# against the live engine. Deterministic and Java-free — the generated
# fixtures are checked in and only regenerated on an upstream-pin bump. The
# device-backed layer 3 runs on the scheduled conformance-differential
# workflow. See scripts/maestro-conformance/README.md. Unlike the guards
# above, this parses corpus flows with the live engine and imports the
# `yaml` package — covered by the shared install above.
- name: Verify Maestro conformance fixtures
uses: ./.github/actions/run-gate
with: { gate: maestro-conformance }
# server.json/smithery.yaml drift otherwise surfaces at publish time (where
# publish-mcp-registry.yml duplicates the same command). Parse-only.
- name: Check MCP registry metadata is in sync
uses: ./.github/actions/run-gate
with: { gate: mcp-metadata }
# Slowest guard (~3 min), so it runs last and structural failures surface
# before it. Runs on plain Node via the shared install above.
- name: Check numeric ranges
uses: ./.github/actions/run-gate
with: { gate: freerange }
# History-backed compatibility gates share one deep checkout: fallow,
# replay-compat, and daemon-wire-compat all need either full history or tags,
# which no shallow unit lane can read.
compat-provenance:
name: Compatibility & Provenance
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Run Fallow audit
env:
FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
uses: ./.github/actions/run-gate
with:
gate: fallow
args: |
--base
${{ env.FALLOW_BASE }}
- name: Check for production-unused exports
uses: ./.github/actions/run-gate
with: { gate: production-exports }
# The frozen replay-compat corpus (#1417) claims each entry was published by
# a released tag. Only a full-history checkout can re-derive that claim.
- name: Verify corpus entries against their released blobs
uses: ./.github/actions/run-gate
with: { gate: replay-compat }
- name: Verify the wire-compat rules
uses: ./.github/actions/run-gate
with: { gate: wire-compat-model }
# The daemon RPC wire ledger (#1432) is compared against the ledger as it
# stood at the last RELEASED tag, which only a tagged checkout can read.
# The shallow Repo Guards lane holds the ledger to its source; this step
# holds it to the last release.
- name: Compare the daemon RPC wire surface against the last released tag
uses: ./.github/actions/run-gate
with: { gate: daemon-wire-compat }
# Typecheck and package verification share one checkout and install; both
# need the default toolchain only, and the package step re-pins Node itself.
typecheck-package:
name: Typecheck & Package
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Run typecheck
uses: ./.github/actions/run-gate
with: { gate: typecheck }
- name: Build CLI
uses: ./.github/actions/run-gate
with: { gate: build }
- name: Verify emitted chunk ownership
uses: ./.github/actions/run-gate
with: { gate: bundle-owner-files }
# The build runs on the default toolchain Node and the package is verified on the minimum
# supported Node, so this job covers what a user on `engines.node` floor actually installs.
- name: Setup Node.js 22.12
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22.12'
# Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports
# every published entry point before running the CLI. See scripts/check-package.ts.
#
# Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm
# requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The
# gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher.
- name: Verify the published package on Node.js 22.12
run: node --experimental-strip-types scripts/check-package.ts
# Runs the full unit + provider-integration suites under coverage with
# thresholds, so a separate unit-tests job would rerun the same tests. The
# suite is sharded across runners; each shard writes a blob report and the
# Coverage Report job merges them, evaluates thresholds once over the full
# suite, and produces every coverage artifact (vitest.config.ts carries the
# shard/merge switches).
coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
AGENT_DEVICE_COVERAGE_SHARD: ${{ matrix.shard }}/2
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Test changed-line coverage gate
if: matrix.shard == 1
uses: ./.github/actions/run-gate
with: { gate: coverage-model }
- name: Run coverage shard
uses: ./.github/actions/run-gate
with: { gate: unit-ci }
# The TMPDIR redirection both test lanes depend on (#1593/#1595). The check is a real
# package script that no workflow ran: it is reachable only through `check:unit`, an
# aggregate CI never invokes, so a leak regression could not fail a PR. Runs per shard,
# because a leak lands on whichever runner executed the leaking file.
- name: Check for leaked temp directories
uses: ./.github/actions/run-gate
with: { gate: tmpdir-leaks }
- name: Upload coverage blob
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-blob-${{ matrix.shard }}
path: .vitest-reports/
# The directory is dot-prefixed, which v4 excludes by default.
include-hidden-files: true
if-no-files-found: error
coverage-report:
name: Coverage Report
needs: coverage
runs-on: ubuntu-latest
timeout-minutes: 10
env:
AGENT_DEVICE_COVERAGE_MERGE: '1'
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Download coverage blobs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: coverage-blob-*
path: .tmp/coverage-blobs
# download-artifact nests each artifact in its own subdirectory; the blob
# merge reads one flat directory.
- name: Collect coverage blobs
run: |
set -euo pipefail
mkdir -p .vitest-reports
find .tmp/coverage-blobs -name '*.json' -exec mv {} .vitest-reports/ \;
ls .vitest-reports
# Reuses the blobs the shards wrote (never reruns tests) and fails when
# merged changed-line coverage < the threshold in
# scripts/coverage-changed/model.ts. The `coverage-waiver` PR label maps to
# the waiver env, which skips the failure but still prints the numbers.
# Gated on the merge step's own outcome (#1781 A5): when it fails,
# lcov.info is never written, so this step would just re-report that
# failure as its own red ("no lcov report") instead of a coverage verdict.
- name: Merge coverage shards
id: run-coverage
uses: ./.github/actions/run-gate
with: { gate: unit-ci }
- name: Enforce changed-line coverage gate
if: steps.run-coverage.outcome == 'success' && github.event_name == 'pull_request'
env:
AGENT_DEVICE_COVERAGE_WAIVER: ${{ contains(github.event.pull_request.labels.*.name, 'coverage-waiver') }}
uses: ./.github/actions/run-gate
with:
gate: coverage
args: |
--base
${{ github.event.pull_request.base.sha }}
integration:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Run integration tests
run: pnpm clean:daemon
- name: Execute integration tests
uses: ./.github/actions/run-gate
with: { gate: integration-node }
- name: Run seeded concurrency torture lane (fast PR sweep)
# #1416's nightly torture lane lives under test/integration/nightly/, out
# of the test:integration:node glob, so this is a *deliberate* fast PR
# sweep (TORTURE_RUNS default 128 seeds, ~sub-second) — not an accidental
# glob inclusion. The Concurrency Torture Nightly workflow sweeps a much
# larger seed range on schedule.
uses: ./.github/actions/run-gate
with: { gate: concurrency-torture }
- name: Run provider-backed integration tests
uses: ./.github/actions/run-gate
with: { gate: provider-integration }
- name: Check Provider-backed integration architecture progress
uses: ./.github/actions/run-gate
with: { gate: integration-progress }
# A build-cache lookup outage must degrade setup-fixture-app to an inline
# build, not fail the caller. This drives that step's real shell against a
# failing `gh`.
- name: Setup-fixture-app cache-failure fallback
uses: ./.github/actions/run-gate
with: { gate: fixture-fallback }
# The trusted-artifact contract the device lanes rely on to decide a cached fixture
# app is the one this commit expects. A real test file that no workflow ran.
- name: Check the trusted fixture-artifact contract
uses: ./.github/actions/run-gate
with: { gate: fixture-cache }
# Shares this job's ubuntu toolchain. AGENT_DEVICE_WEB_E2E is step-scoped
# so it cannot leak into the node/provider suites above.
- name: Run live web smoke
env:
AGENT_DEVICE_WEB_E2E: '1'
run: pnpm clean:daemon
- name: Execute live web smoke
env:
AGENT_DEVICE_WEB_E2E: '1'
uses: ./.github/actions/run-gate
with: { gate: web-smoke }
- name: Upload web smoke artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: web-smoke-artifacts
if-no-files-found: ignore
path: |
test/artifacts/web/**