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
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const COMMANDS: Record<string, TermLine[]> = {
{ 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": [
Expand Down
1 change: 1 addition & 0 deletions client/src/components/docs/DocsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
{
Expand Down
1 change: 1 addition & 0 deletions client/src/content/docs/cli-rules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
39 changes: 39 additions & 0 deletions client/src/content/docs/rules-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
48 changes: 48 additions & 0 deletions src/classifier/rules/R31_abstract_modifier_added.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
1 change: 1 addition & 0 deletions src/classifier/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
7 changes: 7 additions & 0 deletions src/parsers/translators/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
`;

/**
Expand Down
25 changes: 25 additions & 0 deletions tests/e2e/ast-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

});
48 changes: 48 additions & 0 deletions tests/rules/function-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
});
Loading