All files / src/inspections DeepDiagnosticProvider.ts

0% Statements 0/144
0% Branches 0/61
0% Functions 0/20
0% Lines 0/123

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
// src/inspections/DeepDiagnosticProvider.ts
// Deep static analysis for the remaining diagnostic gaps
import * as vscode from 'vscode';
import type { CacheManager } from '../cache/CacheManager';
 
// ─── Deep Diagnostic Provider ─────────────────────────────────────────────
 
export class DeepDiagnosticProvider implements vscode.Disposable {
  private readonly collection: vscode.DiagnosticCollection;
  private readonly disposables: vscode.Disposable[] = [];
  private readonly debounce = new Map<string, ReturnType<typeof setTimeout>>();
 
  constructor(private readonly cache: CacheManager) {
    this.collection = vscode.languages.createDiagnosticCollection('laravel-maestro-deep');
 
    this.disposables.push(
      vscode.workspace.onDidOpenTextDocument((d) => this.scheduleValidation(d)),
      vscode.workspace.onDidSaveTextDocument((d) => this.validate(d)),
      vscode.workspace.onDidChangeTextDocument((e) => this.scheduleValidation(e.document)),
      vscode.workspace.onDidCloseTextDocument((d) => this.collection.delete(d.uri)),
    );
    for (const doc of vscode.workspace.textDocuments) { this.validate(doc); }
  }
 
  private scheduleValidation(doc: vscode.TextDocument): void {
    const key = doc.uri.toString();
    const t = this.debounce.get(key);
    if (t) { clearTimeout(t); }
    this.debounce.set(key, setTimeout(() => { this.debounce.delete(key); this.validate(doc); }, 700));
  }
 
  private validate(doc: vscode.TextDocument): void {
    if (doc.languageId !== 'php' && doc.languageId !== 'blade') { return; }
    const text = doc.getText();
    const diags: vscode.Diagnostic[] = [];
 
    diags.push(...this.checkGuardedStar(doc, text));
    diags.push(...this.checkMissingModelAttribute(doc, text));
    diags.push(...this.checkDirectDbQueryOutsideModel(doc, text));
    diags.push(...this.checkPotentialN1(doc, text));
    diags.push(...this.checkMissingNullCheck(doc, text));
 
    this.collection.set(doc.uri, diags);
  }
 
  // ─── $guarded = ['*'] — writing ANY property is suspicious ───────────────
 
