Conversation
Co-authored-by: kylebrodeur <811103+kylebrodeur@users.noreply.github.com>
Co-authored-by: kylebrodeur <811103+kylebrodeur@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds an “AI-first” CLI code generator and an AI-focused manual to make @kylebrodeur/type-safe-mapping easier to adopt from automation/agent workflows.
Changes:
- Introduces a new
type-safe-mappingCLI (init) that scaffolds mapper boilerplate from TypeScript interfaces (with--stdout/--out). - Adds
AI.mdagent manual and expands README with CLI documentation and examples. - Adds Vitest coverage for CLI behavior and updates TS/packaging config for Node-based CLI usage.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
src/cli.ts |
Implements the CLI, regex-based interface parsing, field pairing heuristics, and mapper code generation. |
tests/cli.test.ts |
Adds end-to-end CLI tests covering stdout mode, file output, overwrite guard, and error paths. |
AI.md |
Adds an agent-oriented usage manual (workflow + CLI reference + pitfalls). |
README.md |
Documents the new CLI, options, and sample generated output. |
package.json |
Publishes AI.md, adds bin entry, and adds Node types for CLI compilation. |
tsconfig.json |
Includes Node typings needed for the CLI build. |
package-lock.json |
Locks dependency updates required by the new/updated dev toolchain. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export { MappedServiceBase } from './MappedServiceBase'; | ||
| export type { MappedType, MappingDefinition, ReverseMapping } from './types'; |
There was a problem hiding this comment.
The “Exports” snippet shows extensionless internal paths ('./MappedServiceBase', './types'), but this package is ESM/NodeNext and the actual source exports use .js extensions. Consider either showing the real src/index.ts exports (with .js) or replacing this section with consumer-facing import examples from the package entrypoint to avoid misleading copy/paste.
| export { MappedServiceBase } from './MappedServiceBase'; | |
| export type { MappedType, MappingDefinition, ReverseMapping } from './types'; | |
| import { | |
| MappedServiceBase, | |
| type MappedType, | |
| type MappingDefinition, | |
| type ReverseMapping, | |
| } from '@kylebrodeur/type-safe-mapping'; |
| | Flag | Description | | ||
| |------|-------------| | ||
| | `--stdout` | Print generated code to stdout instead of writing a file | | ||
| | `--out <path>` | Write the output to a specific file path | | ||
|
|
There was a problem hiding this comment.
The markdown tables in this section start rows with || (double pipe), which renders as an extra empty column in most markdown renderers. Use single leading pipes (| Flag | Description |, etc.) for valid tables.
| | Problem | Fix | | ||
| |---|---| | ||
| | TypeScript errors about index signature | Add `[key: string]: unknown` to your source interface | | ||
| | Type inference fails | Make sure you used `as const` on the field mapping object | | ||
| | Unmapped fields appear in output | Only fields in `fieldMapping` are included — this is intentional | | ||
| | CLI says "no interfaces found" | Check that the file contains a `interface Foo { ... }` declaration | |
There was a problem hiding this comment.
The “Common Pitfalls” table rows start with || (double pipe), which creates an extra empty column in standard markdown rendering. Use single leading pipes for proper table formatting.
| // Match `fieldName?: type;` or `fieldName: type;` | ||
| // This handles simple types, generics, unions, and intersections | ||
| const fieldRegex = /^\s*(readonly\s+)?(\w+)(\?)?:\s*([^;]+);?\s*$/gm; | ||
| let m: RegExpExecArray | null; | ||
|
|
||
| while ((m = fieldRegex.exec(cleaned)) !== null) { | ||
| const fieldName = m[2]; | ||
| const optional = m[3] === '?'; | ||
| const type = m[4].trim(); | ||
|
|
||
| // Skip index signatures like [key: string]: unknown | ||
| if (/^\[/.test(fieldName)) continue; | ||
|
|
||
| fields.push({ name: fieldName, type, optional }); |
There was a problem hiding this comment.
parseFields says it skips index signatures, but the regex only captures \w+ field names, so index signatures ([key: string]: ...) will never match in the first place; the if (/^\[/.test(fieldName)) branch is dead code. Either adjust the regex to also match bracketed keys and keep the skip, or remove the skip check/comment to avoid misleading future maintenance.
| // Match `interface Foo {` or `export interface Foo {` | ||
| const interfaceRegex = /(?:export\s+)?interface\s+(\w+)(?:\s+extends\s+[^{]+)?\s*\{/g; |
There was a problem hiding this comment.
interfaceRegex doesn't match interfaces with generic parameters (e.g. export interface Foo<T> { ... }) because it expects { immediately after the name/optional extends. This contradicts the comment that generics are handled and will cause the CLI to report “No TypeScript interfaces found” on common inputs. Update the regex to allow an optional <...> section (and/or adjust docs/tests accordingly).
| // Match `interface Foo {` or `export interface Foo {` | |
| const interfaceRegex = /(?:export\s+)?interface\s+(\w+)(?:\s+extends\s+[^{]+)?\s*\{/g; | |
| // Match `interface Foo {`, `export interface Foo<T> {`, with optional generics and extends | |
| const interfaceRegex = /(?:export\s+)?interface\s+(\w+)\s*(?:<[^>]+>)?(?:\s+extends\s+[^{]+)?\s*\{/g; |
| function runCli(args: string): { stdout: string; stderr: string; code: number } { | ||
| try { | ||
| const stdout = execSync(`node ${CLI_PATH} ${args}`, { | ||
| cwd: TMP_DIR, | ||
| encoding: 'utf-8', | ||
| }); |
There was a problem hiding this comment.
runCli builds a shell command string (execSync("node ${CLI_PATH} ${args}")). This is brittle with paths containing spaces (and is harder to make cross-platform). Prefer execFileSync('node', [CLI_PATH, ...parsedArgs], { ... }) (or similar) to avoid quoting issues and injection via the args string.
| "engines": { | ||
| "node": ">=18.0.0" | ||
| }, | ||
| "bin": { | ||
| "type-safe-mapping": "./dist/cli.js" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^25.3.5", | ||
| "@vitest/coverage-v8": "^4.0.18", | ||
| "eslint": "^10.0.3", | ||
| "typescript": "^5.9.3", | ||
| "vitest": "^4.0.18" |
There was a problem hiding this comment.
engines.node allows Node >=18, but the updated devDependencies (notably vitest@4 / eslint@10) declare Node >=20 in the lockfile. Contributors running Node 18 will likely be unable to install/run tests/lint. Consider aligning engines.node with the dev toolchain requirement (or downgrading devDependencies to versions that support Node 18).
| // Parse flags | ||
| const stdoutFlag = args.includes('--stdout'); | ||
| const outIndex = args.indexOf('--out'); | ||
| const outFile = outIndex !== -1 ? args[outIndex + 1] : null; |
There was a problem hiding this comment.
--out is parsed as args[outIndex + 1] but there’s no validation that a value is actually present. If a user passes --out at the end, it’s silently ignored and the default output path is used. It would be better to error out when --out is provided without a following path.
| const outFile = outIndex !== -1 ? args[outIndex + 1] : null; | |
| let outFile: string | null = null; | |
| if (outIndex !== -1) { | |
| const outValue = args[outIndex + 1]; | |
| if (!outValue || outValue.startsWith('--')) { | |
| console.error('Error: --out option requires an output file path.'); | |
| printUsage(); | |
| process.exit(1); | |
| } | |
| outFile = outValue; | |
| } |
|
|
||
| const CLI_PATH = resolve(__dirname, '../dist/cli.js'); | ||
| const TMP_DIR = resolve(__dirname, '../tmp-cli-test'); |
There was a problem hiding this comment.
This file uses __dirname, but the repo is ESM ("type": "module" in package.json), where __dirname is not defined. Use import.meta.url + fileURLToPath (or another vitest-provided helper) to derive paths in an ESM-safe way.
| const CLI_PATH = resolve(__dirname, '../dist/cli.js'); | |
| const TMP_DIR = resolve(__dirname, '../tmp-cli-test'); | |
| import { fileURLToPath } from 'node:url'; | |
| const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); | |
| const TMP_DIR = fileURLToPath(new URL('../tmp-cli-test', import.meta.url)); |
| // 1. Source type — must include an index signature | ||
| interface ApiUser { | ||
| user_id: string; | ||
| first_name: string; | ||
| last_name: string; | ||
| [key: string]: unknown; // ← required | ||
| } | ||
|
|
||
| // 2. Field mapping with `as const` — external → internal | ||
| const userMapping = { | ||
| user_id: 'id', | ||
| first_name: 'firstName', | ||
| last_name: 'lastName', |
There was a problem hiding this comment.
This section states the source type “must include an index signature” and labels it “required”, but the library’s public constraint is TSource extends Record<string, unknown>, which does not require an index signature (object types with explicit properties satisfy it). This guidance is likely to confuse users and should be corrected (or the library constraint changed if an index signature is truly required).
| // 1. Source type — must include an index signature | |
| interface ApiUser { | |
| user_id: string; | |
| first_name: string; | |
| last_name: string; | |
| [key: string]: unknown; // ← required | |
| } | |
| // 2. Field mapping with `as const` — external → internal | |
| const userMapping = { | |
| user_id: 'id', | |
| first_name: 'firstName', | |
| last_name: 'lastName', | |
| // 1. Source type — plain object type compatible with Record<string, unknown> | |
| interface ApiUser { | |
| user_id: string; | |
| first_name: string; | |
| last_name: string; | |
| } | |
| // 2. Field mapping with `as const` — external → internal | |
| const userMapping = { | |
| user_id: 'id', | |
| first_name: 'firstName', | |
| lastName: 'lastName', |
Implements Phase 1 (CLI code generator) and Phase 2 (AI.md agent manual) to make the package "agent-ready" — usable directly from AI coding assistants and automation pipelines.
CLI (
src/cli.ts)New
initcommand scaffolds a type-safe mapper from two existing TypeScript interface files:Generated output:
// TODO:stubs--stdoutlets agents inspect before committing to disk;--out <path>for custom output locationAI.md
Root-level agent manual indexed by Cursor, Copilot, Claude, etc. Covers when to use the package, step-by-step agent workflow (scaffold → fill TODOs →
tsc --noEmit), full API reference, and common pitfalls.Supporting changes
package.json:binfield (type-safe-mapping → dist/cli.js),AI.mdin published files,@types/nodein devDependenciestsconfig.json:"types": ["node"]for CLIREADME.md: new CLI section with flag table and generated output exampletests/cli.test.ts: 14 tests covering--stdout, camelCase matching, file write, overwrite guard,--out, and all error pathsType of change
Checklist
Additional Notes
The CLI's TypeScript parser is intentionally regex-based to preserve the zero-runtime-dependency constraint. It handles the common cases (simple types, unions, generics, optional fields, index signatures) but is not a full AST parser — complex nested types or unusual formatting may produce incomplete field lists. Users are expected to review and complete any
// TODO:stubs before runningtsc --noEmit.Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.