@mnemonik/shared 7.119.2 → 7.120.1

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.
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Default indexing rules: which files are not code an agent works with.
3
+ *
4
+ * Code search should return the code agents edit, not generated clients,
5
+ * minified bundles, binary files, vendored dependencies or data dumps. These
6
+ * rules are SOFT defaults: a project can re-include any path they exclude, and
7
+ * that is what separates them from the hard rules that stay where they are
8
+ * (`isSecretFile`, protected paths, symlinks, nested git boundaries and
9
+ * `BUILT_IN_IGNORE_DIRS`, none of which can be overridden).
10
+ *
11
+ * Precedence, highest first:
12
+ * 1. hard rules - applied by the callers before this module is consulted;
13
+ * 2. the project's .gitignore / .mnemonikignore - a path they ignore is out,
14
+ * and a `!pattern` that matches the path ITSELF re-includes it from every
15
+ * soft default (a re-included parent directory does not: `.yarn/*` plus
16
+ * `!.yarn/releases`, Yarn's own recommended .gitignore, must not opt the
17
+ * bundled Yarn release back in);
18
+ * 3. .gitattributes - `linguist-vendored` / `linguist-generated` set or
19
+ * `=true` excludes, `-linguist-...` or `=false` re-includes, `binary` or
20
+ * `-text` means binary. Hierarchical, last match wins, as git resolves it;
21
+ * 4. the soft defaults below - path rules first, then content rules.
22
+ *
23
+ * ONE implementation for every place a path is judged: the daemon walk, its
24
+ * watcher and discovery walk, the server's push backstop and the retirement
25
+ * of stored rows. Two copies of a rule drift, and here drift means the daemon
26
+ * and the server disagree about what exists.
27
+ *
28
+ * Content rules read the SCRUBBED text (`scrubSecrets`), because that is the
29
+ * text the server receives: classifying the raw bytes on one side and the
30
+ * scrubbed bytes on the other would let a redaction placeholder move a file
31
+ * across the minified threshold on one side only.
32
+ */
33
+
34
+ import { posix } from 'path';
35
+
36
+ export const INDEX_EXCLUSION_REASONS = [
37
+ 'vendored',
38
+ 'generated',
39
+ 'minified',
40
+ 'binary',
41
+ 'data_dump',
42
+ 'changelog',
43
+ ] as const;
44
+
45
+ export type IndexExclusionReason = (typeof INDEX_EXCLUSION_REASONS)[number];
46
+
47
+ /** A soft default (or a .gitattributes attribute) excluded the path. */
48
+ export interface IndexExclusion {
49
+ reason: IndexExclusionReason;
50
+ /**
51
+ * The concrete rule, for operators and the retirement shape guard. Never
52
+ * carries a per-file number, so paths group by rule.
53
+ */
54
+ rule: string;
55
+ }
56
+
57
+ export type IndexClassification =
58
+ | {
59
+ include: true;
60
+ /**
61
+ * Set when a project rule re-included the path. Content rules do not run
62
+ * for an overridden path: the project has said it is code.
63
+ */
64
+ override?: 'ignore_file' | 'gitattributes';
65
+ }
66
+ | ({ include: false } & IndexExclusion);
67
+
68
+ /** What a project's .gitignore / .mnemonikignore stack says about one path. */
69
+ export type IgnoreFileVerdict = 'ignored' | 'unignored' | 'unmatched';
70
+
71
+ const REASON_LABELS: Readonly<Record<IndexExclusionReason, string>> = {
72
+ vendored: 'vendored code',
73
+ generated: 'generated code',
74
+ minified: 'minified code',
75
+ binary: 'a binary file',
76
+ data_dump: 'a data dump',
77
+ changelog: 'a changelog',
78
+ };
79
+
80
+ /** Human phrase for a reason: "generated code", "a binary file". */
81
+ export function indexExclusionLabel(reason: IndexExclusionReason): string {
82
+ return REASON_LABELS[reason];
83
+ }
84
+
85
+ /**
86
+ * The one line an agent sees when it asks about an excluded path. Short on
87
+ * purpose: the agent needs to know the absence is deliberate and that its own
88
+ * file tools still reach the file.
89
+ */
90
+ export function indexExclusionNotice(reason: IndexExclusionReason, subject?: string): string {
91
+ return subject
92
+ ? `${subject} is not in the code index: excluded by default as ${REASON_LABELS[reason]}. Read and Grep still work on it.`
93
+ : `Not in the code index: excluded by default as ${REASON_LABELS[reason]}. Read and Grep still work on this file.`;
94
+ }
95
+
96
+ export function isIndexExclusionReason(value: unknown): value is IndexExclusionReason {
97
+ return (
98
+ typeof value === 'string' && (INDEX_EXCLUSION_REASONS as readonly string[]).includes(value)
99
+ );
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Path rules
104
+ // ---------------------------------------------------------------------------
105
+
106
+ interface PathRule {
107
+ reason: IndexExclusionReason;
108
+ rule: string;
109
+ /** Tested against the root-relative POSIX path. */
110
+ re: RegExp;
111
+ }
112
+
113
+ /**
114
+ * Directory rules, tested against a path that ends in '/' for a directory and
115
+ * against the full file path for a file, so the same regex prunes a walk and
116
+ * classifies a file under it. Case-sensitive, as written: `Vendor/` is the
117
+ * CocoaPods/Swift spelling, and `Tests/fixtures/` the Swift package one.
118
+ */
119
+ const DIRECTORY_RULES: readonly PathRule[] = [
120
+ // R2 - vendored dependencies and archive debris.
121
+ {
122
+ reason: 'vendored',
123
+ rule: 'vendored directory',
124
+ re: /(^|\/)(vendors?|Vendor|third[-_]?party|3rd[-_]?party|Godeps|Carthage|jspm_packages|site-packages|dist-packages|__MACOSX)\//,
125
+ },
126
+ {
127
+ reason: 'vendored',
128
+ rule: 'Yarn release/plugin/cache directory',
129
+ re: /(^|\/)\.yarn\/(releases|plugins|sdks|versions|unplugged|cache)\//,
130
+ },
131
+ { reason: 'vendored', rule: 'documentation build output', re: /(^|\/)docs?\/_?build\// },
132
+ // Test fixtures are copies of other code: byte-identical snapshot trees
133
+ // stole the content-hash dedup slot from the real file and outranked it in
134
+ // code_search. This folds in the old built-in `/tests/fixtures/*` pattern.
135
+ { reason: 'vendored', rule: 'test fixtures', re: /(^|\/)([Tt]ests?|[Ss]pecs?)\/fixtures\// },
136
+ { reason: 'vendored', rule: 'test fixtures', re: /(^|\/)(testdata|__fixtures__)\// },
137
+ { reason: 'vendored', rule: 'test snapshots', re: /(^|\/)__snapshots__\// },
138
+ // R3 - generated output directories.
139
+ {
140
+ reason: 'generated',
141
+ rule: 'generated directory',
142
+ re: /(^|\/)(__generated__|_generated|generated|autogen)\//,
143
+ },
144
+ ];
145
+
146
+ /**
147
+ * R3 by base name. `.test.` / `.spec.` names are exempt (see
148
+ * `isTestOrSpecName`): `Generated.test.ts` tests generated code, it is not it.
149
+ */
150
+ const GENERATED_BASENAME_RULES: readonly { rule: string; re: RegExp }[] = [
151
+ // Infixes: foo.gen.ts, foo.g.dart, foo.pb.go, foo.pb.gw.go, foo.freezed.dart,
152
+ // Form1.Designer.cs. Case-insensitive: Visual Studio writes `.Designer.cs`.
153
+ { rule: 'generated infix', re: /\.(gen|g|pb|pb\.gw|freezed|designer)\.[^.]+$/i },
154
+ // Protocol Buffers / gRPC outputs.
155
+ { rule: 'protobuf/gRPC output', re: /_pb2(_grpc)?\.pyi?$/ },
156
+ { rule: 'protobuf/gRPC output', re: /_pb\.(js|ts)$/ },
157
+ { rule: 'protobuf/gRPC output', re: /_grpc_pb\..+$/ },
158
+ { rule: 'protobuf/gRPC output', re: /_grpc\.pb\.go$/ },
159
+ { rule: 'protobuf/gRPC output', re: /\.pb\.(go|cc|h|swift|dart|ts|js)$/ },
160
+ // SpecFlow, Yarn Plug'n'Play, Delphi type libraries.
161
+ { rule: 'SpecFlow feature binding', re: /\.feature\.cs$/ },
162
+ { rule: "Yarn Plug'n'Play loader", re: /^\.pnp\./ },
163
+ { rule: 'Delphi type library', re: /_tlb\.pas$/ },
164
+ ];
165
+
166
+ function isTestOrSpecName(base: string): boolean {
167
+ return base.includes('.test.') || base.includes('.spec.');
168
+ }
169
+
170
+ /**
171
+ * A base name whose stem token is `generated`: the name split on '.', minus
172
+ * the final extension, contains the token `generated` (any case).
173
+ * `Generated.ts`, `foo.generated.ts`, `api.generated.d.ts`; not
174
+ * `generatedClient.ts`, not `generator.ts`.
175
+ */
176
+ function hasGeneratedStemToken(base: string): boolean {
177
+ const tokens = base.split('.');
178
+ if (tokens.length < 2) return false;
179
+ return tokens.slice(0, -1).some((token) => token.toLowerCase() === 'generated');
180
+ }
181
+
182
+ /**
183
+ * R9. Upper-case stems match with any extension, as the convention is
184
+ * written (`CHANGELOG`, `NEWS`, `HISTORY.rst`). Other spellings match only as
185
+ * a document (`History.md`, `changelog.md`): `history.ts` and `news.py` are
186
+ * real modules in real applications.
187
+ */
188
+ const CHANGELOG_UPPER = /^(CHANGELOG|CHANGES|HISTORY|NEWS)(\.[^/]*)?$/;
189
+ const CHANGELOG_DOC = /^(changelog|changes|history|news)(\.(md|markdown|rst|txt|adoc))?$/i;
190
+
191
+ function posixPath(relPath: string): string {
192
+ return relPath.replace(/\\/g, '/').replace(/^\.\//, '');
193
+ }
194
+
195
+ /**
196
+ * Soft default for a directory, from its path alone. `dirRel` is the
197
+ * directory's root-relative path, with or without a trailing slash. Used to
198
+ * prune walks: nothing under a vendored or generated directory can be
199
+ * included by a soft default.
200
+ */
201
+ export function softDefaultForDirectory(dirRel: string): IndexExclusion | null {
202
+ const path = posixPath(dirRel).replace(/\/+$/, '');
203
+ if (!path) return null;
204
+ const withSlash = `${path}/`;
205
+ for (const { reason, rule, re } of DIRECTORY_RULES) {
206
+ if (re.test(withSlash)) return { reason, rule };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /** Soft default for a file from its path alone (R2, R3 by name, R8 AppleDouble, R9). */
212
+ export function softDefaultForPath(relPath: string): IndexExclusion | null {
213
+ const path = posixPath(relPath);
214
+ if (!path) return null;
215
+ for (const { reason, rule, re } of DIRECTORY_RULES) {
216
+ if (re.test(path)) return { reason, rule };
217
+ }
218
+ const base = posix.basename(path);
219
+ // R8: AppleDouble resource forks (`._name.js`) are binary metadata macOS
220
+ // writes beside a file on non-HFS volumes and inside zip archives.
221
+ if (base.startsWith('._')) return { reason: 'binary', rule: 'AppleDouble resource fork' };
222
+ if (!isTestOrSpecName(base)) {
223
+ if (hasGeneratedStemToken(base)) return { reason: 'generated', rule: 'generated file name' };
224
+ for (const { rule, re } of GENERATED_BASENAME_RULES) {
225
+ if (re.test(base)) return { reason: 'generated', rule };
226
+ }
227
+ }
228
+ if (CHANGELOG_UPPER.test(base) || CHANGELOG_DOC.test(base)) {
229
+ return { reason: 'changelog', rule: 'changelog file' };
230
+ }
231
+ return null;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Content rules
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /** git's binary heuristic window. */
239
+ export const BINARY_SNIFF_BYTES = 8000;
240
+ /** Header detection looks at this many leading lines. */
241
+ const HEADER_LINES = 10;
242
+ /** R5: average line length above which a .js/.mjs/.cjs/.css file is minified. */
243
+ export const MINIFIED_AVERAGE_LINE_LENGTH = 110;
244
+ /** R5: a line longer than this, in UTF-8 bytes, is a minified line. */
245
+ export const MINIFIED_LONG_LINE_BYTES = 4096;
246
+ /** R7: a .sql file with at least this many INSERT INTO statements is a dump. */
247
+ export const DATA_DUMP_INSERT_THRESHOLD = 50;
248
+
249
+ const MINIFIABLE_EXTENSION = /\.(js|mjs|cjs|css)$/i;
250
+
251
+ /**
252
+ * Comment-line openers across the languages the scanner reads. A header rule
253
+ * only ever reads comment lines: code that mentions "generated" in a string
254
+ * literal is code.
255
+ */
256
+ const COMMENT_LINE = /^\s*(\/\/|\/\*|\*|#|--|;|<!--|%|\(\*|\{-|"""|''')/;
257
+
258
+ const GO_GENERATED_HEADER = /^\/\/ Code generated .* DO NOT EDIT\.$/;
259
+ const AT_GENERATED = /@generated\b/;
260
+ const GENERATION_PHRASE =
261
+ /\b(generated by|auto-generated|autogenerated|this file was generated|code generated)\b/i;
262
+ const DO_NOT_EDIT_PHRASE = /\b(do not edit|do not modify|manual changes will be overwritten)\b/i;
263
+
264
+ /**
265
+ * R4. A file is generated by its header when, within its first ten lines:
266
+ * a Go-style `// Code generated ... DO NOT EDIT.` line stands alone; or a
267
+ * comment line carries `@generated`; or the comment lines carry BOTH a
268
+ * generation phrase and a do-not-edit phrase. One phrase alone never counts -
269
+ * hand-written files say "generated by" about their outputs all the time.
270
+ */
271
+ function generatedByHeader(content: string): string | null {
272
+ const lines = content.split('\n', HEADER_LINES);
273
+ let generation = false;
274
+ let doNotEdit = false;
275
+ for (const raw of lines) {
276
+ const line = raw.replace(/\r$/, '');
277
+ if (GO_GENERATED_HEADER.test(line)) return 'Go generated-code header';
278
+ if (!COMMENT_LINE.test(line)) continue;
279
+ if (AT_GENERATED.test(line)) return '@generated marker';
280
+ if (GENERATION_PHRASE.test(line)) generation = true;
281
+ if (DO_NOT_EDIT_PHRASE.test(line)) doNotEdit = true;
282
+ }
283
+ return generation && doNotEdit ? 'generated + do-not-edit header' : null;
284
+ }
285
+
286
+ function utf8Length(line: string): number {
287
+ // A UTF-16 unit is at most 3 UTF-8 bytes; skip the exact count when even
288
+ // that bound cannot reach the threshold.
289
+ if (line.length * 3 <= MINIFIED_LONG_LINE_BYTES) return line.length;
290
+ return Buffer.byteLength(line, 'utf8');
291
+ }
292
+
293
+ /**
294
+ * R5. More than one line over 4,096 bytes means minified, whatever the
295
+ * language. A single long line does not: one embedded data URI or a long
296
+ * literal table in hand-written source is common, and a 1,024-character
297
+ * single-line rule would exclude this repository's own src/server/tools.ts.
298
+ */
299
+ function minified(relPath: string, content: string): string | null {
300
+ const lines = content.split('\n');
301
+ let longLines = 0;
302
+ let total = 0;
303
+ for (const line of lines) {
304
+ total += line.length;
305
+ if (line.length > MINIFIED_LONG_LINE_BYTES / 3 && utf8Length(line) > MINIFIED_LONG_LINE_BYTES) {
306
+ longLines++;
307
+ if (longLines > 1) return 'two or more lines over 4,096 bytes';
308
+ }
309
+ }
310
+ if (MINIFIABLE_EXTENSION.test(relPath) && lines.length > 0) {
311
+ const average = total / lines.length;
312
+ if (average > MINIFIED_AVERAGE_LINE_LENGTH) return 'average line length over 110';
313
+ }
314
+ return null;
315
+ }
316
+
317
+ const INSERT_INTO = /\binsert\s+into\b/gi;
318
+ const COPY_FROM_STDIN = /^\s*COPY\s+[^\n]*\bFROM\s+stdin\b/im;
319
+
320
+ /** R7. Schema lives in migrations; rows do not belong in a code index. */
321
+ function dataDump(relPath: string, content: string): string | null {
322
+ if (!/\.sql$/i.test(relPath)) return null;
323
+ if (/(^|\/)migrations\//i.test(posixPath(relPath))) return null;
324
+ if (COPY_FROM_STDIN.test(content)) return 'SQL COPY ... FROM stdin';
325
+ let inserts = 0;
326
+ INSERT_INTO.lastIndex = 0;
327
+ while (INSERT_INTO.exec(content) !== null) {
328
+ if (++inserts >= DATA_DUMP_INSERT_THRESHOLD) return 'SQL with 50+ INSERT INTO statements';
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Soft default from a file's content (R4, R5, R7, R8). `content` is the
335
+ * decoded, scrubbed text. R8 uses git's heuristic: a NUL in the first 8,000
336
+ * characters (which cover at least the first 8,000 bytes).
337
+ */
338
+ export function softDefaultForContent(relPath: string, content: string): IndexExclusion | null {
339
+ if (content.slice(0, BINARY_SNIFF_BYTES).includes('\0')) {
340
+ return { reason: 'binary', rule: 'NUL byte in the first 8,000 bytes' };
341
+ }
342
+ const header = generatedByHeader(content);
343
+ if (header) return { reason: 'generated', rule: header };
344
+ const minifiedRule = minified(relPath, content);
345
+ if (minifiedRule) return { reason: 'minified', rule: minifiedRule };
346
+ const dump = dataDump(relPath, content);
347
+ if (dump) return { reason: 'data_dump', rule: dump };
348
+ return null;
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // .gitattributes
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /** Attribute state after git's rules: set, unset (`-a`), or a value. */
356
+ type AttributeValue = true | false | string;
357
+
358
+ export interface GitattributesRule {
359
+ /** Matched against the path relative to the attributes file's directory. */
360
+ re: RegExp;
361
+ /** No '/' in the pattern: match the base name at any depth. */
362
+ basenameOnly: boolean;
363
+ /** `undefined` = `!attr` (reset to unspecified). */
364
+ attrs: ReadonlyMap<string, AttributeValue | undefined>;
365
+ }
366
+
367
+ /** One .gitattributes file: its directory relative to the root ('' = root). */
368
+ export interface GitattributesLayer {
369
+ baseRel: string;
370
+ rules: readonly GitattributesRule[];
371
+ }
372
+
373
+ function escapeRegex(char: string): string {
374
+ return /[.+^${}()|[\]\\*?/]/.test(char) ? `\\${char}` : char;
375
+ }
376
+
377
+ /** git wildmatch, FNM_PATHNAME semantics: `*` stops at '/', `**` crosses it. */
378
+ function globToRegex(glob: string): RegExp {
379
+ let re = '';
380
+ let i = 0;
381
+ while (i < glob.length) {
382
+ if (glob.startsWith('**', i)) {
383
+ const atSegmentStart = i === 0 || glob[i - 1] === '/';
384
+ const next = glob[i + 2];
385
+ if (atSegmentStart && next === '/') {
386
+ re += '(?:.*/)?';
387
+ i += 3;
388
+ continue;
389
+ }
390
+ if (atSegmentStart && next === undefined) {
391
+ re += '.*';
392
+ i += 2;
393
+ continue;
394
+ }
395
+ re += '[^/]*';
396
+ i += 2;
397
+ continue;
398
+ }
399
+ const char = glob[i] as string;
400
+ if (char === '*') {
401
+ re += '[^/]*';
402
+ } else if (char === '?') {
403
+ re += '[^/]';
404
+ } else if (char === '[') {
405
+ const close = glob.indexOf(']', i + 2);
406
+ if (close === -1) {
407
+ re += '\\[';
408
+ } else {
409
+ let body = glob.slice(i + 1, close);
410
+ if (body.startsWith('!')) body = `^${body.slice(1)}`;
411
+ re += `[${body.replace(/\\/g, '\\\\')}]`;
412
+ i = close;
413
+ }
414
+ } else if (char === '\\' && i + 1 < glob.length) {
415
+ re += escapeRegex(glob[i + 1] as string);
416
+ i++;
417
+ } else {
418
+ re += escapeRegex(char);
419
+ }
420
+ i++;
421
+ }
422
+ return new RegExp(`^${re}$`);
423
+ }
424
+
425
+ /** Split one attributes line into its pattern and attribute tokens. */
426
+ function splitAttributesLine(line: string): { pattern: string; attrs: string[] } | null {
427
+ let pattern: string;
428
+ let rest: string;
429
+ if (line.startsWith('"')) {
430
+ // C-style quoted pattern; only the escapes a path can need.
431
+ let i = 1;
432
+ let out = '';
433
+ for (; i < line.length && line[i] !== '"'; i++) {
434
+ if (line[i] === '\\' && i + 1 < line.length) {
435
+ i++;
436
+ out += line[i] === 't' ? '\t' : line[i] === 'n' ? '\n' : (line[i] as string);
437
+ } else {
438
+ out += line[i];
439
+ }
440
+ }
441
+ if (i >= line.length) return null;
442
+ pattern = out;
443
+ rest = line.slice(i + 1);
444
+ } else {
445
+ const match = /^(\S+)(.*)$/.exec(line);
446
+ if (!match) return null;
447
+ pattern = match[1] as string;
448
+ rest = match[2] as string;
449
+ }
450
+ return { pattern, attrs: rest.split(/\s+/).filter(Boolean) };
451
+ }
452
+
453
+ /**
454
+ * Parse one .gitattributes file. Comments, blank lines, macro definitions
455
+ * (`[attr]...`) and negative patterns (forbidden by git) are skipped. The
456
+ * built-in `binary` macro is expanded to `-text`, which is the part of it this
457
+ * module reads.
458
+ */
459
+ export function parseGitattributes(text: string): GitattributesRule[] {
460
+ const rules: GitattributesRule[] = [];
461
+ for (const rawLine of text.split(/\r?\n/)) {
462
+ const line = rawLine.trim();
463
+ if (!line || line.startsWith('#') || line.startsWith('[attr]')) continue;
464
+ const split = splitAttributesLine(line);
465
+ if (!split || split.pattern.startsWith('!')) continue;
466
+ let { pattern } = split;
467
+ // A pattern that only matches a directory never applies to the files
468
+ // inside it (git: "use path/** instead"), so it can classify nothing.
469
+ if (pattern.endsWith('/')) continue;
470
+ const attrs = new Map<string, AttributeValue | undefined>();
471
+ for (const token of split.attrs) {
472
+ if (token.startsWith('-')) attrs.set(token.slice(1), false);
473
+ else if (token.startsWith('!')) attrs.set(token.slice(1), undefined);
474
+ else if (token.includes('=')) {
475
+ const eq = token.indexOf('=');
476
+ attrs.set(token.slice(0, eq), token.slice(eq + 1));
477
+ } else attrs.set(token, true);
478
+ }
479
+ if (attrs.get('binary') === true) attrs.set('text', false);
480
+ if (attrs.size === 0) continue;
481
+ const basenameOnly = !pattern.includes('/');
482
+ if (pattern.startsWith('/')) pattern = pattern.slice(1);
483
+ rules.push({ re: globToRegex(pattern), basenameOnly, attrs });
484
+ }
485
+ return rules;
486
+ }
487
+
488
+ /** The attributes this module reads, resolved for one path. */
489
+ export interface LinguistAttributes {
490
+ /** true = vendored, false = explicitly not vendored, undefined = unspecified. */
491
+ vendored?: boolean;
492
+ generated?: boolean;
493
+ binary: boolean;
494
+ }
495
+
496
+ function booleanAttribute(value: AttributeValue | undefined): boolean | undefined {
497
+ if (value === true || value === 'true') return true;
498
+ if (value === false || value === 'false') return false;
499
+ return undefined;
500
+ }
501
+
502
+ /**
503
+ * Resolve `linguist-vendored`, `linguist-generated` and binary-ness for one
504
+ * root-relative path. `layers` are applied root first, then deeper; within a
505
+ * file, later lines win - git's own order. `linguist-documentation` is not
506
+ * read: documentation is what agents read.
507
+ */
508
+ export function linguistAttributesFor(
509
+ relPath: string,
510
+ layers: readonly GitattributesLayer[]
511
+ ): LinguistAttributes {
512
+ const path = posixPath(relPath);
513
+ const state = new Map<string, AttributeValue | undefined>();
514
+ // Array.prototype.sort is stable, so equal depths keep the caller's order.
515
+ const ordered = [...layers].sort((a, b) => depthOf(a.baseRel) - depthOf(b.baseRel));
516
+ for (const layer of ordered) {
517
+ let sub: string;
518
+ if (layer.baseRel === '') sub = path;
519
+ else if (path.startsWith(`${layer.baseRel}/`)) sub = path.slice(layer.baseRel.length + 1);
520
+ else continue;
521
+ const base = posix.basename(sub);
522
+ for (const rule of layer.rules) {
523
+ if (!(rule.basenameOnly ? rule.re.test(base) : rule.re.test(sub))) continue;
524
+ for (const [name, value] of rule.attrs) state.set(name, value);
525
+ }
526
+ }
527
+ return {
528
+ vendored: booleanAttribute(state.get('linguist-vendored')),
529
+ generated: booleanAttribute(state.get('linguist-generated')),
530
+ binary: state.get('text') === false,
531
+ };
532
+ }
533
+
534
+ function depthOf(baseRel: string): number {
535
+ return baseRel === '' ? 0 : baseRel.split('/').length;
536
+ }
537
+
538
+ /** True when any rule in these layers can re-include a path (`-linguist-*` or `=false`). */
539
+ export function gitattributesCanReinclude(layers: readonly GitattributesLayer[]): boolean {
540
+ return layers.some((layer) =>
541
+ layer.rules.some((rule) =>
542
+ ['linguist-vendored', 'linguist-generated'].some(
543
+ (name) => booleanAttribute(rule.attrs.get(name)) === false
544
+ )
545
+ )
546
+ );
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // The classifier
551
+ // ---------------------------------------------------------------------------
552
+
553
+ export interface ClassifyForIndexOptions {
554
+ /**
555
+ * Decoded, scrubbed file content. Omit to classify by path only; the
556
+ * result then says nothing about content rules, which the caller applies
557
+ * later unless `override` is set.
558
+ */
559
+ content?: string;
560
+ /**
561
+ * The project's .gitignore / .mnemonikignore verdict for this path. An
562
+ * `'ignored'` path is excluded before this function is consulted;
563
+ * `'unignored'` - a `!pattern` matched the path itself - re-includes it.
564
+ */
565
+ ignoreFileVerdict?: IgnoreFileVerdict;
566
+ /** The .gitattributes files that apply, any order (resolved root first). */
567
+ attributes?: readonly GitattributesLayer[];
568
+ }
569
+
570
+ /**
571
+ * Should this path be indexed? `{ include: true }`, or
572
+ * `{ include: false, reason, rule }`. Hard rules are not this function's
573
+ * business: a caller excludes secrets, protected paths, symlinks, nested git
574
+ * boundaries, built-in directories and user-ignored paths first.
575
+ */
576
+ export function classifyForIndex(
577
+ relPath: string,
578
+ options: ClassifyForIndexOptions = {}
579
+ ): IndexClassification {
580
+ if (options.ignoreFileVerdict === 'unignored') return { include: true, override: 'ignore_file' };
581
+
582
+ if (options.attributes && options.attributes.length > 0) {
583
+ const attrs = linguistAttributesFor(relPath, options.attributes);
584
+ if (attrs.vendored === true) {
585
+ return { include: false, reason: 'vendored', rule: '.gitattributes linguist-vendored' };
586
+ }
587
+ if (attrs.generated === true) {
588
+ return { include: false, reason: 'generated', rule: '.gitattributes linguist-generated' };
589
+ }
590
+ if (attrs.binary) return { include: false, reason: 'binary', rule: '.gitattributes binary' };
591
+ if (attrs.vendored === false || attrs.generated === false) {
592
+ return { include: true, override: 'gitattributes' };
593
+ }
594
+ }
595
+
596
+ const byPath = softDefaultForPath(relPath);
597
+ if (byPath) return { include: false, ...byPath };
598
+
599
+ if (options.content !== undefined) {
600
+ const byContent = softDefaultForContent(relPath, options.content);
601
+ if (byContent) return { include: false, ...byContent };
602
+ }
603
+ return { include: true };
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Rule upload (daemon -> server)
608
+ // ---------------------------------------------------------------------------
609
+
610
+ /**
611
+ * The daemon uploads its root .gitignore / .mnemonikignore lines verbatim
612
+ * (`ignorePatterns`, the server's privacy list). The other rule lines the
613
+ * walk applied ride along as gitignore COMMENT lines, so a server or matcher
614
+ * that predates them reads them as comments and changes nothing:
615
+ *
616
+ * `#mnemonik:gitignore <dir>\t<line>` - a nested .gitignore /
617
+ * .mnemonikignore line, for
618
+ * `!pattern` overrides
619
+ * `#mnemonik:gitattributes <dir>\t<line>` - a .gitattributes line; <dir>
620
+ * is empty for the root file
621
+ *
622
+ * With them the server and retirement judge a path exactly as the daemon did.
623
+ */
624
+ export const NESTED_IGNORE_TAG = '#mnemonik:gitignore ';
625
+ export const GITATTRIBUTES_TAG = '#mnemonik:gitattributes ';
626
+
627
+ export function encodeNestedIgnoreLine(baseRel: string, line: string): string {
628
+ return `${NESTED_IGNORE_TAG}${baseRel}\t${line}`;
629
+ }
630
+
631
+ export function encodeGitattributesLine(baseRel: string, line: string): string {
632
+ return `${GITATTRIBUTES_TAG}${baseRel}\t${line}`;
633
+ }
634
+
635
+ /** Uploaded rules, split back into their layers. */
636
+ export interface DecodedIndexRules {
637
+ /** Root .gitignore / .mnemonikignore lines (the privacy list), in order. */
638
+ rootIgnore: string[];
639
+ /** Nested ignore-file lines by directory, in upload order. */
640
+ nestedIgnore: Map<string, string[]>;
641
+ /** .gitattributes lines by directory ('' = root), in upload order. */
642
+ attributes: Map<string, string[]>;
643
+ }
644
+
645
+ function decodeTagged(line: string, tag: string): { baseRel: string; body: string } | null {
646
+ if (!line.startsWith(tag)) return null;
647
+ const rest = line.slice(tag.length);
648
+ const tab = rest.indexOf('\t');
649
+ if (tab === -1) return null;
650
+ const baseRel = rest.slice(0, tab);
651
+ if (baseRel.split('/').some((segment) => segment === '..')) return null;
652
+ return { baseRel, body: rest.slice(tab + 1) };
653
+ }
654
+
655
+ export function decodeIndexRules(patterns: readonly string[]): DecodedIndexRules {
656
+ const decoded: DecodedIndexRules = {
657
+ rootIgnore: [],
658
+ nestedIgnore: new Map(),
659
+ attributes: new Map(),
660
+ };
661
+ for (const line of patterns) {
662
+ if (typeof line !== 'string') continue;
663
+ const nested = decodeTagged(line, NESTED_IGNORE_TAG);
664
+ if (nested) {
665
+ if (nested.baseRel === '') continue;
666
+ const lines = decoded.nestedIgnore.get(nested.baseRel) ?? [];
667
+ lines.push(nested.body);
668
+ decoded.nestedIgnore.set(nested.baseRel, lines);
669
+ continue;
670
+ }
671
+ const attr = decodeTagged(line, GITATTRIBUTES_TAG);
672
+ if (attr) {
673
+ const lines = decoded.attributes.get(attr.baseRel) ?? [];
674
+ lines.push(attr.body);
675
+ decoded.attributes.set(attr.baseRel, lines);
676
+ continue;
677
+ }
678
+ decoded.rootIgnore.push(line);
679
+ }
680
+ return decoded;
681
+ }