  private checkGuardedStar(
    doc: vscode.TextDocument,
    text: string
  ): vscode.Diagnostic[] {
    const diags: vscode.Diagnostic[] = [];
    const index = this.cache.getIndex();
 
    // Find models with $guarded = ['*']
    const fullyGuardedModels = new Set<string>();
    for (const [, model] of index.models) {
      if (model.guarded.length === 1 && model.guarded[0] === '*') {
        fullyGuardedModels.add(model.className);
      }
    }
    if (fullyGuardedModels.size === 0) { return diags; }
 
    // Match ModelName::create([...]) or ::fill([...]) calls
    const re = /\b(\w+)::(?:create|fill|forceCreate)\s*\(/g;
    let m: RegExpExecArray | null;
    while ((m = re.exec(text)) !== null) {
      if (!fullyGuardedModels.has(m[1])) { continue; }
      const pos = doc.positionAt(m.index);
      const diag = new vscode.Diagnostic(
        new vscode.Range(pos, new vscode.Position(pos.line, pos.character + m[0].length)),
        `${m[1]} has $guarded = ['*']. Mass assignment will silently fail unless $fillable is set.`,
        vscode.DiagnosticSeverity.Warning
      );
      diag.code = 'laravel.guarded-star-mass-assign';
      diag.source = 'Laravel Maestro';
      diag.tags = [vscode.DiagnosticTag.Unnecessary];
      diags.push(diag);
    }
    return diags;
  }
 
  // ─── Accessing non-existent model attribute ───────────────────────────────
 
  private checkMissingModelAttribute(
    doc: vscode.TextDocument,
    text: string
  ): vscode.Diagnostic[] {
    const diags: vscode.Diagnostic[] = [];
    const index = this.cache.getIndex();
 
    // Only run in controller/service files, not in model files themselves
    if (text.includes('extends Model') || text.includes('extends Authenticatable')) { return diags; }
 
    // Pattern: $model->someProperty where someProperty is not in schema and not a method
    const arrowProp = /\$(\w+)\s*->\s*(\w+)/g;
    let m: RegExpExecArray | null;
 
    while ((m = arrowProp.exec(text)) !== null) {
      const varName = m[1];
      const propName = m[2];
 
      // Skip common known props/methods
      if (['id', 'save', 'delete', 'update', 'fill', 'fresh', 'refresh',
           'load', 'loadMissing', 'withTrashed', 'trashed', 'restore',
           'toArray', 'toJson', 'jsonSerialize', 'attributesToArray',
           'getAttributes', 'getOriginal', 'getDirty', 'wasChanged',
           'created_at', 'updated_at', 'deleted_at', 'pivot'].includes(propName)) { continue; }
 
      // Find what model this variable is
      const lineIdx = (text.slice(0, m.index).match(/\n/g) ?? []).length;
      const startScan = Math.max(0, lineIdx - 30);
 
      let modelName: string | undefined;
      const scanText = text.split('\n').slice(startScan, lineIdx + 1).join('\n');
      const assignMatch = new RegExp(`\\$${varName}\\s*=\\s*(\\w+)::(?:find|first|create|make|where)[^;]+`).exec(scanText);
      if (assignMatch) { modelName = assignMatch[1]; }
 
      if (!modelName) { continue; }
 
      const model = Array.from(index.models.values()).find((mo) => mo.className === modelName);
      if (!model || model.columns.length === 0) { continue; }
 
      // Check if property exists in columns, relations, or accessors
      const exists =
        model.columns.some((c) => c.name === propName) ||
        model.relationships.some((r) => r.name === propName) ||
        model.scopes.some((s) => s.name === propName) ||
        Object.keys(model.casts).includes(propName) ||
        propName.endsWith('_count') || propName.endsWith('_exists') ||
        propName.endsWith('_sum') || propName.endsWith('_min') || propName.endsWith('_max');
 
      if (!exists) {
        // Don't flag if it looks like a method call (followed by `(`)
        const afterProp = text.slice(m.index + m[0].length, m.index + m[0].length + 2);
        if (afterProp.trimStart().startsWith('(')) { continue; }
 
        const pos = doc.positionAt(m.index + m[0].indexOf(propName));
        const end = doc.positionAt(m.index + m[0].indexOf(propName) + propName.length);
        const diag = new vscode.Diagnostic(
          new vscode.Range(pos, end),
          `Property '${propName}' not found on ${modelName}. Check column name, cast, or accessor.`,
          vscode.DiagnosticSeverity.Information
        );
        diag.code = 'laravel.unknown-model-property';
        diag.source = 'Laravel Maestro';
        diags.push(diag);
      }
    }
    return diags;
  }
 
  // ─── Direct DB queries outside model scope ────────────────────────────────
 
  private checkDirectDbQueryOutsideModel(
    doc: vscode.TextDocument,
    text: string
  ): vscode.Diagnostic[] {
    const diags: vscode.Diagnostic[] = [];
    if (!doc.fileName.includes('Controller') && !doc.fileName.includes('Service')) { return diags; }
 
    const pattern = /\bDB::table\s*\(\s*['"]([^'"]+)['"]/g;
    let m: RegExpExecArray | null;
    while ((m = pattern.exec(text)) !== null) {
      const pos = doc.positionAt(m.index);
      const diag = new vscode.Diagnostic(
        new vscode.Range(pos, new vscode.Position(pos.line, pos.character + 8)),
        `Consider using an Eloquent model instead of raw DB::table('${m[1]}') for better type safety.`,
        vscode.DiagnosticSeverity.Information
      );
      diag.code = 'laravel.raw-db-query';
      diag.source = 'Laravel Maestro';
      diag.tags = [vscode.DiagnosticTag.Unnecessary];
      diags.push(diag);
    }
    return diags;
  }
 
  // ─── Potential N+1 query detection ───────────────────────────────────────
 
  private checkPotentialN1(
    doc: vscode.TextDocument,
    text: string
  ): vscode.Diagnostic[] {
    const diags: vscode.Diagnostic[] = [];
    const index = this.cache.getIndex();
 
    // Detect: foreach($models as $model) { $model->relationship }
    // A very basic heuristic: foreach loop with a relation access without eager loading
    const foreachPattern = /foreach\s*\(\s*\$(\w+)\s+as\s+\$(\w+)\s*\)/g;
    let m: RegExpExecArray | null;
 
    while ((m = foreachPattern.exec(text)) !== null) {
      const itemVar = m[2];
 
      // Look for relation accesses inside the foreach body
      const loopStart = m.index + m[0].length;
      // Find the matching closing brace (simplified: look for next 300 chars)
      const loopBody = text.slice(loopStart, loopStart + 500);
 
      const relAccessPattern = new RegExp(`\\$${itemVar}\\s*->\\s*(\\w+)`, 'g');
      let relMatch: RegExpExecArray | null;
      while ((relMatch = relAccessPattern.exec(loopBody)) !== null) {
        const relName = relMatch[1];
 
        // Check if this is a known relationship
        for (const [, model] of index.models) {
          const rel = model.relationships.find((r) => r.name === relName);
          if (!rel) { continue; }
 
          // Check if the collection had ->with() before the foreach
          const beforeLoop = text.slice(Math.max(0, m.index - 300), m.index);
          if (beforeLoop.includes(`with('${relName}')`) || beforeLoop.includes(`with("${relName}")`)) { continue; }
          if (beforeLoop.includes(`withCount('${relName}')`) || beforeLoop.includes(`withExists('${relName}')`)) { continue; }
 
          const pos = doc.positionAt(loopStart + relMatch.index);
          const diag = new vscode.Diagnostic(
            new vscode.Range(pos, new vscode.Position(pos.line, pos.character + relMatch[0].length)),
            `Potential N+1 query: accessing relation '${relName}' inside foreach. Consider eager loading with ->with('${relName}').`,
            vscode.DiagnosticSeverity.Warning
          );
          diag.code = 'laravel.n-plus-one';
          diag.source = 'Laravel Maestro';
          diags.push(diag);
          break;
        }
      }
    }
    return diags;
  }
 
  // ─── Missing null check on findOrFail alternatives ───────────────────────
 
  private checkMissingNullCheck(
    doc: vscode.TextDocument,
    text: string
  ): vscode.Diagnostic[] {
    const diags: vscode.Diagnostic[] = [];
 
    // Detect: $x = Model::find($id); then $x-> without null check
    const findPattern = /\$(\w+)\s*=\s*\w+::find\s*\(/g;
    let m: RegExpExecArray | null;
 
    while ((m = findPattern.exec(text)) !== null) {
      const varName = m[1];
      const afterFind = text.slice(m.index, m.index + 600);
 
      // Check if the variable is accessed without null check
      const nullCheck = new RegExp(`(?:if\\s*\\(\\s*!?\\s*\\$${varName}|\\$${varName}\\s*===?\\s*null|isset\\s*\\(\\s*\\$${varName})`);
      const directAccess = new RegExp(`\\$${varName}\\s*->`);
 
      if (!nullCheck.test(afterFind) && directAccess.test(afterFind)) {
        const pos = doc.positionAt(m.index);
        const diag = new vscode.Diagnostic(
          new vscode.Range(pos, new vscode.Position(pos.line, pos.character + 3)),
          `Model::find() can return null. Consider null-checking $${varName} or using findOrFail().`,
          vscode.DiagnosticSeverity.Information
        );
        diag.code = 'laravel.unchecked-find-result';
        diag.source = 'Laravel Maestro';
        diags.push(diag);
      }
    }
    return diags;
  }
 
  dispose(): void {
    this.collection.dispose();
    for (const d of this.disposables) { d.dispose(); }
    for (const t of this.debounce.values()) { clearTimeout(t); }
  }
}