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
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Static analysis engine that derives privacy policy documentation directly from source code, and keeps it in sync as the code changes.
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> **Beta.** The CLI (`init`, `scan`), the Layer 1 static analysis engine, and markdown privacy policy generation all work today. JavaScript/TypeScript (Express, Next.js) is the most tested path; Python (Flask) is newer, with FastAPI and Django explicitly detected and flagged as unsupported rather than silently skipped — see [Supported languages and frameworks](#supported-languages-and-frameworks) and [Known gaps](#known-gaps) below. LLM-assisted reasoning for ambiguous findings (Layer 2) is available now under the bring-your-own-key tier, documented below as one optional step in `init` — a managed (hosted) tier is planned but not yet available.
|
|
6
6
|
>
|
|
7
|
-
> Scanning is manual-only (`srclawyer scan`, run on demand) — this is the free/solo tier's permanent behavior, not a placeholder. CI/CD
|
|
7
|
+
> Scanning is manual-only (`srclawyer scan`, run on demand) — this is the free/solo tier's permanent behavior, not a placeholder. Automatic CI/CD updating (a scan that runs itself on every PR, or on a schedule) isn't available yet; it's scoped for a future paid/team tier. See `PRODUCT_SPEC.md` Section 8.5 for the plan.
|
|
8
8
|
|
|
9
9
|
## Usage
|
|
10
10
|
|
|
@@ -63,18 +63,34 @@ If none of this resolves it, `npx srclawyer scan` (see [Usage](#usage) above) si
|
|
|
63
63
|
|
|
64
64
|
## What `scan` does today
|
|
65
65
|
|
|
66
|
-
- **Known SDK integrations** (import/require detection): Stripe, SendGrid, Twilio, Google Analytics, Mixpanel, Auth0, Firebase, Cognito — each mapped to the data categories it typically processes (`src/rules/processorProfiles.ts`).
|
|
67
|
-
- **Request-body field collection** in Express (`req.body.x`, destructured `req.body`) and Next.js (both `pages/api` and the app router's `await request.json()`), classified against a field-name lexicon (`src/rules/dataCategoryLexicon.ts`).
|
|
66
|
+
- **Known SDK integrations** (import/require detection): Stripe, SendGrid, Twilio, Google Analytics, Mixpanel, Auth0, Firebase, Cognito for JS/TS; Stripe, SendGrid, Twilio, Mixpanel, Auth0, Firebase for Python — each mapped to the data categories it typically processes (`src/rules/processorProfiles.ts`).
|
|
67
|
+
- **Request-body field collection** in Express (`req.body.x`, destructured `req.body`) and Next.js (both `pages/api` and the app router's `await request.json()`), and in **Flask** (`request.form`/`.json`/`.args`, both subscript and `.get(...)` forms), classified against a field-name lexicon (`src/rules/dataCategoryLexicon.ts`). In both languages, a value assigned to a local variable earlier in the *same function* (`const body = req.body` / `data = request.get_json()`) is tracked one mechanical hop, so a later `body.email` / `data['email']` is still caught — not just the direct, unaliased form.
|
|
68
68
|
- **OpenAPI/Swagger schemas** (`components.schemas.*.properties`) and **GraphQL SDL** type/input fields.
|
|
69
69
|
- **HTML forms and inline scripts** (`src/rules/htmlRules.ts`): native `<input>` elements classified by `type` (password/email/tel are unambiguous native signals) or by `id`/`name` against the lexicon; inline `<script>` blocks are extracted and run through the same SDK/framework/web-API rules as any other file.
|
|
70
70
|
- **Browser platform APIs** (`src/rules/webApiRules.ts`): `getUserMedia` (split into microphone and camera access), geolocation, and `localStorage`/`sessionStorage` writes classified by key name.
|
|
71
71
|
- Fields that can't be confidently classified (generic names like `data`, `payload`) are surfaced with `requiresReview: true` rather than silently guessed.
|
|
72
|
-
- **A field-name allowlist** (`src/rules/safeFieldNames.ts`) suppresses findings entirely for exact-match structural fields (`id`, `createdAt`, `status`,
|
|
73
|
-
- **Unsupported language/framework detection** (`src/engine/languageDetection.ts`) runs at the start of every scan: it checks for Django (`manage.py`), Rails (`Gemfile` + `app/controllers`), gRPC (`.proto` files), and falls back to a file-count ratio for
|
|
72
|
+
- **A field-name allowlist** (`src/rules/safeFieldNames.ts`) suppresses findings entirely for exact-match structural and pagination/sort fields (`id`, `createdAt`, `status`, `per_page`, `cursor`, `sort_by`, etc.) so "needs review" stays a signal, not noise from routine boilerplate. Anything not on the allowlist keeps the existing classify-or-flag behavior above.
|
|
73
|
+
- **Unsupported language/framework detection** (`src/engine/languageDetection.ts`) runs at the start of every scan: it checks for Django (`manage.py`), FastAPI (`from fastapi import` / `import fastapi`), Rails (`Gemfile` + `app/controllers`), gRPC (`.proto` files), and falls back to a file-count ratio for Ruby/Go/Java/PHP/C# vs JS/TS/HTML/Python. When it fires, a bordered warning appears at the top of the CLI output *and* at the top of the generated `PRIVACY_POLICY.md` (not just a log line easy to miss), the warning is included in `--json` output, and the process exits with code 1 so CI can't silently treat a scan of an unsupported codebase as a pass.
|
|
74
74
|
- **Generates a markdown privacy policy** (`src/policy/generatePolicy.ts`) in the same command: findings are grouped by data category, each clause cites the file:line that justified it, third-party processors get their own section, and anything flagged `requiresReview` is excluded from the body and listed separately under "Items Requiring Manual Review" instead of being guessed. The mandatory disclaimer (`src/policy/disclaimer.ts`) is always appended, unconditionally.
|
|
75
75
|
|
|
76
76
|
Every finding traces back to a file and line (`src/engine/types.ts#Finding`).
|
|
77
77
|
|
|
78
|
+
### Supported languages and frameworks
|
|
79
|
+
|
|
80
|
+
| Language | Frameworks | Status |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| JavaScript / TypeScript | Express, Next.js (pages + app router) | Supported |
|
|
83
|
+
| Python | Flask | Supported (beta — see "Known gaps" below) |
|
|
84
|
+
| Python | FastAPI, Django | Explicitly detected and flagged as unsupported, never silently skipped |
|
|
85
|
+
| Ruby, Go, Java, PHP, C#, gRPC | — | Explicitly detected and flagged as unsupported |
|
|
86
|
+
|
|
87
|
+
### Known gaps
|
|
88
|
+
|
|
89
|
+
- **Cross-function aliasing isn't tracked, in either language.** The same-function alias tracking described above follows exactly one assignment hop within the function it occurs in. If that value is passed on — as a function parameter, through a closure, via a module-level variable — a field read from it afterward is not detected. Example: a Flask handler reads `data = request.get_json()` and calls `User.from_dict(data)`; a field read as `data['password']` *inside* `from_dict` is not caught, only reads inside the original handler function are.
|
|
90
|
+
- **Data sent to a third party via a raw HTTP call isn't attributed to that processor.** SDK-integration detection (above) is anchored to known package imports. A call like Python's `requests.post("https://api.some-provider.com/...", json={...})` or JS's `fetch("https://api.some-provider.com/...")` — bypassing a dedicated SDK entirely — is invisible to this detection today, in both languages, regardless of how sensitive the data it sends is.
|
|
91
|
+
- **Python support is based on one real-world corpus repository so far** (a Flask app; see `test-fixtures/golden-corpus/`), not the broader validation JS detection has had. Expect rougher edges on Flask codebases with unusual patterns.
|
|
92
|
+
- **OpenAPI/Swagger scanning reports every schema under `components.schemas`, including response-only ones** — a schema that's only ever returned by the server (never submitted by a client) gets identical treatment to a real request-input schema, since nothing currently checks whether a schema is actually reachable from an operation's `requestBody` or `parameters`. On a large, schema-heavy spec this can mean a long "Items Requiring Manual Review" list that includes fields the product never collects, only ever returns. Separately, a schema defined **inline** inside a `requestBody` (not as a named, `$ref`'d `components.schemas` entry) isn't read at all today, in either direction — only named component schemas are scanned.
|
|
93
|
+
|
|
78
94
|
## Network calls
|
|
79
95
|
|
|
80
96
|
Detection itself is fully local and offline — no code leaves your machine for pattern matching. Once detection completes, `scan` makes one batched network call to grade the confidence of what it found (field names and derived yes/no signals only, never source code), and, for scans that use Zod schemas as request validators, one additional call sending a small redacted code fragment. This runs on every scan regardless of tier and is unrelated to the separate, optional LLM-assisted (Layer 2) reasoning described above. See `PRODUCT_SPEC.md`'s "Static Analysis Depth" section for the precise wording.
|
|
@@ -91,6 +107,12 @@ Detection itself is fully local and offline — no code leaves your machine for
|
|
|
91
107
|
|
|
92
108
|
`npm test` never touches the network. A separate, real-world accuracy check lives in `test-fixtures/golden-corpus/` — see its own README for what it measures and why it's not part of the default test run.
|
|
93
109
|
|
|
110
|
+
## Legal
|
|
111
|
+
|
|
112
|
+
SrcLawyer generates a *starting draft*, not a substitute for legal review — every generated policy carries that disclaimer (`src/policy/disclaimer.ts`), and `scan` repeats a short reminder of it at the end of its own output.
|
|
113
|
+
|
|
114
|
+
A dedicated privacy policy and terms of service **for SrcLawyer itself** (the tool, not the policy it writes for your project) don't exist yet. Until they do, this README is the authoritative reference for what this tool sends over the network and to whom — see [Network calls](#network-calls) above — and for how it's licensed — see [License](#license) below.
|
|
115
|
+
|
|
94
116
|
## License
|
|
95
117
|
|
|
96
118
|
[Business Source License 1.1](LICENSE) (BUSL-1.1) — source-available, not OSI-approved open source.
|
|
@@ -3,6 +3,9 @@ export interface ProtectedLogicClientOptions {
|
|
|
3
3
|
baseUrl: string;
|
|
4
4
|
apiKey: string;
|
|
5
5
|
batchSize?: number;
|
|
6
|
+
/** Max total JSON-serialized bytes of items per batch, in addition to batchSize -- whichever limit
|
|
7
|
+
* is hit first ends the batch. */
|
|
8
|
+
batchMaxBytes?: number;
|
|
6
9
|
timeoutMs?: number;
|
|
7
10
|
}
|
|
8
11
|
export interface ScoreFieldsItem {
|
|
@@ -3,42 +3,83 @@
|
|
|
3
3
|
* these calls are bound by). Mirrors resolveAmbiguousFindings.ts's established pattern deliberately:
|
|
4
4
|
* batch, try/catch per batch, never throw out of this module — a failure here degrades the scan
|
|
5
5
|
* (safe local fallback + a scan-level warning), it never crashes it.
|
|
6
|
+
*
|
|
7
|
+
* Batching is by total request-body bytes as well as item count: confirmed via wrangler tail that the
|
|
8
|
+
* Worker's own CPU time limit is hit intermittently on large detect-zod-schema batches (large
|
|
9
|
+
* code fragments mean more server-side parsing work per batch, not just more JSON to transfer). Count
|
|
10
|
+
* alone (the original design) doesn't bound that — 25 large fragments can still be a very large batch.
|
|
6
11
|
*/
|
|
7
12
|
const DEFAULT_BATCH_SIZE = 25;
|
|
13
|
+
const DEFAULT_BATCH_MAX_BYTES = 50_000;
|
|
8
14
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
9
|
-
|
|
15
|
+
const RETRY_BACKOFF_MS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* Greedily fills batches up to maxCount items AND maxBytes of JSON-serialized item size, whichever
|
|
18
|
+
* comes first. A single item already at or over maxBytes (shouldn't happen for zod candidates — see
|
|
19
|
+
* zodPreFilter.ts's own per-fragment cap, enforced upstream — but score-fields items have no such cap)
|
|
20
|
+
* still gets its own one-item batch rather than being dropped, so this never silently discards data;
|
|
21
|
+
* only the per-fragment cap upstream does that, and it always warns when it does.
|
|
22
|
+
*/
|
|
23
|
+
function chunkByCountAndBytes(items, maxCount, maxBytes) {
|
|
10
24
|
const batches = [];
|
|
11
|
-
|
|
12
|
-
|
|
25
|
+
let current = [];
|
|
26
|
+
let currentBytes = 0;
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
|
|
29
|
+
const wouldExceed = current.length > 0 && (current.length >= maxCount || currentBytes + itemBytes > maxBytes);
|
|
30
|
+
if (wouldExceed) {
|
|
31
|
+
batches.push(current);
|
|
32
|
+
current = [];
|
|
33
|
+
currentBytes = 0;
|
|
34
|
+
}
|
|
35
|
+
current.push(item);
|
|
36
|
+
currentBytes += itemBytes;
|
|
37
|
+
}
|
|
38
|
+
if (current.length > 0)
|
|
39
|
+
batches.push(current);
|
|
13
40
|
return batches;
|
|
14
41
|
}
|
|
42
|
+
function sleep(ms) {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
15
45
|
async function postJson(url, apiKey, body, timeoutMs) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
46
|
+
const MAX_ATTEMPTS = 2; // one retry, on a 5xx only -- a 4xx (bad request, auth) won't succeed on retry
|
|
47
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
50
|
+
let status;
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json", "x-api-key": apiKey },
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
});
|
|
58
|
+
if (response.ok)
|
|
59
|
+
return (await response.json());
|
|
60
|
+
status = response.status;
|
|
27
61
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
finally {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
}
|
|
65
|
+
const isLastAttempt = attempt === MAX_ATTEMPTS;
|
|
66
|
+
if (status < 500 || isLastAttempt) {
|
|
67
|
+
throw new Error(`protected-logic request failed (${status})`);
|
|
68
|
+
}
|
|
69
|
+
await sleep(RETRY_BACKOFF_MS);
|
|
32
70
|
}
|
|
71
|
+
// Unreachable: the loop above always either returns or throws.
|
|
72
|
+
throw new Error("protected-logic request failed (unreachable)");
|
|
33
73
|
}
|
|
34
74
|
export async function scoreFields(items, options) {
|
|
35
75
|
const results = new Map();
|
|
36
76
|
if (items.length === 0)
|
|
37
77
|
return { results, error: null };
|
|
38
78
|
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
79
|
+
const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
|
|
39
80
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
40
81
|
let error = null;
|
|
41
|
-
for (const batch of
|
|
82
|
+
for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
|
|
42
83
|
try {
|
|
43
84
|
const response = await postJson(`${options.baseUrl}/v1/score-fields`, options.apiKey, { fields: batch }, timeoutMs);
|
|
44
85
|
for (const result of response.results)
|
|
@@ -55,9 +96,10 @@ export async function detectZodSchemas(items, options) {
|
|
|
55
96
|
if (items.length === 0)
|
|
56
97
|
return { results, error: null };
|
|
57
98
|
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
99
|
+
const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
|
|
58
100
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
59
101
|
let error = null;
|
|
60
|
-
for (const batch of
|
|
102
|
+
for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
|
|
61
103
|
try {
|
|
62
104
|
const response = await postJson(`${options.baseUrl}/v1/detect-zod-schema`, options.apiKey, { candidates: batch }, timeoutMs);
|
|
63
105
|
for (const result of response.results)
|
|
@@ -11,4 +11,4 @@ export interface ResolveProtectedLogicResult {
|
|
|
11
11
|
* same way resolveAmbiguousFindings.ts is layered on top of it for Layer 2 — not called from inside
|
|
12
12
|
* scan() itself, so scan()'s own purity/auditability claim stays true.
|
|
13
13
|
*/
|
|
14
|
-
export declare function resolveProtectedLogic(findings: Finding[], pendingZodCandidates: ZodCandidate[], options: ProtectedLogicClientOptions): Promise<ResolveProtectedLogicResult>;
|
|
14
|
+
export declare function resolveProtectedLogic(findings: Finding[], pendingZodCandidates: ZodCandidate[], options: ProtectedLogicClientOptions, oversizedZodFiles?: string[]): Promise<ResolveProtectedLogicResult>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyFieldName, isAmbiguousFieldName } from "../rules/dataCategoryLexicon.js";
|
|
2
|
+
import { isSafeFieldName } from "../rules/safeFieldNames.js";
|
|
2
3
|
import { countConfidentSiblings } from "../rules/confidenceFactors.js";
|
|
3
4
|
import { detectZodSchemas, scoreFields } from "./protectedLogicClient.js";
|
|
4
5
|
/**
|
|
@@ -7,9 +8,20 @@ import { detectZodSchemas, scoreFields } from "./protectedLogicClient.js";
|
|
|
7
8
|
* same way resolveAmbiguousFindings.ts is layered on top of it for Layer 2 — not called from inside
|
|
8
9
|
* scan() itself, so scan()'s own purity/auditability claim stays true.
|
|
9
10
|
*/
|
|
10
|
-
export async function resolveProtectedLogic(findings, pendingZodCandidates, options) {
|
|
11
|
+
export async function resolveProtectedLogic(findings, pendingZodCandidates, options, oversizedZodFiles = []) {
|
|
11
12
|
let workingFindings = findings;
|
|
12
13
|
const warnings = [];
|
|
14
|
+
if (oversizedZodFiles.length > 0) {
|
|
15
|
+
// Kept to a single summarized line -- this list is meant to be rare now that the hard ceiling is
|
|
16
|
+
// only for pathological cases (see MAX_FRAGMENT_BYTES), but a long list here would still be an
|
|
17
|
+
// unreadable wall of text. The full, untruncated list is always in ScanResult.oversizedZodFiles
|
|
18
|
+
// (surfaced in --json output), so nothing here is actually lost, just not all printed inline.
|
|
19
|
+
const PREVIEW_COUNT = 3;
|
|
20
|
+
const preview = oversizedZodFiles.slice(0, PREVIEW_COUNT).join(", ");
|
|
21
|
+
const remaining = oversizedZodFiles.length - PREVIEW_COUNT;
|
|
22
|
+
const suffix = remaining > 0 ? `, and ${remaining} more (full list in --json output)` : "";
|
|
23
|
+
warnings.push(`Zod-schema detection skipped for ${oversizedZodFiles.length} file(s) because the extracted fragment was too large to send safely: ${preview}${suffix}.`);
|
|
24
|
+
}
|
|
13
25
|
if (pendingZodCandidates.length > 0) {
|
|
14
26
|
const byId = new Map(pendingZodCandidates.map((c) => [c.id, c]));
|
|
15
27
|
const { results, error } = await detectZodSchemas(pendingZodCandidates.map((c) => ({ id: c.id, codeFragment: c.codeFragment })), options);
|
|
@@ -22,6 +34,8 @@ export async function resolveProtectedLogic(findings, pendingZodCandidates, opti
|
|
|
22
34
|
continue;
|
|
23
35
|
const allNames = result.fields.map((f) => f.name);
|
|
24
36
|
for (const field of result.fields) {
|
|
37
|
+
if (isSafeFieldName(field.name))
|
|
38
|
+
continue;
|
|
25
39
|
const sourceLine = candidate.fragmentLineToSourceLine[field.line - 1] ?? field.line;
|
|
26
40
|
const lexiconCategory = classifyFieldName(field.name);
|
|
27
41
|
const ambiguousName = isAmbiguousFieldName(field.name);
|
package/dist/commands/init.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import { CONFIG_FILENAME, DEFAULT_CONFIG, configPath, writeConfig } from "../config/config.js";
|
|
4
|
+
import { CONFIG_FILENAME, DEFAULT_CONFIG, configPath, hasUnmigratedLegacyConfig, legacyConfigMessage, writeConfig, } from "../config/config.js";
|
|
5
|
+
import { NETWORK_CALLS_NOTICE } from "../policy/legalNotices.js";
|
|
5
6
|
function guessIndustry(root) {
|
|
6
7
|
const pkgPath = join(root, "package.json");
|
|
7
8
|
if (!existsSync(pkgPath))
|
|
@@ -29,15 +30,26 @@ export async function runInit(root) {
|
|
|
29
30
|
console.log(`${CONFIG_FILENAME} already exists. Delete it first if you want to redo setup.`);
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
33
|
+
if (hasUnmigratedLegacyConfig(root)) {
|
|
34
|
+
console.log(legacyConfigMessage());
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
32
37
|
const guessedIndustry = guessIndustry(root);
|
|
33
38
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
39
|
const lines = rl[Symbol.asyncIterator]();
|
|
40
|
+
// Printed once, here, not re-shown on every scan -- informational only, never blocks: nothing below
|
|
41
|
+
// waits on an acknowledgment, so this is safe in non-interactive contexts (CI, scripted installs).
|
|
42
|
+
console.log(`${NETWORK_CALLS_NOTICE}\n`);
|
|
35
43
|
console.log("SrcLawyer setup — answered once, reused on every scan.\n");
|
|
36
44
|
const entityLocation = await ask(lines, "Where is your legal entity located? (country)", DEFAULT_CONFIG.entityLocation ?? "");
|
|
37
45
|
const targetMarketsRaw = await ask(lines, "Which regions/markets do you serve? (comma-separated, e.g. US, EU)", "");
|
|
38
46
|
const industry = await ask(lines, "What industry/vertical is this product in?", guessedIndustry ?? "");
|
|
39
47
|
const childrensDataRaw = await ask(lines, "Does this product knowingly collect data from children under 13/16?", "no");
|
|
40
|
-
|
|
48
|
+
// "managed" is deliberately not offered here -- it's a real SrcLawyerTier value and config.ts/
|
|
49
|
+
// resolveProviderForTier.ts both still support it fully (a hand-edited config with tier: managed
|
|
50
|
+
// still gets a clear, accurate error at scan time), but there's no advertised path to it from this
|
|
51
|
+
// prompt, so a tester reading the choices here can't select a dead end during setup.
|
|
52
|
+
const tierRaw = await ask(lines, "Tier: free (static analysis only) / byok (bring your own Anthropic key)", "free");
|
|
41
53
|
const tier = normalizeTier(tierRaw);
|
|
42
54
|
let apiKeyEnvVar = null;
|
|
43
55
|
if (tier === "byok") {
|
package/dist/commands/scan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import { loadConfig } from "../config/config.js";
|
|
3
|
+
import { CONFIG_FILENAME, hasUnmigratedLegacyConfig, legacyConfigMessage, loadConfig } from "../config/config.js";
|
|
4
4
|
import { scan } from "../engine/scanEngine.js";
|
|
5
5
|
import { formatText } from "../report/formatText.js";
|
|
6
6
|
import { generatePolicyMarkdown } from "../policy/generatePolicy.js";
|
|
@@ -8,22 +8,34 @@ import { resolveProviderForTier } from "../llm/resolveProviderForTier.js";
|
|
|
8
8
|
import { resolveAmbiguousFindings } from "../llm/resolveAmbiguousFindings.js";
|
|
9
9
|
import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
|
|
10
10
|
import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
|
|
11
|
+
import { MAX_FRAGMENT_BYTES } from "../rules/zodPreFilter.js";
|
|
12
|
+
import { SCAN_DISCLAIMER_REMINDER, byokNetworkNotice } from "../policy/legalNotices.js";
|
|
11
13
|
export async function runScan(root, options) {
|
|
12
14
|
const config = loadConfig(root);
|
|
15
|
+
let legacyConfigWarning = null;
|
|
13
16
|
if (!config) {
|
|
14
|
-
|
|
17
|
+
if (hasUnmigratedLegacyConfig(root)) {
|
|
18
|
+
legacyConfigWarning = legacyConfigMessage();
|
|
19
|
+
console.error(legacyConfigWarning);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.error(`No ${CONFIG_FILENAME} found. Run "srclawyer init" first.\n`);
|
|
23
|
+
}
|
|
15
24
|
}
|
|
16
25
|
const result = await scan(root);
|
|
17
26
|
let findings = result.findings;
|
|
27
|
+
if (result.maxZodFragmentBytes > 0) {
|
|
28
|
+
console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
|
|
29
|
+
}
|
|
18
30
|
// Not tier-gated, not behind --no-llm: confidence scoring and Zod-schema detection are Layer 1
|
|
19
31
|
// capabilities now, needed on every real scan regardless of which LLM tier (if any) is configured.
|
|
20
32
|
// A failure here degrades honestly (findings keep their safe local fallback) rather than blocking
|
|
21
33
|
// the scan — see resolveProtectedLogic's own per-call error handling.
|
|
22
|
-
const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0;
|
|
34
|
+
const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
|
|
23
35
|
let protectedLogicWarning = null;
|
|
24
36
|
if (hasScorableFindings) {
|
|
25
37
|
const endpoint = resolveProtectedLogicEndpoint();
|
|
26
|
-
const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint);
|
|
38
|
+
const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint, result.oversizedZodFiles);
|
|
27
39
|
findings = resolved.findings;
|
|
28
40
|
protectedLogicWarning = resolved.warning;
|
|
29
41
|
if (protectedLogicWarning)
|
|
@@ -39,6 +51,9 @@ export async function runScan(root, options) {
|
|
|
39
51
|
else if (provider) {
|
|
40
52
|
const ambiguous = findings.filter((f) => f.requiresReview);
|
|
41
53
|
if (ambiguous.length > 0) {
|
|
54
|
+
if (config.tier === "byok" && config.llm) {
|
|
55
|
+
console.log(byokNetworkNotice(config.llm.apiKeyEnvVar));
|
|
56
|
+
}
|
|
42
57
|
console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name}...`);
|
|
43
58
|
const resolved = await resolveAmbiguousFindings(ambiguous, provider, {
|
|
44
59
|
collectsChildrensData: config.collectsChildrensData,
|
|
@@ -51,16 +66,19 @@ export async function runScan(root, options) {
|
|
|
51
66
|
const ambiguousCount = findings.filter((f) => f.requiresReview).length;
|
|
52
67
|
const finalResult = { ...result, findings, ambiguousCount };
|
|
53
68
|
const policyPath = resolve(root, options.out);
|
|
54
|
-
writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError));
|
|
55
|
-
if (result.unsupportedStackWarning || llmError || protectedLogicWarning) {
|
|
69
|
+
writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
|
|
70
|
+
if (result.unsupportedStackWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
|
|
56
71
|
process.exitCode = 1;
|
|
57
72
|
}
|
|
58
73
|
if (options.json) {
|
|
59
|
-
console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning }, null, 2));
|
|
74
|
+
console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning, legacyConfigWarning }, null, 2));
|
|
60
75
|
return;
|
|
61
76
|
}
|
|
62
77
|
console.log(formatText(finalResult));
|
|
63
78
|
if (protectedLogicWarning)
|
|
64
79
|
console.log(`\n${protectedLogicWarning}`);
|
|
80
|
+
if (legacyConfigWarning)
|
|
81
|
+
console.log(`\n${legacyConfigWarning}`);
|
|
65
82
|
console.log(`\nPrivacy policy written to ${join(options.out)}`);
|
|
83
|
+
console.log(`\n${SCAN_DISCLAIMER_REMINDER}`);
|
|
66
84
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export declare const CONFIG_FILENAME = ".
|
|
1
|
+
export declare const CONFIG_FILENAME = ".srclawyer.config.yml";
|
|
2
|
+
/** The pre-0.5.0 name. Never auto-migrated or silently read as a fallback -- see legacyConfigMessage. */
|
|
3
|
+
export declare const LEGACY_CONFIG_FILENAME = ".privacypolicy.yml";
|
|
2
4
|
export type SrcLawyerTier = "free" | "byok" | "managed";
|
|
3
5
|
export interface SrcLawyerLlmConfig {
|
|
4
6
|
provider: "anthropic";
|
|
@@ -14,5 +16,15 @@ export interface SrcLawyerConfig {
|
|
|
14
16
|
}
|
|
15
17
|
export declare const DEFAULT_CONFIG: SrcLawyerConfig;
|
|
16
18
|
export declare function configPath(root: string): string;
|
|
19
|
+
export declare function legacyConfigPath(root: string): string;
|
|
20
|
+
/** True only when the old file is present and the new one isn't -- never fires once migrated. */
|
|
21
|
+
export declare function hasUnmigratedLegacyConfig(root: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Single source of truth for this message so init and scan can't drift into saying it two different
|
|
24
|
+
* ways. Deliberately never auto-reads the legacy file as a fallback, even though the YAML shape is
|
|
25
|
+
* identical -- the rename needs to be an explicit, visible action, not a silent compatibility shim
|
|
26
|
+
* that lets the old filename linger indefinitely.
|
|
27
|
+
*/
|
|
28
|
+
export declare function legacyConfigMessage(): string;
|
|
17
29
|
export declare function loadConfig(root: string): SrcLawyerConfig | null;
|
|
18
30
|
export declare function writeConfig(root: string, config: SrcLawyerConfig): void;
|
package/dist/config/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
|
-
export const CONFIG_FILENAME = ".
|
|
4
|
+
export const CONFIG_FILENAME = ".srclawyer.config.yml";
|
|
5
|
+
/** The pre-0.5.0 name. Never auto-migrated or silently read as a fallback -- see legacyConfigMessage. */
|
|
6
|
+
export const LEGACY_CONFIG_FILENAME = ".privacypolicy.yml";
|
|
5
7
|
export const DEFAULT_CONFIG = {
|
|
6
8
|
entityLocation: null,
|
|
7
9
|
targetMarkets: [],
|
|
@@ -12,6 +14,22 @@ export const DEFAULT_CONFIG = {
|
|
|
12
14
|
export function configPath(root) {
|
|
13
15
|
return join(root, CONFIG_FILENAME);
|
|
14
16
|
}
|
|
17
|
+
export function legacyConfigPath(root) {
|
|
18
|
+
return join(root, LEGACY_CONFIG_FILENAME);
|
|
19
|
+
}
|
|
20
|
+
/** True only when the old file is present and the new one isn't -- never fires once migrated. */
|
|
21
|
+
export function hasUnmigratedLegacyConfig(root) {
|
|
22
|
+
return existsSync(legacyConfigPath(root)) && !existsSync(configPath(root));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Single source of truth for this message so init and scan can't drift into saying it two different
|
|
26
|
+
* ways. Deliberately never auto-reads the legacy file as a fallback, even though the YAML shape is
|
|
27
|
+
* identical -- the rename needs to be an explicit, visible action, not a silent compatibility shim
|
|
28
|
+
* that lets the old filename linger indefinitely.
|
|
29
|
+
*/
|
|
30
|
+
export function legacyConfigMessage() {
|
|
31
|
+
return `Found a legacy ${LEGACY_CONFIG_FILENAME} from an older version of SrcLawyer. Rename it to keep your existing settings:\n\n mv ${LEGACY_CONFIG_FILENAME} ${CONFIG_FILENAME}\n\nThen re-run this command. (If you'd rather start fresh instead, delete ${LEGACY_CONFIG_FILENAME} first.)`;
|
|
32
|
+
}
|
|
15
33
|
export function loadConfig(root) {
|
|
16
34
|
const path = configPath(root);
|
|
17
35
|
if (!existsSync(path))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fg from "fast-glob";
|
|
2
2
|
const CODE_PATTERNS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
|
|
3
|
+
const PYTHON_PATTERNS = ["**/*.py"];
|
|
3
4
|
const HTML_PATTERNS = ["**/*.html", "**/*.htm"];
|
|
4
5
|
const SCHEMA_PATTERNS = ["**/*.graphql", "**/*.gql", "**/openapi.yaml", "**/openapi.yml", "**/openapi.json", "**/swagger.yaml", "**/swagger.yml", "**/swagger.json"];
|
|
5
6
|
const MANIFEST_PATTERNS = ["**/package.json", "**/requirements.txt"];
|
|
@@ -14,12 +15,25 @@ const IGNORE = [
|
|
|
14
15
|
"**/*.spec.*",
|
|
15
16
|
"**/*.d.ts",
|
|
16
17
|
];
|
|
18
|
+
// Separate from IGNORE: node_modules has no Python analogue, and excluding a venv/site-packages
|
|
19
|
+
// directory matters even more here, since it's a full copy of every installed pip package's own
|
|
20
|
+
// source -- the Python equivalent of node_modules, and just as irrelevant/misleading to scan.
|
|
21
|
+
const PYTHON_IGNORE = [
|
|
22
|
+
...IGNORE,
|
|
23
|
+
"**/venv/**",
|
|
24
|
+
"**/.venv/**",
|
|
25
|
+
"**/__pycache__/**",
|
|
26
|
+
"**/site-packages/**",
|
|
27
|
+
"**/test_*.py",
|
|
28
|
+
"**/*_test.py",
|
|
29
|
+
];
|
|
17
30
|
export async function discoverFiles(root) {
|
|
18
|
-
const [codeFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
|
|
31
|
+
const [codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
|
|
19
32
|
fg(CODE_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
|
|
33
|
+
fg(PYTHON_PATTERNS, { cwd: root, absolute: true, ignore: PYTHON_IGNORE, dot: false }),
|
|
20
34
|
fg(HTML_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
|
|
21
35
|
fg(SCHEMA_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
|
|
22
36
|
fg(MANIFEST_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
|
|
23
37
|
]);
|
|
24
|
-
return { codeFiles, htmlFiles, schemaFiles, manifestFiles };
|
|
38
|
+
return { codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles };
|
|
25
39
|
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import fg from "fast-glob";
|
|
4
|
-
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/vendor/**"];
|
|
5
|
-
|
|
4
|
+
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/vendor/**", "**/venv/**", "**/.venv/**", "**/__pycache__/**"];
|
|
5
|
+
// Python moved from UNSUPPORTED_LANGUAGES to here once Flask detection shipped (Stage A) -- it's a
|
|
6
|
+
// supported *language* now, but only one framework within it is actually understood. A Python project
|
|
7
|
+
// using a different framework still needs an honest gap warning; see detectUnsupportedPythonFramework.
|
|
8
|
+
const SUPPORTED_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "html", "htm", "py"];
|
|
6
9
|
const UNSUPPORTED_LANGUAGES = [
|
|
7
|
-
{ label: "Python", extensions: ["py"] },
|
|
8
10
|
{ label: "Ruby", extensions: ["rb"] },
|
|
9
11
|
{ label: "Go", extensions: ["go"] },
|
|
10
12
|
{ label: "Java", extensions: ["java"] },
|
|
11
13
|
{ label: "PHP", extensions: ["php"] },
|
|
12
14
|
{ label: "C#", extensions: ["cs"] },
|
|
13
15
|
];
|
|
16
|
+
const FASTAPI_IMPORT = /\bfrom\s+fastapi\b|\bimport\s+fastapi\b/;
|
|
14
17
|
function bannerize(message) {
|
|
15
|
-
return `UNSUPPORTED CODEBASE — ${message} SrcLawyer only analyzes JavaScript/TypeScript and
|
|
18
|
+
return `UNSUPPORTED CODEBASE — ${message} SrcLawyer only analyzes JavaScript/TypeScript, HTML, and Flask-based Python today; the findings below (if any) are incomplete and MUST NOT be treated as a full compliance scan.`;
|
|
16
19
|
}
|
|
17
20
|
async function detectFrameworkSignature(root) {
|
|
18
21
|
if (existsSync(join(root, "manage.py"))) {
|
|
19
|
-
return bannerize("Django detected (manage.py found at the project root).");
|
|
22
|
+
return bannerize("Django detected (manage.py found at the project root) — Django support isn't built yet.");
|
|
20
23
|
}
|
|
21
24
|
const hasGemfile = existsSync(join(root, "Gemfile"));
|
|
22
25
|
const hasRailsLayout = existsSync(join(root, "config", "routes.rb")) || existsSync(join(root, "app", "controllers"));
|
|
@@ -29,6 +32,27 @@ async function detectFrameworkSignature(root) {
|
|
|
29
32
|
}
|
|
30
33
|
return null;
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Presence-based, like detectFrameworkSignature's manage.py check -- not an absence heuristic ("we
|
|
37
|
+
* found nothing so something must be wrong"), which can't tell a genuinely clean Flask file apart
|
|
38
|
+
* from an unsupported framework. Scans actual file content (not just a marker file) since FastAPI,
|
|
39
|
+
* unlike Django, has no single root-level marker file to check for instead.
|
|
40
|
+
*/
|
|
41
|
+
async function detectUnsupportedPythonFramework(root, pythonFiles) {
|
|
42
|
+
for (const filePath of pythonFiles) {
|
|
43
|
+
let content;
|
|
44
|
+
try {
|
|
45
|
+
content = readFileSync(filePath, "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (FASTAPI_IMPORT.test(content)) {
|
|
51
|
+
return bannerize("FastAPI detected (a `fastapi` import was found) — FastAPI support isn't built yet.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
32
56
|
async function detectDominantUnsupportedLanguage(root) {
|
|
33
57
|
const [supportedFiles, ...unsupportedGroups] = await Promise.all([
|
|
34
58
|
fg(SUPPORTED_EXTENSIONS.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false }),
|
|
@@ -38,11 +62,17 @@ async function detectDominantUnsupportedLanguage(root) {
|
|
|
38
62
|
const dominant = counts.filter((c) => c.count > 0).sort((a, b) => b.count - a.count)[0];
|
|
39
63
|
if (!dominant || dominant.count <= supportedFiles.length)
|
|
40
64
|
return null;
|
|
41
|
-
return bannerize(`${dominant.label} detected as the primary language (${dominant.count} file(s), vs ${supportedFiles.length} supported
|
|
65
|
+
return bannerize(`${dominant.label} detected as the primary language (${dominant.count} file(s), vs ${supportedFiles.length} supported file(s)).`);
|
|
42
66
|
}
|
|
43
67
|
export async function detectUnsupportedStack(root) {
|
|
44
68
|
const frameworkWarning = await detectFrameworkSignature(root);
|
|
45
69
|
if (frameworkWarning)
|
|
46
70
|
return frameworkWarning;
|
|
71
|
+
const pythonFiles = await fg("**/*.py", { cwd: root, absolute: true, ignore: IGNORE, dot: false });
|
|
72
|
+
if (pythonFiles.length > 0) {
|
|
73
|
+
const pythonFrameworkWarning = await detectUnsupportedPythonFramework(root, pythonFiles);
|
|
74
|
+
if (pythonFrameworkWarning)
|
|
75
|
+
return pythonFrameworkWarning;
|
|
76
|
+
}
|
|
47
77
|
return detectDominantUnsupportedLanguage(root);
|
|
48
78
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { Parser, Language } from "web-tree-sitter";
|
|
4
|
+
// This file compiles to dist/engine/pythonAstUtils.js; the grammar is copied to dist/grammars/ by
|
|
5
|
+
// scripts/copyGrammars.js as part of `npm run build` (see grammars/README.md for why it's vendored
|
|
6
|
+
// rather than an npm dependency).
|
|
7
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const GRAMMAR_PATH = join(HERE, "..", "grammars", "tree-sitter-python.wasm");
|
|
9
|
+
// Initialized at most once per process, reused across every Python file in a scan -- mirrors
|
|
10
|
+
// tsProject.ts's lazy-memoized pattern for the same reason (real setup cost, no benefit to repeating
|
|
11
|
+
// it per file). Parser.init()/Language.load() are both async; parser.parse() itself is synchronous
|
|
12
|
+
// once a Parser is set up, and this module's scan-time usage is sequential (not concurrent), so one
|
|
13
|
+
// shared Parser instance is safe to reuse.
|
|
14
|
+
let parserPromise = null;
|
|
15
|
+
function initParser() {
|
|
16
|
+
if (!parserPromise) {
|
|
17
|
+
parserPromise = (async () => {
|
|
18
|
+
try {
|
|
19
|
+
await Parser.init();
|
|
20
|
+
const language = await Language.load(GRAMMAR_PATH);
|
|
21
|
+
const parser = new Parser();
|
|
22
|
+
parser.setLanguage(language);
|
|
23
|
+
return parser;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Honest null, matching astUtils.ts's parseSource -- a missing/incompatible grammar build
|
|
27
|
+
// degrades this file's detection to nothing rather than crashing the whole scan.
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
}
|
|
32
|
+
return parserPromise;
|
|
33
|
+
}
|
|
34
|
+
export async function parsePythonSource(source) {
|
|
35
|
+
const parser = await initParser();
|
|
36
|
+
if (!parser)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
return parser.parse(source);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|