@functional-systems/srcx 0.1.0-dev.4 → 0.1.0-dev.40

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/DESIGN.md +378 -58
  4. package/README.md +628 -35
  5. package/RELEASING.md +15 -3
  6. package/VALIDATION.md +1120 -16
  7. package/dist/artifact.d.ts +31 -0
  8. package/dist/artifact.js +237 -0
  9. package/dist/artifact.js.map +1 -0
  10. package/dist/build.d.ts +86 -3
  11. package/dist/build.js +233 -71
  12. package/dist/build.js.map +1 -1
  13. package/dist/chunk.d.ts +9 -3
  14. package/dist/chunk.js +341 -14
  15. package/dist/chunk.js.map +1 -1
  16. package/dist/cli.js +130 -21
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-policy.d.ts +20 -0
  19. package/dist/file-policy.js +103 -0
  20. package/dist/file-policy.js.map +1 -0
  21. package/dist/implementation.d.ts +11 -0
  22. package/dist/implementation.js +191 -0
  23. package/dist/implementation.js.map +1 -0
  24. package/dist/publish.d.ts +18 -0
  25. package/dist/publish.js +224 -46
  26. package/dist/publish.js.map +1 -1
  27. package/dist/releases.js +21 -3
  28. package/dist/releases.js.map +1 -1
  29. package/dist/remote.d.ts +1 -0
  30. package/dist/remote.js +50 -9
  31. package/dist/remote.js.map +1 -1
  32. package/dist/repository.d.ts +4 -1
  33. package/dist/repository.js +26 -11
  34. package/dist/repository.js.map +1 -1
  35. package/dist/rerank.d.ts +16 -0
  36. package/dist/rerank.js +87 -0
  37. package/dist/rerank.js.map +1 -0
  38. package/dist/search.d.ts +18 -3
  39. package/dist/search.js +143 -23
  40. package/dist/search.js.map +1 -1
  41. package/dist/skills.d.ts +24 -0
  42. package/dist/skills.js +155 -0
  43. package/dist/skills.js.map +1 -0
  44. package/package.json +17 -6
  45. package/runtime/grammars/README.md +50 -0
  46. package/runtime/grammars/tree-sitter-bash.LICENSE +21 -0
  47. package/runtime/grammars/tree-sitter-bash.wasm +0 -0
  48. package/runtime/grammars/tree-sitter-sql.LICENSE +21 -0
  49. package/runtime/grammars/tree-sitter-sql.wasm +0 -0
  50. package/runtime/grammars/tree-sitter-wasm.LICENSE +21 -0
  51. package/runtime/qwen.py +132 -0
  52. package/runtime/requirements.txt +34 -0
  53. package/skills/srcx-search/SKILL.md +103 -0
  54. package/src/artifact.ts +303 -0
  55. package/src/build.ts +313 -84
  56. package/src/chunk.ts +360 -12
  57. package/src/cli.ts +263 -23
  58. package/src/file-policy.ts +166 -0
  59. package/src/implementation.ts +253 -0
  60. package/src/publish.ts +321 -47
  61. package/src/releases.ts +36 -3
  62. package/src/remote.ts +70 -13
  63. package/src/repository.ts +40 -11
  64. package/src/rerank.ts +131 -0
  65. package/src/search.ts +229 -28
  66. 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 { loadBuild, materialize, validateBuild, type Build } from "./build.js";
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,23 @@ import {
16
27
  register,
17
28
  selectRepository,
18
29
  } from "./repository.js";
19
- import { exclusive, publish, published, type Published } from "./publish.js";
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
- import { directHandle, loadHandle, readHandle, search } from "./search.js";
38
+ import {
39
+ directHandle,
40
+ loadHandle,
41
+ readHandle,
42
+ search,
43
+ lookup,
44
+ } from "./search.js";
45
+ import { candidateLimit } from "./rerank.js";
46
+ import { manageSkill, SKILL_AGENTS, SKILL_SCOPES } from "./skills.js";
22
47
  const cli = new Command()
23
48
  .name("srcx")
24
49
  .description("Version-aware code search on LambdaDB")
@@ -39,10 +64,48 @@ const integer = (value: string) => {
39
64
  throw new InvalidArgumentError("Integer is too large.");
40
65
  return n;
41
66
  };
