Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
448c77b
feat(engine): add AST-aware rule context ctx.ast() per ARCH-022
rhuanbarreto Jul 5, 2026
587e2df
feat(adr): enforce ARCH-022 with companion rules, dogfooding ctx.ast()
rhuanbarreto Jul 5, 2026
9b65e6b
test(engine): cover ctx.ast support modules and runner integration
rhuanbarreto Jul 5, 2026
2c8a7d5
refactor(adr): rewrite ARCH-004/ARCH-008 rules with ctx.ast()
rhuanbarreto Jul 5, 2026
f1e210e
docs: document ctx.ast() with per-language examples (en, nb, pt-br)
rhuanbarreto Jul 5, 2026
a5998a7
fix(engine): strip UTF-8 BOM before parsing in Python/Ruby serializers
rhuanbarreto Jul 5, 2026
f6a95da
fix(engine): harden ctx.ast from adversarial review
rhuanbarreto Jul 5, 2026
33a8752
test(engine): cover ctx.ast gaps found in review
rhuanbarreto Jul 5, 2026
d03ca38
docs: note transpiled-loc caveat for typescript ctx.ast (en, nb, pt-br)
rhuanbarreto Jul 5, 2026
b4c29b8
feat(adr): capture ctx.ast hardening invariants in ARCH-022
rhuanbarreto Jul 5, 2026
8c59cd8
chore(memory): record interpreter-subprocess parsing gotchas
rhuanbarreto Jul 5, 2026
7514ccd
feat(engine): type ctx.ast() return per language via overloads
rhuanbarreto Jul 5, 2026
3b1dc38
refactor: remove redundant type assertions now that ctx.ast() overloa…
rhuanbarreto Jul 5, 2026
bb98172
chore: drop explanatory comments about type changes
rhuanbarreto Jul 5, 2026
1ede26b
fix: address PR review findings
rhuanbarreto Jul 5, 2026
5e2dd8a
fix(rules): handle multi-line type-only re-exports in barrel detection
rhuanbarreto Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 70 additions & 29 deletions .archgate/adrs/ARCH-004-no-barrel-files.rules.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,68 @@
/// <reference path="../rules.d.ts" />

