codesift-mcp 0.7.0 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/git-hooks-installer.d.ts.map +1 -1
  3. package/dist/cli/git-hooks-installer.js +18 -5
  4. package/dist/cli/git-hooks-installer.js.map +1 -1
  5. package/dist/cli/hooks.d.ts.map +1 -1
  6. package/dist/cli/hooks.js +106 -2
  7. package/dist/cli/hooks.js.map +1 -1
  8. package/dist/cli/setup.d.ts +5 -0
  9. package/dist/cli/setup.d.ts.map +1 -1
  10. package/dist/cli/setup.js +31 -5
  11. package/dist/cli/setup.js.map +1 -1
  12. package/dist/config.d.ts +2 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +10 -1
  15. package/dist/config.js.map +1 -1
  16. package/dist/instructions.d.ts +1 -1
  17. package/dist/instructions.d.ts.map +1 -1
  18. package/dist/instructions.js +6 -1
  19. package/dist/instructions.js.map +1 -1
  20. package/dist/parser/extractors/hono.d.ts.map +1 -1
  21. package/dist/parser/extractors/hono.js +21 -13
  22. package/dist/parser/extractors/hono.js.map +1 -1
  23. package/dist/parser/extractors/php.d.ts +12 -0
  24. package/dist/parser/extractors/php.d.ts.map +1 -1
  25. package/dist/parser/extractors/php.js +440 -26
  26. package/dist/parser/extractors/php.js.map +1 -1
  27. package/dist/register-tool-loaders.d.ts +16 -0
  28. package/dist/register-tool-loaders.d.ts.map +1 -1
  29. package/dist/register-tool-loaders.js +26 -0
  30. package/dist/register-tool-loaders.js.map +1 -1
  31. package/dist/register-tools.d.ts +3 -1
  32. package/dist/register-tools.d.ts.map +1 -1
  33. package/dist/register-tools.js +354 -7
  34. package/dist/register-tools.js.map +1 -1
  35. package/dist/retrieval/codebase-retrieval.d.ts.map +1 -1
  36. package/dist/retrieval/codebase-retrieval.js +22 -0
  37. package/dist/retrieval/codebase-retrieval.js.map +1 -1
  38. package/dist/retrieval/retrieval-schemas.d.ts +4 -0
  39. package/dist/retrieval/retrieval-schemas.d.ts.map +1 -1
  40. package/dist/retrieval/semantic-handlers.js +1 -1
  41. package/dist/retrieval/semantic-handlers.js.map +1 -1
  42. package/dist/search/semantic.d.ts +21 -5
  43. package/dist/search/semantic.d.ts.map +1 -1
  44. package/dist/search/semantic.js +129 -4
  45. package/dist/search/semantic.js.map +1 -1
  46. package/dist/search/tool-ranker.js +1 -1
  47. package/dist/search/tool-ranker.js.map +1 -1
  48. package/dist/server-helpers.d.ts.map +1 -1
  49. package/dist/server-helpers.js +96 -1
  50. package/dist/server-helpers.js.map +1 -1
  51. package/dist/storage/index-store.d.ts.map +1 -1
  52. package/dist/storage/index-store.js +7 -5
  53. package/dist/storage/index-store.js.map +1 -1
  54. package/dist/storage/registry.d.ts +28 -4
  55. package/dist/storage/registry.d.ts.map +1 -1
  56. package/dist/storage/registry.js +126 -5
  57. package/dist/storage/registry.js.map +1 -1
  58. package/dist/storage/usage-stats.d.ts +2 -0
  59. package/dist/storage/usage-stats.d.ts.map +1 -1
  60. package/dist/storage/usage-stats.js +6 -0
  61. package/dist/storage/usage-stats.js.map +1 -1
  62. package/dist/storage/usage-tracker.js +1 -1
  63. package/dist/storage/usage-tracker.js.map +1 -1
  64. package/dist/tools/_helpers.d.ts.map +1 -1
  65. package/dist/tools/_helpers.js +14 -0
  66. package/dist/tools/_helpers.js.map +1 -1
  67. package/dist/tools/conversation-tools.js +1 -1
  68. package/dist/tools/conversation-tools.js.map +1 -1
  69. package/dist/tools/index-tools.d.ts +12 -0
  70. package/dist/tools/index-tools.d.ts.map +1 -1
  71. package/dist/tools/index-tools.js +52 -5
  72. package/dist/tools/index-tools.js.map +1 -1
  73. package/dist/tools/insights-tools.d.ts +137 -0
  74. package/dist/tools/insights-tools.d.ts.map +1 -0
  75. package/dist/tools/insights-tools.js +438 -0
  76. package/dist/tools/insights-tools.js.map +1 -0
  77. package/dist/tools/pattern-tools.d.ts +7 -0
  78. package/dist/tools/pattern-tools.d.ts.map +1 -1
  79. package/dist/tools/pattern-tools.js +287 -15
  80. package/dist/tools/pattern-tools.js.map +1 -1
  81. package/dist/tools/php-tools.d.ts +78 -4
  82. package/dist/tools/php-tools.d.ts.map +1 -1
  83. package/dist/tools/php-tools.js +824 -42
  84. package/dist/tools/php-tools.js.map +1 -1
  85. package/dist/tools/php8-compat-tools.d.ts +62 -0
  86. package/dist/tools/php8-compat-tools.d.ts.map +1 -0
  87. package/dist/tools/php8-compat-tools.js +287 -0
  88. package/dist/tools/php8-compat-tools.js.map +1 -0
  89. package/dist/tools/php8-migration-candidates-tools.d.ts +68 -0
  90. package/dist/tools/php8-migration-candidates-tools.d.ts.map +1 -0
  91. package/dist/tools/php8-migration-candidates-tools.js +476 -0
  92. package/dist/tools/php8-migration-candidates-tools.js.map +1 -0
  93. package/dist/tools/phpstan-baseline-tools.d.ts +62 -0
  94. package/dist/tools/phpstan-baseline-tools.d.ts.map +1 -0
  95. package/dist/tools/phpstan-baseline-tools.js +263 -0
  96. package/dist/tools/phpstan-baseline-tools.js.map +1 -0
  97. package/dist/tools/project-tools.d.ts +4 -2
  98. package/dist/tools/project-tools.d.ts.map +1 -1
  99. package/dist/tools/project-tools.js +19 -6
  100. package/dist/tools/project-tools.js.map +1 -1
  101. package/dist/tools/react-tools.d.ts +24 -0
  102. package/dist/tools/react-tools.d.ts.map +1 -1
  103. package/dist/tools/react-tools.js +292 -3
  104. package/dist/tools/react-tools.js.map +1 -1
  105. package/dist/tools/search-tools.d.ts.map +1 -1
  106. package/dist/tools/search-tools.js +92 -10
  107. package/dist/tools/search-tools.js.map +1 -1
  108. package/dist/tools/symbol-tools.d.ts.map +1 -1
  109. package/dist/tools/symbol-tools.js +4 -1
  110. package/dist/tools/symbol-tools.js.map +1 -1
  111. package/dist/tools/yii-console-tools.d.ts +69 -0
  112. package/dist/tools/yii-console-tools.d.ts.map +1 -0
  113. package/dist/tools/yii-console-tools.js +256 -0
  114. package/dist/tools/yii-console-tools.js.map +1 -0
  115. package/dist/tools/yii-migrations-tools.d.ts +79 -0
  116. package/dist/tools/yii-migrations-tools.d.ts.map +1 -0
  117. package/dist/tools/yii-migrations-tools.js +543 -0
  118. package/dist/tools/yii-migrations-tools.js.map +1 -0
  119. package/dist/tools/yii-modules-tools.d.ts +63 -0
  120. package/dist/tools/yii-modules-tools.d.ts.map +1 -0
  121. package/dist/tools/yii-modules-tools.js +201 -0
  122. package/dist/tools/yii-modules-tools.js.map +1 -0
  123. package/dist/tools/yii-rbac-tools.d.ts +89 -0
  124. package/dist/tools/yii-rbac-tools.d.ts.map +1 -0
  125. package/dist/tools/yii-rbac-tools.js +238 -0
  126. package/dist/tools/yii-rbac-tools.js.map +1 -0
  127. package/dist/tools/yii3-attribute-candidates-tools.d.ts +72 -0
  128. package/dist/tools/yii3-attribute-candidates-tools.d.ts.map +1 -0
  129. package/dist/tools/yii3-attribute-candidates-tools.js +301 -0
  130. package/dist/tools/yii3-attribute-candidates-tools.js.map +1 -0
  131. package/dist/tools/yii3-migration-tools.d.ts +74 -0
  132. package/dist/tools/yii3-migration-tools.d.ts.map +1 -0
  133. package/dist/tools/yii3-migration-tools.js +440 -0
  134. package/dist/tools/yii3-migration-tools.js.map +1 -0
  135. package/dist/types.d.ts +5 -1
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/utils/constant-file-pattern.d.ts +3 -1
  138. package/dist/utils/constant-file-pattern.d.ts.map +1 -1
  139. package/dist/utils/constant-file-pattern.js +6 -4
  140. package/dist/utils/constant-file-pattern.js.map +1 -1
  141. package/dist/utils/heritage-edges.d.ts +16 -0
  142. package/dist/utils/heritage-edges.d.ts.map +1 -1
  143. package/dist/utils/heritage-edges.js +31 -10
  144. package/dist/utils/heritage-edges.js.map +1 -1
  145. package/dist/utils/source-stripper.d.ts +23 -0
  146. package/dist/utils/source-stripper.d.ts.map +1 -0
  147. package/dist/utils/source-stripper.js +239 -0
  148. package/dist/utils/source-stripper.js.map +1 -0
  149. package/dist/utils/tsconfig-paths.d.ts +2 -2
  150. package/dist/utils/tsconfig-paths.d.ts.map +1 -1
  151. package/dist/utils/tsconfig-paths.js +10 -4
  152. package/dist/utils/tsconfig-paths.js.map +1 -1
  153. package/dist/utils/wall-clock.d.ts +9 -0
  154. package/dist/utils/wall-clock.d.ts.map +1 -0
  155. package/dist/utils/wall-clock.js +19 -0
  156. package/dist/utils/wall-clock.js.map +1 -0
  157. package/package.json +1 -1
  158. package/rules/codesift.md +10 -3
  159. package/rules/codesift.mdc +10 -3
  160. package/rules/codex.md +10 -3
  161. package/rules/gemini.md +10 -3