67
+ const analyzerOption = (description: string) =>
68
+ new Option(
69
+ "--analyzers <names>",
70
+ description + ` (${ANALYZERS.join(", ")}; default: standard)`,
71
+ ).argParser((value) =>
72
+ normalizeAnalyzers(value.split(",").map((name) => name.trim())),
73
+ );
74
+ const filePolicyOption = () =>
75
+ new Option(
76
+ "--file-policy <json>",
77
+ "Path to file policy JSON; pinned at registration or local dry run",
78
+ ).argParser((path) =>
79
+ normalizeFilePolicy(JSON.parse(readFileSync(path, "utf8"))),
80
+ );
42
81
  async function connected() {
43
82
  const settings = await loadSettings();
44
83
  return { settings, remote: new LambdaRemote(settings) };
45
84
  }
85
+ const skills = cli
86
+ .command("skills")
87
+ .description(
88
+ "Install and manage the bundled agent skill (no credentials required)",
89
+ );
90
+ for (const operation of ["install", "update", "remove", "status"] as const) {
91
+ skills
92
+ .command(operation)
93
+ .addOption(
94
+ new Option("--agent <name>", "Agent to configure")
95
+ .choices(SKILL_AGENTS)
96
+ .makeOptionMandatory(),
97
+ )
98
+ .addOption(
99
+ new Option("--scope <scope>", "Installation scope")
100
+ .choices(SKILL_SCOPES)
101
+ .default("user"),
102
+ )
103
+ .option(
104
+ "--path <directory>",
105
+ "Project directory (project scope only; default: current directory)",
106
+ )
107
+ .action(async (options) => output(await manageSkill(operation, options)));
108
+ }
46
109
  cli
47
110
  .command("configure")
48
111
  .requiredOption("--endpoint <origin>")
@@ -81,6 +144,16 @@ repo
81
144
  .option("--remote <name>")
82
145
  .option("--description <text>")
83
146
  .option("--tag <key=value>", "One optional Collection metadata tag")
147
+ .addOption(analyzerOption("Comma-separated text analyzers"))
148
+ .addOption(filePolicyOption())
149
+ .addOption(
150
+ new Option(
151
+ "--embedding <model>",
152
+ "Managed model (source text is sent to the provider on import)",
153
+ )
154
+ .choices(["none", "text-embedding-3-small", "text-embedding-3-large"])
155
+ .default("none"),
156
+ )
84
157
  .action(async (o) => {
85
158
  const { remote } = await connected();
86
159
  let labels: Record<string, string> | undefined;
@@ -95,6 +168,7 @@ repo
95
168
  remote: o.remote,
96
169
  description: o.description,
97
170
  labels,
171
+ preset: presetFor(o.embedding, o.analyzers, o.filePolicy),
98
172
  }),
99
173
  );
100
174
  });
@@ -122,7 +196,27 @@ cli
122
196
  .option("--repo <name>", "Registered remote repository")
123
197
  .option("--ref <ref>", "Local branch, Git tag, or commit OID")
124
198
  .option("--dry-run", "Only materialize and validate a local build")
199
+ .addOption(filePolicyOption())
200
+ .addOption(
201
+ new Option(
202
+ "--embedding <model>",
203
+ "Preset for --dry-run --path; connected imports use the repository preset",
204
+ ).choices(["none", "text-embedding-3-small", "text-embedding-3-large"]),
205
+ )
206
+ .addOption(
207
+ analyzerOption(
208
+ "Text analyzers for --dry-run --path; connected imports use the repository preset",
209
+ ),
210
+ )
125
211
  .option("--output <directory>", "New artifact directory")
212
+ .addOption(
213
+ new Option(
214
+ "--artifact-storage <storage>",
215
+ "Experimental local artifact storage",
216
+ )
217
+ .choices(["jsonl", "segments"])
218
+ .default("jsonl"),
219
+ )
126
220
  .option(
127
221
  "--previous <directory>",
128
222
  "Previous build artifact for a dry-run diff",
@@ -133,7 +227,19 @@ cli
133
227
  "Resume the same pending artifact after inspecting its journal",
134
228
  )
135
229
  .option("--timeout <seconds>", "Validation wait deadline", integer, 120)
