@functional-systems/srcx 0.1.0-dev.24 → 0.1.0-dev.26

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/src/build.ts CHANGED
@@ -15,6 +15,15 @@ import {
15
15
  type Doc,
16
16
  optionalJson,
17
17
  } from "./common.js";
18
+ import {
19
+ FILE_POLICY,
20
+ normalizeFilePolicy,
21
+ hardExclusion,
22
+ privateKeyContent,
23
+ fileDecision,
24
+ type FilePolicy,
25
+ type FileDecision,
26
+ } from "./file-policy.js";
18
27
  export const ANALYZERS = ["english", "japanese", "korean", "standard"] as const;
19
28
  export type Analyzer = (typeof ANALYZERS)[number];
20
29
  export function normalizeAnalyzers(value: unknown): Analyzer[] {
@@ -42,6 +51,7 @@ export type Preset = {
42
51
  /** Internal evaluation override; the CLI keeps its original enrichment. */
43
52
  enrichment?: "path-only-v1";
44
53
  maxFileBytes: number;
54
+ filePolicy: FilePolicy;
45
55
  embedding: {
46
56
  model: string;
47
57
  dimensions: number;
@@ -57,6 +67,7 @@ export const PRESET: Preset = {
57
67
  chunker: CHUNKER,
58
68
  mode: "syntax",
59
69
  maxFileBytes: 1024 * 1024,
70
+ filePolicy: FILE_POLICY,
60
71
  embedding: null,
61
72
  };
62
73
  /** Embedding variants share the same chunking behavior. */
@@ -82,6 +93,7 @@ export const MANAGED_LARGE_PRESET: Preset = {
82
93
  export function presetFor(
83
94
  embedding = "none",
84
95
  analyzers: readonly string[] = PRESET.analyzers,
96
+ filePolicy: unknown = FILE_POLICY,
85
97
  ): Preset {
86
98
  invariant(
87
99
  ["none", "text-embedding-3-small", "text-embedding-3-large"].includes(
@@ -96,17 +108,21 @@ export function presetFor(
96
108
  ? MANAGED_LARGE_PRESET
97
109
  : MANAGED_PRESET;
98
110
  const normalized = normalizeAnalyzers(analyzers);
99
- return hash(normalized) === hash(base.analyzers)
100
- ? base
101
- : { ...base, analyzers: normalized };
111
+ const configured = {
112
+ ...base,
113
+ analyzers: normalized,
114
+ filePolicy: normalizeFilePolicy(filePolicy),
115
+ };
116
+ return hash(configured) === hash(base) ? base : configured;
102
117
  }
103
118
  export function supportedPreset(preset: unknown): preset is Preset {
104
119
  if (!preset || typeof preset !== "object" || Array.isArray(preset))
105
120
  return false;
106
121
  try {
107
122
  const analyzers = normalizeAnalyzers((preset as Preset).analyzers);
123
+ const filePolicy = normalizeFilePolicy((preset as Preset).filePolicy);
108
124
  return [PRESET, MANAGED_PRESET, MANAGED_LARGE_PRESET].some(
109
- (base) => hash({ ...base, analyzers }) === hash(preset),
125
+ (base) => hash({ ...base, analyzers, filePolicy }) === hash(preset),
110
126
  );
111
127
  } catch {
112
128
  return false;
@@ -154,6 +170,8 @@ export type InventoryItem = {
154
170
  bytes: number;
155
171
  status: "included" | "excluded";
156
172
  reason?: string;
173
+ retrieval?: "lexical" | "semantic";
174
+ policyReason?: string;
157
175
  targetBase64?: string;
158
176
  fileId?: string;
159
177
  chunkIds?: string[];
@@ -215,22 +233,6 @@ export async function loadBuild(directory: string): Promise<Build> {
215
233
  b.directory = resolve(directory);
216
234
  return b;
217
235
  }
218
- function exclude(path: string): string | undefined {
219
- if (
220
- /(^|\/)(node_modules|vendor|vendored|dist|build|target|coverage|\.git|\.next)(\/|$)/.test(
221
- path,
222
- )
223
- )
224
- return "dependency-or-build-output";
225
- if (
226
- /\.(png|jpe?g|gif|ico|pdf|zip|gz|jar|class|woff2?|mp[34]|exe|dll|so|dylib|wasm)$/i.test(
227
- path,
228
- )
229
- )
230
- return "binary-extension";
231
- if (/\.(min\.(js|css)|map)$/.test(path)) return "generated-output";
232
- return undefined;
233
- }
234
236
  export async function materialize(args: {
235
237
  identity: Identity;
236
238
  ref: string;
@@ -245,7 +247,8 @@ export async function materialize(args: {
245
247
  configHash = hash(preset),
246
248
  prev = args.previous;
247
249
  invariant(
248
- hash(preset.analyzers) === hash(normalizeAnalyzers(preset.analyzers)) &&
250
+ hash(preset.filePolicy) === hash(normalizeFilePolicy(preset.filePolicy)) &&
251
+ hash(preset.analyzers) === hash(normalizeAnalyzers(preset.analyzers)) &&
249
252
  hash(preset.chunker) === hash(CHUNKER) &&
250
253
  ["syntax", "window"].includes(preset.mode) &&
251
254
  (preset.enrichment === undefined ||
@@ -317,7 +320,10 @@ export async function materialize(args: {
317
320
  item.targetBase64 = (await blobs.read(e)).toString("base64");
318
321
  continue;
319
322
  }
320
- const reason = exclude(e.path);
323
+ const pathDecision = fileDecision(e.path, preset.filePolicy);
324
+ const reason =
325
+ hardExclusion(e.path) ??
326
+ (pathDecision.action === "exclude" ? pathDecision.reason : undefined);
321
327
  if (reason) {
322
328
  item.reason = reason;
323
329
  continue;
@@ -350,6 +356,11 @@ export async function materialize(args: {
350
356
  item.reason = "lfs-pointer";
351
357
  continue;
352
358
  }
359
+ if (privateKeyContent(source)) {
360
+ item.reason = "private-key-content";
361
+ continue;
362
+ }
363
+ const decision = fileDecision(e.path, preset.filePolicy, source);
353
364
  const parsed = await chunk(
354
365
  source,
355
366
  e.path,
@@ -378,6 +389,8 @@ export async function materialize(args: {
378
389
  }
379
390
  Object.assign(item, {
380
391
  status: "included",
392
+ retrieval: decision.action,
393
+ policyReason: decision.reason,
381
394
  contentHash,
382
395
  fileId,
383
396
  chunkIds: [],
@@ -404,7 +417,9 @@ export async function materialize(args: {
404
417
  ? "structural-only"
405
418
  : "embedding-disabled",
406
419
  };
407
- const eligible = !["imports", "structural"].includes(span.chunkKind);
420
+ const eligible =
421
+ decision.action === "semantic" &&
422
+ !["imports", "structural"].includes(span.chunkKind);
408
423
  if (preset.embedding && eligible) {
409
424
  const inputHash = hash([preset.embedding, span.searchText]);
410
425
  if (preset.embedding.managed) {
@@ -443,9 +458,11 @@ export async function materialize(args: {
443
458
  }
444
459
  } else {
445
460
  doc.embeddingSkipReason = preset.embedding
446
- ? span.chunkKind === "imports"
447
- ? "imports-only"
448
- : "structural-only"
461
+ ? decision.action === "lexical"
462
+ ? decision.reason
463
+ : span.chunkKind === "imports"
464
+ ? "imports-only"
465
+ : "structural-only"
449
466
  : "embedding-disabled";
450
467
  item.skipped!++;
451
468
  }
@@ -545,7 +562,10 @@ export async function validateBuild(b: Build): Promise<void> {
545
562
  "Build commit identity mismatch; rebuild from the intended Git commit.",
546
563
  );
547
564
  invariant(
548
- hash(b.preset.analyzers) === hash(normalizeAnalyzers(b.preset.analyzers)) &&
565
+ hash(b.preset.filePolicy) ===
566
+ hash(normalizeFilePolicy(b.preset.filePolicy)) &&
567
+ hash(b.preset.analyzers) ===
568
+ hash(normalizeAnalyzers(b.preset.analyzers)) &&
549
569
  hash(b.preset) === b.configHash &&
550
570
  hash(b.inventory) === b.inventoryHash,
551
571
  "Build metadata hash mismatch.",
@@ -559,6 +579,7 @@ export async function validateBuild(b: Build): Promise<void> {
559
579
  .filter((e) => e.status === "included")
560
580
  .map((e) => [e.fileId!, e]),
561
581
  );
582
+ let activeDecision: FileDecision | undefined;
562
583
  const completedFiles = new Set<string>();
563
584
  const finish = () => {
564
585
  if (activeFile) {
@@ -598,6 +619,19 @@ export async function validateBuild(b: Build): Promise<void> {
598
619
  end = 0;
599
620
  count = 0;
600
621
  const entry = expectedFile.get(doc.id);
622
+ activeDecision = fileDecision(
623
+ doc.path as string,
624
+ b.preset.filePolicy,
625
+ doc.sourceText as string,
626
+ );
627
+ invariant(
628
+ !hardExclusion(doc.path as string) &&
629
+ !privateKeyContent(doc.sourceText as string) &&
630
+ activeDecision.action !== "exclude" &&
631
+ entry?.retrieval === activeDecision.action &&
632
+ entry?.policyReason === activeDecision.reason,
633
+ "File policy or inventory decision mismatch.",
634
+ );
601
635
  const raw = Buffer.from(doc.sourceText as string);
602
636
  invariant(
603
637
  entry &&
@@ -648,9 +682,9 @@ export async function validateBuild(b: Build): Promise<void> {
648
682
  "Invalid chunk source/position/token binding.",
649
683
  );
650
684
  if (b.preset.embedding?.managed) {
651
- const eligible = !["imports", "structural"].includes(
652
- String(doc.chunkKind),
653
- );
685
+ const eligible =
686
+ activeDecision!.action === "semantic" &&
687
+ !["imports", "structural"].includes(String(doc.chunkKind));
654
688
  invariant(
655
689
  eligible
656
690
  ? doc.embeddingStatus === "managed" &&
@@ -662,14 +696,18 @@ export async function validateBuild(b: Build): Promise<void> {
662
696
  doc.embeddingText === undefined &&
663
697
  doc.embeddingInputHash === undefined &&
664
698
  doc.embeddingSkipReason ===
665
- (doc.chunkKind === "imports"
666
- ? "imports-only"
667
- : "structural-only"),
699
+ (activeDecision!.action === "lexical"
700
+ ? activeDecision!.reason
701
+ : doc.chunkKind === "imports"
702
+ ? "imports-only"
703
+ : "structural-only"),
668
704
  "Invalid managed embedding input or eligibility.",
669
705
  );
670
706
  } else if (doc.embeddingStatus === "embedded")
671
707
  invariant(
672
708
  b.preset.embedding &&
709
+ activeDecision!.action === "semantic" &&
710
+ !["imports", "structural"].includes(String(doc.chunkKind)) &&
673
711
  Array.isArray(doc.embedding) &&
674
712
  doc.embedding.length === b.preset.embedding.dimensions &&
675
713
  doc.embedding.every(Number.isFinite),
package/src/chunk.ts CHANGED
@@ -14,12 +14,14 @@ export const CHUNKER = {
14
14
  grammars: "tree-sitter-wasms@0.1.13",
15
15
  sqlGrammar:
16
16
  "tree-sitter-wasm@2.0.2/sql/b77530893b1dd6d1d4814eacf34d25bcbe4a12ec9a25a8c45b320d447265f42b",
17
+ shellGrammar:
18
+ "tree-sitter-wasm@2.0.2/bash/5daf3f2ac1cea01a64cfd6878ef7d247310925a4c62238eb4b8c1feb5dce355a",
17
19
  tokenizer: "js-tiktoken@1.0.21/cl100k_base",
18
20
  targetTokens: 800,
19
21
  maxTokens: 1500,
20
22
  fallbackOverlap: 0.1,
21
23
  enrichment: "path-scope-symbol-v1",
22
- policy: "source-v1",
24
+ policy: "source-v2",
23
25
  } as const;
24
26
  export type Span = {
25
27
  startByte: number;
@@ -49,16 +51,14 @@ async function language(name: string): Promise<Language> {
49
51
  let l = languages.get(name);
50
52
  if (!l) {
51
53
  l = await Language.load(
52
- name === "sql"
54
+ name === "sql" || name === "shell"
53
55
  ? fileURLToPath(
54
56
  new URL(
55
- "../runtime/grammars/tree-sitter-sql.wasm",
57
+ `../runtime/grammars/tree-sitter-${name === "shell" ? "bash" : "sql"}.wasm`,
56
58
  import.meta.url,
57
59
  ),
58
60
  )
59
- : require.resolve(
60
- `tree-sitter-wasms/out/tree-sitter-${name === "shell" ? "bash" : name}.wasm`,
61
- ),
61
+ : require.resolve(`tree-sitter-wasms/out/tree-sitter-${name}.wasm`),
62
62
  );
63
63
  languages.set(name, l);
64
64
  }
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  normalizeAnalyzers,
18
18
  ANALYZERS,
19
19
  } from "./build.js";
20
+ import { normalizeFilePolicy } from "./file-policy.js";
20
21
  import { invariant, optionalJson } from "./common.js";
21
22
  import { LambdaRemote } from "./remote.js";
22
23
  import {
@@ -64,6 +65,13 @@ const analyzerOption = (description: string) =>
64
65
  ).argParser((value) =>
65
66
  normalizeAnalyzers(value.split(",").map((name) => name.trim())),
66
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
+ );
67
75
  async function connected() {
68
76
  const settings = await loadSettings();
69
77
  return { settings, remote: new LambdaRemote(settings) };
@@ -131,6 +139,7 @@ repo
131
139
  .option("--description <text>")
132
140
  .option("--tag <key=value>", "One optional Collection metadata tag")
133
141
  .addOption(analyzerOption("Comma-separated text analyzers"))
142
+ .addOption(filePolicyOption())
134
143
  .addOption(
135
144
  new Option(
136
145
  "--embedding <model>",
@@ -153,7 +162,7 @@ repo
153
162
  remote: o.remote,
154
163
  description: o.description,
155
164
  labels,
156
- preset: presetFor(o.embedding, o.analyzers),
165
+ preset: presetFor(o.embedding, o.analyzers, o.filePolicy),
157
166
  }),
158
167
  );
159
168
  });
@@ -181,6 +190,7 @@ cli
181
190
  .option("--repo <name>", "Registered remote repository")
182
191
  .option("--ref <ref>", "Local branch, Git tag, or commit OID")
183
192
  .option("--dry-run", "Only materialize and validate a local build")
193
+ .addOption(filePolicyOption())
184
194
  .addOption(
185
195
  new Option(
186
196
  "--embedding <model>",
@@ -212,6 +222,10 @@ cli
212
222
  !o.analyzers || (o.dryRun && o.path),
213
223
  "--analyzers is only accepted with --dry-run --path; connected imports use the repository preset.",
214
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
+ );
215
229
  invariant(!(o.path && o.repo), "Choose --path or --repo.");
216
230
  invariant(
217
231
  !o.previous || o.dryRun,
@@ -261,7 +275,7 @@ cli
261
275
  await validateBuild(b);
262
276
  } else {
263
277
  let source;
264
- let preset = presetFor(o.embedding, o.analyzers);
278
+ let preset = presetFor(o.embedding, o.analyzers, o.filePolicy);
265
279
  if (o.path) source = await identity(o.path, o.remote);
266
280
  else {
267
281
  invariant(o.repo, "Use --path for a credential-free dry run.");
@@ -289,6 +303,8 @@ cli
289
303
  path: e.path,
290
304
  status: e.status,
291
305
  reason: e.reason,
306
+ retrieval: e.retrieval,
307
+ policyReason: e.policyReason,
292
308
  parseStatus: e.parseStatus,
293
309
  })),
294
310
  uploaded: false,
@@ -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
+ }