desktop coverage: every CLI command mapped to its window place (cli/lib/desktop-coverage.ts), docs/desktop-coverage.md generated from it, guard test fails on a gap or an unjustified not-for-desktop
This commit is contained in:
parent
a95aa6dd49
commit
dc6bb088a1
4 changed files with 371 additions and 0 deletions
86
tools/gen-desktop-coverage.ts
Normal file
86
tools/gen-desktop-coverage.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// docs/desktop-coverage.md from cli/lib/desktop-coverage.ts, checked against the code: a command is "covered" only
|
||||
// when every method it names exists (serve.ts describeMethods(), or a #[tauri::command] of src-tauri) and the window
|
||||
// calls it (the method quoted in apps/desktop/src, or listed in TOOLBOX, which the More tools tab renders).
|
||||
// `bun tools/gen-desktop-coverage.ts` writes the page; `--check` exits 1 when it is stale. tests/desktop-coverage.test.ts
|
||||
// runs analyze() too.
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { COMMANDS, PLAIN } from '../cli/commands';
|
||||
import { DESKTOP_COVERAGE, OWNER_STEPS, TOOLBOX } from '../cli/lib/desktop-coverage';
|
||||
import { describeMethods } from '../cli/serve';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const OUT = join(ROOT, 'docs/desktop-coverage.md');
|
||||
|
||||
function files(dir: string, out: string[] = []): string[] {
|
||||
for (const f of readdirSync(dir)) {
|
||||
const p = join(dir, f);
|
||||
if (statSync(p).isDirectory()) { if (f !== 'dev' && f !== 'i18n') files(p, out); } else if (/\.(ts|tsx|rs)$/.test(f)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Row { command: string; status: 'covered' | 'gap' | 'not-for-desktop'; where: string; methods: string[]; owner?: string; problems: string[] }
|
||||
|
||||
export function analyze(): { rows: Row[]; extra: string[] } {
|
||||
const src = files(join(ROOT, 'apps/desktop/src')).map(f => readFileSync(f, 'utf8')).join('\n');
|
||||
const rust = files(join(ROOT, 'apps/desktop/src-tauri/src')).map(f => readFileSync(f, 'utf8')).join('\n');
|
||||
const hostCmds = new Set([...rust.matchAll(/#\[tauri::command\][\s\S]{0,200}?fn ([a-z_]+)/g)].map(m => m[1]!));
|
||||
const methods = new Set(describeMethods().map(m => m.method));
|
||||
const toolboxShown = /\bTOOLBOX\b/.test(src);
|
||||
const inToolbox = new Set(TOOLBOX.flatMap(g => g.methods));
|
||||
const reachable = (m: string): string | null => {
|
||||
if (m.startsWith('host:')) {
|
||||
const c = m.slice(5);
|
||||
if (!hostCmds.has(c)) return `${m}: no such tauri command`;
|
||||
return src.includes(`'${c}'`) ? null : `${m}: never invoked by the window`;
|
||||
}
|
||||
if (!methods.has(m)) return `${m}: no such serve method`;
|
||||
if (src.includes(`'${m}'`) || src.includes(`"${m}"`)) return null;
|
||||
if (toolboxShown && inToolbox.has(m)) return null;
|
||||
return `${m}: no window place calls it`;
|
||||
};
|
||||
const all = [...Object.keys(PLAIN), ...Object.keys(COMMANDS)];
|
||||
const rows: Row[] = all.map(command => {
|
||||
const c = DESKTOP_COVERAGE[command];
|
||||
if (!c) return { command, status: 'gap', where: '', methods: [], problems: ['no entry in cli/lib/desktop-coverage.ts'] };
|
||||
if ('not' in c) return { command, status: 'not-for-desktop', where: c.not, methods: [], problems: c.not.trim() ? [] : ['not-for-desktop without a reason'] };
|
||||
const problems = c.methods.map(reachable).filter((p): p is string => !!p);
|
||||
if (!c.methods.length && !(c.owner && toolboxShown && OWNER_STEPS.length)) problems.push('no method and no owner step');
|
||||
return { command, status: problems.length ? 'gap' : 'covered', where: c.where, methods: c.methods, ...(c.owner ? { owner: c.owner } : {}), problems };
|
||||
});
|
||||
return { rows, extra: Object.keys(DESKTOP_COVERAGE).filter(k => !all.includes(k)) };
|
||||
}
|
||||
|
||||
export function render(rows: Row[]): string {
|
||||
const n = (s: Row['status']): number => rows.filter(r => r.status === s).length;
|
||||
const cell = (s: string): string => s.replace(/\|/g, '\\|');
|
||||
return [
|
||||
'# Desktop coverage',
|
||||
'',
|
||||
'Generated by `bun tools/gen-desktop-coverage.ts` from `cli/lib/desktop-coverage.ts`; do not edit by hand.',
|
||||
'Every CLI command (cli/commands.ts) and the desktop place that runs it. "covered" means each method listed exists',
|
||||
'(serve.ts, or a host command `host:`) and the window calls it; tests/desktop-coverage.test.ts fails on a gap.',
|
||||
'Owner-only parts are owner actions in the window or owner steps with their terminal command (More tools).',
|
||||
'',
|
||||
`**${rows.length} commands:** ${n('covered')} covered, ${n('gap')} gaps, ${n('not-for-desktop')} not for the desktop.`,
|
||||
'',
|
||||
'| command | status | where in the window | methods | owner step |',
|
||||
'|---|---|---|---|---|',
|
||||
...rows.map(r => `| \`${r.command}\` | ${r.status} | ${cell(r.where || r.problems.join('; '))} | ${r.methods.map(m => `\`${m}\``).join(', ')} | ${cell(r.owner ?? '')} |`),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { rows } = analyze();
|
||||
const md = render(rows);
|
||||
if (process.argv.includes('--check')) {
|
||||
let cur = '';
|
||||
try { cur = readFileSync(OUT, 'utf8'); } catch { /* missing */ }
|
||||
if (cur !== md) { console.error('docs/desktop-coverage.md is stale: run bun tools/gen-desktop-coverage.ts'); process.exit(1); }
|
||||
} else writeFileSync(OUT, md);
|
||||
const gaps = rows.filter(r => r.status === 'gap');
|
||||
for (const g of gaps) console.log(`gap: ${g.command}: ${g.problems.join('; ')}`);
|
||||
console.log(`${rows.length} commands, ${rows.filter(r => r.status === 'covered').length} covered, ${gaps.length} gaps`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue