srclawyer 0.5.0 → 0.6.0

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.
@@ -1,6 +1,8 @@
1
1
  import { relative } from "node:path";
2
+ import _traverse from "@babel/traverse";
2
3
  import { redactSecrets } from "../engine/secretRedaction.js";
3
4
  import { parseSource } from "../engine/astUtils.js";
5
+ const traverse = (_traverse.default ?? _traverse);
4
6
  /**
5
7
  * The real Zod schema/anchor detection (which call sites count as "this validates incoming request
6
8
  * data" vs. a third-party API response or an env-var schema — the actual scope-boundary reasoning)
@@ -16,9 +18,30 @@ import { parseSource } from "../engine/astUtils.js";
16
18
  * statements instead guarantees every included chunk is independently valid, so concatenating a subset
17
19
  * of them is always a valid Program — no re-implementation of the protected scope-boundary reasoning
18
20
  * is needed to get that guarantee, just "which top-level statement is this line part of."
21
+ *
22
+ * Within the anchor's own enclosing statement, any NESTED function body that doesn't itself contain an
23
+ * anchor match is elided down to an empty block (`{}`) rather than sent whole: the schema judgment only
24
+ * needs the schema and the shape of the anchor call (`X.inputSchema(Y)` / `Y.parse(...)`), never the
25
+ * unrelated business logic a real handler wraps around it (formbricks' signup action alone was ~130
26
+ * lines of that). Eliding is itself statement-boundary-based (a whole function body, start to end), so
27
+ * it can't produce invalid syntax the way an arbitrary line cut could.
19
28
  */
