@gmickel/gno 1.30.7 → 1.32.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 (71) hide show
  1. package/README.md +6 -5
  2. package/assets/skill/SKILL.md +25 -0
  3. package/assets/skill/mcp-reference.md +6 -0
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.32.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +1 -1
  8. package/spec/cli.md +19 -0
  9. package/spec/db/schema.sql +55 -0
  10. package/spec/mcp.md +176 -11
  11. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  12. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  13. package/spec/output-schemas/section-target-create-result.schema.json +20 -0
  14. package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
  15. package/spec/output-schemas/section-target.schema.json +118 -0
  16. package/spec/output-schemas/section.schema.json +113 -0
  17. package/src/core/document-capabilities.ts +13 -0
  18. package/src/core/file-ops.ts +129 -1
  19. package/src/core/file-refactor-adapter.ts +329 -0
  20. package/src/core/file-refactor-apply-edits.ts +61 -0
  21. package/src/core/file-refactor-apply-fs.ts +512 -0
  22. package/src/core/file-refactor-apply-safety.ts +340 -0
  23. package/src/core/file-refactor-apply-validate.ts +401 -0
  24. package/src/core/file-refactor-contract.ts +486 -0
  25. package/src/core/file-refactor-destination.ts +123 -0
  26. package/src/core/file-refactor-from-snapshot.ts +148 -0
  27. package/src/core/file-refactor-journal-port.ts +150 -0
  28. package/src/core/file-refactor-journal.ts +347 -0
  29. package/src/core/file-refactor-paths.ts +60 -0
  30. package/src/core/file-refactor-plan-classify.ts +208 -0
  31. package/src/core/file-refactor-plan-validate.ts +169 -0
  32. package/src/core/file-refactor-planner-types.ts +62 -0
  33. package/src/core/file-refactor-planner.ts +423 -0
  34. package/src/core/file-refactor-resolve.ts +280 -0
  35. package/src/core/file-refactor-service.ts +468 -0
  36. package/src/core/file-refactors.ts +84 -56
  37. package/src/core/link-destination-parse.ts +275 -0
  38. package/src/core/link-inventory-markdown.ts +454 -0
  39. package/src/core/link-inventory-opaque.ts +244 -0
  40. package/src/core/link-inventory-types.ts +47 -0
  41. package/src/core/link-inventory.ts +182 -0
  42. package/src/core/link-relevance.ts +150 -0
  43. package/src/core/section-parse.ts +187 -0
  44. package/src/core/section-target-link.ts +154 -0
  45. package/src/core/section-target-resolve.ts +351 -0
  46. package/src/core/section-target-transport.ts +519 -0
  47. package/src/core/section-target.ts +263 -0
  48. package/src/core/sections.ts +60 -115
  49. package/src/mcp/AGENTS.md +1 -0
  50. package/src/mcp/CLAUDE.md +1 -0
  51. package/src/mcp/http-egress.ts +1 -0
  52. package/src/mcp/tools/index.ts +37 -17
  53. package/src/mcp/tools/sections.ts +512 -0
  54. package/src/mcp/tools/workspace-write.ts +215 -97
  55. package/src/sdk/client.ts +238 -116
  56. package/src/sdk/index.ts +12 -0
  57. package/src/sdk/types.ts +61 -3
  58. package/src/serve/file-refactor-http.ts +239 -0
  59. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  60. package/src/serve/public/globals.built.css +1 -1
  61. package/src/serve/public/lib/section-links.ts +189 -0
  62. package/src/serve/public/pages/DocView.tsx +395 -77
  63. package/src/serve/routes/api.ts +191 -104
  64. package/src/serve/routes/section-targets.ts +221 -0
  65. package/src/serve/server.ts +34 -0
  66. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  67. package/src/store/migrations/index.ts +2 -0
  68. package/src/store/sqlite/adapter.ts +452 -0
  69. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  70. package/src/store/types.ts +84 -0
  71. package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
