@mnemonik/shared 7.119.2 → 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/src/codeScanner.ts
CHANGED
|
@@ -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
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
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
|
-
|
|
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
|
|
793
|
-
*
|
|
794
|
-
* additional gitignore-syntax exclusion list).
|
|
795
|
-
* exists. FAIL-CLOSED: a non-ENOENT read
|
|
796
|
-
* latches `walk.complete = false` so callers
|
|
797
|
-
* treat it as untrustworthy rather than
|
|
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
|
|
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
|
-
|
|
861
|
+
return content.split(/\r?\n/);
|
|
816
862
|
} catch (err) {
|
|
817
|
-
if ((err as Error).message === 'symlink_skipped')
|
|
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('
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
*
|
|
832
|
-
*
|
|
833
|
-
* ignores OR re-includes (via a `!negation`) wins,
|
|
834
|
-
* deeper `.gitignore` overrides a shallower one.
|
|
835
|
-
* trailing slash so `foo/` dir-only rules
|
|
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
|
|
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
|
|
853
|
-
if (res.unignored) return
|
|
916
|
+
if (res.ignored) return 'ignored';
|
|
917
|
+
if (res.unignored) return 'unignored';
|
|
854
918
|
}
|
|
855
|
-
return
|
|
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
|
-
|
|
1024
|
-
|
|
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<{
|
|
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
|
|
1065
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
);
|
|
1187
|
-
|
|
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
|
-
|
|
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.
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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.
|
|
1406
|
-
//
|
|
1407
|
-
// contentHash, and its embedding all agree -
|
|
1408
|
-
// strip in ProjectManager (kept as defense in
|
|
1409
|
-
// reaches the server via an older scanner) but
|
|
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,
|