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 66467bf2..f1460c7b 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"; @@ -24,7 +28,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 +117,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)) { @@ -169,14 +173,8 @@ 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<[], {}, {}>`. */ -function getFullCommandName( - command: { name(): string; parent: unknown } | null -): string { +function getFullCommandName(command: CommandUnknownOpts | null): string { const parts: string[] = []; let current = command; while (current) { @@ -184,7 +182,7 @@ function getFullCommandName( 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/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/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/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/engine/reporter.ts b/src/engine/reporter.ts index 0a657981..22fbcbaf 100644 --- a/src/engine/reporter.ts +++ b/src/engine/reporter.ts @@ -178,10 +178,10 @@ export function reportConsole( const loc = v.file ? (v.line ? `${v.file}:${v.line}` : v.file) : ""; 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" @@ -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) { @@ -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 -): number { +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; } diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 051db779..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,17 +24,51 @@ 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; } +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(); + +/** 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"; /** @@ -100,8 +136,11 @@ 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 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). pushViolation( @@ -112,9 +151,10 @@ 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 = node.object; + const prop = node.property; + if (!obj || !prop) break; + const computed = node.computed ?? false; // Block Bun.spawn, Bun.write, Bun.$, Bun.file, Bun.spawnSync if ( @@ -138,17 +178,17 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "CallExpression": { - const callee = node.callee as AstNode & { name?: string }; - if (callee.name === "eval") { + const name = node.callee?.name; + 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 +197,7 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "NewExpression": { - const callee = node.callee as AstNode & { name?: string }; - if (callee.name === "Function") { + if (node.callee?.name === "Function") { pushViolation( "new Function() is blocked in rule files.", "new Function(" @@ -167,8 +206,7 @@ export function scanRuleSource(source: string): ScanViolation[] { break; } case "ImportExpression": { - const src = node.source as AstNode; - if (src.type !== "Literal") { + if (node.source && node.source.type !== "Literal") { pushViolation( "Dynamic import() with non-literal argument is blocked in rule files.", "import(" @@ -177,11 +215,8 @@ 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") { + const left = node.left; + if (left && left.type === "MemberExpression") { if (left.object?.name === "globalThis") { pushViolation( "Mutating globalThis is blocked in rule files.", @@ -207,21 +242,18 @@ 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 = parseNode(item); + if (child) walk(child); } - } else if ( - value && - typeof value === "object" && - (value as AstNode).type - ) { - walk(value as AstNode); + } else { + const child = parseNode(value); + if (child) walk(child); } } } - walk(ast as unknown as AstNode); + const root = parseNode(ast); + if (root) walk(root); return remapViolations(source, rawViolations); } @@ -291,9 +323,10 @@ 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 = node.object; + const prop = node.property; + if (!obj || !prop) break; + const computed = node.computed ?? false; // Block Bun.env if (obj.name === "Bun" && !computed && prop.name === "env") { @@ -314,21 +347,21 @@ 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 name = node.callee?.name; + 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 name = node.callee?.name; + 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 +371,18 @@ 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 = parseNode(item); + if (child) walkImported(child); } - } else if ( - value && - typeof value === "object" && - (value as AstNode).type - ) { - walkImported(value as AstNode); + } else { + const child = parseNode(value); + if (child) walkImported(child); } } } - walkImported(ast as unknown as AstNode); + const importedRoot = parseNode(ast); + if (importedRoot) walkImported(importedRoot); // Combine: standard scan + imported-only scan const standardViolations = scanRuleSource(source); diff --git a/src/engine/runner.ts b/src/engine/runner.ts index fe35d366..040a2caa 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -5,9 +5,10 @@ import { relative, resolve, isAbsolute } from "node:path"; import type { AstLanguage, - AstNode, EsTreeProgram, GrepMatch, + PythonAstModule, + RubyAstNode, RuleContext, RuleReport, ViolationDetail, @@ -162,15 +163,19 @@ 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). + // 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. - const astImpl = (async ( + async function astImpl( path: string, - language: AstLanguage - ): 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); @@ -203,7 +208,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 +216,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 +255,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..56a42b49 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -56,11 +56,15 @@ export interface AdrDocument { /** * Parse YAML frontmatter from a raw string (the content between --- delimiters). */ +const PlainObjectSchema = z.record(z.string(), z.unknown()); + export function parseFrontmatter(raw: string): Record { - return (Bun.YAML.parse(raw) as Record) ?? {}; + const result = PlainObjectSchema.safeParse(Bun.YAML.parse(raw)); + 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 53a10379..c5b9afe1 100644 --- a/src/formats/pack.ts +++ b/src/formats/pack.ts @@ -2,6 +2,9 @@ // Copyright 2026 Archgate import { z } from "zod"; +import { UserError } from "../helpers/user-error"; +import { formatZodErrors } from "./adr"; + // ---------- Pack metadata (archgate-pack.yaml) ---------- export const PackMetadataSchema = z.object({ @@ -31,8 +34,12 @@ export const PackMetadataSchema = z.object({ export type PackMetadata = z.infer; export function parsePackMetadata(raw: string): PackMetadata { - const parsed = Bun.YAML.parse(raw) as Record; - return PackMetadataSchema.parse(parsed); + const result = PackMetadataSchema.safeParse(Bun.YAML.parse(raw)); + if (!result.success) { + const errors = formatZodErrors(result.error); + throw new UserError(`Invalid pack metadata:\n - ${errors.join("\n - ")}`); + } + return result.data; } // ---------- Community links (community/links.yaml) ---------- diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts index 858b57d7..5c42c2bd 100644 --- a/src/helpers/adr-writer.ts +++ b/src/helpers/adr-writer.ts @@ -92,7 +92,8 @@ export async function createAdrFile( } ): Promise { const prefix = - opts.prefix ?? DOMAIN_PREFIXES[opts.domain as keyof typeof DOMAIN_PREFIXES]; + 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\`.` diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index 7006ed01..9460d002 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().default(null), +}); + +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,15 +180,12 @@ 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"); } logDebug("GitHub user:", data.login); - return { login: data.login, email: data.email ?? null }; + return { login: data.login, email: data.email }; } // --------------------------------------------------------------------------- @@ -213,9 +210,10 @@ export async function claimArchgateToken(githubToken: string): Promise { }); if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { - error?: string; - }; + const errorResult = ErrorResponseSchema.safeParse( + await response.json().catch(() => ({})) + ); + const body = errorResult.success ? errorResult.data : {}; if (isSignupRequiredError(body.error)) { throw new SignupRequiredError(); @@ -226,7 +224,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..63e17440 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,9 +92,13 @@ export async function fetchLatestGitHubVersion( return null; } - const data = (await response.json()) as GitHubRelease; - 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; } // --------------------------------------------------------------------------- @@ -172,7 +176,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..185da991 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -3,6 +3,29 @@ import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; + +const ClaudePermissionsSchema = z + .object({ + allow: z.array(z.string()).default([]).catch([]), + deny: z.array(z.string()).default([]).catch([]), + }) + .passthrough(); + +/** @internal Exported for testing only. */ +export const ClaudeSettingsSchema = z + .object({ + // oxlint-disable-next-line no-useless-undefined -- Zod .catch() requires explicit default for optional fields + agent: z.string().optional().catch(undefined), + permissions: ClaudePermissionsSchema.optional().catch({ + allow: [], + deny: [], + }), + }) + .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 +41,6 @@ export const ARCHGATE_CLAUDE_SETTINGS = { }, } as const; -type ClaudeSettings = Record; - /** * Deduplicate an array of strings while preserving order. */ @@ -40,26 +61,17 @@ 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; } // 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 ?? { allow: [], deny: [] }; merged.permissions = { ...existingPermissions, - allow: dedup([...existingAllow, ...archgate.permissions.allow]), + allow: dedup([...existingPermissions.allow, ...archgate.permissions.allow]), }; return merged; @@ -83,7 +95,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/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`."); diff --git a/src/helpers/opencode-settings.ts b/src/helpers/opencode-settings.ts index ba6e1478..fd976c79 100644 --- a/src/helpers/opencode-settings.ts +++ b/src/helpers/opencode-settings.ts @@ -16,13 +16,20 @@ 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 + // 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; /** * Pure, additive merge of archgate settings into existing opencode config. @@ -67,7 +74,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..050e6e26 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,8 +46,7 @@ export function loadProjectConfig(projectRoot: string): ProjectConfig { try { const text = readFileSync(path, "utf-8"); - const raw = JSON.parse(text) as unknown; - 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; @@ -145,7 +144,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 +156,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 +193,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..e039f469 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -3,16 +3,30 @@ 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"; import { + MessageContentSchema, type ReadSessionOptions, type SessionListResult, type TranscriptEntry, getContentPreview, } from "./session-context"; +const WorkspaceMetaSchema = z.object({ + cwd: z.string().nullable().default(null), +}); + +const CopilotEventSchema = z.object({ + type: z.string().default(""), + data: z.record(z.string(), z.unknown()).optional(), +}); + +const CopilotEventsSchema = z.array(CopilotEventSchema); + interface CopilotSessionSummary { sessionId: string; sessionFile: string; @@ -104,8 +118,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; if (cwd && normalizePath(cwd) === normalizedProjectRoot) { matching.push({ name: dir.name, mtime: dir.mtime }); } @@ -177,10 +192,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, @@ -192,12 +207,16 @@ 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: (event.data as Record)?.content }, + 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 7fe25e0c..cf27c404 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,44 @@ 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; -} +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), +]); + +export const TranscriptEntrySchema = z.object({ + type: z.string().default(""), + role: z.string().default(""), + message: z + .object({ + role: z.string().optional(), + content: MessageContentSchema.optional(), + }) + .optional(), +}); + +export type TranscriptEntry = z.infer; interface ClaudeSessionSummary { sessionFile: string; @@ -92,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; @@ -101,18 +150,8 @@ 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; - const b = block as Record; - if (b.type === "text" && typeof b.text === "string") { - const text = b.text as string; - 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") { - parts.push( - `[tool_result: ${String(b.tool_use_id ?? "").slice(0, 20)}]` - ); - } + const parsed = parseContentBlock(block); + if (parsed) parts.push(parsed); } return parts.join(" | "); } @@ -221,7 +260,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, @@ -232,9 +271,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), }); @@ -368,7 +407,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, @@ -379,9 +418,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 ca23acc8..8bd2dc25 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,13 @@ export async function requestSignup( return { ok: false, token: null }; } - const data = (await response.json().catch(() => ({}))) as { token?: string }; + const SignupResponseSchema = z.object({ + token: z.string().nullable().default(null), + }); + const result = SignupResponseSchema.safeParse( + await response.json().catch(() => ({})) + ); + 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/stack-detect.ts b/src/helpers/stack-detect.ts index dfb0e8bd..62cd3ff5 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,9 +226,10 @@ export async function detectStackUncached( if (hasPkgJson) { try { - const raw = await Bun.file(pkgJsonPath).json(); - const result = PackageJsonSchema.safeParse(raw); - pkgJson = result.success ? result.data : null; + const result = PackageJsonSchema.safeParse( + await Bun.file(pkgJsonPath).json() + ); + if (result.success) pkgJson = result.data; } 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..1e767b7e 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,14 @@ import { isWindows, } from "./platform"; -type VscodeUserSettings = Record; +/** @internal Exported for testing only. */ +export const VscodeSettingsSchema = z + .object({ + "chat.plugins.marketplaces": z.array(z.string()).optional().catch([]), + }) + .passthrough(); + +type VscodeUserSettings = z.infer; /** * VS Code's built-in default marketplaces for `chat.plugins.marketplaces`. @@ -46,11 +55,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. @@ -148,8 +153,13 @@ export async function addMarketplaceToUserSettings( let existing: VscodeUserSettings = {}; if (existsSync(settingsPath)) { - const content = await Bun.file(settingsPath).text(); - existing = Bun.JSONC.parse(content) as VscodeUserSettings; + 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); diff --git a/tests/helpers/claude-settings.test.ts b/tests/helpers/claude-settings.test.ts index 7288558f..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 ); @@ -70,16 +74,23 @@ describe("mergeClaudeSettings", () => { expect(result.anotherKey).toBe(42); }); - test("handles non-object permissions gracefully", () => { + test("handles missing permissions gracefully", () => { + 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( - { permissions: "invalid" }, + parse({ permissions: "invalid" }), 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..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, @@ -67,11 +68,23 @@ describe("mergeMarketplaceUrl", () => { ]); }); - test("handles non-array marketplaces gracefully", () => { - const result = mergeMarketplaceUrl( - { "chat.plugins.marketplaces": "not-an-array", "editor.fontSize": 14 }, - 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); + }); + + 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); });