136
- .action(async (o) => {
230
+ .action(async (o, command) => {
231
+ invariant(
232
+ !o.embedding || (o.dryRun && o.path),
233
+ "--embedding is only accepted with --dry-run --path; connected imports use the repository preset.",
234
+ );
235
+ invariant(
236
+ !o.analyzers || (o.dryRun && o.path),
237
+ "--analyzers is only accepted with --dry-run --path; connected imports use the repository preset.",
238
+ );
239
+ invariant(
240
+ !o.filePolicy || (o.dryRun && o.path),
241
+ "--file-policy is only accepted with --dry-run --path; connected imports use the repository preset.",
242
+ );
137
243
  invariant(!(o.path && o.repo), "Choose --path or --repo.");
138
244
  invariant(
139
245
  !o.previous || o.dryRun,
@@ -144,19 +250,41 @@ cli
144
250
  !(o.artifact && (o.ref || o.path || o.output || o.previous)),
145
251
  "--artifact cannot be combined with --ref/--path/--output/--previous.",
146
252
  );
253
+ invariant(
254
+ !o.artifact || command.getOptionValueSource("artifactStorage") !== "cli",
255
+ "--artifact-storage cannot be combined with --artifact; the artifact records its storage format.",
256
+ );
147
257
  const buildLocal = async (
148
258
  source: Awaited<ReturnType<typeof identity>>,
149
259
  previous?: Build,
260
+ state?: string,
261
+ preset: Preset = PRESET,
150
262
  ) => {
151
263
  invariant(o.ref, "--ref is required to build a commit.");
264
+ const commit = await resolveCommit(source.path, o.ref);
265
+ if (state) {
266
+ const last = await optionalJson<{ artifact: string }>(
267
+ lastBuildPath(state, commit.branch),
268
+ );
269
+ if (last) {
270
+ try {
271
+ previous = await loadBuild(last.artifact);
272
+ } catch (error) {
273
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
274
+ }
275
+ }
276
+ }
152
277
  const parent = join(stateRoot(), "builds");
153
278
  await mkdir(parent, { recursive: true, mode: 0o700 });
154
279
  const path = o.output ? resolve(o.output) : join(parent, randomUUID());
155
280
  return materialize({
156
281
  identity: source,
157
282
  ref: o.ref,
283
+ resolvedCommit: commit,
158
284
  output: path,
159
285
  previous,
286
+ storage: o.artifactStorage,
287
+ preset,
160
288
  });
161
289
  };
162
290
  if (o.dryRun) {
@@ -166,23 +294,26 @@ cli
166
294
  await validateBuild(b);
167
295
  } else {
168
296
  let source;
297
+ let preset = presetFor(o.embedding, o.analyzers, o.filePolicy);
169
298
  if (o.path) source = await identity(o.path, o.remote);
170
299
  else {
171
300
  invariant(o.repo, "Use --path for a credential-free dry run.");
172
301
  const { remote, settings } = await connected();
173
- source = await attachment(
174
- settings,
175
- await selectRepository(remote, o.repo),
176
- );
302
+ const selected = await selectRepository(remote, o.repo);
303
+ preset = selected.preset ?? PRESET;
304
+ source = await attachment(settings, selected);
177
305
  }
178
306
  b = await buildLocal(
179
307
  source,
180
308
  o.previous ? await loadBuild(o.previous) : undefined,
309
+ undefined,
310
+ preset,
181
311
  );
182
312
  }
183
313
  output({
184
314
  status: "locally-validated",
185
315
  artifact: b.directory,
316
+ artifactFormat: b.format,
186
317
  commitOid: b.commitOid,
187
318
  configHash: b.configHash,
188
319
  counts: b.counts,
@@ -192,6 +323,8 @@ cli
192
323
  path: e.path,
193
324
  status: e.status,
194
325
  reason: e.reason,
326
+ retrieval: e.retrieval,
327
+ policyReason: e.policyReason,
195
328
  parseStatus: e.parseStatus,
196
329
  })),
197
330
  uploaded: false,
@@ -212,20 +345,34 @@ cli
212
345
  // Pending attempts reconcile against the journal's immutable remote
213
346
  // baseline; they do not need the previous build's local files.
214
347
  const pending = await optionalJson(join(state, "pending.json"));
348
+ const b = o.artifact
349
+ ? await loadBuild(o.artifact)
350
+ : await buildLocal(
351
+ await attachment(settings, r),
352
+ undefined,
353
+ pending ? undefined : state,
354
+ r.preset ?? PRESET,
355
+ );
215
356
  let baseline: { version: Published; build: Build } | undefined;
216
- if (!pending) {
357
+ // Tracked branches discover their own immutable baseline remotely.
358
+ // Manual SHA/tag imports retain the previous frozen workspace optimization.
359
+ if (!pending && !b.branchRef) {
217
360
  const last = await optionalJson<{
218
361
  artifact: string;
219
362
  version: Published;
220
- }>(join(state, "last-build.json"));
363
+ }>(lastBuildPath(state));
221
364
  if (last) {
222
- const b = await loadBuild(last.artifact);
223
- baseline = { version: last.version, build: b };
365
+ try {
366
+ baseline = {
367
+ version: last.version,
368
+ build: await loadBuild(last.artifact),
369
+ };
370
+ } catch (error) {
371
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
372
+ throw error;
373
+ }
224
374
  }
225
375
  }
226
- const b = o.artifact
227
- ? await loadBuild(o.artifact)
228
- : await buildLocal(await attachment(settings, r), baseline?.build);
229
376
  return publish({
230
377
  store,
231
378
  binding: r,
@@ -249,7 +396,7 @@ cli
249
396
  cli
250
397
  .command("resolve")
251
398
  .requiredOption("--repo <name>")
252
- .requiredOption("--ref <commit-or-release>")
399
+ .requiredOption("--ref <commit-branch-or-release>")
253
400
  .action(async (o) => {
254
401
  const { remote } = await connected();
255
402
  const r = await selectRepository(remote, o.repo);
@@ -258,18 +405,102 @@ cli
258
405
  cli
259
406
  .command("search")
260
407
  .requiredOption("--repo <name>")
261
- .requiredOption("--version <commit-or-release>")
262
- .requiredOption("--query <text>")
408
+ .requiredOption("--version <commit-branch-or-release>")
409
+ .requiredOption(
410
+ "--query <text>",
411
+ "Search text; use the target source language",
412
+ )
413
+ .addHelpText(
414
+ "after",
415
+ "\nQuery language: any language is accepted; queries are not automatically translated.\n" +
416
+ "For English code/comments, prefer English terms and preserve identifiers, API names and error messages.\n" +
417
+ "For Korean documentation, use Korean queries with the korean analyzer.\n" +
418
+ "Agents can translate the search query while answering in the user's language.\n",
419
+ )
263
420
  .option("--limit <count>", "Maximum hits", integer, 10)
421
+ .addOption(
422
+ new Option(
423
+ "--mode <mode>",
424
+ "Retrieval method; semantic/hybrid incur managed query embedding usage",
425
+ )
426
+ .choices(["lexical", "semantic", "hybrid"])
427
+ .default("lexical"),
428
+ )
429
+ .addOption(
430
+ new Option(
431
+ "--rerank <model>",
432
+ "Opt-in local reranker (requires separate Python/model setup)",
433
+ ).choices(["qwen"]),
434
+ )
435
+ .option(
436
+ "--candidates <count>",
437
+ "Rerank pool size (default: max(50, limit); max: 100)",
438
+ integer,
439
+ )
264
440
  .option("--path <path>", "Exact path filter")
265
- .option("--language <name>")
441
+ .option("--language <name>", "Exact programming-language filter")
266
442
  .action(async (o) => {
443
+ const started = performance.now();
444
+ candidateLimit(o.limit, o);
267
445
  const { remote, settings } = await connected();
268
446
  const r = await selectRepository(remote, o.repo),
269
447
  store = remote.store(r.collection),
270
448
  v = await resolveVersion(store, r, o.version);
271
449
  output(
272
- await search(store, settings, r, v, o.query, o.limit, {
450
+ await search(
451
+ store,
452
+ settings,
453
+ r,
454
+ v,
455
+ o.query,
456
+ o.limit,
457
+ {
458
+ path: o.path,
459
+ language: o.language,
460
+ },
461
+ o.mode,
462
+ {
463
+ rerank: o.rerank,
464
+ candidates: o.candidates,
465
+ onTiming: (timing) =>
466
+ process.stderr.write(
467
+ JSON.stringify({
468
+ event: "rerank-timing",
469
+ ...timing,
470
+ commandMs: performance.now() - started,
471
+ }) + "\n",
472
+ ),
473
+ },
474
+ ),
475
+ );
476
+ });
477
+ cli
478
+ .command("lookup")
479
+ .description(
480
+ "Find exact indexed symbol definitions and return their source chunks",
481
+ )
482
+ .requiredOption("--repo <name>")
483
+ .requiredOption("--version <commit-branch-or-release>")
484
+ .requiredOption("--symbol <name>", "Exact case-sensitive indexed symbol name")
485
+ .option("--path <path>", "Exact path filter for ambiguous names")
486
+ .option("--language <name>", "Exact programming-language filter")
487
+ .option(
488
+ "--limit <count>",
489
+ "Maximum source chunks (1–20; results may be partial)",
490
+ integer,
491
+ 5,
492
+ )
493
+ .action(async (o) => {
494
+ invariant(
495
+ o.limit >= 1 && o.limit <= 20,
496
+ "Lookup limit must be between 1 and 20.",
497
+ );
498
+ const { remote, settings } = await connected();
499
+ const r = await selectRepository(remote, o.repo),
500
+ store = remote.store(r.collection);
501
+ const v = await resolveVersion(store, r, o.version);
502
+ output(
503
+ await lookup(store, settings, r, v, o.symbol, o.limit, {
273
504
  path: o.path,
274
505
  language: o.language,
275
506
  }),
@@ -279,12 +510,20 @@ cli
279
510
  .command("read")
280
511
  .option("--result <id>")
281
512
  .option("--repo <name>")
282
- .option("--version <commit-or-release>")
513
+ .option("--version <commit-branch-or-release>")
283
514
  .option("--path <path>")
284
515
  .option("--lines <start:end>")
285
516
  .option("--context <lines>", "Expand an evidence span", integer, 0)
286
517
  .option("--full-file")
518
+ .option(
519
+ "--implementation",
520
+ "Read a linked Python/TypeScript overload implementation",
521
+ )
287
522
  .action(async (o) => {
523
+ invariant(
524
+ !o.implementation || (o.result && !o.fullFile && !o.lines && !o.context),
525
+ "--implementation requires --result and cannot be combined with range options.",
526
+ );
288
527
  invariant(
289
528
  !(o.fullFile && (o.lines || o.context)),
290
529
  "--full-file cannot be combined with --lines or --context.",
@@ -331,6 +570,7 @@ cli
331
570
  context: o.context,
332
571
  fullFile: o.fullFile || (!o.result && !o.lines),
333
572
  lines,
573
+ implementation: o.implementation,
334
574
  },
335
575
  ),
336
576
  );
@@ -0,0 +1,166 @@
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
+ function excludedDirectory(path: string): boolean {
111
+ // Under a Java source-set root, build/dist/target/coverage can be package
112
+ // names. Output directories above that root still exclude the whole tree.
113
+ const javaRoot = /(^|\/)src\/[^/]+\/java\//i.exec(path);
114
+ const outputPath =
115
+ javaRoot && /\.java$/i.test(path) ? path.slice(0, javaRoot.index) : path;
116
+ return (
117
+ /(^|\/)(node_modules|vendor|vendored|third_party|third-party|\.git|\.next|\.nuxt|\.venv|venv|__pycache__|\.cache|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|\.nox|\.srcx)(\/|$)/i.test(
118
+ path,
119
+ ) || /(^|\/)(dist|build|target|coverage)(\/|$)/i.test(outputPath)
120
+ );
121
+ }
122
+
123
+ /** Explicit rules override defaults, in order; the final matching rule wins. */
124
+ export function fileDecision(
125
+ path: string,
126
+ policy: FilePolicy,
127
+ source?: string,
128
+ ): FileDecision {
129
+ let decision: FileDecision = { action: "semantic", reason: "source" };
130
+ if (excludedDirectory(path))
131
+ decision = { action: "exclude", reason: "dependency-or-build-output" };
132
+ else if (/\.(min\.(js|css)|map)$/i.test(path))
133
+ decision = { action: "exclude", reason: "generated-output" };
134
+ else if (/\.(log|log\.\d+)$/i.test(path))
135
+ decision = { action: "exclude", reason: "log-output" };
136
+ else if (
137
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|go\.sum|[^/]+\.lock|packages\.lock\.json|Package\.resolved)$/i.test(
138
+ path,
139
+ )
140
+ )
141
+ decision = { action: "lexical", reason: "lockfile" };
142
+ else if (
143
+ /(^|\/)(licen[sc]e|copying|copyright|notice|authors|contributors)([.-][^/]*)?$/i.test(
144
+ path,
145
+ )
146
+ )
147
+ decision = { action: "lexical", reason: "legal-or-attribution" };
148
+ else if (
149
+ /\.(csv|tsv|jsonl|ndjson|snap)$/i.test(path) ||
150
+ /(^|\/)__snapshots__\//i.test(path)
151
+ )
152
+ decision = { action: "lexical", reason: "data-or-snapshot" };
153
+ else if (
154
+ /\.(pb\.go|generated\.[^/]+|g\.[^/]+)$/i.test(path) ||
155
+ /_pb2(?:_grpc)?\.py$/i.test(path) ||
156
+ (source !== undefined &&
157
+ /(?:^|\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(
158
+ Buffer.from(source, "utf8").subarray(0, 8192).toString("utf8"),
159
+ ))
160
+ )
161
+ decision = { action: "lexical", reason: "generated-source" };
162
+ for (const rule of policy.rules)
163
+ if (glob(rule.pattern).test(path))
164
+ decision = { action: rule.action, reason: `file-rule:${rule.action}` };
165
+ return decision;
166
+ }