@gmickel/gno 1.25.1 → 1.27.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.
Files changed (94) hide show
  1. package/README.md +12 -5
  2. package/assets/skill/SKILL.md +37 -17
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.25.1.zip → gno-browser-clipper-v1.27.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +1 -1
  7. package/spec/cli.md +161 -6
  8. package/spec/db/schema.sql +1 -1
  9. package/spec/evals-agentic.md +17 -0
  10. package/spec/mcp.md +25 -6
  11. package/spec/output-schemas/ask.schema.json +3 -0
  12. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  13. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  14. package/spec/output-schemas/query-diagnose.schema.json +68 -5
  15. package/spec/output-schemas/search-results.schema.json +87 -1
  16. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  17. package/spec/output-schemas/status.schema.json +24 -0
  18. package/spec/project-profile.schema.json +303 -0
  19. package/src/app/context-runtime-contract.ts +4 -1
  20. package/src/app/context-runtime-types.ts +2 -0
  21. package/src/app/context-runtime.ts +26 -0
  22. package/src/app/verified-ask.ts +6 -1
  23. package/src/cli/commands/ask.ts +8 -1
  24. package/src/cli/commands/collection/add.ts +39 -45
  25. package/src/cli/commands/collection/remove.ts +28 -28
  26. package/src/cli/commands/collection/rename.ts +55 -73
  27. package/src/cli/commands/context/add.ts +37 -26
  28. package/src/cli/commands/context/rm.ts +47 -20
  29. package/src/cli/commands/init.ts +55 -125
  30. package/src/cli/commands/models/use.ts +43 -38
  31. package/src/cli/commands/profile-apply.ts +334 -0
  32. package/src/cli/commands/profile.ts +409 -0
  33. package/src/cli/commands/query.ts +6 -3
  34. package/src/cli/commands/search.ts +6 -1
  35. package/src/cli/commands/setup-activation.ts +205 -54
  36. package/src/cli/commands/setup-profile.ts +223 -0
  37. package/src/cli/commands/setup.ts +3 -0
  38. package/src/cli/commands/status.ts +43 -7
  39. package/src/cli/program.ts +112 -9
  40. package/src/config/content-types.ts +82 -0
  41. package/src/config/index.ts +11 -0
  42. package/src/config/project-profile.ts +374 -0
  43. package/src/config/saver.ts +16 -7
  44. package/src/config/types.ts +53 -2
  45. package/src/core/config-mutation.ts +138 -76
  46. package/src/core/config-write-lock.ts +89 -0
  47. package/src/core/context-compiler.ts +38 -1
  48. package/src/core/context-identity.ts +16 -0
  49. package/src/core/context-resolver.ts +2 -12
  50. package/src/core/folder-setup-planning.ts +6 -21
  51. package/src/core/folder-setup.ts +30 -2
  52. package/src/core/path-rules.ts +53 -0
  53. package/src/core/project-affinity-surface.ts +102 -7
  54. package/src/core/project-profile-apply-state.ts +268 -0
  55. package/src/core/project-profile-apply-validation.ts +95 -0
  56. package/src/core/project-profile-apply.ts +408 -0
  57. package/src/core/project-profile-canonical.ts +71 -0
  58. package/src/core/project-profile-diff.ts +302 -0
  59. package/src/core/project-profile-discovery.ts +519 -0
  60. package/src/core/project-profile-file.ts +37 -0
  61. package/src/core/project-profile-parser.ts +98 -0
  62. package/src/core/project-profile.ts +490 -0
  63. package/src/core/retrieval-replay-candidate.ts +6 -1
  64. package/src/ingestion/sync-options.ts +6 -2
  65. package/src/ingestion/sync.ts +21 -29
  66. package/src/ingestion/types.ts +1 -1
  67. package/src/ingestion/walker.ts +85 -44
  68. package/src/llm/cache.ts +21 -0
  69. package/src/mcp/tools/ask.ts +1 -0
  70. package/src/mcp/tools/index.ts +4 -0
  71. package/src/mcp/tools/query.ts +4 -2
  72. package/src/mcp/tools/search.ts +3 -0
  73. package/src/mcp/tools/status.ts +4 -0
  74. package/src/pipeline/content-type-boost.ts +264 -0
  75. package/src/pipeline/diagnose.ts +46 -19
  76. package/src/pipeline/explain.ts +15 -2
  77. package/src/pipeline/hybrid.ts +170 -74
  78. package/src/pipeline/rerank.ts +45 -15
  79. package/src/pipeline/search.ts +29 -11
  80. package/src/pipeline/types.ts +13 -4
  81. package/src/pipeline/vsearch.ts +30 -10
  82. package/src/sdk/client.ts +19 -3
  83. package/src/sdk/index.ts +1 -0
  84. package/src/sdk/types.ts +21 -5
  85. package/src/serve/config-sync.ts +2 -2
  86. package/src/serve/resident-runtime.ts +1 -0
  87. package/src/serve/routes/api.ts +18 -3
  88. package/src/serve/status-model.ts +2 -0
  89. package/src/serve/status.ts +4 -0
  90. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  91. package/src/store/migrations/index.ts +2 -0
  92. package/src/store/sqlite/adapter.ts +86 -0
  93. package/src/store/types.ts +3 -2
  94. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +0 -1
