mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Stop the baseline growth guard from failing open
The base-baseline lookup swallowed every git error, not just a missing file, so any failure to read the base blob left TYPECHECK_BASELINE_BASE_PATH unset and the job passed printing "no drift". That guard is the only thing that catches a raised baseline: the drift check compares the tree against its own committed baseline, so growth is invisible to it. Ask tree objects whether the base has the file, which also distinguishes a genuine absence from a failure, and hard-fail on anything else. The lookup is a bare assignment because inside `[ -n "$(...)" ]` the exit status is discarded, which would have preserved the same fail-open. Each branch now says which one it took, since the guard is silently inactive on any PR that introduces a baseline. Run mobile checks on src/shared changes. Mobile's program pulls 300+ files from there and its baseline now matches on message text, so a desktop-side rename can reword a mobile diagnostic and redden that job for an unrelated author, on a PR that never triggers it. Scan the whole tree for extension shadowing rather than a list of roots that drifts from the tsconfig globs, covering repo-root and mobile/scripts files it previously missed, plus .mts/.cts pairs. State what the e2e ratchet does and does not catch: identity excludes line and column, so a new error is caught unless it exactly matches one fixed in the same file.
This commit is contained in:
@@ -12,6 +12,13 @@ on:
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
# Why all of src/shared: mobile's tsconfig pulls 300+ files from it, and the
|
||||
# typecheck baseline now matches on message text, so a desktop-side rename can
|
||||
# reword a mobile diagnostic and redden this job for an unrelated author.
|
||||
- 'src/shared/**'
|
||||
# Why: the mobile typecheck runs through this script and its baseline.
|
||||
- 'config/scripts/typecheck-diagnostic-baseline.mjs'
|
||||
- 'mobile/typecheck-test-diagnostics.json'
|
||||
# Why: this job holds the only checks that load the Fastfile, so edits to
|
||||
# it or to the release workflow it guards must re-run them.
|
||||
- '.github/workflows/mobile.yml'
|
||||
@@ -44,10 +51,20 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git cat-file -e "$BASE_SHA^{commit}"
|
||||
if git cat-file -e "$BASE_SHA:mobile/typecheck-test-diagnostics.json" 2>/dev/null; then
|
||||
# Why ls-tree --full-tree: it answers "does the base have this file" from tree
|
||||
# objects alone, so a missing baseline is distinguishable from a git failure.
|
||||
# Anything other than a clean absence aborts the step rather than silently
|
||||
# leaving the growth guard unset, which would pass as "no drift".
|
||||
# Why a bare assignment: inside `[ -n "$(...)" ]` the substitution's exit status
|
||||
# is discarded, so a git failure would read as "base has no baseline" and pass.
|
||||
base_entry="$(git ls-tree -r --full-tree --name-only "$BASE_SHA" -- mobile/typecheck-test-diagnostics.json)"
|
||||
if [ -n "$base_entry" ]; then
|
||||
base_baseline="$RUNNER_TEMP/typecheck-mobile-base.json"
|
||||
git show "$BASE_SHA:mobile/typecheck-test-diagnostics.json" > "$base_baseline"
|
||||
echo "TYPECHECK_BASELINE_BASE_PATH=$base_baseline" >> "$GITHUB_ENV"
|
||||
echo "Mobile growth guard ACTIVE against $BASE_SHA."
|
||||
else
|
||||
echo "Mobile base has no baseline; this is the introducing PR, growth guard INACTIVE."
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -219,10 +219,20 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git cat-file -e "$BASE_SHA^{commit}"
|
||||
if git cat-file -e "$BASE_SHA:config/typecheck-e2e-diagnostics.json" 2>/dev/null; then
|
||||
# Why ls-tree --full-tree: it answers "does the base have this file" from tree
|
||||
# objects alone, so a missing baseline is distinguishable from a git failure.
|
||||
# Anything other than a clean absence aborts the step rather than silently
|
||||
# leaving the growth guard unset, which would pass as "no drift".
|
||||
# Why a bare assignment: inside `[ -n "$(...)" ]` the substitution's exit status
|
||||
# is discarded, so a git failure would read as "base has no baseline" and pass.
|
||||
base_entry="$(git ls-tree -r --full-tree --name-only "$BASE_SHA" -- config/typecheck-e2e-diagnostics.json)"
|
||||
if [ -n "$base_entry" ]; then
|
||||
base_baseline="$RUNNER_TEMP/typecheck-e2e-base.json"
|
||||
git show "$BASE_SHA:config/typecheck-e2e-diagnostics.json" > "$base_baseline"
|
||||
echo "TYPECHECK_BASELINE_BASE_PATH=$base_baseline" >> "$GITHUB_ENV"
|
||||
echo "E2E growth guard ACTIVE against $BASE_SHA."
|
||||
else
|
||||
echo "E2E base has no baseline; this is the introducing PR, growth guard INACTIVE."
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
@@ -8,38 +8,50 @@ import { describe, expect, it } from 'vitest'
|
||||
* the program: tsconfig `include` expansion keeps one file per extensionless path
|
||||
* and `.ts` outranks `.tsx`. Nothing errors — the file just stops being typechecked,
|
||||
* which is the exact hole the typecheck-coverage work exists to close.
|
||||
*
|
||||
* Why the whole tree rather than a list of roots: a hand-kept list drifts from the
|
||||
* tsconfig `include` globs it is meant to shadow, and the gap is invisible.
|
||||
*/
|
||||
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const SCANNED_ROOTS = ['src', 'tests', 'config', 'mobile/src', 'mobile/app']
|
||||
const IGNORED_DIRECTORIES = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'out',
|
||||
'build',
|
||||
'.git',
|
||||
'coverage',
|
||||
'.cross-version-checkouts'
|
||||
])
|
||||
// TypeScript resolves these in order, so an earlier entry shadows a later one.
|
||||
const EXTENSION_PRIORITY = ['.ts', '.tsx', '.d.ts']
|
||||
const EXTENSION_PRIORITY = ['.ts', '.tsx', '.mts', '.cts', '.d.ts', '.d.mts', '.d.cts']
|
||||
|
||||
function collectFiles(root) {
|
||||
let found = []
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(root)
|
||||
entries = readdirSync(root, { withFileTypes: true })
|
||||
} catch {
|
||||
return found
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (IGNORED_DIRECTORIES.has(entry)) {
|
||||
if (IGNORED_DIRECTORIES.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
const fullPath = join(root, entry)
|
||||
if (statSync(fullPath).isDirectory()) {
|
||||
const fullPath = join(root, entry.name)
|
||||
let isDirectory = entry.isDirectory()
|
||||
if (entry.isSymbolicLink()) {
|
||||
// Why guarded: a dangling symlink must not fail the sweep with a raw ENOENT.
|
||||
try {
|
||||
isDirectory = statSync(fullPath).isDirectory()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (isDirectory) {
|
||||
found = found.concat(collectFiles(fullPath))
|
||||
continue
|
||||
}
|
||||
if (EXTENSION_PRIORITY.some((extension) => entry.endsWith(extension))) {
|
||||
if (EXTENSION_PRIORITY.some((extension) => entry.name.endsWith(extension))) {
|
||||
found.push(fullPath)
|
||||
}
|
||||
}
|
||||
@@ -47,21 +59,19 @@ function collectFiles(root) {
|
||||
}
|
||||
|
||||
function extensionOf(path) {
|
||||
return path.endsWith('.d.ts') ? '.d.ts' : path.endsWith('.tsx') ? '.tsx' : '.ts'
|
||||
}
|
||||
|
||||
function stemOf(path) {
|
||||
return path.slice(0, -extensionOf(path).length)
|
||||
return EXTENSION_PRIORITY.filter((extension) => path.endsWith(extension)).sort(
|
||||
(left, right) => right.length - left.length
|
||||
)[0]
|
||||
}
|
||||
|
||||
describe('typecheck extension shadowing', () => {
|
||||
it('has no file hidden from the program by a higher-priority extension', () => {
|
||||
const byStem = new Map()
|
||||
for (const root of SCANNED_ROOTS) {
|
||||
for (const file of collectFiles(join(repoRoot, root))) {
|
||||
const stem = stemOf(relative(repoRoot, file).replaceAll('\\', '/'))
|
||||
byStem.set(stem, [...(byStem.get(stem) ?? []), extensionOf(file)])
|
||||
}
|
||||
for (const file of collectFiles(repoRoot)) {
|
||||
const relativePath = relative(repoRoot, file).replaceAll('\\', '/')
|
||||
const extension = extensionOf(relativePath)
|
||||
const stem = relativePath.slice(0, -extension.length)
|
||||
byStem.set(stem, [...(byStem.get(stem) ?? []), extension])
|
||||
}
|
||||
|
||||
const shadowed = [...byStem.entries()]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
// Enforced by `pnpm typecheck`; pre-existing diagnostic identities and counts are ratcheted
|
||||
// in typecheck-e2e-diagnostics.json so new errors fail while the backlog shrinks.
|
||||
// in typecheck-e2e-diagnostics.json so the backlog only shrinks. Identity is
|
||||
// {file, code, message} without line/column, so a new error is caught unless it exactly
|
||||
// matches one that was fixed in the same file. Regenerate with `pnpm typecheck:e2e:write`.
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
// Why: specs import renderer and main modules directly, so the ambient declarations those
|
||||
// modules rely on (vite client types, build-time defines) have to be in the program too.
|
||||
|
||||
Reference in New Issue
Block a user