diff --git a/.github/frontend-architecture-manifest.md b/.github/frontend-architecture-manifest.md new file mode 100644 index 000000000..0aa52ec58 --- /dev/null +++ b/.github/frontend-architecture-manifest.md @@ -0,0 +1,184 @@ +# Frontend Architecture Manifest + +Canonical home for PawWork frontend architecture debt governance. + +This manifest covers git-tracked, hand-written frontend files under `packages/app/src` and `packages/ui/src`. It replaces chat memory as the handoff surface for this work. GitHub issue bodies and PR bodies should summarize and link here instead of carrying a second backlog. + +## Current State + +- Manifest status: governance baseline. +- Baseline commit: `5da4d3d61` (`fix: harden frontend inventory classification`). +- Default branch: `dev`. +- Master owner: [#599 UI rewrite v2](https://github.com/Astro-Han/pawwork/issues/599). +- Historical issue [#440](https://github.com/Astro-Han/pawwork/issues/440) is closed and must not be used as a new work entry. +- Perf gate [#600](https://github.com/Astro-Han/pawwork/issues/600) is closed/completed and is required for implementation PRs. + +## Inventory Command + +The repeatable report command is: + +```sh +bun run frontend:inventory +node script/frontend-inventory.mjs --format json +bun run frontend:inventory -- --format markdown --max-rows 120 +``` + +The script is warn-only in this governance phase. It prints threshold warnings but exits successfully. + +## Report Schema + +The JSON report uses schema version `1`. + +| Field | Meaning | +| --- | --- | +| `summary.schemaVersion` | Report schema version. | +| `summary.command` | Reproduction command for the JSON report. | +| `summary.locMetric` | Current line-count metric. | +| `summary.paths` | Git pathspecs included in the inventory. | +| `summary.totalTrackedTsTsx` | Total tracked `.ts` / `.tsx` frontend files. | +| `summary.production` | Files in the production ratchet set. | +| `summary.visibilityOnly` | Files visible to agents but excluded from default ratchet. | +| `summary.approvedExceptions` | Files with an approved exception entry. | +| `summary.productionOver500` | Production files above the hard warning line. | +| `summary.productionOver200` | Production files above the owner-manifest line. | +| `summary.visibilityOver500` | Visibility-only files above 500 LOC. | +| `summary.visibilityOver200` | Visibility-only files above 200 LOC. | +| `byOwnerLane` | Production counts grouped by owner lane. | +| `records[].path` | Repo-relative file path. | +| `records[].loc` | Physical LOC, including blank lines and comments. | +| `records[].setType` | `production ratchet set` or `visibility-only inventory`. | +| `records[].classifications` | `production`, `test`, `story`, `i18n`, `generated-static`, `pure-config`, `facade`, or `delivered-surface`. | +| `records[].ownerLane` | Owner lane used for planning. | +| `records[].ownerIssue` | Live issue URL when available. | +| `records[].approvedException` | Exception entry, or `null`. | +| `records[].status` | Current governance state for the file. | +| `records[].reason` | Why the status was assigned. | +| `records[].classificationReason` | Why the file entered its set. | +| `records[].ownerReason` | Why the owner lane was selected. | + +If this work changes from physical LOC to logical LOC or another metric, add schema version `2` and do not mix v1 and v2 baseline numbers. + +## Baseline Summary + +Generated with `node script/frontend-inventory.mjs --format json` at baseline commit `5da4d3d61`. +This replaces the earlier `eb80bed96` draft baseline because the review fix made the report include root-level `src/*.ts(x)` files and stopped classifying logic-bearing `index.ts` / pure-config candidates as visibility-only inventory. + +| Metric | Count | +| --- | ---: | +| Tracked `.ts` / `.tsx` files | 682 | +| Production ratchet set | 356 | +| Visibility-only inventory | 326 | +| Approved exceptions | 0 | +| Production files `>500` LOC | 26 | +| Production files `>200` LOC | 80 | +| Visibility-only files `>500` LOC | 15 | +| Visibility-only files `>200` LOC | 56 | + +Production by owner lane: + +| Owner lane | Files | `>200` | `>500` | +| --- | ---: | ---: | ---: | +| other/deferred | 130 | 24 | 5 | +| #638 interface audit | 7 | 1 | 0 | +| #604 settings | 21 | 9 | 3 | +| #599 mainline | 2 | 0 | 0 | +| #606 final shell | 45 | 14 | 6 | +| #601 message flow | 56 | 9 | 5 | +| #605 visual shell | 84 | 18 | 6 | +| #595/#615 scroll-perf | 11 | 5 | 1 | + +## Owner Lanes + +| Lane | Scope | Current state | +| --- | --- | --- | +| [#599 mainline](https://github.com/Astro-Han/pawwork/issues/599) | UI rewrite v2 launch path and integration owner | Open. Primary sequencing source. | +| [#601 message flow](https://github.com/Astro-Han/pawwork/issues/601) | Message timeline, turn shell, message shell, markdown, tool rows | Open. First launch-path implementation lane after governance. | +| [#604 settings](https://github.com/Astro-Han/pawwork/issues/604) | Settings page and settings dialogs | Open. Can be independent, but avoid current #642 typography sweep overlap. | +| [#605 visual shell](https://github.com/Astro-Han/pawwork/issues/605) | Shared visual shell, theme, typography, tokens, motion | Open. Starts after at least two Areas A-D first behavioral PRs. | +| [#606 final shell](https://github.com/Astro-Han/pawwork/issues/606) | Layout, global shell, final assembly | Open. Last lane by issue contract. | +| [#595/#615 scroll-perf](https://github.com/Astro-Han/pawwork/issues/595) | Scroll owner, perf owner, long-session responsiveness | Open. Keep independent from visual-only splits. | +| [#638 interface audit](https://github.com/Astro-Han/pawwork/issues/638) | Cross-package public contracts and type/interface consistency | Open. Contract PRs only, not mixed with UI surface splits. | +| `other/deferred` | No active owner lane matched, or only a closed area matched | Requires a live issue before implementation. | + +Closed area references are allowed as background only. [#602](https://github.com/Astro-Han/pawwork/issues/602) and [#603](https://github.com/Astro-Han/pawwork/issues/603) are closed/completed; do not reopen them by implication. + +## Ratchet Stages + +| Stage | Rule | +| --- | --- | +| 1. Governance | Warn only. Establish baseline, schema, owner map, report command, and exception format. | +| 2. New `>500` guard | Do not add or modify an unexplained production file above 500 LOC. | +| 3. New `>200` guard | Do not add or modify a production file above 200 LOC without owner lane and manifest entry. | +| 4. Tighter ratchet | Only after the launch path is stable and current exceptions are reviewed. | + +## Exception Schema + +An approved exception must include all fields below. An exception without a live issue and review trigger is not accepted. + +| Field | Required | +| --- | --- | +| File path | Yes | +| Owner lane / issue | Yes | +| Why it is not split now | Yes | +| Risk level | Yes | +| Conditions that allow it to remain | Yes | +| Conditions that trigger review | Yes | +| Next PR boundary | Yes | + +Current approved exceptions: none. + +## Production Burn-down + +`>500` production files at baseline: + +| LOC | Owner Lane | Status | Path | +| ---: | --- | --- | --- | +| 2463 | #606 final shell | needs-over-500-resolution | `packages/app/src/pages/layout.tsx` | +| 1217 | #601 message flow | needs-over-500-resolution | `packages/app/src/pages/session/message-timeline.tsx` | +| 1129 | #605 visual shell | needs-over-500-resolution | `packages/ui/src/components/file.tsx` | +| 1119 | #606 final shell | needs-over-500-resolution | `packages/app/src/context/layout.tsx` | +| 753 | #601 message flow | needs-over-500-resolution | `packages/ui/src/components/session-turn.tsx` | +| 733 | #604 settings | needs-over-500-resolution | `packages/app/src/components/settings-general.tsx` | +| 691 | #601 message flow | needs-over-500-resolution | `packages/app/src/pages/session.tsx` | +| 673 | other/deferred | needs-over-500-resolution | `packages/app/src/components/terminal.tsx` | +| 665 | #606 final shell | needs-over-500-resolution | `packages/app/src/context/sync.tsx` | +| 661 | #605 visual shell | needs-over-500-resolution | `packages/ui/src/components/session-review.tsx` | +| 654 | #604 settings | needs-over-500-resolution | `packages/app/src/components/dialog-connect-provider.tsx` | +| 649 | #604 settings | needs-over-500-resolution | `packages/app/src/components/dialog-select-server.tsx` | +| 634 | other/deferred | needs-over-500-resolution | `packages/app/src/addons/serialize.ts` | +| 618 | #601 message flow | needs-over-500-resolution | `packages/app/src/pages/session/use-session-commands.tsx` | +| 598 | #601 message flow | needs-over-500-resolution | `packages/ui/src/components/markdown.tsx` | +| 596 | #605 visual shell | needs-over-500-resolution | `packages/ui/src/components/line-comment-annotations.tsx` | +| 595 | #595/#615 scroll-perf | needs-over-500-resolution | `packages/app/src/pages/session/session-timeline-scroll-controller.ts` | +| 588 | #605 visual shell | needs-over-500-resolution | `packages/ui/src/components/file-icon.tsx` | +| 568 | other/deferred | needs-over-500-resolution | `packages/app/src/utils/persist.ts` | +| 560 | #606 final shell | needs-over-500-resolution | `packages/app/src/context/local.tsx` | +| 553 | other/deferred | needs-over-500-resolution | `packages/app/src/pages/session/session-side-panel.tsx` | +| 543 | #606 final shell | needs-over-500-resolution | `packages/app/src/context/global-sync.tsx` | +| 540 | #605 visual shell | needs-over-500-resolution | `packages/ui/src/theme/resolve.ts` | +| 527 | #605 visual shell | needs-over-500-resolution | `packages/ui/src/context/marked.tsx` | +| 511 | #606 final shell | needs-over-500-resolution | `packages/app/src/context/terminal.tsx` | +| 507 | other/deferred | needs-over-500-resolution | `packages/app/src/components/file-tree.tsx` | + +`>200` files are tracked by the report command. Use: + +```sh +bun run frontend:inventory -- --format markdown --max-rows 120 +``` + +## PR Manifest + +| PR | Owner lane | Base | Depends on | Boundary | Architecture effect | Verification | Status | Public write status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Governance PR | #599 mainline / governance | `dev` | None | Manifest, schema, owner map, warn-only script, baseline report command | boundary created, owner map established, ratchet command added | `bun run frontend:inventory`, `node script/frontend-inventory.mjs --format json`, `bun run frontend:inventory -- --format markdown --max-rows 120` | in progress | PR body only | +| Contract PR | #638 interface audit | Governance branch or post-merge `dev` | Governance PR | Public contract/import boundary and compatibility checks | public contract stabilized, private import risk surfaced | typecheck plus contract-specific compatibility check | planned | PR body only | +| Message-flow PR stack | #601 message flow | post-governance `dev` unless stacked | Governance PR, maybe Contract PR if public imports move | Current launch-path message flow files only | owner extracted, LOC reduced, verification added | typecheck, unit/e2e, #600 perf gate, visual smoke | planned | PR body only | +| Scroll/perf PR stack | #595/#615 scroll-perf | `dev` or message-flow stack if shared files force it | Governance PR | Scroll owner and perf guard work only | owner extracted, perf verification added | typecheck, targeted unit/e2e, #600 perf gate | planned | PR body only | +| Settings PR stack | #604 settings | `dev` after checking #642 overlap | Governance PR | Settings page/dialog family only | owner extracted, LOC reduced | typecheck, settings tests/e2e/manual UI check | planned | PR body only | + +## Handoff Rules + +- Do not call this work complete until the full completion checklist in #599 and this manifest is satisfied. +- Without merge authorization, stop at ready PRs plus handoff. +- After any stack merge, update this manifest before starting the next dependent stack. +- Keep `STATUS.md` as a local pointer only in this checkout; it is excluded from git here and is not the canonical manifest. diff --git a/package.json b/package.json index 5492e4efa..b8df5815c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev:desktop": "bun --cwd packages/desktop-electron dev", "dev:web": "bun --cwd packages/app dev", "dev:opencode": "bun run --cwd packages/opencode --conditions=browser src/index.ts", + "frontend:inventory": "node script/frontend-inventory.mjs", + "frontend:inventory:json": "node script/frontend-inventory.mjs --format json", "typecheck": "bun turbo typecheck", "lint": "eslint \"packages/{app,ui,desktop-electron}/src/**/*.{ts,tsx}\"", "lint:ci": "bun run lint", diff --git a/script/frontend-inventory.mjs b/script/frontend-inventory.mjs new file mode 100644 index 000000000..59282dec4 --- /dev/null +++ b/script/frontend-inventory.mjs @@ -0,0 +1,527 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" + +const FRONTEND_PATHS = [ + "packages/app/src/**/*.ts", + "packages/app/src/**/*.tsx", + "packages/ui/src/**/*.ts", + "packages/ui/src/**/*.tsx", +] +const FRONTEND_PATHSPECS = FRONTEND_PATHS.map((path) => `:(glob)${path}`) + +const OWNER_LANES = [ + { + lane: "#595/#615 scroll-perf", + issue: "https://github.com/Astro-Han/pawwork/issues/595", + reason: "scroll, perf probes, timeline position ownership, or long-session responsiveness", + patterns: [ + /scroll/i, + /virtual/i, + /perf/i, + /performance/i, + /timeline-scroll/i, + /create-auto-scroll/i, + /use-session-hash-scroll/i, + ], + }, + { + lane: "#601 message flow", + issue: "https://github.com/Astro-Han/pawwork/issues/601", + reason: "session timeline, turn shell, message shell, markdown, tool rows, or message-flow state", + patterns: [ + /message/i, + /timeline/i, + /session-turn/i, + /markdown/i, + /reasoning/i, + /tool-call/i, + /attachment/i, + /session-status/i, + /use-session-commands/i, + /use-session-timeline/i, + /^packages\/app\/src\/pages\/session\.tsx$/, + ], + }, + { + lane: "#604 settings", + issue: "https://github.com/Astro-Han/pawwork/issues/604", + reason: "settings page, provider dialogs, settings context, or settings-owned permission surface", + patterns: [ + /settings/i, + /dialog-connect-provider/i, + /dialog-select-server/i, + /dialog-select-provider/i, + /dialog-custom-provider/i, + /dialog-connect-websearch/i, + /provider/i, + /permission/i, + ], + }, + { + lane: "#638 interface audit", + issue: "https://github.com/Astro-Han/pawwork/issues/638", + reason: "public exports, package contract, shared type/interface, schema, event name, or tool-name boundary", + patterns: [ + /types?\.ts$/, + /interface/i, + /contract/i, + /schema/i, + /event-reducer/i, + /tool-name/i, + /event-name/i, + /index\.ts$/, + /desktop-api/i, + /session-status-extractors/i, + ], + }, + { + lane: "#605 visual shell", + issue: "https://github.com/Astro-Han/pawwork/issues/605", + reason: "shared visual primitives, theme, typography, token, style, motion, or reusable UI package surface", + patterns: [ + /^packages\/ui\/src\//, + /theme/i, + /token/i, + /typography/i, + /visual/i, + /style/i, + /animation/i, + /line-comment/i, + /status-popover/i, + ], + }, + { + lane: "#606 final shell", + issue: "https://github.com/Astro-Han/pawwork/issues/606", + reason: "layout, global shell, final assembly, shared app context, or cross-area shell cleanup", + patterns: [ + /layout/i, + /shell/i, + /app\.tsx$/, + /router/i, + /context\/layout/i, + /context\/global/i, + /context\/local/i, + /context\/sync/i, + /context\/terminal/i, + /context\/platform/i, + /context\/command/i, + /context\/notification/i, + ], + }, + { + lane: "#599 mainline", + issue: "https://github.com/Astro-Han/pawwork/issues/599", + reason: "launch path, home/onboarding entry, or UI rewrite integration owner", + patterns: [/home/i, /onboarding/i, /welcome/i, /session-list/i, /launch/i, /project/i], + }, +] + +const CLOSED_AREA_HINTS = [ + { + issue: "https://github.com/Astro-Han/pawwork/issues/602", + reason: "matches closed right-panel Area B; needs current owner before new implementation", + patterns: [ + /terminal/i, + /session-review/i, + /session-side-panel/i, + /file-tree/i, + /file-tabs/i, + /context\/file/i, + /components\/file/i, + ], + }, + { + issue: "https://github.com/Astro-Han/pawwork/issues/603", + reason: "matches closed home Area C; needs current owner before new implementation", + patterns: [/home/i], + }, +] + +function parseArgs(argv) { + const out = { + format: "summary", + maxRows: 80, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === "--format") { + out.format = argv[index + 1] ?? out.format + index += 1 + continue + } + if (arg === "--json") out.format = "json" + if (arg === "--markdown") out.format = "markdown" + if (arg === "--max-rows") { + const maxRows = Number(argv[index + 1] ?? out.maxRows) + if (Number.isFinite(maxRows) && maxRows >= 0) out.maxRows = maxRows + index += 1 + } + } + + return out +} + +function physicalLoc(content) { + if (content.length === 0) return 0 + const newlineCount = content.match(/\n/g)?.length ?? 0 + return newlineCount + (content.endsWith("\n") ? 0 : 1) +} + +function stripComments(content) { + return content + .replace(/\/\*[\s\S]*?\*\//g, "") + .split("\n") + .map(stripLineComment) + .join("\n") + .trim() +} + +function stripLineComment(line) { + let quote = null + let escaped = false + + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + const next = line[index + 1] + + if (escaped) { + escaped = false + continue + } + if (quote && char === "\\") { + escaped = true + continue + } + if (quote) { + if (char === quote) quote = null + continue + } + if (char === '"' || char === "'" || char === "`") { + quote = char + continue + } + if (char === "/" && next === "/") return line.slice(0, index).trimEnd() + } + + return line +} + +function isFacade(path, content) { + const stripped = stripComments(content) + if (!stripped) return false + + const statements = moduleStatements(stripped) + return statements.length > 0 && statements.every(isFacadeStatement) +} + +function moduleStatements(content) { + const statements = [] + let current = "" + + for (const line of content.split(/\n/).map((item) => item.trim()).filter(Boolean)) { + current = current ? `${current} ${line}` : line + if (isCompleteModuleStatement(current)) { + statements.push(current) + current = "" + } + } + + return current ? [] : statements +} + +function isCompleteModuleStatement(statement) { + if (!/^(import|export)\b/.test(statement)) return false + return ( + /\bfrom\s+["'][^"']+["'];?$/.test(statement) || + /^export\s+(type\s+)?\{[\s\S]*\};?$/.test(statement) + ) +} + +function isFacadeStatement(statement) { + return ( + /^export\s+(type\s+)?\{[\s\S]*\}\s+from\s+["'][^"']+["'];?$/.test(statement) || + /^export\s+\*\s+from\s+["'][^"']+["'];?$/.test(statement) || + /^export\s+(type\s+)?\{[\s\S]*\};?$/.test(statement) || + /^import\s+type\s+[\s\S]+\s+from\s+["'][^"']+["'];?$/.test(statement) || + /^import\s+(?!["'])[\s\S]+\s+from\s+["'][^"']+["'];?$/.test(statement) + ) +} + +function isGeneratedOrStatic(path, content) { + const head = content.slice(0, 400).toLowerCase() + return ( + /generated/.test(head) || + /(^|\/)(generated|gen|fixtures?|mocks?|assets)(\/|$)/.test(path) || + /\.gen\.|\.generated\./.test(path) || + /\/(app-icons|file-icons|provider-icons)\/types\.ts$/.test(path) + ) +} + +function isPureConfig(path, content) { + const stripped = stripComments(content) + if (!stripped) return false + if (/<[A-Z_a-z]/.test(stripped)) return false + if (/\b(createSignal|createMemo|createEffect|onMount|onCleanup|function\b)|=>/.test(stripped)) return false + if (/\.config\.ts$/.test(path)) return true + if (/(^|\/)constants\//.test(path)) return true + + const lines = stripped.split(/\n/).map((line) => line.trim()).filter(Boolean) + return lines.length > 0 && lines.every((line) => { + return ( + isBoundImportStatement(line) || + line.startsWith("export const ") || + line.startsWith("export type ") || + line.startsWith("export interface ") || + /^[\]}),;]+$/.test(line) || + /^["'`A-Za-z0-9_$-]+:/.test(line) || + /^["'`].*[,;]?$/.test(line) || + /^[A-Z0-9_$]+[,;]?$/.test(line) + ) + }) +} + +function isBoundImportStatement(line) { + return /^import\s+(?!["'])/.test(line) +} + +function classify(path, content) { + const classifications = [] + const reasons = [] + + if (/(^|\/)(__tests__|__mocks__|test|tests|e2e)(\/|$)|\.(test|spec)\./.test(path)) { + classifications.push("test") + reasons.push("test/spec path or filename") + } + if (/\.stories\./.test(path) || /(^|\/)stories(\/|$)|storybook/.test(path)) { + classifications.push("story") + reasons.push("storybook file") + } + if (/(^|\/)(i18n|locales?|translations?|dictionary|dictionaries)(\/|$)/.test(path)) { + classifications.push("i18n") + reasons.push("i18n dictionary") + } + if (isGeneratedOrStatic(path, content)) { + classifications.push("generated-static") + reasons.push("generated or static map/asset") + } + if (isPureConfig(path, content)) { + classifications.push("pure-config") + reasons.push("pure config or constants table") + } + if (isFacade(path, content)) { + classifications.push("facade") + reasons.push("public facade/barrel") + } + if (/(left-sidebar|sidebar|composer|dock|input-bar|prompt-input)/.test(path)) { + classifications.push("delivered-surface") + reasons.push("already-delivered left-sidebar/composer/dock/input surface") + } + + if (classifications.length > 0) { + return { + setType: "visibility-only inventory", + classifications, + classificationReason: reasons.join("; "), + } + } + + return { + setType: "production ratchet set", + classifications: ["production"], + classificationReason: "hand-written production frontend file", + } +} + +function ownerFor(path) { + for (const owner of OWNER_LANES) { + if (owner.patterns.some((pattern) => pattern.test(path))) return owner + } + + const closedHint = CLOSED_AREA_HINTS.find((hint) => hint.patterns.some((pattern) => pattern.test(path))) + if (closedHint) { + return { + lane: "other/deferred", + issue: closedHint.issue, + reason: closedHint.reason, + } + } + + return { + lane: "other/deferred", + issue: null, + reason: "no active owner lane matched; needs live issue link before implementation", + } +} + +function statusFor(record) { + if (record.setType === "visibility-only inventory") { + return { + status: "inventory-only", + reason: "visible for future agents but outside default production ratchet", + } + } + + if (record.loc > 500) { + return { + status: "needs-over-500-resolution", + reason: ">500 production file; must be resolved or documented as an approved exception", + } + } + + if (record.loc > 200) { + return { + status: "needs-owner-manifest-entry", + reason: ">200 production file; must keep owner lane, responsibility explanation, or approved exception", + } + } + + if (record.ownerLane === "other/deferred") { + return { + status: "needs-live-owner-issue", + reason: "production file has no active owner lane match", + } + } + + return { + status: "within-ratchet", + reason: "production file is within current warn-only threshold", + } +} + +function listFrontendFiles() { + let stdout = "" + try { + stdout = execFileSync("git", ["ls-files", ...FRONTEND_PATHSPECS], { encoding: "utf8" }).trim() + } catch (error) { + exitWithInventoryError("git ls-files failed; run this command from a PawWork git checkout.", error) + } + return stdout ? stdout.split("\n").filter(Boolean).sort() : [] +} + +function readFrontendFile(path) { + try { + return readFileSync(path, "utf8") + } catch (error) { + exitWithInventoryError(`failed to read tracked frontend file: ${path}`, error) + } +} + +function exitWithInventoryError(message, error) { + console.error(`error: ${message}`) + if (error instanceof Error && error.message) console.error(error.message) + process.exit(1) +} + +function buildInventory() { + const files = listFrontendFiles() + const records = files.map((path) => { + const content = readFrontendFile(path) + const classification = classify(path, content) + const owner = ownerFor(path) + const record = { + path, + loc: physicalLoc(content), + setType: classification.setType, + classifications: classification.classifications, + ownerLane: owner.lane, + ownerIssue: owner.issue, + approvedException: null, + classificationReason: classification.classificationReason, + ownerReason: owner.reason, + } + return { + ...record, + ...statusFor(record), + } + }) + + const summary = { + schemaVersion: 1, + command: "node script/frontend-inventory.mjs --format json", + locMetric: "physical LOC including blank lines and comments", + paths: FRONTEND_PATHS, + totalTrackedTsTsx: records.length, + production: records.filter((record) => record.setType === "production ratchet set").length, + visibilityOnly: records.filter((record) => record.setType === "visibility-only inventory").length, + approvedExceptions: records.filter((record) => record.approvedException).length, + productionOver500: records.filter( + (record) => record.setType === "production ratchet set" && record.loc > 500, + ).length, + productionOver200: records.filter( + (record) => record.setType === "production ratchet set" && record.loc > 200, + ).length, + visibilityOver500: records.filter( + (record) => record.setType === "visibility-only inventory" && record.loc > 500, + ).length, + visibilityOver200: records.filter( + (record) => record.setType === "visibility-only inventory" && record.loc > 200, + ).length, + } + + const byOwnerLane = {} + for (const record of records.filter((item) => item.setType === "production ratchet set")) { + byOwnerLane[record.ownerLane] ??= { total: 0, over200: 0, over500: 0 } + byOwnerLane[record.ownerLane].total += 1 + if (record.loc > 200) byOwnerLane[record.ownerLane].over200 += 1 + if (record.loc > 500) byOwnerLane[record.ownerLane].over500 += 1 + } + + return { + summary, + byOwnerLane, + records, + } +} + +function printSummary(inventory) { + console.log("Frontend inventory baseline") + console.log(`Schema: ${inventory.summary.schemaVersion}`) + console.log(`Command: ${inventory.summary.command}`) + console.log(`LOC: ${inventory.summary.locMetric}`) + console.log("") + for (const [key, value] of Object.entries(inventory.summary)) { + if (["schemaVersion", "command", "locMetric", "paths"].includes(key)) continue + console.log(`${key}: ${value}`) + } + console.log("") + console.log("Production by owner lane:") + for (const [owner, counts] of Object.entries(inventory.byOwnerLane)) { + console.log(`- ${owner}: ${counts.total} files, ${counts.over200} over 200, ${counts.over500} over 500`) + } + + const over500 = inventory.records + .filter((record) => record.setType === "production ratchet set" && record.loc > 500) + .sort((a, b) => b.loc - a.loc) + if (over500.length > 0) { + console.error("") + console.error(`warn: ${over500.length} production files are over 500 LOC; this script is warn-only.`) + } +} + +function printMarkdown(inventory, maxRows) { + console.log("| LOC | Owner Lane | Status | Path | Reason |") + console.log("| ---: | --- | --- | --- | --- |") + const rows = inventory.records + .filter((record) => record.setType === "production ratchet set" && record.loc > 200) + .sort((a, b) => b.loc - a.loc) + .slice(0, maxRows) + for (const record of rows) { + console.log( + `| ${record.loc} | ${record.ownerLane} | ${record.status} | \`${record.path}\` | ${record.ownerReason} |`, + ) + } +} + +const args = parseArgs(process.argv.slice(2)) +const inventory = buildInventory() + +if (args.format === "json") { + console.log(JSON.stringify(inventory, null, 2)) +} else if (args.format === "markdown") { + printMarkdown(inventory, args.maxRows) +} else { + printSummary(inventory) +}