From 7740539e179310601014f93c11c5058698b27a62 Mon Sep 17 00:00:00 2001 From: ayushsingh82 Date: Fri, 7 Aug 2026 22:40:48 +0530 Subject: [PATCH] fix: allow overriding the verify row-shape top-level key cap validateRowShape() hardcoded a 12-key top-level cap for `webcmd browser verify` with no way to override it, so any adapter with a wider row shape by design (e.g. the shipped 52-column university course-export adapters) could never pass verify regardless of correctness. Add a --max-top-level-keys flag on `browser verify`, threaded into validateRowShape's existing maxTopLevelKeys option; default behavior is unchanged. Update the failure message and the adapter-author skill's fixture checklist to point wide-row adapters at the new flag instead of skipping the fixture. Fixes #218 --- skills/webcmd-adapter-author/SKILL.md | 2 +- src/browser/verify-fixture.test.ts | 18 ++++++++ src/cli.test.ts | 65 +++++++++++++++++++++++++++ src/cli.ts | 18 ++++++-- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index 6e4fbbb0..92d72013 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -199,7 +199,7 @@ Check these off step by step: [ ] `endpoints.json`: short endpoint name as key; value = `{url, method, params.{required,optional}, response, verified_at: YYYY-MM-DD, notes}`. [ ] `field-map.json`: append only new codes. key = field code; value = `{meaning, verified_at: YYYY-MM-DD, source}`. **Do not overwrite existing keys.** If there is a conflict, align with the visible page before writing. [ ] `notes.md`: prepend `## YYYY-MM-DD by ` with new pitfalls or conclusions from this adapter work. - [ ] `verify/.json`: **required.** Expected values for `webcmd browser verify`: args, rowCount, columns, types, patterns, notEmpty. Step 10 generated this; this item is the checklist gate. + [ ] `verify/.json`: **required.** Expected values for `webcmd browser verify`: args, rowCount, columns, types, patterns, notEmpty. Step 10 generated this; this item is the checklist gate. Rows wider than 12 top-level keys by design (e.g. a spreadsheet-style export) fail shape validation by default — rerun with `webcmd browser verify / --max-top-level-keys ` instead of skipping the fixture. [ ] `fixtures/-.json`: save one complete endpoint response sample after removing cookies, tokens, and private user fields. Use it for later field comparison and offline replay. [ ] If debugging dumped temporary files in the repo or adapter directory, such as `.dbg-*.html`, `raw-*.json`, or similar, **delete them before commit**. Those belong in `~/.webcmd/sites//fixtures/` or `/tmp/`. diff --git a/src/browser/verify-fixture.test.ts b/src/browser/verify-fixture.test.ts index 60d96ea2..19e6b8e5 100644 --- a/src/browser/verify-fixture.test.ts +++ b/src/browser/verify-fixture.test.ts @@ -170,6 +170,24 @@ describe('validateRowShape', () => { ]); }); + it('honors a raised maxTopLevelKeys override for wide-row adapters', () => { + const row = Object.fromEntries(Array.from({ length: 52 }, (_, i) => [`k${i}`, i])); + const failures = validateRowShape([row], { maxTopLevelKeys: 52 }); + expect(failures).toEqual([]); + }); + + it('still reports keys beyond a raised maxTopLevelKeys override', () => { + const row = Object.fromEntries(Array.from({ length: 53 }, (_, i) => [`k${i}`, i])); + const failures = validateRowShape([row], { maxTopLevelKeys: 52 }); + expect(failures).toEqual([ + { + rule: 'shapeKeyCount', + detail: 'row has 53 top-level keys, expected at most 52', + rowIndex: 0, + }, + ]); + }); + it('reports nesting deeper than one level', () => { const failures = validateRowShape([ { title: 'A', stats: { author: { name: 'Ada' } } }, diff --git a/src/cli.test.ts b/src/cli.test.ts index 734ed20e..06e87b5d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1173,6 +1173,71 @@ describe('browser verify', () => { fs.rmSync(fakeHome, { recursive: true, force: true }); } }); + + it('rejects a wide row by default but passes with a raised --max-top-level-keys', async () => { + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-wide-')); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + const wideRow = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`col${i}`, i])); + mockExecFileSync.mockReturnValue(JSON.stringify([wideRow])); + const consoleLogSpy = vi.mocked(console.log); + consoleLogSpy.mockClear(); + + try { + const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn'); + fs.mkdirSync(adapterDir, { recursive: true }); + fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture']); + expect(process.exitCode).toBe(1); + let output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(output).toContain('row has 20 top-level keys, expected at most 12'); + + process.exitCode = undefined; + consoleLogSpy.mockClear(); + const program2 = createProgram('', ''); + await program2.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--max-top-level-keys', '20']); + expect(process.exitCode).toBeUndefined(); + output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(output).not.toContain('violates row shape conventions'); + } finally { + consoleLogSpy.mockClear(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); + + it('rejects a non-positive --max-top-level-keys', async () => { + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-badflag-')); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + try { + const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn'); + fs.mkdirSync(adapterDir, { recursive: true }); + fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--max-top-level-keys', '0']); + + expect(process.exitCode).toBe(2); + expect(mockExecFileSync).not.toHaveBeenCalled(); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); }); describe('profile list', () => { diff --git a/src/cli.ts b/src/cli.ts index 5d0828f6..083fb3a3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -856,8 +856,9 @@ cli({ .option('--strict-memory', 'Fail (not just warn) when ~/.webcmd/sites//endpoints.json or notes.md is missing') .option('--seed-args ', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags') .option('--trace ', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off') + .option('--max-top-level-keys ', 'Override the row-shape top-level key cap (default: 12) for adapters whose rows are wide by design') .description('Execute an adapter and validate output; uses fixture at ~/.webcmd/sites//verify/.json when present') - .action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string } = {}) => { + .action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string; maxTopLevelKeys?: string } = {}) => { try { const parts = name.split('/'); if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } @@ -868,6 +869,16 @@ cli({ return; } + let maxTopLevelKeys: number | undefined; + if (opts.maxTopLevelKeys !== undefined) { + maxTopLevelKeys = Number(opts.maxTopLevelKeys); + if (!Number.isInteger(maxTopLevelKeys) || maxTopLevelKeys <= 0) { + console.error('--max-top-level-keys must be a positive integer'); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + } + const { execFileSync } = await import('node:child_process'); const { loadFixture, writeFixture, deriveFixture, validateRows, validateRowShape, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js'); const filePath = path.join(os.homedir(), '.webcmd', 'clis', site, `${command}.js`); @@ -936,7 +947,7 @@ cli({ console.log(renderVerifyPreview(rows)); console.log(`\n → ${rows.length} row${rows.length === 1 ? '' : 's'}`); - const shapeFailures = validateRowShape(rows); + const shapeFailures = validateRowShape(rows, { maxTopLevelKeys }); if (shapeFailures.length > 0) { console.log(`\n ✗ Adapter output violates row shape conventions:`); for (const f of shapeFailures.slice(0, 20)) { @@ -946,7 +957,8 @@ cli({ if (shapeFailures.length > 20) { console.log(` ... and ${shapeFailures.length - 20} more failure(s)`); } - console.log(`\n Keep rows agent-native: <=12 top-level keys, nesting depth <=1, and id-shaped fields at top level.`); + console.log(`\n Keep rows agent-native: <=${maxTopLevelKeys ?? 12} top-level keys, nesting depth <=1, and id-shaped fields at top level.`); + console.log(` If this adapter's rows are wide by design, rerun with --max-top-level-keys .`); process.exitCode = EXIT_CODES.GENERIC_ERROR; return; }