@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.
@@ -12,6 +12,7 @@ import { scrubSecrets } from './secretPatterns.js';
12
12
  import { isProtectedLocalPath, protectedLocalPaths } from './protectedPaths.js';
13
13
  import { MAX_AST_PARSE_BYTES, MAX_SIGNATURE_CHARS, chunkWithAst, } from './ast/astChunker.js';
14
14
  import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
15
+ import { classifyForIndex, gitattributesCanReinclude, parseGitattributes, softDefaultForContent, softDefaultForDirectory, } from './indexClassifier.js';
15
16
  /**
16
17
  * File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
17
18
  */
@@ -120,6 +121,9 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
120
121
  // `isSecretFile` below, not here. This glob engine cannot express negation
121
122
  // (`.env.example` must stay collectable) and its unanchored substring
122
123
  // matching would make `.env.*` also swallow `.environment.ts`.
124
+ //
125
+ // `/tests/fixtures/*` used to live here. It is now a soft default in
126
+ // indexClassifier.ts (test fixtures, R2), which a project can re-include.
123
127
  '.DS_Store',
124
128
  '*.log',
125
129
  '*.lock',
@@ -130,7 +134,6 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
130
134
  '*.bundle.js',
131
135
  '*.legacy.js',
132
136
  '*.map',
133
- '/tests/fixtures/*',
134
137
  ];
135
138
  /**
136
139
  * Files whose CONTENT is a credential rather than code. Today these are
@@ -199,6 +202,8 @@ export function isSecretFile(relPath) {
199
202
  * list (e.g. `secrets/`). Matched hierarchically via the `ignore` package.
200
203
  */
201
204
  const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'];
205
+ /** Per-directory attributes file: linguist overrides and binary marks (indexClassifier.ts). */
206
+ const ATTRIBUTES_FILE_NAME = '.gitattributes';
202
207
  /**
203
208
  * Every file type the scanner will chunk, by extension. Matched
204
209
  * case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
@@ -505,12 +510,11 @@ export const AUTHORITY_FILE_MATCHERS = [
505
510
  isMigrationSqlAuthorityPath,
506
511
  ];
507
512
  /**
508
- * Segment-anchored match for the `tests/fixtures/` ignore pattern above -
509
- * matches at the path root or immediately after a `/`, mirroring exactly
510
- * what `shouldIgnore` compiles `'/tests/fixtures/*'` to. Exported so
511
- * server-side consumers (e.g. the `/scan/push` route) and query-time
512
- * consumers (e.g. `MemoryManager.searchCodePointers`'s hygiene exclusions)
513
- * share one definition instead of hand-rolling copies that can drift.
513
+ * Segment-anchored match for `tests/fixtures/` - matches at the path root or
514
+ * immediately after a `/`. Indexing no longer uses it (the fixture rule is the
515
+ * soft default in indexClassifier.ts, which a project can override); it stays
516
+ * for query-time hygiene over rows indexed before that rule existed
517
+ * (`MemoryManager.searchCodePointers`).
514
518
  */
515
519
  export const FIXTURE_PATH_RE = /(^|\/)tests\/fixtures\//;
