@4da/mcp-server 6.0.2 → 6.2.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/CHANGELOG.md +433 -0
- package/README.md +26 -18
- package/dist/db.d.ts +73 -8
- package/dist/db.js +180 -43
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +15 -9
- package/dist/embeddings.js +19 -7
- package/dist/gitignore.js +9 -3
- package/dist/index.js +43 -4
- package/dist/live/cache.d.ts +31 -2
- package/dist/live/cache.js +113 -1
- package/dist/live/changelog-classify.d.ts +9 -3
- package/dist/live/changelog-classify.js +125 -12
- package/dist/live/changelog.d.ts +22 -3
- package/dist/live/changelog.js +132 -38
- package/dist/live/dev-scope.d.ts +2 -2
- package/dist/live/fix-paths.d.ts +118 -0
- package/dist/live/fix-paths.js +498 -0
- package/dist/live/github-releases.d.ts +69 -0
- package/dist/live/github-releases.js +252 -0
- package/dist/live/go-registry.d.ts +5 -0
- package/dist/live/go-registry.js +1 -1
- package/dist/live/go-releases.d.ts +48 -0
- package/dist/live/go-releases.js +196 -0
- package/dist/live/index.d.ts +34 -8
- package/dist/live/index.js +66 -7
- package/dist/live/lockfile-parents.d.ts +61 -0
- package/dist/live/lockfile-parents.js +360 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
- package/dist/live/lockfile-parsers-pygo.js +178 -28
- package/dist/live/lockfile-parsers.d.ts +4 -1
- package/dist/live/lockfile-parsers.js +57 -24
- package/dist/live/lockfile-types.d.ts +12 -3
- package/dist/live/lockfile-types.js +2 -1
- package/dist/live/osv-ranges.d.ts +85 -0
- package/dist/live/osv-ranges.js +136 -0
- package/dist/live/osv-scanner.d.ts +1 -0
- package/dist/live/osv-scanner.js +85 -78
- package/dist/live/package-archive.d.ts +2 -0
- package/dist/live/package-archive.js +7 -4
- package/dist/live/pypi-releases.d.ts +44 -0
- package/dist/live/pypi-releases.js +103 -0
- package/dist/live/range-satisfies.d.ts +21 -0
- package/dist/live/range-satisfies.js +190 -0
- package/dist/live/rate-limiter.d.ts +6 -0
- package/dist/live/rate-limiter.js +5 -0
- package/dist/live/registry-health.js +2 -0
- package/dist/live/release-metadata.d.ts +17 -8
- package/dist/live/release-metadata.js +6 -3
- package/dist/live/resolution.d.ts +11 -1
- package/dist/live/resolution.js +16 -6
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +10 -2
- package/dist/live/types.d.ts +17 -1
- package/dist/live/upgrade-go.d.ts +62 -0
- package/dist/live/upgrade-go.js +130 -0
- package/dist/live/upgrade-pypi.d.ts +42 -0
- package/dist/live/upgrade-pypi.js +85 -0
- package/dist/live/upgrade-sources.d.ts +30 -11
- package/dist/live/upgrade-sources.js +93 -21
- package/dist/live/zip-archive.d.ts +22 -0
- package/dist/live/zip-archive.js +98 -0
- package/dist/memory-store.d.ts +19 -0
- package/dist/memory-store.js +8 -0
- package/dist/native-bindings.d.ts +7 -2
- package/dist/native-bindings.js +4 -0
- package/dist/project-tree.d.ts +50 -8
- package/dist/project-tree.js +121 -29
- package/dist/schema-registry.js +5 -5
- package/dist/server-instructions.d.ts +6 -0
- package/dist/server-instructions.js +20 -15
- package/dist/sqlite-driver.d.ts +134 -0
- package/dist/sqlite-driver.js +359 -0
- package/dist/tool-dispatch.d.ts +8 -1
- package/dist/tool-dispatch.js +17 -2
- package/dist/tools/agent-memory.js +10 -2
- package/dist/tools/app-plan.d.ts +11 -1
- package/dist/tools/app-plan.js +32 -6
- package/dist/tools/briefing-task-scope.d.ts +21 -2
- package/dist/tools/briefing-task-scope.js +97 -4
- package/dist/tools/decision-memory.js +9 -3
- package/dist/tools/dependency-check-signals.d.ts +23 -5
- package/dist/tools/dependency-check-signals.js +62 -19
- package/dist/tools/dependency-check-versions.d.ts +94 -0
- package/dist/tools/dependency-check-versions.js +213 -0
- package/dist/tools/dependency-check.d.ts +27 -11
- package/dist/tools/dependency-check.js +321 -74
- package/dist/tools/dependency-health.d.ts +1 -1
- package/dist/tools/dependency-health.js +5 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
- package/dist/tools/knowledge-gap-ranges.js +1 -1
- package/dist/tools/knowledge-gap-scope.d.ts +2 -2
- package/dist/tools/project-scope.d.ts +45 -0
- package/dist/tools/project-scope.js +119 -0
- package/dist/tools/record-feedback.js +1 -1
- package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
- package/dist/tools/upgrade-impact-callsites.js +23 -5
- package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
- package/dist/tools/upgrade-impact-changelog.js +79 -0
- package/dist/tools/upgrade-impact-imports.d.ts +42 -0
- package/dist/tools/upgrade-impact-imports.js +260 -0
- package/dist/tools/upgrade-impact-literals.js +1 -1
- package/dist/tools/upgrade-impact-report.d.ts +23 -0
- package/dist/tools/upgrade-impact-report.js +45 -6
- package/dist/tools/upgrade-impact.d.ts +13 -4
- package/dist/tools/upgrade-impact.js +236 -85
- package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +31 -3
- package/dist/tools/upgrade-planner.js +74 -19
- package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
- package/dist/tools/vulnerability-scan-concise.js +15 -3
- package/dist/tools/vulnerability-scan-format.d.ts +58 -1
- package/dist/tools/vulnerability-scan-format.js +107 -11
- package/dist/tools/vulnerability-scan.d.ts +104 -13
- package/dist/tools/vulnerability-scan.js +27 -3
- package/dist/tools/what-should-i-know.d.ts +22 -2
- package/dist/tools/what-should-i-know.js +122 -12
- package/package.json +10 -32
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* Network sources for `upgrade_impact`: registry version index, the target
|
|
4
4
|
* version's changelog (read from its registry archive) and OSV advisories.
|
|
5
5
|
*
|
|
6
|
-
* Privacy rule (Decision 1, option A): the only hosts contacted
|
|
7
|
-
* package's own registry (registry.npmjs.org; crates.io +
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* Privacy rule (Decision 1, option A): by default the only hosts contacted
|
|
7
|
+
* are the package's own registry (registry.npmjs.org; crates.io +
|
|
8
|
+
* static.crates.io; pypi.org + files.pythonhosted.org; proxy.golang.org) and
|
|
9
|
+
* api.osv.dev. A changelog is read from GitHub only when the user opts in
|
|
10
|
+
* (Decision 1, option B: FOURDA_GITHUB_CHANGELOG, github-releases.ts) and the
|
|
11
|
+
* archive has none for the range; otherwise the report hands the agent a
|
|
12
|
+
* `release_notes_url` built from registry metadata and says how to enable the
|
|
13
|
+
* fallback. Every request goes through `UpgradeNet`, which records the host
|
|
14
|
+
* so `_meta.sources` is a fact, not a claim, and which tests replace with a
|
|
15
|
+
* fake fetch.
|
|
14
16
|
*
|
|
15
17
|
* Request budget per call: index (1) + archive (1) + OSV for from/to (2) = 4,
|
|
16
18
|
* each cached (index 6 h, parsed changelog 7 d, OSV 1 h). The npm packument
|
|
@@ -18,8 +20,40 @@
|
|
|
18
20
|
* is cached — never the raw document.
|
|
19
21
|
*/
|
|
20
22
|
import { compareVersionPrecedence, parseSemverPrecedence } from "./semver-precedence.js";
|
|
21
|
-
import { ArchiveError, defaultFetch,
|
|
22
|
-
import { findChangelogFile, isChangelogName, parseChangelog } from "./changelog.js";
|
|
23
|
+
import { ArchiveError, defaultFetch, fetchArchiveBytes, readRootTextFiles } from "./package-archive.js";
|
|
24
|
+
import { CHANGELOG_PARSER_VERSION, findChangelogFile, isChangelogName, parseChangelog } from "./changelog.js";
|
|
25
|
+
import { compareVersions, isComparable } from "./version-compare.js";
|
|
26
|
+
import { isPrereleaseVersion } from "./osv-ranges.js";
|
|
27
|
+
import { looksLikeZip, readZipRootTextFiles } from "./zip-archive.js";
|
|
28
|
+
import { loadPypiIndex, normalizePypiName, PYPI_NAME } from "./upgrade-pypi.js";
|
|
29
|
+
import { GO_MODULE, goZipUrl, loadGoIndex } from "./upgrade-go.js";
|
|
30
|
+
export const UPGRADE_ECOSYSTEMS = ["npm", "crates.io", "PyPI", "Go"];
|
|
31
|
+
/** Version order for an ecosystem: semver precedence (npm, crates.io, Go without its `v`), PEP 440 (PyPI). Null = unreadable. */
|
|
32
|
+
export function versionOrder(ecosystem) {
|
|
33
|
+
if (ecosystem === "npm" || ecosystem === "crates.io")
|
|
34
|
+
return compareVersionPrecedence;
|
|
35
|
+
return (a, b) => (isComparable(a, ecosystem) && isComparable(b, ecosystem) ? compareVersions(a, b, ecosystem) : null);
|
|
36
|
+
}
|
|
37
|
+
/** Readable as a version in this ecosystem. */
|
|
38
|
+
export function isReadableVersion(version, ecosystem) {
|
|
39
|
+
if (ecosystem === "npm" || ecosystem === "crates.io")
|
|
40
|
+
return parseSemverPrecedence(version) !== null;
|
|
41
|
+
return version !== "0" && isComparable(version, ecosystem);
|
|
42
|
+
}
|
|
43
|
+
/** A prerelease in this ecosystem's own terms (PEP 440 `a`/`b`/`rc`/`.dev`; semver `-x`; a Go pseudo-version). */
|
|
44
|
+
export function isPrerelease(version, ecosystem) {
|
|
45
|
+
if (ecosystem === "npm" || ecosystem === "crates.io")
|
|
46
|
+
return (parseSemverPrecedence(version)?.prerelease.length ?? 0) > 0;
|
|
47
|
+
return isPrereleaseVersion(version, ecosystem);
|
|
48
|
+
}
|
|
49
|
+
/** Package-name identity: crates.io folds `-`/`_`, PyPI normalises per PEP 503, npm and Go are exact. */
|
|
50
|
+
export function sameUpgradeName(a, b, ecosystem) {
|
|
51
|
+
if (ecosystem === "crates.io")
|
|
52
|
+
return a.replace(/-/g, "_") === b.replace(/-/g, "_");
|
|
53
|
+
if (ecosystem === "PyPI")
|
|
54
|
+
return normalizePypiName(a) === normalizePypiName(b);
|
|
55
|
+
return a === b;
|
|
56
|
+
}
|
|
23
57
|
/** crates.io refuses API requests without a descriptive User-Agent. */
|
|
24
58
|
export const CRATES_USER_AGENT = "4da-mcp-server (https://4da.ai; upgrade_impact)";
|
|
25
59
|
const INDEX_TTL = 6 * 3600;
|
|
@@ -42,7 +76,16 @@ export function createUpgradeNet(cache, fetchFn = defaultFetch) {
|
|
|
42
76
|
const NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
43
77
|
const CRATE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
44
78
|
export function isValidPackageName(name, ecosystem) {
|
|
45
|
-
|
|
79
|
+
switch (ecosystem) {
|
|
80
|
+
case "npm":
|
|
81
|
+
return NPM_NAME.test(name) && name.length <= 214;
|
|
82
|
+
case "crates.io":
|
|
83
|
+
return CRATE_NAME.test(name);
|
|
84
|
+
case "PyPI":
|
|
85
|
+
return PYPI_NAME.test(name);
|
|
86
|
+
case "Go":
|
|
87
|
+
return GO_MODULE.test(name) && name.length <= 300 && !name.split("/").some((p) => p === "." || p === "..");
|
|
88
|
+
}
|
|
46
89
|
}
|
|
47
90
|
async function cached(net, key, ttl, load) {
|
|
48
91
|
const hit = net.cache?.get(key);
|
|
@@ -99,21 +142,36 @@ async function loadCratesIndex(net, name) {
|
|
|
99
142
|
}
|
|
100
143
|
/** Registry index with versions in ascending precedence order (unreadable versions dropped). Null = unknown package. */
|
|
101
144
|
export async function getRegistryIndex(net, ecosystem, name) {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
145
|
+
const keyName = ecosystem === "crates.io" ? name.replace(/-/g, "_") : ecosystem === "PyPI" ? normalizePypiName(name) : name;
|
|
146
|
+
const key = `upgrade-impact:index:${ecosystem}:${keyName}`;
|
|
147
|
+
const index = await cached(net, key, INDEX_TTL, () => {
|
|
148
|
+
switch (ecosystem) {
|
|
149
|
+
case "npm":
|
|
150
|
+
return loadNpmIndex(net, name);
|
|
151
|
+
case "crates.io":
|
|
152
|
+
return loadCratesIndex(net, name);
|
|
153
|
+
case "PyPI":
|
|
154
|
+
return loadPypiIndex(net, name);
|
|
155
|
+
case "Go":
|
|
156
|
+
return loadGoIndex(net, name);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
104
159
|
if (!index)
|
|
105
160
|
return null;
|
|
161
|
+
const order = versionOrder(ecosystem);
|
|
106
162
|
const versions = index.versions
|
|
107
|
-
.filter((v) =>
|
|
108
|
-
.sort((a, b) =>
|
|
163
|
+
.filter((v) => isReadableVersion(v.version, ecosystem))
|
|
164
|
+
.sort((a, b) => order(a.version, b.version) ?? 0);
|
|
109
165
|
return { ...index, versions };
|
|
110
166
|
}
|
|
111
|
-
const ARCHIVE_HOSTS = new Set(["registry.npmjs.org", "static.crates.io"]);
|
|
167
|
+
const ARCHIVE_HOSTS = new Set(["registry.npmjs.org", "static.crates.io", "files.pythonhosted.org", "proxy.golang.org"]);
|
|
112
168
|
/** The registry archive URL for one version, or null when it is not on the package's own registry. */
|
|
113
169
|
export function archiveUrl(index, version) {
|
|
114
170
|
const url = index.ecosystem === "crates.io"
|
|
115
171
|
? `https://static.crates.io/crates/${index.name}/${index.name}-${version.version}.crate`
|
|
116
|
-
:
|
|
172
|
+
: index.ecosystem === "Go"
|
|
173
|
+
? goZipUrl(index.name, version.version)
|
|
174
|
+
: version.tarball;
|
|
117
175
|
if (!url)
|
|
118
176
|
return null;
|
|
119
177
|
try {
|
|
@@ -124,20 +182,34 @@ export function archiveUrl(index, version) {
|
|
|
124
182
|
return null;
|
|
125
183
|
}
|
|
126
184
|
}
|
|
185
|
+
/** Cache key of one version's parsed changelog; it carries CHANGELOG_PARSER_VERSION. */
|
|
186
|
+
export function changelogCacheKey(ecosystem, name, version) {
|
|
187
|
+
return `upgrade-impact:changelog:p${CHANGELOG_PARSER_VERSION}:${ecosystem}:${name}:${version}`;
|
|
188
|
+
}
|
|
127
189
|
/** Parsed changelog shipped inside one version's archive (cached 7 days per version). */
|
|
128
190
|
export async function getChangelog(net, index, version) {
|
|
129
191
|
const url = archiveUrl(index, version);
|
|
130
192
|
if (!url) {
|
|
131
|
-
return {
|
|
193
|
+
return {
|
|
194
|
+
found: false,
|
|
195
|
+
reason: index.ecosystem === "PyPI" && !version.tarball
|
|
196
|
+
? `${version.version} has no source distribution on PyPI (wheels only), so there is no archive changelog to read`
|
|
197
|
+
: "the version's archive is not hosted on the package's own registry",
|
|
198
|
+
};
|
|
132
199
|
}
|
|
133
|
-
|
|
200
|
+
// The parser version is in the key: a parser fix must not wait out the 7-day TTL.
|
|
201
|
+
const key = changelogCacheKey(index.ecosystem, index.name, version.version);
|
|
134
202
|
const hit = net.cache?.get(key);
|
|
135
203
|
if (hit)
|
|
136
204
|
return hit;
|
|
137
205
|
const headers = index.ecosystem === "crates.io" ? { "User-Agent": CRATES_USER_AGENT } : {};
|
|
138
206
|
let files;
|
|
139
207
|
try {
|
|
140
|
-
|
|
208
|
+
const bytes = await fetchArchiveBytes(url, net.fetch, headers);
|
|
209
|
+
// A Go module zip holds its files under `<module>@<version>/`; a zip sdist and every tar one directory deep.
|
|
210
|
+
files = looksLikeZip(bytes)
|
|
211
|
+
? readZipRootTextFiles(bytes, isChangelogName, index.ecosystem === "Go" ? `${index.name}@${version.version}/` : null)
|
|
212
|
+
: readRootTextFiles(bytes, isChangelogName);
|
|
141
213
|
}
|
|
142
214
|
catch (err) {
|
|
143
215
|
const result = { found: false, reason: `could not read the ${version.version} archive: ${err.message}` };
|
|
@@ -153,7 +225,7 @@ export async function getChangelog(net, index, version) {
|
|
|
153
225
|
? { found: false, reason: `the ${version.version} archive ships no changelog file` }
|
|
154
226
|
: sections.length === 0
|
|
155
227
|
? { found: false, file, reason: `${file} has no version headings this parser recognises` }
|
|
156
|
-
: { found: true, file, sections };
|
|
228
|
+
: { found: true, file, sections, source: "archive" };
|
|
157
229
|
net.cache?.set(key, result, CACHE_SOURCE, CHANGELOG_TTL);
|
|
158
230
|
return result;
|
|
159
231
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zip archive reader for `upgrade_impact`, Node built-ins only.
|
|
3
|
+
*
|
|
4
|
+
* Go module zips (proxy.golang.org `/@v/<version>.zip`) and the occasional
|
|
5
|
+
* PyPI `.zip` sdist are zip files, not gzip tars. The job is the same as
|
|
6
|
+
* package-archive.ts's: pull a few root-level text files (the changelog) out
|
|
7
|
+
* of a third-party archive without trusting it. Only the central directory
|
|
8
|
+
* is read, so a file's declared sizes come from one place; stored (0) and
|
|
9
|
+
* deflate (8) entries are read, everything else (zip64, encryption, other
|
|
10
|
+
* methods) is skipped rather than guessed at. Each inflate is capped at
|
|
11
|
+
* MAX_FILE_BYTES, so a zip bomb stops at the cap.
|
|
12
|
+
*/
|
|
13
|
+
/** True when the buffer starts like a zip archive (a local file header). */
|
|
14
|
+
export declare function looksLikeZip(buf: Buffer): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Text files directly under `root` (a path prefix such as
|
|
17
|
+
* `github.com/foo/bar@v1.2.3/`; "" for every top-level file of a one-directory
|
|
18
|
+
* archive) whose basename `accept` approves. When `root` is null, files one
|
|
19
|
+
* directory deep are read, as the tar reader does. Oversized, binary and
|
|
20
|
+
* unreadable entries are skipped.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readZipRootTextFiles(buf: Buffer, accept: (basename: string) => boolean, root?: string | null): Map<string, string>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Zip archive reader for `upgrade_impact`, Node built-ins only.
|
|
4
|
+
*
|
|
5
|
+
* Go module zips (proxy.golang.org `/@v/<version>.zip`) and the occasional
|
|
6
|
+
* PyPI `.zip` sdist are zip files, not gzip tars. The job is the same as
|
|
7
|
+
* package-archive.ts's: pull a few root-level text files (the changelog) out
|
|
8
|
+
* of a third-party archive without trusting it. Only the central directory
|
|
9
|
+
* is read, so a file's declared sizes come from one place; stored (0) and
|
|
10
|
+
* deflate (8) entries are read, everything else (zip64, encryption, other
|
|
11
|
+
* methods) is skipped rather than guessed at. Each inflate is capped at
|
|
12
|
+
* MAX_FILE_BYTES, so a zip bomb stops at the cap.
|
|
13
|
+
*/
|
|
14
|
+
import { inflateRawSync } from "node:zlib";
|
|
15
|
+
import { ArchiveError, MAX_COMPRESSED_BYTES, MAX_FILE_BYTES } from "./package-archive.js";
|
|
16
|
+
const EOCD_SIG = 0x06054b50;
|
|
17
|
+
const CDH_SIG = 0x02014b50;
|
|
18
|
+
const LFH_SIG = 0x04034b50;
|
|
19
|
+
/** The end-of-central-directory record is 22 bytes plus a comment of at most 65,535. */
|
|
20
|
+
const EOCD_SEARCH = 22 + 0xffff;
|
|
21
|
+
const MAX_ENTRIES = 200_000;
|
|
22
|
+
/** True when the buffer starts like a zip archive (a local file header). */
|
|
23
|
+
export function looksLikeZip(buf) {
|
|
24
|
+
return buf.length >= 4 && buf.readUInt32LE(0) === LFH_SIG;
|
|
25
|
+
}
|
|
26
|
+
function findEocd(buf) {
|
|
27
|
+
const stop = Math.max(0, buf.length - EOCD_SEARCH);
|
|
28
|
+
for (let i = buf.length - 22; i >= stop; i--) {
|
|
29
|
+
if (buf.readUInt32LE(i) === EOCD_SIG)
|
|
30
|
+
return i;
|
|
31
|
+
}
|
|
32
|
+
throw new ArchiveError("not a zip archive (no end-of-central-directory record)");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Text files directly under `root` (a path prefix such as
|
|
36
|
+
* `github.com/foo/bar@v1.2.3/`; "" for every top-level file of a one-directory
|
|
37
|
+
* archive) whose basename `accept` approves. When `root` is null, files one
|
|
38
|
+
* directory deep are read, as the tar reader does. Oversized, binary and
|
|
39
|
+
* unreadable entries are skipped.
|
|
40
|
+
*/
|
|
41
|
+
export function readZipRootTextFiles(buf, accept, root = null) {
|
|
42
|
+
if (buf.length > MAX_COMPRESSED_BYTES) {
|
|
43
|
+
throw new ArchiveError(`archive is ${buf.length} bytes compressed (cap ${MAX_COMPRESSED_BYTES})`);
|
|
44
|
+
}
|
|
45
|
+
if (buf.length < 22)
|
|
46
|
+
throw new ArchiveError("not a zip archive (too short)");
|
|
47
|
+
const eocd = findEocd(buf);
|
|
48
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
49
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
50
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
51
|
+
if (cdOffset === 0xffffffff || count === 0xffff)
|
|
52
|
+
throw new ArchiveError("zip64 archives are not read");
|
|
53
|
+
if (cdOffset + cdSize > buf.length || count > MAX_ENTRIES)
|
|
54
|
+
throw new ArchiveError("corrupt zip central directory");
|
|
55
|
+
const files = new Map();
|
|
56
|
+
let p = cdOffset;
|
|
57
|
+
for (let n = 0; n < count; n++) {
|
|
58
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CDH_SIG)
|
|
59
|
+
throw new ArchiveError("corrupt zip central directory entry");
|
|
60
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
61
|
+
const method = buf.readUInt16LE(p + 10);
|
|
62
|
+
const compressed = buf.readUInt32LE(p + 20);
|
|
63
|
+
const size = buf.readUInt32LE(p + 24);
|
|
64
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
65
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
66
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
67
|
+
const local = buf.readUInt32LE(p + 42);
|
|
68
|
+
const name = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
|
|
69
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
70
|
+
if (name.endsWith("/") || !isRootFile(name, root) || !accept(name.split("/").pop() ?? ""))
|
|
71
|
+
continue;
|
|
72
|
+
if (flags & 0x1 || (method !== 0 && method !== 8) || size > MAX_FILE_BYTES)
|
|
73
|
+
continue;
|
|
74
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== LFH_SIG)
|
|
75
|
+
continue;
|
|
76
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
77
|
+
if (start + compressed > buf.length)
|
|
78
|
+
continue;
|
|
79
|
+
const raw = buf.subarray(start, start + compressed);
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = method === 0 ? Buffer.from(raw) : inflateRawSync(raw, { maxOutputLength: MAX_FILE_BYTES });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (data.includes(0))
|
|
88
|
+
continue;
|
|
89
|
+
files.set(name, data.toString("utf8"));
|
|
90
|
+
}
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
function isRootFile(name, root) {
|
|
94
|
+
if (root !== null)
|
|
95
|
+
return name.startsWith(root) && !name.slice(root.length).includes("/") && name.length > root.length;
|
|
96
|
+
const parts = name.split("/").filter(Boolean);
|
|
97
|
+
return parts.length > 0 && parts.length <= 2;
|
|
98
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a decision, agent memory or feedback record is written.
|
|
3
|
+
*
|
|
4
|
+
* Reads go through the database's main connection (read-only for the desktop
|
|
5
|
+
* app's database). A write into one of the app-owned tables the tools exist to
|
|
6
|
+
* write goes through FourDADatabase.writerFor: the app's own table via a
|
|
7
|
+
* separate write connection, or, when the app's schema lacks the table or a
|
|
8
|
+
* column, the server's store with a note. A stand-in object without writerFor
|
|
9
|
+
* (some tests hand tools a minimal double) writes to its own connection.
|
|
10
|
+
*/
|
|
11
|
+
import type { AppWritableTable, FourDADatabase, WriteTarget } from "./db.js";
|
|
12
|
+
export declare function writerOf(db: FourDADatabase, table: AppWritableTable, columns: readonly string[]): WriteTarget;
|
|
13
|
+
/** `_meta` for a tool result when the record could not go where it normally goes. */
|
|
14
|
+
export declare function writeMeta(target: WriteTarget): {
|
|
15
|
+
_meta?: {
|
|
16
|
+
written_to: string;
|
|
17
|
+
note: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function writerOf(db, table, columns) {
|
|
2
|
+
const writerFor = db.writerFor;
|
|
3
|
+
return typeof writerFor === "function" ? writerFor.call(db, table, columns) : { raw: db.getRawDb(), target: "database" };
|
|
4
|
+
}
|
|
5
|
+
/** `_meta` for a tool result when the record could not go where it normally goes. */
|
|
6
|
+
export function writeMeta(target) {
|
|
7
|
+
return target.note ? { _meta: { written_to: target.target, note: target.note } } : {};
|
|
8
|
+
}
|
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
* (local or -g) left no better_sqlite3.node, `--doctor` still reported the
|
|
9
9
|
* bindings as loaded, and every tool failed with "Could not locate the
|
|
10
10
|
* bindings file" and no hint why. `npx @4da/mcp-server` was unaffected.
|
|
11
|
+
*
|
|
12
|
+
* Since 6.1.0 the server uses Node's built-in node:sqlite when the runtime
|
|
13
|
+
* has it (sqlite-driver.ts), so this matters only on Node 22.0-22.12, where
|
|
14
|
+
* better-sqlite3 is the one driver.
|
|
11
15
|
*/
|
|
12
|
-
import type BetterSqlite3 from "better-sqlite3";
|
|
13
16
|
export declare const NATIVE_BINDING_FIX: string;
|
|
14
17
|
/** Errors that mean the compiled module is absent or built for another Node/platform. */
|
|
15
18
|
export declare function isNativeBindingError(err: unknown): boolean;
|
|
16
19
|
/** The first line of a native-binding error, with the fix appended. */
|
|
17
20
|
export declare function nativeBindingMessage(err: unknown): string;
|
|
18
21
|
/** Null when an in-memory database opens; otherwise why not, with the fix. */
|
|
19
|
-
export declare function checkNativeBindings(Database:
|
|
22
|
+
export declare function checkNativeBindings(Database: new (file: string) => {
|
|
23
|
+
close(): unknown;
|
|
24
|
+
}): string | null;
|
package/dist/native-bindings.js
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* (local or -g) left no better_sqlite3.node, `--doctor` still reported the
|
|
10
10
|
* bindings as loaded, and every tool failed with "Could not locate the
|
|
11
11
|
* bindings file" and no hint why. `npx @4da/mcp-server` was unaffected.
|
|
12
|
+
*
|
|
13
|
+
* Since 6.1.0 the server uses Node's built-in node:sqlite when the runtime
|
|
14
|
+
* has it (sqlite-driver.ts), so this matters only on Node 22.0-22.12, where
|
|
15
|
+
* better-sqlite3 is the one driver.
|
|
12
16
|
*/
|
|
13
17
|
// Each remedy measured on npm 12.2.0 (node:24-bookworm-slim), 2026-10-03.
|
|
14
18
|
export const NATIVE_BINDING_FIX = "npm 12+ blocks dependency install scripts unless allowed. With npx: allow it once with " +
|
package/dist/project-tree.d.ts
CHANGED
|
@@ -9,23 +9,65 @@
|
|
|
9
9
|
* app does not have this problem — it records each dependency's own manifest
|
|
10
10
|
* directory — so this is the standalone counterpart of that inventory.
|
|
11
11
|
*
|
|
12
|
-
* A directory joins when it holds a lockfile (or go.mod / requirements
|
|
13
|
-
* which carry exact versions themselves). Workspace members that share
|
|
14
|
-
* root's lockfile are covered by the root.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* A directory joins when it holds a lockfile (or go.mod / a requirements
|
|
13
|
+
* file, which carry exact versions themselves). Workspace members that share
|
|
14
|
+
* their root's lockfile are covered by the root. Directories that hold
|
|
15
|
+
* installed code, build output, test fixtures or tool worktrees are skipped.
|
|
16
|
+
*
|
|
17
|
+
* Bounded (MAX_DEPTH levels below the root, MAX_PROJECTS projects), and the
|
|
18
|
+
* bound is never silent. langchain keeps 108 lockfiles up to three levels
|
|
19
|
+
* down; the old bounds (depth 2, 64 projects) read 64 and dropped 44 with no
|
|
20
|
+
* trace in any answer (2026-10-10). Past a bound the walk keeps going without
|
|
21
|
+
* scanning, up to MAX_WALK_DIRS directories, so `discovery` can name every
|
|
22
|
+
* lockfile that was left out and say why.
|
|
17
23
|
*/
|
|
18
24
|
import { type ProjectScanResult } from "./project-scanner.js";
|
|
25
|
+
/**
|
|
26
|
+
* Measured on langchain (108 lockfiles, up to 3 levels down; 2026-10-10):
|
|
27
|
+
* the walk plus every project scan takes about 2 s. Depth 4 covers it with a
|
|
28
|
+
* level to spare; 256 projects is 2.4x its count.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MAX_DEPTH = 4;
|
|
31
|
+
export declare const MAX_PROJECTS = 256;
|
|
19
32
|
export interface ProjectTreeEntry {
|
|
20
33
|
dir: string;
|
|
21
34
|
scan: ProjectScanResult;
|
|
22
35
|
}
|
|
36
|
+
/** What the walk left out, so an answer can say it is partial. */
|
|
37
|
+
export interface TreeDiscovery {
|
|
38
|
+
/** Projects scanned (the root included). */
|
|
39
|
+
projects: number;
|
|
40
|
+
maxDepth: number;
|
|
41
|
+
maxProjects: number;
|
|
42
|
+
/** Lockfiles found but not scanned, relative to the root (the first MAX_LISTED_SKIPS). */
|
|
43
|
+
skipped: Array<{
|
|
44
|
+
path: string;
|
|
45
|
+
reason: "depth" | "project_limit";
|
|
46
|
+
}>;
|
|
47
|
+
/** Every lockfile found but not scanned (`skipped` may list fewer). */
|
|
48
|
+
skippedCount: number;
|
|
49
|
+
/** True when the walk itself stopped at MAX_WALK_DIRS: lockfiles beyond it were not even counted. */
|
|
50
|
+
walkTruncated: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** Python requirement files: requirements.txt, requirements-dev.txt, dev_requirements.txt, _test_minimum_requirements.txt. */
|
|
53
|
+
export declare function isRequirementsFileName(name: string): boolean;
|
|
54
|
+
/** The lockfiles (and exact-pin files) that make a directory a project, by name. */
|
|
55
|
+
export declare function lockfilesIn(dir: string, listing?: string[]): string[];
|
|
23
56
|
/**
|
|
24
57
|
* The root (always) and every locked project below it, each with its own
|
|
25
|
-
* scan
|
|
26
|
-
* part of the project and are
|
|
58
|
+
* scan, plus what the bounds left out. Directories and lockfiles the
|
|
59
|
+
* repository's .gitignore excludes are not part of the project and are
|
|
60
|
+
* skipped (gitignore.ts); they are not reported as left out either.
|
|
27
61
|
*/
|
|
28
|
-
export declare function scanProjectTree(root: string
|
|
62
|
+
export declare function scanProjectTree(root: string, limits?: {
|
|
63
|
+
maxDepth: number;
|
|
64
|
+
maxProjects: number;
|
|
65
|
+
}): {
|
|
66
|
+
entries: ProjectTreeEntry[];
|
|
67
|
+
discovery: TreeDiscovery;
|
|
68
|
+
};
|
|
69
|
+
/** One sentence for an answer, or null when nothing was left out. */
|
|
70
|
+
export declare function discoveryNote(discovery: TreeDiscovery | null): string | null;
|
|
29
71
|
/**
|
|
30
72
|
* Resolution groups for the live layer: one per (project directory, ecosystem).
|
|
31
73
|
* A lockfile whose manifest declared nothing the scanner could read still gets
|
package/dist/project-tree.js
CHANGED
|
@@ -10,11 +10,17 @@
|
|
|
10
10
|
* app does not have this problem — it records each dependency's own manifest
|
|
11
11
|
* directory — so this is the standalone counterpart of that inventory.
|
|
12
12
|
*
|
|
13
|
-
* A directory joins when it holds a lockfile (or go.mod / requirements
|
|
14
|
-
* which carry exact versions themselves). Workspace members that share
|
|
15
|
-
* root's lockfile are covered by the root.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* A directory joins when it holds a lockfile (or go.mod / a requirements
|
|
14
|
+
* file, which carry exact versions themselves). Workspace members that share
|
|
15
|
+
* their root's lockfile are covered by the root. Directories that hold
|
|
16
|
+
* installed code, build output, test fixtures or tool worktrees are skipped.
|
|
17
|
+
*
|
|
18
|
+
* Bounded (MAX_DEPTH levels below the root, MAX_PROJECTS projects), and the
|
|
19
|
+
* bound is never silent. langchain keeps 108 lockfiles up to three levels
|
|
20
|
+
* down; the old bounds (depth 2, 64 projects) read 64 and dropped 44 with no
|
|
21
|
+
* trace in any answer (2026-10-10). Past a bound the walk keeps going without
|
|
22
|
+
* scanning, up to MAX_WALK_DIRS directories, so `discovery` can name every
|
|
23
|
+
* lockfile that was left out and say why.
|
|
18
24
|
*/
|
|
19
25
|
import * as fs from "node:fs";
|
|
20
26
|
import * as path from "node:path";
|
|
@@ -29,60 +35,143 @@ const LOCK_MARKERS = [
|
|
|
29
35
|
"Cargo.lock",
|
|
30
36
|
"poetry.lock",
|
|
31
37
|
"uv.lock",
|
|
38
|
+
"pdm.lock",
|
|
32
39
|
"Pipfile.lock",
|
|
33
40
|
"go.mod",
|
|
34
|
-
"requirements.txt",
|
|
35
41
|
];
|
|
36
42
|
const SKIP_DIRS = new Set([
|
|
37
43
|
"node_modules", "target", ".git", "dist", "build", "out", "vendor", ".venv", "venv",
|
|
38
44
|
"__pycache__", ".next", ".nuxt", "coverage", ".claude", ".codex", ".cursor", "fixtures",
|
|
39
45
|
"__fixtures__", "testdata", "test-fixtures", "__tests__", ".turbo", ".cache", ".pnpm-store",
|
|
40
46
|
]);
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Measured on langchain (108 lockfiles, up to 3 levels down; 2026-10-10):
|
|
49
|
+
* the walk plus every project scan takes about 2 s. Depth 4 covers it with a
|
|
50
|
+
* level to spare; 256 projects is 2.4x its count.
|
|
51
|
+
*/
|
|
52
|
+
export const MAX_DEPTH = 4;
|
|
53
|
+
export const MAX_PROJECTS = 256;
|
|
54
|
+
/** Directories visited in total, scanned or not: the hard cap on the walk itself. */
|
|
55
|
+
const MAX_WALK_DIRS = 20_000;
|
|
56
|
+
/** Lockfile paths listed in `discovery.skipped`; the count covers the rest. */
|
|
57
|
+
const MAX_LISTED_SKIPS = 50;
|
|
58
|
+
/** Python requirement files: requirements.txt, requirements-dev.txt, dev_requirements.txt, _test_minimum_requirements.txt. */
|
|
59
|
+
export function isRequirementsFileName(name) {
|
|
60
|
+
return /requirements[\w.-]*\.txt$/i.test(name);
|
|
61
|
+
}
|
|
62
|
+
/** The lockfiles (and exact-pin files) that make a directory a project, by name. */
|
|
63
|
+
export function lockfilesIn(dir, listing) {
|
|
64
|
+
let names;
|
|
65
|
+
try {
|
|
66
|
+
names = listing ?? fs.readdirSync(dir);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const found = names.filter((n) => LOCK_MARKERS.includes(n) || isRequirementsFileName(n));
|
|
72
|
+
// pip-compile-multi and similar keep the pins in a requirements/ folder (superset: requirements/base.txt).
|
|
73
|
+
if (names.includes("requirements")) {
|
|
74
|
+
try {
|
|
75
|
+
for (const n of fs.readdirSync(path.join(dir, "requirements")))
|
|
76
|
+
if (/\.txt$/i.test(n))
|
|
77
|
+
found.push(`requirements/${n}`);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// a file named "requirements"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
43
85
|
/**
|
|
44
86
|
* The root (always) and every locked project below it, each with its own
|
|
45
|
-
* scan
|
|
46
|
-
* part of the project and are
|
|
87
|
+
* scan, plus what the bounds left out. Directories and lockfiles the
|
|
88
|
+
* repository's .gitignore excludes are not part of the project and are
|
|
89
|
+
* skipped (gitignore.ts); they are not reported as left out either.
|
|
47
90
|
*/
|
|
48
|
-
export function scanProjectTree(root) {
|
|
91
|
+
export function scanProjectTree(root, limits = { maxDepth: MAX_DEPTH, maxProjects: MAX_PROJECTS }) {
|
|
49
92
|
const entries = [{ dir: root, scan: scanCurrentProject(root) }];
|
|
50
93
|
const ignore = new IgnoreRules(root);
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
94
|
+
const skipped = [];
|
|
95
|
+
let skippedCount = 0;
|
|
96
|
+
let visited = 0;
|
|
97
|
+
let walkTruncated = false;
|
|
98
|
+
// One directory listing per directory: it decides both whether the
|
|
99
|
+
// directory is a project and which children to descend into.
|
|
100
|
+
const listing = (dir) => {
|
|
56
101
|
try {
|
|
57
|
-
|
|
102
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
58
103
|
}
|
|
59
104
|
catch {
|
|
60
|
-
return;
|
|
105
|
+
return null;
|
|
61
106
|
}
|
|
107
|
+
};
|
|
108
|
+
const walk = (dir, rel, depth, children) => {
|
|
62
109
|
for (const child of children) {
|
|
63
|
-
if (entries.length >= MAX_PROJECTS)
|
|
64
|
-
return;
|
|
65
110
|
if (!child.isDirectory() || SKIP_DIRS.has(child.name) || child.name.startsWith("."))
|
|
66
111
|
continue;
|
|
112
|
+
if (++visited > MAX_WALK_DIRS) {
|
|
113
|
+
walkTruncated = true;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
67
116
|
const childRel = rel ? `${rel}/${child.name}` : child.name;
|
|
68
117
|
if (ignore.ignores(childRel, true))
|
|
69
118
|
continue;
|
|
70
119
|
const sub = path.join(dir, child.name);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
120
|
+
const grandchildren = listing(sub);
|
|
121
|
+
if (!grandchildren)
|
|
122
|
+
continue;
|
|
123
|
+
// A .gitignore is only read where one exists.
|
|
124
|
+
if (grandchildren.some((g) => g.name === ".gitignore"))
|
|
125
|
+
ignore.load(childRel);
|
|
126
|
+
const names = grandchildren.map((g) => g.name);
|
|
127
|
+
const locks = lockfilesIn(sub, names).filter((name) => !ignore.ignores(`${childRel}/${name}`, false));
|
|
128
|
+
if (locks.length > 0) {
|
|
129
|
+
const reason = depth > limits.maxDepth ? "depth" : entries.length >= limits.maxProjects ? "project_limit" : null;
|
|
130
|
+
if (reason === null) {
|
|
131
|
+
entries.push({ dir: sub, scan: scanCurrentProject(sub) });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
skippedCount += locks.length;
|
|
135
|
+
for (const name of locks)
|
|
136
|
+
if (skipped.length < MAX_LISTED_SKIPS)
|
|
137
|
+
skipped.push({ path: `${childRel}/${name}`, reason });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
walk(sub, childRel, depth + 1, grandchildren);
|
|
141
|
+
if (walkTruncated)
|
|
142
|
+
return;
|
|
76
143
|
}
|
|
77
144
|
};
|
|
78
|
-
walk(root, "", 1);
|
|
79
|
-
return
|
|
145
|
+
walk(root, "", 1, listing(root) ?? []);
|
|
146
|
+
return {
|
|
147
|
+
entries,
|
|
148
|
+
discovery: { projects: entries.length, maxDepth: limits.maxDepth, maxProjects: limits.maxProjects, skipped, skippedCount, walkTruncated },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** One sentence for an answer, or null when nothing was left out. */
|
|
152
|
+
export function discoveryNote(discovery) {
|
|
153
|
+
if (!discovery || (discovery.skippedCount === 0 && !discovery.walkTruncated))
|
|
154
|
+
return null;
|
|
155
|
+
const parts = [];
|
|
156
|
+
if (discovery.skippedCount > 0) {
|
|
157
|
+
const depth = discovery.skipped.some((s) => s.reason === "depth");
|
|
158
|
+
const limit = discovery.skipped.some((s) => s.reason === "project_limit");
|
|
159
|
+
const why = [
|
|
160
|
+
depth ? `deeper than ${discovery.maxDepth} levels` : null,
|
|
161
|
+
limit ? `past the ${discovery.maxProjects}-project limit` : null,
|
|
162
|
+
].filter(Boolean).join(" or ");
|
|
163
|
+
const listed = discovery.skipped.slice(0, 5).map((s) => s.path).join(", ");
|
|
164
|
+
parts.push(`${discovery.skippedCount} lockfile${discovery.skippedCount === 1 ? " was" : "s were"} found but NOT scanned (${why}): ${listed}${discovery.skippedCount > 5 ? ", ..." : ""}; pass project_path to scan one of them`);
|
|
165
|
+
}
|
|
166
|
+
if (discovery.walkTruncated)
|
|
167
|
+
parts.push(`the directory walk stopped after ${MAX_WALK_DIRS} directories, so lockfiles beyond that were not counted`);
|
|
168
|
+
return `Partial scan: ${parts.join("; ")}.`;
|
|
80
169
|
}
|
|
81
170
|
/** Lockfiles that make an ecosystem scannable in a directory even when no direct dependency was parsed. */
|
|
82
171
|
const LOCKS_BY_LANGUAGE = {
|
|
83
172
|
npm: ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"],
|
|
84
173
|
rust: ["Cargo.lock"],
|
|
85
|
-
python: ["poetry.lock", "uv.lock", "
|
|
174
|
+
python: ["poetry.lock", "uv.lock", "pdm.lock", "Pipfile.lock"],
|
|
86
175
|
go: ["go.mod"],
|
|
87
176
|
};
|
|
88
177
|
/**
|
|
@@ -94,8 +183,11 @@ const LOCKS_BY_LANGUAGE = {
|
|
|
94
183
|
export function treeResolutionGroups(entries) {
|
|
95
184
|
return entries.flatMap(({ dir, scan }) => {
|
|
96
185
|
const byLanguage = { ...scan.depsByEcosystem };
|
|
186
|
+
const present = lockfilesIn(dir);
|
|
187
|
+
const pins = present.some((n) => isRequirementsFileName(n) || n.startsWith("requirements/"));
|
|
97
188
|
for (const [language, locks] of Object.entries(LOCKS_BY_LANGUAGE)) {
|
|
98
|
-
|
|
189
|
+
const pinned = language === "python" && pins;
|
|
190
|
+
if (!byLanguage[language] && (pinned || locks.some((lock) => present.includes(lock)))) {
|
|
99
191
|
byLanguage[language] = { deps: [], devDeps: [] };
|
|
100
192
|
}
|
|
101
193
|
}
|