Skip to content

Commit 763553c

Browse files
refactor(analyzers): http/graphql の重複走査を endpoints.ts に集約し、GraphQL の選択解析を修正
- readSource / buildShortNameIndex を endpoints.ts に追加(try/catch 6 か所と 短縮名索引 2 か所の重複を解消) - ブレース対応の自前実装 3 か所を lex.ts の matchBrace に寄せた - selectionsOf が入れ子・引数名を root 選択として拾い、2 つ目以降の root 選択を落としていたのを修正(深さ計算が名前の枝に `{` を食われて壊れていた)
1 parent 7b8d578 commit 763553c

4 files changed

Lines changed: 176 additions & 108 deletions

File tree

src/analyzers/endpoints.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,38 @@
55
// 揃っている。個別に再パースせず、既存ノードの meta(file/line)から
66
// 「その呼び出し行を含む関数」を引くことで呼び出し元を特定する。
77

8-
import type { Ctx } from '../context.ts';
8+
import { readFileText, type Ctx } from '../context.ts';
9+
10+
/** ws 相対パスのソースを読む。読めないファイルは解析対象から外すだけで、走査は続ける。 */
11+
export function readSource(ctx: Ctx, wsRel: string): string | undefined {
12+
try {
13+
return readFileText(ctx, wsRel);
14+
} catch {
15+
return undefined;
16+
}
17+
}
18+
19+
/**
20+
* 関数ノードを「短い名前 → ノード id」で引ける索引。レシーバ/パッケージ修飾
21+
* (`UserHandler.Get`)は落として最後の要素だけを鍵にする。
22+
* lower=true なら鍵を小文字化する(GraphQL のフィールド名は大小が揺れるため)。
23+
*/
24+
export function buildShortNameIndex(ctx: Ctx, lower = false): Map<string, string[]> {
25+
const index = new Map<string, string[]>();
26+
for (const node of ctx.builder.nodes.values()) {
27+
if (node.kind !== 'func') continue;
28+
const label = node.label;
29+
const short = label.includes('.') ? label.slice(label.lastIndexOf('.') + 1) : label;
30+
const key = lower ? short.toLowerCase() : short;
31+
let list = index.get(key);
32+
if (!list) {
33+
list = [];
34+
index.set(key, list);
35+
}
36+
list.push(node.id);
37+
}
38+
return index;
39+
}
940

