diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d088b5f..03b7712 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,9 +125,10 @@ diffguardian/ │ │ ├── index.ts # Rule barrel file │ │ ├── R01_param_removed.ts │ │ ├── R02_param_reordered.ts -│ │ ├── ... # 28 rules total +│ │ ├── ... # 29 rules total │ │ ├── R29_type_alias_union_narrowed.ts -│ │ └── R30_generator_toggle.ts +│ │ ├── R30_generator_toggle.ts +│ │ └── R31_abstract_modifier_added.ts │ │ │ ├── reporter/ │ │ ├── types.ts # Reporter interface and config types @@ -238,7 +239,7 @@ export const yourRule: FunctionRule = { Before submitting a new rule, ensure the following: -1. Assign the next available rule ID (`R31`, `R32`, etc.) +1. Assign the next available rule ID (`R32`, `R33`, etc.) 2. Create the rule file in `src/classifier/rules/` 3. Export it from `src/classifier/rules/index.ts` 4. Write tests covering both positive and negative cases diff --git a/README.md b/README.md index 3f483e0..e96591a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The result? Silent regressions, broken CI/CD pipelines, and painful merge resolu ### The Solution Traditional diffs show **what changed**. Diff Guardian shows **what breaks**. -Diff Guardian acts as an automated safety net. Using WASM-compiled Tree-Sitter grammars, it parses your code into abstract syntax tree (AST) signatures, compares the before and after states across any branch, and evaluates every diff against **28 strict production rules**. It then traces every call site across your ecosystem to show **exactly who is affected** before you merge or push. +Diff Guardian acts as an automated safety net. Using WASM-compiled Tree-Sitter grammars, it parses your code into abstract syntax tree (AST) signatures, compares the before and after states across any branch, and evaluates every diff against **29 strict production rules**. It then traces every call site across your ecosystem to show **exactly who is affected** before you merge or push. ``` $ npx dg compare main feature-branch @@ -61,7 +61,7 @@ $ npx dg compare main feature-branch | Capability | Description | |---|---| | **AST-Powered Analysis** | Tree-Sitter WASM grammars parse your code into structural signatures — not regex | -| **28 Classification Rules** | Covers parameter changes, return types, generics, visibility, enums, interfaces, type aliases, and more | +| **29 Classification Rules** | Covers parameter changes, return types, generics, visibility, enums, interfaces, type aliases, and more | | **Blast Radius Tracing** | JIT import scanner and call-site tracer shows every consumer affected by a breaking change | | **CI/CD Native** | Auto-detects GitHub Actions and posts PR comments with full audit reports | | **Git Hook Enforcement** | Built-in Husky hooks block broken code at `pre-push`, `pre-merge-commit`, and `post-merge` | @@ -185,7 +185,7 @@ Shows every file that imports the given symbol and where it is used: npx dg rules ``` -Prints all 28 classification rules with their IDs, names, targets, and descriptions. +Prints all 29 classification rules with their IDs, names, targets, and descriptions. > For detailed examples and remediation guidance, see the [full rules documentation](https://diffguardian.vercel.app/docs/rules/all). diff --git a/client/src/components/Terminal.tsx b/client/src/components/Terminal.tsx index 4f0e317..d37aa5e 100644 --- a/client/src/components/Terminal.tsx +++ b/client/src/components/Terminal.tsx @@ -102,7 +102,7 @@ const COMMANDS: Record = { { text: " R25 - Interface Prop Required [Target: interface]", type: "trace" }, { text: " R26 - Interface Prop Removed [Target: interface]", type: "trace" }, { text: " R27 - Enum Member Changed [Target: enum]", type: "trace" }, - { text: " ... and 19 more rules", type: "info" }, + { text: " ... and 20 more rules", type: "info" }, ], "npx dg init": [ diff --git a/client/src/components/docs/DocsSidebar.tsx b/client/src/components/docs/DocsSidebar.tsx index 6d6c6ce..2880f81 100644 --- a/client/src/components/docs/DocsSidebar.tsx +++ b/client/src/components/docs/DocsSidebar.tsx @@ -77,6 +77,7 @@ const sections: SidebarSection[] = [ { label: "R28 Exported", slug: "rules/r28" }, { label: "R29 Union Narrowed", slug: "rules/r29" }, { label: "R30 Generator Toggle", slug: "rules/r30" }, + { label: "R31 Abstract Modifier Added", slug: "rules/r31" }, ], }, { diff --git a/client/src/content/docs/cli-rules.tsx b/client/src/content/docs/cli-rules.tsx index 4ffa20d..0eac142 100644 --- a/client/src/content/docs/cli-rules.tsx +++ b/client/src/content/docs/cli-rules.tsx @@ -39,6 +39,7 @@ const allRules: RuleEntry[] = [ { id: "R28", name: "Visibility Widened", target: "function", severity: "safe", languages: "All", description: "A previously internal symbol becomes exported. The API surface expands, but existing callers are unaffected.", example: "function helper() -> export function helper()" }, { id: "R29", name: "Type Alias Union Narrowed", target: "type_alias", severity: "breaking", languages: "TypeScript", description: "A string/number/boolean literal union type alias loses one of its members. Exhaustive switch statements and literal comparisons against the removed value will fail to compile or silently mishandle it.", example: "type Status = 'active' | 'inactive' -> type Status = 'active'" }, { id: "R30", name: "Generator Function Toggle", target: "function", severity: "breaking", languages: "TS, Python", description: "A function adds or removes the generator modifier (function*, yield). The calling convention changes completely — callers receive an iterator instead of a direct value, or vice versa.", example: "function nextId() -> function* nextId()" }, + { id: "R31", name: "Abstract Modifier Added", target: "function", severity: "breaking", languages: "All", description: "A concrete method is changed to abstract. Every existing subclass that does not already override this method will fail to compile, since a concrete implementation is no longer inherited.", example: "process(): void { ... } -> abstract process(): void;" }, ]; function SeverityBadge({ severity }: { severity: string }) { diff --git a/client/src/content/docs/rules-data.ts b/client/src/content/docs/rules-data.ts index 6d0e2e4..c7dd459 100644 --- a/client/src/content/docs/rules-data.ts +++ b/client/src/content/docs/rules-data.ts @@ -1072,6 +1072,45 @@ const id = nextId(); // id is now a Generator object, not a number`, ], relatedRules: ["R11", "R17", "R21"], }, + "R31": { + id: "R31", + name: "Abstract Modifier Added", + severity: "breaking", + target: "function", + languages: ["All languages"], + summary: "Flags when a concrete method gains the `abstract` modifier. Every subclass that inherited the previous default implementation without overriding it will fail to compile, since that default no longer exists.", + whyItMatters: `An abstract method has no body — it's a contract every subclass must fulfill. When a previously concrete method becomes abstract, any subclass that relied on the inherited default implementation loses it instantly. The subclass compiles fine in isolation right up until this change; the break appears at every subclass definition, not at the method itself.`, + howItWorks: `The translator stamps an \`isAbstract: boolean\` flag on every \`FunctionSignature\` by detecting the \`abstract\` modifier keyword (TypeScript, Java) or the language's equivalent construct (Python's \`@abstractmethod\`, Go/Rust trait conventions) during AST parsing. The rule normalizes \`undefined\` to \`false\` and only fires when the modifier is newly added (\`false\` → \`true\`). Removing \`abstract\` (\`true\` → \`false\`) is intentionally not flagged — it only adds a usable default implementation, which breaks nothing.`, + beforeCode: `// service.ts +export abstract class Service { + process(): void { + console.log('default behavior'); + } +}`, + afterCode: `// service.ts (BREAKING — method is now abstract) +export abstract class Service { + abstract process(): void; +} + +// subclass.ts — BREAKS +export class MyService extends Service {} +// Error: non-abstract class 'MyService' does not implement +// inherited abstract member 'process'.`, + beforeLabel: "Before", + afterLabel: "After (converted to abstract)", + cliOutput: `$ npx dg check + + [BREAKING] Service#process (modifier_changed) + src/service.ts:2 + Method was made abstract. Every existing subclass that does not already override this method will fail to compile, since a concrete implementation is no longer inherited.`, + realWorldScenario: `A maintainer decides a base class method should no longer have a sensible default and marks it abstract to force every subclass to think about its own implementation. The change compiles cleanly in the file where it was made — the compiler errors only appear in every downstream subclass file, often in a different package or repository the maintainer never opened.`, + edgeCases: [ + "isAbstract undefined on both sides (the vast majority of methods) is normalized to false — no false positive", + "Only the concrete → abstract direction fires; abstract → concrete is safe and silent by design", + "Applies only to methods (functions with a className) in practice, since free functions never receive isAbstract: true from any translator", + ], + relatedRules: ["R17", "R30"], + }, }; /** Return ordered list of rule IDs */ diff --git a/src/classifier/rules/R31_abstract_modifier_added.ts b/src/classifier/rules/R31_abstract_modifier_added.ts new file mode 100644 index 0000000..73343b3 --- /dev/null +++ b/src/classifier/rules/R31_abstract_modifier_added.ts @@ -0,0 +1,48 @@ +/** + * RULE 31: Abstract Modifier Added + * Flags when a concrete method gains the `abstract` modifier. + * This is a breaking change because every existing subclass that does not + * already override this method was previously inheriting a working default + * implementation. Once the method becomes abstract, that default is gone — + * those subclasses will fail to compile until they supply their own + * implementation. + * + * The reverse direction (abstract → concrete) is safe and non-breaking, but + * still reported as an advisory `safe` result: subclasses now have an + * optional default implementation available, and downstream developers may + * want to know they can delete their now-unnecessary override boilerplate. + */ + +import { FunctionRule, RuleResult } from '../types'; + +export const abstractModifierAddedRule: FunctionRule = { + id: 'R31', + name: 'Abstract Modifier Added', + description: 'Flags when a concrete method is changed to abstract, forcing every subclass to supply its own implementation.', + languages: 'all', + target: 'function', + + check(oldSig, newSig): RuleResult | null { + // Normalize undefined to false for safe boolean comparison + const wasAbstract = oldSig.isAbstract === true; + const isAbstractNow = newSig.isAbstract === true; + + if (!wasAbstract && isAbstractNow) { + return { + severity: 'breaking', + changeType: 'modifier_changed', + message: `Method '${newSig.name}' was made abstract. Every existing subclass that does not already override this method will fail to compile, since a concrete implementation is no longer inherited.`, + }; + } + + if (wasAbstract && !isAbstractNow) { + return { + severity: 'safe', + changeType: 'modifier_changed', + message: `Method '${newSig.name}' is no longer abstract and now has a default implementation. Existing subclass overrides remain valid and are now optional — safe to remove if the default is sufficient.`, + }; + } + + return null; + }, +}; diff --git a/src/classifier/rules/index.ts b/src/classifier/rules/index.ts index efed75a..f4b3ee9 100644 --- a/src/classifier/rules/index.ts +++ b/src/classifier/rules/index.ts @@ -26,3 +26,4 @@ export * from './R27_enum_changed'; export * from './R28_exported'; export * from './R29_type_alias_union_narrowed'; export * from './R30_generator_toggle'; +export * from './R31_abstract_modifier_added'; diff --git a/src/parsers/translators/typescript.ts b/src/parsers/translators/typescript.ts index 0200f0a..e9b7764 100644 --- a/src/parsers/translators/typescript.ts +++ b/src/parsers/translators/typescript.ts @@ -8,6 +8,7 @@ * Covers every TypeScript/JavaScript construct that produces a public API: * - Top-level functions (function_declaration, generator_function_declaration) * - Class methods (method_definition) + * - Abstract class methods (abstract_method_signature) * - Interface method signatures (method_signature) * - Exported arrow functions (lexical_declaration → arrow_function) * - Class constructors (constructor) @@ -84,6 +85,12 @@ const FN_QUERY_SRC_TS = ` parameters: (formal_parameters) @params return_type: (type_annotation)? @return ) @fn + + (abstract_method_signature + name: (property_identifier) @name + parameters: (formal_parameters) @params + return_type: (type_annotation)? @return + ) @fn `; /** diff --git a/tests/e2e/ast-e2e.test.ts b/tests/e2e/ast-e2e.test.ts index 01e9c3c..ac208c2 100644 --- a/tests/e2e/ast-e2e.test.ts +++ b/tests/e2e/ast-e2e.test.ts @@ -178,4 +178,29 @@ describe('E2E AST Pipeline — WASM + Engine integration', () => { expect(changes[0].changeType).toBe('symbol_added'); }); + it('✅ Identifies a concrete method converted to abstract (R31)', async () => { + const oldCode = ` + export abstract class Service { + process(): void { + console.log('default behavior'); + } + } + `; + const newCode = ` + export abstract class Service { + abstract process(): void; + } + `; + + const diff = createMockDiff(oldCode, newCode); + const parsedDiffs = await mapper.buildSignatureCache([diff]); + const changes = engine.compare(parsedDiffs[0]); + + expect(changes).toHaveLength(1); + expect(changes[0].severity).toBe('breaking'); + expect(changes[0].changeType).toBe('modifier_changed'); + expect(changes[0].message).toContain('abstract'); + expect(changes[0].symbolType).toBe('function'); + }); + }); diff --git a/tests/rules/function-rules.test.ts b/tests/rules/function-rules.test.ts index ee0d4d0..751d914 100644 --- a/tests/rules/function-rules.test.ts +++ b/tests/rules/function-rules.test.ts @@ -38,6 +38,7 @@ import { defaultValueChangedRule } from '../../src/classifier/rules/R23_defa import { constructorChangedRule } from '../../src/classifier/rules/R24_constructor_changed'; import { exportedRule } from '../../src/classifier/rules/R28_exported'; import { generatorToggledRule } from '../../src/classifier/rules/R30_generator_toggle'; +import { abstractModifierAddedRule } from '../../src/classifier/rules/R31_abstract_modifier_added'; // ═══════════════════════════════════════════════════════════════════════════════ // R01: Parameter Removed @@ -1436,3 +1437,50 @@ describe('R30 — generatorToggledRule', () => { expect(result).toBeNull(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// R31: Abstract Modifier Added +// ───────────────────────────────────────────────────────────────────────────── + +describe('R31 — abstractModifierAddedRule', () => { + + it('✅ True Positive: flags concrete method → abstract', () => { + const oldSig = mockFnSig({ isAbstract: false }); + const newSig = mockFnSig({ isAbstract: true }); + + const result = abstractModifierAddedRule.check(oldSig, newSig); + + expect(result).not.toBeNull(); + expect(asSingle(result).severity).toBe('breaking'); + expect(asSingle(result).changeType).toBe('modifier_changed'); + expect(asSingle(result).message).toContain('abstract'); + }); + + it('✅ True Positive (Safe): flags abstract → concrete as advisory', () => { + const oldSig = mockFnSig({ isAbstract: true }); + const newSig = mockFnSig({ isAbstract: false }); + + const result = abstractModifierAddedRule.check(oldSig, newSig); + + expect(result).not.toBeNull(); + expect(asSingle(result).severity).toBe('safe'); + expect(asSingle(result).changeType).toBe('modifier_changed'); + }); + + it('🚫 False Positive: both concrete must return null', () => { + const sig = mockFnSig({ isAbstract: false }); + + const result = abstractModifierAddedRule.check(sig, sig); + + expect(result).toBeNull(); + }); + + it('🔲 Edge Case: isAbstract undefined on both sides must not throw and must return null', () => { + const oldSig = mockFnSig({}); + const newSig = mockFnSig({}); + + const result = abstractModifierAddedRule.check(oldSig, newSig); + + expect(result).toBeNull(); + }); +});