@@ -64,6 +64,58 @@ export async function resolvePhpNamespace(repo, className) {
64
64
  psr4_prefix: bestPrefix,
65
65
  };
66
66
  }
67
+ /**
68
+ * Names of class roots that we treat as "this is an ActiveRecord". The check
69
+ * is done on the LAST namespace segment so prefixed forms (yii\\db\\ActiveRecord,
70
+ * \\yii\\db\\ActiveRecord, app\\models\\ActiveRecord) all match. Includes
71
+ * yii\\base\\Model because Yii2 form models extending Model share the
72
+ * rules() / behaviors() lifecycle that analyzeActiveRecord introspects;
73
+ * downstream callers can filter by tableName() presence if they need a
74
+ * stricter "real DB-backed AR" criterion.
75
+ */
76
+ const AR_ROOT_NAMES = new Set(["ActiveRecord", "Model", "BaseActiveRecord"]);
77
+ /**
78
+ * Walk a class symbol's `extends` chain and return true if any ancestor
79
+ * matches a known ActiveRecord base class. Resolves transitively via the
80
+ * symbol index — handles cases like `User extends BaseUser` where
81
+ * `BaseUser extends ActiveRecord`.
82
+ *
83
+ * Direct match (root name in our AR_ROOT_NAMES set) wins immediately.
84
+ * Otherwise we look up the parent class symbol by name and recurse. The
85
+ * lookup uses last-segment name matching (e.g. `BaseUser` matches a class
86
+ * symbol whose `name` is exactly `BaseUser`, regardless of namespace) which
87
+ * is good enough for the codebases we care about; cross-package aliased
88
+ * resolution would require parsing per-file `use` tables.
89
+ *
90
+ * Cycle protection via a visited set; depth-cap of 5 (no real Yii2 model
91
+ * has a deeper chain).
92
+ */
93
+ function isActiveRecordHierarchy(cls, index, visited = new Set(), depth = 0) {
94
+ if (depth > 5)
95
+ return false;
96
+ if (visited.has(cls.name))
97
+ return false;
98
+ visited.add(cls.name);
99
+ const exts = cls.extends ?? [];
100
+ for (const baseFqcn of exts) {
101
+ // Last segment of FQCN (handles "\\yii\\db\\ActiveRecord" and aliases).
102
+ const last = baseFqcn.split(/[\\\\]+/).pop() ?? baseFqcn;
103
+ if (AR_ROOT_NAMES.has(last))
104
+ return true;
105
+ // Look up the base class as an indexed symbol and recurse.
106
+ const baseSym = index.symbols.find((s) => s.kind === "class" && s.name === last);
107
+ if (baseSym && isActiveRecordHierarchy(baseSym, index, visited, depth + 1)) {
108
+ return true;
109
+ }
110
+ }
111
+ // Fallback for older indexes (e.g. before the v2.0.0 extractor bump): if
112
+ // `extends` is missing on this symbol, try the legacy regex against
113
+ // `source` so we don't regress on unindexed projects.
114
+ if (!cls.extends && cls.source) {
115
+ return /extends\s+(?:ActiveRecord|Model|\\yii\\db\\ActiveRecord)\b/.test(cls.source);
116
+ }
117
+ return false;
118
+ }
67
119
  export async function analyzeActiveRecord(repo, options) {
68
120
  const index = await getCodeIndex(repo);
69
121
  if (!index)
@@ -82,11 +134,9 @@ export async function analyzeActiveRecord(repo, options) {
82
134
  });
83
135
  const models = [];