@@ -0,0 +1,490 @@
1
+ // node:fs/promises provides realpath/stat; Bun has no equivalent for symlink-safe path identity and regular-file metadata.
2
+ import { realpath, stat } from "node:fs/promises";
3
+ // node:path provides cross-platform path operations; Bun has no path utilities.
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ import type { Config, ModelPreset } from "../config/types";
7
+
8
+ import { normalizeContentTypes } from "../config/content-types";
9
+ import { createDefaultConfig } from "../config/defaults";
10
+ import {
11
+ PROJECT_PROFILE_FORCED_EXCLUDES,
12
+ PROJECT_PROFILE_SCHEMA_VERSION,
13
+ type ProjectProfile,
14
+ } from "../config/project-profile";
15
+ import { parseModelUri } from "../llm/cache";
16
+ import { getPreset } from "../llm/registry";
17
+ import { canonicalOperationalPath } from "./config-write-lock";
18
+ import { normalizePersistedContextText } from "./context-identity";
19
+ import { hasLikelySecretPath } from "./path-rules";
20
+ import {
21
+ canonicalProjectProfileJson,
22
+ compareCodeUnits,
23
+ fingerprintProjectProfileState,
24
+ normalizeLogicalPath,
25
+ projectProfileIncludePattern,
26
+ sha256,
27
+ sortedUnique,
28
+ } from "./project-profile-canonical";
29
+ import { parseProjectProfile } from "./project-profile-parser";
30
+
31
+ export {
32
+ canonicalProjectProfileJson,
33
+ fingerprintProjectProfileState,
34
+ projectProfileIncludePattern,
35
+ } from "./project-profile-canonical";
36
+ export type ProjectProfileDiagnosticCode =
37
+ | "COLLECTION_ROOT_NOT_DIRECTORY"
38
+ | "CONTEXT_FILE_INVALID"
39
+ | "CONTEXT_FILE_NOT_REGULAR"
40
+ | "CONTEXT_FILE_TOO_LARGE"
41
+ | "CONTEXT_FILE_UNREADABLE"
42
+ | "CONTEXT_TEXT_EMPTY"
43
+ | "INVALID_PROFILE"
44
+ | "MIGRATION_REQUIRED"
45
+ | "MODEL_CACHE_CHECK_FAILED"
46
+ | "MODEL_PATH_OVERLAP"
47
+ | "MODEL_PRESET_NOT_FOUND"
48
+ | "MODEL_PRESET_UNAVAILABLE_OFFLINE"
49
+ | "PATH_NOT_FOUND"
50
+ | "SYMLINK_ESCAPE"
51
+ | "UNSAFE_PATH"
52
+ | "UNSUPPORTED_SCHEMA_MAJOR"
53
+ | "UNSUPPORTED_SCHEMA_MINOR";
54
+
55
+ export interface ProjectProfileDiagnostic {
56
+ code: ProjectProfileDiagnosticCode;
57
+ severity: "error" | "warning";
58
+ path: string;
59
+ message: string;
60
+ }
61
+
62
+ export interface ProjectProfileContextState {
63
+ scopeType: "collection";
64
+ scopeKey: string;
65
+ text: string;
66
+ source:
67
+ | { kind: "inline"; sha256: string }
68
+ | { kind: "file"; path: string; sha256: string };
69
+ }
70
+
71
+ export interface ProjectProfileDesiredState {
72
+ schemaVersion: typeof PROJECT_PROFILE_SCHEMA_VERSION;
73
+ collection: {
74
+ name: string;
75
+ root: string;
76
+ include: string[];
77
+ exclude: string[];
78
+ languageHint?: string;
79
+ modelPreset?: string;
80
+ };
81
+ contexts: ProjectProfileContextState[];
82
+ contentTypes: Array<{
83
+ id: string;
84
+ prefixes: string[];
85
+ preset: string;
86
+ graphHints?: string[];
87
+ searchBoost?: number;
88
+ temporal?: boolean;
89
+ }>;
90
+ affinityDefaults: {
91
+ enabled: boolean;
92
+ contribution: number;
93
+ };
94
+ recommendedCapabilities: string[];
95
+ }
96
+
97
+ export interface ResolvedProjectProfilePaths {
98
+ profileRoot: string;
99
+ collectionRoot: string;
100
+ contextFiles: Array<{
101
+ logicalPath: string;
102
+ absolutePath: string;
103
+ }>;
104
+ }
105
+
106
+ export interface CompiledProjectProfile {
107
+ profile: ProjectProfile;
108
+ desiredState: ProjectProfileDesiredState;
109
+ canonicalJson: string;
110
+ fingerprint: string;
111
+ resolvedPaths: ResolvedProjectProfilePaths;
112
+ diagnostics: ProjectProfileDiagnostic[];
113
+ }
114
+
115
+ export type CompileProjectProfileResult =
116
+ | { ok: true; value: CompiledProjectProfile }
117
+ | { ok: false; diagnostics: ProjectProfileDiagnostic[] };
118
+
119
+ export interface ProjectProfileCompilerOptions {
120
+ profileRoot: string;
121
+ config?: Config;
122
+ isModelAvailableOffline?: (
123
+ modelUri: string,
124
+ modelType: "embed" | "rerank" | "expand" | "gen"
125
+ ) => Promise<boolean>;
126
+ }
127
+
128
+ export const PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES = 65_536;
129
+
130
+ const isContained = (parent: string, candidate: string): boolean => {
131
+ const pathFromParent = relative(parent, candidate);
132
+ return (
133
+ pathFromParent === "" ||
134
+ (pathFromParent !== ".." &&
135
+ !pathFromParent.startsWith(`..${sep}`) &&
136
+ !isAbsolute(pathFromParent))
137
+ );
138
+ };
139
+
140
+ const resolveContainedPath = async (
141
+ profileRoot: string,
142
+ logicalPath: string,
143
+ diagnosticPath: string
144
+ ): Promise<
145
+ | {
146
+ ok: true;
147
+ absolutePath: string;
148
+ metadata: Awaited<ReturnType<typeof stat>>;
149
+ }
150
+ | { ok: false; diagnostic: ProjectProfileDiagnostic }
151
+ > => {
152
+ const candidate = resolve(profileRoot, normalizeLogicalPath(logicalPath));
153
+ let absolutePath: string;
154
+ let metadata: Awaited<ReturnType<typeof stat>>;
155
+ try {
156
+ absolutePath = await realpath(candidate);
157
+ metadata = await stat(absolutePath);
158
+ } catch {
159
+ return {
160
+ ok: false,
161
+ diagnostic: {
162
+ code: "PATH_NOT_FOUND",
163
+ severity: "error",
164
+ path: diagnosticPath,
165
+ message: "Referenced project path does not exist.",
166
+ },
167
+ };
168
+ }
169
+ if (!isContained(profileRoot, absolutePath)) {
170
+ return {
171
+ ok: false,
172
+ diagnostic: {
173
+ code: "SYMLINK_ESCAPE",
174
+ severity: "error",
175
+ path: diagnosticPath,
176
+ message:
177
+ "Referenced project path resolves outside the trusted profile root.",
178
+ },
179
+ };
180
+ }
181
+ return { ok: true, absolutePath, metadata };
182
+ };
183
+
184
+ const modelUris = (
185
+ preset: ModelPreset
186
+ ): Array<["embed" | "rerank" | "expand" | "gen", string]> => [
187
+ ["embed", preset.embed],
188
+ ["rerank", preset.rerank],
189
+ ["expand", preset.expand ?? preset.gen],
190
+ ["gen", preset.gen],
191
+ ];
192
+
193
+ const diagnoseModelPreset = async (
194
+ profile: ProjectProfile,
195
+ options: ProjectProfileCompilerOptions,
196
+ profileRoot: string
197
+ ): Promise<ProjectProfileDiagnostic[]> => {
198
+ const presetId = profile.collection.modelPreset;
199
+ if (!presetId) return [];
200
+ const preset = getPreset(options.config ?? createDefaultConfig(), presetId);
201
+ if (!preset) {
202
+ return [
203
+ {
204
+ code: "MODEL_PRESET_NOT_FOUND",
205
+ severity: "error",
206
+ path: "collection.modelPreset",
207
+ message: `Model preset alias "${presetId}" is not configured.`,
208
+ },
209
+ ];
210
+ }
211
+ for (const [, uri] of modelUris(preset)) {
212
+ const parsed = parseModelUri(uri);
213
+ if (!parsed.ok || parsed.value.scheme !== "file") continue;
214
+ const modelPath = await canonicalOperationalPath(parsed.value.file);
215
+ if (isContained(profileRoot, modelPath)) {
216
+ return [
217
+ {
218
+ code: "MODEL_PATH_OVERLAP",
219
+ severity: "error",
220
+ path: "collection.modelPreset",
221
+ message:
222
+ "The selected model preset resolves runtime model state inside the project profile root.",
223
+ },
224
+ ];
225
+ }
226
+ }
227
+ if (!options.isModelAvailableOffline) return [];
228
+
229
+ const unavailable: string[] = [];
230
+ try {
231
+ for (const [modelType, uri] of modelUris(preset)) {
232
+ if (!(await options.isModelAvailableOffline(uri, modelType))) {
233
+ unavailable.push(modelType);
234
+ }
235
+ }
236
+ } catch {
237
+ return [
238
+ {
239
+ code: "MODEL_CACHE_CHECK_FAILED",
240
+ severity: "error",
241
+ path: "collection.modelPreset",
242
+ message: `Offline cache availability could not be verified for model preset alias "${presetId}".`,
243
+ },
244
+ ];
245
+ }
246
+ if (unavailable.length === 0) return [];
247
+ return [
248
+ {
249
+ code: "MODEL_PRESET_UNAVAILABLE_OFFLINE",
250
+ severity: "error",
251
+ path: "collection.modelPreset",
252
+ message: `Model preset alias "${presetId}" is unavailable offline for: ${unavailable.join(", ")}.`,
253
+ },
254
+ ];
255
+ };
256
+
257
+ export async function compileProjectProfileYaml(
258
+ yaml: string,
259
+ options: ProjectProfileCompilerOptions
260
+ ): Promise<CompileProjectProfileResult> {
261
+ const parsed = parseProjectProfile(yaml);
262
+ if ("ok" in parsed) return parsed;
263
+ const profile = parsed;
264
+ const diagnostics: ProjectProfileDiagnostic[] = [];
265
+
266
+ let profileRoot: string;
267
+ try {
268
+ profileRoot = await realpath(options.profileRoot);
269
+ } catch {
270
+ return {
271
+ ok: false,
272
+ diagnostics: [
273
+ {
274
+ code: "PATH_NOT_FOUND",
275
+ severity: "error",
276
+ path: "profileRoot",
277
+ message: "Trusted project profile root does not exist.",
278
+ },
279
+ ],
280
+ };
281
+ }
282
+
283
+ const collectionRoot = await resolveContainedPath(
284
+ profileRoot,
285
+ profile.collection.root,
286
+ "collection.root"
287
+ );
288
+ if (!collectionRoot.ok) diagnostics.push(collectionRoot.diagnostic);
289
+ else if (!collectionRoot.metadata.isDirectory()) {
290
+ diagnostics.push({
291
+ code: "COLLECTION_ROOT_NOT_DIRECTORY",
292
+ severity: "error",
293
+ path: "collection.root",
294
+ message: "The collection root must resolve to a directory.",
295
+ });
296
+ }
297
+
298
+ const contexts: ProjectProfileContextState[] = [];
299
+ const contextFiles: ResolvedProjectProfilePaths["contextFiles"] = [];
300
+ for (const [index, context] of profile.contexts.entries()) {
301
+ if ("text" in context) {
302
+ const normalizedText = normalizePersistedContextText(context.text);
303
+ if (!normalizedText) {
304
+ diagnostics.push({
305
+ code: "CONTEXT_TEXT_EMPTY",
306
+ severity: "error",
307
+ path: `contexts[${index}].text`,
308
+ message: "Context text must not be empty after normalization.",
309
+ });
310
+ continue;
311
+ }
312
+ contexts.push({
313
+ scopeType: "collection",
314
+ scopeKey: `${profile.collection.name}:`,
315
+ text: normalizedText,
316
+ source: { kind: "inline", sha256: sha256(context.text) },
317
+ });
318
+ continue;
319
+ }
320
+
321
+ const resolved = await resolveContainedPath(
322
+ profileRoot,
323
+ context.file,
324
+ `contexts[${index}].file`
325
+ );
326
+ if (!resolved.ok) {
327
+ diagnostics.push(resolved.diagnostic);
328
+ continue;
329
+ }
330
+ const resolvedLogicalPath = normalizeLogicalPath(
331
+ relative(profileRoot, resolved.absolutePath)
332
+ );
333
+ if (hasLikelySecretPath(resolvedLogicalPath)) {
334
+ diagnostics.push({
335
+ code: "UNSAFE_PATH",
336
+ severity: "error",
337
+ path: `contexts[${index}].file`,
338
+ message:
339
+ "Context input resolves to a likely credential or secret file.",
340
+ });
341
+ continue;
342
+ }
343
+ if (!resolved.metadata.isFile()) {
344
+ diagnostics.push({
345
+ code: "CONTEXT_FILE_NOT_REGULAR",
346
+ severity: "error",
347
+ path: `contexts[${index}].file`,
348
+ message: "Context input must resolve to a regular file.",
349
+ });
350
+ continue;
351
+ }
352
+ if (resolved.metadata.size > PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES) {
353
+ diagnostics.push({
354
+ code: "CONTEXT_FILE_TOO_LARGE",
355
+ severity: "error",
356
+ path: `contexts[${index}].file`,
357
+ message: `Context file exceeds ${PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES} bytes.`,
358
+ });
359
+ continue;
360
+ }
361
+ let bytes: Uint8Array;
362
+ try {
363
+ bytes = new Uint8Array(
364
+ await Bun.file(resolved.absolutePath)
365
+ .slice(0, PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES + 1)
366
+ .arrayBuffer()
367
+ );
368
+ } catch {
369
+ diagnostics.push({
370
+ code: "CONTEXT_FILE_UNREADABLE",
371
+ severity: "error",
372
+ path: `contexts[${index}].file`,
373
+ message: "Context file could not be read.",
374
+ });
375
+ continue;
376
+ }
377
+ if (bytes.byteLength > PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES) {
378
+ diagnostics.push({
379
+ code: "CONTEXT_FILE_TOO_LARGE",
380
+ severity: "error",
381
+ path: `contexts[${index}].file`,
382
+ message: `Context file exceeds ${PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES} bytes.`,
383
+ });
384
+ continue;
385
+ }
386
+ let text: string;
387
+ try {
388
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
389
+ } catch {
390
+ diagnostics.push({
391
+ code: "CONTEXT_FILE_INVALID",
392
+ severity: "error",
393
+ path: `contexts[${index}].file`,
394
+ message: "Context file is not valid UTF-8.",
395
+ });
396
+ continue;
397
+ }
398
+ const logicalPath = normalizeLogicalPath(context.file);
399
+ const normalizedText = normalizePersistedContextText(text);
400
+ if (!normalizedText) {
401
+ diagnostics.push({
402
+ code: "CONTEXT_TEXT_EMPTY",
403
+ severity: "error",
404
+ path: `contexts[${index}].file`,
405
+ message: "Context file must not be empty after normalization.",
406
+ });
407
+ continue;
408
+ }
409
+ contexts.push({
410
+ scopeType: "collection",
411
+ scopeKey: `${profile.collection.name}:`,
412
+ text: normalizedText,
413
+ source: { kind: "file", path: logicalPath, sha256: sha256(bytes) },
414
+ });
415
+ contextFiles.push({
416
+ logicalPath,
417
+ absolutePath: resolved.absolutePath,
418
+ });
419
+ }
420
+
421
+ diagnostics.push(
422
+ ...(await diagnoseModelPreset(profile, options, profileRoot))
423
+ );
424
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
425
+ return { ok: false, diagnostics };
426
+ }
427
+
428
+ const normalizedContentTypes = normalizeContentTypes(
429
+ Object.entries(profile.contentTypes).map(([id, rule]) => ({ id, ...rule }))
430
+ ).rules.map((rule) => ({
431
+ id: rule.id,
432
+ prefixes: sortedUnique(rule.prefixes.map(normalizeLogicalPath)),
433
+ preset: rule.preset,
434
+ ...(rule.graphHints ? { graphHints: sortedUnique(rule.graphHints) } : {}),
435
+ ...(rule.searchBoost === undefined
436
+ ? {}
437
+ : { searchBoost: rule.searchBoost }),
438
+ ...(rule.temporal === undefined ? {} : { temporal: rule.temporal }),
439
+ }));
440
+
441
+ const desiredState: ProjectProfileDesiredState = {
442
+ schemaVersion: PROJECT_PROFILE_SCHEMA_VERSION,
443
+ collection: {
444
+ name: profile.collection.name,
445
+ root: normalizeLogicalPath(profile.collection.root),
446
+ include: sortedUnique(
447
+ profile.collection.include.map(normalizeLogicalPath)
448
+ ),
449
+ exclude: sortedUnique([
450
+ ...profile.collection.exclude.map(normalizeLogicalPath),
451
+ ...PROJECT_PROFILE_FORCED_EXCLUDES,
452
+ ]),
453
+ ...(profile.collection.languageHint
454
+ ? { languageHint: profile.collection.languageHint }
455
+ : {}),
456
+ ...(profile.collection.modelPreset
457
+ ? { modelPreset: profile.collection.modelPreset }
458
+ : {}),
459
+ },
460
+ contexts: contexts.sort((left, right) =>
461
+ compareCodeUnits(
462
+ canonicalProjectProfileJson(left),
463
+ canonicalProjectProfileJson(right)
464
+ )
465
+ ),
466
+ contentTypes: normalizedContentTypes,
467
+ affinityDefaults: profile.affinityDefaults,
468
+ recommendedCapabilities: sortedUnique(profile.recommendedCapabilities),
469
+ };
470
+ const canonicalJson = canonicalProjectProfileJson(desiredState);
471
+ return {
472
+ ok: true,
473
+ value: {
474
+ profile,
475
+ desiredState,
476
+ canonicalJson,
477
+ fingerprint: fingerprintProjectProfileState(desiredState),
478
+ resolvedPaths: {
479
+ profileRoot,
480
+ collectionRoot: collectionRoot.ok
481
+ ? collectionRoot.absolutePath
482
+ : profileRoot,
483
+ contextFiles: contextFiles.sort((left, right) =>
484
+ compareCodeUnits(left.logicalPath, right.logicalPath)
485
+ ),
486
+ },
487
+ diagnostics,
488
+ },
489
+ };
490
+ }
@@ -12,6 +12,7 @@ import type { RetrievalQrelsCase } from "./retrieval-qrels";
12
12
  import type { RetrievalReplayCandidate } from "./retrieval-replay-types";
