@functional-systems/srcx 0.1.0-dev.3 → 0.1.0-dev.30
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 +132 -1
- package/DESIGN.md +193 -58
- package/README.md +542 -38
- package/RELEASING.md +30 -9
- package/VALIDATION.md +834 -25
- package/dist/build.d.ts +81 -1
- package/dist/build.js +187 -37
- package/dist/build.js.map +1 -1
- package/dist/chunk.d.ts +9 -3
- package/dist/chunk.js +341 -14
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +103 -19
- package/dist/cli.js.map +1 -1
- package/dist/file-policy.d.ts +20 -0
- package/dist/file-policy.js +96 -0
- package/dist/file-policy.js.map +1 -0
- package/dist/implementation.d.ts +11 -0
- package/dist/implementation.js +191 -0
- package/dist/implementation.js.map +1 -0
- package/dist/publish.d.ts +18 -0
- package/dist/publish.js +158 -34
- package/dist/publish.js.map +1 -1
- package/dist/releases.js +21 -3
- package/dist/releases.js.map +1 -1
- package/dist/remote.js +38 -9
- package/dist/remote.js.map +1 -1
- package/dist/repository.d.ts +4 -1
- package/dist/repository.js +15 -11
- package/dist/repository.js.map +1 -1
- package/dist/rerank.d.ts +16 -0
- package/dist/rerank.js +87 -0
- package/dist/rerank.js.map +1 -0
- package/dist/search.d.ts +14 -4
- package/dist/search.js +119 -23
- package/dist/search.js.map +1 -1
- package/dist/skills.d.ts +24 -0
- package/dist/skills.js +155 -0
- package/dist/skills.js.map +1 -0
- package/package.json +16 -5
- package/runtime/grammars/README.md +50 -0
- package/runtime/grammars/tree-sitter-bash.LICENSE +21 -0
- package/runtime/grammars/tree-sitter-bash.wasm +0 -0
- package/runtime/grammars/tree-sitter-sql.LICENSE +21 -0
- package/runtime/grammars/tree-sitter-sql.wasm +0 -0
- package/runtime/grammars/tree-sitter-wasm.LICENSE +21 -0
- package/runtime/qwen.py +132 -0
- package/runtime/requirements.txt +34 -0
- package/skills/srcx-search/SKILL.md +85 -0
- package/src/build.ts +258 -47
- package/src/chunk.ts +360 -12
- package/src/cli.ts +212 -24
- package/src/file-policy.ts +157 -0
- package/src/implementation.ts +253 -0
- package/src/publish.ts +226 -34
- package/src/releases.ts +36 -3
- package/src/remote.ts +59 -13
- package/src/repository.ts +20 -11
- package/src/rerank.ts +131 -0
- package/src/search.ts +154 -28
- package/src/skills.ts +205 -0
package/src/cli.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import { Command, InvalidArgumentError, Option } from "commander";
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { configure, loadSettings, stateRoot } from "./settings.js";
|
|
8
|
-
import { identity } from "./git.js";
|
|
9
|
-
import {
|
|
8
|
+
import { identity, resolveCommit } from "./git.js";
|
|
9
|
+
import {
|
|
10
|
+
loadBuild,
|
|
11
|
+
materialize,
|
|
12
|
+
validateBuild,
|
|
13
|
+
type Build,
|
|
14
|
+
type Preset,
|
|
15
|
+
PRESET,
|
|
16
|
+
presetFor,
|
|
17
|
+
normalizeAnalyzers,
|
|
18
|
+
ANALYZERS,
|
|
19
|
+
} from "./build.js";
|
|
20
|
+
import { normalizeFilePolicy } from "./file-policy.js";
|
|
10
21
|
import { invariant, optionalJson } from "./common.js";
|
|
11
22
|
import { LambdaRemote } from "./remote.js";
|
|
12
23
|
import {
|
|
@@ -16,9 +27,17 @@ import {
|
|
|
16
27
|
register,
|
|
17
28
|
selectRepository,
|
|
18
29
|
} from "./repository.js";
|
|
19
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
exclusive,
|
|
32
|
+
lastBuildPath,
|
|
33
|
+
publish,
|
|
34
|
+
published,
|
|
35
|
+
type Published,
|
|
36
|
+
} from "./publish.js";
|
|
20
37
|
import { resolveVersion, syncTags } from "./releases.js";
|
|
21
38
|
import { directHandle, loadHandle, readHandle, search } from "./search.js";
|
|
39
|
+
import { candidateLimit } from "./rerank.js";
|
|
40
|
+
import { manageSkill, SKILL_AGENTS, SKILL_SCOPES } from "./skills.js";
|
|
22
41
|
const cli = new Command()
|
|
23
42
|
.name("srcx")
|
|
24
43
|
.description("Version-aware code search on LambdaDB")
|
|
@@ -39,10 +58,48 @@ const integer = (value: string) => {
|
|
|
39
58
|
throw new InvalidArgumentError("Integer is too large.");
|
|
40
59
|
return n;
|
|
41
60
|
};
|
|
61
|
+
const analyzerOption = (description: string) =>
|
|
62
|
+
new Option(
|
|
63
|
+
"--analyzers <names>",
|
|
64
|
+
description + ` (${ANALYZERS.join(", ")}; default: standard)`,
|
|
65
|
+
).argParser((value) =>
|
|
66
|
+
normalizeAnalyzers(value.split(",").map((name) => name.trim())),
|
|
67
|
+
);
|
|
68
|
+
const filePolicyOption = () =>
|
|
69
|
+
new Option(
|
|
70
|
+
"--file-policy <json>",
|
|
71
|
+
"Path to file policy JSON; pinned at registration or local dry run",
|
|
72
|
+
).argParser((path) =>
|
|
73
|
+
normalizeFilePolicy(JSON.parse(readFileSync(path, "utf8"))),
|
|
74
|
+
);
|
|
42
75
|
async function connected() {
|
|
43
76
|
const settings = await loadSettings();
|
|
44
77
|
return { settings, remote: new LambdaRemote(settings) };
|
|
45
78
|
}
|
|
79
|
+
const skills = cli
|
|
80
|
+
.command("skills")
|
|
81
|
+
.description(
|
|
82
|
+
"Install and manage the bundled agent skill (no credentials required)",
|
|
83
|
+
);
|
|
84
|
+
for (const operation of ["install", "update", "remove", "status"] as const) {
|
|
85
|
+
skills
|
|
86
|
+
.command(operation)
|
|
87
|
+
.addOption(
|
|
88
|
+
new Option("--agent <name>", "Agent to configure")
|
|
89
|
+
.choices(SKILL_AGENTS)
|
|
90
|
+
.makeOptionMandatory(),
|
|
91
|
+
)
|
|
92
|
+
.addOption(
|
|
93
|
+
new Option("--scope <scope>", "Installation scope")
|
|
94
|
+
.choices(SKILL_SCOPES)
|
|
95
|
+
.default("user"),
|
|
96
|
+
)
|
|
97
|
+
.option(
|
|
98
|
+
"--path <directory>",
|
|
99
|
+
"Project directory (project scope only; default: current directory)",
|
|
100
|
+
)
|
|
101
|
+
.action(async (options) => output(await manageSkill(operation, options)));
|
|
102
|
+
}
|
|
46
103
|
cli
|
|
47
104
|
.command("configure")
|
|
48
105
|
.requiredOption("--endpoint <origin>")
|
|
@@ -81,6 +138,16 @@ repo
|
|
|
81
138
|
.option("--remote <name>")
|
|
82
139
|
.option("--description <text>")
|
|
83
140
|
.option("--tag <key=value>", "One optional Collection metadata tag")
|
|
141
|
+
.addOption(analyzerOption("Comma-separated text analyzers"))
|
|
142
|
+
.addOption(filePolicyOption())
|
|
143
|
+
.addOption(
|
|
144
|
+
new Option(
|
|
145
|
+
"--embedding <model>",
|
|
146
|
+
"Managed model (source text is sent to the provider on import)",
|
|
147
|
+
)
|
|
148
|
+
.choices(["none", "text-embedding-3-small", "text-embedding-3-large"])
|
|
149
|
+
.default("none"),
|
|
150
|
+
)
|
|
84
151
|
.action(async (o) => {
|
|
85
152
|
const { remote } = await connected();
|
|
86
153
|
let labels: Record<string, string> | undefined;
|
|
@@ -95,6 +162,7 @@ repo
|
|
|
95
162
|
remote: o.remote,
|
|
96
163
|
description: o.description,
|
|
97
164
|
labels,
|
|
165
|
+
preset: presetFor(o.embedding, o.analyzers, o.filePolicy),
|
|
98
166
|
}),
|
|
99
167
|
);
|
|
100
168
|
});
|
|
@@ -122,6 +190,18 @@ cli
|
|
|
122
190
|
.option("--repo <name>", "Registered remote repository")
|
|
123
191
|
.option("--ref <ref>", "Local branch, Git tag, or commit OID")
|
|
124
192
|
.option("--dry-run", "Only materialize and validate a local build")
|
|
193
|
+
.addOption(filePolicyOption())
|
|
194
|
+
.addOption(
|
|
195
|
+
new Option(
|
|
196
|
+
"--embedding <model>",
|
|
197
|
+
"Preset for --dry-run --path; connected imports use the repository preset",
|
|
198
|
+
).choices(["none", "text-embedding-3-small", "text-embedding-3-large"]),
|
|
199
|
+
)
|
|
200
|
+
.addOption(
|
|
201
|
+
analyzerOption(
|
|
202
|
+
"Text analyzers for --dry-run --path; connected imports use the repository preset",
|
|
203
|
+
),
|
|
204
|
+
)
|
|
125
205
|
.option("--output <directory>", "New artifact directory")
|
|
126
206
|
.option(
|
|
127
207
|
"--previous <directory>",
|
|
@@ -134,6 +214,18 @@ cli
|
|
|
134
214
|
)
|
|
135
215
|
.option("--timeout <seconds>", "Validation wait deadline", integer, 120)
|
|
136
216
|
.action(async (o) => {
|
|
217
|
+
invariant(
|
|
218
|
+
!o.embedding || (o.dryRun && o.path),
|
|
219
|
+
"--embedding is only accepted with --dry-run --path; connected imports use the repository preset.",
|
|
220
|
+
);
|
|
221
|
+
invariant(
|
|
222
|
+
!o.analyzers || (o.dryRun && o.path),
|
|
223
|
+
"--analyzers is only accepted with --dry-run --path; connected imports use the repository preset.",
|
|
224
|
+
);
|
|
225
|
+
invariant(
|
|
226
|
+
!o.filePolicy || (o.dryRun && o.path),
|
|
227
|
+
"--file-policy is only accepted with --dry-run --path; connected imports use the repository preset.",
|
|
228
|
+
);
|
|
137
229
|
invariant(!(o.path && o.repo), "Choose --path or --repo.");
|
|
138
230
|
invariant(
|
|
139
231
|
!o.previous || o.dryRun,
|
|
@@ -147,16 +239,33 @@ cli
|
|
|
147
239
|
const buildLocal = async (
|
|
148
240
|
source: Awaited<ReturnType<typeof identity>>,
|
|
149
241
|
previous?: Build,
|
|
242
|
+
state?: string,
|
|
243
|
+
preset: Preset = PRESET,
|
|
150
244
|
) => {
|
|
151
245
|
invariant(o.ref, "--ref is required to build a commit.");
|
|
246
|
+
const commit = await resolveCommit(source.path, o.ref);
|
|
247
|
+
if (state) {
|
|
248
|
+
const last = await optionalJson<{ artifact: string }>(
|
|
249
|
+
lastBuildPath(state, commit.branch),
|
|
250
|
+
);
|
|
251
|
+
if (last) {
|
|
252
|
+
try {
|
|
253
|
+
previous = await loadBuild(last.artifact);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
152
259
|
const parent = join(stateRoot(), "builds");
|
|
153
260
|
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
154
261
|
const path = o.output ? resolve(o.output) : join(parent, randomUUID());
|
|
155
262
|
return materialize({
|
|
156
263
|
identity: source,
|
|
157
264
|
ref: o.ref,
|
|
265
|
+
resolvedCommit: commit,
|
|
158
266
|
output: path,
|
|
159
267
|
previous,
|
|
268
|
+
preset,
|
|
160
269
|
});
|
|
161
270
|
};
|
|
162
271
|
if (o.dryRun) {
|
|
@@ -166,18 +275,20 @@ cli
|
|
|
166
275
|
await validateBuild(b);
|
|
167
276
|
} else {
|
|
168
277
|
let source;
|
|
278
|
+
let preset = presetFor(o.embedding, o.analyzers, o.filePolicy);
|
|
169
279
|
if (o.path) source = await identity(o.path, o.remote);
|
|
170
280
|
else {
|
|
171
281
|
invariant(o.repo, "Use --path for a credential-free dry run.");
|
|
172
282
|
const { remote, settings } = await connected();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
283
|
+
const selected = await selectRepository(remote, o.repo);
|
|
284
|
+
preset = selected.preset ?? PRESET;
|
|
285
|
+
source = await attachment(settings, selected);
|
|
177
286
|
}
|
|
178
287
|
b = await buildLocal(
|
|
179
288
|
source,
|
|
180
289
|
o.previous ? await loadBuild(o.previous) : undefined,
|
|
290
|
+
undefined,
|
|
291
|
+
preset,
|
|
181
292
|
);
|
|
182
293
|
}
|
|
183
294
|
output({
|
|
@@ -192,6 +303,8 @@ cli
|
|
|
192
303
|
path: e.path,
|
|
193
304
|
status: e.status,
|
|
194
305
|
reason: e.reason,
|
|
306
|
+
retrieval: e.retrieval,
|
|
307
|
+
policyReason: e.policyReason,
|
|
195
308
|
parseStatus: e.parseStatus,
|
|
196
309
|
})),
|
|
197
310
|
uploaded: false,
|
|
@@ -212,20 +325,34 @@ cli
|
|
|
212
325
|
// Pending attempts reconcile against the journal's immutable remote
|
|
213
326
|
// baseline; they do not need the previous build's local files.
|
|
214
327
|
const pending = await optionalJson(join(state, "pending.json"));
|
|
328
|
+
const b = o.artifact
|
|
329
|
+
? await loadBuild(o.artifact)
|
|
330
|
+
: await buildLocal(
|
|
331
|
+
await attachment(settings, r),
|
|
332
|
+
undefined,
|
|
333
|
+
pending ? undefined : state,
|
|
334
|
+
r.preset ?? PRESET,
|
|
335
|
+
);
|
|
215
336
|
let baseline: { version: Published; build: Build } | undefined;
|
|
216
|
-
|
|
337
|
+
// Tracked branches discover their own immutable baseline remotely.
|
|
338
|
+
// Manual SHA/tag imports retain the previous frozen workspace optimization.
|
|
339
|
+
if (!pending && !b.branchRef) {
|
|
217
340
|
const last = await optionalJson<{
|
|
218
341
|
artifact: string;
|
|
219
342
|
version: Published;
|
|
220
|
-
}>(
|
|
343
|
+
}>(lastBuildPath(state));
|
|
221
344
|
if (last) {
|
|
222
|
-
|
|
223
|
-
|
|
345
|
+
try {
|
|
346
|
+
baseline = {
|
|
347
|
+
version: last.version,
|
|
348
|
+
build: await loadBuild(last.artifact),
|
|
349
|
+
};
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
224
354
|
}
|
|
225
355
|
}
|
|
226
|
-
const b = o.artifact
|
|
227
|
-
? await loadBuild(o.artifact)
|
|
228
|
-
: await buildLocal(await attachment(settings, r), baseline?.build);
|
|
229
356
|
return publish({
|
|
230
357
|
store,
|
|
231
358
|
binding: r,
|
|
@@ -249,7 +376,7 @@ cli
|
|
|
249
376
|
cli
|
|
250
377
|
.command("resolve")
|
|
251
378
|
.requiredOption("--repo <name>")
|
|
252
|
-
.requiredOption("--ref <commit-or-release>")
|
|
379
|
+
.requiredOption("--ref <commit-branch-or-release>")
|
|
253
380
|
.action(async (o) => {
|
|
254
381
|
const { remote } = await connected();
|
|
255
382
|
const r = await selectRepository(remote, o.repo);
|
|
@@ -258,33 +385,93 @@ cli
|
|
|
258
385
|
cli
|
|
259
386
|
.command("search")
|
|
260
387
|
.requiredOption("--repo <name>")
|
|
261
|
-
.requiredOption("--version <commit-or-release>")
|
|
262
|
-
.requiredOption(
|
|
388
|
+
.requiredOption("--version <commit-branch-or-release>")
|
|
389
|
+
.requiredOption(
|
|
390
|
+
"--query <text>",
|
|
391
|
+
"Search text; use the target source language",
|
|
392
|
+
)
|
|
393
|
+
.addHelpText(
|
|
394
|
+
"after",
|
|
395
|
+
"\nQuery language: any language is accepted; queries are not automatically translated.\n" +
|
|
396
|
+
"For English code/comments, prefer English terms and preserve identifiers, API names and error messages.\n" +
|
|
397
|
+
"For Korean documentation, use Korean queries with the korean analyzer.\n" +
|
|
398
|
+
"Agents can translate the search query while answering in the user's language.\n",
|
|
399
|
+
)
|
|
263
400
|
.option("--limit <count>", "Maximum hits", integer, 10)
|
|
401
|
+
.addOption(
|
|
402
|
+
new Option(
|
|
403
|
+
"--mode <mode>",
|
|
404
|
+
"Retrieval method; semantic/hybrid incur managed query embedding usage",
|
|
405
|
+
)
|
|
406
|
+
.choices(["lexical", "semantic", "hybrid"])
|
|
407
|
+
.default("lexical"),
|
|
408
|
+
)
|
|
409
|
+
.addOption(
|
|
410
|
+
new Option(
|
|
411
|
+
"--rerank <model>",
|
|
412
|
+
"Opt-in local reranker (requires separate Python/model setup)",
|
|
413
|
+
).choices(["qwen"]),
|
|
414
|
+
)
|
|
415
|
+
.option(
|
|
416
|
+
"--candidates <count>",
|
|
417
|
+
"Rerank pool size (default: max(50, limit); max: 100)",
|
|
418
|
+
integer,
|
|
419
|
+
)
|
|
264
420
|
.option("--path <path>", "Exact path filter")
|
|
265
|
-
.option("--language <name>")
|
|
421
|
+
.option("--language <name>", "Exact programming-language filter")
|
|
266
422
|
.action(async (o) => {
|
|
423
|
+
const started = performance.now();
|
|
424
|
+
candidateLimit(o.limit, o);
|
|
267
425
|
const { remote, settings } = await connected();
|
|
268
426
|
const r = await selectRepository(remote, o.repo),
|
|
269
427
|
store = remote.store(r.collection),
|
|
270
428
|
v = await resolveVersion(store, r, o.version);
|
|
271
429
|
output(
|
|
272
|
-
await search(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
430
|
+
await search(
|
|
431
|
+
store,
|
|
432
|
+
settings,
|
|
433
|
+
r,
|
|
434
|
+
v,
|
|
435
|
+
o.query,
|
|
436
|
+
o.limit,
|
|
437
|
+
{
|
|
438
|
+
path: o.path,
|
|
439
|
+
language: o.language,
|
|
440
|
+
},
|
|
441
|
+
o.mode,
|
|
442
|
+
{
|
|
443
|
+
rerank: o.rerank,
|
|
444
|
+
candidates: o.candidates,
|
|
445
|
+
onTiming: (timing) =>
|
|
446
|
+
process.stderr.write(
|
|
447
|
+
JSON.stringify({
|
|
448
|
+
event: "rerank-timing",
|
|
449
|
+
...timing,
|
|
450
|
+
commandMs: performance.now() - started,
|
|
451
|
+
}) + "\n",
|
|
452
|
+
),
|
|
453
|
+
},
|
|
454
|
+
),
|
|
276
455
|
);
|
|
277
456
|
});
|
|
278
457
|
cli
|
|
279
458
|
.command("read")
|
|
280
459
|
.option("--result <id>")
|
|
281
460
|
.option("--repo <name>")
|
|
282
|
-
.option("--version <commit-or-release>")
|
|
461
|
+
.option("--version <commit-branch-or-release>")
|
|
283
462
|
.option("--path <path>")
|
|
284
463
|
.option("--lines <start:end>")
|
|
285
464
|
.option("--context <lines>", "Expand an evidence span", integer, 0)
|
|
286
465
|
.option("--full-file")
|
|
466
|
+
.option(
|
|
467
|
+
"--implementation",
|
|
468
|
+
"Read a linked Python/TypeScript overload implementation",
|
|
469
|
+
)
|
|
287
470
|
.action(async (o) => {
|
|
471
|
+
invariant(
|
|
472
|
+
!o.implementation || (o.result && !o.fullFile && !o.lines && !o.context),
|
|
473
|
+
"--implementation requires --result and cannot be combined with range options.",
|
|
474
|
+
);
|
|
288
475
|
invariant(
|
|
289
476
|
!(o.fullFile && (o.lines || o.context)),
|
|
290
477
|
"--full-file cannot be combined with --lines or --context.",
|
|
@@ -331,6 +518,7 @@ cli
|
|
|
331
518
|
context: o.context,
|
|
332
519
|
fullFile: o.fullFile || (!o.result && !o.lines),
|
|
333
520
|
lines,
|
|
521
|
+
implementation: o.implementation,
|
|
334
522
|
},
|
|
335
523
|
),
|
|
336
524
|
);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { invariant } from "./common.js";
|
|
2
|
+
|
|
3
|
+
export type FileAction = "exclude" | "lexical" | "semantic";
|
|
4
|
+
export type FileRule = { pattern: string; action: FileAction };
|
|
5
|
+
export type FilePolicy = { version: 1; rules: FileRule[] };
|
|
6
|
+
export const FILE_POLICY: FilePolicy = { version: 1, rules: [] };
|
|
7
|
+
export type FileDecision = { action: FileAction; reason: string };
|
|
8
|
+
|
|
9
|
+
/** Deliberately small glob syntax: repo-relative paths, *, ?, and whole-segment **. */
|
|
10
|
+
function glob(pattern: string): RegExp {
|
|
11
|
+
let expression = "^";
|
|
12
|
+
const parts = pattern.split("/");
|
|
13
|
+
for (const [index, part] of parts.entries()) {
|
|
14
|
+
if (part === "**")
|
|
15
|
+
expression += index === parts.length - 1 ? "[\\s\\S]*" : "(?:[^/]+/)*";
|
|
16
|
+
else {
|
|
17
|
+
for (const char of part)
|
|
18
|
+
expression +=
|
|
19
|
+
char === "*"
|
|
20
|
+
? "[^/]*"
|
|
21
|
+
: char === "?"
|
|
22
|
+
? "[^/]"
|
|
23
|
+
: char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
if (index !== parts.length - 1) expression += "/";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return new RegExp(expression + "$", "u");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeFilePolicy(value: unknown): FilePolicy {
|
|
31
|
+
invariant(
|
|
32
|
+
value !== null && typeof value === "object" && !Array.isArray(value),
|
|
33
|
+
"File policy must be an object.",
|
|
34
|
+
);
|
|
35
|
+
const policy = value as FilePolicy;
|
|
36
|
+
invariant(
|
|
37
|
+
Object.keys(policy).sort().join(",") === "rules,version" &&
|
|
38
|
+
policy.version === 1 &&
|
|
39
|
+
Array.isArray(policy.rules) &&
|
|
40
|
+
policy.rules.length <= 100,
|
|
41
|
+
"File policy requires version: 1 and at most 100 rules.",
|
|
42
|
+
);
|
|
43
|
+
const rules = policy.rules.map((rule) => {
|
|
44
|
+
invariant(
|
|
45
|
+
rule &&
|
|
46
|
+
typeof rule === "object" &&
|
|
47
|
+
Object.keys(rule).sort().join(",") === "action,pattern" &&
|
|
48
|
+
["exclude", "lexical", "semantic"].includes(rule.action),
|
|
49
|
+
"Each file rule requires pattern and action (exclude, lexical, semantic).",
|
|
50
|
+
);
|
|
51
|
+
invariant(
|
|
52
|
+
typeof rule.pattern === "string" &&
|
|
53
|
+
rule.pattern.length > 0 &&
|
|
54
|
+
rule.pattern.length <= 512 &&
|
|
55
|
+
!/[\\\x00-\x1f\x7f\[\]{}!]/.test(rule.pattern) &&
|
|
56
|
+
rule.pattern
|
|
57
|
+
.split("/")
|
|
58
|
+
.every(
|
|
59
|
+
(part) =>
|
|
60
|
+
part &&
|
|
61
|
+
part !== "." &&
|
|
62
|
+
part !== ".." &&
|
|
63
|
+
(!part.includes("**") || part === "**"),
|
|
64
|
+
),
|
|
65
|
+
"File policy patterns must be relative paths using only *, ?, and whole-segment ** wildcards.",
|
|
66
|
+
);
|
|
67
|
+
glob(rule.pattern);
|
|
68
|
+
return { pattern: rule.pattern, action: rule.action };
|
|
69
|
+
});
|
|
70
|
+
return { version: 1, rules };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** These bodies cannot be re-enabled by repository rules. This is not a secret scanner. */
|
|
74
|
+
export function hardExclusion(path: string): string | undefined {
|
|
75
|
+
const name = path.split("/").at(-1)!;
|
|
76
|
+
if (
|
|
77
|
+
(/^\.env(?:\.|$)/i.test(name) &&
|
|
78
|
+
!/\.(example|sample|template)$/i.test(name)) ||
|
|
79
|
+
/^(\.npmrc|\.pypirc|\.netrc|_netrc|id_rsa|id_dsa|id_ecdsa|id_ed25519)$/i.test(
|
|
80
|
+
name,
|
|
81
|
+
) ||
|
|
82
|
+
/(^|\/)\.(aws|ssh)(\/|$)/i.test(path)
|
|
83
|
+
)
|
|
84
|
+
return "credential-file";
|
|
85
|
+
if (
|
|
86
|
+
/\.(pem|key|crt|cer|csr|der|p12|pfx|p7b|p7c|jks|keystore|srl)$/i.test(path)
|
|
87
|
+
)
|
|
88
|
+
return "crypto-material";
|
|
89
|
+
if (
|
|
90
|
+
/\.(svgz?|png|apng|jpe?g|jfif|gif|webp|avif|bmp|dib|tiff?|ico|icns|heic|heif|jxl|psd|ai|eps|pnm|pbm|pgm|ppm|xbm|xpm)$/i.test(
|
|
91
|
+
path,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return "image-extension";
|
|
95
|
+
if (
|
|
96
|
+
/\.(pdf|zip|gz|bz2|xz|7z|tar|rar|jar|class|woff2?|ttf|otf|mp[34]|wav|ogg|mov|exe|dll|so|dylib|wasm|pyc|pyo|o|a|sqlite3?|db)$/i.test(
|
|
97
|
+
path,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return "binary-extension";
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function privateKeyContent(source: string): boolean {
|
|
105
|
+
return /(?:^|\r?\n)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----\r?\n/.test(
|
|
106
|
+
source,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Explicit rules override defaults, in order; the final matching rule wins. */
|
|
111
|
+
export function fileDecision(
|
|
112
|
+
path: string,
|
|
113
|
+
policy: FilePolicy,
|
|
114
|
+
source?: string,
|
|
115
|
+
): FileDecision {
|
|
116
|
+
let decision: FileDecision = { action: "semantic", reason: "source" };
|
|
117
|
+
if (
|
|
118
|
+
/(^|\/)(node_modules|vendor|vendored|third_party|third-party|dist|build|target|coverage|\.git|\.next|\.nuxt|\.venv|venv|__pycache__|\.cache|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|\.nox|\.srcx)(\/|$)/i.test(
|
|
119
|
+
path,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
decision = { action: "exclude", reason: "dependency-or-build-output" };
|
|
123
|
+
else if (/\.(min\.(js|css)|map)$/i.test(path))
|
|
124
|
+
decision = { action: "exclude", reason: "generated-output" };
|
|
125
|
+
else if (/\.(log|log\.\d+)$/i.test(path))
|
|
126
|
+
decision = { action: "exclude", reason: "log-output" };
|
|
127
|
+
else if (
|
|
128
|
+
/(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|go\.sum|[^/]+\.lock|packages\.lock\.json|Package\.resolved)$/i.test(
|
|
129
|
+
path,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
decision = { action: "lexical", reason: "lockfile" };
|
|
133
|
+
else if (
|
|
134
|
+
/(^|\/)(licen[sc]e|copying|copyright|notice|authors|contributors)([.-][^/]*)?$/i.test(
|
|
135
|
+
path,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
decision = { action: "lexical", reason: "legal-or-attribution" };
|
|
139
|
+
else if (
|
|
140
|
+
/\.(csv|tsv|jsonl|ndjson|snap)$/i.test(path) ||
|
|
141
|
+
/(^|\/)__snapshots__\//i.test(path)
|
|
142
|
+
)
|
|
143
|
+
decision = { action: "lexical", reason: "data-or-snapshot" };
|
|
144
|
+
else if (
|
|
145
|
+
/\.(pb\.go|generated\.[^/]+|g\.[^/]+)$/i.test(path) ||
|
|
146
|
+
/_pb2(?:_grpc)?\.py$/i.test(path) ||
|
|
147
|
+
(source !== undefined &&
|
|
148
|
+
/(?:^|\n)[ \t]*(?:\/\/|#|\/\*|\*|<!--)[^\r\n]*(?:code generated[^\r\n]*do not edit|(?:auto[- ]?generated|generated by)[^\r\n]*do not edit|do not edit[^\r\n]*generated|@generated(?:[ \t]|$))/i.test(
|
|
149
|
+
Buffer.from(source, "utf8").subarray(0, 8192).toString("utf8"),
|
|
150
|
+
))
|
|
151
|
+
)
|
|
152
|
+
decision = { action: "lexical", reason: "generated-source" };
|
|
153
|
+
for (const rule of policy.rules)
|
|
154
|
+
if (glob(rule.pattern).test(path))
|
|
155
|
+
decision = { action: rule.action, reason: `file-rule:${rule.action}` };
|
|
156
|
+
return decision;
|
|
157
|
+
}
|