@optave/codegraph 2.3.0 → 2.3.1-dev.1aeea34

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/src/parser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { Language, Parser } from 'web-tree-sitter';
4
+ import { Language, Parser, Query } from 'web-tree-sitter';
5
5
  import { warn } from './logger.js';
6
6
  import { getNative, loadNative } from './native.js';
7
7
 
@@ -38,6 +38,33 @@ function grammarPath(name) {
38
38
 
39
39
  let _initialized = false;
40
40
 
41
+ // Query cache for JS/TS/TSX extractors (populated during createParsers)
42
+ const _queryCache = new Map();
43
+
44
+ // Shared patterns for all JS/TS/TSX (class_declaration excluded — name type differs)
45
+ const COMMON_QUERY_PATTERNS = [
46
+ '(function_declaration name: (identifier) @fn_name) @fn_node',
47
+ '(variable_declarator name: (identifier) @varfn_name value: (arrow_function) @varfn_value)',
48
+ '(variable_declarator name: (identifier) @varfn_name value: (function_expression) @varfn_value)',
49
+ '(method_definition name: (property_identifier) @meth_name) @meth_node',
50
+ '(import_statement source: (string) @imp_source) @imp_node',
51
+ '(export_statement) @exp_node',
52
+ '(call_expression function: (identifier) @callfn_name) @callfn_node',
53
+ '(call_expression function: (member_expression) @callmem_fn) @callmem_node',
54
+ '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node',
55
+ '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node',
56
+ ];
57
+
58
+ // JS: class name is (identifier)
59
+ const JS_CLASS_PATTERN = '(class_declaration name: (identifier) @cls_name) @cls_node';
60
+
61
+ // TS/TSX: class name is (type_identifier), plus interface and type alias
62
+ const TS_EXTRA_PATTERNS = [
63
+ '(class_declaration name: (type_identifier) @cls_name) @cls_node',
64
+ '(interface_declaration name: (type_identifier) @iface_name) @iface_node',
65
+ '(type_alias_declaration name: (type_identifier) @type_name) @type_node',
66
+ ];
67
+
41
68
  export async function createParsers() {
42
69
  if (!_initialized) {
43
70
  await Parser.init();
@@ -51,6 +78,14 @@ export async function createParsers() {
51
78
  const parser = new Parser();
52
79
  parser.setLanguage(lang);
53
80
  parsers.set(entry.id, parser);
81
+ // Compile and cache tree-sitter Query for JS/TS/TSX extractors
82
+ if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) {
83
+ const isTS = entry.id === 'typescript' || entry.id === 'tsx';
84
+ const patterns = isTS
85
+ ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS]
86
+ : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN];
87
+ _queryCache.set(entry.id, new Query(lang, patterns.join('\n')));
88
+ }
54
89
  } catch (e) {
55
90
  if (entry.required) throw e;
56
91
  warn(
@@ -242,7 +277,9 @@ function wasmExtractSymbols(parsers, filePath, code) {
242
277
 
243
278
  const ext = path.extname(filePath);
244
279
  const entry = _extToLang.get(ext);
245
- return entry ? entry.extractor(tree, filePath) : null;
280
+ if (!entry) return null;
281
+ const query = _queryCache.get(entry.id) || null;
282
+ return entry.extractor(tree, filePath, query);
246
283
  }
247
284
 
248
285
  /**
package/src/queries.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { coChangeForFiles } from './cochange.js';
4
5
  import { findCycles } from './cycles.js';
5
6
  import { findDbPath, openReadonlyOrFail } from './db.js';
6
7
  import { debug } from './logger.js';
@@ -67,6 +68,8 @@ export const ALL_SYMBOL_KINDS = [
67
68
  'module',
68
69
  ];
69
70
 
71
+ export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf'];
72
+
70
73
  /**
71
74
  * Get all ancestor class names for a given class using extends edges.
72
75
  */
@@ -728,16 +731,34 @@ export function diffImpactData(customDbPath, opts = {}) {
728
731
  const affectedFiles = new Set();
729
732
  for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
730
733
 
734
+ // Look up historically coupled files from co-change data
735
+ let historicallyCoupled = [];
736
+ try {
737
+ db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
738
+ const changedFilesList = [...changedRanges.keys()];
739
+ const coResults = coChangeForFiles(changedFilesList, db, {
740
+ minJaccard: 0.3,
741
+ limit: 20,
742
+ noTests,
743
+ });
744
+ // Exclude files already found via static analysis
745
+ historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
746
+ } catch {
747
+ /* co_changes table doesn't exist — skip silently */
748
+ }
749
+
731
750
  db.close();
732
751
  return {
733
752
  changedFiles: changedRanges.size,
734
753
  newFiles: [...newFiles],
735
754
  affectedFunctions: functionResults,
736
755
  affectedFiles: [...affectedFiles],
756
+ historicallyCoupled,
737
757
  summary: {
738
758
  functionsChanged: affectedFunctions.length,
739
759
  callersAffected: allAffected.size,
740
760
  filesAffected: affectedFiles.size,
761
+ historicallyCoupledCount: historicallyCoupled.length,
741
762
  },
742
763
  };
743
764
  }
@@ -876,7 +897,7 @@ export function listFunctionsData(customDbPath, opts = {}) {
876
897
 
877
898
  let rows = db
878
899
  .prepare(
879
- `SELECT name, kind, file, line FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
900
+ `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
880
901
  )
881
902
  .all(...params);
882
903
 
@@ -1077,6 +1098,22 @@ export function statsData(customDbPath, opts = {}) {
1077
1098
  falsePositiveWarnings,
1078
1099
  };
1079
1100
 
1101
+ // Role distribution
1102
+ let roleRows;
1103
+ if (noTests) {
1104
+ const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
1105
+ const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
1106
+ const counts = {};
1107
+ for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
1108
+ roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
1109
+ } else {
1110
+ roleRows = db
1111
+ .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
1112
+ .all();
1113
+ }
1114
+ const roles = {};
1115
+ for (const r of roleRows) roles[r.role] = r.c;
1116
+
1080
1117
  db.close();
1081
1118
  return {
1082
1119
  nodes: { total: totalNodes, byKind: nodesByKind },
@@ -1086,6 +1123,7 @@ export function statsData(customDbPath, opts = {}) {
1086
1123
  hotspots,
1087
1124
  embeddings,
1088
1125
  quality,
1126
+ roles,
1089
1127
  };
1090
1128
  }
1091
1129
 
@@ -1182,6 +1220,22 @@ export function stats(customDbPath, opts = {}) {
1182
1220
  }
1183
1221
  }
1184
1222
 
1223
+ // Roles
1224
+ if (data.roles && Object.keys(data.roles).length > 0) {
1225
+ const total = Object.values(data.roles).reduce((a, b) => a + b, 0);
1226
+ console.log(`\nRoles: ${total} classified symbols`);
1227
+ const roleParts = Object.entries(data.roles)
1228
+ .sort((a, b) => b[1] - a[1])
1229
+ .map(([k, v]) => `${k} ${v}`);
1230
+ for (let i = 0; i < roleParts.length; i += 3) {
1231
+ const row = roleParts
1232
+ .slice(i, i + 3)
1233
+ .map((p) => p.padEnd(18))
1234
+ .join('');
1235
+ console.log(` ${row}`);
1236
+ }
1237
+ }
1238
+
1185
1239
  console.log();
1186
1240
  }
1187
1241
 
@@ -1649,6 +1703,7 @@ export function contextData(name, customDbPath, opts = {}) {
1649
1703
  kind: node.kind,
1650
1704
  file: node.file,
1651
1705
  line: node.line,
1706
+ role: node.role || null,
1652
1707
  endLine: node.end_line || null,
1653
1708
  source,
1654
1709
  signature,
@@ -1675,7 +1730,8 @@ export function context(name, customDbPath, opts = {}) {
1675
1730
 
1676
1731
  for (const r of data.results) {
1677
1732
  const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1678
- console.log(`\n# ${r.name} (${r.kind}) ${r.file}:${lineRange}\n`);
1733
+ const roleTag = r.role ? ` [${r.role}]` : '';
1734
+ console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`);
1679
1735
 
1680
1736
  // Signature
1681
1737
  if (r.signature) {
@@ -1787,6 +1843,7 @@ function explainFileImpl(db, target, getFileLines) {
1787
1843
  name: s.name,
1788
1844
  kind: s.kind,
1789
1845
  line: s.line,
1846
+ role: s.role || null,
1790
1847
  summary: fileLines ? extractSummary(fileLines, s.line) : null,
1791
1848
  signature: fileLines ? extractSignature(fileLines, s.line) : null,
1792
1849
  });
@@ -1907,6 +1964,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
1907
1964
  kind: node.kind,
1908
1965
  file: node.file,
1909
1966
  line: node.line,
1967
+ role: node.role || null,
1910
1968
  endLine: node.end_line || null,
1911
1969
  lineCount,
1912
1970
  summary,
@@ -2018,8 +2076,9 @@ export function explain(target, customDbPath, opts = {}) {
2018
2076
  console.log(`\n## Exported`);
2019
2077
  for (const s of r.publicApi) {
2020
2078
  const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
2079
+ const roleTag = s.role ? ` [${s.role}]` : '';
2021
2080
  const summary = s.summary ? ` -- ${s.summary}` : '';
2022
- console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
2081
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`);
2023
2082
  }
2024
2083
  }
2025
2084
 
@@ -2027,8 +2086,9 @@ export function explain(target, customDbPath, opts = {}) {
2027
2086
  console.log(`\n## Internal`);
2028
2087
  for (const s of r.internal) {
2029
2088
  const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
2089
+ const roleTag = s.role ? ` [${s.role}]` : '';
2030
2090
  const summary = s.summary ? ` -- ${s.summary}` : '';
2031
- console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
2091
+ console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`);
2032
2092
  }
2033
2093
  }
2034
2094
 
@@ -2045,9 +2105,10 @@ export function explain(target, customDbPath, opts = {}) {
2045
2105
  const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
2046
2106
  const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
2047
2107
  const summaryPart = r.summary ? ` | ${r.summary}` : '';
2108
+ const roleTag = r.role ? ` [${r.role}]` : '';
2048
2109
  const depthLevel = r._depth || 0;
2049
2110
  const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#');
2050
- console.log(`\n${indent}${heading} ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
2111
+ console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`);
2051
2112
  if (lineInfo || r.summary) {
2052
2113
  console.log(`${indent} ${lineInfo}${summaryPart}`);
2053
2114
  }
@@ -2134,6 +2195,7 @@ function whereSymbolImpl(db, target, noTests) {
2134
2195
  kind: node.kind,
2135
2196
  file: node.file,
2136
2197
  line: node.line,
2198
+ role: node.role || null,
2137
2199
  exported,
2138
2200
  uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
2139
2201
  };
@@ -2220,8 +2282,9 @@ export function where(target, customDbPath, opts = {}) {
2220
2282
 
2221
2283
  if (data.mode === 'symbol') {
2222
2284
  for (const r of data.results) {
2285
+ const roleTag = r.role ? ` [${r.role}]` : '';
2223
2286
  const tag = r.exported ? ' (exported)' : '';
2224
- console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}${tag}`);
2287
+ console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`);
2225
2288
  if (r.uses.length > 0) {
2226
2289
  const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
2227
2290
  console.log(` Used in: ${useStrs.join(', ')}`);
@@ -2250,6 +2313,81 @@ export function where(target, customDbPath, opts = {}) {
2250
2313
  console.log();
2251
2314
  }
2252
2315
 
2316
+ // ─── rolesData ──────────────────────────────────────────────────────────
2317
+
2318
+ export function rolesData(customDbPath, opts = {}) {
2319
+ const db = openReadonlyOrFail(customDbPath);
2320
+ const noTests = opts.noTests || false;
2321
+ const filterRole = opts.role || null;
2322
+ const filterFile = opts.file || null;
2323
+
2324
+ const conditions = ['role IS NOT NULL'];
2325
+ const params = [];
2326
+
2327
+ if (filterRole) {
2328
+ conditions.push('role = ?');
2329
+ params.push(filterRole);
2330
+ }
2331
+ if (filterFile) {
2332
+ conditions.push('file LIKE ?');
2333
+ params.push(`%${filterFile}%`);
2334
+ }
2335
+
2336
+ let rows = db
2337
+ .prepare(
2338
+ `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
2339
+ )
2340
+ .all(...params);
2341
+
2342
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
2343
+
2344
+ const summary = {};
2345
+ for (const r of rows) {
2346
+ summary[r.role] = (summary[r.role] || 0) + 1;
2347
+ }
2348
+
2349
+ db.close();
2350
+ return { count: rows.length, summary, symbols: rows };
2351
+ }
2352
+
2353
+ export function roles(customDbPath, opts = {}) {
2354
+ const data = rolesData(customDbPath, opts);
2355
+ if (opts.json) {
2356
+ console.log(JSON.stringify(data, null, 2));
2357
+ return;
2358
+ }
2359
+
2360
+ if (data.count === 0) {
2361
+ console.log('No classified symbols found. Run "codegraph build" first.');
2362
+ return;
2363
+ }
2364
+
2365
+ const total = data.count;
2366
+ console.log(`\nNode roles (${total} symbols):\n`);
2367
+
2368
+ const summaryParts = Object.entries(data.summary)
2369
+ .sort((a, b) => b[1] - a[1])
2370
+ .map(([role, count]) => `${role}: ${count}`);
2371
+ console.log(` ${summaryParts.join(' ')}\n`);
2372
+
2373
+ const byRole = {};
2374
+ for (const s of data.symbols) {
2375
+ if (!byRole[s.role]) byRole[s.role] = [];
2376
+ byRole[s.role].push(s);
2377
+ }
2378
+
2379
+ for (const [role, symbols] of Object.entries(byRole)) {
2380
+ console.log(`## ${role} (${symbols.length})`);
2381
+ for (const s of symbols.slice(0, 30)) {
2382
+ console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`);
2383
+ }
2384
+ if (symbols.length > 30) {
2385
+ console.log(` ... and ${symbols.length - 30} more`);
2386
+ }
2387
+ console.log();
2388
+ }
2389
+ }
2390
+
2253
2391
  export function fnImpact(name, customDbPath, opts = {}) {
2254
2392
  const data = fnImpactData(name, customDbPath, opts);
2255
2393
  if (opts.json) {
@@ -2309,9 +2447,20 @@ export function diffImpact(customDbPath, opts = {}) {
2309
2447
  console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`);
2310
2448
  if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`);
2311
2449
  }
2450
+ if (data.historicallyCoupled && data.historicallyCoupled.length > 0) {
2451
+ console.log('\n Historically coupled (not in static graph):\n');
2452
+ for (const c of data.historicallyCoupled) {
2453
+ const pct = `${(c.jaccard * 100).toFixed(0)}%`;
2454
+ console.log(
2455
+ ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`,
2456
+ );
2457
+ }
2458
+ }
2312
2459
  if (data.summary) {
2313
- console.log(
2314
- `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files\n`,
2315
- );
2460
+ let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`;
2461
+ if (data.summary.historicallyCoupledCount > 0) {
2462
+ summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`;
2463
+ }
2464
+ console.log(`${summaryLine}\n`);
2316
2465
  }
2317
2466
  }