@@ -93,6 +93,10 @@ import {
93
93
  handleDocSimilar,
94
94
  } from "./routes/links";
95
95
  import { createMcpHttpGateway } from "./routes/mcp";
96
+ import {
97
+ handleCreateSectionTarget,
98
+ handleResolveSectionTarget,
99
+ } from "./routes/section-targets";
96
100
  import {
97
101
  handleTraceDelete,
98
102
  handleTraceExport,
@@ -1172,6 +1176,36 @@ export async function startServer(
1172
1176
  );
1173
1177
  },
1174
1178
  },
1179
+ "/api/doc/:id/section-targets": {
1180
+ POST: async (req: Request) => {
1181
+ if (!isRequestAllowed(req, port)) {
1182
+ return withSecurityHeaders(forbiddenResponse(), isDev);
1183
+ }
1184
+ const parts = new URL(req.url).pathname.split("/");
1185
+ const id = decodeURIComponent(parts[3] || "");
1186
+ return withSecurityHeaders(
1187
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
1188
+ handleCreateSectionTarget(store, id, req)
1189
+ ),
1190
+ isDev
1191
+ );
1192
+ },
1193
+ },
1194
+ "/api/doc/:id/section-targets/resolve": {
1195
+ POST: async (req: Request) => {
1196
+ if (!isRequestAllowed(req, port)) {
1197
+ return withSecurityHeaders(forbiddenResponse(), isDev);
1198
+ }
1199
+ const parts = new URL(req.url).pathname.split("/");
1200
+ const id = decodeURIComponent(parts[3] || "");
1201
+ return withSecurityHeaders(
1202
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
1203
+ handleResolveSectionTarget(store, id, req)
1204
+ ),
1205
+ isDev
1206
+ );
1207
+ },
1208
+ },
1175
1209
  "/api/doc/:id/backlinks": {
1176
1210
  GET: async (req: Request) => {
1177
1211
  const url = new URL(req.url);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Migration: bounded, metadata-only file-refactor recovery journal.
3
+ *
4
+ * Stores IDs, plan digests, collection/relpaths, phase/state, timestamps,
5
+ * and fingerprints/status only. Never stores note bodies or replacements.
6
+ * Create prunes oldest terminal receipts under FILE_REFACTOR_JOURNAL_MAX_RECEIPTS.
7
+ *
8
+ * @module src/store/migrations/026-file-refactor-recovery-journal
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+
13
+ import type { Migration } from "./runner";
14
+
15
+ export const migration: Migration = {
16
+ version: 26,
17
+ name: "file_refactor_recovery_journal",
18
+
19
+ up(db: Database): void {
20
+ db.exec(`
21
+ CREATE TABLE IF NOT EXISTS file_refactor_recovery_journal (
22
+ journal_id TEXT PRIMARY KEY,
23
+ plan_digest TEXT NOT NULL,
24
+ collection TEXT NOT NULL,
25
+ operation TEXT NOT NULL
26
+ CHECK (operation IN ('rename', 'move')),
27
+ source_rel_path TEXT NOT NULL,
28
+ target_rel_path TEXT NOT NULL,
29
+ phase TEXT NOT NULL
30
+ CHECK (phase IN (
31
+ 'prepared', 'staging', 'committing', 'committed', 'sync_pending',
32
+ 'converged', 'rolling_back', 'rolled_back', 'recovery_required',
33
+ 'aborted'
34
+ )),
35
+ phase_ordinal INTEGER NOT NULL CHECK (phase_ordinal >= 0),
36
+ filesystem_state TEXT NOT NULL
37
+ CHECK (filesystem_state IN (
38
+ 'unchanged', 'committed', 'rolled_back', 'recovery_required'
39
+ )),
40
+ index_state TEXT NOT NULL
41
+ CHECK (index_state IN (
42
+ 'not_attempted', 'pending', 'converged', 'skipped'
43
+ )),
44
+ file_entries_json TEXT NOT NULL
45
+ CHECK (length(CAST(file_entries_json AS BLOB)) <= 65536),
46
+ created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
47
+ updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
48
+ CHECK (length(CAST(journal_id AS BLOB)) BETWEEN 1 AND 128),
49
+ CHECK (
50
+ length(plan_digest) = 64
51
+ AND plan_digest NOT GLOB '*[^0-9a-f]*'
52
+ ),
53
+ CHECK (length(CAST(collection AS BLOB)) BETWEEN 1 AND 256),
54
+ CHECK (length(CAST(source_rel_path AS BLOB)) BETWEEN 1 AND 4096),
55
+ CHECK (length(CAST(target_rel_path AS BLOB)) BETWEEN 1 AND 4096),
56
+ CHECK (updated_at_ms >= created_at_ms)
57
+ );
58
+
59
+ CREATE INDEX IF NOT EXISTS idx_file_refactor_journal_plan_digest
60
+ ON file_refactor_recovery_journal(plan_digest, updated_at_ms DESC, journal_id DESC);
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_file_refactor_journal_collection
63
+ ON file_refactor_recovery_journal(collection, updated_at_ms DESC);
64
+ `);
65
+ },
66
+
67
+ down(db: Database): void {
68
+ db.exec("DROP INDEX IF EXISTS idx_file_refactor_journal_collection");
69
+ db.exec("DROP INDEX IF EXISTS idx_file_refactor_journal_plan_digest");
70
+ db.exec("DROP TABLE IF EXISTS file_refactor_recovery_journal");
71
+ },
72
+ };
@@ -39,6 +39,7 @@ import { migration as m022 } from "./022-record-export-lineage";
39
39
  import { migration as m023 } from "./023-collection-egress-policy";
40
40
  import { migration as m024 } from "./024-egress-derived-lineage";
41
41
  import { migration as m025 } from "./025-collection-egress-policy-revision";
42
+ import { migration as m026 } from "./026-file-refactor-recovery-journal";
42
43
 
43
44
  /** All migrations in order */
44
45
  export const migrations = [
@@ -67,4 +68,5 @@ export const migrations = [
67
68
  m023,
68
69
  m024,
69
70
  m025,
71
+ m026,
70
72
  ];
@@ -16,6 +16,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
16
16
  import { basename } from "node:path";
17
17
 
18
18
  import type { EgressLineage } from "../../core/egress-provenance";
19
+ import type {
20
+ FileRefactorJournalAdvance,
21
+ FileRefactorRecoveryReceipt,
22
+ FileRefactorRecoveryReceiptDraft,
23
+ } from "../../core/file-refactor-journal";
19
24
  import type {
20
25
  ActivationIndexDocument,
21
26
  ActivationIndexIdentity,
@@ -55,6 +60,8 @@ import type {
55
60
  EgressAuditStatusResult,
56
61
  FtsResult,
57
62
  FtsSearchOptions,
63
+ FileRefactorResolutionReferrerDocument,
64
+ FileRefactorResolutionSnapshot,
58
65
  GetGraphNeighborsOptions,
59
66
  GetGraphOptions,
60
67
  GraphEdgeConfidence,
@@ -112,6 +119,10 @@ import {
112
119
  type FtsTokenizer,
113
120
  resolveConfiguredEgressPolicy,
114
121
  } from "../../config/types";
122
+ import {
123
+ getDocumentCapabilities,
124
+ isTextLikeReferenceDocument,
125
+ } from "../../core/document-capabilities";
115
126
  import { analyzeGraphCommunities } from "../../core/graph-analysis";
116
127
  import {
117
128
  classifyResolvedGraphEdge,
@@ -122,6 +133,7 @@ import {
122
133
  buildWikiBestRankMatchCountSubquery,
123
134
  buildWikiBestRankSubquery,
124
135
  } from "../../core/graph-resolver";
136
+ import { buildContentPrefilterNeedles } from "../../core/link-relevance";
125
137
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
126
138
  import {
127
139
  parseActivationReceipt,
@@ -160,6 +172,12 @@ import {
160
172
  listEgressAuditReceipts as listStoredEgressAuditReceipts,
161
173
  purgeEgressAuditReceipts as purgeStoredEgressAuditReceipts,
162
174
  } from "./egress-audit-store";
175
+ import {
176
+ advanceFileRefactorReceipt as advanceStoredFileRefactorReceipt,
177
+ createFileRefactorPreparedReceipt as createStoredFileRefactorPreparedReceipt,
178
+ getFileRefactorReceiptById as getStoredFileRefactorReceiptById,
179
+ getLatestFileRefactorReceiptByPlanDigest as getStoredLatestFileRefactorReceiptByPlanDigest,
180
+ } from "./file-refactor-journal-store";
163
181
  import { loadFts5Snowball } from "./fts5-snowball";
164
182
  import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
165
183
  import {
@@ -1960,6 +1978,38 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1960
1978
  return purgeStoredDocumentChanges(this.ensureOpen());
1961
1979
  }
1962
1980
 
1981
+ async createFileRefactorPreparedReceipt(
1982
+ draft: FileRefactorRecoveryReceiptDraft
1983
+ ): Promise<StoreResult<FileRefactorRecoveryReceipt>> {
1984
+ return createStoredFileRefactorPreparedReceipt(this.ensureOpen(), draft);
1985
+ }
1986
+
1987
+ async advanceFileRefactorReceipt(
1988
+ journalId: string,
1989
+ update: FileRefactorJournalAdvance
1990
+ ): Promise<StoreResult<FileRefactorRecoveryReceipt>> {
1991
+ return advanceStoredFileRefactorReceipt(
1992
+ this.ensureOpen(),
1993
+ journalId,
1994
+ update
1995
+ );
1996
+ }
1997
+
1998
+ async getFileRefactorReceiptById(
1999
+ journalId: string
2000
+ ): Promise<StoreResult<FileRefactorRecoveryReceipt | null>> {
2001
+ return getStoredFileRefactorReceiptById(this.ensureOpen(), journalId);
2002
+ }
2003
+
2004
+ async getLatestFileRefactorReceiptByPlanDigest(
2005
+ planDigest: string
2006
+ ): Promise<StoreResult<FileRefactorRecoveryReceipt | null>> {
2007
+ return getStoredLatestFileRefactorReceiptByPlanDigest(
2008
+ this.ensureOpen(),
2009
+ planDigest
2010
+ );
2011
+ }
2012
+
1963
2013
  async upsertSavedCapsuleRegistration(
1964
2014
  input: SavedCapsuleRegistrationInput
1965
2015
  ): Promise<StoreResult<SavedCapsuleRegistrationRecord>> {
@@ -3371,6 +3421,408 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3371
3421
  }
3372
3422
  }
3373
3423
 
3424
+ /**
3425
+ * Bounded read seam for reference-safe rename/move planning.
3426
+ * Loads catalog metadata plus content for indexed backlinks unioned with a
3427
+ * conservative SQL content prefilter (opaque embeds/HTML/code/malformed may
3428
+ * not appear in doc_links). Missing mirror content fails closed.
3429
+ */
3430
+ async getFileRefactorResolutionSnapshot(input: {
3431
+ sourceUri: string;
3432
+ maxCatalogDocuments?: number;
3433
+ maxReferrerDocuments?: number;
3434
+ maxContentCharsPerDocument?: number;
3435
+ maxTotalContentChars?: number;
3436
+ }): Promise<StoreResult<FileRefactorResolutionSnapshot>> {
3437
+ try {
3438
+ const db = this.ensureOpen();
3439
+ const maxCatalog = Math.max(
3440
+ 1,
3441
+ Math.floor(input.maxCatalogDocuments ?? 5_000)
3442
+ );
3443
+ const maxReferrers = Math.max(
3444
+ 1,
3445
+ Math.floor(input.maxReferrerDocuments ?? 5_000)
3446
+ );
3447
+ const maxChars = Math.max(
3448
+ 1,
3449
+ Math.floor(input.maxContentCharsPerDocument ?? 1_000_000)
3450
+ );
3451
+ const maxTotalChars = Math.max(
3452
+ maxChars,
3453
+ Math.floor(input.maxTotalContentChars ?? 20_000_000)
3454
+ );
3455
+ const truncationReasons: string[] = [];
3456
+
3457
+ const sourceRow = db
3458
+ .query<
3459
+ {
3460
+ id: number;
3461
+ uri: string;
3462
+ rel_path: string;
3463
+ collection: string;
3464
+ title: string | null;
3465
+ mirror_hash: string | null;
3466
+ source_ext: string;
3467
+ source_mime: string;
3468
+ record_key: string | null;
3469
+ },
3470
+ [string]
3471
+ >(
3472
+ `SELECT id, uri, rel_path, collection, title, mirror_hash,
3473
+ source_ext, source_mime, record_key
3474
+ FROM documents
3475
+ WHERE uri = ? AND active = 1`
3476
+ )
3477
+ .get(input.sourceUri);
3478
+
3479
+ if (!sourceRow) {
3480
+ return err("NOT_FOUND", `Document not found: ${input.sourceUri}`);
3481
+ }
3482
+
3483
+ const sourceCaps = getDocumentCapabilities({
3484
+ sourceExt: sourceRow.source_ext,
3485
+ sourceMime: sourceRow.source_mime,
3486
+ contentAvailable: Boolean(sourceRow.mirror_hash),
3487
+ recordKey: sourceRow.record_key,
3488
+ });
3489
+
3490
+ let sourceContent: string | null = null;
3491
+ let sourceContentTruncated = false;
3492
+ if (sourceRow.mirror_hash) {
3493
+ const contentRow = db
3494
+ .query<{ markdown: string }, [number, string]>(
3495
+ `SELECT substr(markdown, 1, ?) AS markdown
3496
+ FROM content WHERE mirror_hash = ?`
3497
+ )
3498
+ .get(maxChars + 1, sourceRow.mirror_hash);
3499
+ if (contentRow) {
3500
+ sourceContentTruncated = contentRow.markdown.length > maxChars;
3501
+ sourceContent = sourceContentTruncated
3502
+ ? contentRow.markdown.slice(0, maxChars)
3503
+ : contentRow.markdown;
3504
+ if (sourceContentTruncated) {
3505
+ truncationReasons.push("source_content_truncated");
3506
+ }
3507
+ } else {
3508
+ truncationReasons.push("source_content_missing");
3509
+ }
3510
+ } else {
3511
+ // Null mirror_hash means source bytes are unavailable — fail closed.
3512
+ truncationReasons.push("source_content_missing");
3513
+ }
3514
+
3515
+ const catalogRows = db
3516
+ .query<
3517
+ {
3518
+ id: number;
3519
+ uri: string;
3520
+ rel_path: string;
3521
+ collection: string;
3522
+ title: string | null;
3523
+ },
3524
+ [string, number]
3525
+ >(
3526
+ `SELECT id, uri, rel_path, collection, title
3527
+ FROM documents
3528
+ WHERE active = 1 AND collection = ?
3529
+ ORDER BY id
3530
+ LIMIT ?`
3531
+ )
3532
+ .all(sourceRow.collection, maxCatalog + 1);
3533
+
3534
+ if (catalogRows.length > maxCatalog) {
3535
+ truncationReasons.push("catalog_truncated");
3536
+ }
3537
+ const catalog = catalogRows.slice(0, maxCatalog).map((row) => ({
3538
+ id: row.id,
3539
+ uri: row.uri,
3540
+ relPath: row.rel_path,
3541
+ collection: row.collection,
3542
+ title: row.title,
3543
+ }));
3544
+
3545
+ const occupiedRelPaths = catalog.map((doc) => doc.relPath);
3546
+
3547
+ const backlinksResult = await this.getBacklinksForDoc(sourceRow.id, {
3548
+ collection: sourceRow.collection,
3549
+ });
3550
+ if (!backlinksResult.ok) {
3551
+ return backlinksResult;
3552
+ }
3553
+
3554
+ const candidateIds = new Set<number>();
3555
+ for (const row of backlinksResult.value) {
3556
+ if (row.sourceDocId !== sourceRow.id) {
3557
+ candidateIds.add(row.sourceDocId);
3558
+ }
3559
+ }
3560
+
3561
+ const needles = buildContentPrefilterNeedles({
3562
+ relPath: sourceRow.rel_path,
3563
+ title: sourceRow.title,
3564
+ });
3565
+ const escapeLike = (value: string): string =>
3566
+ value
3567
+ .replaceAll("\\", "\\\\")
3568
+ .replaceAll("%", "\\%")
3569
+ .replaceAll("_", "\\_");
3570
+
3571
+ if (needles.length > 0) {
3572
+ const likeClauses = needles
3573
+ .map(() => `c.markdown LIKE '%' || ? || '%' ESCAPE '\\'`)
3574
+ .join(" OR ");
3575
+ const prefilterRows = db
3576
+ .query<{ id: number }, (string | number)[]>(
3577
+ `SELECT DISTINCT d.id AS id
3578
+ FROM documents d
3579
+ INNER JOIN content c ON c.mirror_hash = d.mirror_hash
3580
+ WHERE d.active = 1
3581
+ AND d.collection = ?
3582
+ AND d.id != ?
3583
+ AND (${likeClauses})
3584
+ ORDER BY d.id
3585
+ LIMIT ?`
3586
+ )
3587
+ .all(
3588
+ sourceRow.collection,
3589
+ sourceRow.id,
3590
+ ...needles.map(escapeLike),
3591
+ maxReferrers + 1
3592
+ );
3593
+ if (prefilterRows.length > maxReferrers) {
3594
+ truncationReasons.push("content_prefilter_truncated");
3595
+ }
3596
+ for (const row of prefilterRows.slice(0, maxReferrers + 1)) {
3597
+ candidateIds.add(row.id);
3598
+ }
3599
+ }
3600
+
3601
+ // Completeness: union same-collection docs whose mirror content cannot be
3602
+ // loaded. Text-like docs (including read-only logical .md records) fail
3603
+ // closed; non-text binaries are omitted (they cannot hold markdown/wiki/
3604
+ // HTML refs). Preserve referrer caps via LIMIT + sorted slice below.
3605
+ const missingMirrorRows = db
3606
+ .query<
3607
+ {
3608
+ id: number;
3609
+ source_ext: string;
3610
+ source_mime: string;
3611
+ },
3612
+ [string, number, number]
3613
+ >(
3614
+ `SELECT d.id AS id,
3615
+ d.source_ext AS source_ext,
3616
+ d.source_mime AS source_mime
3617
+ FROM documents d
3618
+ WHERE d.active = 1
3619
+ AND d.collection = ?
3620
+ AND d.id != ?
3621
+ AND (
3622
+ d.mirror_hash IS NULL
3623
+ OR NOT EXISTS (
3624
+ SELECT 1 FROM content c WHERE c.mirror_hash = d.mirror_hash
3625
+ )
3626
+ )
3627
+ ORDER BY d.id
3628
+ LIMIT ?`
3629
+ )
3630
+ .all(sourceRow.collection, sourceRow.id, maxReferrers + 1);
3631
+ if (missingMirrorRows.length > maxReferrers) {
3632
+ truncationReasons.push("missing_mirror_scan_truncated");
3633
+ }
3634
+ for (const row of missingMirrorRows) {
3635
+ if (!isTextLikeReferenceDocument(row.source_ext, row.source_mime)) {
3636
+ continue;
3637
+ }
3638
+ candidateIds.add(row.id);
3639
+ }
3640
+
3641
+ const sortedCandidateIds = [...candidateIds].sort((a, b) => a - b);
3642
+ if (sortedCandidateIds.length > maxReferrers) {
3643
+ truncationReasons.push("referrers_truncated");
3644
+ }
3645
+
3646
+ const referrers: FileRefactorResolutionReferrerDocument[] = [];
3647
+ let totalContentChars = sourceContent?.length ?? 0;
3648
+
3649
+ for (const referrerId of sortedCandidateIds.slice(0, maxReferrers)) {
3650
+ const referrerRow = db
3651
+ .query<
3652
+ {
3653
+ id: number;
3654
+ uri: string;
3655
+ rel_path: string;
3656
+ collection: string;
3657
+ title: string | null;
3658
+ mirror_hash: string | null;
3659
+ source_ext: string;
3660
+ source_mime: string;
3661
+ record_key: string | null;
3662
+ },
3663
+ [number]
3664
+ >(
3665
+ `SELECT id, uri, rel_path, collection, title, mirror_hash,
3666
+ source_ext, source_mime, record_key
3667
+ FROM documents
3668
+ WHERE id = ? AND active = 1`
3669
+ )
3670
+ .get(referrerId);
3671
+ if (!referrerRow) continue;
3672
+
3673
+ const caps = getDocumentCapabilities({
3674
+ sourceExt: referrerRow.source_ext,
3675
+ sourceMime: referrerRow.source_mime,
3676
+ contentAvailable: Boolean(referrerRow.mirror_hash),
3677
+ recordKey: referrerRow.record_key,
3678
+ });
3679
+
3680
+ if (!referrerRow.mirror_hash) {
3681
+ truncationReasons.push("referrer_content_missing");
3682
+ referrers.push({
3683
+ id: referrerRow.id,
3684
+ uri: referrerRow.uri,
3685
+ relPath: referrerRow.rel_path,
3686
+ collection: referrerRow.collection,
3687
+ title: referrerRow.title,
3688
+ content: null,
3689
+ contentTruncated: false,
3690
+ contentMissing: true,
3691
+ editable: caps.editable,
3692
+ editableReason: caps.editable
3693
+ ? undefined
3694
+ : caps.reason
3695
+ ? "read_only_document"
3696
+ : "capability_denied",
3697
+ sourceExt: referrerRow.source_ext,
3698
+ sourceMime: referrerRow.source_mime,
3699
+ recordKey: referrerRow.record_key,
3700
+ });
3701
+ continue;
3702
+ }
3703
+
3704
+ const contentRow = db
3705
+ .query<{ markdown: string }, [number, string]>(
3706
+ `SELECT substr(markdown, 1, ?) AS markdown
3707
+ FROM content WHERE mirror_hash = ?`
3708
+ )
3709
+ .get(maxChars + 1, referrerRow.mirror_hash);
3710
+
3711
+ if (!contentRow) {
3712
+ truncationReasons.push("referrer_content_missing");
3713
+ referrers.push({
3714
+ id: referrerRow.id,
3715
+ uri: referrerRow.uri,
3716
+ relPath: referrerRow.rel_path,
3717
+ collection: referrerRow.collection,
3718
+ title: referrerRow.title,
3719
+ content: null,
3720
+ contentTruncated: false,
3721
+ contentMissing: true,
3722
+ editable: caps.editable,
3723
+ editableReason: caps.editable
3724
+ ? undefined
3725
+ : caps.reason
3726
+ ? "read_only_document"
3727
+ : "capability_denied",
3728
+ sourceExt: referrerRow.source_ext,
3729
+ sourceMime: referrerRow.source_mime,
3730
+ recordKey: referrerRow.record_key,
3731
+ });
3732
+ continue;
3733
+ }
3734
+
3735
+ const contentTruncated = contentRow.markdown.length > maxChars;
3736
+ if (contentTruncated) {
3737
+ truncationReasons.push("referrer_content_truncated");
3738
+ }
3739
+ const content = contentTruncated
3740
+ ? contentRow.markdown.slice(0, maxChars)
3741
+ : contentRow.markdown;
3742
+ totalContentChars += content.length;
3743
+ if (totalContentChars > maxTotalChars) {
3744
+ truncationReasons.push("total_content_truncated");
3745
+ referrers.push({
3746
+ id: referrerRow.id,
3747
+ uri: referrerRow.uri,
3748
+ relPath: referrerRow.rel_path,
3749
+ collection: referrerRow.collection,
3750
+ title: referrerRow.title,
3751
+ content: null,
3752
+ contentTruncated: false,
3753
+ contentMissing: true,
3754
+ editable: caps.editable,
3755
+ editableReason: caps.editable
3756
+ ? undefined
3757
+ : caps.reason
3758
+ ? "read_only_document"
3759
+ : "capability_denied",
3760
+ sourceExt: referrerRow.source_ext,
3761
+ sourceMime: referrerRow.source_mime,
3762
+ recordKey: referrerRow.record_key,
3763
+ });
3764
+ continue;
3765
+ }
3766
+
3767
+ referrers.push({
3768
+ id: referrerRow.id,
3769
+ uri: referrerRow.uri,
3770
+ relPath: referrerRow.rel_path,
3771
+ collection: referrerRow.collection,
3772
+ title: referrerRow.title,
3773
+ content,
3774
+ contentTruncated,
3775
+ contentMissing: false,
3776
+ editable: caps.editable,
3777
+ editableReason: caps.editable
3778
+ ? undefined
3779
+ : caps.reason
3780
+ ? "read_only_document"
3781
+ : "capability_denied",
3782
+ sourceExt: referrerRow.source_ext,
3783
+ sourceMime: referrerRow.source_mime,
3784
+ recordKey: referrerRow.record_key,
3785
+ });
3786
+ }
3787
+
3788
+ const snapshot: FileRefactorResolutionSnapshot = {
3789
+ source: {
3790
+ id: sourceRow.id,
3791
+ uri: sourceRow.uri,
3792
+ relPath: sourceRow.rel_path,
3793
+ collection: sourceRow.collection,
3794
+ title: sourceRow.title,
3795
+ mirrorHash: sourceRow.mirror_hash,
3796
+ sourceExt: sourceRow.source_ext,
3797
+ sourceMime: sourceRow.source_mime,
3798
+ recordKey: sourceRow.record_key,
3799
+ content: sourceContent,
3800
+ contentTruncated: sourceContentTruncated,
3801
+ editable: sourceCaps.editable,
3802
+ editableReason: sourceCaps.editable
3803
+ ? undefined
3804
+ : sourceCaps.reason
3805
+ ? "read_only_document"
3806
+ : "capability_denied",
3807
+ },
3808
+ catalog,
3809
+ referrers,
3810
+ occupiedRelPaths,
3811
+ truncated: truncationReasons.length > 0,
3812
+ truncationReasons: [...new Set(truncationReasons)].sort(),
3813
+ };
3814
+ return ok(snapshot);
3815
+ } catch (cause) {
3816
+ return err(
3817
+ "QUERY_FAILED",
3818
+ cause instanceof Error
3819
+ ? cause.message
3820
+ : "Failed to load file refactor resolution snapshot",
3821
+ cause
3822
+ );
3823
+ }
3824
+ }
3825
+
3374
3826
  /**
3375
3827
  * Set semantic edges for a document.
3376
3828
  * Replaces edges from the given source.