20
29
  const ZOD_IMPORT_RE = /from\s+["']zod(?:\/v[34](?:-mini)?)?["']/;
21
30
  const ANCHOR_CALL_RE = /\.(?:parse|safeParse|parseAsync|safeParseAsync|inputSchema)\s*\(/;
31
+ /**
32
+ * A hard ceiling, not a normal operating limit: protectedLogicClient.ts batches by total bytes, so a
33
+ * large fragment is sent ALONE in its own request rather than bundled with others -- the actual
34
+ * per-batch CPU risk that caused intermittent "Worker exceeded CPU time limit" failures (confirmed via
35
+ * wrangler tail) is handled there, not here. This constant exists only to refuse something pathological
36
+ * (e.g. a machine-generated file with one enormous schema), not to cap ordinary real-world files -- and
37
+ * trimmed extraction (above) means ordinary files need it even less than before.
38
+ *
39
+ * Measured (uncapped, pre-trimming) across the three golden-corpus repos: max 41,992 bytes
40
+ * (formbricks/packages/types/surveys/types.ts). This value is PROVISIONAL and unvalidated against the
41
+ * real Worker limit: that depends on a Cloudflare plan upgrade that hasn't happened yet. Revisit once
42
+ * the upgrade is confirmed and the 10-run wrangler tail test has been repeated.
43
+ */
44
+ export const MAX_FRAGMENT_BYTES = 65_536;
22
45
  // Resolving "which identifier does this anchor call reference, and where is IT declared at the top
23
46
  // level" is plain lexical lookup, not the protected scope-boundary judgment — safe to do locally so a
24
47
  // schema declared far from its anchor (very common: define near the top, use in an export lower down)
@@ -30,6 +53,9 @@ function lineRange(node) {
30
53
  return null;
31
54
  return [node.loc.start.line - 1, node.loc.end.line - 1]; // 0-based, inclusive
32
55
  }
56
+ function containsLine(range, line0) {
57
+ return line0 >= range[0] && line0 <= range[1];
58
+ }
33
59
  function findEnclosingStatement(body, line0) {
34
60
  return body.find((stmt) => stmt.loc && stmt.loc.start.line - 1 <= line0 && line0 <= stmt.loc.end.line - 1);
35
61
  }
@@ -37,9 +63,51 @@ function findDeclarationStatement(body, name) {
37
63
  return body.find((stmt) => stmt.type === "VariableDeclaration" &&
38
64
  stmt.declarations.some((d) => d.id.type === "Identifier" && d.id.name === name));
39
65
  }
66
+ /**
67
+ * Within `root`'s own line range, finds every function body that contains none of `protectedLines`
68
+ * (the real anchor match lines) and returns the set of its INNER line numbers (never the lines holding
69
+ * the opening/closing brace themselves, so the brace pair that keeps the surrounding statement valid is
70
+ * always kept). A function that DOES contain a protected line is left alone -- its content is exactly
71
+ * the point of sending this fragment -- but still descended into, in case it wraps a further, genuinely
72
+ * unrelated nested closure of its own.
73
+ */
74
+ function findElidableInnerLines(file, rootRange, protectedLines) {
75
+ const elidable = new Set();
76
+ // @babel/traverse requires a Program/File root (not an arbitrary sub-statement) -- traverse the
77
+ // whole file and use rootRange to scope the search to just the one enclosing statement.
78
+ traverse(file, {
79
+ Function(path) {
80
+ const body = path.node.body;
81
+ if (body.type !== "BlockStatement")
82
+ return; // concise arrow body (`() => x`) -- nothing to strip
83
+ const bodyRange = lineRange(body);
84
+ if (!bodyRange)
85
+ return;
86
+ if (bodyRange[0] < rootRange[0] || bodyRange[1] > rootRange[1])
87
+ return; // outside root entirely
88
+ // Only the lines STRICTLY BETWEEN the braces count as "inside" this block for protection
89
+ // purposes -- the opening-brace line very often also carries the anchor itself (formbricks'
90
+ // `actionClient.inputSchema(X).action(async () => {` is one line), and that anchor is textually
91
+ // before the `{`, not inside the block it introduces. Using the full inclusive range here would
92
+ // make every such block look "protected" and defeat elision for exactly the common case this
93
+ // exists to handle.
94
+ const innerRange = [bodyRange[0] + 1, bodyRange[1] - 1];
95
+ if (protectedLines.some((line0) => containsLine(innerRange, line0)))
96
+ return; // keep + descend further
97
+ for (let i = innerRange[0]; i <= innerRange[1]; i++)
98
+ elidable.add(i);
99
+ path.skip(); // the whole block is already elided; no need to look inside it
100
+ },
101
+ });
102
+ return elidable;
103
+ }
104
+ function importedLocalNames(decl) {
105
+ return decl.specifiers.map((spec) => spec.local.name);
106
+ }
40
107
  export function findZodCandidates(source, filePath, root) {
108
+ const none = { candidates: [], oversizedFiles: [] };
41
109
  if (!ZOD_IMPORT_RE.test(source))
42
- return [];
110
+ return none;
43
111
  const lines = source.split("\n");
44
112
  const matchLineIndexes = [];
45
113
  lines.forEach((line, i) => {
@@ -47,12 +115,13 @@ export function findZodCandidates(source, filePath, root) {
47
115
  matchLineIndexes.push(i);
48
116
  });
49
117
  if (matchLineIndexes.length === 0)
50
- return [];
118
+ return none;
51
119
  const ast = parseSource(source, filePath);
52
120
  if (!ast)
53
- return [];
121
+ return none;
54
122
  const body = ast.program.body;
55
123
  const includedLines = new Set();
124
+ const excludedLines = new Set();
56
125
  const includeRange = (range) => {
57
126
  if (!range)
58
127
  return;
@@ -61,31 +130,53 @@ export function findZodCandidates(source, filePath, root) {
61
130
  };
62
131
  for (const directive of ast.program.directives)
63
132
  includeRange(lineRange(directive));
64
- for (const stmt of body) {
65
- if (stmt.type === "ImportDeclaration")
66
- includeRange(lineRange(stmt));
67
- }
133
+ const processedStatements = new Set();
68
134
  // One candidate per file, not per match — a single fragment covering every anchor-shaped
69
- // statement plus the top-of-file imports, merged and deduped. Keeps this a single call per file
135
+ // statement plus the schemas it references, merged and deduped. Keeps this a single call per file
70
136
  // rather than one per match; the server already handles "multiple schemas in one fragment" naturally.
71
137
  for (const matchLine of matchLineIndexes) {
72
138
  const enclosing = findEnclosingStatement(body, matchLine);
73
139
  if (!enclosing)
74
140
  continue;
75
- includeRange(lineRange(enclosing));
141
+ if (!processedStatements.has(enclosing)) {
142
+ processedStatements.add(enclosing);
143
+ const stmtRange = lineRange(enclosing);
144
+ if (stmtRange) {
145
+ includeRange(stmtRange);
146
+ for (const line0 of findElidableInnerLines(ast, stmtRange, matchLineIndexes))
147
+ excludedLines.add(line0);
148
+ }
149
+ }
76
150
  const line = lines[matchLine];
77
151
  const referencedName = line.match(INPUT_SCHEMA_ARG_RE)?.[1] ?? line.match(PARSE_RECEIVER_RE)?.[1];
78
152
  if (!referencedName)
79
153
  continue;
80
154
  const declStmt = findDeclarationStatement(body, referencedName);
81
155
  if (declStmt)
82
- includeRange(lineRange(declStmt));
156
+ includeRange(lineRange(declStmt)); // the schema itself -- never elided internally
83
157
  }
84
158
  if (includedLines.size === 0)
85
- return [];
86
- const sortedIndexes = [...includedLines].sort((a, b) => a - b);
159
+ return none;
160
+ // Only the imports actually referenced by what's being sent -- not every import in the file. A
161
+ // simple whole-word scan of the (not yet import-inclusive) fragment text, not real scope resolution:
162
+ // this can only ever over-include (a name that merely appears in a comment or string still pulls its
163
+ // import in), never under-include a genuinely used binding, which is the safe direction to err in.
164
+ const coreIndexes = [...includedLines].filter((i) => !excludedLines.has(i));
165
+ const coreText = coreIndexes.map((i) => lines[i]).join("\n");
166
+ for (const stmt of body) {
167
+ if (stmt.type !== "ImportDeclaration")
168
+ continue;
169
+ const referenced = importedLocalNames(stmt).some((name) => new RegExp(`\\b${name}\\b`).test(coreText));
170
+ if (referenced)
171
+ includeRange(lineRange(stmt));
172
+ }
173
+ const sortedIndexes = [...includedLines].filter((i) => !excludedLines.has(i)).sort((a, b) => a - b);
87
174
  const fragment = sortedIndexes.map((i) => lines[i]).join("\n");
88
175
  const fragmentLineToSourceLine = sortedIndexes.map((i) => i + 1);
89
176
  const relPath = relative(root, filePath);
90
- return [{ id: relPath, codeFragment: redactSecrets(fragment), fragmentLineToSourceLine }];
177
+ const redacted = redactSecrets(fragment);
178
+ if (Buffer.byteLength(redacted, "utf8") > MAX_FRAGMENT_BYTES) {
179
+ return { candidates: [], oversizedFiles: [relPath] };
180
+ }
181
+ return { candidates: [{ id: relPath, codeFragment: redacted, fragmentLineToSourceLine }], oversizedFiles: [] };
91
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Static analysis engine that derives privacy policy documentation directly from source code",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "postinstall.cjs"
25
25
  ],
26
26
  "scripts": {
27
- "build": "tsc -p tsconfig.json",
27
+ "build": "tsc -p tsconfig.json && node scripts/copyGrammars.js",
28
28
  "prepare": "npm run build",
29
29
  "postinstall": "node postinstall.cjs",
30
30
  "dev": "tsx src/cli.ts",
@@ -41,6 +41,7 @@
41
41
  "fast-glob": "^3.3.2",
42
42
  "js-yaml": "^4.1.0",
43
43
  "typescript": "^5.6.2",
44
+ "web-tree-sitter": "^0.27.0",
44
45
  "yaml": "^2.5.1"
45
46
  },
46
47
  "devDependencies": {