@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.
- package/dist/codeScanner.d.ts +87 -18
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +229 -74
- package/dist/codeScanner.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/indexClassifier.d.ts +178 -0
- package/dist/indexClassifier.d.ts.map +1 -0
- package/dist/indexClassifier.js +585 -0
- package/dist/indexClassifier.js.map +1 -0
- package/dist/indexJudge.d.ts +50 -0
- package/dist/indexJudge.d.ts.map +1 -0
- package/dist/indexJudge.js +116 -0
- package/dist/indexJudge.js.map +1 -0
- package/package.json +1 -1
- package/src/codeScanner.ts +305 -81
- package/src/index.ts +34 -0
- package/src/indexClassifier.ts +681 -0
- package/src/indexJudge.ts +149 -0
package/dist/codeScanner.js
CHANGED
|
@@ -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
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
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
|
|
683
|
-
*
|
|
684
|
-
* additional gitignore-syntax exclusion list).
|
|
685
|
-
* exists. FAIL-CLOSED: a non-ENOENT read
|
|
686
|
-
* latches `walk.complete = false` so callers
|
|
687
|
-
* treat it as untrustworthy rather than
|
|
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
|
|
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
|
-
|
|
698
|
-
patterns.push(line);
|
|
701
|
+
return content.split(/\r?\n/);
|
|
699
702
|
}
|
|
700
703
|
catch (err) {
|
|
701
704
|
if (err.message === 'symlink_skipped')
|
|
702
|
-
|
|
705
|
+
return null;
|
|
703
706
|
if (err?.code !== 'ENOENT') {
|
|
704
707
|
walk.complete = false;
|
|
705
|
-
logDebug('
|
|
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
|
-
|
|
713
|
-
|
|
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
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* ignores OR re-includes (via a `!negation`) wins,
|
|
719
|
-
* deeper `.gitignore` overrides a shallower one.
|
|
720
|
-
* trailing slash so `foo/` dir-only rules
|
|
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
|
-
|
|
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
|
|
763
|
+
return 'ignored';
|
|
743
764
|
if (res.unignored)
|
|
744
|
-
return
|
|
765
|
+
return 'unignored';
|
|
745
766
|
}
|
|
746
|
-
return
|
|
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
|
|
932
|
-
|
|
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
|
-
|
|
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
|
-
|
|
974
|
-
|
|
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.
|
|
1088
|
+
if (!this.isChunkable(fullPath, relativePath))
|
|
981
1089
|
continue;
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
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.
|
|
1269
|
+
if (!this.isChunkable(fullPath, relativePath))
|
|
1142
1270
|
continue;
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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.
|
|
1218
|
-
//
|
|
1219
|
-
// contentHash, and its embedding all agree -
|
|
1220
|
-
// strip in ProjectManager (kept as defense in
|
|
1221
|
-
// reaches the server via an older scanner) but
|
|
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') {
|