diff --git a/AGENTS.md b/AGENTS.md index b1f11bd3db1..6e965f1e73f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -586,11 +586,13 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. over raw `Theme.of(context)` calls. - **Keep widgets small and composable.** One public widget per file; push private sub-widgets (`_Foo`) into sibling `part` files under a - `/` folder rather than growing the page file. Hard ceiling: - **1000 lines/file**, enforced across Desktop, Web, and Mobile by the + `/` folder rather than growing the page file. Mobile's hard ceiling is + **1200 lines/file**, enforced with the other surface-specific limits by the repository-level `just file-size-check` gate (`just check`, CI, and every - pre-push). If the guard trips, **split the file — never bump the limit or add - an override to slip under it.** + pre-push). If an individual file trips the guard, **split the file — never + bump a surface limit or add an override merely to admit that file.** + Deliberate repository-wide policy revisions must update the enforced rules, + tests, and guidance together. - Feature modules must not import from other feature modules — only from `shared/`. - Use `Grid` tokens for spacing, `Radii` for border radius. diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index bfe4fcc8570..bc2d179695b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -1,61 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -const MAX_LINES = 1000; - -const rules = [ - { root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES }, - // Workspace member crates. Without this the ratchet's only Rust root is - // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the - // repo's one size discipline -- silently, since the check still exits 0. - { - root: "src-tauri/crates", - extensions: new Set([".rs"]), - maxLines: MAX_LINES, - }, - { - root: "src/app", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/context", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/lib", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/ui", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/styles", - extensions: new Set([".css"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Desktop", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/desktop/scripts/file-size-policy.mjs b/desktop/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..5728b9187a6 --- /dev/null +++ b/desktop/scripts/file-size-policy.mjs @@ -0,0 +1,53 @@ +const DESKTOP_FRONTEND_MAX_LINES = 1200; +const DESKTOP_RUST_MAX_LINES = 1500; + +export const rules = [ + { + root: "src-tauri/src", + extensions: new Set([".rs"]), + maxLines: DESKTOP_RUST_MAX_LINES, + }, + // Workspace member crates. Without this the ratchet's only Rust root is + // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the + // repo's one size discipline -- silently, since the check still exits 0. + { + root: "src-tauri/crates", + extensions: new Set([".rs"]), + maxLines: DESKTOP_RUST_MAX_LINES, + }, + { + root: "src/app", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/features", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/api", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/context", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/lib", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/ui", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/styles", + extensions: new Set([".css"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, +]; diff --git a/desktop/src-tauri/src/archive/metric_store.rs b/desktop/src-tauri/src/archive/metric_store.rs index 9595e4d3323..78363223063 100644 --- a/desktop/src-tauri/src/archive/metric_store.rs +++ b/desktop/src-tauri/src/archive/metric_store.rs @@ -9,7 +9,7 @@ //! via [`AgentMetricIndexRow::from_payload`]. //! //! Kept in a sibling file (not `store.rs`) to keep that file under the -//! 1000-line gate, per the existing `pipeline.rs` precedent. +//! 1500-line gate, per the existing `pipeline.rs` precedent. use rusqlite::{params, Connection, OptionalExtension}; diff --git a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs index 2dc568d701c..337dbac922b 100644 --- a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs +++ b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs @@ -1,7 +1,7 @@ //! Kind-44200 (NIP-AM agent turn metric) archive and `get_agent_usage_series` //! integration tests for `archive/mod.rs`. //! -//! Kept in a sibling file so `mod_tests.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `mod_tests.rs` stays under the 1500-line gate; //! `#[path]`-included from there so the shared fixtures (`in_memory`, //! `add_sub`, `candidate`, `make_observer_frame`, `run_batch_sync_with_keys`) //! stay private to `mod_tests`. diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index 21587669268..c589b5bd522 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -1,6 +1,6 @@ //! Unit and integration tests for `archive/mod.rs`. //! -//! Kept in a sibling file so `mod.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `mod.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::pipeline::BucketWithResult; @@ -622,7 +622,7 @@ fn test_commit_archive_rolls_back_when_scope_write_would_fail() { } // Kind-44200 agent-turn-metric coverage lives in a sibling file to keep this -// one under the 1000-line gate; nested here (not in `mod.rs`) so it inherits +// one under the 1500-line gate; nested here (not in `mod.rs`) so it inherits // the shared fixtures above through `use super::*`. #[path = "mod_agent_metric_tests.rs"] mod agent_metric; diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index 98ff64dff48..f2fb3e6b895 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -1,6 +1,6 @@ //! Archive pipeline — three-phase plan/query/commit split. //! -//! Separated from `mod.rs` to keep that file under the 1000-line gate. +//! Separated from `mod.rs` to keep that file under the 1500-line gate. //! //! # Send-safety //! diff --git a/desktop/src-tauri/src/archive/retention.rs b/desktop/src-tauri/src/archive/retention.rs index 5ee9acff200..2e150da97a7 100644 --- a/desktop/src-tauri/src/archive/retention.rs +++ b/desktop/src-tauri/src/archive/retention.rs @@ -10,7 +10,7 @@ //! Phase-2 prune scan, the get/set accessors for the observer window, and the //! PRAGMA-based size readout. The prune worker itself lands in Phase 2. //! -//! Kept in a sibling file (not `store.rs`) to respect the 1000-line gate, per +//! Kept in a sibling file (not `store.rs`) to respect the 1500-line gate, per //! the existing `metric_store.rs` / `pipeline.rs` / `store_migrations.rs` //! precedent. diff --git a/desktop/src-tauri/src/archive/retention_tests.rs b/desktop/src-tauri/src/archive/retention_tests.rs index 26e6a25fdae..122cd01a99a 100644 --- a/desktop/src-tauri/src/archive/retention_tests.rs +++ b/desktop/src-tauri/src/archive/retention_tests.rs @@ -1,7 +1,7 @@ //! Behavior tests for the observer-retention setting, the size readout, and the //! M4 migration. //! -//! Kept in a sibling file so `retention.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `retention.rs` stays under the 1500-line gate; //! `#[path]`-included from there. `super::*` brings the retention API (and its //! `rusqlite::{params, Connection}` imports) into scope; `super::super::store` //! reaches the neighbouring subscription mutators and the base `SCHEMA`. diff --git a/desktop/src-tauri/src/archive/store_migration_tests.rs b/desktop/src-tauri/src/archive/store_migration_tests.rs index 6a40d7f4cd7..6aa585cfb46 100644 --- a/desktop/src-tauri/src/archive/store_migration_tests.rs +++ b/desktop/src-tauri/src/archive/store_migration_tests.rs @@ -1,6 +1,6 @@ //! Migration tests for `archive/store.rs` — M1: harness column. //! -//! Kept in a sibling file so `store_tests.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `store_tests.rs` stays under the 1500-line gate; //! `#[path]`-included from `store.rs`. use super::*; diff --git a/desktop/src-tauri/src/archive/store_migrations.rs b/desktop/src-tauri/src/archive/store_migrations.rs index 35a21e25d45..82a24e581c3 100644 --- a/desktop/src-tauri/src/archive/store_migrations.rs +++ b/desktop/src-tauri/src/archive/store_migrations.rs @@ -4,7 +4,7 @@ //! `archive_migrations`, so a migration that already ran is a no-op. //! //! Kept in a sibling file (not `store.rs`) to keep that file under the -//! 1000-line gate, per the existing `metric_store.rs` / `pipeline.rs` +//! 1500-line gate, per the existing `metric_store.rs` / `pipeline.rs` //! precedent. use rusqlite::{params, Connection}; diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index c0f85430d4d..b7e02d8f4dc 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `archive/store.rs`. //! -//! Kept in a sibling file so `store.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `store.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::*; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 9c9aa58c1fd..2952ce644d7 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `commands/agent_config.rs` (split to keep `agent_config.rs` -//! under the 1000-line file-size ratchet). +//! under the 1500-line file-size ratchet). //! //! Included via `#[path = "agent_config_tests.rs"] mod tests;` at the bottom of //! `agent_config.rs`, so `use super::*` gives access to all items in that module. diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index 407ab449744..d05e0f2480b 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `card.rs` — split into a child module file so the parent -//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`). +//! stays under the 1500-line gate (same layout as `snapshot/tests.rs`). use super::*; use std::collections::BTreeMap; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..4f87c129607 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -2,7 +2,7 @@ //! and their supporting helpers. //! //! Import-side commands and helpers live in `snapshot::import` to keep this -//! file under the 1000-line gate. +//! file under the 1500-line gate. //! //! Split from `personas/mod.rs` to keep that file under the line-count gate. diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 75a1edea65e..0b0de4ddbc5 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -1,6 +1,6 @@ //! Import-side helpers for `buzz-agent-snapshot v1`. //! -//! Extracted from `snapshot.rs` to keep that file under the 1000-line gate. +//! Extracted from `snapshot.rs` to keep that file under the 1500-line gate. //! The Tauri commands here (`preview_agent_snapshot_import`, //! `confirm_agent_snapshot_import`) are re-exported from `snapshot.rs` and //! registered in `lib.rs` through the same `personas::` path as the export diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs index 36eaa997163..136ef65a453 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs @@ -1,7 +1,7 @@ //! Export-size guard tests for `validate_snapshot_encode_size`. //! //! Kept in a sibling file so `snapshot/tests.rs` stays under the -//! 1000-line gate; `#[path]`-included from there as a child module, +//! 1500-line gate; `#[path]`-included from there as a child module, //! so `super::*` still resolves to the shared test imports. //! //! Tests call `validate_snapshot_encode_size` directly so they prove the diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs index 296444f78d0..43ca23cc822 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -1,7 +1,7 @@ //! Locked-card import tests for `decode_snapshot_for_import`. //! //! Kept in a sibling file so `snapshot/tests.rs` stays under the -//! 1000-line gate; `#[path]`-included from there as a child module, +//! 1500-line gate; `#[path]`-included from there as a child module, //! so `super::*` still resolves to the shared test helpers. use super::*; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs index b17efa1ad11..e327cb0e491 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs @@ -1,6 +1,6 @@ //! Tests for `memory_entries_from_listing` — the shared level → entries //! selection used by both snapshot export and card minting. Split from -//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included +//! `tests.rs` to keep that file under the 1500-line gate; `#[path]`-included //! from there as a child module, so `super::*` resolves to `tests`'s parent //! scope re-exports. diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index 9f234749bc9..2fe94da3f17 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -1,7 +1,7 @@ //! Unit tests for `managed_agents/agent_snapshot.rs`. //! //! Kept in a sibling file so `agent_snapshot.rs` stays under the -//! 1000-line gate; `#[path]`-included from there. +//! 1500-line gate; `#[path]`-included from there. use super::*; use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 36b6022b53b..244381d2d01 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `config_bridge/reader.rs` (kept in a sibling file so -//! `reader.rs` stays under the 1000-line budget; `#[path]`-included from +//! `reader.rs` stays under the 1500-line budget; `#[path]`-included from //! there). use std::{collections::BTreeMap, path::Path, sync::Mutex}; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index f86793f91a1..0974bf9c581 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -1,5 +1,5 @@ //! Additional tests for `config_bridge/reader.rs` — split out to keep -//! `reader_tests.rs` under the 1000-line file-size ratchet. +//! `reader_tests.rs` under the 1500-line file-size ratchet. //! //! Included as `mod ext` inside `reader_tests.rs`, so `use super::*` gives //! access to all helpers and types from that module. diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index dd708b6e59e..fa528899fd6 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -1,5 +1,5 @@ //! B5 effort lifecycle tests split out of `spawn_snapshot/tests.rs` to hold -//! that file under the 1000-line file-size ratchet. +//! that file under the 1500-line file-size ratchet. //! //! Included as `mod ext` inside `tests.rs`, so `use super::*` gives access to //! its `record`, `snap`, and `record_with_env_effort` helpers. diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac3..d39fcf41009 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `managed_agents/storage.rs`. //! -//! Kept in a sibling file so `storage.rs` stays closer to the 1000-line gate; +//! Kept in a sibling file so `storage.rs` stays closer to the 1500-line gate; //! `#[path]`-included from there. use std::cell::RefCell; diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index ff7900d3923..64d67f57cd8 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `managed_agents/teams.rs`. //! -//! Kept in a sibling file so `teams.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `teams.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::{ diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7822211541b..9f53fbe8949 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -153,8 +153,8 @@ with a TypeScript lookup table or an id comparison in a component. place that resolves it for dialog surfaces and publishes it through `ui/AgentRunLocationContext.tsx`; the field reads that context and lets an explicit `runLocation` prop win. Do **not** thread the value as a prop - through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — both are - already over the 1000-line ceiling, and neither uses the value itself. + through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — neither uses + the value itself, and the shared context keeps the dialog boundary stable. Surfaces rendered outside `AgentDialog` (e.g. `EditRespondToDialog`) pass the prop directly. Local names "your computer, including files, accounts, and connected tools"; remote names "the @@ -216,9 +216,9 @@ with a TypeScript lookup table or an id comparison in a component. cosmetic — the Rust command rejects non-local backends because remote effort is set at deploy time via `policy_env`. Because it reads its inputs from the config surface the dialog already fetches (`useAgentConfigSurface`) and owns - its own mutation, it does **not** thread new props through the over-1000-line - dialog (see rule 11): keep effort state inside the section component, never - as dialog-level props. The read-only display is the `thinkingEffort` + its own mutation, it does **not** thread new props through the dialog (see + rule 11): keep effort state inside the section component, never as + dialog-level props. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which already shows both facts — `field.value` (canonical, the effort the next spawn will launch with) and, when a running ACP session differs, diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index a99822ede5e..1282e5f1e14 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -150,7 +150,7 @@ class _ChannelRefreshFence { /// nothing awaits it, so a throw would only surface as an unhandled error. /// /// An extension in this part file rather than a method on the notifier because -/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// `channels_provider.dart` sits against the repository-wide 1200-line file /// ceiling enforced by `just file-size-check`. extension _CatchUpFencing on ChannelsNotifier { bool _isCatchUpRetired( @@ -188,7 +188,7 @@ Future _fenced(_ChannelRefreshFence fence, Future future) async { /// profiles in one round-trip. Returns lowercase pubkey to label. /// /// Lives in this part file because `channels_provider.dart` sits against the -/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. Future> _resolveDmDisplayNames( RelaySessionNotifier session, _ChannelRefreshFence fence, @@ -271,7 +271,7 @@ Future> _fetchHuddleStarts( /// Counts distinct `p`-tagged members per channel from kind:39002 events. /// /// Lives in this part file to keep `channels_provider.dart` under the -/// repository-wide 1000-line ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line ceiling enforced by `just file-size-check`. Map _memberCountsByChannelId(Iterable memberEvents) { final memberCounts = {}; for (final event in memberEvents) { @@ -297,7 +297,7 @@ Map _memberCountsByChannelId(Iterable memberEvents) { /// filter, so a retired response is discarded rather than merged. /// /// Lives in this part file because `channels_provider.dart` sits against the -/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. class _ChannelRefreshCoordinator { /// Resolves the relay-and-identity scope that is active right now. final String Function() currentScope; @@ -429,7 +429,7 @@ Future> _fetchPaginatedChannelEvents( /// Thread-interest and unread helpers shared by [ChannelsNotifier]. /// /// Lives in this part file because `channels_provider.dart` sits against the -/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. String? _observedUnreadRootId(NostrEvent event) => _isBroadcastReply(event) ? null : event.threadReference.rootId; @@ -456,7 +456,7 @@ String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); /// Records one observed unread event for a channel's badge state. /// /// An extension in this part file rather than a method on the notifier because -/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// `channels_provider.dart` sits against the repository-wide 1200-line file /// ceiling enforced by `just file-size-check`. Private members stay reachable: /// a part shares its parent's library. extension _ObservedUnreadRecording on ChannelsNotifier { diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 03f29c58a1b..04acaf88386 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -210,7 +210,7 @@ class ChannelsNotifier extends AsyncNotifier> { final dedupedMetas = latestMetaPerId.values; // Resolve DM participant display names. Extracted into the part file so - // `channels_provider.dart` stays under the 1000-line ceiling enforced by + // `channels_provider.dart` stays under the 1200-line ceiling enforced by // `just file-size-check`. final displayNames = await _resolveDmDisplayNames( session, diff --git a/mobile/scripts/check-file-sizes.mjs b/mobile/scripts/check-file-sizes.mjs index 765cd8edcfb..62aa93295ee 100644 --- a/mobile/scripts/check-file-sizes.mjs +++ b/mobile/scripts/check-file-sizes.mjs @@ -1,22 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -const MAX_LINES = 1000; - -const rules = [ - { - root: "lib", - extensions: new Set([".dart"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Mobile", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/mobile/scripts/file-size-policy.mjs b/mobile/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..2d05dfb4be5 --- /dev/null +++ b/mobile/scripts/file-size-policy.mjs @@ -0,0 +1,7 @@ +export const rules = [ + { + root: "lib", + extensions: new Set([".dart"]), + maxLines: 1200, + }, +]; diff --git a/scripts/check-file-sizes-core.test.mjs b/scripts/check-file-sizes-core.test.mjs index 9b4b910404d..986d1a3810c 100644 --- a/scripts/check-file-sizes-core.test.mjs +++ b/scripts/check-file-sizes-core.test.mjs @@ -1,9 +1,19 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { policy as desktopPolicy } from "../desktop/scripts/check-file-sizes.mjs"; +import { policy as mobilePolicy } from "../mobile/scripts/check-file-sizes.mjs"; +import { policy as webPolicy } from "../web/scripts/check-file-sizes.mjs"; import { allowedLineCount, countLines, @@ -12,6 +22,8 @@ import { resolveBaseRef, } from "./check-file-sizes-core.mjs"; +const repoRoot = path.resolve(import.meta.dirname, ".."); + function git(repo, ...args) { // These fixture repositories inherit both hook configuration and Git's // repository-local environment when this test runs from pre-push. Isolate @@ -26,6 +38,62 @@ function git(repo, ...args) { }).trim(); } +function createEntrypointFixture({ + surface, + files, + lineDelta = 1, + symlinkEntrypoint = false, +}) { + const repo = realpathSync( + mkdtempSync(path.join(tmpdir(), `file-size-${surface}-`)), + ); + const scriptsDir = path.join(repo, "scripts"); + const surfaceScriptsDir = path.join(repo, surface, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(surfaceScriptsDir, { recursive: true }); + copyFileSync( + path.join(repoRoot, "scripts/check-file-sizes-core.mjs"), + path.join(scriptsDir, "check-file-sizes-core.mjs"), + ); + for (const fileName of ["check-file-sizes.mjs", "file-size-policy.mjs"]) { + copyFileSync( + path.join(repoRoot, surface, "scripts", fileName), + path.join(surfaceScriptsDir, fileName), + ); + } + + git(repo, "init", "-b", "main"); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "add", "."); + git(repo, "commit", "-m", "base"); + const base = git(repo, "rev-parse", "HEAD"); + git(repo, "switch", "-c", "feature"); + + for (const { relativeFile, maxLines } of files) { + const governedFile = path.join(repo, surface, relativeFile); + const lineCount = maxLines + lineDelta; + mkdirSync(path.dirname(governedFile), { recursive: true }); + writeFileSync(governedFile, `${"line\n".repeat(lineCount - 1)}line`); + } + + const realEntrypointPath = path.join( + surfaceScriptsDir, + "check-file-sizes.mjs", + ); + let entrypointPath = realEntrypointPath; + if (symlinkEntrypoint) { + entrypointPath = path.join(repo, `${surface}-file-size-check.mjs`); + symlinkSync(realEntrypointPath, entrypointPath); + } + const result = spawnSync(realpathSync(process.execPath), [entrypointPath], { + cwd: repo, + encoding: "utf8", + env: { ...process.env, CHECK_FILE_SIZES_BASE: base }, + }); + return { result, relativeFiles: files.map(({ relativeFile }) => relativeFile) }; +} + test("local base resolution uses the branch merge-base and fails without origin/main", () => { const repo = mkdtempSync(path.join(tmpdir(), "file-size-base-")); git(repo, "init", "-b", "main"); @@ -47,12 +115,151 @@ test("local base resolution uses the branch merge-base and fails without origin/ ); }); +const entrypointCases = [ + { + surface: "desktop", + files: [ + { relativeFile: "src-tauri/src/oversized.rs", maxLines: 1500 }, + { relativeFile: "src-tauri/crates/oversized.rs", maxLines: 1500 }, + { relativeFile: "src/app/oversized.ts", maxLines: 1200 }, + { relativeFile: "src/features/oversized.tsx", maxLines: 1200 }, + { relativeFile: "src/shared/api/oversized.ts", maxLines: 1200 }, + { relativeFile: "src/shared/context/oversized.tsx", maxLines: 1200 }, + { relativeFile: "src/shared/lib/oversized.ts", maxLines: 1200 }, + { relativeFile: "src/shared/ui/oversized.tsx", maxLines: 1200 }, + { relativeFile: "src/shared/styles/oversized.css", maxLines: 1200 }, + ], + }, + { + surface: "mobile", + files: [{ relativeFile: "lib/oversized.dart", maxLines: 1200 }], + }, + { + surface: "web", + files: [ + { relativeFile: "src/app/oversized.ts", maxLines: 1000 }, + { relativeFile: "src/features/oversized.tsx", maxLines: 1000 }, + { relativeFile: "src/shared/api/oversized.ts", maxLines: 1000 }, + ], + }, +]; + +test("surface entrypoints execute every production rule", () => { + for (const fixture of entrypointCases) { + const { result, relativeFiles } = createEntrypointFixture(fixture); + assert.equal( + result.status, + 1, + `${fixture.surface} should reject every ceiling + 1: ${result.stderr || result.stdout}`, + ); + for (const relativeFile of relativeFiles) { + assert.ok( + result.stderr.includes(relativeFile), + `${fixture.surface} should report ${relativeFile}: ${result.stderr}`, + ); + } + } +}); + +test("surface entrypoints execute through symlinked paths", () => { + for (const fixture of entrypointCases) { + const { result, relativeFiles } = createEntrypointFixture({ + ...fixture, + symlinkEntrypoint: true, + }); + assert.equal( + result.status, + 1, + `${fixture.surface} symlink should reject ceiling + 1: ${result.stderr || result.stdout}`, + ); + for (const relativeFile of relativeFiles) { + assert.ok( + result.stderr.includes(relativeFile), + `${fixture.surface} symlink should report ${relativeFile}: ${result.stderr}`, + ); + } + } +}); + +test("surface entrypoints allow every production rule at its ceiling", () => { + for (const fixture of entrypointCases) { + const { result } = createEntrypointFixture({ ...fixture, lineDelta: 0 }); + assert.equal( + result.status, + 0, + `${fixture.surface} should allow every ceiling: ${result.stderr || result.stdout}`, + ); + } +}); + test("counts empty, LF, and CRLF content with the existing semantics", () => { assert.equal(countLines(""), 0); assert.equal(countLines("one\n"), 2); assert.equal(countLines("one\r\ntwo"), 2); }); +test("surface entrypoints expose the exact ordered production policies", () => { + const policies = [ + [ + desktopPolicy, + [ + ["src-tauri/src", [".rs"], 1500], + ["src-tauri/crates", [".rs"], 1500], + ["src/app", [".ts", ".tsx"], 1200], + ["src/features", [".ts", ".tsx"], 1200], + ["src/shared/api", [".ts", ".tsx"], 1200], + ["src/shared/context", [".ts", ".tsx"], 1200], + ["src/shared/lib", [".ts", ".tsx"], 1200], + ["src/shared/ui", [".ts", ".tsx"], 1200], + ["src/shared/styles", [".css"], 1200], + ], + ], + [mobilePolicy, [["lib", [".dart"], 1200]]], + [ + webPolicy, + [ + ["src/app", [".ts", ".tsx"], 1000], + ["src/features", [".ts", ".tsx"], 1000], + ["src/shared/api", [".ts", ".tsx"], 1000], + ], + ], + ]; + + for (const [policy, expectedRules] of policies) { + const actualRules = policy.rules.map((rule) => [ + rule.root, + [...rule.extensions], + rule.maxLines, + ]); + assert.deepEqual( + actualRules, + expectedRules, + `${policy.label} production rules`, + ); + + for (const rule of policy.rules) { + assert.equal( + evaluateFileSize({ + baseLines: null, + candidateLines: rule.maxLines, + maxLines: rule.maxLines, + }).violates, + false, + `${policy.label} ${rule.root} should allow the ceiling`, + ); + assert.equal( + evaluateFileSize({ + baseLines: null, + candidateLines: rule.maxLines + 1, + maxLines: rule.maxLines, + }).violates, + true, + `${policy.label} ${rule.root} should reject ceiling + 1`, + ); + } + } +}); + test("new files use the configured ceiling", () => { assert.equal(allowedLineCount(null, 1000), 1000); assert.deepEqual( diff --git a/web/scripts/check-file-sizes.mjs b/web/scripts/check-file-sizes.mjs index 810a2b7ae72..f43b596cd16 100644 --- a/web/scripts/check-file-sizes.mjs +++ b/web/scripts/check-file-sizes.mjs @@ -1,32 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -const MAX_LINES = 1000; - -const rules = [ - { - root: "src/app", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Web", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/web/scripts/file-size-policy.mjs b/web/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..7c1bdd8a5ed --- /dev/null +++ b/web/scripts/file-size-policy.mjs @@ -0,0 +1,19 @@ +const MAX_LINES = 1000; + +export const rules = [ + { + root: "src/app", + extensions: new Set([".ts", ".tsx"]), + maxLines: MAX_LINES, + }, + { + root: "src/features", + extensions: new Set([".ts", ".tsx"]), + maxLines: MAX_LINES, + }, + { + root: "src/shared/api", + extensions: new Set([".ts", ".tsx"]), + maxLines: MAX_LINES, + }, +];