srclawyer 0.4.1 → 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.
- package/README.md +28 -6
- package/dist/cloud/protectedLogicClient.d.ts +3 -0
- package/dist/cloud/protectedLogicClient.js +62 -20
- package/dist/cloud/resolveProtectedLogic.d.ts +1 -1
- package/dist/cloud/resolveProtectedLogic.js +15 -1
- package/dist/commands/init.js +14 -2
- package/dist/commands/scan.js +25 -7
- package/dist/config/config.d.ts +13 -1
- package/dist/config/config.js +19 -1
- package/dist/engine/discoverFiles.d.ts +1 -0
- package/dist/engine/discoverFiles.js +16 -2
- package/dist/engine/languageDetection.js +37 -7
- package/dist/engine/pythonAstUtils.d.ts +2 -0
- package/dist/engine/pythonAstUtils.js +44 -0
- package/dist/engine/scanEngine.js +42 -5
- package/dist/engine/types.d.ts +9 -0
- package/dist/grammars/LICENSE +21 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/llm/resolveProviderForTier.d.ts +1 -1
- package/dist/llm/resolveProviderForTier.js +3 -2
- package/dist/policy/generatePolicy.d.ts +2 -2
- package/dist/policy/generatePolicy.js +15 -2
- package/dist/policy/legalNotices.d.ts +5 -0
- package/dist/policy/legalNotices.js +29 -0
- package/dist/report/formatText.js +1 -1
- package/dist/rules/dataCategoryLexicon.js +9 -3
- package/dist/rules/frameworkRules.js +89 -12
- package/dist/rules/pythonFrameworkRules.d.ts +3 -0
- package/dist/rules/pythonFrameworkRules.js +136 -0
- package/dist/rules/pythonSdkRules.d.ts +3 -0
- package/dist/rules/pythonSdkRules.js +73 -0
- package/dist/rules/safeFieldNames.js +14 -0
- package/dist/rules/zodPreFilter.d.ts +21 -1
- package/dist/rules/zodPreFilter.js +104 -13
- package/package.json +3 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { PROCESSOR_PROFILES } from "./processorProfiles.js";
|
|
3
|
+
import { redactSecrets } from "../engine/secretRedaction.js";
|
|
4
|
+
/**
|
|
5
|
+
* Deliberately narrower than sdkRules.ts's JS table -- not every processor has a reliable
|
|
6
|
+
* import-name signal in Python's ecosystem:
|
|
7
|
+
* - Cognito: Python code almost always calls it through boto3, AWS's *general* SDK used for every
|
|
8
|
+
* AWS service (S3, DynamoDB, SQS, ...). Mapping a bare `import boto3` to "Amazon Cognito" would be
|
|
9
|
+
* wrong far more often than right; reliably detecting it needs finding the specific
|
|
10
|
+
* `boto3.client("cognito-idp")` call, not just the import -- a real gap, not covered here.
|
|
11
|
+
* - Google Analytics: no single well-known, unambiguous pip import root the way `stripe` or
|
|
12
|
+
* `twilio` are for their processors. Left out rather than guessed.
|
|
13
|
+
*/
|
|
14
|
+
const SDK_MATCHERS = [
|
|
15
|
+
{ processorKey: "stripe", moduleNames: ["stripe"] },
|
|
16
|
+
{ processorKey: "sendgrid", moduleNames: ["sendgrid"] },
|
|
17
|
+
{ processorKey: "twilio", moduleNames: ["twilio"] },
|
|
18
|
+
{ processorKey: "mixpanel", moduleNames: ["mixpanel"] },
|
|
19
|
+
{ processorKey: "auth0", moduleNames: ["auth0"] },
|
|
20
|
+
// PyPI package is "firebase-admin"; its import root is firebase_admin.
|
|
21
|
+
{ processorKey: "firebase", moduleNames: ["firebase_admin"] },
|
|
22
|
+
];
|
|
23
|
+
function matchProcessor(moduleRoot) {
|
|
24
|
+
return SDK_MATCHERS.find((matcher) => matcher.moduleNames.includes(moduleRoot));
|
|
25
|
+
}
|
|
26
|
+
/** The root package name from a dotted module path node (e.g. "twilio" from "twilio.rest"). */
|
|
27
|
+
function moduleRootName(moduleNameNode) {
|
|
28
|
+
if (!moduleNameNode)
|
|
29
|
+
return null;
|
|
30
|
+
if (moduleNameNode.type === "dotted_name") {
|
|
31
|
+
return moduleNameNode.child(0)?.text ?? null;
|
|
32
|
+
}
|
|
33
|
+
if (moduleNameNode.type === "aliased_import") {
|
|
34
|
+
return moduleRootName(moduleNameNode.childForFieldName("name"));
|
|
35
|
+
}
|
|
36
|
+
// relative_import ("from . import x") and anything else has no external package name.
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export function runPythonSdkRules(tree, filePath, source, root) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
const lines = source.split("\n");
|
|
42
|
+
const relPath = relative(root, filePath);
|
|
43
|
+
const seenProcessors = new Set();
|
|
44
|
+
const importNodes = [
|
|
45
|
+
...tree.rootNode.descendantsOfType("import_statement"),
|
|
46
|
+
...tree.rootNode.descendantsOfType("import_from_statement"),
|
|
47
|
+
];
|
|
48
|
+
for (const node of importNodes) {
|
|
49
|
+
const fieldName = node.type === "import_statement" ? "name" : "module_name";
|
|
50
|
+
const moduleRoot = moduleRootName(node.childForFieldName(fieldName));
|
|
51
|
+
if (!moduleRoot)
|
|
52
|
+
continue;
|
|
53
|
+
const matcher = matchProcessor(moduleRoot);
|
|
54
|
+
if (!matcher || seenProcessors.has(matcher.processorKey))
|
|
55
|
+
continue;
|
|
56
|
+
seenProcessors.add(matcher.processorKey);
|
|
57
|
+
const profile = PROCESSOR_PROFILES[matcher.processorKey];
|
|
58
|
+
const line = node.startPosition.row + 1;
|
|
59
|
+
const evidenceLine = lines[line - 1] ?? "";
|
|
60
|
+
findings.push({
|
|
61
|
+
id: `sdk:${matcher.processorKey}:${relPath}:${line}`,
|
|
62
|
+
dataCategories: profile.dataCategories,
|
|
63
|
+
processor: profile.name,
|
|
64
|
+
description: `${profile.name} integration detected. ${profile.disclosureNotes}`,
|
|
65
|
+
confidence: "high",
|
|
66
|
+
source: "sdk-rule",
|
|
67
|
+
location: { file: relPath, line, column: 0 },
|
|
68
|
+
evidence: redactSecrets(evidenceLine.trim()),
|
|
69
|
+
requiresReview: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// Exact-match only, deliberately narrow -- this is the one silent-drop path in the whole pipeline
|
|
2
|
+
// (a name on this list never becomes a finding at all, not even a low-confidence one flagged for
|
|
3
|
+
// review). Names that are ALSO plausible PII in some real API (e.g. "next"/"prev" as redirect
|
|
4
|
+
// targets, "since"/"until"/"before"/"after" as ambiguous short words) are deliberately left off even
|
|
5
|
+
// when they're common pagination/cursor names elsewhere, so they stay visible for manual review
|
|
6
|
+
// rather than being silently and permanently suppressed.
|
|
1
7
|
const SAFE_FIELD_NAMES = new Set([
|
|
2
8
|
"id",
|
|
3
9
|
"uuid",
|
|
@@ -16,6 +22,14 @@ const SAFE_FIELD_NAMES = new Set([
|
|
|
16
22
|
"total",
|
|
17
23
|
"sort",
|
|
18
24
|
"order",
|
|
25
|
+
"perpage",
|
|
26
|
+
"pagesize",
|
|
27
|
+
"cursor",
|
|
28
|
+
"nextcursor",
|
|
29
|
+
"prevcursor",
|
|
30
|
+
"sortby",
|
|
31
|
+
"orderby",
|
|
32
|
+
"sortorder",
|
|
19
33
|
]);
|
|
20
34
|
function normalize(fieldName) {
|
|
21
35
|
return fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hard ceiling, not a normal operating limit: protectedLogicClient.ts batches by total bytes, so a
|
|
3
|
+
* large fragment is sent ALONE in its own request rather than bundled with others -- the actual
|
|
4
|
+
* per-batch CPU risk that caused intermittent "Worker exceeded CPU time limit" failures (confirmed via
|
|
5
|
+
* wrangler tail) is handled there, not here. This constant exists only to refuse something pathological
|
|
6
|
+
* (e.g. a machine-generated file with one enormous schema), not to cap ordinary real-world files -- and
|
|
7
|
+
* trimmed extraction (above) means ordinary files need it even less than before.
|
|
8
|
+
*
|
|
9
|
+
* Measured (uncapped, pre-trimming) across the three golden-corpus repos: max 41,992 bytes
|
|
10
|
+
* (formbricks/packages/types/surveys/types.ts). This value is PROVISIONAL and unvalidated against the
|
|
11
|
+
* real Worker limit: that depends on a Cloudflare plan upgrade that hasn't happened yet. Revisit once
|
|
12
|
+
* the upgrade is confirmed and the 10-run wrangler tail test has been repeated.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_FRAGMENT_BYTES = 65536;
|
|
1
15
|
export interface ZodCandidate {
|
|
2
16
|
id: string;
|
|
3
17
|
codeFragment: string;
|
|
@@ -6,4 +20,10 @@ export interface ZodCandidate {
|
|
|
6
20
|
* since the fragment is a non-contiguous excerpt of the original file, not a raw slice. */
|
|
7
21
|
fragmentLineToSourceLine: number[];
|
|
8
22
|
}
|
|
9
|
-
export
|
|
23
|
+
export interface FindZodCandidatesResult {
|
|
24
|
+
candidates: ZodCandidate[];
|
|
25
|
+
/** Relative paths of files whose extracted fragment exceeded MAX_FRAGMENT_BYTES and was dropped
|
|
26
|
+
* rather than sent -- the caller must surface these, never drop them silently. */
|
|
27
|
+
oversizedFiles: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function findZodCandidates(source: string, filePath: string, root: string): FindZodCandidatesResult;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|