1041
/** ノード id からトップレベル(サービス/モジュール)の祖先 id を返す。 */
1142
export function topAncestorId(ctx: Ctx, id: string): string {

src/analyzers/graphql.ts

Lines changed: 92 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@
1414
// 無効化: strata.config.json の { "graphql": { "enabled": false } }
1515

1616
import * as path from 'node:path';
17-
import { chainBase, readFileText, type Ctx, type Project } from '../context.ts';
18-
import { countLines, makeLineFinder } from '../lex.ts';
19-
import { buildFuncIndex, enclosingNodeId, topAncestorId, type FuncIndex } from './endpoints.ts';
17+
import { chainBase, type Ctx } from '../context.ts';
18+
import { countLines, makeLineFinder, matchBrace } from '../lex.ts';
19+
import {
20+
buildFuncIndex,
21+
buildShortNameIndex,
22+
enclosingNodeId,
23+
readSource,
24+
topAncestorId,
25+
type FuncIndex,
26+
} from './endpoints.ts';
2027

2128
const ROOTS: Record<string, 'query' | 'mutation' | 'subscription'> = {
2229
Query: 'query',
@@ -38,18 +45,7 @@ function* typeBlocks(
3845
const re = /\b(extend\s+)?(type|interface)\s+(\w+)([^{}]*)\{/g;
3946
for (let m = re.exec(sdl); m; m = re.exec(sdl)) {
4047
const open = re.lastIndex - 1;
41-
let depth = 0;
42-
let end = -1;
43-
for (let i = open; i < sdl.length; i++) {
44-
if (sdl[i] === '{') depth++;
45-
else if (sdl[i] === '}') {
46-
depth--;
47-
if (depth === 0) {
48-
end = i;
49-
break;
50-
}
51-
}
52-
}
48+
const end = matchBrace(sdl, open);
5349
if (end < 0) continue;
5450
yield {
5551
kind: (m[1] ? 'extend ' : '') + m[3],
@@ -88,39 +84,82 @@ function entitiesOf(sdl: string): { owned: Record<string, string>; extended: Rec
8884
return { owned, extended };
8985
}
9086

87+
/** open の位置の括弧に対応する閉じ括弧の位置。見つからなければ -1。 */
88+
function matchParen(src: string, open: number): number {
89+
let depth = 0;
90+
for (let i = open; i < src.length; i++) {
91+
if (src[i] === '(') depth++;
92+
else if (src[i] === ')') {
93+
depth--;
94+
if (depth === 0) return i;
95+
}
96+
}
97+
return -1;
98+
}
99+
100+
/** `...Fragment` / `... on Type` を読み飛ばし、最後に読んだ位置を返す。 */
101+
function skipSpread(body: string, start: number): number {
102+
let i = start;
103+
while (i < body.length && body[i] === '.') i++;
104+
const name = /^\s*(\w+)/.exec(body.slice(i));
105+
if (!name) return i - 1;
106+
i += name[0].length;
107+
if (name[1] !== 'on') return i - 1;
108+
const type = /^\s*\w+/.exec(body.slice(i));
109+
return (type ? i + type[0].length : i) - 1;
110+
}
111+
112+
/**
113+
* 選択セットの本文から、最上位の選択フィールド名だけを取り出す。
114+
* 引数リスト `(...)`・ディレクティブ・コメント・文字列・入れ子の選択セットは読み飛ばす。
115+
* `alias: field` は field 側を採る(スキーマに存在するのは field のため)。
116+
* 制限: ルート直下のインラインフラグメント(`... on X { ... }`)の中身は数えない。
117+
*/
118+
function rootFields(body: string): string[] {
119+
const out: string[] = [];
120+
let depth = 0;
121+
for (let i = 0; i < body.length; i++) {
122+
const ch = body[i];
123+
if (ch === '{') depth++;
124+
else if (ch === '}') depth--;
125+
else if (ch === '(') {
126+
const close = matchParen(body, i);
127+
if (close < 0) break;
128+
i = close;
129+
} else if (ch === '"') {
130+
// 三重引用符も終端が `"` なので、次の引用符まで飛ばせば足りる
131+
while (++i < body.length && body[i] !== '"') if (body[i] === '\\') i++;
132+
} else if (ch === '#') {
133+
while (i < body.length && body[i] !== '\n') i++;
134+
} else if (ch === '.') {
135+
i = skipSpread(body, i);
136+
} else if (ch === '@' || ch === '$') {
137+
while (i + 1 < body.length && /\w/.test(body[i + 1])) i++; // ディレクティブ名・変数名
138+
} else if (/[A-Za-z_]/.test(ch)) {
139+
let j = i;
140+
while (j < body.length && /\w/.test(body[j])) j++;
141+
const word = body.slice(i, j);
142+
i = j - 1;
143+
// 直後が `:` ならエイリアス。実フィールド名は次の語なので、ここでは出さない
144+
let k = j;
145+
while (k < body.length && /\s/.test(body[k])) k++;
146+
if (body[k] === ':') continue;
147+
if (depth === 0 && !['on', 'true', 'false', 'null'].includes(word)) out.push(word);
148+
}
149+
}
150+
return out;
151+
}
152+
91153
/** クライアント側の操作テキストから、ルート直下の選択フィールド名を取り出す。 */
92154
function selectionsOf(op: string): Array<{ root: 'query' | 'mutation' | 'subscription'; name: string }> {
93155
const out: Array<{ root: 'query' | 'mutation' | 'subscription'; name: string }> = [];
94156
const re = /\b(query|mutation|subscription)\b[^{}]*\{/g;
95157
for (let m = re.exec(op); m; m = re.exec(op)) {
96158
const open = re.lastIndex - 1;
97-
let depth = 0;
98-
let end = -1;
99-
for (let i = open; i < op.length; i++) {
100-
if (op[i] === '{') depth++;
101-
else if (op[i] === '}') {
102-
depth--;
103-
if (depth === 0) {
104-
end = i;
105-
break;
106-
}
107-
}
108-
}
159+
const end = matchBrace(op, open);
109160
if (end < 0) continue;
110-
const body = op.slice(open + 1, end);
111-
// ルート直下(ネストの外側)の識別子だけを見る
112-
let depth2 = 0;
113-
const nameRe = /([A-Za-z_]\w*)\s*(\(|\{|:|\n|$)|([{}])/g;
114-
for (let f = nameRe.exec(body); f; f = nameRe.exec(body)) {
115-
if (f[3] === '{') depth2++;
116-
else if (f[3] === '}') depth2--;
117-
else if (depth2 === 0 && f[1]) {
118-
const nm = f[1];
119-
if (!['fragment', 'on', 'true', 'false', 'null'].includes(nm)) {
120-
out.push({ root: m[1] as 'query' | 'mutation' | 'subscription', name: nm });
121-
}
122-
}
123-
}
161+
const root = m[1] as 'query' | 'mutation' | 'subscription';
162+
for (const name of rootFields(op.slice(open + 1, end))) out.push({ root, name });
124163
re.lastIndex = end;
125164
}
126165
return out;
@@ -191,12 +230,8 @@ export function detectGraphql(ctx: Ctx): void {
191230
// 1) スキーマファイル(.graphql / .gql / .graphqls)
192231
for (const project of ctx.projects) {
193232
for (const wsRel of project.gqlFiles ?? []) {
194-
let src: string;
195-
try {
196-
src = readFileText(ctx, wsRel);
197-
} catch {
198-
continue;
199-
}
233+
const src = readSource(ctx, wsRel);
234+
if (src === undefined) continue;
200235
const dirname = path.posix.dirname(wsRel);
201236
const dir = dirname === '.' ? '' : dirname;
202237
const base = chainBase(ctx, project, dir);
@@ -217,12 +252,8 @@ export function detectGraphql(ctx: Ctx): void {
217252
// 2) コード中のインライン SDL(Apollo の typeDefs = gql`type Query { ... }`)
218253
for (const project of ctx.projects) {
219254
for (const wsRel of [...project.jsFiles, ...project.goFiles]) {
220-
let src: string;
221-
try {
222-
src = readFileText(ctx, wsRel);
223-
} catch {
224-
continue;
225-
}
255+
const src = readSource(ctx, wsRel);
256+
if (src === undefined) continue;
226257
if (!src.includes('type Query') && !src.includes('type Mutation') && !src.includes('extend type')) continue;
227258
const lineOf = makeLineFinder(src);
228259
for (const lit of gqlLiterals(src)) {
@@ -238,14 +269,7 @@ export function detectGraphql(ctx: Ctx): void {
238269
if (fields.length === 0 && entityUsers.length === 0) return;
239270

240271
// 3) リゾルバ実装 → impl 辺
241-
const funcByLabel = new Map<string, string[]>();
242-
for (const node of ctx.builder.nodes.values()) {
243-
if (node.kind !== 'func') continue;
244-
const short = node.label.includes('.') ? node.label.slice(node.label.lastIndexOf('.') + 1) : node.label;
245-
const key = short.toLowerCase();
246-
if (!funcByLabel.has(key)) funcByLabel.set(key, []);
247-
funcByLabel.get(key)!.push(node.id);
248-
}
272+
const funcByLabel = buildShortNameIndex(ctx, true);
249273
const linkImpl = (rootKind: 'query' | 'mutation' | 'subscription', name: string, funcId: string): void => {
250274
const cands = byName.get(`${rootKind}:${name.toLowerCase()}`) ?? [];
251275
if (cands.length === 1) ctx.builder.addEdge(cands[0].id, funcId, 'impl');
@@ -254,12 +278,8 @@ export function detectGraphql(ctx: Ctx): void {
254278
for (const project of ctx.projects) {
255279
// Go(gqlgen): func (r *queryResolver) Users(ctx context.Context, ...)
256280
for (const wsRel of project.goFiles) {
257-
let src: string;
258-
try {
259-
src = readFileText(ctx, wsRel);
260-
} catch {
261-
continue;
262-
}
281+
const src = readSource(ctx, wsRel);
282+
if (src === undefined) continue;
263283
if (!/Resolver\b/.test(src)) continue;
264284
const lineOf = makeLineFinder(src);
265285
const re = /func\s*\(\s*\w+\s+\*?(query|mutation|subscription)Resolver\s*\)\s*(\w+)\s*\(/gi;
@@ -271,29 +291,14 @@ export function detectGraphql(ctx: Ctx): void {
271291
}
272292
// TS/JS(Apollo): const resolvers = { Query: { users: ..., }, Mutation: { ... } }
273293
for (const wsRel of project.jsFiles) {
274-
let src: string;
275-
try {
276-
src = readFileText(ctx, wsRel);
277-
} catch {
278-
continue;
279-
}
294+
const src = readSource(ctx, wsRel);
295+
if (src === undefined) continue;
280296
if (!/\b(Query|Mutation|Subscription)\s*:\s*\{/.test(src)) continue;
281297
const lineOf = makeLineFinder(src);
282298
const blockRe = /\b(Query|Mutation|Subscription)\s*:\s*\{/g;
283299
for (let m = blockRe.exec(src); m; m = blockRe.exec(src)) {
284300
const open = blockRe.lastIndex - 1;
285-
let depth = 0;
286-
let end = -1;
287-
for (let i = open; i < src.length; i++) {
288-
if (src[i] === '{') depth++;
289-
else if (src[i] === '}') {
290-
depth--;
291-
if (depth === 0) {
292-
end = i;
293-
break;
294-
}
295-
}
296-
}
301+
const end = matchBrace(src, open);
297302
if (end < 0) continue;
298303
const body = src.slice(open + 1, end);
299304
const rootKind = ROOTS[m[1]];
@@ -319,12 +324,8 @@ export function detectGraphql(ctx: Ctx): void {
319324
// 4) クライアント操作 → graphql 辺
320325
for (const project of ctx.projects) {
321326
for (const wsRel of [...project.jsFiles, ...project.goFiles, ...project.pyFiles]) {
322-
let src: string;
323-
try {
324-
src = readFileText(ctx, wsRel);
325-
} catch {
326-
continue;
327-
}
327+
const src = readSource(ctx, wsRel);
328+
if (src === undefined) continue;
328329
if (!/\b(query|mutation|subscription)\b/.test(src)) continue;
329330
const lineOf = makeLineFinder(src);
330331
for (const lit of gqlLiterals(src)) {

src/analyzers/http.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,17 @@
1616
//
1717
// 無効化: strata.config.json の { "http": { "enabled": false } }
1818

19-
import { readFileText, type Ctx, type Project } from '../context.ts';
19+
import type { Ctx } from '../context.ts';
2020
import { makeLineFinder, matchBrace, stripSource } from '../lex.ts';
2121
import {
2222
buildFuncIndex,
23+
buildShortNameIndex,
2324
enclosingNodeId,
2425
looksLikeWebhook,
2526
normalizePath,
2627
pathKey,
28+
readSource,
2729
topAncestorId,
28-
type FuncIndex,
2930
} from './endpoints.ts';
3031

3132
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
@@ -418,13 +419,7 @@ export function detectHttp(ctx: Ctx): void {
418419
const calls: CallRec[] = [];
419420

420421
for (const project of ctx.projects) {
421-
const read = (rel: string): string | undefined => {
422-
try {
423-
return readFileText(ctx, rel);
424-
} catch {
425-
return undefined;
426-
}
427-
};
422+
const read = (rel: string): string | undefined => readSource(ctx, rel);
428423
for (const rel of project.goFiles) {
429424
const src = read(rel);
430425
if (src) scanGo(src, rel, routes, calls);
@@ -486,13 +481,7 @@ export function detectHttp(ctx: Ctx): void {
486481
}
487482

488483
// ハンドラ実装への impl 辺(同一サービス内で名前が一意に決まるときだけ)
489-
const funcByName = new Map<string, string[]>();
490-
for (const node of ctx.builder.nodes.values()) {
491-
if (node.kind !== 'func') continue;
492-
const short = node.label.includes('.') ? node.label.slice(node.label.lastIndexOf('.') + 1) : node.label;
493-
if (!funcByName.has(short)) funcByName.set(short, []);
494-
funcByName.get(short)!.push(node.id);
495-
}
484+
const funcByName = buildShortNameIndex(ctx);
496485
for (const { id, rec } of routeIds) {
497486
if (!rec.handler) continue;
498487
const parts = rec.handler.replace(/[()]/g, '').split('.');

0 commit comments

Comments
 (0)