/**
* Determines whether a file is a barrel (re-export-only index.ts).
* A Program body is barrel-shaped when every top-level statement is purely
* import/re-export plumbing:
* - ImportDeclaration — `import { x } from "./y"` / `import "./y"`
* - ExportAllDeclaration — `export * from "./y"`
* - ExportNamedDeclaration with — `export { x } from "./y"` / `export { x }`
* declaration === null
*
* A barrel file contains only export/import statements and no executable
* logic — no function definitions, class definitions, variable declarations,
* or statements beyond re-exports.
* Anything else — `export const x = ...`, `export default ...`, function or
* class declarations, expression statements — is executable logic, so the
* file is not a barrel.
*/
function isBarrelFile(content: string): boolean {
const lines = content
function isReExportOnlyBody(body: EsTreeNode[]): boolean {
return body.every((node) => {
if (node.type === "ImportDeclaration") return true;
if (node.type === "ExportAllDeclaration") return true;
return (
node.type === "ExportNamedDeclaration" &&
(node.declaration === null || node.declaration === undefined)
);
});
}

/**
* Fallback for files whose transpiled Program body is EMPTY: ctx.ast()
* transpiles TypeScript before parsing, which erases type-only syntax
* (`export type { X } from "./y"`, `import type ...`), so a pure
* type-re-export barrel parses to an empty Program. Conservatively inspect
* the source: strip comments and blank space, then require every remaining
* statement to start with `import` or `export`. A comment-only/empty file
* is not a barrel.
*
* Handles multi-line statements (e.g. `export type {\n A,\n} from ...`)
* by tracking brace depth — continuation lines inside `{ }` are part of
* the enclosing import/export, not new statements.
*/
function isTypeOnlyBarrel(source: string): boolean {
const stripped = source
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.replaceAll(/\/\/[^\n]*/gu, "")
.trim();

if (stripped === "") return false;

const lines = stripped
.split("\n")
.map((l) => l.trim())
.filter(
(l) =>
l !== "" &&
!l.startsWith("//") &&
!l.startsWith("/*") &&
!l.startsWith("*")
);
.filter((l) => l !== "" && l !== ";");

if (lines.length === 0) return false;

return lines.every(
(line) =>
// export { Foo } from "./bar" / export type { Foo } from "./bar"
line.startsWith("export ") ||
line.startsWith("export{") ||
// import type { Foo } from "./bar"
line.startsWith("import ") ||
// Continuation of multi-line export blocks
line.startsWith("} from") ||
line.startsWith("type ") ||
/^[A-Za-z_$,\s]+$/u.test(line) ||
line === "}" ||
line === "};"
);
let braceDepth = 0;
for (const line of lines) {
if (braceDepth === 0 && !/^(?:import|export)\b/u.test(line)) {
return false;
}
for (const ch of line) {
if (ch === "{") braceDepth++;
else if (ch === "}") braceDepth--;
}
}

return true;
}

export default {
Expand All @@ -48,8 +76,21 @@ export default {
);

const checks = indexFiles.map(async (file) => {
const content = await ctx.readFile(file);
if (isBarrelFile(content)) {
let program: EsTreeProgram;
try {
program = await ctx.ast(file, "typescript");
} catch {
// A syntactically broken index.ts must not kill this rule for
// every other file — typecheck/lint report real syntax errors.
return;
}

const barrel =
program.body.length > 0
? isReExportOnlyBody(program.body)
: isTypeOnlyBarrel(await ctx.readFile(file));

if (barrel) {
ctx.report.violation({
message: `Barrel file detected: ${file} contains only re-exports and no logic. Import directly from source modules instead.`,
file,
Expand Down
206 changes: 165 additions & 41 deletions .archgate/adrs/ARCH-008-typed-command-options.rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,93 @@
/// <reference path="../rules.d.ts" />

/**
* ARCH-008 enforcement, rewritten on top of ctx.ast() (ARCH-022).
*
* The previous implementation grepped single lines for `.option(...)` and
* missed any call formatted across multiple lines — exactly the fragility
* ARCH-022 was introduced to fix. These rules now walk the ESTree produced
* by ctx.ast(file, "typescript") and inspect real CallExpression arguments,
* so formatting, whitespace, and string escaping no longer matter.
*/

/**
* Description strings that enumerate a fixed set of values, e.g.
* "editor integration to configure (claude, cursor, vscode, copilot)" or
* "ADR domain: backend, frontend, data, architecture, general".
* Same heuristic as the previous regex rule, but applied to the parsed
* Literal VALUE rather than raw source text.
*/
const CHOICE_ENUMERATION = /(?:claude.*cursor|backend.*frontend)/u;

/** A flag literal like "--editor <editor>" — a value-taking long option. */
const VALUE_TAKING_FLAG = /^--[\w-]+\s+<\w+>/u;

/** Bare global parsers that must not be passed as .option()'s third arg. */
const PARSER_IDENTIFIERS = new Set(["parseInt", "parseFloat", "Number"]);

/** Depth-first walk over an ESTree-shaped tree. */
function walk(node: unknown, visit: (n: EsTreeNode) => void): void {
if (Array.isArray(node)) {
for (const item of node) walk(item, visit);
return;
}
if (!node || typeof node !== "object") return;
const n = node as EsTreeNode;
if (typeof n.type === "string") visit(n);
for (const value of Object.values(n)) {
if (value && typeof value === "object") walk(value, visit);
}
}

/** Collect the argument lists of every `<expr>.option(...)` call in a tree. */
function findOptionCallArgs(tree: EsTreeProgram): EsTreeNode[][] {
const calls: EsTreeNode[][] = [];
walk(tree, (n) => {
if (n.type !== "CallExpression") return;
const callee = n.callee as EsTreeNode | undefined;
if (callee?.type !== "MemberExpression" || callee.computed === true) return;
const property = callee.property as EsTreeNode | undefined;
if (property?.type !== "Identifier" || property.name !== "option") return;
calls.push((n.arguments as EsTreeNode[] | undefined) ?? []);
});
return calls;
}

function isStringLiteral(
node: EsTreeNode | undefined
): node is EsTreeNode & { value: string } {
return node?.type === "Literal" && typeof node.value === "string";
}

/**
* Locate the 1-based line of an option's flag string in the ORIGINAL source.
*
* ctx.ast(file, "typescript") parses Bun-transpiled output, which reprints
* the module and collapses multi-line calls onto single lines — node.loc
* therefore refers to transpiled lines and is unusable for reporting.
* Searching the untranspiled source for the quoted flag literal gives an
* exact line instead; when the flag can't be found (e.g. built dynamically),
* the violation is reported file-only rather than with a wrong line.
*/
function findFlagLine(source: string, flag: string): number | undefined {
const needles = [`"${flag}"`, `'${flag}'`, `\`${flag}\``];
const lines = source.split("\n");
for (const [index, lineText] of lines.entries()) {
if (needles.some((needle) => lineText.includes(needle))) return index + 1;
}
return undefined;
}

function reportWithLine(
ctx: RuleContext,
detail: { message: string; file: string; fix: string },
source: string,
flag: string | undefined
): void {
const line = flag === undefined ? undefined : findFlagLine(source, flag);
ctx.report.violation({ ...detail, ...(line === undefined ? {} : { line }) });
}

export default {
rules: {
"use-add-option-for-choices": {
Expand All @@ -8,28 +96,42 @@ export default {
severity: "error",
async check(ctx) {
const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
// Detect .option() calls whose description enumerates known choice values
// e.g. "editor integration to configure (claude, cursor, vscode, copilot)"
// or "ADR domain: backend, frontend, data, architecture, general"
const matches = await Promise.all(
files.map((file) =>
ctx.grep(
file,
/\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*(?:claude.*cursor|backend.*frontend)[^"']*["']/u
)
)
await Promise.all(
files.map(async (file) => {
let tree: EsTreeProgram;
try {
tree = await ctx.ast(file, "typescript");
} catch {
return;
}
const flagged: string[] = [];
for (const args of findOptionCallArgs(tree)) {
if (args.length < 2) continue;
const [flag, description] = args;
if (!isStringLiteral(flag) || !isStringLiteral(description)) {
continue;
}
if (!VALUE_TAKING_FLAG.test(flag.value)) continue;
if (!CHOICE_ENUMERATION.test(description.value)) continue;
flagged.push(flag.value);
}
if (flagged.length === 0) return;
const source = await ctx.readFile(file);
for (const flag of flagged) {
reportWithLine(
ctx,
{
message:
"Use new Option().choices() with .addOption() instead of .option() for fixed-choice options",
file,
fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()",
},
source,
flag
);
}
})
Comment thread
cursor[bot] marked this conversation as resolved.
);
for (const fileMatches of matches) {
for (const m of fileMatches) {
ctx.report.violation({
message:
"Use new Option().choices() with .addOption() instead of .option() for fixed-choice options",
file: m.file,
line: m.line,
fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()",
});
}
}
},
},
"use-add-option-for-arg-parser": {
Expand All @@ -38,27 +140,49 @@ export default {
severity: "error",
async check(ctx) {
const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
// Detect .option() calls that pass a function as the third argument
// e.g. .option("--max-entries <n>", "...", parseInt)
const matches = await Promise.all(
files.map((file) =>
ctx.grep(
file,
/\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*["'],\s*(?:parseInt|parseFloat|\()/u
)
)
await Promise.all(
files.map(async (file) => {
let tree: EsTreeProgram;
try {
tree = await ctx.ast(file, "typescript");
} catch {
return;
}
// Flag .option(flag, description, parser) calls whose third
// argument is a function expression or a bare global parser
// identifier (parseInt/parseFloat/Number). Literal defaults
// (strings, numbers, booleans) remain allowed.
const flagged: (string | undefined)[] = [];
for (const args of findOptionCallArgs(tree)) {
if (args.length < 3) continue;
const third = args[2];
const isFunctionArg =
third?.type === "ArrowFunctionExpression" ||
third?.type === "FunctionExpression";
const isParserIdentifier =
third?.type === "Identifier" &&
PARSER_IDENTIFIERS.has(String(third.name ?? ""));
if (!isFunctionArg && !isParserIdentifier) continue;
const flag = args[0];
flagged.push(isStringLiteral(flag) ? flag.value : undefined);
}
if (flagged.length === 0) return;
const source = await ctx.readFile(file);
for (const flag of flagged) {
reportWithLine(
ctx,
{
message:
"Use new Option().argParser() with .addOption() instead of passing a parser to .option()",
file,
fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()",
},
source,
flag
);
}
})
);
for (const fileMatches of matches) {
for (const m of fileMatches) {
ctx.report.violation({
message:
"Use new Option().argParser() with .addOption() instead of passing a parser to .option()",
file: m.file,
line: m.line,
fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()",
});
}
}
},
},
},
Expand Down
Loading
Loading