516
520
  export function isFixturePath(path) {
@@ -679,50 +683,67 @@ export class CodeScanner {
679
683
  return true;
680
684
  }
681
685
  /**
682
- * Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
683
- * ignore layer (the two files are unioned - `.mnemonikignore` is just an
684
- * additional gitignore-syntax exclusion list). Returns null when neither file
685
- * exists. FAIL-CLOSED: a non-ENOENT read error (EISDIR, EACCES, timeout)
686
- * latches `walk.complete = false` so callers deriving *removals* from the walk
687
- * treat it as untrustworthy rather than silently ignoring nothing.
686
+ * Read `.gitignore` + `.mnemonikignore` + `.gitattributes` in `absDir` and
687
+ * compile them into one layer (the two ignore files are unioned -
688
+ * `.mnemonikignore` is just an additional gitignore-syntax exclusion list).
689
+ * Returns null when none of the three exists. FAIL-CLOSED: a non-ENOENT read
690
+ * error (EISDIR, EACCES, timeout) latches `walk.complete = false` so callers
691
+ * deriving *removals* from the walk treat it as untrustworthy rather than
692
+ * silently ignoring nothing.
688
693
  */
689
694
  async loadIgnoreLayer(absDir, baseRel, walk, rootPath) {
690
695
  if (this.isProtected(absDir))
691
696
  return null;
692
- const patterns = [];
693
- for (const name of IGNORE_FILE_NAMES) {
697
+ const read = async (name) => {
694
698
  const filePath = join(absDir, name);
695
699
  try {
696
700
  const content = await withTimeout(this.readConfined(filePath, rootPath), FILE_OP_TIMEOUT_MS, `readFile timed out: ${filePath}`);
697
- for (const line of content.split(/\r?\n/))
698
- patterns.push(line);
701
+ return content.split(/\r?\n/);
699
702
  }
700
703
  catch (err) {
701
704
  if (err.message === 'symlink_skipped')
702
- continue;
705
+ return null;
703
706
  if (err?.code !== 'ENOENT') {
704
707
  walk.complete = false;
705
- logDebug('Ignore-file read failed (fail-closed)', { path: filePath, err });
708
+ logDebug('Rule-file read failed (fail-closed)', { path: filePath, err });
706
709
  }
710
+ return null;
707
711
  }
712
+ };
713
+ const patterns = [];
714
+ for (const name of IGNORE_FILE_NAMES) {
715
+ const lines = await read(name);
716
+ if (lines)
717
+ patterns.push(...lines);
718
+ }
719
+ const attributeLines = (await read(ATTRIBUTES_FILE_NAME)) ?? [];
720
+ if (patterns.length > 0 || attributeLines.length > 0) {
721
+ walk.rules?.set(baseRel, { ignore: patterns, attributes: attributeLines });
708
722
  }
723
+ const attributes = attributeLines.length > 0 ? parseGitattributes(attributeLines.join('\n')) : [];
709
724
  // `ignore` tolerates blank/comment lines; null when nothing meaningful.
710
- if (patterns.length === 0)
725
+ if (patterns.length === 0 && attributes.length === 0)
711
726
  return null;
712
- const ig = ignore().add(patterns);
713
- return { baseRel, ig };
727
+ return {
728
+ baseRel,
729
+ ig: patterns.length > 0 ? ignore().add(patterns) : null,
730
+ hasNegation: patterns.some((line) => line.trimStart().startsWith('!')),
731
+ attributes: attributes.length > 0 ? attributes : null,
732
+ };
714
733
  }
715
734
  /**
716
- * True when `entryRel` (relative to the scan root) is excluded by any ignore
717
- * layer in `stack`. Tested deepest-first: the first layer that explicitly
718
- * ignores OR re-includes (via a `!negation`) wins, matching git's rule that a
719
- * deeper `.gitignore` overrides a shallower one. Directories are tested with a
720
- * trailing slash so `foo/` dir-only rules match the directory itself.
735
+ * What the .gitignore / .mnemonikignore layers in `stack` say about
736
+ * `entryRel` (relative to the scan root). Tested deepest-first: the first
737
+ * layer that explicitly ignores OR re-includes (via a `!negation`) wins,
738
+ * matching git's rule that a deeper `.gitignore` overrides a shallower one.
739
+ * Directories are tested with a trailing slash so `foo/` dir-only rules
740
+ * match the directory itself. `'unignored'` means a negation matched this
741
+ * path itself; a re-included parent directory leaves its files `'unmatched'`.
721
742
  */
722
- ignoredByStack(entryRel, isDir, stack) {
743
+ ignoreVerdict(entryRel, isDir, stack) {
723
744
  for (let i = stack.length - 1; i >= 0; i--) {
724
745
  const layer = stack[i];
725
- if (!layer)
746
+ if (!layer?.ig)
726
747
  continue;
727
748
  const { baseRel, ig } = layer;
728
749
  let sub;
@@ -739,11 +760,55 @@ export class CodeScanner {
739
760
  continue;
740
761
  const res = ig.test(isDir ? sub + '/' : sub);
741
762
  if (res.ignored)
742
- return true;
763
+ return 'ignored';
743
764
  if (res.unignored)
744
- return false;
765
+ return 'unignored';
745
766
  }
746
- return false;
767
+ return 'unmatched';
768
+ }
769
+ ignoredByStack(entryRel, isDir, stack) {
770
+ return this.ignoreVerdict(entryRel, isDir, stack) === 'ignored';
771
+ }
772
+ static attributeLayers(stack) {
773
+ return stack.flatMap((layer) => layer.attributes ? [{ baseRel: layer.baseRel, rules: layer.attributes }] : []);
774
+ }
775
+ /**
776
+ * Skip this directory? User-ignored directories are skipped as before. A
777
+ * directory a soft default excludes (vendor/, generated/, ...) is pruned too -
778
+ * nothing under it can be indexed - UNLESS a rule above it could re-include
779
+ * something inside it: a `!pattern` or a `-linguist-*` attribute. Then the
780
+ * walk descends and judges each file, so the override works. Rule files
781
+ * INSIDE a pruned directory are never read: a vendored library's own
782
+ * .gitignore negations are not the project's intent.
783
+ */
784
+ skipDirectory(relPath, stack) {
785
+ const posixRel = relPath.split(sep).join('/');
786
+ const verdict = this.ignoreVerdict(posixRel, true, stack);
787
+ if (verdict === 'ignored')
788
+ return true;
789
+ if (verdict === 'unignored')
790
+ return false;
791
+ if (!softDefaultForDirectory(posixRel))
792
+ return false;
793
+ const canReinclude = stack.some((layer) => layer.hasNegation) ||
794
+ gitattributesCanReinclude(CodeScanner.attributeLayers(stack));
795
+ return !canReinclude;
796
+ }
797
+ /**
798
+ * The index decision for a file from its path and the rule stack: `null`
799
+ * when a user ignore rule excludes it, otherwise the classifier's answer
800
+ * (precedence in indexClassifier.ts). Content rules are the caller's next
801
+ * step for an included path without an `override`.
802
+ */
803
+ classifyEntry(relPath, stack) {
804
+ const posixRel = relPath.split(sep).join('/');
805
+ const verdict = this.ignoreVerdict(posixRel, false, stack);
806
+ if (verdict === 'ignored')
807
+ return null;
808
+ return classifyForIndex(posixRel, {
809
+ ignoreFileVerdict: verdict,
810
+ attributes: CodeScanner.attributeLayers(stack),
811
+ });
747
812
  }
748
813
  /**
749
814
  * Reconstruct the root->parent ignore-layer stack for a single file, for the
@@ -769,6 +834,32 @@ export class CodeScanner {
769
834
  }
770
835
  return stack;
771
836
  }
837
+ /**
838
+ * A predicate for walkers outside this class (the daemon's file watcher):
839
+ * true when the walk would descend into `absDir`, by the same rules
840
+ * `listFiles` applies - user ignore files and soft-default directories,
841
+ * with their overrides. Rule files are read once per directory for the life
842
+ * of the returned predicate; take a fresh one after a rule file changes.
843
+ * Directories outside `rootPath`, and the root itself, always pass.
844
+ */
845
+ directoryFilter(rootPath) {
846
+ const cache = new Map();
847
+ return async (absDir) => {
848
+ try {
849
+ const root = await this.canonicalRoot(rootPath);
850
+ const rel = relative(root, absDir).split(sep).join('/');
851
+ if (!rel || rel.startsWith('..'))
852
+ return true;
853
+ const stack = await this.buildIgnoreStackForFile(rel, root, cache, { complete: true });
854
+ return !this.skipDirectory(rel, stack);
855
+ }
856
+ catch {
857
+ // Fail open: watching a directory the walk would skip costs a watch
858
+ // handle; not watching one it would index loses changes.
859
+ return true;
860
+ }
861
+ };
862
+ }
772
863
  /**
773
864
  * True when any directory strictly between `rootPath` and `filePath`
774
865
  * carries its own `.git` entry (see isGitBoundary). Paths that don't
@@ -894,9 +985,10 @@ export class CodeScanner {
894
985
  rootPath = await this.canonicalRoot(rootPath);
895
986
  const chunks = [];
896
987
  const chunkless = [];
988
+ const excluded = [];
897
989
  const walk = { complete: true };
898
- await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk, [], chunkless);
899
- return { chunks: this.scrubChunks(chunks), complete: walk.complete, chunkless };
990
+ await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk, [], chunkless, excluded);
991
+ return { chunks: this.scrubChunks(chunks), complete: walk.complete, chunkless, excluded };
900
992
  }
901
993
  /**
902
994
  * Enumerate scan-eligible relative file paths under `rootPath` without
@@ -928,14 +1020,29 @@ export class CodeScanner {
928
1020
  async listFilesWithStatus(rootPath) {
929
1021
  rootPath = await this.canonicalRoot(rootPath);
930
1022
  const paths = [];
931
- const walk = { complete: true };
932
- await this.traversePaths(rootPath, rootPath, paths, 0, walk);
1023
+ const overridden = new Set();
1024
+ const walk = { complete: true, rules: new Map() };
1025
+ await this.traversePaths(rootPath, rootPath, paths, 0, walk, [], overridden);
1026
+ // Only a complete walk replaces the record: a partial one may be missing
1027
+ // rule files, and uploading it would tell the server fewer rules exist.
1028
+ if (walk.complete && walk.rules)
1029
+ this.ruleFilesByRoot.set(rootPath, walk.rules);
933
1030
  return {
934
1031
  paths: paths.filter((p) => !/(^|[/\\])\.\.([/\\]|$)/.test(p)),
935
1032
  complete: walk.complete,
1033
+ overridden,
936
1034
  };
937
1035
  }
938
- async traversePaths(currentPath, rootPath, out, depth, walk, stack = []) {
1036
+ /**
1037
+ * The rule files the last complete `listFilesWithStatus` walk of `rootPath`
1038
+ * applied, by directory ('' = root). Null before the first complete walk.
1039
+ * The daemon uploads these lines so the server judges paths as it did.
1040
+ */
1041
+ async indexRuleFiles(rootPath) {
1042
+ return this.ruleFilesByRoot.get(await this.canonicalRoot(rootPath)) ?? null;
1043
+ }
1044
+ ruleFilesByRoot = new Map();
1045
+ async traversePaths(currentPath, rootPath, out, depth, walk, stack = [], overridden) {
939
1046
  if (depth >= CodeScanner.MAX_DEPTH)
940
1047
  return;
941
1048
  if (this.isProtected(currentPath))
@@ -970,18 +1077,22 @@ export class CodeScanner {
970
1077
  continue;
971
1078
  const stats = lstats;
972
1079
  if (stats.isDirectory()) {
973
- if (this.ignoredByStack(relativePath, true, localStack))
974
- continue; // .gitignore/.mnemonikignore
1080
+ // .gitignore/.mnemonikignore, then soft-default directories
1081
+ if (this.skipDirectory(relativePath, localStack))
1082
+ continue;
975
1083
  if (await isGitBoundary(fullPath))
976
1084
  continue; // another repo's subtree
977
- await this.traversePaths(fullPath, rootPath, out, depth + 1, walk, localStack);
1085
+ await this.traversePaths(fullPath, rootPath, out, depth + 1, walk, localStack, overridden);
978
1086
  }
979
1087
  else if (stats.isFile()) {
980
- if (this.ignoredByStack(relativePath, false, localStack))
1088
+ if (!this.isChunkable(fullPath, relativePath))
981
1089
  continue;
982
- if (this.isChunkable(fullPath, relativePath)) {
983
- out.push(relativePath);
984
- }
1090
+ const decision = this.classifyEntry(relativePath, localStack);
1091
+ if (!decision?.include)
1092
+ continue;
1093
+ out.push(relativePath);
1094
+ if (decision.override)
1095
+ overridden?.add(relativePath);
985
1096
  }
986
1097
  }
987
1098
  catch (error) {
@@ -999,8 +1110,13 @@ export class CodeScanner {
999
1110
  * but yielded no chunk because each section fell under `minChunkSize`. The
1000
1111
  * daemon still pushes such a file's whole content: it is a doc the project
1001
1112
  * owns, and it would otherwise never reach the server.
1113
+ *
1114
+ * `excluded`, when given, receives every chunkable file a soft default
1115
+ * excluded (indexClassifier.ts), by path or by content. The daemon tells the
1116
+ * server about content exclusions so the server can retire what an earlier
1117
+ * version of the file left in the index.
1002
1118
  */
1003
- async scanFiles(filePaths, rootPath, chunkless) {
1119
+ async scanFiles(filePaths, rootPath, chunkless, excluded) {
1004
1120
  rootPath = await this.canonicalRoot(rootPath);
1005
1121
  const chunks = [];
1006
1122
  const ignoreCache = new Map();
@@ -1015,15 +1131,6 @@ export class CodeScanner {
1015
1131
  if (this.shouldIgnore(fileRel)) {
1016
1132
  continue;
1017
1133
  }
1018
- // Honor .gitignore/.mnemonikignore on the incremental path too, by
1019
- // reconstructing this file's ancestor ignore-layer stack (memoized
1020
- // per directory across the batch).
1021
- if (rootPath && !fileRel.startsWith('..')) {
1022
- const stack = await this.buildIgnoreStackForFile(fileRel, rootPath, ignoreCache, walk);
1023
- if (this.ignoredByStack(fileRel, false, stack)) {
1024
- continue;
1025
- }
1026
- }
1027
1134
  // Explicit file lists arrive from watcher events, which can race a
1028
1135
  // worktree/nested-clone appearing - apply the same nested-git-boundary
1029
1136
  // rule the walks use (an ancestor between root and the file carrying
@@ -1031,10 +1138,30 @@ export class CodeScanner {
1031
1138
  if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
1032
1139
  continue;
1033
1140
  }
1034
- if (this.isChunkable(filePath, fileRel)) {
1035
- const fileChunks = await this.parseFile(filePath, rootPath || filePath, undefined, chunkless);
1036
- chunks.push(...fileChunks);
1141
+ if (!this.isChunkable(filePath, fileRel))
1142
+ continue;
1143
+ // Honor .gitignore/.mnemonikignore/.gitattributes on the incremental
1144
+ // path too, by reconstructing this file's ancestor rule stack (memoized
1145
+ // per directory across the batch), and judge it exactly as the walks do.
1146
+ let applyContentRules = true;
1147
+ if (rootPath && !fileRel.startsWith('..')) {
1148
+ const stack = await this.buildIgnoreStackForFile(fileRel, rootPath, ignoreCache, walk);
1149
+ const decision = this.classifyEntry(fileRel, stack);
1150
+ if (!decision)
1151
+ continue;
1152
+ if (!decision.include) {
1153
+ excluded?.push({
1154
+ path: fileRel,
1155
+ reason: decision.reason,
1156
+ rule: decision.rule,
1157
+ stage: 'path',
1158
+ });
1159
+ continue;
1160
+ }
1161
+ applyContentRules = !decision.override;
1037
1162
  }
1163
+ const fileChunks = await this.parseFile(filePath, rootPath || filePath, undefined, chunkless, { applyContentRules, ...(excluded ? { excluded } : {}) });
1164
+ chunks.push(...fileChunks);
1038
1165
  }
1039
1166
  catch (error) {
1040
1167
  logDebug('Error scanning file', { filePath, error });
@@ -1089,7 +1216,7 @@ export class CodeScanner {
1089
1216
  * Recursively traverse directory
1090
1217
  * Added depth parameter with max limit
1091
1218
  */
1092
- async traverseDirectory(currentPath, rootPath, chunks, depth, walk, stack = [], chunkless) {
1219
+ async traverseDirectory(currentPath, rootPath, chunks, depth, walk, stack = [], chunkless, excluded) {
1093
1220
  // Prevent infinite recursion
1094
1221
  if (depth >= CodeScanner.MAX_DEPTH) {
1095
1222
  logDebug('Max directory depth reached, skipping', { path: currentPath, depth });
@@ -1131,19 +1258,24 @@ export class CodeScanner {
1131
1258
  continue;
1132
1259
  const stats = lstats;
1133
1260
  if (stats.isDirectory()) {
1134
- if (this.ignoredByStack(relativePath, true, localStack))
1135
- continue; // .gitignore/.mnemonikignore
1261
+ // .gitignore/.mnemonikignore, then soft-default directories
1262
+ if (this.skipDirectory(relativePath, localStack))
1263
+ continue;
1136
1264
  if (await isGitBoundary(fullPath))
1137
1265
  continue; // another repo's subtree
1138
- await this.traverseDirectory(fullPath, rootPath, chunks, depth + 1, walk, localStack, chunkless);
1266
+ await this.traverseDirectory(fullPath, rootPath, chunks, depth + 1, walk, localStack, chunkless, excluded);
1139
1267
  }
1140
1268
  else if (stats.isFile()) {
1141
- if (this.ignoredByStack(relativePath, false, localStack))
1269
+ if (!this.isChunkable(fullPath, relativePath))
1142
1270
  continue;
1143
- if (this.isChunkable(fullPath, relativePath)) {
1144
- const fileChunks = await this.parseFile(fullPath, rootPath, stats, chunkless);
1145
- chunks.push(...fileChunks);
1146
- }
1271
+ const decision = this.classifyEntry(relativePath, localStack);
1272
+ if (!decision?.include)
1273
+ continue;
1274
+ const fileChunks = await this.parseFile(fullPath, rootPath, stats, chunkless, {
1275
+ applyContentRules: !decision.override,
1276
+ ...(excluded ? { excluded } : {}),
1277
+ });
1278
+ chunks.push(...fileChunks);
1147
1279
  }
1148
1280
  }
1149
1281
  catch (error) {
@@ -1196,7 +1328,9 @@ export class CodeScanner {
1196
1328
  * chunked here can always be shipped and stored verbatim.
1197
1329
  */
1198
1330
  static MAX_FILE_SIZE = MAX_SCANNED_FILE_BYTES;
1199
- async parseFile(filePath, rootPath, enumerated, chunkless) {
1331
+ async parseFile(filePath, rootPath, enumerated, chunkless, contentGate = {
1332
+ applyContentRules: true,
1333
+ }) {
1200
1334
  try {
1201
1335
  // Check file size before reading to avoid memory issues
1202
1336
  // Wrap stat with timeout
@@ -1212,18 +1346,39 @@ export class CodeScanner {
1212
1346
  }
1213
1347
  // Wrap readFile with timeout
1214
1348
  let content = await withTimeout(this.readConfined(filePath, rootPath, stats), FILE_OP_TIMEOUT_MS, `readFile timed out: ${filePath}`);
1349
+ const relativePath = relative(rootPath, filePath);
1350
+ // Content soft defaults (binary, generated header, minified, data dump),
1351
+ // read from the SCRUBBED text because that is what the server receives
1352
+ // and re-judges - the two sides must not straddle a threshold. A NUL in
1353
+ // the first 8,000 bytes now means "binary, skip" (git's heuristic)
1354
+ // instead of strip-and-index.
1355
+ if (contentGate.applyContentRules) {
1356
+ const byContent = softDefaultForContent(relativePath, scrubSecrets(content));
1357
+ if (byContent) {
1358
+ contentGate.excluded?.push({
1359
+ path: relativePath.split(sep).join('/'),
1360
+ reason: byContent.reason,
1361
+ rule: byContent.rule,
1362
+ stage: 'content',
1363
+ });
1364
+ logDebug('Skipping file excluded by a content soft default', {
1365
+ filePath: relativePath,
1366
+ ...byContent,
1367
+ });
1368
+ return [];
1369
+ }
1370
+ }
1215
1371
  // NUL sanitation (ingestion boundary, daemon side): Postgres `text`
1216
1372
  // columns reject the literal NUL byte (U+0000) - a single one aborts
1217
- // the whole INSERT/UPDATE. Some source files legitimately contain
1218
- // them. Strip before any chunking so every chunk's content, its
1219
- // contentHash, and its embedding all agree - mirrors the server-side
1220
- // strip in ProjectManager (kept as defense in depth for content that
1221
- // reaches the server via an older scanner) but doing it here means
1222
- // the daemon never even transmits the NUL bytes.
1373
+ // the whole INSERT/UPDATE. Text that is otherwise included can still
1374
+ // carry a stray NUL past the binary window. Strip before any chunking so
1375
+ // every chunk's content, its contentHash, and its embedding all agree -
1376
+ // mirrors the server-side strip in ProjectManager (kept as defense in
1377
+ // depth for content that reaches the server via an older scanner) but
1378
+ // doing it here means the daemon never even transmits the NUL bytes.
1223
1379
  if (content.includes('\0')) {
1224
1380
  content = content.replace(/\0/g, '');
1225
1381
  }
1226
- const relativePath = relative(rootPath, filePath);
1227
1382
  const language = this.detectLanguage(filePath);
1228
1383
  // Try to extract functions/classes
1229
1384
  if (language === 'markdown') {