Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions schema/debtlens.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@
"type": "string"
}
}
},
"propDrilling": {
"type": "object",
"description": "Prop-drilling rule configuration.",
"properties": {
"ignoreComponents": {
"type": "array",
"items": {
"type": "string"
},
"description": "Additional UI primitive component names to ignore (extends built-in host components)."
}
},
"additionalProperties": false
}
}
}
3 changes: 3 additions & 0 deletions src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ export const defaultConfig: Required<DebtLensConfig> = {
},
maxFiles: 2000,
vocabulary: {},
propDrilling: {
ignoreComponents: [],
},
};
4 changes: 4 additions & 0 deletions src/config/mergeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio
},
maxFiles: cliOptions.maxFiles ?? fileConfig.maxFiles ?? defaultConfig.maxFiles,
vocabulary: { ...defaultConfig.vocabulary, ...(fileConfig.vocabulary ?? {}) },
propDrillingIgnoreComponents: [
...(defaultConfig.propDrilling?.ignoreComponents ?? []),
...(fileConfig.propDrilling?.ignoreComponents ?? []),
],
changedFiles: cliOptions.changedFiles,
};
}
12 changes: 12 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ export function buildConfigSchema(): Record<string, unknown> {
items: { type: "string" },
},
},
propDrilling: {
type: "object",
description: "Prop-drilling rule configuration.",
properties: {
ignoreComponents: {
type: "array",
items: { type: "string" },
description: "Additional UI primitive component names to ignore (extends built-in host components).",
},
},
additionalProperties: false,
},
},
};
}
6 changes: 6 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface DebtLensConfig {
maxFiles?: number;
/** Concept id -> competing term variants, used by the naming-drift rule. */
vocabulary?: Record<string, string[]>;
/** Prop-drilling rule configuration. */
propDrilling?: {
ignoreComponents?: string[];
};
}

export interface ScanOptions {
Expand All @@ -58,6 +62,8 @@ export interface ScanOptions {
vocabulary?: Record<string, string[]>;
/** When set, only scan files whose absolute path is in this list (--changed mode). */
changedFiles?: string[];
/** Prop-drilling rule configuration. */
propDrillingIgnoreComponents?: string[];
}

export interface CliOptions {
Expand Down
3 changes: 2 additions & 1 deletion src/detectors/propDrilling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const propDrillingDetector: Detector = {
detect(context: DetectorContext): DebtIssue[] {
const issues: DebtIssue[] = [];
const maxForwardedProps = context.getThreshold("prop-drilling.maxForwardedProps", 4);
const customIgnoreComponents = context.options.propDrillingIgnoreComponents;

for (const file of context.files) {
for (const fn of collectFunctionLikes(file)) {
Expand All @@ -33,7 +34,7 @@ export const propDrillingDetector: Detector = {
const tagName = jsx.getTagNameNode().getText();
// Skip host elements (lowercase DOM tags) and UI primitives (RN/icon
// components are PascalCase but aren't user-defined children).
if (/^[a-z]/.test(tagName) || isHostComponent(tagName)) continue;
if (/^[a-z]/.test(tagName) || isHostComponent(tagName, customIgnoreComponents)) continue;
const childForwarded = new Set<string>();
for (const attribute of jsx.getAttributes()) {
if (!Node.isJsxAttribute(attribute)) continue;
Expand Down
10 changes: 8 additions & 2 deletions src/utils/hostComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@ export const HOST_COMPONENTS = new Set<string>([
/**
* Returns true if a JSX tag name refers to a host primitive (so it should be ignored
* as a "child component"). Handles namespaced tags like `Animated.View`.
*
* @param tagName - The JSX tag name to check
* @param customIgnoreComponents - Optional array of additional component names to treat as host primitives
*/
export function isHostComponent(tagName: string): boolean {
export function isHostComponent(tagName: string, customIgnoreComponents?: string[]): boolean {
const base = tagName.split(".").pop() ?? tagName;
return HOST_COMPONENTS.has(base) || HOST_COMPONENTS.has(tagName);
const allComponents = customIgnoreComponents
? new Set([...HOST_COMPONENTS, ...customIgnoreComponents])
: HOST_COMPONENTS;
return allComponents.has(base) || allComponents.has(tagName);
}
30 changes: 30 additions & 0 deletions tests/detectors/propDrilling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,34 @@ export function Parent({ a, b, c, d, e }: Props) {
const issues = await runDetector(propDrillingDetector, { "Parent.tsx": src });
assert.equal(issues.length, 1);
});

it("does NOT flag a component forwarding to a custom ignored component", async () => {
const src = `
export function Parent({ a, b, c, d, e }: Props) {
return <CustomButton a={a} b={b} c={c} d={d} e={e} />;
}
`;
const issues = await runDetector(propDrillingDetector, { "Parent.tsx": src }, {
propDrillingIgnoreComponents: ["CustomButton"],
});
assert.equal(issues.length, 0);
});

it("flags forwarding to a user-defined child even when other custom components are ignored", async () => {
const src = `
export function Parent({ a, b, c, d, e, f }: Props) {
return (
<>
<CustomButton a={a} b={b} />
<Child c={c} d={d} e={e} f={f} />
</>
);
}
`;
const issues = await runDetector(propDrillingDetector, { "Parent.tsx": src }, {
propDrillingIgnoreComponents: ["CustomButton"],
});
assert.equal(issues.length, 1);
assert.match(issues[0]?.message ?? "", /forwards 4 props/);
});
});
3 changes: 3 additions & 0 deletions tests/helpers/runDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface RunDetectorOptions {
minSeverity?: ScanOptions["minSeverity"];
/** Naming-drift vocabulary override. */
vocabulary?: Record<string, string[]>;
/** Prop-drilling custom ignore components. */
propDrillingIgnoreComponents?: string[];
}

/**
Expand Down Expand Up @@ -60,6 +62,7 @@ export async function runDetector(
rules: undefined,
maxFiles: undefined,
vocabulary: options.vocabulary,
propDrillingIgnoreComponents: options.propDrillingIgnoreComponents,
};

const issues = await detector.detect({
Expand Down
Loading