iamsecure 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +127 -2
- package/bin/iamsecure.js +136 -0
- package/package.json +36 -4
- package/src/ai/index.js +170 -0
- package/src/ai/providers.js +161 -0
- package/src/code/context.js +123 -0
- package/src/code/engine.js +117 -0
- package/src/code/files.js +85 -0
- package/src/code/index.js +30 -0
- package/src/code/rules.js +545 -0
- package/src/code/secrets.js +123 -0
- package/src/code/strip.js +51 -0
- package/src/deps/cvss.js +59 -0
- package/src/deps/graph.js +88 -0
- package/src/deps/index.js +144 -0
- package/src/deps/lockfiles/composer.js +31 -0
- package/src/deps/lockfiles/npm.js +77 -0
- package/src/deps/lockfiles/pnpm.js +56 -0
- package/src/deps/lockfiles/python.js +101 -0
- package/src/deps/lockfiles/yarn.js +93 -0
- package/src/deps/osv.js +106 -0
- package/src/project.js +102 -0
- package/src/report/sarif.js +58 -0
- package/src/report/terminal.js +253 -0
- package/src/risk/exploit.js +53 -0
- package/src/risk/score.js +215 -0
- package/src/scan.js +91 -0
- package/src/ui/index.html +980 -0
- package/src/ui/server.js +97 -0
- package/src/util/cache.js +35 -0
- package/src/util/http.js +43 -0
- package/src/util/semver.js +23 -0
- package/src/util/toml.js +129 -0
- package/src/util/yaml.js +85 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Works out WHERE code runs (server, browser, build/test) and which packages each file imports.
|
|
2
|
+
// This is what lets a ReDoS in a browser-only package rank far below the same bug on the server.
|
|
3
|
+
|
|
4
|
+
import { builtinModules } from 'node:module';
|
|
5
|
+
|
|
6
|
+
const BUILTINS = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
|
|
7
|
+
|
|
8
|
+
export const CONTEXT_LABEL = {
|
|
9
|
+
server: 'runs on server',
|
|
10
|
+
client: 'browser only',
|
|
11
|
+
universal: 'server + browser',
|
|
12
|
+
build: 'build/test only',
|
|
13
|
+
unused: 'installed, not imported',
|
|
14
|
+
unknown: 'usage unknown',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const TEST_RE = /(^|\/)(__tests__|__mocks__|tests?|e2e|cypress|playwright|stories|fixtures)\/|\.(test|spec|stories|cy|e2e)\.[cm]?[jt]sx?$/;
|
|
18
|
+
const BUILD_RE = /(^|\/)[^/]+\.config\.[cm]?[jt]s$|(^|\/)(scripts|tools|\.storybook|\.github|\.husky)\/|(^|\/)(gulpfile|gruntfile|Jakefile)\.[cm]?[jt]s$/i;
|
|
19
|
+
const SERVER_HINT_RE = /\bfrom\s+['"](?:node:)?(fs|child_process|net|crypto|server-only|@prisma\/client|pg|mysql2?|mongoose|redis|ioredis)['"]|require\(\s*['"](?:node:)?(fs|child_process|net)['"]\)|\bprocess\.env\.(?!NEXT_PUBLIC_|VITE_|REACT_APP_)\w+/;
|
|
20
|
+
const CLIENT_HINT_RE = /\b(useState|useEffect|useLayoutEffect|useReducer)\s*\(|\bwindow\.|\bdocument\.|\blocalStorage\b|from\s+['"]client-only['"]/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} rel path relative to project root, forward slashes
|
|
24
|
+
* @param {string} text file contents
|
|
25
|
+
* @param {string} framework from detectFramework()
|
|
26
|
+
* @returns {'server'|'client'|'universal'|'build'}
|
|
27
|
+
*/
|
|
28
|
+
export function classifyFile(rel, text, framework) {
|
|
29
|
+
if (TEST_RE.test(rel) || BUILD_RE.test(rel)) return 'build';
|
|
30
|
+
if (/\.(php|py)$/.test(rel)) return 'server';
|
|
31
|
+
|
|
32
|
+
const head = text.slice(0, 400).replace(/^(\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*/, '');
|
|
33
|
+
if (/^['"]use client['"]/.test(head)) return 'client';
|
|
34
|
+
if (/^['"]use server['"]/.test(head)) return 'server';
|
|
35
|
+
if (/from\s+['"]server-only['"]/.test(text)) return 'server';
|
|
36
|
+
if (/from\s+['"]client-only['"]/.test(text)) return 'client';
|
|
37
|
+
if (/\.server\.[cm]?[jt]sx?$|(^|\/)\+(page|layout)\.server\.|(^|\/)\+server\.[jt]s$/.test(rel)) return 'server';
|
|
38
|
+
if (/\.client\.[cm]?[jt]sx?$/.test(rel)) return 'client';
|
|
39
|
+
|
|
40
|
+
const p = rel.replace(/^(apps|packages)\/[^/]+\//, '');
|
|
41
|
+
switch (framework) {
|
|
42
|
+
case 'next':
|
|
43
|
+
if (/^(src\/)?pages\/api\//.test(p)) return 'server';
|
|
44
|
+
if (/^(src\/)?(middleware|instrumentation)\.[jt]s$/.test(p) || /^server\.[cm]?[jt]s$/.test(p)) return 'server';
|
|
45
|
+
if (/^(src\/)?app\//.test(p)) return 'server'; // React Server Components unless "use client"
|
|
46
|
+
if (/^(src\/)?pages\//.test(p)) return 'universal';
|
|
47
|
+
break;
|
|
48
|
+
case 'nuxt':
|
|
49
|
+
case 'sveltekit':
|
|
50
|
+
case 'remix':
|
|
51
|
+
case 'astro':
|
|
52
|
+
if (/^(src\/)?(server|api)\//.test(p)) return 'server';
|
|
53
|
+
break;
|
|
54
|
+
case 'cra':
|
|
55
|
+
case 'vite-spa':
|
|
56
|
+
case 'spa':
|
|
57
|
+
if (/^(server|api|backend|functions)\//.test(p)) return 'server';
|
|
58
|
+
return 'client';
|
|
59
|
+
case 'node-server':
|
|
60
|
+
case 'node':
|
|
61
|
+
return 'server';
|
|
62
|
+
}
|
|
63
|
+
const server = SERVER_HINT_RE.test(text);
|
|
64
|
+
const client = CLIENT_HINT_RE.test(text);
|
|
65
|
+
if (server && !client) return 'server';
|
|
66
|
+
if (client && !server) return 'client';
|
|
67
|
+
return 'universal';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const IMPORT_RE =
|
|
71
|
+
/(?:\bimport\s+(?:type\s+)?(?:[\w*{}\s,$]+?\s+from\s+)?|\bexport\s+(?:type\s+)?(?:[\w*{}\s,$]+?\s+)?from\s+|\brequire\s*\(\s*|\bimport\s*\(\s*)['"]([^'"\n]+)['"]/g;
|
|
72
|
+
|
|
73
|
+
/** Package name for a bare import specifier, or null for relative/aliased/builtin imports. */
|
|
74
|
+
export function packageOf(spec) {
|
|
75
|
+
if (/^[./~#]|^@\/|^[a-z]+:/i.test(spec) && !spec.startsWith('node:')) return null;
|
|
76
|
+
if (BUILTINS.has(spec) || BUILTINS.has(spec.split('/')[0])) return null;
|
|
77
|
+
const parts = spec.split('/');
|
|
78
|
+
return spec.startsWith('@') ? (parts.length > 1 ? `${parts[0]}/${parts[1]}` : null) : parts[0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {{pkg:string, line:number, binding:string|null}[]} */
|
|
82
|
+
export function extractImports(text) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const m of text.matchAll(IMPORT_RE)) {
|
|
85
|
+
const pkg = packageOf(m[1]);
|
|
86
|
+
if (!pkg) continue;
|
|
87
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
88
|
+
// Capture the local name: `import _ from 'lodash'`, `const jwt = require('jsonwebtoken')`, `import * as x`.
|
|
89
|
+
const before = text.slice(Math.max(0, m.index - 80), m.index + m[0].length);
|
|
90
|
+
const binding =
|
|
91
|
+
m[0].match(/^import\s+(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)[\s,]/)?.[1] ??
|
|
92
|
+
before.match(/(?:const|let|var)\s+([\w$]+)\s*=\s*require\s*\(\s*['"][^'"]+['"]$/)?.[1] ??
|
|
93
|
+
null;
|
|
94
|
+
out.push({ pkg, line, binding });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Packages a framework loads for you even if no file imports them. */
|
|
100
|
+
export function frameworkRuntime(framework) {
|
|
101
|
+
const ssr = ['server', 'client'];
|
|
102
|
+
const map = {
|
|
103
|
+
next: { next: ssr, react: ssr, 'react-dom': ssr },
|
|
104
|
+
nuxt: { nuxt: ssr, vue: ssr },
|
|
105
|
+
remix: { '@remix-run/node': ['server'], '@remix-run/react': ssr, react: ssr, 'react-dom': ssr },
|
|
106
|
+
sveltekit: { '@sveltejs/kit': ssr, svelte: ssr },
|
|
107
|
+
astro: { astro: ['server'] },
|
|
108
|
+
cra: { react: ['client'], 'react-dom': ['client'] },
|
|
109
|
+
'vite-spa': { react: ['client'], 'react-dom': ['client'], vue: ['client'], svelte: ['client'] },
|
|
110
|
+
spa: { react: ['client'], 'react-dom': ['client'], vue: ['client'] },
|
|
111
|
+
};
|
|
112
|
+
return map[framework] ?? {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Merge a set of contexts into one exposure bucket. */
|
|
116
|
+
export function mergeContexts(set) {
|
|
117
|
+
const s = new Set(set);
|
|
118
|
+
if (s.has('universal') || (s.has('server') && s.has('client'))) return 'universal';
|
|
119
|
+
if (s.has('server')) return 'server';
|
|
120
|
+
if (s.has('client')) return 'client';
|
|
121
|
+
if (s.has('build')) return 'build';
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { stripComments } from './strip.js';
|
|
2
|
+
import { RULES, SOURCES, langOf } from './rules.js';
|
|
3
|
+
import { levelFromScore } from '../deps/cvss.js';
|
|
4
|
+
|
|
5
|
+
const ASSIGN = {
|
|
6
|
+
js: /(?:const|let|var)\s+(?:\{([^}]+)\}|\[([^\]]+)\]|([\w$]+))\s*=\s*([^;\n]+)/g,
|
|
7
|
+
php: /(\$\w+)\s*=\s*([^;\n]+)/g,
|
|
8
|
+
py: /^\s*([\w, ]+?)\s*=\s*([^\n]+)/gm,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
|
12
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lightweight intra-file taint tracking: variables assigned (directly or transitively)
|
|
16
|
+
* from a user-input source. Good enough to tell `exec(userInput)` from `exec("ls")`.
|
|
17
|
+
*/
|
|
18
|
+
export function taintedNames(code, lang) {
|
|
19
|
+
const names = new Set();
|
|
20
|
+
const src = SOURCES[lang];
|
|
21
|
+
// `[^\w$.]` before the name: `import.meta.url` is not the tainted variable `url`.
|
|
22
|
+
const isTainted = (rhs) => src.test(rhs) || [...names].some((n) => new RegExp(`(^|[^\\w$.])${escapeRe(n)}([^\\w$]|$)`).test(rhs));
|
|
23
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
24
|
+
const before = names.size;
|
|
25
|
+
for (const m of code.matchAll(ASSIGN[lang])) {
|
|
26
|
+
if (lang === 'js') {
|
|
27
|
+
const [, destructObj, destructArr, single, rhs] = m;
|
|
28
|
+
if (!isTainted(rhs)) continue;
|
|
29
|
+
if (single) names.add(single);
|
|
30
|
+
for (const part of (destructObj ?? destructArr ?? '').split(',')) {
|
|
31
|
+
const name = part.split(':').pop().split('=')[0].trim().replace(/^\.\.\./, '');
|
|
32
|
+
if (/^[\w$]+$/.test(name)) names.add(name);
|
|
33
|
+
}
|
|
34
|
+
} else if (lang === 'php') {
|
|
35
|
+
if (isTainted(m[2])) names.add(m[1]);
|
|
36
|
+
} else {
|
|
37
|
+
if (!isTainted(m[2])) continue;
|
|
38
|
+
for (const n of m[1].split(',')) if (/^\w+$/.test(n.trim())) names.add(n.trim());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (names.size === before) break;
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function argsAfter(window, idx) {
|
|
47
|
+
// Text from the sink onwards, roughly up to the end of the call/statement.
|
|
48
|
+
return window.slice(idx).split(/;\s*(\n|$)/)[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DYNAMIC = /\$\{|['"`]\s*\+|\+\s*['"`\w$]|\(\s*[\w$.[\]]+\s*[,)]|\(\s*\w+\s*\(|=\s*[\w$.[\]()]+\s*;?\s*$|\$\w+|\bf['"]|%\s*\(|\.format\(/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run all rules over one file.
|
|
55
|
+
* @returns {any[]} findings
|
|
56
|
+
*/
|
|
57
|
+
export function scanFile(rel, text, fileContext) {
|
|
58
|
+
const lang = langOf(rel);
|
|
59
|
+
if (!lang) return [];
|
|
60
|
+
const rules = RULES[lang];
|
|
61
|
+
const code = stripComments(text, lang);
|
|
62
|
+
const lines = code.split('\n');
|
|
63
|
+
// Same text with string contents blanked: most sinks must appear in code, not inside a string.
|
|
64
|
+
const bare = stripComments(text, lang, { blankStrings: true }).split('\n');
|
|
65
|
+
const raw = text.split('\n');
|
|
66
|
+
const tainted = taintedNames(code, lang);
|
|
67
|
+
const taintRe = tainted.size
|
|
68
|
+
? new RegExp(`(^|[^\\w$.])(${[...tainted].map(escapeRe).join('|')})([^\\w$]|$)`)
|
|
69
|
+
: null;
|
|
70
|
+
const findings = [];
|
|
71
|
+
|
|
72
|
+
for (const rule of rules) {
|
|
73
|
+
if (rule.requires && !rule.requires.test(code)) continue;
|
|
74
|
+
if (rule.fileExclude && rule.fileExclude.test(code)) continue;
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
const m = rule.sink.exec(rule.inStrings ? line : bare[i]);
|
|
78
|
+
if (!m) continue;
|
|
79
|
+
if (rule.lineHint && !rule.lineHint.test(line)) continue;
|
|
80
|
+
if (rule.exclude && rule.exclude.test(line)) continue;
|
|
81
|
+
|
|
82
|
+
const window = lines.slice(i, i + 3).join('\n');
|
|
83
|
+
// From the sink onwards (including the sink itself, which may already contain $_GET, req.body, …).
|
|
84
|
+
const args = argsAfter(window, m.index);
|
|
85
|
+
const isTainted = SOURCES[lang].test(args) || (taintRe?.test(args) ?? false);
|
|
86
|
+
const isDynamic = isTainted || DYNAMIC.test(args);
|
|
87
|
+
if (rule.when === 'tainted' && !isTainted) continue;
|
|
88
|
+
if (rule.when === 'dynamic' && !isDynamic) continue;
|
|
89
|
+
|
|
90
|
+
const score = isTainted ? (rule.taintedSev ?? rule.sev) : rule.sev;
|
|
91
|
+
findings.push({
|
|
92
|
+
id: `code:${rule.id}:${rel}:${i + 1}`,
|
|
93
|
+
kind: rule.category === 'secret' ? 'secret' : 'code',
|
|
94
|
+
title: rule.title,
|
|
95
|
+
ruleId: rule.id,
|
|
96
|
+
category: rule.category,
|
|
97
|
+
cwe: rule.cwe,
|
|
98
|
+
severityScore: score,
|
|
99
|
+
severity: levelFromScore(score),
|
|
100
|
+
confidence: isTainted ? 'high' : rule.when === 'always' ? 'medium' : 'low',
|
|
101
|
+
tainted: isTainted,
|
|
102
|
+
context: rule.context ?? fileContext,
|
|
103
|
+
location: { file: rel, line: i + 1, snippet: clip(raw[i].trim(), 240) },
|
|
104
|
+
fix: rule.fix,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function scanCode(src) {
|
|
112
|
+
const findings = [];
|
|
113
|
+
for (const [rel, context] of src.contexts) {
|
|
114
|
+
findings.push(...scanFile(rel, src.texts.get(rel), context));
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
'node_modules', '.git', '.hg', '.svn', '.next', '.nuxt', '.output', '.svelte-kit', '.vercel', '.netlify', '.turbo',
|
|
6
|
+
'.cache', '.parcel-cache', '.idea', '.vscode', 'dist', 'build', 'out', 'coverage', 'storybook-static', 'vendor',
|
|
7
|
+
'__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache', 'bower_components', '.yarn', '.pnpm-store',
|
|
8
|
+
'.iamsecure',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const CODE_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.astro', '.php', '.py']);
|
|
12
|
+
const CONFIG_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.properties', '.sh', '.tf', '.xml', '.conf', '.cfg']);
|
|
13
|
+
const MAX_SIZE = 1024 * 1024;
|
|
14
|
+
const MAX_FILES = 25000;
|
|
15
|
+
|
|
16
|
+
/** Very small .gitignore support: plain names, dir/ names, and simple * globs. */
|
|
17
|
+
function loadIgnore(root) {
|
|
18
|
+
let lines = [];
|
|
19
|
+
try {
|
|
20
|
+
lines = fs.readFileSync(path.join(root, '.gitignore'), 'utf8').split(/\r?\n/);
|
|
21
|
+
} catch {}
|
|
22
|
+
const rules = lines
|
|
23
|
+
.map((l) => l.trim())
|
|
24
|
+
.filter((l) => l && !l.startsWith('#') && !l.startsWith('!'))
|
|
25
|
+
.map((l) => {
|
|
26
|
+
const anchored = l.startsWith('/');
|
|
27
|
+
const p = l.replace(/^\//, '').replace(/\/$/, '');
|
|
28
|
+
const re = new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/(?<!\.)\*/g, '[^/]*') + '$');
|
|
29
|
+
return { re, anchored, hasSlash: p.includes('/') };
|
|
30
|
+
});
|
|
31
|
+
return (rel) => {
|
|
32
|
+
const base = path.basename(rel);
|
|
33
|
+
return rules.some((r) => (r.anchored || r.hasSlash ? r.re.test(rel) : r.re.test(base)));
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk the project and return files worth scanning.
|
|
39
|
+
* .env files are always returned (even if gitignored) so secrets in them can be reported.
|
|
40
|
+
* @returns {{rel:string, abs:string, ext:string, kind:'code'|'config'|'env'}[]}
|
|
41
|
+
*/
|
|
42
|
+
export function walkFiles(root) {
|
|
43
|
+
const ignored = loadIgnore(root);
|
|
44
|
+
const out = [];
|
|
45
|
+
const stack = [''];
|
|
46
|
+
while (stack.length && out.length < MAX_FILES) {
|
|
47
|
+
const dir = stack.pop();
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = fs.readdirSync(path.join(root, dir), { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
56
|
+
if (e.isDirectory()) {
|
|
57
|
+
if (!SKIP_DIRS.has(e.name) && !ignored(rel)) stack.push(rel);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!e.isFile()) continue;
|
|
61
|
+
const isEnv = /^\.env(\..+)?$/.test(e.name) && !/\.(example|sample|template|dist)$/.test(e.name);
|
|
62
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
63
|
+
if (!isEnv && ignored(rel)) continue;
|
|
64
|
+
if (!isEnv && !CODE_EXT.has(ext) && !CONFIG_EXT.has(ext)) continue;
|
|
65
|
+
if (/\.min\.[cm]?js$|\.bundle\.js$|-lock\.json$|\.lock$|\.map$/.test(e.name) || e.name === 'package-lock.json') continue;
|
|
66
|
+
out.push({ rel, abs: path.join(root, rel), ext, kind: isEnv ? 'env' : CODE_EXT.has(ext) ? 'code' : 'config' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read a text file, or null if it's too big / binary / generated. */
|
|
73
|
+
export function readText(abs) {
|
|
74
|
+
try {
|
|
75
|
+
if (fs.statSync(abs).size > MAX_SIZE) return null;
|
|
76
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
77
|
+
if (text.includes('\u0000')) return null;
|
|
78
|
+
// Bundled/minified output: a handful of extremely long lines.
|
|
79
|
+
const lines = text.split('\n');
|
|
80
|
+
if (lines.length < 5 && text.length > 5000) return null;
|
|
81
|
+
return text;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { walkFiles, readText } from './files.js';
|
|
2
|
+
import { classifyFile, extractImports } from './context.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read the project's source once: classify every code file and index which packages it imports.
|
|
6
|
+
* @returns {{files: {rel:string, kind:string, ext:string, context:string|null}[], texts: Map<string,string>,
|
|
7
|
+
* contexts: Map<string,string>, usage: Map<string, {file:string,line:number,context:string,binding:string|null}[]>}}
|
|
8
|
+
*/
|
|
9
|
+
export function analyzeSource(project) {
|
|
10
|
+
const files = walkFiles(project.root);
|
|
11
|
+
const texts = new Map();
|
|
12
|
+
const contexts = new Map();
|
|
13
|
+
const usage = new Map();
|
|
14
|
+
|
|
15
|
+
for (const f of files) {
|
|
16
|
+
const text = readText(f.abs);
|
|
17
|
+
if (text === null) continue;
|
|
18
|
+
texts.set(f.rel, text);
|
|
19
|
+
if (f.kind !== 'code') continue;
|
|
20
|
+
f.context = classifyFile(f.rel, text, project.framework);
|
|
21
|
+
contexts.set(f.rel, f.context);
|
|
22
|
+
if (!/\.(php|py)$/.test(f.rel)) {
|
|
23
|
+
for (const imp of extractImports(text)) {
|
|
24
|
+
if (!usage.has(imp.pkg)) usage.set(imp.pkg, []);
|
|
25
|
+
usage.get(imp.pkg).push({ file: f.rel, line: imp.line, context: f.context, binding: imp.binding });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { files, texts, contexts, usage };
|
|
30
|
+
}
|