@shrkcrft/structural-search 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/engine/apply-rewrite.d.ts +29 -0
  2. package/dist/engine/apply-rewrite.d.ts.map +1 -0
  3. package/dist/engine/apply-rewrite.js +68 -0
  4. package/dist/engine/match-pattern.d.ts +12 -0
  5. package/dist/engine/match-pattern.d.ts.map +1 -0
  6. package/dist/engine/match-pattern.js +150 -0
  7. package/dist/engine/plan-rewrite.d.ts +21 -0
  8. package/dist/engine/plan-rewrite.d.ts.map +1 -0
  9. package/dist/engine/plan-rewrite.js +187 -0
  10. package/dist/engine/run-search.d.ts +17 -0
  11. package/dist/engine/run-search.d.ts.map +1 -0
  12. package/dist/engine/run-search.js +157 -0
  13. package/dist/engine/sign-rewrite.d.ts +39 -0
  14. package/dist/engine/sign-rewrite.d.ts.map +1 -0
  15. package/dist/engine/sign-rewrite.js +87 -0
  16. package/dist/index.d.ts +13 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +13 -0
  19. package/dist/registry/pattern-registry-store.d.ts +46 -0
  20. package/dist/registry/pattern-registry-store.d.ts.map +1 -0
  21. package/dist/registry/pattern-registry-store.js +120 -0
  22. package/dist/registry/starter-patterns.d.ts +12 -0
  23. package/dist/registry/starter-patterns.d.ts.map +1 -0
  24. package/dist/registry/starter-patterns.js +84 -0
  25. package/dist/schema/match.d.ts +25 -0
  26. package/dist/schema/match.d.ts.map +1 -0
  27. package/dist/schema/match.js +1 -0
  28. package/dist/schema/pattern-registry.d.ts +52 -0
  29. package/dist/schema/pattern-registry.d.ts.map +1 -0
  30. package/dist/schema/pattern-registry.js +60 -0
  31. package/dist/schema/pattern.d.ts +85 -0
  32. package/dist/schema/pattern.d.ts.map +1 -0
  33. package/dist/schema/pattern.js +22 -0
  34. package/dist/schema/rewrite.d.ts +58 -0
  35. package/dist/schema/rewrite.d.ts.map +1 -0
  36. package/dist/schema/rewrite.js +1 -0
  37. package/dist/schema/signed-rewrite.d.ts +20 -0
  38. package/dist/schema/signed-rewrite.d.ts.map +1 -0
  39. package/dist/schema/signed-rewrite.js +1 -0
  40. package/package.json +52 -0
