codesift-mcp 0.7.0 → 0.8.2
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.
- package/README.md +3 -3
- package/dist/cli/git-hooks-installer.d.ts.map +1 -1
- package/dist/cli/git-hooks-installer.js +18 -5
- package/dist/cli/git-hooks-installer.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +53 -0
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/setup.d.ts +5 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +31 -5
- package/dist/cli/setup.js.map +1 -1
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -1
- package/dist/config.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +6 -1
- package/dist/instructions.js.map +1 -1
- package/dist/parser/extractors/hono.d.ts.map +1 -1
- package/dist/parser/extractors/hono.js +21 -13
- package/dist/parser/extractors/hono.js.map +1 -1
- package/dist/parser/extractors/php.d.ts +12 -0
- package/dist/parser/extractors/php.d.ts.map +1 -1
- package/dist/parser/extractors/php.js +440 -26
- package/dist/parser/extractors/php.js.map +1 -1
- package/dist/register-tool-loaders.d.ts +16 -0
- package/dist/register-tool-loaders.d.ts.map +1 -1
- package/dist/register-tool-loaders.js +26 -0
- package/dist/register-tool-loaders.js.map +1 -1
- package/dist/register-tools.d.ts +3 -1
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +354 -7
- package/dist/register-tools.js.map +1 -1
- package/dist/retrieval/codebase-retrieval.d.ts.map +1 -1
- package/dist/retrieval/codebase-retrieval.js +22 -0
- package/dist/retrieval/codebase-retrieval.js.map +1 -1
- package/dist/retrieval/retrieval-schemas.d.ts +4 -0
- package/dist/retrieval/retrieval-schemas.d.ts.map +1 -1
- package/dist/retrieval/semantic-handlers.js +1 -1
- package/dist/retrieval/semantic-handlers.js.map +1 -1
- package/dist/search/semantic.d.ts +21 -5
- package/dist/search/semantic.d.ts.map +1 -1
- package/dist/search/semantic.js +129 -4
- package/dist/search/semantic.js.map +1 -1
- package/dist/search/tool-ranker.js +1 -1
- package/dist/search/tool-ranker.js.map +1 -1
- package/dist/server-helpers.js +1 -1
- package/dist/server-helpers.js.map +1 -1
- package/dist/storage/index-store.d.ts.map +1 -1
- package/dist/storage/index-store.js +7 -5
- package/dist/storage/index-store.js.map +1 -1
- package/dist/storage/registry.d.ts +28 -4
- package/dist/storage/registry.d.ts.map +1 -1
- package/dist/storage/registry.js +126 -5
- package/dist/storage/registry.js.map +1 -1
- package/dist/storage/usage-stats.d.ts +2 -0
- package/dist/storage/usage-stats.d.ts.map +1 -1
- package/dist/storage/usage-stats.js +6 -0
- package/dist/storage/usage-stats.js.map +1 -1
- package/dist/tools/_helpers.d.ts.map +1 -1
- package/dist/tools/_helpers.js +14 -0
- package/dist/tools/_helpers.js.map +1 -1
- package/dist/tools/conversation-tools.js +1 -1
- package/dist/tools/conversation-tools.js.map +1 -1
- package/dist/tools/index-tools.d.ts +12 -0
- package/dist/tools/index-tools.d.ts.map +1 -1
- package/dist/tools/index-tools.js +52 -5
- package/dist/tools/index-tools.js.map +1 -1
- package/dist/tools/insights-tools.d.ts +137 -0
- package/dist/tools/insights-tools.d.ts.map +1 -0
- package/dist/tools/insights-tools.js +438 -0
- package/dist/tools/insights-tools.js.map +1 -0
- package/dist/tools/pattern-tools.d.ts +7 -0
- package/dist/tools/pattern-tools.d.ts.map +1 -1
- package/dist/tools/pattern-tools.js +287 -15
- package/dist/tools/pattern-tools.js.map +1 -1
- package/dist/tools/php-tools.d.ts +78 -4
- package/dist/tools/php-tools.d.ts.map +1 -1
- package/dist/tools/php-tools.js +824 -42
- package/dist/tools/php-tools.js.map +1 -1
- package/dist/tools/php8-compat-tools.d.ts +62 -0
- package/dist/tools/php8-compat-tools.d.ts.map +1 -0
- package/dist/tools/php8-compat-tools.js +287 -0
- package/dist/tools/php8-compat-tools.js.map +1 -0
- package/dist/tools/php8-migration-candidates-tools.d.ts +68 -0
- package/dist/tools/php8-migration-candidates-tools.d.ts.map +1 -0
- package/dist/tools/php8-migration-candidates-tools.js +476 -0
- package/dist/tools/php8-migration-candidates-tools.js.map +1 -0
- package/dist/tools/phpstan-baseline-tools.d.ts +62 -0
- package/dist/tools/phpstan-baseline-tools.d.ts.map +1 -0
- package/dist/tools/phpstan-baseline-tools.js +263 -0
- package/dist/tools/phpstan-baseline-tools.js.map +1 -0
- package/dist/tools/project-tools.d.ts +4 -2
- package/dist/tools/project-tools.d.ts.map +1 -1
- package/dist/tools/project-tools.js +19 -6
- package/dist/tools/project-tools.js.map +1 -1
- package/dist/tools/react-tools.d.ts +24 -0
- package/dist/tools/react-tools.d.ts.map +1 -1
- package/dist/tools/react-tools.js +292 -3
- package/dist/tools/react-tools.js.map +1 -1
- package/dist/tools/search-tools.d.ts.map +1 -1
- package/dist/tools/search-tools.js +35 -5
- package/dist/tools/search-tools.js.map +1 -1
- package/dist/tools/symbol-tools.d.ts.map +1 -1
- package/dist/tools/symbol-tools.js +4 -1
- package/dist/tools/symbol-tools.js.map +1 -1
- package/dist/tools/yii-console-tools.d.ts +69 -0
- package/dist/tools/yii-console-tools.d.ts.map +1 -0
- package/dist/tools/yii-console-tools.js +256 -0
- package/dist/tools/yii-console-tools.js.map +1 -0
- package/dist/tools/yii-migrations-tools.d.ts +79 -0
- package/dist/tools/yii-migrations-tools.d.ts.map +1 -0
- package/dist/tools/yii-migrations-tools.js +543 -0
- package/dist/tools/yii-migrations-tools.js.map +1 -0
- package/dist/tools/yii-modules-tools.d.ts +63 -0
- package/dist/tools/yii-modules-tools.d.ts.map +1 -0
- package/dist/tools/yii-modules-tools.js +201 -0
- package/dist/tools/yii-modules-tools.js.map +1 -0
- package/dist/tools/yii-rbac-tools.d.ts +89 -0
- package/dist/tools/yii-rbac-tools.d.ts.map +1 -0
- package/dist/tools/yii-rbac-tools.js +238 -0
- package/dist/tools/yii-rbac-tools.js.map +1 -0
- package/dist/tools/yii3-attribute-candidates-tools.d.ts +72 -0
- package/dist/tools/yii3-attribute-candidates-tools.d.ts.map +1 -0
- package/dist/tools/yii3-attribute-candidates-tools.js +301 -0
- package/dist/tools/yii3-attribute-candidates-tools.js.map +1 -0
- package/dist/tools/yii3-migration-tools.d.ts +74 -0
- package/dist/tools/yii3-migration-tools.d.ts.map +1 -0
- package/dist/tools/yii3-migration-tools.js +440 -0
- package/dist/tools/yii3-migration-tools.js.map +1 -0
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/constant-file-pattern.d.ts +3 -1
- package/dist/utils/constant-file-pattern.d.ts.map +1 -1
- package/dist/utils/constant-file-pattern.js +6 -4
- package/dist/utils/constant-file-pattern.js.map +1 -1
- package/dist/utils/heritage-edges.d.ts +16 -0
- package/dist/utils/heritage-edges.d.ts.map +1 -1
- package/dist/utils/heritage-edges.js +31 -10
- package/dist/utils/heritage-edges.js.map +1 -1
- package/dist/utils/source-stripper.d.ts +23 -0
- package/dist/utils/source-stripper.d.ts.map +1 -0
- package/dist/utils/source-stripper.js +239 -0
- package/dist/utils/source-stripper.js.map +1 -0
- package/dist/utils/tsconfig-paths.d.ts +2 -2
- package/dist/utils/tsconfig-paths.d.ts.map +1 -1
- package/dist/utils/tsconfig-paths.js +10 -4
- package/dist/utils/tsconfig-paths.js.map +1 -1
- package/dist/utils/wall-clock.d.ts +9 -0
- package/dist/utils/wall-clock.d.ts.map +1 -0
- package/dist/utils/wall-clock.js +19 -0
- package/dist/utils/wall-clock.js.map +1 -0
- package/package.json +1 -1
- package/rules/codesift.md +10 -3
- package/rules/codesift.mdc +10 -3
- package/rules/codex.md +10 -3
- package/rules/gemini.md +10 -3
package/dist/tools/php-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
177
|
-
|
|
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
|
|
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
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
424
|
-
return
|
|
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
|
|
438
|
-
return
|
|
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
|
|
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
|
|
445
|
-
return
|
|
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 });
|