From 75839c15ce642539e8c30afc30fbe32d7c788e53 Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:28:18 +0900 Subject: [PATCH 1/9] feat!: remove path field and strengthen key type in ParseIssue BREAKING CHANGE: ParseIssue.path (always-empty array for external format compatibility) is removed. It conveyed no information and implied false Standard Schema compliance. BREAKING CHANGE: ParseIssue.key type changes from optional `unknown` to required `string`. FormData keys are always strings per spec; the previous type was misleading. ParseIssue is now: { code: IssueCode; key: string } Co-Authored-By: Claude Sonnet 4.6 --- examples/03-error-handling.ts | 1 - src/issues/createIssue.test.ts | 22 +++++++--------------- src/issues/createIssue.ts | 24 +++++------------------- src/parse.test.ts | 16 ++-------------- src/parse.ts | 6 +++--- src/types/ParseIssue.ts | 24 +++--------------------- 6 files changed, 20 insertions(+), 73 deletions(-) diff --git a/examples/03-error-handling.ts b/examples/03-error-handling.ts index bf72e7c..954c879 100644 --- a/examples/03-error-handling.ts +++ b/examples/03-error-handling.ts @@ -30,7 +30,6 @@ for (const issue of result.issues) { // Interpret or format them at a higher layer if needed. console.error({ code: issue.code, - path: issue.path, key: issue.key, }); } diff --git a/src/issues/createIssue.test.ts b/src/issues/createIssue.test.ts index d42f147..5162ea9 100644 --- a/src/issues/createIssue.test.ts +++ b/src/issues/createIssue.test.ts @@ -3,36 +3,28 @@ import { createIssue } from "#issues/createIssue"; import type { ParseIssue } from "#types/ParseIssue"; describe("createIssue", () => { - it("creates an issue with empty path", () => { - const issue = createIssue("forbidden_key", { key: "__proto__" }); - - expect(issue.path).toEqual([]); - }); - it("preserves the issue code", () => { - const issue = createIssue("duplicate_key", { key: "a" }); + const issue = createIssue("duplicate_key", "a"); expect(issue.code).toBe("duplicate_key"); }); - it("includes provided payload fields", () => { - const issue = createIssue("invalid_key", { key: "" }); + it("preserves the key", () => { + const issue = createIssue("forbidden_key", "__proto__"); - expect(issue).toMatchObject({ - key: "", - }); + expect(issue.key).toBe("__proto__"); }); it("returns a plain object", () => { - const issue = createIssue("forbidden_key", { key: "__proto__" }); + const issue = createIssue("forbidden_key", "__proto__"); expect(Object.getPrototypeOf(issue)).toBe(Object.prototype); }); it("matches ParseIssue shape", () => { - const issue: ParseIssue = createIssue("forbidden_key", { key: "__proto__" }); + const issue: ParseIssue = createIssue("forbidden_key", "__proto__"); expect(issue).toHaveProperty("code"); - expect(issue).toHaveProperty("path"); + expect(issue).toHaveProperty("key"); }); }); diff --git a/src/issues/createIssue.ts b/src/issues/createIssue.ts index 7d51428..add534e 100644 --- a/src/issues/createIssue.ts +++ b/src/issues/createIssue.ts @@ -2,31 +2,17 @@ import type { IssueCode } from "#types/IssueCode"; import type { ParseIssue } from "#types/ParseIssue"; /** - * Payload for creating a ParseIssue. - * - * @property {unknown} key - The problematic key that triggered the issue (for debugging) - */ -interface IssuePayload { - key?: unknown; -} - -/** - * Creates a ParseIssue with the specified code and optional payload. + * Creates a ParseIssue with the specified code and key. * * This is an internal utility function for generating structured issue reports - * during FormData parsing. All issues have an empty `path` array, as this parser - * does not infer structural relationships. + * during FormData parsing. * * @param code - The type of issue (invalid_key, forbidden_key, or duplicate_key) - * @param payload - Optional payload containing the problematic key + * @param key - The FormData key that caused the issue * @returns A ParseIssue object ready to be added to the issues array * * @internal */ -export function createIssue(code: IssueCode, payload: IssuePayload = {}): ParseIssue { - return { - code, - path: [], - ...payload, - }; +export function createIssue(code: IssueCode, key: string): ParseIssue { + return { code, key }; } diff --git a/src/parse.test.ts b/src/parse.test.ts index 0a00b34..2705022 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -31,7 +31,7 @@ describe("duplicate key detection", () => { const issue = result.issues[0]; assert(issue); expect(issue.code).toBe("duplicate_key"); - expect(issue.path).toEqual([]); + expect(issue.key).toBe("a"); }); it("treats bracket notation as opaque keys", () => { @@ -45,6 +45,7 @@ describe("duplicate key detection", () => { const issue = result.issues[0]; assert(issue); expect(issue.code).toBe("duplicate_key"); + expect(issue.key).toBe("items[]"); }); }); @@ -85,7 +86,6 @@ describe("forbidden key detection", () => { assert(issue); expect(issue.code).toBe("forbidden_key"); expect(issue.key).toBe("prototype"); - expect(issue.path).toEqual([]); }); }); @@ -102,7 +102,6 @@ describe("invalid key detection", () => { assert(issue); expect(issue.code).toBe("invalid_key"); expect(issue.key).toBe(""); - expect(issue.path).toEqual([]); }); it("handles non-string keys gracefully if they occur", () => { @@ -118,17 +117,6 @@ describe("invalid key detection", () => { }); describe("boundary constraints", () => { - it("always returns empty path", () => { - const fd = new FormData(); - fd.append("__proto__", "x"); - - const result = parse(fd); - - const issue = result.issues[0]; - assert(issue); - expect(issue.path).toEqual([]); - }); - it("creates data object with no prototype", () => { const fd = new FormData(); fd.append("a", "1"); diff --git a/src/parse.ts b/src/parse.ts index 0604c1c..9d7538b 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -36,17 +36,17 @@ export function parse(formData: FormData): ParseResult { for (const [key, value] of formData.entries()) { if (typeof key !== "string" || key.length === 0) { - issues.push(createIssue("invalid_key", { key })); + issues.push(createIssue("invalid_key", key)); continue; } if (FORBIDDEN_KEYS.has(key)) { - issues.push(createIssue("forbidden_key", { key })); + issues.push(createIssue("forbidden_key", key)); continue; } if (seenKeys.has(key)) { - issues.push(createIssue("duplicate_key", { key })); + issues.push(createIssue("duplicate_key", key)); continue; } diff --git a/src/types/ParseIssue.ts b/src/types/ParseIssue.ts index 7d77865..fccf35a 100644 --- a/src/types/ParseIssue.ts +++ b/src/types/ParseIssue.ts @@ -21,27 +21,9 @@ export interface ParseIssue { code: IssueCode; /** - * Always an empty array (no structural inference). + * The field key that caused the issue. * - * This field exists only for compatibility with external issue formats. - * - * @see {@link https://github.com/roottool/safe-formdata/blob/main/AGENTS.md | Why path is always empty} - */ - path: readonly []; - - /** - * The field key that caused the issue (for debugging). - * - * This is the original key from FormData, even if it's invalid or forbidden. - * - * @example - * ```typescript - * { - * code: 'invalid_key', - * key: 'user.name', // Contains invalid character '.' - * path: [] - * } - * ``` + * This is the original key from FormData, reported as-is without interpretation. */ - key?: unknown; + key: string; } From 53e026cada3de7ef7342258bcae13aa4d35c894a Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:28:37 +0900 Subject: [PATCH 2/9] docs: update ParseIssue references across documentation and skill guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect removal of path field and key type change (unknown → string) in AGENTS.md, README.md, and all boundary-validator skill references. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 6 ++---- README.md | 5 ++--- skills/boundary-validator/SKILL.md | 2 -- skills/boundary-validator/examples/bad-code.md | 2 -- skills/boundary-validator/examples/good-code.md | 12 +----------- skills/boundary-validator/references/api-contract.md | 7 ++----- skills/boundary-validator/references/design-rules.md | 2 -- .../boundary-validator/references/security-rules.md | 4 ---- .../references/validation-patterns.md | 3 --- 9 files changed, 7 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab122f3..6f37971 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,8 +119,7 @@ No additional IssueCode may be introduced without a major version bump. ### ParseIssue shape - `code` must be one of the allowed IssueCode values. -- `path` must always be an empty array (no structural inference). This field exists only to preserve compatibility with external issue formats. -- `key?` may contain the problematic key when an issue occurs (for debugging purposes). +- `key` must be the original FormData key that caused the issue, reported as-is without interpretation. - Issues are informational, not exceptions. Note: In v1.0+, additional fields such as `message: string` and `meta?: Record` may be considered for enhanced error reporting. @@ -170,8 +169,7 @@ if (result.data !== null) { ```ts export interface ParseIssue { code: IssueCode; - path: readonly []; - key?: unknown; + key: string; } ``` diff --git a/README.md b/README.md index 8f246e7..5220e95 100644 --- a/README.md +++ b/README.md @@ -239,12 +239,11 @@ export interface ParseResult { ```ts export interface ParseIssue { code: "invalid_key" | "forbidden_key" | "duplicate_key"; - path: string[]; - key?: unknown; + key: string; } ``` -- `path` is always empty and exists only for compatibility +- `key` is the original FormData key that caused the issue - Issues are informational and are never thrown --- diff --git a/skills/boundary-validator/SKILL.md b/skills/boundary-validator/SKILL.md index 8741a1e..f11b52c 100644 --- a/skills/boundary-validator/SKILL.md +++ b/skills/boundary-validator/SKILL.md @@ -124,7 +124,6 @@ data[key] = value; // always overwrites if (seen.has(key)) { issues.push({ code: "duplicate_key", - path: [], key, }); // Do NOT continue processing @@ -221,7 +220,6 @@ const FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"]; if (FORBIDDEN_KEYS.includes(key)) { issues.push({ code: "forbidden_key", - path: [], key, }); } diff --git a/skills/boundary-validator/examples/bad-code.md b/skills/boundary-validator/examples/bad-code.md index 09c6b46..7f62ecb 100644 --- a/skills/boundary-validator/examples/bad-code.md +++ b/skills/boundary-validator/examples/bad-code.md @@ -350,7 +350,6 @@ function parse(formData: FormData): ParseResult { if (key === "__proto__") { issues.push({ code: "forbidden_key", - path: [], key, }); // Problem: Continues processing other keys @@ -422,7 +421,6 @@ function parse(formData: FormData): ParseResult { if (typeof key !== "string" || key.length === 0) { issues.push({ code: "invalid_key", - path: [], key, }); continue; diff --git a/skills/boundary-validator/examples/good-code.md b/skills/boundary-validator/examples/good-code.md index d5a0c88..1d52088 100644 --- a/skills/boundary-validator/examples/good-code.md +++ b/skills/boundary-validator/examples/good-code.md @@ -21,7 +21,6 @@ export function parse(formData: FormData): ParseResult { if (typeof key !== "string" || key.length === 0) { issues.push({ code: "invalid_key" as IssueCode, - path: [] as const, key, }); continue; @@ -31,7 +30,6 @@ export function parse(formData: FormData): ParseResult { if (FORBIDDEN_KEYS.includes(key as any)) { issues.push({ code: "forbidden_key" as IssueCode, - path: [] as const, key, }); continue; @@ -41,7 +39,6 @@ export function parse(formData: FormData): ParseResult { if (seen.has(key)) { issues.push({ code: "duplicate_key" as IssueCode, - path: [] as const, key, }); continue; @@ -70,7 +67,6 @@ export function parse(formData: FormData): ParseResult { if (typeof key !== "string") { issues.push({ code: "invalid_key", - path: [], key, }); continue; @@ -80,7 +76,6 @@ if (typeof key !== "string") { if (key.length === 0) { issues.push({ code: "invalid_key", - path: [], key: "", }); continue; @@ -101,7 +96,6 @@ const FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"] as const; if (FORBIDDEN_KEYS.includes(key as any)) { issues.push({ code: "forbidden_key", - path: [], key, }); continue; // Do not process forbidden keys @@ -127,7 +121,6 @@ for (const [key, value] of formData.entries()) { if (seen.has(key)) { issues.push({ code: "duplicate_key", - path: [], key, }); continue; // Do not process duplicate @@ -137,7 +130,7 @@ for (const [key, value] of formData.entries()) { } // ✅ Alternative: Detect duplicates using Map -const keyCount = new Map(); +const keyCount = new Map(); for (const [key] of formData.entries()) { keyCount.set(key, (keyCount.get(key) || 0) + 1); @@ -147,7 +140,6 @@ for (const [key, count] of keyCount) { if (count > 1) { issues.push({ code: "duplicate_key", - path: [], key, }); } @@ -263,7 +255,6 @@ it("rejects __proto__ key", () => { expect(result.data).toBeNull(); expect(result.issues).toContainEqual({ code: "forbidden_key", - path: [], key: "__proto__", }); }); @@ -279,7 +270,6 @@ it("reports duplicate keys", () => { expect(result.data).toBeNull(); expect(result.issues).toContainEqual({ code: "duplicate_key", - path: [], key: "username", }); }); diff --git a/skills/boundary-validator/references/api-contract.md b/skills/boundary-validator/references/api-contract.md index a7341af..3570fae 100644 --- a/skills/boundary-validator/references/api-contract.md +++ b/skills/boundary-validator/references/api-contract.md @@ -116,17 +116,14 @@ if (result.data !== null) { ```typescript export interface ParseIssue { code: IssueCode; - path: readonly []; - key?: unknown; + key: string; } ``` #### Constraints - **`code`**: Must be one of the allowed IssueCode values -- **`path`**: Must always be an empty array `[]` (no structural inference) - - This field exists only to preserve compatibility with external issue formats -- **`key`**: Optional, may contain the problematic key when an issue occurs (for debugging) +- **`key`**: Must be the original FormData key that caused the issue, reported as-is without interpretation - **Issues are informational, not exceptions** #### Future Considerations diff --git a/skills/boundary-validator/references/design-rules.md b/skills/boundary-validator/references/design-rules.md index 100897c..e4b6237 100644 --- a/skills/boundary-validator/references/design-rules.md +++ b/skills/boundary-validator/references/design-rules.md @@ -90,7 +90,6 @@ for (const [key, value] of formData.entries()) { if (seen.has(key)) { issues.push({ code: "duplicate_key", - path: [], key, }); continue; // Do not process further @@ -216,7 +215,6 @@ for (const [key, value] of formData.entries()) { if (typeof key !== "string" || key.length === 0) { issues.push({ code: "invalid_key", - path: [], key, }); continue; diff --git a/skills/boundary-validator/references/security-rules.md b/skills/boundary-validator/references/security-rules.md index 557a84b..dd9dd92 100644 --- a/skills/boundary-validator/references/security-rules.md +++ b/skills/boundary-validator/references/security-rules.md @@ -47,7 +47,6 @@ for (const [key, value] of formData.entries()) { if (FORBIDDEN_KEYS.includes(key as any)) { issues.push({ code: "forbidden_key", - path: [], key, }); continue; // Do not process forbidden keys @@ -70,7 +69,6 @@ const result = parse(formData); expect(result.data).toBeNull(); expect(result.issues).toContainEqual({ code: "forbidden_key", - path: [], key: "__proto__", }); @@ -82,7 +80,6 @@ const result2 = parse(formData); expect(result2.data).toBeNull(); expect(result2.issues).toContainEqual({ code: "forbidden_key", - path: [], key: "constructor", }); @@ -94,7 +91,6 @@ const result3 = parse(formData); expect(result3.data).toBeNull(); expect(result3.issues).toContainEqual({ code: "forbidden_key", - path: [], key: "prototype", }); ``` diff --git a/skills/boundary-validator/references/validation-patterns.md b/skills/boundary-validator/references/validation-patterns.md index 7796b20..0a4ca74 100644 --- a/skills/boundary-validator/references/validation-patterns.md +++ b/skills/boundary-validator/references/validation-patterns.md @@ -137,7 +137,6 @@ for (const [key, value] of formData.entries()) { if (seen.has(key)) { issues.push({ code: "duplicate_key", - path: [], key, }); continue; // Do not process @@ -300,7 +299,6 @@ for (const [key, value] of formData.entries()) { if (invalidCondition) { issues.push({ code: "invalid_key", - path: [], key, }); continue; // Do not throw @@ -362,7 +360,6 @@ for (const [key, value] of formData.entries()) { if (FORBIDDEN_KEYS.includes(key as any)) { issues.push({ code: "forbidden_key", - path: [], key, }); continue; From c6011890b3cdf2f321a81b8505d19c079886b8bb Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:33:16 +0900 Subject: [PATCH 3/9] feat!: narrow ParseResult failure issues to non-empty tuple BREAKING CHANGE: ParseResult failure branch issues type changes from ParseIssue[] to [ParseIssue, ...ParseIssue[]], reflecting the invariant that at least one issue always exists when data is null. Uses destructuring at the return site to let TypeScript infer the non-empty tuple type without a type assertion. Co-Authored-By: Claude Sonnet 4.6 --- src/parse.ts | 5 +++-- src/types/ParseResult.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 9d7538b..2acf098 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -54,10 +54,11 @@ export function parse(formData: FormData): ParseResult { data[key] = value; } - return issues.length > 0 + const [firstIssue, ...restIssues] = issues; + return firstIssue !== undefined ? { data: null, - issues, + issues: [firstIssue, ...restIssues], } : { data, diff --git a/src/types/ParseResult.ts b/src/types/ParseResult.ts index db75a19..d1c61b6 100644 --- a/src/types/ParseResult.ts +++ b/src/types/ParseResult.ts @@ -64,7 +64,9 @@ export type ParseResult = data: null; /** - * Array of validation issues that prevented successful parsing. + * Non-empty array of validation issues that prevented successful parsing. + * + * Always contains at least one issue when `data` is `null`. * * Possible issue codes: * - `invalid_key`: Key contains unsafe characters @@ -83,5 +85,5 @@ export type ParseResult = * } * ``` */ - issues: ParseIssue[]; + issues: [ParseIssue, ...ParseIssue[]]; }; From 7fd50946fe55b4770ae718b21b950709664b365a Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:33:57 +0900 Subject: [PATCH 4/9] feat: export SuccessResult and FailureResult named types Derived from ParseResult via Extract, providing named aliases for each branch of the discriminated union without changing ParseResult itself. SuccessResult = Extract }> FailureResult = Extract FailureResult.issues is [ParseIssue, ...ParseIssue[]], reflecting the non-empty tuple introduced in the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9450698..cd2223b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,8 @@ export { parse } from "#parse"; export type { IssueCode } from "#types/IssueCode"; export type { ParseIssue } from "#types/ParseIssue"; export type { ParseResult } from "#types/ParseResult"; + +import type { ParseResult } from "#types/ParseResult"; + +export type SuccessResult = Extract }>; +export type FailureResult = Extract; From 68a583791fe8ba8ab530c68e88aaa40ce6b42630 Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:34:26 +0900 Subject: [PATCH 5/9] docs: fix invalid_key description in IssueCode and ParseIssue JSDoc The previous description claimed keys with characters outside [a-zA-Z0-9_-] were rejected, but the implementation only rejects empty strings and non-string keys. Keys are treated as opaque strings per design. Co-Authored-By: Claude Sonnet 4.6 --- src/types/IssueCode.ts | 4 ++-- src/types/ParseIssue.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/types/IssueCode.ts b/src/types/IssueCode.ts index aa52fd5..68f0ce2 100644 --- a/src/types/IssueCode.ts +++ b/src/types/IssueCode.ts @@ -3,8 +3,8 @@ * * All issue codes represent security boundaries enforced by safe-formdata: * - * - **`invalid_key`**: Key contains characters outside `[a-zA-Z0-9_-]`. - * Prevents injection attacks and ensures predictable field names. + * - **`invalid_key`**: Key is empty or not a string. + * Prevents ambiguous or unrepresentable keys from entering application logic. * * - **`forbidden_key`**: Key is a forbidden prototype property * (`__proto__`, `constructor`, `prototype`). diff --git a/src/types/ParseIssue.ts b/src/types/ParseIssue.ts index fccf35a..c960268 100644 --- a/src/types/ParseIssue.ts +++ b/src/types/ParseIssue.ts @@ -12,7 +12,7 @@ export interface ParseIssue { /** * Type of validation issue. * - * - `invalid_key`: Key contains characters outside `[a-zA-Z0-9_-]` + * - `invalid_key`: Key is empty or not a string * - `forbidden_key`: Key is a forbidden prototype property (e.g., `__proto__`) * - `duplicate_key`: Key appears multiple times in FormData * From ee473565e44b3a5c18733258c4988909b177f924 Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:35:51 +0900 Subject: [PATCH 6/9] refactor: replace type assertion with type annotation for data object Object.create(null) returns any; using a type annotation instead of a cast (as) lets TypeScript verify assignability rather than silence it. Co-Authored-By: Claude Sonnet 4.6 --- src/parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse.ts b/src/parse.ts index 2acf098..4b6199c 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -30,7 +30,7 @@ import type { ParseResult } from "#types/ParseResult"; * @see {@link https://github.com/roottool/safe-formdata/blob/main/AGENTS.md AGENTS.md} for design rules */ export function parse(formData: FormData): ParseResult { - const data = Object.create(null) as Record; + const data: Record = Object.create(null); const issues = []; const seenKeys = new Set(); From 18387796bd02ffd6a26fda895da3d6f70999cbc6 Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:48:44 +0900 Subject: [PATCH 7/9] fix: resolve boundary-validator findings in parse.ts and ParseResult.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit `ParseIssue[]` type annotation to `issues` array (was inferred as `never[]`) - Add comment explaining destructuring pattern used to avoid type assertion - Fix JSDoc example: `if (result.data)` → `if (result.data !== null)` - Fix JSDoc description: "unsafe characters" → "empty or not a string" for invalid_key Co-Authored-By: Claude Sonnet 4.6 --- src/parse.ts | 6 ++++-- src/types/ParseResult.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 4b6199c..620b821 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,5 +1,6 @@ import { createIssue } from "#issues/createIssue"; import { FORBIDDEN_KEYS } from "#issues/forbiddenKeys"; +import type { ParseIssue } from "#types/ParseIssue"; import type { ParseResult } from "#types/ParseResult"; /** @@ -20,7 +21,7 @@ import type { ParseResult } from "#types/ParseResult"; * fd.append('name', 'alice') * const result = parse(fd) * - * if (result.data) { + * if (result.data !== null) { * // Success: result.data is { name: 'alice' } * } else { * // Failure: result.issues contains detected problems @@ -31,7 +32,7 @@ import type { ParseResult } from "#types/ParseResult"; */ export function parse(formData: FormData): ParseResult { const data: Record = Object.create(null); - const issues = []; + const issues: ParseIssue[] = []; const seenKeys = new Set(); for (const [key, value] of formData.entries()) { @@ -54,6 +55,7 @@ export function parse(formData: FormData): ParseResult { data[key] = value; } + // Destructure to let TypeScript infer [ParseIssue, ...ParseIssue[]] without a type assertion const [firstIssue, ...restIssues] = issues; return firstIssue !== undefined ? { diff --git a/src/types/ParseResult.ts b/src/types/ParseResult.ts index d1c61b6..f4b86c3 100644 --- a/src/types/ParseResult.ts +++ b/src/types/ParseResult.ts @@ -69,7 +69,7 @@ export type ParseResult = * Always contains at least one issue when `data` is `null`. * * Possible issue codes: - * - `invalid_key`: Key contains unsafe characters + * - `invalid_key`: Key is empty or not a string * - `forbidden_key`: Key is a forbidden prototype property * - `duplicate_key`: Key appears multiple times * From 6161a60a84e6cc322b7e9f33dc11d70afa179d47 Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 01:52:25 +0900 Subject: [PATCH 8/9] docs(boundary-validator): add SuccessResult/FailureResult and update ParseResult type in api-contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SuccessResult/FailureResult utility types to Public API section - Update ParseResult failure branch: ParseIssue[] → [ParseIssue, ...ParseIssue[]] - Update constraint description to mention "non-empty tuple" - Add checklist item for SuccessResult/FailureResult derivation rule - Update Last updated date Co-Authored-By: Claude Sonnet 4.6 --- .../boundary-validator/references/api-contract.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/skills/boundary-validator/references/api-contract.md b/skills/boundary-validator/references/api-contract.md index 3570fae..d6c3422 100644 --- a/skills/boundary-validator/references/api-contract.md +++ b/skills/boundary-validator/references/api-contract.md @@ -8,10 +8,14 @@ These constraints ensure API stability and prevent breaking changes. ## Public API (Minimal and Stable) -The safe-formdata public API consists of a single function: +The safe-formdata public API consists of a single function and utility types: ```typescript parse(formData: FormData): ParseResult + +// Named utility types derived from ParseResult +type SuccessResult = Extract }>; +type FailureResult = Extract; ``` ### Constraints @@ -64,7 +68,7 @@ export type ParseResult = } | { data: null; - issues: ParseIssue[]; + issues: [ParseIssue, ...ParseIssue[]]; }; ``` @@ -72,7 +76,7 @@ export type ParseResult = - **Must be a discriminated union**: Two distinct shapes based on success/failure - **Success state**: `data` is a Record, `issues` is an empty array (literal type `[]`) -- **Failure state**: `data` is `null`, `issues` is a non-empty array +- **Failure state**: `data` is `null`, `issues` is a non-empty tuple (`[ParseIssue, ...ParseIssue[]]`) - **No intermediate states**: Partial success is not allowed #### Type Narrowing Pattern @@ -227,7 +231,7 @@ See: */ export type ParseResult = | { data: Record; issues: [] } - | { data: null; issues: ParseIssue[] }; + | { data: null; issues: [ParseIssue, ...ParseIssue[]] }; ```` --- @@ -269,6 +273,7 @@ The following changes are **non-breaking** and allowed in minor/patch versions: When reviewing API changes: - [ ] Public API remains `parse(formData): ParseResult` only +- [ ] `SuccessResult` / `FailureResult` utility types are derived from `ParseResult` (not independently defined) - [ ] No overloads added - [ ] No options parameters added - [ ] `ParseResult` structure unchanged @@ -279,4 +284,4 @@ When reviewing API changes: --- **Source**: AGENTS.md (lines 119-199) -**Last updated**: 2026-01-12 +**Last updated**: 2026-03-02 From 4a201ca06202335f0845ed604a864adc2f03575f Mon Sep 17 00:00:00 2001 From: roottool Date: Mon, 2 Mar 2026 02:21:25 +0900 Subject: [PATCH 9/9] docs(changelog): add v0.2.0 migration guide under [Unreleased] Document breaking changes with before/after examples and migration steps: - ParseIssue.path removal - ParseIssue.key addition (required string) - issues type narrowed to non-empty tuple [ParseIssue, ...ParseIssue[]] Use [Unreleased] header to align with planned workflow automation that will auto-rewrite it to [v{version}] at release time. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b04b9cb..a6fa474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,62 @@ This project uses GitHub Releases as the single source of truth for all changes. -For the full and authoritative change history, including breaking changes and migration notes, +For the full and authoritative change history, including breaking changes and migration notes, please see: -No additional changelog is maintained in this file. +Migration guides for breaking changes are maintained below. + +--- + +## [Unreleased] + +> **Breaking changes** — In the 0.x series, minor version bumps are treated as effectively major releases. + +### `ParseIssue.path` removed + +The `path` field has been removed from `ParseIssue`. + +```ts +// v0.1.x +interface ParseIssue { + code: IssueCode; + path: readonly []; // removed +} + +// v0.2.0 +interface ParseIssue { + code: IssueCode; + key: string; // added (see below) +} +``` + +**Migration**: Remove all references to `issue.path`. Because `path` was always an empty array, any reference to it was effectively a no-op and can be deleted outright. + +### `ParseIssue.key` added (required, `string`) + +A required `key: string` field has been added to identify which FormData key caused the issue. + +```ts +// v0.1.x — no key field +issue.code; // "forbidden_key" + +// v0.2.0 — key identifies the offending field +issue.code; // "forbidden_key" +issue.key; // "__proto__" +``` + +**Migration**: Use `issue.key` wherever you need to identify the offending field. This is an additive change with no runtime impact, but type definitions that reference `ParseIssue` must be updated. + +### `issues` on failure narrowed to a non-empty tuple + +```ts +// v0.1.x +issues: ParseIssue[] + +// v0.2.0 +issues: [ParseIssue, ...ParseIssue[]] +``` + +When parsing fails (`data === null`), the type now guarantees at least one issue is present. + +**Migration**: Accessing `result.issues[0]` remains safe. No changes to narrowing logic are required. However, if you reference the `ParseResult` type explicitly, you may need to update type annotations.