@mnemonik/shared 7.119.1 → 7.120.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.
@@ -18,6 +18,18 @@ import {
18
18
  type AstChunk,
19
19
  } from './ast/astChunker.js';
20
20
  import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
21
+ import {
22
+ classifyForIndex,
23
+ gitattributesCanReinclude,
24
+ parseGitattributes,
25
+ softDefaultForContent,
26
+ softDefaultForDirectory,
27
+ type GitattributesLayer,
28
+ type GitattributesRule,
29
+ type IgnoreFileVerdict,
30
+ type IndexClassification,
31
+ type IndexExclusionReason,
32
+ } from './indexClassifier.js';
21
33
 
22
34
  /**
23
35
  * File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
@@ -106,6 +118,31 @@ export interface ScanOptions {
106
118
  */
107
119
  interface WalkState {
108
120
  complete: boolean;
121
+ /**
122
+ * When present, every rule file the walk reads is recorded here by
123
+ * directory, so the daemon can upload the lines it actually applied.
124
+ */
125
+ rules?: Map<string, IndexRuleFiles>;
126
+ }
127
+
128
+ /** The rule-file lines one directory contributed to a walk. */
129
+ export interface IndexRuleFiles {
130
+ /** .gitignore + .mnemonikignore lines, unioned, in read order. */
131
+ ignore: string[];
132
+ /** .gitattributes lines, in read order. */
133
+ attributes: string[];
134
+ }
135
+
136
+ /**
137
+ * A file a walk declined to index because a soft default excluded it.
138
+ * `stage: 'content'` means the file was read and its content decided.
139
+ */
140
+ export interface IndexExcludedFile {
141
+ /** Root-relative POSIX path, spelled as a chunk's `filePath`. */
142
+ path: string;
143
+ reason: IndexExclusionReason;
144
+ rule: string;
145
+ stage: 'path' | 'content';
109
146
  }
110
147
 
111
148
  /**
@@ -190,6 +227,9 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
190
227
  // `isSecretFile` below, not here. This glob engine cannot express negation
191
228
  // (`.env.example` must stay collectable) and its unanchored substring
192
229
  // matching would make `.env.*` also swallow `.environment.ts`.
230
+ //
231
+ // `/tests/fixtures/*` used to live here. It is now a soft default in
232
+ // indexClassifier.ts (test fixtures, R2), which a project can re-include.
193
233
  '.DS_Store',
194
234
  '*.log',
195
235
  '*.lock',
@@ -200,7 +240,6 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
200
240
  '*.bundle.js',
201
241
  '*.legacy.js',
202
242
  '*.map',
203
- '/tests/fixtures/*',
204
243
  ];
205
244
 
206
245
  /**
@@ -271,6 +310,9 @@ export function isSecretFile(relPath: string): boolean {
271
310
  */
272
311
  const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
273
312
 
313
+ /** Per-directory attributes file: linguist overrides and binary marks (indexClassifier.ts). */
314
+ const ATTRIBUTES_FILE_NAME = '.gitattributes';
315
+
274
316
  /**
275
317
  * Every file type the scanner will chunk, by extension. Matched
276
318
  * case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
@@ -587,12 +629,11 @@ export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
587
629
  ];
588
630
 
589
631
  /**
590
- * Segment-anchored match for the `tests/fixtures/` ignore pattern above -
591
- * matches at the path root or immediately after a `/`, mirroring exactly
592
- * what `shouldIgnore` compiles `'/tests/fixtures/*'` to. Exported so
593
- * server-side consumers (e.g. the `/scan/push` route) and query-time
594
- * consumers (e.g. `MemoryManager.searchCodePointers`'s hygiene exclusions)
595
- * share one definition instead of hand-rolling copies that can drift.
632
+ * Segment-anchored match for `tests/fixtures/` - matches at the path root or
633
+ * immediately after a `/`. Indexing no longer uses it (the fixture rule is the
634
+ * soft default in indexClassifier.ts, which a project can override); it stays
635
+ * for query-time hygiene over rows indexed before that rule existed
636
+ * (`MemoryManager.searchCodePointers`).
596
637
  */