package/src/registry.js CHANGED
@@ -136,12 +136,20 @@ export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
136
136
  * or that haven't been accessed within `ttlDays` days.
137
137
  * Returns an array of `{ name, path, reason }` for each pruned entry.
138
138
  */
139
- export function pruneRegistry(registryPath = REGISTRY_PATH, ttlDays = DEFAULT_TTL_DAYS) {
139
+ export function pruneRegistry(
140
+ registryPath = REGISTRY_PATH,
141
+ ttlDays = DEFAULT_TTL_DAYS,
142
+ excludeNames = [],
143
+ ) {
140
144
  const registry = loadRegistry(registryPath);
141
145
  const pruned = [];
142
146
  const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
147
+ const excludeSet = new Set(
148
+ excludeNames.filter((n) => typeof n === 'string' && n.trim().length > 0),
149
+ );
143
150
 
144
151
  for (const [name, entry] of Object.entries(registry.repos)) {
152
+ if (excludeSet.has(name)) continue;
145
153
  if (!fs.existsSync(entry.path)) {
146
154
  pruned.push({ name, path: entry.path, reason: 'missing' });
147
155
  delete registry.repos[name];
package/src/structure.js CHANGED
@@ -224,6 +224,100 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
224
224
  debug(`Structure: ${dirCount} directories, ${fileSymbols.size} files with metrics`);
225
225
  }
226
226
 
227
+ // ─── Node role classification ─────────────────────────────────────────
228
+
229
+ function median(sorted) {
230
+ if (sorted.length === 0) return 0;
231
+ const mid = Math.floor(sorted.length / 2);
232
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
233
+ }
234
+
235
+ export function classifyNodeRoles(db) {
236
+ const rows = db
237
+ .prepare(
238
+ `SELECT n.id, n.kind, n.file,
239
+ COALESCE(fi.cnt, 0) AS fan_in,
240
+ COALESCE(fo.cnt, 0) AS fan_out
241
+ FROM nodes n
242
+ LEFT JOIN (
243
+ SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
244
+ ) fi ON n.id = fi.target_id
245
+ LEFT JOIN (
246
+ SELECT source_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY source_id
247
+ ) fo ON n.id = fo.source_id
248
+ WHERE n.kind NOT IN ('file', 'directory')`,
249
+ )
250
+ .all();
251
+
252
+ if (rows.length === 0) {
253
+ return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
254
+ }
255
+
256
+ const exportedIds = new Set(
257
+ db
258
+ .prepare(
259
+ `SELECT DISTINCT e.target_id
260
+ FROM edges e
261
+ JOIN nodes caller ON e.source_id = caller.id
262
+ JOIN nodes target ON e.target_id = target.id
263
+ WHERE e.kind = 'calls' AND caller.file != target.file`,
264
+ )
265
+ .all()
266
+ .map((r) => r.target_id),
267
+ );
268
+
269
+ const nonZeroFanIn = rows
270
+ .filter((r) => r.fan_in > 0)
271
+ .map((r) => r.fan_in)
272
+ .sort((a, b) => a - b);
273
+ const nonZeroFanOut = rows
274
+ .filter((r) => r.fan_out > 0)
275
+ .map((r) => r.fan_out)
276
+ .sort((a, b) => a - b);
277
+
278
+ const medFanIn = median(nonZeroFanIn);
279
+ const medFanOut = median(nonZeroFanOut);
280
+
281
+ const updates = [];
282
+ const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
283
+
284
+ for (const row of rows) {
285
+ const highIn = row.fan_in >= medFanIn && row.fan_in > 0;
286
+ const highOut = row.fan_out >= medFanOut && row.fan_out > 0;
287
+ const isExported = exportedIds.has(row.id);
288
+
289
+ let role;
290
+ if (row.fan_in === 0 && !isExported) {
291
+ role = 'dead';
292
+ } else if (row.fan_in === 0 && isExported) {
293
+ role = 'entry';
294
+ } else if (highIn && !highOut) {
295
+ role = 'core';
296
+ } else if (highIn && highOut) {
297
+ role = 'utility';
298
+ } else if (!highIn && highOut) {
299
+ role = 'adapter';
300
+ } else {
301
+ role = 'leaf';
302
+ }
303
+
304
+ updates.push({ id: row.id, role });
305
+ summary[role]++;
306
+ }
307
+
308
+ const clearRoles = db.prepare('UPDATE nodes SET role = NULL');
309
+ const setRole = db.prepare('UPDATE nodes SET role = ? WHERE id = ?');
310
+
311
+ db.transaction(() => {
312
+ clearRoles.run();
313
+ for (const u of updates) {
314
+ setRole.run(u.role, u.id);
315
+ }
316
+ })();
317
+
318
+ return summary;
319
+ }
320
+
227
321
  // ─── Query functions (read-only) ──────────────────────────────────────
228
322
 
229
323
  /**