@@ -0,0 +1,29 @@
1
+ import type { IRewritePlan } from '../schema/rewrite.js';
2
+ export interface IApplyRewriteOptions {
3
+ projectRoot: string;
4
+ /** When true, just compute what would be written (no fs touch). */
5
+ dryRun?: boolean;
6
+ }
7
+ export interface IApplyRewriteResult {
8
+ filesChanged: number;
9
+ filesAttempted: number;
10
+ bytesWritten: number;
11
+ /** Files whose pre-existing content didn't match plan.edits[i].before. */
12
+ conflicts: readonly string[];
13
+ diagnostics: readonly string[];
14
+ }
15
+ /**
16
+ * Apply a rewrite plan to disk.
17
+ *
18
+ * Edits are applied in reverse offset order so earlier offsets stay
19
+ * valid as later edits shrink/grow the file. If the file content has
20
+ * changed since the plan was computed (any edit's `before` no longer
21
+ * matches the text at its position), the whole file is skipped and
22
+ * reported in `conflicts`. This is the safety mechanism — never
23
+ * silently overwrite drifted content.
24
+ *
25
+ * Dry-run mode (`--dry-run`) computes everything except the
26
+ * `writeFileSync`; useful for previewing what `apply` would do.
27
+ */
28
+ export declare function applyRewritePlan(plan: IRewritePlan, options: IApplyRewriteOptions): IApplyRewriteResult;
29
+ //# sourceMappingURL=apply-rewrite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply-rewrite.d.ts","sourceRoot":"","sources":["../../src/engine/apply-rewrite.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,oBAAoB,GAC5B,mBAAmB,CAgDrB"}
@@ -0,0 +1,68 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ /**
4
+ * Apply a rewrite plan to disk.
5
+ *
6
+ * Edits are applied in reverse offset order so earlier offsets stay
7
+ * valid as later edits shrink/grow the file. If the file content has
8
+ * changed since the plan was computed (any edit's `before` no longer
9
+ * matches the text at its position), the whole file is skipped and
10
+ * reported in `conflicts`. This is the safety mechanism — never
11
+ * silently overwrite drifted content.
12
+ *
13
+ * Dry-run mode (`--dry-run`) computes everything except the
14
+ * `writeFileSync`; useful for previewing what `apply` would do.
15
+ */
16
+ export function applyRewritePlan(plan, options) {
17
+ let filesChanged = 0;
18
+ let bytesWritten = 0;
19
+ const conflicts = [];
20
+ const diagnostics = [];
21
+ for (const f of plan.files) {
22
+ const abs = nodePath.resolve(options.projectRoot, f.path);
23
+ let text;
24
+ try {
25
+ text = readFileSync(abs, 'utf8');
26
+ }
27
+ catch (e) {
28
+ diagnostics.push(`${f.path}: read failed (${e.message})`);
29
+ continue;
30
+ }
31
+ // Verify every edit's `before` still matches.
32
+ let drifted = false;
33
+ for (const e of f.edits) {
34
+ const current = text.slice(e.start, e.end);
35
+ if (current !== e.before) {
36
+ drifted = true;
37
+ conflicts.push(f.path);
38
+ diagnostics.push(`${f.path}:${e.line}: expected "${e.before}" at offset ${e.start}, found "${current}" — skipping file`);
39
+ break;
40
+ }
41
+ }
42
+ if (drifted)
43
+ continue;
44
+ let next = text;
45
+ // Apply in reverse.
46
+ const sorted = [...f.edits].sort((a, b) => b.start - a.start);
47
+ for (const e of sorted) {
48
+ next = next.slice(0, e.start) + e.replacement + next.slice(e.end);
49
+ }
50
+ if (next === text)
51
+ continue;
52
+ if (!options.dryRun) {
53
+ writeFileSync(abs, next, 'utf8');
54
+ bytesWritten += Buffer.byteLength(next, 'utf8');
55
+ }
56
+ else {
57
+ bytesWritten += Buffer.byteLength(next, 'utf8');
58
+ }
59
+ filesChanged += 1;
60
+ }
61
+ return {
62
+ filesAttempted: plan.files.length,
63
+ filesChanged,
64
+ bytesWritten,
65
+ conflicts,
66
+ diagnostics,
67
+ };
68
+ }
@@ -0,0 +1,12 @@
1
+ import * as ts from 'typescript';
2
+ import type { StructuralPattern } from '../schema/pattern.js';
3
+ /**
4
+ * Test a single AST node against a pattern. Returns true when the node
5
+ * is the kind the pattern targets AND every declared constraint holds.
6
+ *
7
+ * Patterns are intentionally narrow — a missing constraint never blocks
8
+ * a match (only constraint failure does). The walker calls
9
+ * `matchPattern` for every node; the matcher dispatches by `kind`.
10
+ */
11
+ export declare function matchPattern(node: ts.Node, pattern: StructuralPattern): boolean;
12
+ //# sourceMappingURL=match-pattern.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match-pattern.d.ts","sourceRoot":"","sources":["../../src/engine/match-pattern.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC;AACjC,OAAO,KAAK,EAQV,iBAAiB,EAClB,MAAM,sBAAsB,CAAC;AAE9B;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAmB/E"}
@@ -0,0 +1,150 @@
1
+ import * as ts from 'typescript';
2
+ /**
3
+ * Test a single AST node against a pattern. Returns true when the node
4
+ * is the kind the pattern targets AND every declared constraint holds.
5
+ *
6
+ * Patterns are intentionally narrow — a missing constraint never blocks
7
+ * a match (only constraint failure does). The walker calls
8
+ * `matchPattern` for every node; the matcher dispatches by `kind`.
9
+ */
10
+ export function matchPattern(node, pattern) {
11
+ switch (pattern.kind) {
12
+ case 'Identifier':
13
+ return ts.isIdentifier(node) && matchIdentifier(node, pattern);
14
+ case 'StringLiteral':
15
+ return ts.isStringLiteral(node) && matchStringLiteral(node, pattern);
16
+ case 'CallExpression':
17
+ return ts.isCallExpression(node) && matchCallExpression(node, pattern);
18
+ case 'NewExpression':
19
+ return ts.isNewExpression(node) && matchNewExpression(node, pattern);
20
+ case 'ImportDeclaration':
21
+ return ts.isImportDeclaration(node) && matchImportDeclaration(node, pattern);
22
+ case 'ClassDeclaration':
23
+ return ts.isClassDeclaration(node) && matchClassDeclaration(node, pattern);
24
+ case 'Decorator':
25
+ return ts.isDecorator(node) && matchDecorator(node, pattern);
26
+ default:
27
+ return false;
28
+ }
29
+ }
30
+ function matchIdentifier(node, pat) {
31
+ if (pat.name && pat.name !== '*' && node.text !== pat.name)
32
+ return false;
33
+ if (pat.nameRegex) {
34
+ const re = new RegExp(pat.nameRegex);
35
+ if (!re.test(node.text))
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ function matchStringLiteral(node, pat) {
41
+ if (pat.text !== undefined && node.text !== pat.text)
42
+ return false;
43
+ if (pat.textRegex) {
44
+ const re = new RegExp(pat.textRegex);
45
+ if (!re.test(node.text))
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+ function matchCallExpression(node, pat) {
51
+ if (pat.callee) {
52
+ const callee = unwrapCallee(node.expression);
53
+ if (!callee || !ts.isIdentifier(callee))
54
+ return false;
55
+ if (!matchIdentifier(callee, pat.callee))
56
+ return false;
57
+ }
58
+ if (pat.argCount !== undefined && node.arguments.length !== pat.argCount)
59
+ return false;
60
+ if (pat.minArgs !== undefined && node.arguments.length < pat.minArgs)
61
+ return false;
62
+ return true;
63
+ }
64
+ function matchNewExpression(node, pat) {
65
+ if (pat.callee) {
66
+ const callee = unwrapCallee(node.expression);
67
+ if (!callee || !ts.isIdentifier(callee))
68
+ return false;
69
+ if (!matchIdentifier(callee, pat.callee))
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+ function matchImportDeclaration(node, pat) {
75
+ if (!ts.isStringLiteral(node.moduleSpecifier))
76
+ return false;
77
+ const spec = node.moduleSpecifier.text;
78
+ if (pat.from !== undefined && spec !== pat.from)
79
+ return false;
80
+ if (pat.fromRegex) {
81
+ const re = new RegExp(pat.fromRegex);
82
+ if (!re.test(spec))
83
+ return false;
84
+ }
85
+ if (pat.sideEffectOnly === true) {
86
+ if (node.importClause)
87
+ return false;
88
+ }
89
+ if (pat.importedName) {
90
+ const clause = node.importClause;
91
+ if (!clause)
92
+ return false;
93
+ if (clause.namedBindings &&
94
+ ts.isNamedImports(clause.namedBindings) &&
95
+ clause.namedBindings.elements.some((e) => e.name.text === pat.importedName || (e.propertyName && e.propertyName.text === pat.importedName))) {
96
+ return true;
97
+ }
98
+ if (clause.name && clause.name.text === pat.importedName)
99
+ return true;
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ function matchClassDeclaration(node, pat) {
105
+ if (pat.name !== undefined) {
106
+ if (!node.name || node.name.text !== pat.name)
107
+ return false;
108
+ }
109
+ if (pat.nameRegex) {
110
+ if (!node.name)
111
+ return false;
112
+ const re = new RegExp(pat.nameRegex);
113
+ if (!re.test(node.name.text))
114
+ return false;
115
+ }
116
+ if (pat.hasDecoratorNamed) {
117
+ const mods = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined;
118
+ if (!mods || mods.length === 0)
119
+ return false;
120
+ const found = mods.some((d) => decoratorIdentifierName(d) === pat.hasDecoratorNamed);
121
+ if (!found)
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+ function matchDecorator(node, pat) {
127
+ const name = decoratorIdentifierName(node);
128
+ if (pat.name !== undefined && name !== pat.name)
129
+ return false;
130
+ if (pat.isCall !== undefined) {
131
+ const isCall = ts.isCallExpression(node.expression);
132
+ if (pat.isCall !== isCall)
133
+ return false;
134
+ }
135
+ return true;
136
+ }
137
+ function decoratorIdentifierName(d) {
138
+ const expr = d.expression;
139
+ if (ts.isIdentifier(expr))
140
+ return expr.text;
141
+ if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression))
142
+ return expr.expression.text;
143
+ return undefined;
144
+ }
145
+ function unwrapCallee(expr) {
146
+ // Allow `Foo.bar(...)` to match callee Identifier `bar`.
147
+ if (ts.isPropertyAccessExpression(expr))
148
+ return expr.name;
149
+ return expr;
150
+ }
@@ -0,0 +1,21 @@
1
+ import { type IRewritePlan, type RewriteRecipe } from '../schema/rewrite.js';
2
+ import type { StructuralPattern } from '../schema/pattern.js';
3
+ export interface IPlanRewriteOptions {
4
+ projectRoot: string;
5
+ pattern: StructuralPattern;
6
+ recipe: RewriteRecipe;
7
+ /** Restrict to specific project-relative files. */
8
+ files?: readonly string[];
9
+ /** Cap on returned edits per file. Default 200. */
10
+ perFileLimit?: number;
11
+ /** Cap on total files included in the plan. Default 5000. */
12
+ fileLimit?: number;
13
+ }
14
+ /**
15
+ * Compute a rewrite plan: per-file edits keyed by character offset.
16
+ *
17
+ * Pure function — does NOT touch disk. `applyRewritePlan` (sibling)
18
+ * is the only write step, and it requires a plan as input.
19
+ */
20
+ export declare function planRewrite(options: IPlanRewriteOptions): IRewritePlan;
21
+ //# sourceMappingURL=plan-rewrite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan-rewrite.d.ts","sourceRoot":"","sources":["../../src/engine/plan-rewrite.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAkB9D,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,mDAAmD;IACnD,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CA+CtE"}
@@ -0,0 +1,187 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import * as ts from 'typescript';
4
+ import { STRUCTURAL_REWRITE_SCHEMA, } from "../schema/rewrite.js";
5
+ import { matchPattern } from "./match-pattern.js";
6
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
7
+ const SKIP_DIRS = new Set([
8
+ 'node_modules',
9
+ 'dist',
10
+ 'build',
11
+ 'coverage',
12
+ '.git',
13
+ '.sharkcraft',
14
+ '.next',
15
+ '.cache',
16
+ '.tmp-pack',
17
+ 'out',
18
+ 'target',
19
+ ]);
20
+ /**
21
+ * Compute a rewrite plan: per-file edits keyed by character offset.
22
+ *
23
+ * Pure function — does NOT touch disk. `applyRewritePlan` (sibling)
24
+ * is the only write step, and it requires a plan as input.
25
+ */
26
+ export function planRewrite(options) {
27
+ const perFileLimit = options.perFileLimit ?? 200;
28
+ const fileLimit = options.fileLimit ?? 5000;
29
+ const diagnostics = [];
30
+ validatePatternRecipeMatch(options.pattern, options.recipe, diagnostics);
31
+ const targets = options.files
32
+ ? options.files.map((f) => nodePath.resolve(options.projectRoot, f)).slice(0, fileLimit)
33
+ : walk(options.projectRoot).slice(0, fileLimit);
34
+ const files = [];
35
+ let totalEdits = 0;
36
+ let filesScanned = 0;
37
+ for (const abs of targets) {
38
+ filesScanned += 1;
39
+ let text;
40
+ try {
41
+ text = readFileSync(abs, 'utf8');
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ let sf;
47
+ try {
48
+ sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, pickScriptKind(abs));
49
+ }
50
+ catch (e) {
51
+ diagnostics.push(`${nodePath.relative(options.projectRoot, abs)}: parse failed (${e.message})`);
52
+ continue;
53
+ }
54
+ const rel = nodePath.relative(options.projectRoot, abs).split(nodePath.sep).join('/');
55
+ const edits = [];
56
+ visit(sf, sf, options.pattern, options.recipe, edits, perFileLimit);
57
+ if (edits.length === 0)
58
+ continue;
59
+ // Sort by start ascending (also the order the visitor produced
60
+ // them — depth-first, so this is essentially a no-op but defensive).
61
+ edits.sort((a, b) => a.start - b.start);
62
+ files.push({ path: rel, edits });
63
+ totalEdits += edits.length;
64
+ }
65
+ return {
66
+ schema: STRUCTURAL_REWRITE_SCHEMA,
67
+ pattern: options.pattern,
68
+ recipe: options.recipe,
69
+ filesScanned,
70
+ totalEdits,
71
+ files,
72
+ diagnostics,
73
+ };
74
+ }
75
+ function visit(node, sf, pattern, recipe, edits, perFileLimit) {
76
+ if (edits.length >= perFileLimit)
77
+ return;
78
+ if (matchPattern(node, pattern)) {
79
+ const edit = applyRecipe(node, sf, recipe);
80
+ if (edit)
81
+ edits.push(edit);
82
+ }
83
+ ts.forEachChild(node, (child) => visit(child, sf, pattern, recipe, edits, perFileLimit));
84
+ }
85
+ function applyRecipe(node, sf, recipe) {
86
+ switch (recipe.kind) {
87
+ case 'replace-identifier-name': {
88
+ if (!ts.isIdentifier(node))
89
+ return undefined;
90
+ return makeEdit(sf, node.getStart(sf), node.getEnd(), recipe.to);
91
+ }
92
+ case 'replace-call-callee': {
93
+ if (!ts.isCallExpression(node))
94
+ return undefined;
95
+ const callee = node.expression;
96
+ if (ts.isIdentifier(callee)) {
97
+ return makeEdit(sf, callee.getStart(sf), callee.getEnd(), recipe.to);
98
+ }
99
+ if (ts.isPropertyAccessExpression(callee)) {
100
+ return makeEdit(sf, callee.getStart(sf), callee.getEnd(), recipe.to);
101
+ }
102
+ return undefined;
103
+ }
104
+ case 'replace-import-from': {
105
+ if (!ts.isImportDeclaration(node))
106
+ return undefined;
107
+ const spec = node.moduleSpecifier;
108
+ if (!ts.isStringLiteral(spec))
109
+ return undefined;
110
+ // Replace just the inner string; preserve the quote characters.
111
+ const start = spec.getStart(sf) + 1;
112
+ const end = spec.getEnd() - 1;
113
+ return makeEdit(sf, start, end, recipe.to);
114
+ }
115
+ default:
116
+ return undefined;
117
+ }
118
+ }
119
+ function makeEdit(sf, start, end, replacement) {
120
+ const before = sf.text.slice(start, end);
121
+ if (before === replacement) {
122
+ // No-op edit; skip.
123
+ return { start, end, replacement, before, line: 0 };
124
+ }
125
+ const lineNo = sf.getLineAndCharacterOfPosition(start).line + 1;
126
+ return { start, end, replacement, before, line: lineNo };
127
+ }
128
+ function validatePatternRecipeMatch(pattern, recipe, diagnostics) {
129
+ const expected = {
130
+ 'replace-identifier-name': 'Identifier',
131
+ 'replace-call-callee': 'CallExpression',
132
+ 'replace-import-from': 'ImportDeclaration',
133
+ };
134
+ const want = expected[recipe.kind];
135
+ if (want !== pattern.kind) {
136
+ diagnostics.push(`recipe "${recipe.kind}" expects pattern kind "${want}" but got "${pattern.kind}" — no edits will be produced`);
137
+ }
138
+ }
139
+ function walk(root) {
140
+ const out = [];
141
+ const stack = [root];
142
+ while (stack.length > 0) {
143
+ const dir = stack.pop();
144
+ let entries;
145
+ try {
146
+ entries = readdirSync(dir);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ for (const name of entries) {
152
+ if (SKIP_DIRS.has(name))
153
+ continue;
154
+ if (name.startsWith('.') && name !== '.')
155
+ continue;
156
+ const full = nodePath.join(dir, name);
157
+ let st;
158
+ try {
159
+ st = statSync(full);
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ if (st.isDirectory()) {
165
+ stack.push(full);
166
+ continue;
167
+ }
168
+ if (!st.isFile())
169
+ continue;
170
+ if (!SOURCE_EXTS.has(nodePath.extname(full).toLowerCase()))
171
+ continue;
172
+ out.push(full);
173
+ }
174
+ }
175
+ return out.sort();
176
+ }
177
+ function pickScriptKind(absPath) {
178
+ const ext = nodePath.extname(absPath).toLowerCase();
179
+ switch (ext) {
180
+ case '.tsx': return ts.ScriptKind.TSX;
181
+ case '.jsx': return ts.ScriptKind.JSX;
182
+ case '.js':
183
+ case '.mjs':
184
+ case '.cjs': return ts.ScriptKind.JS;
185
+ default: return ts.ScriptKind.TS;
186
+ }
187
+ }
@@ -0,0 +1,17 @@
1
+ import type { IStructuralSearchResult } from '../schema/match.js';
2
+ import type { StructuralPattern } from '../schema/pattern.js';
3
+ export interface IRunSearchOptions {
4
+ projectRoot: string;
5
+ pattern: StructuralPattern;
6
+ /** Restrict to specific project-relative files (skips the walker). */
7
+ files?: readonly string[];
8
+ /** Cap on returned matches. Default 500. */
9
+ limit?: number;
10
+ }
11
+ /**
12
+ * Run a structural pattern against a project (or a file subset). Returns
13
+ * up to `limit` matches. Files that fail to parse get a diagnostic but
14
+ * do not abort the search.
15
+ */
16
+ export declare function runSearch(options: IRunSearchOptions): IStructuralSearchResult;
17
+ //# sourceMappingURL=run-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-search.d.ts","sourceRoot":"","sources":["../../src/engine/run-search.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAoB,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAkB9D,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;IAC3B,sEAAsE;IACtE,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,iBAAiB,GAAG,uBAAuB,CAuC7E"}
@@ -0,0 +1,157 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import * as ts from 'typescript';
4
+ import { matchPattern } from "./match-pattern.js";
5
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
6
+ const SKIP_DIRS = new Set([
7
+ 'node_modules',
8
+ 'dist',
9
+ 'build',
10
+ 'coverage',
11
+ '.git',
12
+ '.sharkcraft',
13
+ '.next',
14
+ '.cache',
15
+ '.tmp-pack',
16
+ 'out',
17
+ 'target',
18
+ ]);
19
+ /**
20
+ * Run a structural pattern against a project (or a file subset). Returns
21
+ * up to `limit` matches. Files that fail to parse get a diagnostic but
22
+ * do not abort the search.
23
+ */
24
+ export function runSearch(options) {
25
+ const { projectRoot, pattern } = options;
26
+ const limit = options.limit ?? 500;
27
+ const targets = options.files ? options.files.map((f) => nodePath.resolve(projectRoot, f)) : walk(projectRoot);
28
+ const matches = [];
29
+ const diagnostics = [];
30
+ let filesScanned = 0;
31
+ let truncated = false;
32
+ for (const abs of targets) {
33
+ if (truncated)
34
+ break;
35
+ filesScanned += 1;
36
+ let text;
37
+ try {
38
+ text = readFileSync(abs, 'utf8');
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ let sf;
44
+ try {
45
+ sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, pickScriptKind(abs));
46
+ }
47
+ catch (e) {
48
+ diagnostics.push(`${nodePath.relative(projectRoot, abs)}: parse failed (${e.message})`);
49
+ continue;
50
+ }
51
+ const rel = nodePath.relative(projectRoot, abs).split(nodePath.sep).join('/');
52
+ visit(sf, sf, rel, pattern, matches, limit);
53
+ if (matches.length >= limit) {
54
+ truncated = true;
55
+ }
56
+ }
57
+ return {
58
+ schema: 'sharkcraft.structural-search/v1',
59
+ pattern: { kind: pattern.kind, summary: summarisePattern(pattern) },
60
+ filesScanned,
61
+ matchCount: matches.length,
62
+ truncated,
63
+ matches,
64
+ diagnostics,
65
+ };
66
+ }
67
+ function visit(node, sf, relPath, pattern, out, limit) {
68
+ if (out.length >= limit)
69
+ return;
70
+ if (matchPattern(node, pattern)) {
71
+ const start = node.getStart(sf);
72
+ const { line, character } = sf.getLineAndCharacterOfPosition(start);
73
+ const end = Math.min(node.getEnd(), start + 200);
74
+ const excerpt = sf.text
75
+ .slice(start, end)
76
+ .replace(/\s+/g, ' ')
77
+ .trim()
78
+ .slice(0, 140);
79
+ out.push({
80
+ file: relPath,
81
+ line: line + 1,
82
+ column: character,
83
+ nodeKind: ts.SyntaxKind[node.kind] ?? String(node.kind),
84
+ excerpt,
85
+ });
86
+ }
87
+ ts.forEachChild(node, (child) => visit(child, sf, relPath, pattern, out, limit));
88
+ }
89
+ function walk(root) {
90
+ const out = [];
91
+ const stack = [root];
92
+ while (stack.length > 0) {
93
+ const dir = stack.pop();
94
+ let entries;
95
+ try {
96
+ entries = readdirSync(dir);
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ for (const name of entries) {
102
+ if (SKIP_DIRS.has(name))
103
+ continue;
104
+ if (name.startsWith('.') && name !== '.')
105
+ continue;
106
+ const full = nodePath.join(dir, name);
107
+ let st;
108
+ try {
109
+ st = statSync(full);
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ if (st.isDirectory()) {
115
+ stack.push(full);
116
+ continue;
117
+ }
118
+ if (!st.isFile())
119
+ continue;
120
+ if (!SOURCE_EXTS.has(nodePath.extname(full).toLowerCase()))
121
+ continue;
122
+ out.push(full);
123
+ }
124
+ }
125
+ return out.sort();
126
+ }
127
+ function pickScriptKind(absPath) {
128
+ const ext = nodePath.extname(absPath).toLowerCase();
129
+ switch (ext) {
130
+ case '.tsx': return ts.ScriptKind.TSX;
131
+ case '.jsx': return ts.ScriptKind.JSX;
132
+ case '.js':
133
+ case '.mjs':
134
+ case '.cjs': return ts.ScriptKind.JS;
135
+ default: return ts.ScriptKind.TS;
136
+ }
137
+ }
138
+ function summarisePattern(p) {
139
+ switch (p.kind) {
140
+ case 'Identifier':
141
+ return `Identifier name=${p.name ?? p.nameRegex ?? '*'}`;
142
+ case 'StringLiteral':
143
+ return `StringLiteral text=${p.text ?? p.textRegex ?? '*'}`;
144
+ case 'CallExpression':
145
+ return `CallExpression callee=${p.callee?.name ?? p.callee?.nameRegex ?? '*'}`;
146
+ case 'NewExpression':
147
+ return `NewExpression callee=${p.callee?.name ?? p.callee?.nameRegex ?? '*'}`;
148
+ case 'ImportDeclaration':
149
+ return `ImportDeclaration from=${p.from ?? p.fromRegex ?? '*'}`;
150
+ case 'ClassDeclaration':
151
+ return `ClassDeclaration name=${p.name ?? p.nameRegex ?? '*'} decorator=${p.hasDecoratorNamed ?? '-'}`;
152
+ case 'Decorator':
153
+ return `Decorator name=${p.name ?? '*'}`;
154
+ default:
155
+ return 'unknown';
156
+ }
157
+ }