From 4db7422f43d0a25ef390532a01d3b76647461fce Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 21:35:35 -0300 Subject: [PATCH 01/10] refactor: remove all `as` type assertions from source code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace every `as Type` assertion in src/ with type-safe alternatives: - HTTP responses: Zod schemas (auth, repo-probe, binary-upgrade, signup) - Config files: typed Zod schemas with .passthrough() for read-modify-write (claude-settings, vscode-settings, opencode-settings) - Session data: Zod array/object schemas (session-context, session-context-copilot) - AST walking: type guard functions — isAstNode(), childNode(), strProp(), boolProp() (rule-scanner) - Function overloads: Promise return type for implementation (runner) - Function patching: Proxy with apply/get traps (prompt) - Commander options: proper type narrowing, typed return (cli, check, reporter) - Property access: type-safe alternatives — .some(), Object.entries(), Object.fromEntries() (project-config, adr-writer, stack-detect) - Telemetry config: Zod schema replacing manual field checks Zero `as Type` assertions remain in src/. Only `as const` (safe literal narrowing) and `import { X as Y }` (renaming) remain. Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/cli.ts | 19 +-- src/commands/adr/create.ts | 2 +- src/commands/check.ts | 2 +- src/engine/ast-support.ts | 2 +- src/engine/reporter.ts | 6 +- src/engine/rule-scanner.ts | 157 +++++++++++++++---------- src/engine/runner.ts | 24 ++-- src/formats/adr.ts | 9 +- src/formats/pack.ts | 2 +- src/helpers/adr-writer.ts | 4 +- src/helpers/auth.ts | 73 ++++++------ src/helpers/binary-upgrade.ts | 13 +- src/helpers/claude-settings.ts | 37 ++++-- src/helpers/opencode-settings.ts | 13 +- src/helpers/project-config.ts | 12 +- src/helpers/prompt.ts | 36 +++--- src/helpers/repo-probe.ts | 24 ++-- src/helpers/session-context-copilot.ts | 25 ++-- src/helpers/session-context.ts | 25 ++-- src/helpers/signup.ts | 6 +- src/helpers/stack-detect.ts | 17 ++- src/helpers/telemetry-config.ts | 30 +++-- src/helpers/vscode-settings.ts | 17 +-- tests/helpers/claude-settings.test.ts | 14 +-- tests/helpers/vscode-settings.test.ts | 13 +- 25 files changed, 333 insertions(+), 249 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 66467bf2..9f045e52 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,7 +24,7 @@ import { finalizeCommand, } from "./helpers/exit"; import { installGit } from "./helpers/git"; -import { type LogLevel, logError, setLogLevel } from "./helpers/log"; +import { logError, setLogLevel } from "./helpers/log"; import { createPathIfNotExists, paths } from "./helpers/paths"; import { getPlatformInfo, isSupportedPlatform } from "./helpers/platform"; import { @@ -113,11 +113,11 @@ async function main() { // Apply log level from global option before any command runs const rootOpts = program.opts(); - setLogLevel(rootOpts.logLevel as LogLevel); + setLogLevel(rootOpts.logLevel); const fullCommand = getFullCommandName(actionCommand); addBreadcrumb("command", `Running: ${fullCommand}`); // Collect which options were used (presence only, no values) - const opts = actionCommand.opts() as Record; + const opts = actionCommand.opts(); const optionFlags: Record = {}; const optionsUsed: string[] = []; for (const key of Object.keys(opts)) { @@ -174,17 +174,20 @@ async function main() { * from the `preAction` / `postAction` hook callback, where Commander gives us * a `CommandUnknownOpts`, not the narrowly-typed `Command<[], {}, {}>`. */ -function getFullCommandName( - command: { name(): string; parent: unknown } | null -): string { +interface CommandLike { + name(): string; + parent: CommandLike | null; +} + +function getFullCommandName(command: CommandLike | null): string { const parts: string[] = []; - let current = command; + let current: CommandLike | null = command; while (current) { const name = current.name(); if (name && name !== "archgate") { parts.unshift(name); } - current = current.parent as typeof command; + current = current.parent; } return parts.join(" ") || "root"; } diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index c65df5bd..8b8e4f59 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -85,7 +85,7 @@ export function registerAdrCreateCommand(adr: Command) { ]) ); - domain = answers.domain as AdrDomain; + domain = answers.domain; title = answers.title; files = answers.files ? answers.files diff --git a/src/commands/check.ts b/src/commands/check.ts index bedb1fab..a73e0fee 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -183,6 +183,6 @@ export function registerCheckCommand(program: Command) { const exitCode = getExitCode(result, summary); // Only 0, 1, and 2 are emitted by getExitCode() - await exitWith(exitCode as 0 | 1 | 2); + await exitWith(exitCode); }); } diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index c9d8cbc0..094302d7 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -195,7 +195,7 @@ export function parseAstJson( language: string ): Record | unknown[] { try { - return JSON.parse(stdout) as Record | unknown[]; + return JSON.parse(stdout); } catch { throw new Error( `Failed to parse "${path}" as ${language}: interpreter produced invalid JSON output` diff --git a/src/engine/reporter.ts b/src/engine/reporter.ts index 0a657981..f69cd025 100644 --- a/src/engine/reporter.ts +++ b/src/engine/reporter.ts @@ -176,7 +176,7 @@ export function reportConsole( // Print violations for (const v of r.violations) { const loc = v.file ? (v.line ? `${v.file}:${v.line}` : v.file) : ""; - const sevColor = + const sevColor: "red" | "yellow" | "dim" = v.severity === "error" ? "red" : v.severity === "warning" @@ -191,7 +191,7 @@ export function reportConsole( : "info"; console.log( - ` ${styleText(sevColor as "red", `[${sevLabel}]`)} ${v.message}${loc ? ` ${styleText("dim", loc)}` : ""}` + ` ${styleText(sevColor, `[${sevLabel}]`)} ${v.message}${loc ? ` ${styleText("dim", loc)}` : ""}` ); if (v.fix && verbose) { @@ -315,7 +315,7 @@ export function reportCI( export function getExitCode( result: CheckResult, summary?: ReportSummary -): number { +): 0 | 1 | 2 { if (summary) { if (summary.ruleErrors > 0) return 2; if (summary.failed > 0) return 1; diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 051db779..1b9c6396 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -33,6 +33,32 @@ interface AstNode { [key: string]: unknown; } +/** Runtime check: is `value` an AST node (has a `type` string property)? */ +function isAstNode(value: unknown): value is AstNode { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +/** Narrow an AST child property to an AstNode, or null. */ +function childNode(value: unknown): AstNode | null { + return isAstNode(value) ? value : null; +} + +/** Read a string-valued property from an AST node, or undefined. */ +function strProp(node: AstNode, key: string): string | undefined { + const v = node[key]; + return typeof v === "string" ? v : undefined; +} + +/** Read a boolean-valued property from an AST node (defaults to false). */ +function boolProp(node: AstNode, key: string): boolean { + return node[key] === true; +} + import { remapViolations, type RawViolation } from "./source-positions"; /** @@ -100,8 +126,9 @@ export function scanRuleSource(source: string): ScanViolation[] { switch (node.type) { case "ImportDeclaration": { - const src = (node.source as { value: string }).value; - if (BANNED_MODULES.test(src) || src === "bun") { + const srcNode = childNode(node.source); + const src = srcNode ? strProp(srcNode, "value") : undefined; + if (src && (BANNED_MODULES.test(src) || src === "bun")) { // Use `from "module"` as search anchor — `from` is in code context // (unlike the bare module string which buildNonCodeRanges marks as non-code). pushViolation( @@ -112,43 +139,47 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "MemberExpression": { - const obj = node.object as AstNode & { name?: string }; - const prop = node.property as AstNode & { name?: string }; - const computed = node.computed as boolean; + const obj = childNode(node.object); + const prop = childNode(node.property); + if (!obj || !prop) break; + const computed = boolProp(node, "computed"); + const objName = strProp(obj, "name"); + const propName = strProp(prop, "name"); // Block Bun.spawn, Bun.write, Bun.$, Bun.file, Bun.spawnSync if ( - obj.name === "Bun" && + objName === "Bun" && !computed && - BLOCKED_BUN_PROPS.has(prop.name ?? "") + BLOCKED_BUN_PROPS.has(propName ?? "") ) { pushViolation( - `Bun.${prop.name}() is blocked in rule files. Use the RuleContext API instead.`, - `Bun.${prop.name}` + `Bun.${propName}() is blocked in rule files. Use the RuleContext API instead.`, + `Bun.${propName}` ); } // Block computed access: Bun[x], globalThis[x] - if (computed && (obj.name === "Bun" || obj.name === "globalThis")) { + if (computed && (objName === "Bun" || objName === "globalThis")) { pushViolation( - `Computed property access on ${obj.name} is blocked in rule files.`, - `${obj.name}[` + `Computed property access on ${objName} is blocked in rule files.`, + `${objName}[` ); } break; } case "CallExpression": { - const callee = node.callee as AstNode & { name?: string }; - if (callee.name === "eval") { + const callee = childNode(node.callee); + const name = callee ? strProp(callee, "name") : undefined; + if (name === "eval") { pushViolation("eval() is blocked in rule files.", "eval("); } - if (callee.name === "Function") { + if (name === "Function") { pushViolation( "Function() constructor is blocked in rule files.", "Function(" ); } - if (callee.name === "fetch") { + if (name === "fetch") { pushViolation( "fetch() is blocked in rule files. Rules should not make network requests.", "fetch(" @@ -157,8 +188,9 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "NewExpression": { - const callee = node.callee as AstNode & { name?: string }; - if (callee.name === "Function") { + const callee = childNode(node.callee); + const name = callee ? strProp(callee, "name") : undefined; + if (name === "Function") { pushViolation( "new Function() is blocked in rule files.", "new Function(" @@ -167,8 +199,8 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "ImportExpression": { - const src = node.source as AstNode; - if (src.type !== "Literal") { + const src = childNode(node.source); + if (src && src.type !== "Literal") { pushViolation( "Dynamic import() with non-literal argument is blocked in rule files.", "import(" @@ -177,22 +209,20 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "AssignmentExpression": { - const left = node.left as AstNode & { - object?: AstNode & { name?: string }; - property?: AstNode & { name?: string }; - }; - if (left.type === "MemberExpression") { - if (left.object?.name === "globalThis") { + const left = childNode(node.left); + if (left && left.type === "MemberExpression") { + const leftObj = childNode(left.object); + const leftProp = childNode(left.property); + const leftObjName = leftObj ? strProp(leftObj, "name") : undefined; + const leftPropName = leftProp ? strProp(leftProp, "name") : undefined; + if (leftObjName === "globalThis") { pushViolation( "Mutating globalThis is blocked in rule files.", "globalThis." ); } - if ( - left.object?.name === "process" && - left.property?.name === "env" - ) { - const target = `${left.object.name}.${left.property.name}`; + if (leftObjName === "process" && leftPropName === "env") { + const target = `${leftObjName}.${leftPropName}`; pushViolation( `Mutating ${target} is blocked in rule files.`, target @@ -207,21 +237,17 @@ export function scanRuleSource(source: string): ScanViolation[] { for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const item of value) { - if (item && typeof item === "object" && (item as AstNode).type) { - walk(item as AstNode); - } + const child = childNode(item); + if (child) walk(child); } - } else if ( - value && - typeof value === "object" && - (value as AstNode).type - ) { - walk(value as AstNode); + } else { + const child = childNode(value); + if (child) walk(child); } } } - walk(ast as unknown as AstNode); + if (isAstNode(ast)) walk(ast); return remapViolations(source, rawViolations); } @@ -291,12 +317,15 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { switch (node.type) { case "MemberExpression": { - const obj = node.object as AstNode & { name?: string }; - const prop = node.property as AstNode & { name?: string }; - const computed = node.computed as boolean; + const obj = childNode(node.object); + const prop = childNode(node.property); + if (!obj || !prop) break; + const computed = boolProp(node, "computed"); + const objName = strProp(obj, "name"); + const propName = strProp(prop, "name"); // Block Bun.env - if (obj.name === "Bun" && !computed && prop.name === "env") { + if (objName === "Bun" && !computed && propName === "env") { pushViolation( "Bun.env access is blocked in imported rule files.", "Bun.env" @@ -304,8 +333,8 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { } // Block process env reads - if (obj.name === "process" && !computed && prop.name === "env") { - const target = `${obj.name}.${prop.name}`; + if (objName === "process" && !computed && propName === "env") { + const target = `${objName}.${propName}`; pushViolation( `${target} access is blocked in imported rule files.`, target @@ -314,21 +343,23 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { break; } case "CallExpression": { - const callee = node.callee as AstNode & { name?: string }; - if (callee.name && IMPORTED_BLOCKED_GLOBALS.has(callee.name)) { + const callee = childNode(node.callee); + const name = callee ? strProp(callee, "name") : undefined; + if (name && IMPORTED_BLOCKED_GLOBALS.has(name)) { pushViolation( - `${callee.name}() is blocked in imported rule files.`, - `${callee.name}(` + `${name}() is blocked in imported rule files.`, + `${name}(` ); } break; } case "NewExpression": { - const callee = node.callee as AstNode & { name?: string }; - if (callee.name && IMPORTED_BLOCKED_GLOBALS.has(callee.name)) { + const callee = childNode(node.callee); + const name = callee ? strProp(callee, "name") : undefined; + if (name && IMPORTED_BLOCKED_GLOBALS.has(name)) { pushViolation( - `new ${callee.name}() is blocked in imported rule files.`, - `new ${callee.name}(` + `new ${name}() is blocked in imported rule files.`, + `new ${name}(` ); } break; @@ -338,21 +369,17 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const item of value) { - if (item && typeof item === "object" && (item as AstNode).type) { - walkImported(item as AstNode); - } + const child = childNode(item); + if (child) walkImported(child); } - } else if ( - value && - typeof value === "object" && - (value as AstNode).type - ) { - walkImported(value as AstNode); + } else { + const child = childNode(value); + if (child) walkImported(child); } } } - walkImported(ast as unknown as AstNode); + if (isAstNode(ast)) walkImported(ast); // Combine: standard scan + imported-only scan const standardViolations = scanRuleSource(source); diff --git a/src/engine/runner.ts b/src/engine/runner.ts index fe35d366..7a292d69 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -5,8 +5,6 @@ import { relative, resolve, isAbsolute } from "node:path"; import type { AstLanguage, - AstNode, - EsTreeProgram, GrepMatch, RuleContext, RuleReport, @@ -162,15 +160,15 @@ function createRuleContext( }, }; - // ARCH-022: ctx.ast() implementation. Declared as a const cast to the - // overloaded RuleContext["ast"] type so this single implementation - // signature satisfies the language-narrowed public overloads (a single - // broad signature is not directly assignable to the narrow overloads). - // The four guardrails below MUST run in this order before any subprocess. - const astImpl = (async ( + // ARCH-022: ctx.ast() implementation. The broad implementation signature + // returns `Promise` so it satisfies the language-narrowed public + // overloads on RuleContext["ast"] without double-cast. The four guardrails + // below MUST run in this order before any subprocess. + const astImpl: RuleContext["ast"] = async ( path: string, language: AstLanguage - ): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise => { // Guardrail 1: path safety — same sandbox as readFile/glob. const absPath = safePath(projectRoot, path); @@ -203,7 +201,7 @@ function createRuleContext( // sloppy-mode script (mirroring the .cjs handling below). return parseJsModule(js, { sourceType: lowerPath.endsWith(".cts") ? "script" : "module", - }) as unknown as EsTreeProgram; + }); } // .cjs cannot legally contain import/export in Node — parse it as // a sloppy-mode script so CommonJS-isms (top-level return, `with`) @@ -211,7 +209,7 @@ function createRuleContext( return parseJsModule(source, { jsx: lowerPath.endsWith(".jsx"), sourceType: lowerPath.endsWith(".cjs") ? "script" : "module", - }) as unknown as EsTreeProgram; + }); } catch (err) { throw new Error( `Failed to parse "${path}" as ${language}: ${parseErrorMessage(err)}` @@ -250,8 +248,8 @@ function createRuleContext( const detail = stderr.trim() || `exit code ${exitCode}`; throw new Error(`Failed to parse "${path}" as ${language}: ${detail}`); } - return parseAstJson(stdout, path, language) as unknown as AstNode; - }) as unknown as RuleContext["ast"]; + return parseAstJson(stdout, path, language); + }; return { projectRoot, diff --git a/src/formats/adr.ts b/src/formats/adr.ts index f1bac2c9..9f61924d 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -57,7 +57,14 @@ export interface AdrDocument { * Parse YAML frontmatter from a raw string (the content between --- delimiters). */ export function parseFrontmatter(raw: string): Record { - return (Bun.YAML.parse(raw) as Record) ?? {}; + const parsed = Bun.YAML.parse(raw); + if (!isPlainObject(parsed)) return {}; + return parsed; +} + +/** Narrow `unknown` to a plain object (not array, not null). */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function formatZodErrors(error: z.ZodError): string[] { diff --git a/src/formats/pack.ts b/src/formats/pack.ts index 53a10379..06c09fb6 100644 --- a/src/formats/pack.ts +++ b/src/formats/pack.ts @@ -31,7 +31,7 @@ export const PackMetadataSchema = z.object({ export type PackMetadata = z.infer; export function parsePackMetadata(raw: string): PackMetadata { - const parsed = Bun.YAML.parse(raw) as Record; + const parsed: unknown = Bun.YAML.parse(raw); return PackMetadataSchema.parse(parsed); } diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts index 858b57d7..97f705ad 100644 --- a/src/helpers/adr-writer.ts +++ b/src/helpers/adr-writer.ts @@ -91,8 +91,8 @@ export async function createAdrFile( rules?: boolean; } ): Promise { - const prefix = - opts.prefix ?? DOMAIN_PREFIXES[opts.domain as keyof typeof DOMAIN_PREFIXES]; + const domainPrefixes: Record = DOMAIN_PREFIXES; + const prefix = opts.prefix ?? domainPrefixes[opts.domain]; if (!prefix) { throw new UserError( `No prefix registered for domain '${opts.domain}'. Pass opts.prefix or register via \`archgate domain add\`.` diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index 7006ed01..bbc5d25a 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -8,6 +8,8 @@ * credential helpers (macOS Keychain, Windows Credential Manager, libsecret). */ +import { z } from "zod"; + import { logDebug } from "./log"; import { SignupRequiredError, isSignupRequiredError } from "./signup"; import { UserError } from "./user-error"; @@ -30,34 +32,32 @@ const GITHUB_CLIENT_ID = "Ov23liZUI9Aiv2ZrSAgn"; // Types // --------------------------------------------------------------------------- -interface DeviceCodeResponse { - device_code: string; - user_code: string; - verification_uri: string; - expires_in: number; - interval: number; -} - -interface DeviceTokenSuccessResponse { - access_token: string; - token_type: string; - scope: string; -} - -interface DeviceTokenPendingResponse { - error: "authorization_pending" | "slow_down"; - error_description?: string; -} - -interface DeviceTokenErrorResponse { - error: "expired_token" | "access_denied" | string; - error_description?: string; -} - -type DeviceTokenResponse = - | DeviceTokenSuccessResponse - | DeviceTokenPendingResponse - | DeviceTokenErrorResponse; +const DeviceCodeResponseSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + expires_in: z.number(), + interval: z.number(), +}); + +type DeviceCodeResponse = z.infer; + +const DeviceTokenResponseSchema = z.union([ + z.object({ + access_token: z.string(), + token_type: z.string(), + scope: z.string(), + }), + z.object({ error: z.string(), error_description: z.string().optional() }), +]); + +const GitHubUserSchema = z.object({ + login: z.string().optional(), + email: z.string().nullable().optional(), +}); + +const TokenResponseSchema = z.object({ token: z.string().optional() }); +const ErrorResponseSchema = z.object({ error: z.string().optional() }); // --------------------------------------------------------------------------- // GitHub Device Flow @@ -83,7 +83,7 @@ export async function requestDeviceCode(): Promise { ); } - const data = (await response.json()) as DeviceCodeResponse; + const data = DeviceCodeResponseSchema.parse(await response.json()); logDebug("Device code received, expires in:", data.expires_in, "seconds"); return data; } @@ -126,7 +126,7 @@ export async function pollForAccessToken( throw new UserError(`GitHub token poll failed (HTTP ${response.status})`); } - const data = (await response.json()) as DeviceTokenResponse; + const data = DeviceTokenResponseSchema.parse(await response.json()); if ("access_token" in data) { logDebug("Access token received"); @@ -180,10 +180,7 @@ export async function getGitHubUser( ); } - const data = (await response.json()) as { - login?: string; - email?: string | null; - }; + const data = GitHubUserSchema.parse(await response.json()); if (!data.login) { throw new Error("GitHub API did not return a username"); } @@ -213,9 +210,9 @@ export async function claimArchgateToken(githubToken: string): Promise { }); if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { - error?: string; - }; + const body = ErrorResponseSchema.parse( + await response.json().catch(() => ({})) + ); if (isSignupRequiredError(body.error)) { throw new SignupRequiredError(); @@ -226,7 +223,7 @@ export async function claimArchgateToken(githubToken: string): Promise { throw new UserError(message); } - const data = (await response.json()) as { token?: string }; + const data = TokenResponseSchema.parse(await response.json()); if (!data.token) { throw new Error("Plugins service did not return a token"); } diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts index 8f37b43e..efa03e81 100644 --- a/src/helpers/binary-upgrade.ts +++ b/src/helpers/binary-upgrade.ts @@ -6,6 +6,8 @@ import { unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { z } from "zod"; + import { logDebug } from "./log"; import { internalPath } from "./paths"; import { isWindows } from "./platform"; @@ -62,9 +64,7 @@ export function getArtifactInfo(): ArtifactInfo | null { // Version fetching // --------------------------------------------------------------------------- -interface GitHubRelease { - tag_name?: string; -} +const GitHubReleaseSchema = z.object({ tag_name: z.string().optional() }); const GITHUB_RELEASES_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; @@ -92,7 +92,7 @@ export async function fetchLatestGitHubVersion( return null; } - const data = (await response.json()) as GitHubRelease; + const data = GitHubReleaseSchema.parse(await response.json()); logDebug("Latest release tag:", data.tag_name ?? "(none)"); return data.tag_name ?? null; } @@ -172,7 +172,10 @@ export async function downloadReleaseBinary( combined.set(chunk, offset); offset += chunk.byteLength; } - buffer = combined.buffer as ArrayBuffer; + buffer = combined.buffer.slice( + combined.byteOffset, + combined.byteOffset + combined.byteLength + ); } else { buffer = await response.arrayBuffer(); } diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts index 203e88c0..d07939f5 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -3,6 +3,24 @@ import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; + +const ClaudePermissionsSchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .passthrough(); + +const ClaudeSettingsSchema = z + .object({ + agent: z.string().optional(), + permissions: ClaudePermissionsSchema.optional(), + }) + .passthrough(); + +type ClaudeSettings = z.infer; + /** * Settings that archgate injects into .claude/settings.local.json. * Scalar keys are set only if absent; array keys are appended with dedup. @@ -18,8 +36,6 @@ export const ARCHGATE_CLAUDE_SETTINGS = { }, } as const; -type ClaudeSettings = Record; - /** * Deduplicate an array of strings while preserving order. */ @@ -46,16 +62,8 @@ export function mergeClaudeSettings( } // Nested permissions object: merge allow array with dedup, preserve deny - const existingPermissions = - typeof merged.permissions === "object" && - merged.permissions !== null && - !Array.isArray(merged.permissions) - ? (merged.permissions as Record) - : {}; - - const existingAllow = Array.isArray(existingPermissions.allow) - ? (existingPermissions.allow as string[]) - : []; + const existingPermissions = merged.permissions ?? {}; + const existingAllow = existingPermissions.allow ?? []; merged.permissions = { ...existingPermissions, @@ -83,7 +91,10 @@ export async function configureClaudeSettings( let existing: ClaudeSettings = {}; if (existsSync(settingsPath)) { try { - existing = (await Bun.file(settingsPath).json()) as ClaudeSettings; + const result = ClaudeSettingsSchema.safeParse( + await Bun.file(settingsPath).json() + ); + if (result.success) existing = result.data; } catch { // Corrupted settings file — start fresh } diff --git a/src/helpers/opencode-settings.ts b/src/helpers/opencode-settings.ts index ba6e1478..33aa1e22 100644 --- a/src/helpers/opencode-settings.ts +++ b/src/helpers/opencode-settings.ts @@ -16,13 +16,19 @@ import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; + import { logDebug } from "./log"; import { opencodeConfigDir } from "./paths"; /** The agent name used in opencode's `default_agent` config field. */ const DEFAULT_AGENT = "archgate-developer"; -type OpencodeConfig = Record; +const OpencodeConfigSchema = z + .object({ default_agent: z.string().optional() }) + .passthrough(); + +type OpencodeConfig = z.infer; /** * Pure, additive merge of archgate settings into existing opencode config. @@ -67,7 +73,10 @@ export async function configureOpencodeSettings(): Promise { let existing: OpencodeConfig = {}; if (existsSync(configPath)) { try { - existing = (await Bun.file(configPath).json()) as OpencodeConfig; + const result = OpencodeConfigSchema.safeParse( + await Bun.file(configPath).json() + ); + if (result.success) existing = result.data; } catch { // Corrupted config file — start fresh } diff --git a/src/helpers/project-config.ts b/src/helpers/project-config.ts index b563883a..64f1395f 100644 --- a/src/helpers/project-config.ts +++ b/src/helpers/project-config.ts @@ -14,7 +14,7 @@ import { join } from "node:path"; import { DOMAIN_PREFIXES as DEFAULT_DOMAIN_PREFIXES, - ADR_DOMAINS as DEFAULT_DOMAINS, + ADR_DOMAINS, } from "../formats/adr"; import { DomainNameSchema, @@ -46,7 +46,7 @@ export function loadProjectConfig(projectRoot: string): ProjectConfig { try { const text = readFileSync(path, "utf-8"); - const raw = JSON.parse(text) as unknown; + const raw: unknown = JSON.parse(text); const result = ProjectConfigSchema.safeParse(raw); if (!result.success) { logDebug("Project config invalid, using empty:", result.error.message); @@ -145,7 +145,7 @@ export async function ensureBaseBranch( } export function isDefaultDomain(domain: string): boolean { - return (DEFAULT_DOMAINS as readonly string[]).includes(domain); + return ADR_DOMAINS.some((d) => d === domain); } export interface DomainEntry { @@ -157,10 +157,9 @@ export interface DomainEntry { export function listDomainEntries(projectRoot: string): DomainEntry[] { const config = loadProjectConfig(projectRoot); const custom = config.domains; - const defaults = DEFAULT_DOMAIN_PREFIXES as Record; const merged: DomainEntry[] = []; - for (const [domain, prefix] of Object.entries(defaults)) { + for (const [domain, prefix] of Object.entries(DEFAULT_DOMAIN_PREFIXES)) { merged.push({ domain, prefix, source: "default" }); } for (const [domain, prefix] of Object.entries(custom)) { @@ -195,8 +194,7 @@ export async function addCustomDomain( ); } - const defaults = DEFAULT_DOMAIN_PREFIXES as Record; - const usedDefaultPrefix = Object.entries(defaults).find( + const usedDefaultPrefix = Object.entries(DEFAULT_DOMAIN_PREFIXES).find( ([, p]) => p === prefix ); if (usedDefaultPrefix) { diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts index 51220b38..b3e97697 100644 --- a/src/helpers/prompt.ts +++ b/src/helpers/prompt.ts @@ -82,20 +82,18 @@ function ensureNewlinePatches(): void { function patchStreamWrite(stream: NodeJS.WriteStream): void { const original = stream.write; - stream.write = function patchedWrite( - this: NodeJS.WriteStream, - chunk: unknown, - ...rest: unknown[] - ): boolean { - if (typeof chunk === "string") { - chunk = toCrlf(chunk); - } - return original.apply(this, [chunk, ...rest] as unknown as [ - string, - BufferEncoding?, - ((err?: Error | null) => void)?, - ]); - } as typeof stream.write; + stream.write = new Proxy(original, { + apply(target, thisArg, args: unknown[]) { + if (typeof args[0] === "string") { + args[0] = toCrlf(args[0]); + } + return Reflect.apply(target, thisArg, args); + }, + get(target, prop, receiver) { + if (prop === "name") return "patchedWrite"; + return Reflect.get(target, prop, receiver); + }, + }); } // --------------------------------------------------------------------------- @@ -113,14 +111,16 @@ function patchStreamWrite(stream: NodeJS.WriteStream): void { */ function patchConsoleMethods(): void { for (const method of ["log", "info"] as const) { - console[method] = ((...args: unknown[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console[method] = (...args: any[]) => { process.stdout.write(format(...args) + "\n"); - }) as typeof console.log; + }; } for (const method of ["error", "warn", "debug"] as const) { - console[method] = ((...args: unknown[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console[method] = (...args: any[]) => { process.stderr.write(format(...args) + "\n"); - }) as typeof console.error; + }; } } diff --git a/src/helpers/repo-probe.ts b/src/helpers/repo-probe.ts index 5addec49..faa8941c 100644 --- a/src/helpers/repo-probe.ts +++ b/src/helpers/repo-probe.ts @@ -16,10 +16,18 @@ * identity shared. */ +import { z } from "zod"; + import { logDebug } from "./log"; // `import type` is erased at compile time, so there's no runtime circularity // with `repo.ts` even though `repo.ts` imports the probe's runtime bindings. -import type { RepoContext, RepoHost } from "./repo"; +import type { RepoContext } from "./repo"; + +// Zod schemas for external API responses +const GitHubRepoSchema = z.object({ private: z.boolean().optional() }); +const GitLabProjectSchema = z.object({ visibility: z.string().optional() }); +const BitbucketRepoSchema = z.object({ is_private: z.boolean().optional() }); +const AzureProjectSchema = z.object({ visibility: z.string().optional() }); // --------------------------------------------------------------------------- // Cache @@ -65,11 +73,7 @@ async function probePublic( repo: Pick ): Promise { if (!repo.host || !repo.owner || !repo.name) return null; - const { host, owner, name } = repo as { - host: RepoHost; - owner: string; - name: string; - }; + const { host, owner, name } = repo; if (host === "other") return null; try { @@ -122,7 +126,7 @@ async function probeGitHub( if (!res) return null; if (res.status === 200) { try { - const data = (await res.json()) as { private?: boolean }; + const data = GitHubRepoSchema.parse(await res.json()); return data.private === false; } catch { return null; @@ -145,7 +149,7 @@ async function probeGitLab( if (!res) return null; if (res.status === 200) { try { - const data = (await res.json()) as { visibility?: string }; + const data = GitLabProjectSchema.parse(await res.json()); return data.visibility === "public"; } catch { return null; @@ -164,7 +168,7 @@ async function probeBitbucket( if (!res) return null; if (res.status === 200) { try { - const data = (await res.json()) as { is_private?: boolean }; + const data = BitbucketRepoSchema.parse(await res.json()); return data.is_private === false; } catch { return null; @@ -196,7 +200,7 @@ async function probeAzureDevOps( if (!res) return null; if (res.status === 200) { try { - const data = (await res.json()) as { visibility?: string }; + const data = AzureProjectSchema.parse(await res.json()); return data.visibility === "public"; } catch { return null; diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index e56004cf..5d571ce4 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -3,6 +3,8 @@ import { readdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; +import { z } from "zod"; + import { logDebug } from "./log"; import { copilotSessionStateDir } from "./paths"; import { isWindows } from "./platform"; @@ -13,6 +15,15 @@ import { getContentPreview, } from "./session-context"; +const WorkspaceMetaSchema = z.object({ cwd: z.string().optional() }); + +const CopilotEventSchema = z.object({ + type: z.string().optional(), + data: z.record(z.string(), z.unknown()).optional(), +}); + +const CopilotEventsSchema = z.array(CopilotEventSchema); + interface CopilotSessionSummary { sessionId: string; sessionFile: string; @@ -104,8 +115,9 @@ async function findMatchingCopilotSessions( try { // oxlint-disable-next-line no-await-in-loop -- sequential read needed: each session's YAML determines project match const raw = await Bun.file(yamlPath).text(); - const meta = Bun.YAML.parse(raw) as Record; - const cwd = typeof meta.cwd === "string" ? meta.cwd : null; + const metaResult = WorkspaceMetaSchema.safeParse(Bun.YAML.parse(raw)); + if (!metaResult.success) continue; + const cwd = metaResult.data.cwd ?? null; if (cwd && normalizePath(cwd) === normalizedProjectRoot) { matching.push({ name: dir.name, mtime: dir.mtime }); } @@ -177,10 +189,10 @@ export async function readCopilotSession( // 4. Read events.jsonl const eventsFile = join(stateDir, target.name, "events.jsonl"); - let rawEntries: Array>; + let rawEntries: z.infer; try { const raw = await Bun.file(eventsFile).text(); - rawEntries = Bun.JSONL.parse(raw) as Array>; + rawEntries = CopilotEventsSchema.parse(Bun.JSONL.parse(raw)); } catch { return { ok: false, @@ -196,9 +208,8 @@ export async function readCopilotSession( if (!COPILOT_RELEVANT_TYPES.has(eventType)) continue; const role = eventType === "user.message" ? "user" : "assistant"; // Normalize to TranscriptEntry shape so getContentPreview works - const normalized: TranscriptEntry = { - message: { content: (event.data as Record)?.content }, - }; + const content = event.data?.content; + const normalized: TranscriptEntry = { message: { content } }; relevant.push({ role, contentPreview: getContentPreview(normalized) }); } diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 7fe25e0c..c7734139 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -4,6 +4,8 @@ import { readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; +import { z } from "zod"; + import type { EditorTarget } from "./init-project"; import { isWSL, toWindowsPath } from "./platform"; @@ -50,12 +52,15 @@ export async function encodeProjectPath( const RELEVANT_TYPES = new Set(["user", "assistant"]); export const RELEVANT_ROLES = new Set(["user", "assistant"]); -export interface TranscriptEntry { - type?: string; - role?: string; - message?: { role?: string; content?: unknown }; - [key: string]: unknown; -} +export const TranscriptEntrySchema = z.object({ + type: z.string().optional(), + role: z.string().optional(), + message: z + .object({ role: z.string().optional(), content: z.unknown() }) + .optional(), +}); + +export type TranscriptEntry = z.infer; interface ClaudeSessionSummary { sessionFile: string; @@ -102,9 +107,9 @@ export function getContentPreview(entry: TranscriptEntry): string { const parts: string[] = []; for (const block of content) { if (typeof block !== "object" || block === null) continue; - const b = block as Record; + const b: Record = block; if (b.type === "text" && typeof b.text === "string") { - const text = b.text as string; + const text: string = b.text; parts.push(text.length > 300 ? text.slice(0, 300) + "..." : text); } else if (b.type === "tool_use") { parts.push(`[tool_use: ${b.name}]`); @@ -221,7 +226,7 @@ export async function readClaudeCodeSession( let entries: TranscriptEntry[]; try { const raw = await Bun.file(sessionFile).text(); - entries = Bun.JSONL.parse(raw) as TranscriptEntry[]; + entries = z.array(TranscriptEntrySchema).parse(Bun.JSONL.parse(raw)); } catch { return { ok: false, @@ -368,7 +373,7 @@ export async function readCursorSession( let entries: TranscriptEntry[]; try { const raw = await Bun.file(sessionFile).text(); - entries = Bun.JSONL.parse(raw) as TranscriptEntry[]; + entries = z.array(TranscriptEntrySchema).parse(Bun.JSONL.parse(raw)); } catch { return { ok: false, diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts index ca23acc8..055e553f 100644 --- a/src/helpers/signup.ts +++ b/src/helpers/signup.ts @@ -4,6 +4,8 @@ * signup.ts — Archgate plugins platform signup for unregistered users. */ +import { z } from "zod"; + import { logDebug } from "./log"; const PLUGINS_API = "https://plugins.archgate.dev"; @@ -65,7 +67,9 @@ export async function requestSignup( return { ok: false, token: null }; } - const data = (await response.json().catch(() => ({}))) as { token?: string }; + const data = z + .object({ token: z.string().optional() }) + .parse(await response.json().catch(() => ({}))); logDebug("Signup successful, token provided:", Boolean(data.token)); return { ok: true, token: data.token ?? null }; } diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index dfb0e8bd..0bd0cef3 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -28,8 +28,16 @@ const StackCacheSchema = z.object({ /** Loose schema for the subset of package.json we inspect. */ const PackageJsonSchema = z.object({ - dependencies: z.record(z.string(), z.string()).optional(), - devDependencies: z.record(z.string(), z.string()).optional(), + dependencies: z + .record(z.string(), z.string()) + .optional() + // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default + .catch(undefined), + devDependencies: z + .record(z.string(), z.string()) + .optional() + // oxlint-disable-next-line no-useless-undefined + .catch(undefined), }); /** PEP 621 pyproject.toml — only the [project].dependencies list. */ @@ -218,8 +226,9 @@ export async function detectStackUncached( if (hasPkgJson) { try { - const raw = await Bun.file(pkgJsonPath).json(); - const result = PackageJsonSchema.safeParse(raw); + const result = PackageJsonSchema.safeParse( + await Bun.file(pkgJsonPath).json() + ); pkgJson = result.success ? result.data : null; } catch { logDebug("Failed to parse package.json"); diff --git a/src/helpers/telemetry-config.ts b/src/helpers/telemetry-config.ts index 9dc8c36a..5b371bd0 100644 --- a/src/helpers/telemetry-config.ts +++ b/src/helpers/telemetry-config.ts @@ -14,6 +14,8 @@ import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; +import { z } from "zod"; + import { logDebug } from "./log"; import { internalPath, createPathIfNotExists } from "./paths"; @@ -21,16 +23,14 @@ import { internalPath, createPathIfNotExists } from "./paths"; // Types // --------------------------------------------------------------------------- -export interface TelemetryConfig { - /** Whether telemetry is enabled (default: true). */ - telemetry: boolean; - /** Random UUID generated on first use — not derived from any user data. */ - installId: string; - /** ISO date of first telemetry config creation. */ - createdAt: string; - /** Whether the first-run privacy notice has been shown. */ - noticeShown?: boolean; -} +const TelemetryConfigSchema = z.object({ + telemetry: z.boolean(), + installId: z.string(), + createdAt: z.string().optional(), + noticeShown: z.boolean().optional(), +}); + +export type TelemetryConfig = z.infer; // --------------------------------------------------------------------------- // Paths @@ -88,13 +88,11 @@ export function loadTelemetryConfig(): TelemetryConfig { const path = configPath(); if (existsSync(path)) { const text = readFileSync(path, "utf-8"); - const parsed = JSON.parse(text) as Partial; - if (parsed.installId && typeof parsed.telemetry === "boolean") { + const result = TelemetryConfigSchema.safeParse(JSON.parse(text)); + if (result.success) { cachedConfig = { - telemetry: parsed.telemetry, - installId: parsed.installId, - createdAt: parsed.createdAt ?? new Date().toISOString(), - noticeShown: parsed.noticeShown, + ...result.data, + createdAt: result.data.createdAt ?? new Date().toISOString(), }; logDebug("Telemetry config loaded:", cachedConfig.installId); return cachedConfig; diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index b9c6c379..d9344609 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -4,6 +4,8 @@ import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { z } from "zod"; + import { getWindowsHomeDirFromWSL, isMacOS, @@ -11,7 +13,11 @@ import { isWindows, } from "./platform"; -type VscodeUserSettings = Record; +const VscodeSettingsSchema = z + .object({ "chat.plugins.marketplaces": z.array(z.string()).optional() }) + .passthrough(); + +type VscodeUserSettings = z.infer; /** * VS Code's built-in default marketplaces for `chat.plugins.marketplaces`. @@ -46,11 +52,7 @@ export function mergeMarketplaceUrl( const merged: VscodeUserSettings = { ...existing }; const hasExplicitMarketplaces = "chat.plugins.marketplaces" in existing; - const existingMarketplaces = Array.isArray( - merged["chat.plugins.marketplaces"] - ) - ? (merged["chat.plugins.marketplaces"] as string[]) - : []; + const existingMarketplaces = merged["chat.plugins.marketplaces"] ?? []; // When the key is absent, seed with VS Code's built-in defaults so we don't // silently override them by setting the key explicitly. @@ -149,7 +151,8 @@ export async function addMarketplaceToUserSettings( let existing: VscodeUserSettings = {}; if (existsSync(settingsPath)) { const content = await Bun.file(settingsPath).text(); - existing = Bun.JSONC.parse(content) as VscodeUserSettings; + const result = VscodeSettingsSchema.safeParse(Bun.JSONC.parse(content)); + if (result.success) existing = result.data; } const merged = mergeMarketplaceUrl(existing, marketplaceUrl); diff --git a/tests/helpers/claude-settings.test.ts b/tests/helpers/claude-settings.test.ts index 7288558f..d270cad8 100644 --- a/tests/helpers/claude-settings.test.ts +++ b/tests/helpers/claude-settings.test.ts @@ -70,16 +70,12 @@ describe("mergeClaudeSettings", () => { expect(result.anotherKey).toBe(42); }); - test("handles non-object permissions gracefully", () => { - const result = mergeClaudeSettings( - { permissions: "invalid" }, - ARCHGATE_CLAUDE_SETTINGS - ); + test("handles missing permissions gracefully", () => { + const result = mergeClaudeSettings({}, ARCHGATE_CLAUDE_SETTINGS); - const permissions = result.permissions as Record; - expect(permissions.allow).toEqual( - ARCHGATE_CLAUDE_SETTINGS.permissions.allow - ); + expect(result.permissions?.allow).toEqual([ + ...ARCHGATE_CLAUDE_SETTINGS.permissions.allow, + ]); }); }); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index bf995336..e3519a10 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -67,12 +67,13 @@ describe("mergeMarketplaceUrl", () => { ]); }); - test("handles non-array marketplaces gracefully", () => { - const result = mergeMarketplaceUrl( - { "chat.plugins.marketplaces": "not-an-array", "editor.fontSize": 14 }, - URL - ); - expect(result["chat.plugins.marketplaces"]).toEqual([URL]); + test("handles missing marketplaces gracefully", () => { + const result = mergeMarketplaceUrl({ "editor.fontSize": 14 }, URL); + expect(result["chat.plugins.marketplaces"]).toEqual([ + "github/copilot-plugins", + "github/awesome-copilot", + URL, + ]); expect(result["editor.fontSize"]).toBe(14); }); }); From acfff859dfb14ffcda751cf44fce53bd8212b55c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 21:38:46 -0300 Subject: [PATCH 02/10] refactor: remove unnecessary type annotations, let inference work - project-config: pass JSON.parse directly to safeParse, no intermediate - pack: pass YAML.parse directly to Zod .parse(), no intermediate - stack-detect: let spread result type be inferred - session-context: use `in` narrowing instead of annotated Record cast Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/formats/pack.ts | 3 +-- src/helpers/project-config.ts | 3 +-- src/helpers/session-context.ts | 18 +++++++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/formats/pack.ts b/src/formats/pack.ts index 06c09fb6..f83d5690 100644 --- a/src/formats/pack.ts +++ b/src/formats/pack.ts @@ -31,8 +31,7 @@ export const PackMetadataSchema = z.object({ export type PackMetadata = z.infer; export function parsePackMetadata(raw: string): PackMetadata { - const parsed: unknown = Bun.YAML.parse(raw); - return PackMetadataSchema.parse(parsed); + return PackMetadataSchema.parse(Bun.YAML.parse(raw)); } // ---------- Community links (community/links.yaml) ---------- diff --git a/src/helpers/project-config.ts b/src/helpers/project-config.ts index 64f1395f..050e6e26 100644 --- a/src/helpers/project-config.ts +++ b/src/helpers/project-config.ts @@ -46,8 +46,7 @@ export function loadProjectConfig(projectRoot: string): ProjectConfig { try { const text = readFileSync(path, "utf-8"); - const raw: unknown = JSON.parse(text); - const result = ProjectConfigSchema.safeParse(raw); + const result = ProjectConfigSchema.safeParse(JSON.parse(text)); if (!result.success) { logDebug("Project config invalid, using empty:", result.error.message); return EMPTY_CONFIG; diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index c7734139..5b085143 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -107,15 +107,19 @@ export function getContentPreview(entry: TranscriptEntry): string { const parts: string[] = []; for (const block of content) { if (typeof block !== "object" || block === null) continue; - const b: Record = block; - if (b.type === "text" && typeof b.text === "string") { - const text: string = b.text; + if (!("type" in block)) continue; + if ( + block.type === "text" && + "text" in block && + typeof block.text === "string" + ) { + const text = block.text; parts.push(text.length > 300 ? text.slice(0, 300) + "..." : text); - } else if (b.type === "tool_use") { - parts.push(`[tool_use: ${b.name}]`); - } else if (b.type === "tool_result") { + } else if (block.type === "tool_use" && "name" in block) { + parts.push(`[tool_use: ${block.name}]`); + } else if (block.type === "tool_result" && "tool_use_id" in block) { parts.push( - `[tool_result: ${String(b.tool_use_id ?? "").slice(0, 20)}]` + `[tool_result: ${String(block.tool_use_id ?? "").slice(0, 20)}]` ); } } From 9012211a094e24cd2c92f7fa43ccde38904ca6a5 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 21:42:31 -0300 Subject: [PATCH 03/10] refactor: use Commander's own types and as const for inference - cli: use CommandUnknownOpts from Commander instead of custom CommandLike interface - reporter: use `as const` on ternary branches instead of explicit type annotation for sevColor Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/cli.ts | 19 +++++++------------ src/engine/reporter.ts | 8 ++++---- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9f045e52..a7f8c090 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,11 @@ #!/usr/bin/env bun // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { Command, Option } from "@commander-js/extra-typings"; +import { + Command, + type CommandUnknownOpts, + Option, +} from "@commander-js/extra-typings"; import { semver } from "bun"; import packageJson from "../package.json"; @@ -169,19 +173,10 @@ async function main() { /** * Reconstruct the full command name from Commander's command chain. * E.g., "adr create" from the "create" subcommand of "adr". - * - * Typed against the loose Commander "unknown opts" shape because it's called - * from the `preAction` / `postAction` hook callback, where Commander gives us - * a `CommandUnknownOpts`, not the narrowly-typed `Command<[], {}, {}>`. */ -interface CommandLike { - name(): string; - parent: CommandLike | null; -} - -function getFullCommandName(command: CommandLike | null): string { +function getFullCommandName(command: CommandUnknownOpts | null): string { const parts: string[] = []; - let current: CommandLike | null = command; + let current: CommandUnknownOpts | null = command; while (current) { const name = current.name(); if (name && name !== "archgate") { diff --git a/src/engine/reporter.ts b/src/engine/reporter.ts index f69cd025..134cb638 100644 --- a/src/engine/reporter.ts +++ b/src/engine/reporter.ts @@ -176,12 +176,12 @@ export function reportConsole( // Print violations for (const v of r.violations) { const loc = v.file ? (v.line ? `${v.file}:${v.line}` : v.file) : ""; - const sevColor: "red" | "yellow" | "dim" = + const sevColor = v.severity === "error" - ? "red" + ? ("red" as const) : v.severity === "warning" - ? "yellow" - : "dim"; + ? ("yellow" as const) + : ("dim" as const); const sevLabel = v.severity === "error" From d74cf4c82f1bdc655cca0d0b9bb096ed9a08f196 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 21:44:06 -0300 Subject: [PATCH 04/10] refactor: use as const on getExitCode returns instead of return type annotation Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/engine/reporter.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/engine/reporter.ts b/src/engine/reporter.ts index 134cb638..22fbcbaf 100644 --- a/src/engine/reporter.ts +++ b/src/engine/reporter.ts @@ -312,23 +312,20 @@ export function reportCI( * one for telemetry, and walking `result.results` a second time here is pure * duplication. */ -export function getExitCode( - result: CheckResult, - summary?: ReportSummary -): 0 | 1 | 2 { +export function getExitCode(result: CheckResult, summary?: ReportSummary) { if (summary) { - if (summary.ruleErrors > 0) return 2; - if (summary.failed > 0) return 1; - if (summary.warningsExceeded) return 1; - return 0; + if (summary.ruleErrors > 0) return 2 as const; + if (summary.failed > 0) return 1 as const; + if (summary.warningsExceeded) return 1 as const; + return 0 as const; } const hasErrors = result.results.some((r) => r.error); - if (hasErrors) return 2; + if (hasErrors) return 2 as const; const hasViolations = result.results.some((r) => r.violations.some((v) => v.severity === "error") ); - if (hasViolations) return 1; + if (hasViolations) return 1 as const; - return 0; + return 0 as const; } From acdd9794da78dde0a7f0b73e4bb7f52ad4d9e7cc Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 21:48:16 -0300 Subject: [PATCH 05/10] refactor: remove widening annotation in adr-writer domain prefix lookup Use Object.entries().find() instead of widening DOMAIN_PREFIXES to Record for string key indexing. Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/helpers/adr-writer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts index 97f705ad..5c42c2bd 100644 --- a/src/helpers/adr-writer.ts +++ b/src/helpers/adr-writer.ts @@ -91,8 +91,9 @@ export async function createAdrFile( rules?: boolean; } ): Promise { - const domainPrefixes: Record = DOMAIN_PREFIXES; - const prefix = opts.prefix ?? domainPrefixes[opts.domain]; + const prefix = + opts.prefix ?? + Object.entries(DOMAIN_PREFIXES).find(([d]) => d === opts.domain)?.[1]; if (!prefix) { throw new UserError( `No prefix registered for domain '${opts.domain}'. Pass opts.prefix or register via \`archgate domain add\`.` From 2c9e6645774803a1ecf189a240f08960834414cc Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 22:02:16 -0300 Subject: [PATCH 06/10] refactor: replace AST type guards with Zod schema validation Replace isAstNode/childNode/strProp/boolProp type guard functions with a recursive AstNodeSchema Zod schema. The schema validates node structure including type, name, value, computed, and recursive child fields (source, object, property, callee, left). The value field accepts both literals and child nodes (Property.value can be an ObjectExpression). Walk functions now access typed fields directly from the schema-validated node instead of manual property extraction. Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/engine/rule-scanner.ts | 153 +++++++++++++++++++------------------ 1 file changed, 78 insertions(+), 75 deletions(-) diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 1b9c6396..f8c0d848 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate +import { z } from "zod"; + import { parseJsModule, type MeriyahProgram } from "./js-parser"; /** @@ -22,41 +24,49 @@ export interface ScanViolation { endColumn: number; } -interface AstLoc { - start: { line: number; column: number }; - end: { line: number; column: number }; -} +// --------------------------------------------------------------------------- +// Zod schemas for ESTree AST nodes +// --------------------------------------------------------------------------- interface AstNode { type: string; - loc?: AstLoc; + name?: string; + value?: string | number | boolean | null | AstNode; + computed?: boolean; + source?: AstNode; + object?: AstNode; + property?: AstNode; + callee?: AstNode; + left?: AstNode; [key: string]: unknown; } -/** Runtime check: is `value` an AST node (has a `type` string property)? */ -function isAstNode(value: unknown): value is AstNode { - return ( - typeof value === "object" && - value !== null && - "type" in value && - typeof value.type === "string" - ); -} - -/** Narrow an AST child property to an AstNode, or null. */ -function childNode(value: unknown): AstNode | null { - return isAstNode(value) ? value : null; -} - -/** Read a string-valued property from an AST node, or undefined. */ -function strProp(node: AstNode, key: string): string | undefined { - const v = node[key]; - return typeof v === "string" ? v : undefined; -} +const AstNodeSchema: z.ZodType = z + .object({ + type: z.string(), + name: z.string().optional(), + value: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.lazy(() => AstNodeSchema), + ]) + .optional(), + computed: z.boolean().optional(), + source: z.lazy(() => AstNodeSchema).optional(), + object: z.lazy(() => AstNodeSchema).optional(), + property: z.lazy(() => AstNodeSchema).optional(), + callee: z.lazy(() => AstNodeSchema).optional(), + left: z.lazy(() => AstNodeSchema).optional(), + }) + .passthrough(); -/** Read a boolean-valued property from an AST node (defaults to false). */ -function boolProp(node: AstNode, key: string): boolean { - return node[key] === true; +/** Parse an unknown value into an AstNode, or return null. */ +function parseNode(value: unknown): AstNode | null { + const result = AstNodeSchema.safeParse(value); + return result.success ? result.data : null; } import { remapViolations, type RawViolation } from "./source-positions"; @@ -126,8 +136,10 @@ export function scanRuleSource(source: string): ScanViolation[] { switch (node.type) { case "ImportDeclaration": { - const srcNode = childNode(node.source); - const src = srcNode ? strProp(srcNode, "value") : undefined; + const src = + typeof node.source?.value === "string" + ? node.source.value + : undefined; if (src && (BANNED_MODULES.test(src) || src === "bun")) { // Use `from "module"` as search anchor — `from` is in code context // (unlike the bare module string which buildNonCodeRanges marks as non-code). @@ -139,37 +151,34 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "MemberExpression": { - const obj = childNode(node.object); - const prop = childNode(node.property); + const obj = node.object; + const prop = node.property; if (!obj || !prop) break; - const computed = boolProp(node, "computed"); - const objName = strProp(obj, "name"); - const propName = strProp(prop, "name"); + const computed = node.computed ?? false; // Block Bun.spawn, Bun.write, Bun.$, Bun.file, Bun.spawnSync if ( - objName === "Bun" && + obj.name === "Bun" && !computed && - BLOCKED_BUN_PROPS.has(propName ?? "") + BLOCKED_BUN_PROPS.has(prop.name ?? "") ) { pushViolation( - `Bun.${propName}() is blocked in rule files. Use the RuleContext API instead.`, - `Bun.${propName}` + `Bun.${prop.name}() is blocked in rule files. Use the RuleContext API instead.`, + `Bun.${prop.name}` ); } // Block computed access: Bun[x], globalThis[x] - if (computed && (objName === "Bun" || objName === "globalThis")) { + if (computed && (obj.name === "Bun" || obj.name === "globalThis")) { pushViolation( - `Computed property access on ${objName} is blocked in rule files.`, - `${objName}[` + `Computed property access on ${obj.name} is blocked in rule files.`, + `${obj.name}[` ); } break; } case "CallExpression": { - const callee = childNode(node.callee); - const name = callee ? strProp(callee, "name") : undefined; + const name = node.callee?.name; if (name === "eval") { pushViolation("eval() is blocked in rule files.", "eval("); } @@ -188,9 +197,7 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "NewExpression": { - const callee = childNode(node.callee); - const name = callee ? strProp(callee, "name") : undefined; - if (name === "Function") { + if (node.callee?.name === "Function") { pushViolation( "new Function() is blocked in rule files.", "new Function(" @@ -199,8 +206,7 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "ImportExpression": { - const src = childNode(node.source); - if (src && src.type !== "Literal") { + if (node.source && node.source.type !== "Literal") { pushViolation( "Dynamic import() with non-literal argument is blocked in rule files.", "import(" @@ -209,20 +215,19 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "AssignmentExpression": { - const left = childNode(node.left); + const left = node.left; if (left && left.type === "MemberExpression") { - const leftObj = childNode(left.object); - const leftProp = childNode(left.property); - const leftObjName = leftObj ? strProp(leftObj, "name") : undefined; - const leftPropName = leftProp ? strProp(leftProp, "name") : undefined; - if (leftObjName === "globalThis") { + if (left.object?.name === "globalThis") { pushViolation( "Mutating globalThis is blocked in rule files.", "globalThis." ); } - if (leftObjName === "process" && leftPropName === "env") { - const target = `${leftObjName}.${leftPropName}`; + if ( + left.object?.name === "process" && + left.property?.name === "env" + ) { + const target = `${left.object.name}.${left.property.name}`; pushViolation( `Mutating ${target} is blocked in rule files.`, target @@ -237,17 +242,18 @@ export function scanRuleSource(source: string): ScanViolation[] { for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const item of value) { - const child = childNode(item); + const child = parseNode(item); if (child) walk(child); } } else { - const child = childNode(value); + const child = parseNode(value); if (child) walk(child); } } } - if (isAstNode(ast)) walk(ast); + const root = parseNode(ast); + if (root) walk(root); return remapViolations(source, rawViolations); } @@ -317,15 +323,13 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { switch (node.type) { case "MemberExpression": { - const obj = childNode(node.object); - const prop = childNode(node.property); + const obj = node.object; + const prop = node.property; if (!obj || !prop) break; - const computed = boolProp(node, "computed"); - const objName = strProp(obj, "name"); - const propName = strProp(prop, "name"); + const computed = node.computed ?? false; // Block Bun.env - if (objName === "Bun" && !computed && propName === "env") { + if (obj.name === "Bun" && !computed && prop.name === "env") { pushViolation( "Bun.env access is blocked in imported rule files.", "Bun.env" @@ -333,8 +337,8 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { } // Block process env reads - if (objName === "process" && !computed && propName === "env") { - const target = `${objName}.${propName}`; + if (obj.name === "process" && !computed && prop.name === "env") { + const target = `${obj.name}.${prop.name}`; pushViolation( `${target} access is blocked in imported rule files.`, target @@ -343,8 +347,7 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { break; } case "CallExpression": { - const callee = childNode(node.callee); - const name = callee ? strProp(callee, "name") : undefined; + const name = node.callee?.name; if (name && IMPORTED_BLOCKED_GLOBALS.has(name)) { pushViolation( `${name}() is blocked in imported rule files.`, @@ -354,8 +357,7 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { break; } case "NewExpression": { - const callee = childNode(node.callee); - const name = callee ? strProp(callee, "name") : undefined; + const name = node.callee?.name; if (name && IMPORTED_BLOCKED_GLOBALS.has(name)) { pushViolation( `new ${name}() is blocked in imported rule files.`, @@ -369,17 +371,18 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const item of value) { - const child = childNode(item); + const child = parseNode(item); if (child) walkImported(child); } } else { - const child = childNode(value); + const child = parseNode(value); if (child) walkImported(child); } } } - if (isAstNode(ast)) walkImported(ast); + const importedRoot = parseNode(ast); + if (importedRoot) walkImported(importedRoot); // Combine: standard scan + imported-only scan const standardViolations = scanRuleSource(source); From 36e115df5bbe0c1d480d297c52f3d0239800365c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 22:23:19 -0300 Subject: [PATCH 07/10] refactor: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli: remove redundant type annotation on `current` variable - runner: replace Promise with overloaded function declaration matching RuleContext["ast"] signatures — no any or as needed - adr: use Zod PlainObjectSchema instead of manual isPlainObject guard - pack: use safeParse + UserError (matching parseAdr pattern) - auth: use safeParse for error body to handle non-object JSON - binary-upgrade: use safeParse to preserve null-on-failure contract - signup: use safeParse for response validation - claude/vscode/opencode settings: add .catch(undefined) on schema fields to prevent whole-object wipe when a single field is invalid - vscode-settings: wrap JSONC parse in try/catch for corrupted files - session-context: use discriminated union schema for content blocks (text/tool_use/tool_result) — replaces manual in-checks - stack-detect: use PackageJsonSchema with typed deps/devDeps fields - ARCH-022 rule: add FunctionDeclaration case for overloaded astImpl Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- .../ARCH-022-ast-aware-rule-context.rules.ts | 8 ++++ src/cli.ts | 2 +- src/engine/runner.ts | 25 +++++++---- src/formats/adr.ts | 12 ++---- src/formats/pack.ts | 12 +++++- src/helpers/auth.ts | 3 +- src/helpers/binary-upgrade.ts | 10 +++-- src/helpers/claude-settings.ts | 12 ++++-- src/helpers/opencode-settings.ts | 3 +- src/helpers/session-context-copilot.ts | 4 +- src/helpers/session-context.ts | 43 ++++++++++++------- src/helpers/signup.ts | 8 ++-- src/helpers/stack-detect.ts | 2 +- src/helpers/vscode-settings.ts | 18 ++++++-- 14 files changed, 109 insertions(+), 53 deletions(-) diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts index dcfea362..b59201e1 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts @@ -66,6 +66,14 @@ export default { } return; } + // Overloaded function declaration: `async function astImpl(…) {…}` + if (n.type === "FunctionDeclaration") { + const id = n.id as (EsTreeNode & { name?: string }) | undefined; + if (id?.name === "astImpl") { + astMethodBody = n.body; + } + return; + } // Fallback: inline `ast(path, language) { … }` object method, in // case the implementation is ever moved back onto the object. if (n.type === "Property") { diff --git a/src/cli.ts b/src/cli.ts index a7f8c090..f1460c7b 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -176,7 +176,7 @@ async function main() { */ function getFullCommandName(command: CommandUnknownOpts | null): string { const parts: string[] = []; - let current: CommandUnknownOpts | null = command; + let current = command; while (current) { const name = current.name(); if (name && name !== "archgate") { diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 7a292d69..040a2caa 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -5,7 +5,10 @@ import { relative, resolve, isAbsolute } from "node:path"; import type { AstLanguage, + EsTreeProgram, GrepMatch, + PythonAstModule, + RubyAstNode, RuleContext, RuleReport, ViolationDetail, @@ -160,15 +163,19 @@ function createRuleContext( }, }; - // ARCH-022: ctx.ast() implementation. The broad implementation signature - // returns `Promise` so it satisfies the language-narrowed public - // overloads on RuleContext["ast"] without double-cast. The four guardrails - // below MUST run in this order before any subprocess. - const astImpl: RuleContext["ast"] = async ( + // ARCH-022: ctx.ast() implementation. Overload declarations match + // RuleContext["ast"] so each language narrows to the correct return type. + // The four guardrails below MUST run in this order before any subprocess. + async function astImpl( path: string, - language: AstLanguage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): Promise => { + language: "typescript" | "javascript" + ): Promise; + async function astImpl( + path: string, + language: "python" + ): Promise; + async function astImpl(path: string, language: "ruby"): Promise; + async function astImpl(path: string, language: AstLanguage) { // Guardrail 1: path safety — same sandbox as readFile/glob. const absPath = safePath(projectRoot, path); @@ -249,7 +256,7 @@ function createRuleContext( throw new Error(`Failed to parse "${path}" as ${language}: ${detail}`); } return parseAstJson(stdout, path, language); - }; + } return { projectRoot, diff --git a/src/formats/adr.ts b/src/formats/adr.ts index 9f61924d..da2717ce 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -56,15 +56,11 @@ export interface AdrDocument { /** * Parse YAML frontmatter from a raw string (the content between --- delimiters). */ -export function parseFrontmatter(raw: string): Record { - const parsed = Bun.YAML.parse(raw); - if (!isPlainObject(parsed)) return {}; - return parsed; -} +const PlainObjectSchema = z.record(z.string(), z.unknown()); -/** Narrow `unknown` to a plain object (not array, not null). */ -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +export function parseFrontmatter(raw: string): Record { + const result = PlainObjectSchema.safeParse(Bun.YAML.parse(raw)); + return result.success ? result.data : {}; } function formatZodErrors(error: z.ZodError): string[] { diff --git a/src/formats/pack.ts b/src/formats/pack.ts index f83d5690..94f936b0 100644 --- a/src/formats/pack.ts +++ b/src/formats/pack.ts @@ -2,6 +2,8 @@ // Copyright 2026 Archgate import { z } from "zod"; +import { UserError } from "../helpers/user-error"; + // ---------- Pack metadata (archgate-pack.yaml) ---------- export const PackMetadataSchema = z.object({ @@ -31,7 +33,15 @@ export const PackMetadataSchema = z.object({ export type PackMetadata = z.infer; export function parsePackMetadata(raw: string): PackMetadata { - return PackMetadataSchema.parse(Bun.YAML.parse(raw)); + const result = PackMetadataSchema.safeParse(Bun.YAML.parse(raw)); + if (!result.success) { + const errors = result.error.issues.map((i) => { + const path = i.path.join("."); + return path ? `${path}: ${i.message}` : i.message; + }); + throw new UserError(`Invalid pack metadata:\n - ${errors.join("\n - ")}`); + } + return result.data; } // ---------- Community links (community/links.yaml) ---------- diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index bbc5d25a..8d204506 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -210,9 +210,10 @@ export async function claimArchgateToken(githubToken: string): Promise { }); if (!response.ok) { - const body = ErrorResponseSchema.parse( + const errorResult = ErrorResponseSchema.safeParse( await response.json().catch(() => ({})) ); + const body = errorResult.success ? errorResult.data : {}; if (isSignupRequiredError(body.error)) { throw new SignupRequiredError(); diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts index efa03e81..63e17440 100644 --- a/src/helpers/binary-upgrade.ts +++ b/src/helpers/binary-upgrade.ts @@ -92,9 +92,13 @@ export async function fetchLatestGitHubVersion( return null; } - const data = GitHubReleaseSchema.parse(await response.json()); - logDebug("Latest release tag:", data.tag_name ?? "(none)"); - return data.tag_name ?? null; + const result = GitHubReleaseSchema.safeParse(await response.json()); + if (!result.success) { + logDebug("Failed to parse GitHub release response"); + return null; + } + logDebug("Latest release tag:", result.data.tag_name ?? "(none)"); + return result.data.tag_name ?? null; } // --------------------------------------------------------------------------- diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts index d07939f5..57a3b1e8 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -7,15 +7,19 @@ import { z } from "zod"; const ClaudePermissionsSchema = z .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), + // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default + allow: z.array(z.string()).optional().catch(undefined), + // oxlint-disable-next-line no-useless-undefined + deny: z.array(z.string()).optional().catch(undefined), }) .passthrough(); const ClaudeSettingsSchema = z .object({ - agent: z.string().optional(), - permissions: ClaudePermissionsSchema.optional(), + // oxlint-disable-next-line no-useless-undefined + agent: z.string().optional().catch(undefined), + // oxlint-disable-next-line no-useless-undefined + permissions: ClaudePermissionsSchema.optional().catch(undefined), }) .passthrough(); diff --git a/src/helpers/opencode-settings.ts b/src/helpers/opencode-settings.ts index 33aa1e22..fd976c79 100644 --- a/src/helpers/opencode-settings.ts +++ b/src/helpers/opencode-settings.ts @@ -25,7 +25,8 @@ import { opencodeConfigDir } from "./paths"; const DEFAULT_AGENT = "archgate-developer"; const OpencodeConfigSchema = z - .object({ default_agent: z.string().optional() }) + // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default + .object({ default_agent: z.string().optional().catch(undefined) }) .passthrough(); type OpencodeConfig = z.infer; diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index 5d571ce4..7926d9f1 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -9,6 +9,7 @@ import { logDebug } from "./log"; import { copilotSessionStateDir } from "./paths"; import { isWindows } from "./platform"; import { + MessageContentSchema, type ReadSessionOptions, type SessionListResult, type TranscriptEntry, @@ -208,7 +209,8 @@ export async function readCopilotSession( if (!COPILOT_RELEVANT_TYPES.has(eventType)) continue; const role = eventType === "user.message" ? "user" : "assistant"; // Normalize to TranscriptEntry shape so getContentPreview works - const content = event.data?.content; + const contentResult = MessageContentSchema.safeParse(event.data?.content); + const content = contentResult.success ? contentResult.data : undefined; const normalized: TranscriptEntry = { message: { content } }; relevant.push({ role, contentPreview: getContentPreview(normalized) }); } diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 5b085143..db6a29d6 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -52,11 +52,25 @@ export async function encodeProjectPath( const RELEVANT_TYPES = new Set(["user", "assistant"]); export const RELEVANT_ROLES = new Set(["user", "assistant"]); +const ContentBlockSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), text: z.string() }), + z.object({ type: z.literal("tool_use"), name: z.string() }), + z.object({ type: z.literal("tool_result"), tool_use_id: z.string() }), +]); + +export const MessageContentSchema = z.union([ + z.string(), + z.array(ContentBlockSchema), +]); + export const TranscriptEntrySchema = z.object({ type: z.string().optional(), role: z.string().optional(), message: z - .object({ role: z.string().optional(), content: z.unknown() }) + .object({ + role: z.string().optional(), + content: MessageContentSchema.optional(), + }) .optional(), }); @@ -106,21 +120,18 @@ export function getContentPreview(entry: TranscriptEntry): string { if (Array.isArray(content)) { const parts: string[] = []; for (const block of content) { - if (typeof block !== "object" || block === null) continue; - if (!("type" in block)) continue; - if ( - block.type === "text" && - "text" in block && - typeof block.text === "string" - ) { - const text = block.text; - parts.push(text.length > 300 ? text.slice(0, 300) + "..." : text); - } else if (block.type === "tool_use" && "name" in block) { - parts.push(`[tool_use: ${block.name}]`); - } else if (block.type === "tool_result" && "tool_use_id" in block) { - parts.push( - `[tool_result: ${String(block.tool_use_id ?? "").slice(0, 20)}]` - ); + switch (block.type) { + case "text": { + const text = block.text; + parts.push(text.length > 300 ? text.slice(0, 300) + "..." : text); + break; + } + case "tool_use": + parts.push(`[tool_use: ${block.name}]`); + break; + case "tool_result": + parts.push(`[tool_result: ${block.tool_use_id.slice(0, 20)}]`); + break; } } return parts.join(" | "); diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts index 055e553f..99756100 100644 --- a/src/helpers/signup.ts +++ b/src/helpers/signup.ts @@ -67,9 +67,11 @@ export async function requestSignup( return { ok: false, token: null }; } - const data = z - .object({ token: z.string().optional() }) - .parse(await response.json().catch(() => ({}))); + const SignupResponseSchema = z.object({ token: z.string().optional() }); + const result = SignupResponseSchema.safeParse( + await response.json().catch(() => ({})) + ); + const data = result.success ? result.data : {}; logDebug("Signup successful, token provided:", Boolean(data.token)); return { ok: true, token: data.token ?? null }; } diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index 0bd0cef3..62cd3ff5 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -229,7 +229,7 @@ export async function detectStackUncached( const result = PackageJsonSchema.safeParse( await Bun.file(pkgJsonPath).json() ); - pkgJson = result.success ? result.data : null; + if (result.success) pkgJson = result.data; } catch { logDebug("Failed to parse package.json"); } diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index d9344609..844c6716 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -14,7 +14,13 @@ import { } from "./platform"; const VscodeSettingsSchema = z - .object({ "chat.plugins.marketplaces": z.array(z.string()).optional() }) + .object({ + "chat.plugins.marketplaces": z + .array(z.string()) + .optional() + // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default + .catch(undefined), + }) .passthrough(); type VscodeUserSettings = z.infer; @@ -150,9 +156,13 @@ export async function addMarketplaceToUserSettings( let existing: VscodeUserSettings = {}; if (existsSync(settingsPath)) { - const content = await Bun.file(settingsPath).text(); - const result = VscodeSettingsSchema.safeParse(Bun.JSONC.parse(content)); - if (result.success) existing = result.data; + try { + const content = await Bun.file(settingsPath).text(); + const result = VscodeSettingsSchema.safeParse(Bun.JSONC.parse(content)); + if (result.success) existing = result.data; + } catch { + // Corrupted settings file — start fresh + } } const merged = mergeMarketplaceUrl(existing, marketplaceUrl); From 60b414ac9b1f234f7f1642ed5a3457bc97a422df Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 7 Jul 2026 05:45:40 -0300 Subject: [PATCH 08/10] refactor: use Zod .default() to eliminate nullish coalescing Address review feedback: set defaults in Zod schemas instead of using ?? at consumer sites. - session-context: type/role default to "" via schema, removing ?? "" and non-null assertions (!) from consumers - session-context-copilot: event type defaults to "" via schema - auth: email defaults to null via .nullable().default(null) - signup: token defaults to null via .nullable().default(null) - claude-settings: permissions.allow/deny default to [] via schema, catch invalid permissions to { allow: [], deny: [] } - vscode-settings: catch invalid marketplaces to [] - Add regression tests for invalid-data graceful handling through schema .catch() (claude-settings, vscode-settings) Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/helpers/auth.ts | 4 +- src/helpers/claude-settings.ts | 22 +++++------ src/helpers/session-context-copilot.ts | 16 +++++--- src/helpers/session-context-opencode.ts | 6 ++- src/helpers/session-context.ts | 12 +++--- src/helpers/signup.ts | 8 ++-- src/helpers/vscode-settings.ts | 9 ++--- tests/helpers/claude-settings.test.ts | 51 ++++++++++++++++--------- tests/helpers/vscode-settings.test.ts | 12 ++++++ 9 files changed, 88 insertions(+), 52 deletions(-) diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index 8d204506..9460d002 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -53,7 +53,7 @@ const DeviceTokenResponseSchema = z.union([ const GitHubUserSchema = z.object({ login: z.string().optional(), - email: z.string().nullable().optional(), + email: z.string().nullable().default(null), }); const TokenResponseSchema = z.object({ token: z.string().optional() }); @@ -185,7 +185,7 @@ export async function getGitHubUser( throw new Error("GitHub API did not return a username"); } logDebug("GitHub user:", data.login); - return { login: data.login, email: data.email ?? null }; + return { login: data.login, email: data.email }; } // --------------------------------------------------------------------------- diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts index 57a3b1e8..d2c8bc62 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -7,19 +7,20 @@ import { z } from "zod"; const ClaudePermissionsSchema = z .object({ - // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default - allow: z.array(z.string()).optional().catch(undefined), - // oxlint-disable-next-line no-useless-undefined - deny: z.array(z.string()).optional().catch(undefined), + allow: z.array(z.string()).default([]).catch([]), + deny: z.array(z.string()).default([]).catch([]), }) .passthrough(); -const ClaudeSettingsSchema = z +/** @internal Exported for testing only. */ +export const ClaudeSettingsSchema = z .object({ - // oxlint-disable-next-line no-useless-undefined + // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default for optional fields agent: z.string().optional().catch(undefined), - // oxlint-disable-next-line no-useless-undefined - permissions: ClaudePermissionsSchema.optional().catch(undefined), + permissions: ClaudePermissionsSchema.optional().catch({ + allow: [], + deny: [], + }), }) .passthrough(); @@ -66,12 +67,11 @@ export function mergeClaudeSettings( } // Nested permissions object: merge allow array with dedup, preserve deny - const existingPermissions = merged.permissions ?? {}; - const existingAllow = existingPermissions.allow ?? []; + const existingPermissions = merged.permissions ?? { allow: [], deny: [] }; merged.permissions = { ...existingPermissions, - allow: dedup([...existingAllow, ...archgate.permissions.allow]), + allow: dedup([...existingPermissions.allow, ...archgate.permissions.allow]), }; return merged; diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index 7926d9f1..e039f469 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -16,10 +16,12 @@ import { getContentPreview, } from "./session-context"; -const WorkspaceMetaSchema = z.object({ cwd: z.string().optional() }); +const WorkspaceMetaSchema = z.object({ + cwd: z.string().nullable().default(null), +}); const CopilotEventSchema = z.object({ - type: z.string().optional(), + type: z.string().default(""), data: z.record(z.string(), z.unknown()).optional(), }); @@ -118,7 +120,7 @@ async function findMatchingCopilotSessions( const raw = await Bun.file(yamlPath).text(); const metaResult = WorkspaceMetaSchema.safeParse(Bun.YAML.parse(raw)); if (!metaResult.success) continue; - const cwd = metaResult.data.cwd ?? null; + const cwd = metaResult.data.cwd; if (cwd && normalizePath(cwd) === normalizedProjectRoot) { matching.push({ name: dir.name, mtime: dir.mtime }); } @@ -205,13 +207,17 @@ export async function readCopilotSession( // 5. Filter to user/assistant events and normalize to TranscriptEntry shape const relevant: CopilotSessionSummary["transcript"] = []; for (const event of rawEntries) { - const eventType = String(event.type ?? ""); + const eventType = event.type; if (!COPILOT_RELEVANT_TYPES.has(eventType)) continue; const role = eventType === "user.message" ? "user" : "assistant"; // Normalize to TranscriptEntry shape so getContentPreview works const contentResult = MessageContentSchema.safeParse(event.data?.content); const content = contentResult.success ? contentResult.data : undefined; - const normalized: TranscriptEntry = { message: { content } }; + const normalized: TranscriptEntry = { + type: "", + role, + message: { content }, + }; relevant.push({ role, contentPreview: getContentPreview(normalized) }); } diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index 9f958c05..16ef12b5 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -279,7 +279,11 @@ export function readOpencodeSession( const content = contentParts.join("\n"); if (content.length === 0) continue; - const normalized: TranscriptEntry = { message: { content } }; + const normalized: TranscriptEntry = { + type: "", + role: msg.role, + message: { content }, + }; relevant.push({ role: msg.role, contentPreview: getContentPreview(normalized), diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index db6a29d6..0d1aae72 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -64,8 +64,8 @@ export const MessageContentSchema = z.union([ ]); export const TranscriptEntrySchema = z.object({ - type: z.string().optional(), - role: z.string().optional(), + type: z.string().default(""), + role: z.string().default(""), message: z .object({ role: z.string().optional(), @@ -252,9 +252,9 @@ export async function readClaudeCodeSession( const relevant: ClaudeSessionSummary["transcript"] = []; for (const entry of entries) { - if (!RELEVANT_TYPES.has(entry.type ?? "")) continue; + if (!RELEVANT_TYPES.has(entry.type)) continue; relevant.push({ - type: entry.type!, + type: entry.type, role: entry.message?.role, contentPreview: getContentPreview(entry), }); @@ -399,9 +399,9 @@ export async function readCursorSession( const relevant: CursorSessionSummary["transcript"] = []; for (const entry of entries) { - if (!RELEVANT_ROLES.has(entry.role ?? "")) continue; + if (!RELEVANT_ROLES.has(entry.role)) continue; relevant.push({ - role: entry.role!, + role: entry.role, contentPreview: getContentPreview(entry), }); } diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts index 99756100..8bd2dc25 100644 --- a/src/helpers/signup.ts +++ b/src/helpers/signup.ts @@ -67,11 +67,13 @@ export async function requestSignup( return { ok: false, token: null }; } - const SignupResponseSchema = z.object({ token: z.string().optional() }); + const SignupResponseSchema = z.object({ + token: z.string().nullable().default(null), + }); const result = SignupResponseSchema.safeParse( await response.json().catch(() => ({})) ); - const data = result.success ? result.data : {}; + const data = result.success ? result.data : { token: null }; logDebug("Signup successful, token provided:", Boolean(data.token)); - return { ok: true, token: data.token ?? null }; + return { ok: true, token: data.token }; } diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index 844c6716..1e767b7e 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -13,13 +13,10 @@ import { isWindows, } from "./platform"; -const VscodeSettingsSchema = z +/** @internal Exported for testing only. */ +export const VscodeSettingsSchema = z .object({ - "chat.plugins.marketplaces": z - .array(z.string()) - .optional() - // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default - .catch(undefined), + "chat.plugins.marketplaces": z.array(z.string()).optional().catch([]), }) .passthrough(); diff --git a/tests/helpers/claude-settings.test.ts b/tests/helpers/claude-settings.test.ts index d270cad8..21832b0a 100644 --- a/tests/helpers/claude-settings.test.ts +++ b/tests/helpers/claude-settings.test.ts @@ -7,27 +7,31 @@ import { join } from "node:path"; import { ARCHGATE_CLAUDE_SETTINGS, + ClaudeSettingsSchema, mergeClaudeSettings, configureClaudeSettings, } from "../../src/helpers/claude-settings"; +/** Parse raw input through the schema, matching the real configureClaudeSettings flow. */ +function parse(raw: Record) { + return ClaudeSettingsSchema.parse(raw); +} + describe("mergeClaudeSettings", () => { test("sets all archgate values when existing settings are empty", () => { - const result = mergeClaudeSettings({}, ARCHGATE_CLAUDE_SETTINGS); + const result = mergeClaudeSettings(parse({}), ARCHGATE_CLAUDE_SETTINGS); expect(result.agent).toBe("archgate:developer"); - expect(result.permissions).toEqual({ - allow: [ - "Skill(archgate:architect)", - "Skill(archgate:quality-manager)", - "Skill(archgate:adr-author)", - ], - }); + expect(result.permissions?.allow).toEqual([ + "Skill(archgate:architect)", + "Skill(archgate:quality-manager)", + "Skill(archgate:adr-author)", + ]); }); test("preserves existing agent (does not overwrite)", () => { const result = mergeClaudeSettings( - { agent: "custom-agent" }, + parse({ agent: "custom-agent" }), ARCHGATE_CLAUDE_SETTINGS ); @@ -36,12 +40,13 @@ describe("mergeClaudeSettings", () => { test("appends permissions.allow with dedup", () => { const result = mergeClaudeSettings( - { permissions: { allow: ["Bash(git *)", "Skill(archgate:architect)"] } }, + parse({ + permissions: { allow: ["Bash(git *)", "Skill(archgate:architect)"] }, + }), ARCHGATE_CLAUDE_SETTINGS ); - const permissions = result.permissions as Record; - expect(permissions.allow).toEqual([ + expect(result.permissions?.allow).toEqual([ "Bash(git *)", "Skill(archgate:architect)", "Skill(archgate:quality-manager)", @@ -51,18 +56,17 @@ describe("mergeClaudeSettings", () => { test("preserves existing deny permissions", () => { const result = mergeClaudeSettings( - { permissions: { allow: ["Bash(ls)"], deny: ["Bash(rm -rf *)"] } }, + parse({ permissions: { allow: ["Bash(ls)"], deny: ["Bash(rm -rf *)"] } }), ARCHGATE_CLAUDE_SETTINGS ); - const permissions = result.permissions as Record; - expect(permissions.deny).toEqual(["Bash(rm -rf *)"]); - expect(Array.isArray(permissions.allow)).toBe(true); + expect(result.permissions?.deny).toEqual(["Bash(rm -rf *)"]); + expect(Array.isArray(result.permissions?.allow)).toBe(true); }); test("preserves unknown top-level keys", () => { const result = mergeClaudeSettings( - { customSetting: "value", anotherKey: 42 }, + parse({ customSetting: "value", anotherKey: 42 }), ARCHGATE_CLAUDE_SETTINGS ); @@ -71,7 +75,18 @@ describe("mergeClaudeSettings", () => { }); test("handles missing permissions gracefully", () => { - const result = mergeClaudeSettings({}, ARCHGATE_CLAUDE_SETTINGS); + const result = mergeClaudeSettings(parse({}), ARCHGATE_CLAUDE_SETTINGS); + + expect(result.permissions?.allow).toEqual([ + ...ARCHGATE_CLAUDE_SETTINGS.permissions.allow, + ]); + }); + + test("handles non-object permissions gracefully via schema catch", () => { + const result = mergeClaudeSettings( + parse({ permissions: "invalid" }), + ARCHGATE_CLAUDE_SETTINGS + ); expect(result.permissions?.allow).toEqual([ ...ARCHGATE_CLAUDE_SETTINGS.permissions.allow, diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index e3519a10..35a1bb39 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -12,6 +12,7 @@ import { _resetAllCaches, } from "../../src/helpers/platform"; import { + VscodeSettingsSchema, mergeMarketplaceUrl, configureVscodeSettings, addMarketplaceToUserSettings, @@ -76,6 +77,17 @@ describe("mergeMarketplaceUrl", () => { ]); expect(result["editor.fontSize"]).toBe(14); }); + + test("handles non-array marketplaces gracefully via schema catch", () => { + const parsed = VscodeSettingsSchema.parse({ + "chat.plugins.marketplaces": "not-an-array", + "editor.fontSize": 14, + }); + const result = mergeMarketplaceUrl(parsed, URL); + // Invalid value is caught to [] — treated as explicitly set (no defaults seeded) + expect(result["chat.plugins.marketplaces"]).toEqual([URL]); + expect(result["editor.fontSize"]).toBe(14); + }); }); describe("configureVscodeSettings", () => { From a16d2f99e38c8fec28bad4323b28b55db99be9ba Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 7 Jul 2026 06:13:51 -0300 Subject: [PATCH 09/10] refactor: remove non-null assertions, type editor as literal union - git-files: use optional chaining instead of ! on adrFileGlobs - login-flow: type SignupEditor as literal union of editor values, eliminating the ! assertion and the ?? fallback - init: use SignupEditor type for SIGNUP_EDITORS map Zero non-null assertions (!) remain in src/. Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/commands/init.ts | 18 +++++++----------- src/commands/plugin/install.ts | 4 ++-- src/commands/plugin/url.ts | 4 ++-- src/engine/git-files.ts | 4 ++-- src/helpers/init-project.ts | 32 ++++++++++++++++++++++++++------ src/helpers/login-flow.ts | 31 ++++++++++++++++++++----------- 6 files changed, 59 insertions(+), 34 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 7c38c333..332dbff3 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -10,7 +10,12 @@ import { Option } from "@commander-js/extra-typings"; import { loadCredentials } from "../helpers/credential-store"; import { detectEditors, promptEditorSelection } from "../helpers/editor-detect"; import { exitWith, handleCommandError } from "../helpers/exit"; -import { EDITOR_LABELS, initProject } from "../helpers/init-project"; +import { + EDITOR_LABELS, + EDITOR_TARGETS, + SIGNUP_EDITORS, + initProject, +} from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; import { logError, logInfo, logWarn } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; @@ -42,19 +47,10 @@ const EDITOR_DIRS: Record = { opencode: "(user-scope)", }; -/** Map init editor flags to signup editor identifiers. */ -const SIGNUP_EDITORS: Record = { - claude: "claude-code", - cursor: "cursor", - vscode: "vscode", - copilot: "copilot-cli", - opencode: "opencode", -}; - const editorOption = new Option( "--editor ", "editor integration (omit to auto-detect and select)" -).choices(["claude", "cursor", "vscode", "copilot", "opencode"] as const); +).choices(EDITOR_TARGETS); export function registerInitCommand(program: Command) { program diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index c76021bf..e1e88151 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -11,7 +11,7 @@ import { promptEditorSelection, } from "../../helpers/editor-detect"; import { exitWith, handleCommandError } from "../../helpers/exit"; -import { EDITOR_LABELS } from "../../helpers/init-project"; +import { EDITOR_LABELS, EDITOR_TARGETS } from "../../helpers/init-project"; import type { EditorTarget } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; import { findProjectRoot } from "../../helpers/paths"; @@ -32,7 +32,7 @@ import { configureVscodeSettings } from "../../helpers/vscode-settings"; const editorOption = new Option( "--editor ", "target editor (omit to auto-detect and select)" -).choices(["claude", "cursor", "vscode", "copilot", "opencode"] as const); +).choices(EDITOR_TARGETS); /** * Install the archgate plugin for a single editor. diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts index d8fe54b6..f2fc3b96 100644 --- a/src/commands/plugin/url.ts +++ b/src/commands/plugin/url.ts @@ -8,7 +8,7 @@ import { promptSingleEditorSelection, } from "../../helpers/editor-detect"; import { handleCommandError } from "../../helpers/exit"; -import type { EditorTarget } from "../../helpers/init-project"; +import { EDITOR_TARGETS, type EditorTarget } from "../../helpers/init-project"; import { buildCursorMarketplaceUrl, buildMarketplaceUrl, @@ -18,7 +18,7 @@ import { const editorOption = new Option( "--editor ", "target editor (omit to auto-detect and select)" -).choices(["claude", "cursor", "vscode", "copilot", "opencode"] as const); +).choices(EDITOR_TARGETS); export function registerPluginUrlCommand(plugin: Command) { plugin diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index e4efb274..3dd56ebe 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -77,8 +77,8 @@ export async function resolveScopedFiles( fileWarnThreshold?: number; } ): Promise { - const hasExplicitFiles = (adrFileGlobs?.length ?? 0) > 0; - const patterns = hasExplicitFiles ? adrFileGlobs! : ["**/*"]; + const patterns = adrFileGlobs?.length ? adrFileGlobs : ["**/*"]; + const hasExplicitFiles = Boolean(adrFileGlobs?.length); const respectGitignore = options?.respectGitignore !== false; const label = options?.adrId ? `ADR ${options.adrId}` : "resolveScopedFiles"; const fileWarnThreshold = diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index fb479ed4..1c18e1a9 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -18,12 +18,15 @@ import { ensureBaseBranch } from "./project-config"; import { writeRulesShim } from "./rules-shim"; import { configureVscodeSettings } from "./vscode-settings"; -export type EditorTarget = - | "claude" - | "cursor" - | "vscode" - | "copilot" - | "opencode"; +export const EDITOR_TARGETS = [ + "claude", + "cursor", + "vscode", + "copilot", + "opencode", +] as const; + +export type EditorTarget = (typeof EDITOR_TARGETS)[number]; export const EDITOR_LABELS: Record = { claude: "Claude Code", @@ -33,6 +36,23 @@ export const EDITOR_LABELS: Record = { opencode: "opencode", }; +/** Values sent to the signup API — one per EditorTarget. */ +export type SignupEditor = + | "claude-code" + | "vscode" + | "copilot-cli" + | "cursor" + | "opencode"; + +/** Map editor targets to signup API identifiers. */ +export const SIGNUP_EDITORS: Record = { + claude: "claude-code", + cursor: "cursor", + vscode: "vscode", + copilot: "copilot-cli", + opencode: "opencode", +}; + interface InitOptions { editor?: EditorTarget; /** When true, attempt to install the archgate plugin using stored credentials. */ diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 9b20e2b1..e26aa1b2 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -14,16 +14,24 @@ import { claimArchgateToken, } from "./auth"; import { saveCredentials } from "./credential-store"; +import { + EDITOR_LABELS, + EDITOR_TARGETS, + SIGNUP_EDITORS, + type SignupEditor, +} from "./init-project"; import { logDebug, logError, logInfo } from "./log"; import { withPromptFix } from "./prompt"; import { SignupRequiredError, requestSignup } from "./signup"; +export type { SignupEditor } from "./init-project"; + export interface LoginFlowOptions { /** * Pre-selected editor for signup (skip the editor prompt). * When omitted, the user is prompted to choose. */ - editor?: string; + editor?: SignupEditor; } export interface LoginFlowResult { @@ -112,7 +120,7 @@ async function runSignupPrompt( githubUser: string, githubToken: string, githubEmail: string | null, - preselectedEditor?: string + preselectedEditor?: SignupEditor ): Promise { // Lazy-load inquirer — it costs ~200ms to parse and is only needed for // interactive signup prompts, not on every CLI startup. @@ -128,19 +136,20 @@ async function runSignupPrompt( }) ); - let editor = preselectedEditor; - if (!editor) { + let editor: SignupEditor = preselectedEditor ?? "claude-code"; + if (!preselectedEditor) { + // Build choices from the canonical EDITOR_LABELS + SIGNUP_EDITORS maps + // so adding a new editor in init-project.ts propagates here automatically. + const choices = EDITOR_TARGETS.map((key) => ({ + name: EDITOR_LABELS[key], + value: SIGNUP_EDITORS[key], + })); const ans = await withPromptFix(() => inquirer.prompt({ type: "select", name: "editor", message: "Which editor will you use with archgate?", - choices: [ - { name: "Claude Code", value: "claude-code" }, - { name: "VS Code", value: "vscode" }, - { name: "Copilot CLI", value: "copilot-cli" }, - { name: "Cursor", value: "cursor" }, - ], + choices, }) ); editor = ans.editor; @@ -172,7 +181,7 @@ async function runSignupPrompt( } logInfo("\nSubmitting signup request..."); - const result = await requestSignup(githubUser, email, useCase, editor!); + const result = await requestSignup(githubUser, email, useCase, editor); if (!result.ok) { logError("Signup request failed. Please try again with `archgate login`."); From 7562794a14e863b9aec4c60bc2cbc766cc9a09ad Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 7 Jul 2026 06:36:13 -0300 Subject: [PATCH 10/10] refactor: address remaining PR review feedback - Extract formatZodErrors into shared export from adr.ts, reuse in pack.ts instead of duplicating the path:message mapping - claude-settings: use nullish check on merged.agent instead of `in` operator, so invalid values caught to undefined get the default - session-context: expand ContentBlockSchema with catch-all for unknown Anthropic block types (thinking, image, etc.) so newer Claude sessions don't fail parsing. Known blocks (text, tool_use, tool_result) are still typed; unknown blocks are silently skipped in content preview Claude-Session: https://claude.ai/code/session_01P2kzr4bcBvg7AtaRhyHdMa Signed-off-by: Rhuan Barreto --- src/formats/adr.ts | 3 +- src/formats/pack.ts | 6 ++-- src/helpers/claude-settings.ts | 4 +-- src/helpers/session-context.ts | 53 +++++++++++++++++++++++----------- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/formats/adr.ts b/src/formats/adr.ts index da2717ce..56a42b49 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -63,7 +63,8 @@ export function parseFrontmatter(raw: string): Record { return result.success ? result.data : {}; } -function formatZodErrors(error: z.ZodError): string[] { +/** Format Zod validation errors as `path: message` strings. */ +export function formatZodErrors(error: z.ZodError): string[] { return error.issues.map((issue) => { const path = issue.path.join("."); return path ? `${path}: ${issue.message}` : issue.message; diff --git a/src/formats/pack.ts b/src/formats/pack.ts index 94f936b0..c5b9afe1 100644 --- a/src/formats/pack.ts +++ b/src/formats/pack.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { UserError } from "../helpers/user-error"; +import { formatZodErrors } from "./adr"; // ---------- Pack metadata (archgate-pack.yaml) ---------- @@ -35,10 +36,7 @@ export type PackMetadata = z.infer; export function parsePackMetadata(raw: string): PackMetadata { const result = PackMetadataSchema.safeParse(Bun.YAML.parse(raw)); if (!result.success) { - const errors = result.error.issues.map((i) => { - const path = i.path.join("."); - return path ? `${path}: ${i.message}` : i.message; - }); + const errors = formatZodErrors(result.error); throw new UserError(`Invalid pack metadata:\n - ${errors.join("\n - ")}`); } return result.data; diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts index d2c8bc62..185da991 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -61,8 +61,8 @@ export function mergeClaudeSettings( ): ClaudeSettings { const merged: ClaudeSettings = { ...existing }; - // Scalar: set only if absent - if (!("agent" in merged)) { + // Scalar: set only if absent or invalid (caught to undefined by schema) + if (!merged.agent) { merged.agent = archgate.agent; } diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 0d1aae72..cf27c404 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -52,12 +52,27 @@ export async function encodeProjectPath( const RELEVANT_TYPES = new Set(["user", "assistant"]); export const RELEVANT_ROLES = new Set(["user", "assistant"]); -const ContentBlockSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("text"), text: z.string() }), - z.object({ type: z.literal("tool_use"), name: z.string() }), - z.object({ type: z.literal("tool_result"), tool_use_id: z.string() }), +const TextBlockSchema = z.object({ type: z.literal("text"), text: z.string() }); +const ToolUseBlockSchema = z.object({ + type: z.literal("tool_use"), + name: z.string(), +}); +const ToolResultBlockSchema = z.object({ + type: z.literal("tool_result"), + tool_use_id: z.string(), +}); +// Catch-all for block types we don't inspect (thinking, image, etc.) +const UnknownBlockSchema = z.object({ type: z.string() }).passthrough(); + +const ContentBlockSchema = z.union([ + TextBlockSchema, + ToolUseBlockSchema, + ToolResultBlockSchema, + UnknownBlockSchema, ]); +type ContentBlock = z.infer; + export const MessageContentSchema = z.union([ z.string(), z.array(ContentBlockSchema), @@ -111,6 +126,21 @@ type CursorSessionResult = | { ok: true; data: CursorSessionSummary } | { ok: false; error: string; path?: string; available?: string[] }; +/** Extract a preview string from a single content block, or null for unknown types. */ +function parseContentBlock(block: ContentBlock): string | null { + const text = TextBlockSchema.safeParse(block); + if (text.success) { + const t = text.data.text; + return t.length > 300 ? t.slice(0, 300) + "..." : t; + } + const toolUse = ToolUseBlockSchema.safeParse(block); + if (toolUse.success) return `[tool_use: ${toolUse.data.name}]`; + const toolResult = ToolResultBlockSchema.safeParse(block); + if (toolResult.success) + return `[tool_result: ${toolResult.data.tool_use_id.slice(0, 20)}]`; + return null; +} + /** Extract a concise content preview from a transcript entry. */ export function getContentPreview(entry: TranscriptEntry): string { const content = entry.message?.content; @@ -120,19 +150,8 @@ export function getContentPreview(entry: TranscriptEntry): string { if (Array.isArray(content)) { const parts: string[] = []; for (const block of content) { - switch (block.type) { - case "text": { - const text = block.text; - parts.push(text.length > 300 ? text.slice(0, 300) + "..." : text); - break; - } - case "tool_use": - parts.push(`[tool_use: ${block.name}]`); - break; - case "tool_result": - parts.push(`[tool_result: ${block.tool_use_id.slice(0, 20)}]`); - break; - } + const parsed = parseContentBlock(block); + if (parsed) parts.push(parsed); } return parts.join(" | "); }