mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
* feat(cli-registry): add cliManagementEnabled flag and GET /api/clis
Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI
Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review).
Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or
custom) via a shallow merge onto its clis.json override; shell/claude are
structurally un-disableable (Decision 4), an unknown id 404s rather than
becoming a creation backdoor.
Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted
install command (shell:true, bounded by timeout, process-group killed on
expiry, output captured, audit-logged). A custom entry's id is refused
outright, independent of anything Phase 5 does (Decision 3: a custom
entry's install text is display-only, never executed).
Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) /
DELETE /api/clis/:id (custom only) — a deliberately minimal request shape
(id/label/shortBadge/binaries/a simple launch variant), assembled into a
full CliEntry with conservative capability defaults and re-validated
through CliEntrySchema before writing, never a relaxed path for
UI-originated entries. Stock-id collisions, duplicate custom ids, and
edits/deletes against a stock id are all rejected explicitly.
Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated
independently on cliManagementEnabled AND admin-in-multi-user-mode
(Decision 5), fetching/rendering GET /api/clis and wiring every write
endpoint above.
Every write endpoint answers the same way when the feature is off: 403
FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own
checklist item). registry-writer.ts is a new, deliberately separate write
module so registry.ts itself stays import-side-effect-free, same tmp+
rename+0600 shape as custom-model-hosts.ts.
27 new/updated route tests covering every gate, collision, and cleanup
path; full CI gate green (415/416 files, 7854 tests).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else
window.__codemanCliAvailable — the flag isCliAvailable() reads client-side
to gate the welcome-screen buttons, the Run-menu dropdown and the mobile
overview — was built purely from each CLI's own installed-on-PATH resolver
(isClaudeAvailable() etc.), with no reference to the registry's `enabled`
flag at all. So disabling a CLI via the new Settings UI (or a hand-edited
clis.json) updated the settings row and nothing else: every launch surface
kept offering it, both live and after a full page reload, since even a
fresh render never consulted the registry.
Fixed in two places:
- server.ts: after building `available`, intersect the nine real
SessionMode ids against `enabledClis()`. git/cloudflared (utility
binaries, not CLI registry entries) and deepseekBinary (a secondary
installed-only flag for the "add a profile" affordance) are deliberately
left alone.
- settings-ui.js: `toggleCliEnabled()` now patches
`window.__codemanCliAvailable` in place and refreshes the welcome screen,
the mobile overview and an already-open Run menu, mirroring the existing
`installDeepSeekProfile()` pattern for the same "injected once, needs an
explicit patch" reason — without this half, the server-side fix alone
still left every surface stale until the next reload.
New test in test/render-index-html.test.ts: an installed-but-disabled CLI
(codex, forced via clis.json + reloadCliRegistry()) reads as unavailable,
while an installed-and-enabled one (claude) is unaffected by the override.
Verified on the Debian devbox (codeman-devbox, real tmux — this sandbox has
none and WebServer's constructor hard-requires it): typecheck clean, the
new test passes (17/17 in render-index-html.test.ts), the CLI-registry
suites pass (86/86), and the full CI gate is green (415 test files, 7855
tests, 0 failures).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
* docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap
Phases 1-6 were implemented across two commits (da07b38c, db4557d9) with no
corresponding update to the plan doc itself — every checklist still read
Status: TODO and every box unchecked. Brings the doc in line with the tree:
- A new "Status as of 2026-09-22" section up top: what's actually
implemented (verified by grepping the routes/schema/UI, not just trusting
the commit messages), the availability-flag staleness bug found and fixed
in this session (commit 0c77dd0a) with its devbox verification record, and
one real outstanding gap.
- The outstanding gap: a custom CLI created via Phase 5's write API has no
way to actually be launched. The Run menu is static per-mode markup with
no consumer of window.__codemanCliCatalog, so Phase 6's own "create a
custom entry, confirm it can be launched" verify step was never actually
exercised against this. Documented with two candidate fixes, neither
started.
- Each phase's checklist flipped to [x] where confirmed present in the tree,
Status lines updated from TODO to DONE, and the two originally-open
questions (Phase 2's installed source, Phase 5's PUT endpoint shape)
marked resolved against what actually shipped.
No code changes in this commit — documentation only, so a future session
(or the one already mid-flight on a separate checkout of this same branch)
picks up accurate status instead of a stale plan.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
* docs: add the CLI-registry deployment plan and the parked Copilot plan
Both were sitting as untracked scratch files in the master checkout,
never committed to any branch. Moving them here rather than leaving them
loose:
- DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up
series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and
is where PR C (this branch's own CLI-management work) belongs.
- docs/copilot-integration-plan.md is explicitly PARKED, referenced by
name in docs/cli-enable-disable-plan.md's own header as a sibling plan
tracked separately — kept for continuity, not active on this branch.
The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md
and their review-response counterparts) described PR A/B/B2, all now
merged — deleted from the master checkout as stale rather than committed
anywhere, since their content is superseded by the real merged PRs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
* fix(cli-registry): render enabled CLIs in launch surfaces
* test(cli-registry): update frontend branch guard
* fix(test): isolate suite from deployment environment
* fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent
shell/claude were both structurally un-disableable in the original plan
(Decision 4). Revised: shell keeps the hard backend guarantee (it is the
one non-agent mode several code paths assume always exists as a raw-
terminal fallback), but claude is now a normal toggleable entry like any
other CLI.
Safe to do because internal session creation (tmux-manager.ts, session.ts,
Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not
check `enabled` at all - only the Run menu and the HTTP-facing
sessionModeSchema() (new session requests through the normal API) key off
it. Disabling claude therefore behaves identically in kind to disabling
any other CLI: no internal fallback path breaks, it just stops being
offered for new sessions until re-enabled.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): hide shell's toggle entirely instead of greying it out
A permanently-disabled switch next to every other row's working toggle
read as broken rather than intentional. shell now renders no switch at
all - a plain "Always available" label - so there is nothing to click
that could look like it should work but doesn't. Backend guard is
unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this
is UI-only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical
renderCliList() previously rendered in registry order (each entry's fixed
order field). Now sorts installed CLIs first, then not-installed, each
group alphabetical by label - matches how a user actually scans the list
(what's ready to use, then what needs installing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* style: prettier fixes from the master merge
* fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels
Four gaps found verifying #476 against the #343 review trail:
- Installed or edited CLIs kept reading as missing/stale. Every binary lookup
(the nine per-CLI resolvers and the generic registry one) caches in its own
closure, with a negative-cache backoff of up to 5 minutes, and nothing
cleared them. invalidateCliExecutableResolvers(binaries) now drops those
caches per binary; install (success or failure), create, edit and delete
call it plus invalidateCliResolverCache(id). Before this, a CLI installed
from Settings could fail to launch for minutes, and an edited custom entry
kept launching its old binary until a restart.
- The Settings "installed" badge for a custom entry used a private `which`,
ignoring the entry's searchDirs and the login-shell lookup that spawn and
the Run menu use; it now asks the same generic resolver they do.
- Install ran on a single click. The #343 review asked for auto-install to
sit behind an explicit confirm; the confirm now names the exact command,
which GET /api/clis returns for stock entries only (installCommand).
- The phone Run button showed the two-letter tab badge ("CC", "CX") instead
of the word ("Claude", "Codex"). It uses the registry label again, which is
identical to the old static table for every stock CLI (now pinned).
14 new tests; 9 of them fail against the previous head and pass here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs
Must-fix:
- registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that
does not parse or has group/world permission bits instead of overwriting it
(isUnsafePermissions now exported from registry.ts)
- mutateRegistryFile(): one promise chain for every mutation, with the
existence/duplicate checks inside the serialized step, plus a unique tmp
name per write
- docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings
section) and api-reference (the six /api/clis routes)
- drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md
Smaller:
- PUT /api/clis/custom/:id keeps the entry's current enabled state when the
body omits it
- runMode setter falls back to the first enabled catalogue entry, not 'claude'
- shell guard keyed on kind === 'shell' (routes + Settings list); stock probe
map shared with server.ts via utils/cli-installed-probes.ts
- stock claude label is now 'Claude Code', so the Run menu / phone overview
label rewrites are gone (doctor row keeps "Claude CLI" via its override)
- welcome buttons are translatable again and read "Run Claude Code" /
"Run Shell"; zh-CN gains "Run Codex" / "Run OMP"
- install: per-id in-flight guard (409) and CODEMAN_* stripped from its env
- fileoverview / CliEnableSchema comments no longer say stock-only
- test-env isolation changes moved to their own PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* test(cli-registry): pin the #343/#347 findings #476 makes reachable
A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(#347 finding 5: no more `cd <path> && undefined`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
/**
|
|
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
|
* (`session-ui.js`, `mobile-overview.js`), mirroring
|
|
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
|
*
|
|
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
|
* `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the
|
|
* frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …)
|
|
* keeps its own hand-authored per-CLI rules deliberately — "moving them is
|
|
* its own piece of work verified by a browser/mobile suite the CI gate cannot
|
|
* see." Widening this guard to the whole directory would force either fixing
|
|
* or allowlisting dozens of branches in files nobody has touched or reviewed
|
|
* for this change, which is scope B2 never took on.
|
|
*
|
|
* Port: none (pure static analysis).
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
|
|
|
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
|
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
|
|
|
/**
|
|
* Every currently-surviving branch, each with the COUNT of physical call
|
|
* sites carrying it and the reason none of them is a `CliCapabilities`
|
|
* field, keyed `<file>::<the matched expression>` — deliberately NO line
|
|
* number. An earlier version keyed on `<file>::<line>::<expression>`, and
|
|
* inserting one comment line at the top of `session-ui.js` shifted every
|
|
* subsequent line number, so all 21 entries went stale and the same 21
|
|
* branches were then reported as "new". `session-ui.js` is one of the most
|
|
* contended files in the repo, so a guard that goes red on any unrelated
|
|
* edit to it sends the next person after the wrong problem.
|
|
*
|
|
* The `count` is what closes the gap dropping the line number opened: a key
|
|
* alone says "this expression is approved somewhere in this file", so a
|
|
* BRAND NEW `mode === 'codex'` site anywhere in `session-ui.js` would reuse
|
|
* the same key as the two approved ones and pass silently. The count makes
|
|
* that a mismatch — one more occurrence than declared — and the "counts
|
|
* match" test below catches it, while a genuinely new expression (a CLI id
|
|
* with no ALLOWED_BRANCHES entry at all) is still caught by the separate
|
|
* "no unapproved id branches" test either way.
|
|
*/
|
|
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
|
"session-ui.js::mode === 'shell'": {
|
|
count: 3,
|
|
reason:
|
|
'run() dispatch, availability gating (shell needs no CLI probe at all), and the button-label ternary (pinned exact ' +
|
|
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
|
|
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
|
|
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
|
|
'hardcoded, see the PR #458 review thread)',
|
|
},
|
|
|
|
"session-ui.js::mode === 'claude'": {
|
|
count: 3,
|
|
reason:
|
|
'three claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
|
|
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
|
|
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
|
|
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
|
|
"by design, mirroring the backend capabilities.ralph gate), and the runMode setter's " +
|
|
'validity check',
|
|
},
|
|
|
|
// The 8 external CLIs remain in the button-label ternary only. The runMode
|
|
// setter now validates custom entries through the injected registry catalog.
|
|
"session-ui.js::mode === 'opencode'": { count: 1, reason: 'button-label ternary' },
|
|
"session-ui.js::mode === 'codex'": { count: 1, reason: 'button-label ternary' },
|
|
"session-ui.js::mode === 'gemini'": { count: 1, reason: 'button-label ternary' },
|
|
"session-ui.js::mode === 'antigravity'": {
|
|
count: 1,
|
|
reason: 'button-label ternary',
|
|
},
|
|
"session-ui.js::mode === 'pi'": { count: 1, reason: 'button-label ternary' },
|
|
"session-ui.js::mode === 'grok'": { count: 1, reason: 'button-label ternary' },
|
|
"session-ui.js::mode === 'deepseek'": { count: 1, reason: 'button-label ternary' },
|
|
"session-ui.js::mode === 'omp'": { count: 1, reason: 'button-label ternary' },
|
|
|
|
// The docker adopt-preflight status line and the docker link/adopt toast
|
|
// both list the agent CLIs probed INSIDE the container and leave `shell`
|
|
// out of that human-readable "found ..." summary (it is always present and
|
|
// is not an agent CLI). Written as `(m) => m !== 'shell'`, the naming the
|
|
// original named-variable pattern could not see; the widened pattern
|
|
// normalizes the `m` to `mode` (see BRANCH_PATTERN below).
|
|
"session-ui.js::mode !== 'shell'": {
|
|
count: 2,
|
|
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
|
|
},
|
|
|
|
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
|
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
|
"mobile-overview.js::mode !== 'shell'": {
|
|
count: 1,
|
|
reason: 'shell needs no CLI, so it is exempt from the availability gate',
|
|
},
|
|
};
|
|
|
|
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
|
const IDS = STOCK_CLIS.map((e) => e.id as string);
|
|
const ID_ALT = IDS.join('|');
|
|
|
|
/**
|
|
* The backend guard's four shapes (see its own comment for why all four
|
|
* matter), with ONE deliberate widening on the first.
|
|
*
|
|
* The backend pattern accepts a comparison only when its left-hand side is
|
|
* literally named `mode`, `id` or `agentType`, so both
|
|
* `const m = this._runMode; if (m === 'codex')` and
|
|
* `if (this._runMode !== 'gemini')` slip past it, and `session-ui.js` already
|
|
* uses exactly that naming (`(m) => m !== 'shell'`, twice). The review of
|
|
* PR #458 surfaced that blind spot, so here the left-hand side is ANY
|
|
* identifier (`[\w$]+`, the leaf of a member chain), normalized to `mode` in
|
|
* the allowlist key by `scan()` so a local rename never churns the entries.
|
|
* Measured over both scanned files before widening: every extra hit was a
|
|
* genuine mode comparison (the two `m !== 'shell'` filters, allowlisted
|
|
* above), so the widening added no false positive; a future one gets an
|
|
* allowlist entry with its reason like any other. The backend guard keeps
|
|
* its narrower form and is deliberately not changed here.
|
|
*
|
|
* Still unseen, and worth knowing: a Yoda comparison (`'codex' === mode`),
|
|
* and an id list held in a variable (`EXTERNAL.includes(mode)`), since the
|
|
* third shape needs the literal list inline.
|
|
*/
|
|
const BRANCH_PATTERN = new RegExp(
|
|
[
|
|
// <identifier> === 'codex' / <identifier> !== 'codex' (any left-hand identifier, see above)
|
|
`\\b[\\w$]+\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
|
// case 'codex':
|
|
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
|
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
|
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
|
|
].join('|'),
|
|
'g'
|
|
);
|
|
|
|
/** Blanks comment lines before scanning — see the backend guard's own comment on why. */
|
|
function uncommented(source: string): string {
|
|
return source
|
|
.split('\n')
|
|
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
|
|
.join('\n');
|
|
}
|
|
|
|
interface Finding {
|
|
file: string;
|
|
expression: string;
|
|
line: number;
|
|
key: string;
|
|
}
|
|
|
|
function scan(): Finding[] {
|
|
const findings: Finding[] = [];
|
|
for (const file of SCANNED_FILES) {
|
|
const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n');
|
|
lines.forEach((line, i) => {
|
|
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
|
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
|
// Normalize the comparison shape's left-hand identifier (whatever the
|
|
// local is called: `id`, `agentType`, `m`, `_runMode`) to `mode`; the
|
|
// lookahead leaves the `case`/`.includes(` shapes untouched.
|
|
const expression = match[0].replace(/\s+/g, ' ').replace(/^[\w$]+(?=\s*[!=]==)/, 'mode');
|
|
findings.push({ file, expression, line: i + 1, key: `${file}::${expression}` });
|
|
}
|
|
});
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
const findings = scan();
|
|
|
|
function actualCounts(): Map<string, number> {
|
|
const counts = new Map<string, number>();
|
|
for (const f of findings) counts.set(f.key, (counts.get(f.key) ?? 0) + 1);
|
|
return counts;
|
|
}
|
|
|
|
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
|
|
it('scans both files (sanity)', () => {
|
|
// If this drops to zero the scanner or the file list drifted and every
|
|
// assertion below would pass vacuously.
|
|
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
|
|
expect(scannedBytes).toBeGreaterThan(10_000);
|
|
});
|
|
|
|
it('builds its id list from the live catalog (sanity)', () => {
|
|
expect(IDS).toContain('claude');
|
|
expect(IDS).toContain('deepseek');
|
|
expect(IDS.length).toBeGreaterThanOrEqual(9);
|
|
});
|
|
|
|
it('still detects a branch when one exists (anti-vacuity)', () => {
|
|
const samples = [
|
|
"if (session.mode === 'codex') { doSomething(); }",
|
|
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
|
"switch (mode) { case 'gemini': return 1; }",
|
|
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
|
// The two forms the named-variable pattern was blind to (see BRANCH_PATTERN).
|
|
"const m = this._runMode; if (m === 'codex') { doSomething(); }",
|
|
"if (this._runMode !== 'gemini') { doSomething(); }",
|
|
];
|
|
for (const sample of samples) {
|
|
BRANCH_PATTERN.lastIndex = 0;
|
|
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
|
|
}
|
|
BRANCH_PATTERN.lastIndex = 0;
|
|
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
|
|
});
|
|
|
|
it('has no unapproved id branches', () => {
|
|
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
|
|
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
|
|
expect(
|
|
offenders,
|
|
offenders.length === 0
|
|
? ''
|
|
: `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` +
|
|
'Two ways out, in order of preference:\n' +
|
|
' 1. Derive the difference from a shared module-level constant, the way\n' +
|
|
' _runCliMode()/RUN_MODE_LAUNCH/EXTERNAL_CLI_MODES do.\n' +
|
|
' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' +
|
|
' ALLOWED_BRANCHES in this file WITH the reason.'
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('every allowlisted branch occurs exactly its declared number of times', () => {
|
|
// This is what closes the gap the line-number removal opened (see the
|
|
// ALLOWED_BRANCHES header comment): a key alone cannot tell "the two
|
|
// approved sites" from "the two approved sites plus a brand new third
|
|
// one reusing the same expression" — the count can. A mismatch in
|
|
// either direction is real: higher means an unreviewed NEW branch
|
|
// landed reusing an approved expression, lower means one of the
|
|
// reviewed call sites was removed and the entry is now a stale lie
|
|
// about the codebase (the count going to 0 is the old "stale entry"
|
|
// case, now folded into this same check rather than a separate one).
|
|
const actual = actualCounts();
|
|
const mismatches: string[] = [];
|
|
for (const [key, { count: expected }] of Object.entries(ALLOWED_BRANCHES)) {
|
|
const got = actual.get(key) ?? 0;
|
|
if (got !== expected) {
|
|
mismatches.push(` ${key} expected ${expected}, found ${got}`);
|
|
}
|
|
}
|
|
expect(
|
|
mismatches,
|
|
mismatches.length === 0
|
|
? ''
|
|
: `ALLOWED_BRANCHES count mismatch(es):\n${mismatches.join('\n')}\n\n` +
|
|
'A count LOWER than declared means a reviewed call site was removed — update or delete ' +
|
|
'the entry. A count HIGHER than declared means a NEW branch landed reusing an already-' +
|
|
'approved expression — review it and bump the count (or fix the branch) explicitly, ' +
|
|
'rather than let it ride in on an existing approval.'
|
|
).toEqual([]);
|
|
});
|
|
});
|