597
638
  export const FIXTURE_PATH_RE = /(^|\/)tests\/fixtures\//;
598
639
  export function isFixturePath(path: string): boolean {
@@ -666,7 +707,12 @@ export async function isGitBoundary(dirPath: string): Promise<boolean> {
666
707
  */
667
708
  interface IgnoreLayer {
668
709
  baseRel: string;
669
- ig: Ignore;
710
+ /** Compiled .gitignore + .mnemonikignore; null when this directory has neither. */
711
+ ig: Ignore | null;
712
+ /** Any `!pattern` line: a path under here may be re-included from a soft default. */
713
+ hasNegation: boolean;
714
+ /** Parsed .gitattributes; null when this directory has none. */
715
+ attributes: GitattributesRule[] | null;
670
716
  }
671
717
  type IgnoreStack = IgnoreLayer[];
672
718
 
@@ -789,12 +835,13 @@ export class CodeScanner {
789
835
  }
790
836
 
791
837
  /**
792
- * Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
793
- * ignore layer (the two files are unioned - `.mnemonikignore` is just an
794
- * additional gitignore-syntax exclusion list). Returns null when neither file
795
- * exists. FAIL-CLOSED: a non-ENOENT read error (EISDIR, EACCES, timeout)
796
- * latches `walk.complete = false` so callers deriving *removals* from the walk
797
- * treat it as untrustworthy rather than silently ignoring nothing.
838
+ * Read `.gitignore` + `.mnemonikignore` + `.gitattributes` in `absDir` and
839
+ * compile them into one layer (the two ignore files are unioned -
840
+ * `.mnemonikignore` is just an additional gitignore-syntax exclusion list).
841
+ * Returns null when none of the three exists. FAIL-CLOSED: a non-ENOENT read
842
+ * error (EISDIR, EACCES, timeout) latches `walk.complete = false` so callers
843
+ * deriving *removals* from the walk treat it as untrustworthy rather than
844
+ * silently ignoring nothing.
798
845
  */
799
846
  private async loadIgnoreLayer(
800
847
  absDir: string,
@@ -803,8 +850,7 @@ export class CodeScanner {
803
850
  rootPath: string
804
851
  ): Promise<IgnoreLayer | null> {
805
852
  if (this.isProtected(absDir)) return null;
806
- const patterns: string[] = [];
807
- for (const name of IGNORE_FILE_NAMES) {
853
+ const read = async (name: string): Promise<string[] | null> => {
808
854
  const filePath = join(absDir, name);
809
855
  try {
810
856
  const content = await withTimeout(
@@ -812,32 +858,50 @@ export class CodeScanner {
812
858
  FILE_OP_TIMEOUT_MS,
813
859
  `readFile timed out: ${filePath}`
814
860
  );
815
- for (const line of content.split(/\r?\n/)) patterns.push(line);
861
+ return content.split(/\r?\n/);
816
862
  } catch (err) {
817
- if ((err as Error).message === 'symlink_skipped') continue;
863
+ if ((err as Error).message === 'symlink_skipped') return null;
818
864
  if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
819
865
  walk.complete = false;
820
- logDebug('Ignore-file read failed (fail-closed)', { path: filePath, err });
866
+ logDebug('Rule-file read failed (fail-closed)', { path: filePath, err });
821
867
  }
868
+ return null;
822
869
  }
870
+ };
871
+ const patterns: string[] = [];
872
+ for (const name of IGNORE_FILE_NAMES) {
873
+ const lines = await read(name);
874
+ if (lines) patterns.push(...lines);
875
+ }
876
+ const attributeLines = (await read(ATTRIBUTES_FILE_NAME)) ?? [];
877
+ if (patterns.length > 0 || attributeLines.length > 0) {
878
+ walk.rules?.set(baseRel, { ignore: patterns, attributes: attributeLines });
823
879
  }
880
+ const attributes =
881
+ attributeLines.length > 0 ? parseGitattributes(attributeLines.join('\n')) : [];
824
882
  // `ignore` tolerates blank/comment lines; null when nothing meaningful.
825
- if (patterns.length === 0) return null;
826
- const ig = ignore().add(patterns);
827
- return { baseRel, ig };
883
+ if (patterns.length === 0 && attributes.length === 0) return null;
884
+ return {
885
+ baseRel,
886
+ ig: patterns.length > 0 ? ignore().add(patterns) : null,
887
+ hasNegation: patterns.some((line) => line.trimStart().startsWith('!')),
888
+ attributes: attributes.length > 0 ? attributes : null,
889
+ };
828
890
  }
829
891
 
830
892
  /**
831
- * True when `entryRel` (relative to the scan root) is excluded by any ignore
832
- * layer in `stack`. Tested deepest-first: the first layer that explicitly
833
- * ignores OR re-includes (via a `!negation`) wins, matching git's rule that a
834
- * deeper `.gitignore` overrides a shallower one. Directories are tested with a
835
- * trailing slash so `foo/` dir-only rules match the directory itself.
893
+ * What the .gitignore / .mnemonikignore layers in `stack` say about
894
+ * `entryRel` (relative to the scan root). Tested deepest-first: the first
895
+ * layer that explicitly ignores OR re-includes (via a `!negation`) wins,
896
+ * matching git's rule that a deeper `.gitignore` overrides a shallower one.
897
+ * Directories are tested with a trailing slash so `foo/` dir-only rules
898
+ * match the directory itself. `'unignored'` means a negation matched this
899
+ * path itself; a re-included parent directory leaves its files `'unmatched'`.
836
900
  */
837
- private ignoredByStack(entryRel: string, isDir: boolean, stack: IgnoreStack): boolean {
901
+ private ignoreVerdict(entryRel: string, isDir: boolean, stack: IgnoreStack): IgnoreFileVerdict {
838
902
  for (let i = stack.length - 1; i >= 0; i--) {
839
903
  const layer = stack[i];
840
- if (!layer) continue;
904
+ if (!layer?.ig) continue;
841
905
  const { baseRel, ig } = layer;
842
906
  let sub: string;
843
907
  if (baseRel === '') {
@@ -849,10 +913,57 @@ export class CodeScanner {
849
913
  }
850
914
  if (!sub) continue;
851
915
  const res = ig.test(isDir ? sub + '/' : sub);
852
- if (res.ignored) return true;
853
- if (res.unignored) return false;
916
+ if (res.ignored) return 'ignored';
917
+ if (res.unignored) return 'unignored';
854
918
  }
855
- return false;
919
+ return 'unmatched';
920
+ }
921
+
922
+ private ignoredByStack(entryRel: string, isDir: boolean, stack: IgnoreStack): boolean {
923
+ return this.ignoreVerdict(entryRel, isDir, stack) === 'ignored';
924
+ }
925
+
926
+ private static attributeLayers(stack: IgnoreStack): GitattributesLayer[] {
927
+ return stack.flatMap((layer) =>
928
+ layer.attributes ? [{ baseRel: layer.baseRel, rules: layer.attributes }] : []
929
+ );
930
+ }
931
+
932
+ /**
933
+ * Skip this directory? User-ignored directories are skipped as before. A
934
+ * directory a soft default excludes (vendor/, generated/, ...) is pruned too -
935
+ * nothing under it can be indexed - UNLESS a rule above it could re-include
936
+ * something inside it: a `!pattern` or a `-linguist-*` attribute. Then the
937
+ * walk descends and judges each file, so the override works. Rule files
938
+ * INSIDE a pruned directory are never read: a vendored library's own
939
+ * .gitignore negations are not the project's intent.
940
+ */
941
+ private skipDirectory(relPath: string, stack: IgnoreStack): boolean {
942
+ const posixRel = relPath.split(sep).join('/');
943
+ const verdict = this.ignoreVerdict(posixRel, true, stack);
944
+ if (verdict === 'ignored') return true;
945
+ if (verdict === 'unignored') return false;
946
+ if (!softDefaultForDirectory(posixRel)) return false;
947
+ const canReinclude =
948
+ stack.some((layer) => layer.hasNegation) ||
949
+ gitattributesCanReinclude(CodeScanner.attributeLayers(stack));
950
+ return !canReinclude;
951
+ }
952
+
953
+ /**
954
+ * The index decision for a file from its path and the rule stack: `null`
955
+ * when a user ignore rule excludes it, otherwise the classifier's answer
956
+ * (precedence in indexClassifier.ts). Content rules are the caller's next
957
+ * step for an included path without an `override`.
958
+ */
959
+ private classifyEntry(relPath: string, stack: IgnoreStack): IndexClassification | null {
960
+ const posixRel = relPath.split(sep).join('/');
961
+ const verdict = this.ignoreVerdict(posixRel, false, stack);
962
+ if (verdict === 'ignored') return null;
963
+ return classifyForIndex(posixRel, {
964
+ ignoreFileVerdict: verdict,
965
+ attributes: CodeScanner.attributeLayers(stack),
966
+ });
856
967
  }
857
968
 
858
969
  /**
@@ -889,6 +1000,31 @@ export class CodeScanner {
889
1000
  return stack;
890
1001
  }
891
1002
 
1003
+ /**
1004
+ * A predicate for walkers outside this class (the daemon's file watcher):
1005
+ * true when the walk would descend into `absDir`, by the same rules
1006
+ * `listFiles` applies - user ignore files and soft-default directories,
1007
+ * with their overrides. Rule files are read once per directory for the life
1008
+ * of the returned predicate; take a fresh one after a rule file changes.
1009
+ * Directories outside `rootPath`, and the root itself, always pass.
1010
+ */
1011
+ directoryFilter(rootPath: string): (absDir: string) => Promise<boolean> {
1012
+ const cache = new Map<string, IgnoreLayer | null>();
1013
+ return async (absDir: string): Promise<boolean> => {
1014
+ try {
1015
+ const root = await this.canonicalRoot(rootPath);
1016
+ const rel = relative(root, absDir).split(sep).join('/');
1017
+ if (!rel || rel.startsWith('..')) return true;
1018
+ const stack = await this.buildIgnoreStackForFile(rel, root, cache, { complete: true });
1019
+ return !this.skipDirectory(rel, stack);
1020
+ } catch {
1021
+ // Fail open: watching a directory the walk would skip costs a watch
1022
+ // handle; not watching one it would index loses changes.
1023
+ return true;
1024
+ }
1025
+ };
1026
+ }
1027
+
892
1028
  /**
893
1029
  * True when any directory strictly between `rootPath` and `filePath`
894
1030
  * carries its own `.git` entry (see isGitBoundary). Paths that don't
@@ -1019,15 +1155,20 @@ export class CodeScanner {
1019
1155
  * redaction guarantee as `scanFiles`). `chunkless` lists the Markdown and
1020
1156
  * instruction files that chunked to nothing, as `scanFiles` reports them.
1021
1157
  */
1022
- async scanDirectoryWithStatus(
1023
- rootPath: string
1024
- ): Promise<{ chunks: CodeChunk[]; complete: boolean; chunkless: string[] }> {
1158
+ async scanDirectoryWithStatus(rootPath: string): Promise<{
1159
+ chunks: CodeChunk[];
1160
+ complete: boolean;
1161
+ chunkless: string[];
1162
+ /** Files a soft default excluded by their content (path exclusions are not listed). */
1163
+ excluded: IndexExcludedFile[];
1164
+ }> {
1025
1165
  rootPath = await this.canonicalRoot(rootPath);
1026
1166
  const chunks: CodeChunk[] = [];
1027
1167
  const chunkless: string[] = [];
1168
+ const excluded: IndexExcludedFile[] = [];
1028
1169
  const walk: WalkState = { complete: true };
1029
- await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk, [], chunkless);
1030
- return { chunks: this.scrubChunks(chunks), complete: walk.complete, chunkless };
1170
+ await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk, [], chunkless, excluded);
1171
+ return { chunks: this.scrubChunks(chunks), complete: walk.complete, chunkless, excluded };
1031
1172
  }
1032
1173
 
1033
1174
  /**
@@ -1058,24 +1199,50 @@ export class CodeScanner {
1058
1199
  * MUST NOT derive removedFiles from an incomplete inventory (a truncated
1059
1200
  * walk otherwise reads as mass deletion and deprecates live memories).
1060
1201
  */
1061
- async listFilesWithStatus(rootPath: string): Promise<{ paths: string[]; complete: boolean }> {
1202
+ async listFilesWithStatus(rootPath: string): Promise<{
1203
+ paths: string[];
1204
+ complete: boolean;
1205
+ /**
1206
+ * Listed paths a project rule re-included (a `!pattern` or a
1207
+ * `-linguist-*` attribute). Content rules do not apply to them; every
1208
+ * other listed path still answers to the content rules when read.
1209
+ */
1210
+ overridden: Set<string>;
1211
+ }> {
1062
1212
  rootPath = await this.canonicalRoot(rootPath);
1063
1213
  const paths: string[] = [];
1064
- const walk: WalkState = { complete: true };
1065
- await this.traversePaths(rootPath, rootPath, paths, 0, walk);
1214
+ const overridden = new Set<string>();
1215
+ const walk: WalkState = { complete: true, rules: new Map() };
1216
+ await this.traversePaths(rootPath, rootPath, paths, 0, walk, [], overridden);
1217
+ // Only a complete walk replaces the record: a partial one may be missing
1218
+ // rule files, and uploading it would tell the server fewer rules exist.
1219
+ if (walk.complete && walk.rules) this.ruleFilesByRoot.set(rootPath, walk.rules);
1066
1220
  return {
1067
1221
  paths: paths.filter((p) => !/(^|[/\\])\.\.([/\\]|$)/.test(p)),
1068
1222
  complete: walk.complete,
1223
+ overridden,
1069
1224
  };
1070
1225
  }
1071
1226
 
1227
+ /**
1228
+ * The rule files the last complete `listFilesWithStatus` walk of `rootPath`
1229
+ * applied, by directory ('' = root). Null before the first complete walk.
1230
+ * The daemon uploads these lines so the server judges paths as it did.
1231
+ */
1232
+ async indexRuleFiles(rootPath: string): Promise<ReadonlyMap<string, IndexRuleFiles> | null> {
1233
+ return this.ruleFilesByRoot.get(await this.canonicalRoot(rootPath)) ?? null;
1234
+ }
1235
+
1236
+ private readonly ruleFilesByRoot = new Map<string, Map<string, IndexRuleFiles>>();
1237
+
1072
1238
  private async traversePaths(
1073
1239
  currentPath: string,
1074
1240
  rootPath: string,
1075
1241
  out: string[],
1076
1242
  depth: number,
1077
1243
  walk: WalkState,
1078
- stack: IgnoreStack = []
1244
+ stack: IgnoreStack = [],
1245
+ overridden?: Set<string>
1079
1246
  ): Promise<void> {
1080
1247
  if (depth >= CodeScanner.MAX_DEPTH) return;
1081
1248
  if (this.isProtected(currentPath)) return;
@@ -1116,14 +1283,24 @@ export class CodeScanner {
1116
1283
  const stats = lstats;
1117
1284
 
1118
1285
  if (stats.isDirectory()) {
1119
- if (this.ignoredByStack(relativePath, true, localStack)) continue; // .gitignore/.mnemonikignore
1286
+ // .gitignore/.mnemonikignore, then soft-default directories
1287
+ if (this.skipDirectory(relativePath, localStack)) continue;
1120
1288
  if (await isGitBoundary(fullPath)) continue; // another repo's subtree
1121
- await this.traversePaths(fullPath, rootPath, out, depth + 1, walk, localStack);
1289
+ await this.traversePaths(
1290
+ fullPath,
1291
+ rootPath,
1292
+ out,
1293
+ depth + 1,
1294
+ walk,
1295
+ localStack,
1296
+ overridden
1297
+ );
1122
1298
  } else if (stats.isFile()) {
1123
- if (this.ignoredByStack(relativePath, false, localStack)) continue;
1124
- if (this.isChunkable(fullPath, relativePath)) {
1125
- out.push(relativePath);
1126
- }
1299
+ if (!this.isChunkable(fullPath, relativePath)) continue;
1300
+ const decision = this.classifyEntry(relativePath, localStack);
1301
+ if (!decision?.include) continue;
1302
+ out.push(relativePath);
1303
+ if (decision.override) overridden?.add(relativePath);
1127
1304
  }
1128
1305
  } catch (error) {
1129
1306
  walk.complete = false;
@@ -1141,11 +1318,17 @@ export class CodeScanner {
1141
1318
  * but yielded no chunk because each section fell under `minChunkSize`. The
1142
1319
  * daemon still pushes such a file's whole content: it is a doc the project
1143
1320
  * owns, and it would otherwise never reach the server.
1321
+ *
1322
+ * `excluded`, when given, receives every chunkable file a soft default
1323
+ * excluded (indexClassifier.ts), by path or by content. The daemon tells the
1324
+ * server about content exclusions so the server can retire what an earlier
1325
+ * version of the file left in the index.
1144
1326
  */
1145
1327
  async scanFiles(
1146
1328
  filePaths: string[],
1147
1329
  rootPath: string,
1148
- chunkless?: string[]
1330
+ chunkless?: string[],
1331
+ excluded?: IndexExcludedFile[]
1149
1332
  ): Promise<CodeChunk[]> {
1150
1333
  rootPath = await this.canonicalRoot(rootPath);
1151
1334
  const chunks: CodeChunk[] = [];
@@ -1160,15 +1343,6 @@ export class CodeScanner {
1160
1343
  if (this.shouldIgnore(fileRel)) {
1161
1344
  continue;
1162
1345
  }
1163
- // Honor .gitignore/.mnemonikignore on the incremental path too, by
1164
- // reconstructing this file's ancestor ignore-layer stack (memoized
1165
- // per directory across the batch).
1166
- if (rootPath && !fileRel.startsWith('..')) {
1167
- const stack = await this.buildIgnoreStackForFile(fileRel, rootPath, ignoreCache, walk);
1168
- if (this.ignoredByStack(fileRel, false, stack)) {
1169
- continue;
1170
- }
1171
- }
1172
1346
  // Explicit file lists arrive from watcher events, which can race a
1173
1347
  // worktree/nested-clone appearing - apply the same nested-git-boundary
1174
1348
  // rule the walks use (an ancestor between root and the file carrying
@@ -1176,16 +1350,36 @@ export class CodeScanner {
1176
1350
  if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
1177
1351
  continue;
1178
1352
  }
1353
+ if (!this.isChunkable(filePath, fileRel)) continue;
1179
1354
 
1180
- if (this.isChunkable(filePath, fileRel)) {
1181
- const fileChunks = await this.parseFile(
1182
- filePath,
1183
- rootPath || filePath,
1184
- undefined,
1185
- chunkless
1186
- );
1187
- chunks.push(...fileChunks);
1355
+ // Honor .gitignore/.mnemonikignore/.gitattributes on the incremental
1356
+ // path too, by reconstructing this file's ancestor rule stack (memoized
1357
+ // per directory across the batch), and judge it exactly as the walks do.
1358
+ let applyContentRules = true;
1359
+ if (rootPath && !fileRel.startsWith('..')) {
1360
+ const stack = await this.buildIgnoreStackForFile(fileRel, rootPath, ignoreCache, walk);
1361
+ const decision = this.classifyEntry(fileRel, stack);
1362
+ if (!decision) continue;
1363
+ if (!decision.include) {
1364
+ excluded?.push({
1365
+ path: fileRel,
1366
+ reason: decision.reason,
1367
+ rule: decision.rule,
1368
+ stage: 'path',
1369
+ });
1370
+ continue;
1371
+ }
1372
+ applyContentRules = !decision.override;
1188
1373
  }
1374
+
1375
+ const fileChunks = await this.parseFile(
1376
+ filePath,
1377
+ rootPath || filePath,
1378
+ undefined,
1379
+ chunkless,
1380
+ { applyContentRules, ...(excluded ? { excluded } : {}) }
1381
+ );
1382
+ chunks.push(...fileChunks);
1189
1383
  } catch (error) {
1190
1384
  logDebug('Error scanning file', { filePath, error });
1191
1385
  }
@@ -1248,7 +1442,8 @@ export class CodeScanner {
1248
1442
  depth: number,
1249
1443
  walk: WalkState,
1250
1444
  stack: IgnoreStack = [],
1251
- chunkless?: string[]
1445
+ chunkless?: string[],
1446
+ excluded?: IndexExcludedFile[]
1252
1447
  ): Promise<void> {
1253
1448
  // Prevent infinite recursion
1254
1449
  if (depth >= CodeScanner.MAX_DEPTH) {
@@ -1303,7 +1498,8 @@ export class CodeScanner {
1303
1498
  const stats = lstats;
1304
1499
 
1305
1500
  if (stats.isDirectory()) {
1306
- if (this.ignoredByStack(relativePath, true, localStack)) continue; // .gitignore/.mnemonikignore
1501
+ // .gitignore/.mnemonikignore, then soft-default directories
1502
+ if (this.skipDirectory(relativePath, localStack)) continue;
1307
1503
  if (await isGitBoundary(fullPath)) continue; // another repo's subtree
1308
1504
  await this.traverseDirectory(
1309
1505
  fullPath,
@@ -1312,14 +1508,18 @@ export class CodeScanner {
1312
1508
  depth + 1,
1313
1509
  walk,
1314
1510
  localStack,
1315
- chunkless
1511
+ chunkless,
1512
+ excluded
1316
1513
  );
1317
1514
  } else if (stats.isFile()) {
1318
- if (this.ignoredByStack(relativePath, false, localStack)) continue;
1319
- if (this.isChunkable(fullPath, relativePath)) {
1320
- const fileChunks = await this.parseFile(fullPath, rootPath, stats, chunkless);
1321
- chunks.push(...fileChunks);
1322
- }
1515
+ if (!this.isChunkable(fullPath, relativePath)) continue;
1516
+ const decision = this.classifyEntry(relativePath, localStack);
1517
+ if (!decision?.include) continue;
1518
+ const fileChunks = await this.parseFile(fullPath, rootPath, stats, chunkless, {
1519
+ applyContentRules: !decision.override,
1520
+ ...(excluded ? { excluded } : {}),
1521
+ });
1522
+ chunks.push(...fileChunks);
1323
1523
  }
1324
1524
  } catch (error) {
1325
1525
  walk.complete = false;
@@ -1377,7 +1577,10 @@ export class CodeScanner {
1377
1577
  filePath: string,
1378
1578
  rootPath: string,
1379
1579
  enumerated?: Stats,
1380
- chunkless?: string[]
1580
+ chunkless?: string[],
1581
+ contentGate: { applyContentRules: boolean; excluded?: IndexExcludedFile[] } = {
1582
+ applyContentRules: true,
1583
+ }
1381
1584
  ): Promise<CodeChunk[]> {
1382
1585
  try {
1383
1586
  // Check file size before reading to avoid memory issues
@@ -1400,18 +1603,39 @@ export class CodeScanner {
1400
1603
  FILE_OP_TIMEOUT_MS,
1401
1604
  `readFile timed out: ${filePath}`
1402
1605
  );
1606
+ const relativePath = relative(rootPath, filePath);
1607
+ // Content soft defaults (binary, generated header, minified, data dump),
1608
+ // read from the SCRUBBED text because that is what the server receives
1609
+ // and re-judges - the two sides must not straddle a threshold. A NUL in
1610
+ // the first 8,000 bytes now means "binary, skip" (git's heuristic)
1611
+ // instead of strip-and-index.
1612
+ if (contentGate.applyContentRules) {
1613
+ const byContent = softDefaultForContent(relativePath, scrubSecrets(content));
1614
+ if (byContent) {
1615
+ contentGate.excluded?.push({
1616
+ path: relativePath.split(sep).join('/'),
1617
+ reason: byContent.reason,
1618
+ rule: byContent.rule,
1619
+ stage: 'content',
1620
+ });
1621
+ logDebug('Skipping file excluded by a content soft default', {
1622
+ filePath: relativePath,
1623
+ ...byContent,
1624
+ });
1625
+ return [];
1626
+ }
1627
+ }
1403
1628
  // NUL sanitation (ingestion boundary, daemon side): Postgres `text`
1404
1629
  // columns reject the literal NUL byte (U+0000) - a single one aborts
1405
- // the whole INSERT/UPDATE. Some source files legitimately contain
1406
- // them. Strip before any chunking so every chunk's content, its
1407
- // contentHash, and its embedding all agree - mirrors the server-side
1408
- // strip in ProjectManager (kept as defense in depth for content that
1409
- // reaches the server via an older scanner) but doing it here means
1410
- // the daemon never even transmits the NUL bytes.
1630
+ // the whole INSERT/UPDATE. Text that is otherwise included can still
1631
+ // carry a stray NUL past the binary window. Strip before any chunking so
1632
+ // every chunk's content, its contentHash, and its embedding all agree -
1633
+ // mirrors the server-side strip in ProjectManager (kept as defense in
1634
+ // depth for content that reaches the server via an older scanner) but
1635
+ // doing it here means the daemon never even transmits the NUL bytes.
1411
1636
  if (content.includes('\0')) {
1412
1637
  content = content.replace(/\0/g, '');
1413
1638
  }
1414
- const relativePath = relative(rootPath, filePath);
1415
1639
  const language = this.detectLanguage(filePath);
1416
1640
 
1417
1641
  // Try to extract functions/classes
package/src/index.ts CHANGED
@@ -26,7 +26,41 @@ export {
26
26
  logAstCapabilityOnce,
27
27
  type CodeChunk,
28
28
  type ScanOptions,
29
+ type IndexExcludedFile,
30
+ type IndexRuleFiles,
29
31
  } from './codeScanner.js';
32
+ export {
33
+ INDEX_EXCLUSION_REASONS,
34
+ BINARY_SNIFF_BYTES,
35
+ MINIFIED_AVERAGE_LINE_LENGTH,
36
+ MINIFIED_LONG_LINE_BYTES,
37
+ DATA_DUMP_INSERT_THRESHOLD,
38
+ NESTED_IGNORE_TAG,
39
+ GITATTRIBUTES_TAG,
40
+ classifyForIndex,
41
+ softDefaultForPath,
42
+ softDefaultForDirectory,
43
+ softDefaultForContent,
44
+ parseGitattributes,
45
+ linguistAttributesFor,
46
+ gitattributesCanReinclude,
47
+ indexExclusionLabel,
48
+ indexExclusionNotice,
49
+ isIndexExclusionReason,
50
+ encodeNestedIgnoreLine,
51
+ encodeGitattributesLine,
52
+ decodeIndexRules,
53
+ type IndexExclusionReason,
54
+ type IndexExclusion,
55
+ type IndexClassification,
56
+ type IgnoreFileVerdict,
57
+ type GitattributesLayer,
58
+ type GitattributesRule,
59
+ type LinguistAttributes,
60
+ type ClassifyForIndexOptions,
61
+ type DecodedIndexRules,
62
+ } from './indexClassifier.js';
63
+ export { makeIndexJudge, type IndexJudge, type IndexJudgement } from './indexJudge.js';
30
64
  export {
31
65
  AST_LANGUAGE_IDS,
32
66
  QUERY_CHAIN,