84
136
  for (const cls of classSymbols) {
85
- // Heuristic: only models that have source containing ActiveRecord or extend Model
86
137
  if (!cls.source)
87
138
  continue;
88
- const extendsAR = /extends\s+(?:ActiveRecord|Model|\\yii\\db\\ActiveRecord)/.test(cls.source);
89
- if (!extendsAR)
139
+ if (!isActiveRecordHierarchy(cls, index))
90
140
  continue;
91
141
  const model = {
92
142
  name: cls.name,
@@ -156,11 +206,63 @@ export async function analyzeActiveRecord(repo, options) {
156
206
  }
157
207
  return { models, total: models.length };
158
208
  }
209
+ /**
210
+ * Build a class-const → literal-value map for the entire index. Yii2's
211
+ * canonical event idiom is `Event::on(User::class, User::EVENT_AFTER_LOGIN, ...)`,
212
+ * where `EVENT_AFTER_LOGIN` is a class constant with a string value. The
213
+ * default tracePhpEvent regex only sees literals, so without resolution
214
+ * `Class::CONST` references look like dead code. This pre-pass walks all
215
+ * `constant` symbols belonging to PHP classes and extracts their string /
216
+ * int literal values from `source`.
217
+ *
218
+ * Map keys are `ClassName::CONST_NAME`. Class lookup is by last name segment
219
+ * — same convention as isActiveRecordHierarchy — so namespace prefixes don't
220
+ * matter for callers using `User::EVENT_X` against a class named `User`.
221
+ *
222
+ * Returns an empty map if no constants resolve. Cost is one O(n) walk per
223
+ * call; could be cached on the index in the future if event tracing becomes
224
+ * a hot path.
225
+ */
226
+ function buildConstantValueMap(index) {
227
+ const out = new Map();
228
+ // First, build classId → className map so we can resolve const owners.
229
+ const classIdToName = new Map();
230
+ for (const s of index.symbols) {
231
+ if (s.kind === "class" || s.kind === "interface" || s.kind === "enum") {
232
+ // Use the symbol id as key — every constant carries `parent` referring
233
+ // to its enclosing class id, so we only need the id→name lookup.
234
+ const id = s.id;
235
+ if (id)
236
+ classIdToName.set(id, s.name);
237
+ }
238
+ }
239
+ for (const s of index.symbols) {
240
+ if (s.kind !== "constant")
241
+ continue;
242
+ if (!s.parent || !s.source)
243
+ continue;
244
+ const className = classIdToName.get(s.parent);
245
+ if (!className)
246
+ continue;
247
+ // Match the literal value: `const NAME = 'value';` or `const NAME = "v";`
248
+ // or `const NAME = 42;`. We accept the first occurrence in the constant's
249
+ // source slice — the extractor already narrows source to a single decl.
250
+ const m = /=\s*(?:['"]([^'"]+)['"]|(-?\d+(?:\.\d+)?))/.exec(s.source);
251
+ if (!m)
252
+ continue;
253
+ const value = m[1] ?? m[2];
254
+ if (value === undefined)
255
+ continue;
256
+ out.set(`${className}::${s.name}`, value);
257
+ }
258
+ return out;
259
+ }
159
260
  export async function tracePhpEvent(repo, options) {
160
261
  const index = await getCodeIndex(repo);
161
262
  if (!index)
162
263
  throw new Error(`Repository "${repo}" not found.`);
163
264
  const eventMap = new Map();
265
+ const constantValues = buildConstantValueMap(index);
164
266
  const getOrCreate = (name) => {
165
267
  let e = eventMap.get(name);
166
268
  if (!e) {
@@ -169,15 +271,28 @@ export async function tracePhpEvent(repo, options) {
169
271
  }
170
272
  return e;
171
273
  };
274
+ // Resolve `Class::CONST` references to their literal values via the pre-pass
275
+ // map. Returns the original key when the class+const pair isn't indexed
276
+ // (e.g. constants defined in vendor/) so the trace at least shows there's
277
+ // SOMETHING happening at this site.
278
+ const resolveEventName = (raw) => {
279
+ return constantValues.get(raw) ?? raw;
280
+ };
172
281
  // Scan PHP file symbols for event triggers and listeners
173
282
  const phpSymbols = index.symbols.filter((s) => s.file.endsWith(".php") && s.source);
174
283
  for (const sym of phpSymbols) {
175
284
  const source = sym.source;
176
- // Triggers: ->trigger('eventName') or Event::trigger(...)
177
- const triggerRe = /->trigger\s*\(\s*['"]([^'"]+)['"]/g;
285
+ // Triggers: ->trigger('eventName') or ->trigger(Class::CONST)
286
+ // Now also accepts a bare identifier path (Foo::BAR) in addition to the
287
+ // string-literal form.
288
+ const triggerRe = /->trigger\s*\(\s*(?:['"]([^'"]+)['"]|([A-Z_][\w]*::[A-Z_][\w]*))/g;
178
289
  let match;
179
290
  while ((match = triggerRe.exec(source)) !== null) {
180
- const eventName = match[1];
291
+ const literal = match[1];
292
+ const constRef = match[2];
293
+ const eventName = literal ?? (constRef ? resolveEventName(constRef) : undefined);
294
+ if (!eventName)
295
+ continue;
181
296
  if (options?.event_name && eventName !== options.event_name)
182
297
  continue;
183
298
  const line = sym.start_line + (source.slice(0, match.index).match(/\n/g)?.length ?? 0);
@@ -187,10 +302,17 @@ export async function tracePhpEvent(repo, options) {
187
302
  context: extractLineContext(source, match.index),
188
303
  });
189
304
  }
190
- // Listeners: ->on('eventName', ...) or Event::on(...)
191
- const listenerRe = /(?:->|::)on\s*\(\s*['"]([^'"]+)['"]/g;
305
+ // Listeners: ->on('eventName', ...) or ::on('eventName', ...) or
306
+ // ::on(Foo::class, Foo::EVENT_BAR, ...)
307
+ // Yii2 prefers the class-const form for built-in events, so resolution is
308
+ // critical here.
309
+ const listenerRe = /(?:->|::)on\s*\(\s*(?:[A-Z_][\w]*::class\s*,\s*)?(?:['"]([^'"]+)['"]|([A-Z_][\w]*::[A-Z_][\w]*))/g;
192
310
  while ((match = listenerRe.exec(source)) !== null) {
193
- const eventName = match[1];
311
+ const literal = match[1];
312
+ const constRef = match[2];
313
+ const eventName = literal ?? (constRef ? resolveEventName(constRef) : undefined);
314
+ if (!eventName)
315
+ continue;
194
316
  if (options?.event_name && eventName !== options.event_name)
195
317
  continue;
196
318
  const line = sym.start_line + (source.slice(0, match.index).match(/\n/g)?.length ?? 0);
@@ -204,48 +326,320 @@ export async function tracePhpEvent(repo, options) {
204
326
  const events = [...eventMap.values()];
205
327
  return { events, total: events.length };
206
328
  }
329
+ /**
330
+ * Yii2 view + layout + widget + asset-bundle inventory.
331
+ *
332
+ * Beyond the original render→view mapping (Sprint 1), the tool now also:
333
+ * - Distinguishes render flavors (full/partial/ajax/json/file) so
334
+ * downstream caching/SEO audits can scope their checks.
335
+ * - Resolves `@alias/...` paths via the path-alias map sourced from
336
+ * `Yii::setAlias()` calls and `aliases` keys in config files.
337
+ * - Captures `$this->layout = '...'` assignments (controller-wide and
338
+ * per-action overrides).
339
+ * - Lists widget references (`GridView::begin/widget`) found anywhere
340
+ * in the codebase.
341
+ * - Lists `AssetBundle::register($this)` calls.
342
+ */
207
343
  export async function findPhpViews(repo, options) {
208
344
  const index = await getCodeIndex(repo);
209
345
  if (!index)
210
346
  throw new Error(`Repository "${repo}" not found.`);
347
+ const includeWidgets = options?.include_widgets ?? true;
348
+ const includeBundles = options?.include_asset_bundles ?? true;
211
349
  const mappings = [];
350
+ const layouts = [];
351
+ const widgets = [];
352
+ const assetBundles = [];
353
+ // Resolve path aliases up-front. The map is consulted for every
354
+ // render() / layout assignment / view file path that begins with `@`.
355
+ const aliasMap = await resolvePathAliases(index);
212
356
  // Find action methods in controllers
213
357
  const controllers = index.symbols.filter((s) => s.kind === "class" && s.name.endsWith("Controller") && s.file.endsWith(".php"));
214
358
  for (const ctrl of controllers) {
215
359
  if (options?.controller && !ctrl.name.includes(options.controller))
216
360
  continue;
361
+ // Controller-wide layout: `$this->layout = '...'` declared as a property
362
+ // OR set in init() / beforeAction() / a per-action method.
363
+ if (ctrl.source) {
364
+ const layoutPropRe = /(?:public|protected|private)?\s*\$layout\s*=\s*['"]([^'"]+)['"]/;
365
+ const propMatch = layoutPropRe.exec(ctrl.source);
366
+ if (propMatch) {
367
+ const layoutName = propMatch[1];
368
+ layouts.push({
369
+ controller: ctrl.name,
370
+ action: null,
371
+ layout: layoutName,
372
+ layout_file: resolveLayoutFile(layoutName, ctrl.name, aliasMap, index),
373
+ set_at_line: ctrl.start_line + (ctrl.source.slice(0, propMatch.index).match(/\n/g)?.length ?? 0),
374
+ });
375
+ }
376
+ }
217
377
  const actions = index.symbols.filter((s) => s.parent === ctrl.id && s.kind === "method" && s.name.startsWith("action"));
218
378
  for (const action of actions) {
219
379
  if (!action.source)
220
380
  continue;
221
381
  // Match $this->render('viewName'), renderPartial('...'), renderAjax('...')
222
- const renderRe = /\$this->render(?:Partial|Ajax|AsJson)?\s*\(\s*['"]([^'"]+)['"]/g;
382
+ // Capture the render KIND from the suffix so callers can filter by
383
+ // flavor downstream.
384
+ const renderRe = /\$this->render(Partial|Ajax|AsJson|File)?\s*\(\s*['"]([^'"]+)['"]/g;
223
385
  let match;
224
386
  while ((match = renderRe.exec(action.source)) !== null) {
225
- const viewName = match[1];
226
- // Yii2 convention: views/{controller-id}/{view}.php
227
- const controllerId = pascalToKebab(ctrl.name.replace(/Controller$/, ""));
228
- const viewFile = `views/${controllerId}/${viewName}.php`;
229
- const exists = index.files.some((f) => f.path === viewFile || f.path.endsWith("/" + viewFile));
387
+ const suffix = match[1] ?? "";
388
+ const viewName = match[2];
389
+ const renderKind = suffix === ""
390
+ ? "full"
391
+ : suffix.toLowerCase().replace("asjson", "json");
392
+ const { file: viewFile, alias } = resolveViewFile(viewName, ctrl.name, aliasMap, index);
230
393
  const line = action.start_line + (action.source.slice(0, match.index).match(/\n/g)?.length ?? 0);
231
394
  mappings.push({
232
395
  controller: ctrl.name,
233
396
  action: action.name,
234
397
  view_name: viewName,
235
- view_file: exists ? viewFile : null,
398
+ view_file: viewFile,
236
399
  render_line: line,
400
+ render_kind: renderKind,
401
+ path_alias: alias,
402
+ });
403
+ }
404
+ // Per-action layout override: `$this->layout = '...'` inside the
405
+ // action body. Distinct entry from the controller-wide property.
406
+ const layoutAssignRe = /\$this->layout\s*=\s*['"]([^'"]+)['"]/g;
407
+ while ((match = layoutAssignRe.exec(action.source)) !== null) {
408
+ const layoutName = match[1];
409
+ const line = action.start_line +
410
+ (action.source.slice(0, match.index).match(/\n/g)?.length ?? 0);
411
+ layouts.push({
412
+ controller: ctrl.name,
413
+ action: action.name,
414
+ layout: layoutName,
415
+ layout_file: resolveLayoutFile(layoutName, ctrl.name, aliasMap, index),
416
+ set_at_line: line,
237
417
  });
238
418
  }
239
419
  }
240
420
  }
241
- return { mappings, total: mappings.length };
421
+ // Widget references scan ALL PHP symbols + all .php files at module
422
+ // level (views are file-scope code, not symbols).
423
+ if (includeWidgets) {
424
+ collectWidgetRefs(index, widgets);
425
+ }
426
+ // AssetBundle::register() — same scope.
427
+ if (includeBundles) {
428
+ collectAssetBundleRefs(index, assetBundles);
429
+ }
430
+ return {
431
+ mappings,
432
+ total: mappings.length,
433
+ layouts,
434
+ widgets,
435
+ asset_bundles: assetBundles,
436
+ };
437
+ }
438
+ /**
439
+ * Build a path-alias map from Yii::setAlias() calls + config-file aliases.
440
+ * Map is keyed by the alias INCLUDING the leading `@` (so callers can
441
+ * test membership cheaply against the input string).
442
+ *
443
+ * Default Yii2 aliases (`@app`, `@webroot`, etc.) are inferred from the
444
+ * repo root when not explicitly set, so a fresh project still gets a
445
+ * useful default map.
446
+ */
447
+ async function resolvePathAliases(index) {
448
+ const aliases = new Map();
449
+ // Default aliases. `@app` is the conventional Yii2 anchor — almost
450
+ // every project has it pointing at the repo root.
451
+ aliases.set("@app", ".");
452
+ // Scan main config files for explicit aliases. Both
453
+ // 'aliases' => ['@foo' => 'path']
454
+ // and
455
+ // Yii::setAlias('@foo', 'path');
456
+ // are recognized.
457
+ const configFiles = index.files.filter((f) => /config\/(?:web|main|console|api|backend|frontend|common)(?:[-_][\w-]+)?\.php$/.test(f.path));
458
+ for (const cf of configFiles) {
459
+ let source;
460
+ try {
461
+ source = await readFile(join(index.root, cf.path), "utf-8");
462
+ }
463
+ catch {
464
+ continue;
465
+ }
466
+ // `aliases` => ['@x' => 'path', ...]
467
+ const aliasArrayRe = /['"]aliases['"]\s*=>\s*\[([^\]]+)\]/g;
468
+ let m;
469
+ while ((m = aliasArrayRe.exec(source)) !== null) {
470
+ const inner = m[1];
471
+ const entryRe = /['"](@[\w/.-]+)['"]\s*=>\s*['"]([^'"]+)['"]/g;
472
+ let em;
473
+ while ((em = entryRe.exec(inner)) !== null) {
474
+ aliases.set(em[1], em[2]);
475
+ }
476
+ }
477
+ // Yii::setAlias('@x', 'path');
478
+ const setAliasRe = /Yii::setAlias\s*\(\s*['"](@[\w/.-]+)['"]\s*,\s*['"]([^'"]+)['"]/g;
479
+ while ((m = setAliasRe.exec(source)) !== null) {
480
+ aliases.set(m[1], m[2]);
481
+ }
482
+ }
483
+ return aliases;
484
+ }
485
+ /**
486
+ * Resolve a view name to a path within the indexed file set. Prefers
487
+ * explicit `@alias/...` resolution; otherwise falls back to the Yii2
488
+ * convention `views/<controller-id>/<view>.php`. Returns the resolved
489
+ * (path, alias) pair — alias is the leading `@token` if one was used.
490
+ *
491
+ * The resolved path is matched against `index.files` to confirm the view
492
+ * actually exists; if not, file is null but alias is still surfaced so
493
+ * callers can flag missing-file findings against external asset paths.
494
+ */
495
+ function resolveViewFile(viewName, controllerClass, aliases, index) {
496
+ // Path alias: `@app/views/...` or any alias-prefixed string.
497
+ if (viewName.startsWith("@")) {
498
+ const aliasMatch = /^(@[\w-]+)(\/.*)?$/.exec(viewName);
499
+ if (aliasMatch) {
500
+ const alias = aliasMatch[1];
501
+ const remainder = aliasMatch[2] ?? "";
502
+ const aliasTarget = aliases.get(alias);
503
+ if (aliasTarget !== undefined) {
504
+ const candidate = (aliasTarget.replace(/\/$/, "") + remainder).replace(/^\.\//, "");
505
+ const final = candidate.endsWith(".php") ? candidate : candidate + ".php";
506
+ const exists = index.files.some((f) => f.path === final || f.path.endsWith("/" + final));
507
+ return { file: exists ? final : null, alias };
508
+ }
509
+ return { file: null, alias };
510
+ }
511
+ }
512
+ // Path-style relative name: `subdir/foo` keeps the explicit subdir.
513
+ const isPath = viewName.includes("/");
514
+ const controllerId = pascalToKebab(controllerClass.replace(/Controller$/, ""));
515
+ const candidate = isPath
516
+ ? `views/${viewName}.php`
517
+ : `views/${controllerId}/${viewName}.php`;
518
+ const exists = index.files.some((f) => f.path === candidate || f.path.endsWith("/" + candidate));
519
+ return { file: exists ? candidate : null, alias: null };
520
+ }
521
+ function resolveLayoutFile(layoutName, controllerClass, aliases, index) {
522
+ // Layouts default to `views/layouts/<name>.php` rather than per-controller.
523
+ if (layoutName.startsWith("@")) {
524
+ const aliasMatch = /^(@[\w-]+)(\/.*)?$/.exec(layoutName);
525
+ if (aliasMatch) {
526
+ const aliasTarget = aliases.get(aliasMatch[1]);
527
+ if (!aliasTarget)
528
+ return null;
529
+ const remainder = aliasMatch[2] ?? "";
530
+ const candidate = (aliasTarget.replace(/\/$/, "") + remainder).replace(/^\.\//, "");
531
+ const final = candidate.endsWith(".php") ? candidate : candidate + ".php";
532
+ const exists = index.files.some((f) => f.path === final || f.path.endsWith("/" + final));
533
+ return exists ? final : null;
534
+ }
535
+ return null;
536
+ }
537
+ const isPath = layoutName.includes("/");
538
+ const candidate = isPath
539
+ ? `views/${layoutName}.php`
540
+ : `views/layouts/${layoutName}.php`;
541
+ const exists = index.files.some((f) => f.path === candidate || f.path.endsWith("/" + candidate));
542
+ return exists ? candidate : null;
543
+ void controllerClass; // referenced for future per-module path resolution
544
+ }
545
+ function collectWidgetRefs(index, out) {
546
+ // Build a quick parentId → class name map so we can attribute widget
547
+ // references to their containing class (when the widget lives inside
548
+ // a class method).
549
+ const idToClass = new Map();
550
+ for (const s of index.symbols) {
551
+ if (s.kind === "class") {
552
+ const id = s.id;
553
+ if (id)
554
+ idToClass.set(id, s.name);
555
+ }
556
+ }
557
+ // Yii2 widget API: `Widget::begin([...])` (followed by ::end()) and
558
+ // `Widget::widget([...])`. Both are method-call forms; we look for any
559
+ // CamelCase identifier ending in expected widget suffixes (Form, View,
560
+ // Pjax, Menu, Pager, Breadcrumbs, Modal) to filter out unrelated
561
+ // static calls. The bound list is pragmatic — the Yii2 ecosystem has
562
+ // hundreds of widget classes but ~95% match this suffix family.
563
+ const WIDGET_SUFFIXES = /(?:Form|View|Pjax|Menu|Pager|Breadcrumbs|Modal|GridView|ListView|DetailView|LinkPager|Captcha|Alert|Tabs|NavBar|Carousel|Dropdown|FileInput|DatePicker|RangeInput|Select2|TimePicker|Slider|MaskedInput|RadioButton|Tag)$/;
564
+ const re = /\b([A-Z][\w]*?)::(begin|widget)\s*\(/g;
565
+ for (const sym of index.symbols) {
566
+ if (!sym.source)
567
+ continue;
568
+ if (!sym.file.endsWith(".php"))
569
+ continue;
570
+ let m;
571
+ while ((m = re.exec(sym.source)) !== null) {
572
+ const widgetName = m[1];
573
+ const kindRaw = m[2];
574
+ if (!WIDGET_SUFFIXES.test(widgetName))
575
+ continue;
576
+ const callerClass = sym.parent ? idToClass.get(sym.parent) ?? null : null;
577
+ const callerMethod = sym.kind === "method" ? sym.name : null;
578
+ const line = sym.start_line +
579
+ (sym.source.slice(0, m.index).match(/\n/g)?.length ?? 0);
580
+ out.push({
581
+ widget: widgetName,
582
+ caller_class: callerClass,
583
+ caller_method: callerMethod,
584
+ file: sym.file,
585
+ line,
586
+ kind: kindRaw === "begin" ? "begin" : "widget",
587
+ });
588
+ }
589
+ }
590
+ }
591
+ function collectAssetBundleRefs(index, out) {
592
+ // `BundleClass::register($this)` — the canonical AssetBundle entry
593
+ // point. We capture the class name (last segment for FQCN forms).
594
+ const re = /\b([A-Z][\w\\]*?)::register\s*\(\s*\$this\b/g;
595
+ for (const sym of index.symbols) {
596
+ if (!sym.source)
597
+ continue;
598
+ if (!sym.file.endsWith(".php"))
599
+ continue;
600
+ let m;
601
+ while ((m = re.exec(sym.source)) !== null) {
602
+ const fqcn = m[1];
603
+ const last = fqcn.split(/[\\\\]+/).pop() ?? fqcn;
604
+ // Reject obvious false positives — `Yii::register()` doesn't exist
605
+ // but a few user classes might also expose `register($this)` for
606
+ // unrelated reasons. Filter to names that LOOK like an AssetBundle:
607
+ // suffix `Asset` is the universal Yii2 convention.
608
+ if (!/Asset(?:s|Bundle)?$/.test(last))
609
+ continue;
610
+ const line = sym.start_line +
611
+ (sym.source.slice(0, m.index).match(/\n/g)?.length ?? 0);
612
+ out.push({ bundle: last, file: sym.file, line });
613
+ }
614
+ }
242
615
  }
243
616
  export async function resolvePhpService(repo, options) {
244
617
  const index = await getCodeIndex(repo);
245
618
  if (!index)
246
619
  throw new Error(`Repository "${repo}" not found.`);
247
620
  const services = [];
248
- const configFiles = index.files.filter((f) => /config\/(web|console|main|db)\.php$/.test(f.path));
621
+ // Sprint 3: include `params*.php` only as suppress-source — those files
622
+ // hold flat key-value pairs that look like components but aren't. We also
623
+ // drop config/test*.php (intentionally divergent) and pick up the broader
624
+ // *-local.php and main-*.php variants (advanced template + per-env splits).
625
+ const configFiles = index.files.filter((f) => {
626
+ if (!f.path.endsWith(".php"))
627
+ return false;
628
+ if (/config\/test/.test(f.path))
629
+ return false;
630
+ return /config\/(?:web|console|main|db|api|backend|frontend|common)(?:[-_][\w-]+)?\.php$/.test(f.path);
631
+ });
632
+ // Track (name, class, source, configFile) tuples so we don't duplicate
633
+ // when the same component appears in both web.php and main-local.php.
634
+ const seen = new Set();
635
+ const dedupKey = (name, cls, sourceLabel, file) => `${sourceLabel}::${name}::${cls ?? "<factory>"}::${file}`;
636
+ const pushService = (s) => {
637
+ const key = dedupKey(s.name, s.class, s.source, s.config_file ?? "");
638
+ if (seen.has(key))
639
+ return;
640
+ seen.add(key);
641
+ services.push(s);
642
+ };
249
643
  for (const cf of configFiles) {
250
644
  let source;
251
645
  try {
@@ -255,7 +649,14 @@ export async function resolvePhpService(repo, options) {
255
649
  continue;
256
650
  }
257
651
  // Match component definitions: 'componentName' => ['class' => 'FQCN', ...]
258
- const componentRe = /['"]([\w-]+)['"]\s*=>\s*\[\s*['"]class['"]\s*=>\s*['"]([\w\\]+)['"]/g;
652
+ // Top-level components live under 'components' => [...]; module-scoped
653
+ // ones live under 'modules' => ['<id>' => ['components' => [...]]]. We
654
+ // don't try to distinguish here — every match is tagged via post-pass.
655
+ //
656
+ // The key pattern accepts both bare names ("db") and FQCNs
657
+ // ("app\\interfaces\\LoggerInterface") because container.singletons /
658
+ // container.definitions almost always use FQCNs as keys.
659
+ const componentRe = /['"]([\w\\-]+)['"]\s*=>\s*\[\s*['"]class['"]\s*=>\s*['"]([\w\\]+)['"]/g;
259
660
  let match;
260
661
  while ((match = componentRe.exec(source)) !== null) {
261
662
  const name = match[1];
@@ -270,17 +671,173 @@ export async function resolvePhpService(repo, options) {
270
671
  filePath = resolved.file_path;
271
672
  }
272
673
  catch { /* ignore */ }
273
- services.push({
674
+ // Best-effort source labeling: scan the prefix up to the match to see
675
+ // whether we're inside `'modules' => ['x' => ['components' => [...]]]`
676
+ // or `'container' => ['singletons' => [...]]`. This is fuzzy; the
677
+ // labeling failures fall back to "components".
678
+ const prefix = source.slice(0, match.index);
679
+ const sourceLabel = inferConfigSection(prefix);
680
+ pushService({
274
681
  name,
275
682
  class: cls,
276
683
  file: filePath,
277
684
  config_file: cf.path,
685
+ source: sourceLabel,
686
+ });
687
+ }
688
+ // DI container: `Yii::$container->set(InterfaceName::class, ImplName::class)`
689
+ // and the static `'container' => ['definitions' => [...]]` form. Both are
690
+ // common in Yii2 codebases that use interface-based DI.
691
+ const containerSetRe = /Yii::\$container->set\s*\(\s*([\w\\]+)::class\s*,\s*([\w\\]+)::class/g;
692
+ while ((match = containerSetRe.exec(source)) !== null) {
693
+ const iface = match[1];
694
+ const impl = match[2];
695
+ if (options?.service_name && iface !== options.service_name)
696
+ continue;
697
+ let filePath = null;
698
+ try {
699
+ const resolved = await resolvePhpNamespace(repo, impl);
700
+ if (resolved.exists)
701
+ filePath = resolved.file_path;
702
+ }
703
+ catch { /* ignore */ }
704
+ pushService({
705
+ name: iface,
706
+ class: impl,
707
+ file: filePath,
708
+ config_file: cf.path,
709
+ source: "container.set",
710
+ });
711
+ }
712
+ // Closure / factory: `'mailer' => function() { return new Mailer(); }`
713
+ // We can't statically resolve the produced class, so we surface the
714
+ // service name with class=null and is_factory=true so callers can
715
+ // either ignore them or flag them as needs-manual-review.
716
+ const factoryRe = /['"]([\w-]+)['"]\s*=>\s*function\s*\(/g;
717
+ while ((match = factoryRe.exec(source)) !== null) {
718
+ const name = match[1];
719
+ if (options?.service_name && name !== options.service_name)
720
+ continue;
721
+ pushService({
722
+ name,
723
+ class: null,
724
+ file: null,
725
+ config_file: cf.path,
726
+ source: "factory",
727
+ is_factory: true,
278
728
  });
279
729
  }
280
730
  }
281
731
  return { services, total: services.length };
282
732
  }
733
+ /**
734
+ * Sprint 3 helper: given the source prefix up to a component match, identify
735
+ * which Yii2 config section we're inside by walking the prefix forward with a
736
+ * bracket-balanced stack. Each `'KEY' => [` pushes KEY onto the stack; each
737
+ * matching `]` pops it. At the end of the prefix the stack tells us the
738
+ * exact nesting path, regardless of how many sibling sections came before.
739
+ *
740
+ * Why not regex: regex can't track balanced brackets. The previous version
741
+ * used non-greedy `[\\s\\S]*?` which incorrectly matched a `'modules' =>
742
+ * ['x' => [...]]` block that had already closed by the time we reached a
743
+ * `'container' => ['singletons' => ...]` later in the file.
744
+ *
745
+ * Returns one of:
746
+ * "module:<id>" — inside `'modules' => ['<id>' => ['components' => [<HERE>...
747
+ * "container.singletons" — inside `'container' => ['singletons' => [<HERE>...
748
+ * "container.definitions" — inside `'container' => ['definitions' => [<HERE>...
749
+ * "components" — fallback (top-level components or unknown)
750
+ *
751
+ * String literals (single + double quoted) and PHP comments are skipped so
752
+ * brackets inside them don't confuse the depth counter.
753
+ */
754
+ function inferConfigSection(prefix) {
755
+ const stack = [];
756
+ let depth = 0;
757
+ let i = 0;
758
+ while (i < prefix.length) {
759
+ const c = prefix[i];
760
+ // Skip comments
761
+ if (c === "/" && prefix[i + 1] === "/") {
762
+ const nl = prefix.indexOf("\n", i);
763
+ i = nl === -1 ? prefix.length : nl + 1;
764
+ continue;
765
+ }
766
+ if (c === "/" && prefix[i + 1] === "*") {
767
+ const end = prefix.indexOf("*/", i + 2);
768
+ i = end === -1 ? prefix.length : end + 2;
769
+ continue;
770
+ }
771
+ if (c === "#") {
772
+ const nl = prefix.indexOf("\n", i);
773
+ i = nl === -1 ? prefix.length : nl + 1;
774
+ continue;
775
+ }
776
+ // Look for `'KEY' => [` BEFORE the generic string-skip — otherwise the
777
+ // string-skip swallows the opening quote and we never push the key.
778
+ if (c === '"' || c === "'") {
779
+ const m = /^(['"])([\w\\-]+)\1\s*=>\s*\[/.exec(prefix.slice(i));
780
+ if (m) {
781
+ const keyName = m[2];
782
+ // Push at the new depth (after the bracket we're about to enter).
783
+ stack.push({ key: keyName, depth: depth + 1 });
784
+ depth++;
785
+ i += m[0].length;
786
+ continue;
787
+ }
788
+ // Plain string literal — skip past the closing quote.
789
+ const quote = c;
790
+ i++;
791
+ while (i < prefix.length) {
792
+ if (prefix[i] === "\\") {
793
+ i += 2;
794
+ continue;
795
+ }
796
+ if (prefix[i] === quote) {
797
+ i++;
798
+ break;
799
+ }
800
+ i++;
801
+ }
802
+ continue;
803
+ }
804
+ if (c === "[") {
805
+ depth++;
806
+ i++;
807
+ continue;
808
+ }
809
+ if (c === "]") {
810
+ depth--;
811
+ while (stack.length > 0 && stack[stack.length - 1].depth > depth) {
812
+ stack.pop();
813
+ }
814
+ i++;
815
+ continue;
816
+ }
817
+ i++;
818
+ }
819
+ // Read the live nesting path from the stack.
820
+ const keys = stack.map((f) => f.key);
821
+ // module:<id> when we're inside modules.<id>.components.<*>
822
+ const modIdx = keys.indexOf("modules");
823
+ if (modIdx !== -1 && keys.length >= modIdx + 3) {
824
+ const moduleId = keys[modIdx + 1];
825
+ const inner = keys[modIdx + 2];
826
+ if (inner === "components")
827
+ return `module:${moduleId}`;
828
+ }
829
+ const cIdx = keys.indexOf("container");
830
+ if (cIdx !== -1 && keys.length >= cIdx + 2) {
831
+ const sub = keys[cIdx + 1];
832
+ if (sub === "singletons")
833
+ return "container.singletons";
834
+ if (sub === "definitions")
835
+ return "container.definitions";
836
+ }
837
+ return "components";
838
+ }
283
839
  const PHP_SECURITY_CHECKS = [
840
+ // Original 8 checks
284
841
  { pattern: "sql-injection-php", severity: "critical" },
285
842
  { pattern: "xss-php", severity: "critical" },
286
843
  { pattern: "eval-php", severity: "critical" },
@@ -289,15 +846,45 @@ const PHP_SECURITY_CHECKS = [
289
846
  { pattern: "file-include-var", severity: "high" },
290
847
  { pattern: "unescaped-yii-view", severity: "high" },
291
848
  { pattern: "raw-query-yii", severity: "high" },
849
+ // Sprint 2 additions: Yii2- + PHP-specific patterns informed by tgm-panel
850
+ // db-audit + perf-audit findings, plus the gap analysis section 4 catalog.
851
+ { pattern: "yii-csrf-disabled", severity: "high" },
852
+ { pattern: "yii-debug-mode-prod", severity: "critical" },
853
+ { pattern: "yii-cookie-no-validation", severity: "high" },
854
+ { pattern: "yii-mass-assignment-unsafe", severity: "medium" },
855
+ { pattern: "yii-raw-sql-where", severity: "high" },
856
+ { pattern: "php-md5-password", severity: "high" },
857
+ { pattern: "php-rand-token", severity: "high" },
858
+ { pattern: "php-loose-comparison-secret", severity: "medium" },
859
+ { pattern: "yii-rbac-cached-permission", severity: "low" },
860
+ { pattern: "yii-no-row-level-locking", severity: "high" },
861
+ { pattern: "yii-config-hardcoded-secret", severity: "critical" },
862
+ { pattern: "yii-unbounded-all", severity: "medium" },
292
863
  ];
864
+ /**
865
+ * Patterns that hit code at module level (top-level `return [...]`,
866
+ * top-level `define(...)` calls in entry-point files) and therefore are
867
+ * NOT visible via `searchPatterns` — that helper iterates `index.symbols`,
868
+ * so files without any class/function/method produce zero hits. We scan
869
+ * these patterns by reading file content directly.
870
+ */
871
+ const FILE_LEVEL_PATTERNS = new Set([
872
+ "yii-debug-mode-prod",
873
+ "yii-cookie-no-validation",
874
+ "yii-config-hardcoded-secret",
875
+ ]);
293
876
  export async function phpSecurityScan(repo, options) {
294
877
  const selectedChecks = options?.checks
295
878
  ? PHP_SECURITY_CHECKS.filter((c) => options.checks.includes(c.pattern))
296
879
  : PHP_SECURITY_CHECKS;
297
880
  const findings = [];
298
881
  const summary = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
882
+ // Symbol-level scans run via the existing searchPatterns helper. Skip the
883
+ // file-level patterns here — they're handled below by a direct file read.
884
+ const symbolLevelChecks = selectedChecks.filter((c) => !FILE_LEVEL_PATTERNS.has(c.pattern));
885
+ const fileLevelChecks = selectedChecks.filter((c) => FILE_LEVEL_PATTERNS.has(c.pattern));
299
886
  // Run pattern checks in parallel
300
- const results = await Promise.all(selectedChecks.map((check) => searchPatterns(repo, check.pattern, {
887
+ const results = await Promise.all(symbolLevelChecks.map((check) => searchPatterns(repo, check.pattern, {
301
888
  file_pattern: options?.file_pattern ?? ".php",
302
889
  include_tests: false,
303
890
  }).then((r) => ({ check, result: r })).catch(() => null)));
@@ -317,12 +904,100 @@ export async function phpSecurityScan(repo, options) {
317
904
  summary.total++;
318
905
  }
319
906
  }
907
+ // File-level scan: read every PHP file once, run each file-level pattern
908
+ // against it. This catches top-level `define('YII_DEBUG', true)` and
909
+ // hardcoded literals in `return [...]` config arrays which never live
910
+ // inside a class or function.
911
+ if (fileLevelChecks.length > 0) {
912
+ const fileFindings = await runFileLevelChecks(repo, fileLevelChecks, options?.file_pattern);
913
+ for (const f of fileFindings) {
914
+ findings.push(f);
915
+ summary[f.severity]++;
916
+ summary.total++;
917
+ }
918
+ }
320
919
  return {
321
920
  findings,
322
921
  summary,
323
922
  checks_run: selectedChecks.map((c) => c.pattern),
324
923
  };
325
924
  }
925
+ async function runFileLevelChecks(repo, checks, filePattern) {
926
+ const index = await getCodeIndex(repo);
927
+ if (!index)
928
+ return [];
929
+ const { BUILTIN_PATTERNS } = await import("./pattern-tools.js");
930
+ const out = [];
931
+ const phpFiles = index.files.filter((f) => {
932
+ if (!f.path.endsWith(".php"))
933
+ return false;
934
+ if (filePattern && !f.path.includes(filePattern))
935
+ return false;
936
+ return true;
937
+ });
938
+ // Pull each pattern definition up-front. We want one regex object per
939
+ // check, not per file, to avoid re-compilation churn.
940
+ const compiled = checks
941
+ .map((check) => {
942
+ const def = BUILTIN_PATTERNS[check.pattern];
943
+ if (!def)
944
+ return null;
945
+ // Re-create the regex with /g so we can iterate matches across the
946
+ // whole file content. Built-in patterns are stored without /g because
947
+ // searchPatterns calls .exec() once per symbol.
948
+ const flags = (def.regex.flags.includes("g") ? "" : "g") + def.regex.flags;
949
+ return {
950
+ check,
951
+ regex: new RegExp(def.regex.source, flags),
952
+ fileIncludePattern: def.fileIncludePattern,
953
+ fileExcludePattern: def.fileExcludePattern,
954
+ };
955
+ })
956
+ .filter((c) => c !== null);
957
+ await Promise.all(phpFiles.map(async (file) => {
958
+ let content;
959
+ try {
960
+ content = await readFile(join(index.root, file.path), "utf-8");
961
+ }
962
+ catch {
963
+ return;
964
+ }
965
+ for (const c of compiled) {
966
+ if (c.fileIncludePattern && !c.fileIncludePattern.test(file.path))
967
+ continue;
968
+ if (c.fileExcludePattern && c.fileExcludePattern.test(file.path))
969
+ continue;
970
+ c.regex.lastIndex = 0;
971
+ let m;
972
+ while ((m = c.regex.exec(content)) !== null) {
973
+ const line = countLines(content, m.index);
974
+ out.push({
975
+ severity: c.check.severity,
976
+ pattern: c.check.pattern,
977
+ file: file.path,
978
+ line,
979
+ context: extractLine(content, m.index),
980
+ description: "",
981
+ });
982
+ }
983
+ }
984
+ }));
985
+ return out;
986
+ }
987
+ function countLines(source, idx) {
988
+ let line = 1;
989
+ for (let i = 0; i < idx; i++) {
990
+ if (source.charCodeAt(i) === 10)
991
+ line++;
992
+ }
993
+ return line;
994
+ }
995
+ function extractLine(source, idx) {
996
+ const start = source.lastIndexOf("\n", idx) + 1;
997
+ const end = source.indexOf("\n", idx);
998
+ const out = source.slice(start, end === -1 ? source.length : end);
999
+ return out.trim().slice(0, 200);
1000
+ }
326
1001
  // ---------------------------------------------------------------------------
327
1002
  // 7h. find_php_n_plus_one — detect foreach + relation access without ->with()
328
1003
  // ---------------------------------------------------------------------------
@@ -397,35 +1072,26 @@ export async function findPhpNPlusOne(repo, options) {
397
1072
  });
398
1073
  return findings.length >= limit;
399
1074
  };
400
- for (const sym of index.symbols) {
401
- if (sym.kind !== "method" || !sym.file.endsWith(".php") || !sym.source)
402
- continue;
403
- if (filePattern && !sym.file.includes(filePattern))
404
- continue;
405
- const src = sym.source;
1075
+ // Helper: scan a single chunk of source (a method body OR a view file) for
1076
+ // all 4 N+1 patterns. Returns true once `limit` is hit so the caller can
1077
+ // short-circuit.
1078
+ function scanChunk(file, methodName, src, startLine) {
406
1079
  const foreachRe = /foreach\s*\(\s*\$(\w+)\s+as\s+(?:\$\w+\s*=>\s*)?\$(\w+)\s*\)/g;
407
1080
  let fm;
408
1081
  while ((fm = foreachRe.exec(src)) !== null) {
409
1082
  const itemVar = fm[2];
410
1083
  const foreachIdx = fm.index;
411
1084
  const after = src.slice(foreachIdx);
412
- // Guard against double-counting: each distinct relation name reported
413
- // once per foreach, regardless of which pattern matched first.
414
1085
  const seen = new Set();
415
1086
  // Pattern 1 — property access: $item->profile
416
- // The negative lookahead `(?![\w(])` blocks both:
417
- // 1) following word chars (prevents backtracking to a partial capture
418
- // like `$item->getProfile()` matching "getProfil" as a property);
419
- // 2) an opening paren (excludes method calls, handled by pattern 2).
420
1087
  const propRe = new RegExp(`\\$${itemVar}->(\\w+)(?![\\w(])`, "g");
421
1088
  let m;
422
1089
  while ((m = propRe.exec(after)) !== null) {
423
- if (emitFinding({ file: sym.file, name: sym.name, source: src, start_line: sym.start_line }, foreachIdx, m[1], "foreach-access-without-with", seen)) {
424
- return { findings, total: findings.length };
1090
+ if (emitFinding({ file, name: methodName, source: src, start_line: startLine }, foreachIdx, m[1], "foreach-access-without-with", seen)) {
1091
+ return true;
425
1092
  }
426
1093
  }
427
1094
  // Pattern 2 — getter method call: $item->getProfile()
428
- // Normalize to bare relation name for both the dedup and the ->with() check.
429
1095
  const getterRe = new RegExp(`\\$${itemVar}->(get\\w+)\\s*\\(\\s*\\)`, "g");
430
1096
  while ((m = getterRe.exec(after)) !== null) {
431
1097
  const rawMethod = m[1];
@@ -434,19 +1100,84 @@ export async function findPhpNPlusOne(repo, options) {
434
1100
  const normalized = normalizeGetter(rawMethod);
435
1101
  if (!normalized || METHOD_CALL_BLOCKLIST.has(normalized.toLowerCase()))
436
1102
  continue;
437
- if (emitFinding({ file: sym.file, name: sym.name, source: src, start_line: sym.start_line }, foreachIdx, normalized, "foreach-getter-without-with", seen)) {
438
- return { findings, total: findings.length };
1103
+ if (emitFinding({ file, name: methodName, source: src, start_line: startLine }, foreachIdx, normalized, "foreach-getter-without-with", seen)) {
1104
+ return true;
439
1105
  }
440
1106
  }
441
- // Pattern 3 — chained access: $item->rel->sub (the first segment is the trigger)
1107
+ // Pattern 3 — chained access: $item->rel->sub
442
1108
  const chainRe = new RegExp(`\\$${itemVar}->(\\w+)->\\w`, "g");
443
1109
  while ((m = chainRe.exec(after)) !== null) {
444
- if (emitFinding({ file: sym.file, name: sym.name, source: src, start_line: sym.start_line }, foreachIdx, m[1], "foreach-chained-without-with", seen)) {
445
- return { findings, total: findings.length };
1110
+ if (emitFinding({ file, name: methodName, source: src, start_line: startLine }, foreachIdx, m[1], "foreach-chained-without-with", seen)) {
1111
+ return true;
1112
+ }
1113
+ }
1114
+ // Pattern 4 (Sprint 3) — explicit lookup in loop body. Inside the foreach,
1115
+ // a `Model::findOne(...)` / `Model::findAll(...)` / `->find()` is the
1116
+ // lazy-load smell — each iteration hits the database.
1117
+ //
1118
+ // We scan a bounded 2000-char window after the foreach header to keep
1119
+ // the regex cost predictable on large methods. A nested foreach inside
1120
+ // the window will still match on its own /g iteration, and the outer
1121
+ // `seen` set deduplicates so we never double-report a single class+method.
1122
+ const body = after.slice(0, Math.min(after.length, 2000));
1123
+ const findOneRe = /(\w+)::(findOne|findAll|find|findBySql)\s*\(/g;
1124
+ let lm;
1125
+ while ((lm = findOneRe.exec(body)) !== null) {
1126
+ const targetClass = lm[1];
1127
+ const method = lm[2];
1128
+ // Filter common false positives: top-level utility classes that
1129
+ // happen to expose static `find*` methods but aren't AR.
1130
+ if (targetClass === "Yii" ||
1131
+ targetClass === "ArrayHelper" ||
1132
+ targetClass === "self" ||
1133
+ targetClass === "static") {
1134
+ continue;
1135
+ }
1136
+ const synthetic = `${targetClass}::${method}`;
1137
+ if (emitFinding({ file, name: methodName, source: src, start_line: startLine }, foreachIdx, synthetic, "foreach-findone-in-loop", seen)) {
1138
+ return true;
446
1139
  }
447
1140
  }
448
1141
  }
1142
+ return false;
1143
+ }
1144
+ // Method-level scan (Patterns 1-4 inside class methods, the original surface).
1145
+ for (const sym of index.symbols) {
1146
+ if (sym.kind !== "method" || !sym.file.endsWith(".php") || !sym.source)
1147
+ continue;
1148
+ if (filePattern && !sym.file.includes(filePattern))
1149
+ continue;
1150
+ if (scanChunk(sym.file, sym.name, sym.source, sym.start_line)) {
1151
+ return { findings, total: findings.length };
1152
+ }
449
1153
  }
1154
+ // View-level scan (Sprint 3 Pattern 5) — Yii2 views/**/*.php files render
1155
+ // lists of models at module level. They're not class methods so they have
1156
+ // no symbol; scan the raw file content. `views/**/*.php` is the canonical
1157
+ // path; `_*.php` partials live at the same level.
1158
+ const viewFiles = index.files.filter((f) => {
1159
+ if (!f.path.endsWith(".php"))
1160
+ return false;
1161
+ if (filePattern && !f.path.includes(filePattern))
1162
+ return false;
1163
+ // Standard Yii2 view paths (basic + advanced + module-scoped layouts).
1164
+ return /(?:^|\/)(?:views|widgets|layouts)\//.test(f.path);
1165
+ });
1166
+ await Promise.all(viewFiles.map(async (file) => {
1167
+ if (findings.length >= limit)
1168
+ return;
1169
+ let content;
1170
+ try {
1171
+ content = await readFile(join(index.root, file.path), "utf-8");
1172
+ }
1173
+ catch {
1174
+ return;
1175
+ }
1176
+ // For views the "method name" is just the file basename — that's what
1177
+ // the caller sees in the finding when there is no enclosing function.
1178
+ const methodName = file.path.split("/").pop() ?? file.path;
1179
+ scanChunk(file.path, methodName, content, 1);
1180
+ }));
450
1181
  return { findings, total: findings.length };
451
1182
  }
452
1183
  /**
@@ -532,7 +1263,7 @@ const AUDIT_TIMEOUT = 8000;
532
1263
  export async function phpProjectAudit(repo, options) {
533
1264
  const startTime = Date.now();
534
1265
  const gates = [];
535
- const allChecks = ["security", "activerecord", "complexity", "dead_code", "patterns", "clones", "hotspots", "n_plus_one", "god_model"];
1266
+ const allChecks = ["security", "activerecord", "complexity", "dead_code", "patterns", "clones", "hotspots", "n_plus_one", "god_model", "yii_performance"];
536
1267
  const enabled = new Set(options?.checks ?? allChecks);
537
1268
  const fp = options?.file_pattern ?? ".php";
538
1269
  const secOpts = {};
@@ -557,6 +1288,55 @@ export async function phpProjectAudit(repo, options) {
557
1288
  tasks.push({ name: "n_plus_one", run: () => findPhpNPlusOne(repo, options?.file_pattern ? { file_pattern: options.file_pattern } : undefined) });
558
1289
  if (enabled.has("god_model"))
559
1290
  tasks.push({ name: "god_model", run: () => findPhpGodModel(repo) });
1291
+ if (enabled.has("yii_performance")) {
1292
+ // Sprint 7: 5 perf patterns sourced from tgm-panel performance-audit
1293
+ // findings. Run them through the file-level scanner alongside
1294
+ // file-level security patterns so module-level matches (configs,
1295
+ // entry-points, view files) are picked up. Each pattern uses its own
1296
+ // severity tier consistent with the perf-audit recommendations.
1297
+ const PERF_PATTERNS = [
1298
+ { pattern: "yii-translate-in-loop", severity: "medium" },
1299
+ { pattern: "yii-dbtarget-info-level", severity: "medium" },
1300
+ { pattern: "yii-find-with-large-then-filter", severity: "high" },
1301
+ { pattern: "yii-cache-no-ttl", severity: "low" },
1302
+ { pattern: "yii-no-batch-on-large", severity: "high" },
1303
+ ];
1304
+ tasks.push({
1305
+ name: "yii_performance",
1306
+ run: async () => {
1307
+ // We reuse the security scan plumbing (parallel pattern runs +
1308
+ // file-level fallback) but with the perf catalog. The result shape
1309
+ // matches PhpSecurityScanResult — caller treats it as informational.
1310
+ const findings = [];
1311
+ const summary = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
1312
+ const symbolResults = await Promise.all(PERF_PATTERNS.map((check) => searchPatterns(repo, check.pattern, {
1313
+ file_pattern: fp,
1314
+ include_tests: false,
1315
+ }).then((r) => ({ check, result: r })).catch(() => null)));
1316
+ for (const res of symbolResults) {
1317
+ if (!res)
1318
+ continue;
1319
+ for (const m of res.result.matches) {
1320
+ findings.push({
1321
+ severity: res.check.severity,
1322
+ pattern: res.check.pattern,
1323
+ file: m.file,
1324
+ line: m.start_line,
1325
+ context: m.context,
1326
+ description: "",
1327
+ });
1328
+ summary[res.check.severity]++;
1329
+ summary.total++;
1330
+ }
1331
+ }
1332
+ return {
1333
+ findings,
1334
+ summary,
1335
+ checks_run: PERF_PATTERNS.map((p) => p.pattern),
1336
+ };
1337
+ },
1338
+ });
1339
+ }
560
1340
  const settled = await Promise.allSettled(tasks.map(async (t) => {
561
1341
  const s = Date.now();
562
1342
  const r = await Promise.race([t.run(), new Promise((ok) => setTimeout(() => ok("TIMEOUT"), AUDIT_TIMEOUT))]);
@@ -599,6 +1379,8 @@ export async function phpProjectAudit(repo, options) {
599
1379
  count = result?.findings?.length ?? 0;
600
1380
  else if (name === "god_model")
601
1381
  count = result?.models?.length ?? 0;
1382
+ else if (name === "yii_performance")
1383
+ count = result?.findings?.length ?? 0;
602
1384
  if (name !== "activerecord")
603
1385
  totalFindings += count;
604
1386
  gates.push({ name, status: "ok", findings_count: count, duration_ms: ms });