13
13
 
14
14
  import { parseUri } from "../app/constants";
15
+ import { normalizeContentTypes } from "../config";
15
16
  import { searchHybrid } from "../pipeline/hybrid";
16
17
  import { searchBm25 } from "../pipeline/search";
17
18
  import { SEARCH_RESULTS_TRACE_METADATA } from "../pipeline/types";
@@ -145,7 +146,11 @@ const runCandidateOnce = async (
145
146
  options: HybridSearchOptions
146
147
  ): Promise<StoreResult<SearchResults>> => {
147
148
  if (candidate.type === "bm25") {
148
- return searchBm25(deps.store, source.query.text, options);
149
+ return searchBm25(deps.store, source.query.text, {
150
+ ...options,
151
+ contentTypeRules: normalizeContentTypes(deps.config.contentTypes ?? [])
152
+ .rules,
153
+ });
149
154
  }
150
155
  if (candidate.type === "vector") {
151
156
  if (!(deps.vectorIndex && deps.embedPort)) {
@@ -7,7 +7,10 @@
7
7
  import type { Config, NormalizedContentTypeRule } from "../config";
8
8
  import type { SyncOptions } from "./types";
9
9
 
10
- import { fingerprintContentTypeRules, normalizeContentTypes } from "../config";
10
+ import {
11
+ fingerprintContentTypeMetadataRules,
12
+ normalizeContentTypes,
13
+ } from "../config";
11
14
 
12
15
  export function resolveContentTypeRules(
13
16
  config?: Pick<Config, "contentTypes">
@@ -24,6 +27,7 @@ export function withContentTypeRules(
24
27
  ...options,
25
28
  contentTypeRules: rules,
26
29
  contentTypeRulesFingerprint:
27
- options.contentTypeRulesFingerprint ?? fingerprintContentTypeRules(rules),
30
+ options.contentTypeRulesFingerprint ??
31
+ fingerprintContentTypeMetadataRules(rules),
28
32
  };
29
33
  }
@@ -33,7 +33,10 @@ import type {
33
33
  WalkerPort,
34
34
  } from "./types";
35
35
 
36
- import { fingerprintContentTypeRules } from "../config";
36
+ import {
37
+ fingerprintContentTypeMetadataRules,
38
+ resolveContentTypeRule,
39
+ } from "../config";
37
40
  import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
38
41
  import {
39
42
  type ConversionPipeline,
@@ -80,7 +83,8 @@ const MAX_CONCURRENCY = 16;
80
83
  * Documents with ingestVersion < INGEST_VERSION will be re-processed.
81
84
  */
82
85
  export const INGEST_VERSION = 6;
83
- const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
86
+ const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT =
87
+ fingerprintContentTypeMetadataRules([]);
84
88
  const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
85
89
  const PROJECTION_YIELD_INTERVAL = 25;
86
90
  const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
@@ -401,18 +405,6 @@ function parseCategories(input: unknown): string[] {
401
405
  return [];
402
406
  }
403
407
 
404
- function matchPrefixContentType(
405
- relPath: string,
406
- rules: NormalizedContentTypeRule[]
407
- ): string | undefined {
408
- for (const rule of rules) {
409
- if (rule.prefixes.some((prefix) => relPath.startsWith(prefix))) {
410
- return rule.id;
411
- }
412
- }
413
- return undefined;
414
- }
415
-
416
408
  export function extractDocumentMetadata(
417
409
  markdown: string,
418
410
  relPath: string,
@@ -421,23 +413,23 @@ export function extractDocumentMetadata(
421
413
  ): DocumentMetadata {
422
414
  const parsed = parseFrontmatter(markdown);
423
415
  const metadata = parsed.metadata;
424
- const typedRules = new Map(contentTypeRules.map((rule) => [rule.id, rule]));
425
416
  const rawFrontmatterType =
426
417
  typeof metadata.type === "string"
427
418
  ? normalizeFrontmatterScalar(metadata.type)
428
419
  : "";
429
- const frontmatterType = typedRules.get(rawFrontmatterType)?.id;
430
- const prefixType =
431
- frontmatterType === undefined
432
- ? matchPrefixContentType(relPath, contentTypeRules)
433
- : undefined;
420
+ const configuredRule = resolveContentTypeRule(
421
+ rawFrontmatterType,
422
+ relPath,
423
+ contentTypeRules
424
+ );
434
425
  const inferred = inferPathContentType(relPath, ext);
435
- const contentType = frontmatterType ?? prefixType ?? inferred.contentType;
436
- const contentTypeSource: ContentTypeSource = frontmatterType
437
- ? "frontmatter-type"
438
- : prefixType
439
- ? "prefix"
440
- : inferred.source;
426
+ const contentType = configuredRule?.rule.id ?? inferred.contentType;
427
+ const contentTypeSource: ContentTypeSource =
428
+ configuredRule?.source === "configured-id"
429
+ ? "frontmatter-type"
430
+ : configuredRule?.source === "prefix"
431
+ ? "prefix"
432
+ : inferred.source;
441
433
  const categories = new Set<string>([contentType]);
442
434
 
443
435
  const fmCategories = parseCategories(
@@ -679,7 +671,7 @@ export class SyncService {
679
671
  const contentTypeRules = options.contentTypeRules ?? [];
680
672
  const contentTypeRulesFingerprint =
681
673
  options.contentTypeRulesFingerprint ??
682
- fingerprintContentTypeRules(contentTypeRules);
674
+ fingerprintContentTypeMetadataRules(contentTypeRules);
683
675
 
684
676
  // 4. Check existing doc for skip/repair decision
685
677
  const existingResult = await store.getDocument(
@@ -1066,7 +1058,7 @@ export class SyncService {
1066
1058
  contentTypeRules: options.contentTypeRules ?? [],
1067
1059
  contentTypeRulesFingerprint:
1068
1060
  options.contentTypeRulesFingerprint ??
1069
- fingerprintContentTypeRules(options.contentTypeRules ?? []),
1061
+ fingerprintContentTypeMetadataRules(options.contentTypeRules ?? []),
1070
1062
  };
1071
1063
  const results: FileSyncResult[] = [];
1072
1064
  const projectionSourceIds = new Set<number>();
@@ -1411,7 +1403,7 @@ export class SyncService {
1411
1403
  contentTypeRules: options.contentTypeRules ?? [],
1412
1404
  contentTypeRulesFingerprint:
1413
1405
  options.contentTypeRulesFingerprint ??
1414
- fingerprintContentTypeRules(options.contentTypeRules ?? []),
1406
+ fingerprintContentTypeMetadataRules(options.contentTypeRules ?? []),
1415
1407
  };
1416
1408
  const errors: Array<{ relPath: string; code: string; message: string }> =
1417
1409
  [];
@@ -135,7 +135,7 @@ export interface SyncOptions {
135
135
  concurrency?: number;
136
136
  /** Normalized content type rules from config.contentTypes. */
137
137
  contentTypeRules?: NormalizedContentTypeRule[];
138
- /** Stable hash of the normalized content type rules, used for re-derivation. */
138
+ /** Stable hash of metadata-affecting rules, used for re-derivation. */
139
139
  contentTypeRulesFingerprint?: string;
140
140
  /** Internal orchestration flag: defer graph projection to an outer sync. */
141
141
  projectTypedEdges?: boolean;