@gmickel/gno 2.7.1 → 2.8.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.
- package/README.md +3 -2
- package/assets/skill/SKILL.md +11 -1
- package/assets/skill/cli-reference.md +8 -1
- package/assets/skill/examples.md +2 -1
- package/assets/skill/mcp-reference.md +3 -1
- package/assets/skill/recipes/memory-scoped-recall.md +10 -5
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +86 -9
- package/spec/db/schema.sql +0 -1
- package/spec/mcp.md +26 -7
- package/spec/output-schemas/audit-report.schema.json +18 -4
- package/spec/output-schemas/backlinks.schema.json +4 -0
- package/spec/output-schemas/collection-list.schema.json +13 -0
- package/spec/output-schemas/graph.schema.json +2 -0
- package/spec/output-schemas/links-list.schema.json +4 -0
- package/spec/output-schemas/memory-recall.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +18 -3
- package/src/cli/commands/audit.ts +23 -4
- package/src/cli/commands/collection/list.ts +39 -5
- package/src/cli/commands/embed.ts +3 -3
- package/src/cli/commands/graph.ts +3 -1
- package/src/cli/commands/links.ts +61 -180
- package/src/cli/commands/shared.ts +7 -0
- package/src/cli/commands/status.ts +6 -0
- package/src/cli/program.ts +12 -2
- package/src/config/loader.ts +43 -0
- package/src/config/types.ts +8 -0
- package/src/core/audit-contract.ts +16 -4
- package/src/core/audit-freshness.ts +11 -1
- package/src/core/audit-links.ts +197 -25
- package/src/core/audit-outside-index.ts +215 -0
- package/src/core/audit-provenance.ts +11 -4
- package/src/core/audit-workspace.ts +30 -9
- package/src/core/audit.ts +76 -16
- package/src/core/context-compiler.ts +3 -0
- package/src/core/context-evidence.ts +11 -0
- package/src/core/graph-edge-confidence.ts +23 -1
- package/src/core/host-paths.ts +1 -0
- package/src/core/knowledge-impact.ts +28 -0
- package/src/core/link-inventory-markdown.ts +2 -3
- package/src/core/link-workspace.ts +324 -0
- package/src/core/links.ts +40 -17
- package/src/core/memory-recall.ts +254 -15
- package/src/core/memory-types.ts +12 -0
- package/src/core/memory.ts +2 -0
- package/src/core/retrieval-replay-candidate.ts +6 -0
- package/src/core/retrieval-trace-request.ts +3 -0
- package/src/index.ts +14 -1
- package/src/ingestion/graph-reconciliation.ts +77 -15
- package/src/ingestion/source-availability/darwin-path.ts +9 -3
- package/src/ingestion/sync.ts +27 -4
- package/src/ingestion/types.ts +14 -0
- package/src/llm/inference-scope.ts +4 -3
- package/src/mcp/http-egress.ts +42 -3
- package/src/mcp/tools/audit.ts +11 -2
- package/src/mcp/tools/changes.ts +1 -0
- package/src/mcp/tools/links.ts +74 -93
- package/src/mcp/tools/sessions.ts +33 -4
- package/src/mcp/tools/status.ts +4 -0
- package/src/pipeline/expansion.ts +19 -31
- package/src/pipeline/graph-retrieval.ts +22 -2
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +10 -3
- package/src/sdk/client.ts +1 -0
- package/src/serve/findings-pass.ts +1 -1
- package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
- package/src/serve/public/pages/GraphView.tsx +2 -0
- package/src/serve/routes/changes.ts +6 -1
- package/src/serve/routes/graph.ts +3 -1
- package/src/serve/routes/links.ts +45 -50
- package/src/serve/routes/sessions.ts +41 -53
- package/src/serve/server.ts +2 -1
- package/src/serve/status.ts +1 -0
- package/src/sessions/config-refresh.ts +111 -0
- package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
- package/src/store/migrations/034-collection-link-workspace.ts +47 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +477 -337
- package/src/store/sqlite/eligibility.ts +8 -2
- package/src/store/sqlite/graph-link-resolver.ts +259 -5
- package/src/store/sqlite/graph-neighbors.ts +147 -40
- package/src/store/sqlite/graph-reference-state.ts +13 -2
- package/src/store/sqlite/graph-similarity.ts +96 -0
- package/src/store/sqlite/workspace-link-resolver.ts +742 -0
- package/src/store/types.ts +64 -5
- package/src/store/vector/stats.ts +1 -1
- package/src/store/vector/status.ts +27 -0
- package/src/store/vector/stored-vectors.ts +158 -0
- package/src/store/vector/types.ts +6 -0
- package/src/store/vector/variant-search.ts +30 -14
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
|
@@ -11,8 +11,11 @@ export const AUDIT_RULE_SET_VERSION = "1.0" as const;
|
|
|
11
11
|
|
|
12
12
|
/** Default returned finding cap; totals remain exact when truncated. */
|
|
13
13
|
export const AUDIT_DEFAULT_MAX_FINDINGS = 100;
|
|
14
|
-
/** Hard upper bound for `--max-findings` / MCP maxFindings. */
|
|
15
|
-
export const AUDIT_MAX_FINDINGS_LIMIT =
|
|
14
|
+
/** Hard numeric upper bound for `--max-findings` / MCP maxFindings. */
|
|
15
|
+
export const AUDIT_MAX_FINDINGS_LIMIT = 100_000;
|
|
16
|
+
/** Returns every finding of the bounded audit snapshot. */
|
|
17
|
+
export const AUDIT_MAX_FINDINGS_ALL = "all" as const;
|
|
18
|
+
export type AuditMaxFindings = number | typeof AUDIT_MAX_FINDINGS_ALL;
|
|
16
19
|
export const AUDIT_MAX_EVIDENCE_PER_FINDING = 8;
|
|
17
20
|
export const AUDIT_MAX_GUIDANCE_PER_FINDING = 4;
|
|
18
21
|
export const AUDIT_MAX_EVIDENCE_DETAIL_CHARS = 512;
|
|
@@ -201,7 +204,14 @@ export interface AuditCounts {
|
|
|
201
204
|
|
|
202
205
|
export interface AuditTruncation {
|
|
203
206
|
findingsTruncated: boolean;
|
|
204
|
-
maxFindings:
|
|
207
|
+
maxFindings: AuditMaxFindings;
|
|
208
|
+
/**
|
|
209
|
+
* The bounded audit snapshot (documents or links) was cut; finding totals
|
|
210
|
+
* cover the snapshot only and are not complete-index totals.
|
|
211
|
+
*/
|
|
212
|
+
snapshotTruncated: boolean;
|
|
213
|
+
/** Evidence items, candidate lists or evidence detail text were clipped. */
|
|
214
|
+
evidenceTruncated: boolean;
|
|
205
215
|
}
|
|
206
216
|
|
|
207
217
|
export interface AuditTiming {
|
|
@@ -255,6 +265,8 @@ export interface AuditRuleContribution {
|
|
|
255
265
|
findings?: AuditFindingDraft[];
|
|
256
266
|
/** Exact findings before any evaluator-side payload cap. */
|
|
257
267
|
findingCount?: number;
|
|
268
|
+
/** Evaluator clipped evidence (for example a long tied-candidate list). */
|
|
269
|
+
evidenceTruncated?: boolean;
|
|
258
270
|
examinedCount?: number;
|
|
259
271
|
durationMs?: number;
|
|
260
272
|
skipReason?: string | null;
|
|
@@ -283,7 +295,7 @@ export interface AuditRunInput {
|
|
|
283
295
|
capabilities: AuditCapabilitySnapshot;
|
|
284
296
|
captureFingerprints: AuditFingerprintCapture;
|
|
285
297
|
rules: readonly AuditRuleEvaluator[];
|
|
286
|
-
maxFindings?:
|
|
298
|
+
maxFindings?: AuditMaxFindings;
|
|
287
299
|
maxAttempts?: number;
|
|
288
300
|
gnoVersion?: string;
|
|
289
301
|
clock?: () => Date;
|
|
@@ -33,6 +33,8 @@ export interface AuditFreshnessOptions {
|
|
|
33
33
|
now: Date;
|
|
34
34
|
agePolicy?: AuditAgePolicy;
|
|
35
35
|
truncated?: boolean;
|
|
36
|
+
/** Effective per-rule cap; defaults to FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE. */
|
|
37
|
+
maxFindingsPerRule?: number;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const finding = (input: {
|
|
@@ -68,6 +70,7 @@ const rule = (input: {
|
|
|
68
70
|
skipped?: boolean;
|
|
69
71
|
message: string;
|
|
70
72
|
reason?: string;
|
|
73
|
+
maxFindingsPerRule?: number;
|
|
71
74
|
}): AuditRuleContribution => ({
|
|
72
75
|
ruleId: input.ruleId,
|
|
73
76
|
category: "freshness",
|
|
@@ -83,7 +86,10 @@ const rule = (input: {
|
|
|
83
86
|
message: input.message,
|
|
84
87
|
findings: [...input.findings]
|
|
85
88
|
.sort(compareAuditFindingDrafts)
|
|
86
|
-
.slice(
|
|
89
|
+
.slice(
|
|
90
|
+
0,
|
|
91
|
+
input.maxFindingsPerRule ?? FRESHNESS_AUDIT_MAX_FINDINGS_PER_RULE
|
|
92
|
+
),
|
|
87
93
|
findingCount: input.findings.length,
|
|
88
94
|
examinedCount: input.examinedCount,
|
|
89
95
|
skipReason: input.reason ?? null,
|
|
@@ -186,6 +192,7 @@ export const evaluateFreshnessAudit = (
|
|
|
186
192
|
: undefined;
|
|
187
193
|
return [
|
|
188
194
|
rule({
|
|
195
|
+
maxFindingsPerRule: options.maxFindingsPerRule,
|
|
189
196
|
ruleId: "freshness.source-readable",
|
|
190
197
|
findings: unavailable,
|
|
191
198
|
examinedCount: documents.length,
|
|
@@ -197,6 +204,7 @@ export const evaluateFreshnessAudit = (
|
|
|
197
204
|
reason,
|
|
198
205
|
}),
|
|
199
206
|
rule({
|
|
207
|
+
maxFindingsPerRule: options.maxFindingsPerRule,
|
|
200
208
|
ruleId: "freshness.source-index-drift",
|
|
201
209
|
findings: drift,
|
|
202
210
|
examinedCount: byteComparableCount,
|
|
@@ -207,6 +215,7 @@ export const evaluateFreshnessAudit = (
|
|
|
207
215
|
reason,
|
|
208
216
|
}),
|
|
209
217
|
rule({
|
|
218
|
+
maxFindingsPerRule: options.maxFindingsPerRule,
|
|
210
219
|
ruleId: "freshness.index-revision",
|
|
211
220
|
findings: staleRevision,
|
|
212
221
|
examinedCount: documents.length,
|
|
@@ -217,6 +226,7 @@ export const evaluateFreshnessAudit = (
|
|
|
217
226
|
reason,
|
|
218
227
|
}),
|
|
219
228
|
rule({
|
|
229
|
+
maxFindingsPerRule: options.maxFindingsPerRule,
|
|
220
230
|
ruleId: "freshness.configured-age-signal",
|
|
221
231
|
findings: ageSignals,
|
|
222
232
|
examinedCount: documents.length,
|
package/src/core/audit-links.ts
CHANGED
|
@@ -6,9 +6,19 @@ import type {
|
|
|
6
6
|
} from "../store/sqlite/graph-link-resolver";
|
|
7
7
|
import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { isTraversableResolution } from "../store/sqlite/graph-link-resolver";
|
|
10
|
+
import {
|
|
11
|
+
AUDIT_MAX_EVIDENCE_DETAIL_CHARS,
|
|
12
|
+
compareAuditCodeUnits,
|
|
13
|
+
compareAuditFindingDrafts,
|
|
14
|
+
} from "./audit";
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 1.1: workspace-wide resolution and reference/resolution/scope evidence.
|
|
18
|
+
* 1.2: `links.outside-index` for workspace files that are not indexed.
|
|
19
|
+
*/
|
|
20
|
+
export const LINK_AUDIT_RULE_VERSION = "1.2" as const;
|
|
21
|
+
/** Default per-rule cap; runs pass the effective `maxFindings` instead. */
|
|
12
22
|
export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
|
|
13
23
|
|
|
14
24
|
export interface AuditOrphanPolicy {
|
|
@@ -16,23 +26,148 @@ export interface AuditOrphanPolicy {
|
|
|
16
26
|
ignorePathPrefixes: readonly string[];
|
|
17
27
|
/** Mirrored duplicate rows are excluded from orphan claims by default. */
|
|
18
28
|
ignoreMirrorDuplicates?: boolean;
|
|
29
|
+
/** Effective per-rule cap; defaults to LINK_AUDIT_MAX_FINDINGS_PER_RULE. */
|
|
30
|
+
maxFindingsPerRule?: number;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
const boundedFindings = (
|
|
22
|
-
findings: readonly AuditFindingDraft[]
|
|
34
|
+
findings: readonly AuditFindingDraft[],
|
|
35
|
+
maxFindingsPerRule: number
|
|
23
36
|
): AuditFindingDraft[] =>
|
|
24
|
-
[...findings]
|
|
25
|
-
.sort(compareAuditFindingDrafts)
|
|
26
|
-
.slice(0, LINK_AUDIT_MAX_FINDINGS_PER_RULE);
|
|
37
|
+
[...findings].sort(compareAuditFindingDrafts).slice(0, maxFindingsPerRule);
|
|
27
38
|
|
|
28
39
|
const lineLocation = (line: number, column: number): string =>
|
|
29
40
|
`L${line}:C${column}`;
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
type LinkReferenceKind =
|
|
43
|
+
| "wiki-name"
|
|
44
|
+
| "wiki-path"
|
|
45
|
+
| "explicit-collection"
|
|
46
|
+
| "markdown";
|
|
47
|
+
|
|
48
|
+
const referenceKind = (
|
|
32
49
|
link: AuditLinkSnapshot["links"][number]
|
|
50
|
+
): LinkReferenceKind =>
|
|
51
|
+
link.linkType === "markdown"
|
|
52
|
+
? "markdown"
|
|
53
|
+
: link.explicitCollection === true
|
|
54
|
+
? "explicit-collection"
|
|
55
|
+
: link.targetRef.includes("/")
|
|
56
|
+
? "wiki-path"
|
|
57
|
+
: "wiki-name";
|
|
58
|
+
|
|
59
|
+
interface CandidateEvidence {
|
|
60
|
+
/** Canonical-path-ordered tied candidates visible in the audit scope. */
|
|
61
|
+
uris: string[];
|
|
62
|
+
/** Candidates outside the requested collection scope (identity withheld). */
|
|
63
|
+
withheld: number;
|
|
64
|
+
truncated: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const linkFinding = (
|
|
68
|
+
link: AuditLinkSnapshot["links"][number],
|
|
69
|
+
context: {
|
|
70
|
+
documents: ReadonlyMap<number, AuditLinkSnapshotDocument>;
|
|
71
|
+
scope: ReadonlySet<string> | null;
|
|
72
|
+
},
|
|
73
|
+
onEvidenceTruncated: () => void
|
|
33
74
|
): AuditFindingDraft => {
|
|
34
75
|
const ambiguous = (link.resolved?.matchCount ?? 0) > 1;
|
|
76
|
+
const outsideIndex = !ambiguous && link.outsideIndex === true;
|
|
35
77
|
const target = `${link.targetCollection}:${link.targetRef}`;
|
|
78
|
+
const kind = referenceKind(link);
|
|
79
|
+
const tied = (link.resolved?.candidates ?? []).map((candidate) =>
|
|
80
|
+
context.documents.get(candidate.id)
|
|
81
|
+
);
|
|
82
|
+
const visible = tied.filter(
|
|
83
|
+
(document): document is AuditLinkSnapshotDocument =>
|
|
84
|
+
document !== undefined &&
|
|
85
|
+
(context.scope === null || context.scope.has(document.collection))
|
|
86
|
+
);
|
|
87
|
+
// Scope describes every tied candidate; withheld ones are only counted.
|
|
88
|
+
const resolvedScope = !ambiguous
|
|
89
|
+
? null
|
|
90
|
+
: link.explicitCollection === true
|
|
91
|
+
? "explicit-collection"
|
|
92
|
+
: tied.some(
|
|
93
|
+
(document) =>
|
|
94
|
+
document !== undefined &&
|
|
95
|
+
document.collection !== link.sourceCollection
|
|
96
|
+
)
|
|
97
|
+
? "cross-collection"
|
|
98
|
+
: "same-collection";
|
|
99
|
+
const base = {
|
|
100
|
+
anchor: link.targetAnchor,
|
|
101
|
+
endColumn: link.endCol,
|
|
102
|
+
endLine: link.endLine,
|
|
103
|
+
linkType: link.linkType,
|
|
104
|
+
matchCount: link.resolved?.matchCount ?? 0,
|
|
105
|
+
matchRank: link.resolved?.matchRank ?? null,
|
|
106
|
+
normalizedTarget: link.targetRefNorm,
|
|
107
|
+
referenceKind: kind,
|
|
108
|
+
resolutionStatus: ambiguous
|
|
109
|
+
? "ambiguous"
|
|
110
|
+
: outsideIndex
|
|
111
|
+
? "outside-index"
|
|
112
|
+
: "unresolved",
|
|
113
|
+
resolvedScope,
|
|
114
|
+
};
|
|
115
|
+
// Candidate URIs are added while the detail stays within the evidence
|
|
116
|
+
// bound, so a long tie is reported as truncated rather than silently cut.
|
|
117
|
+
const candidates: CandidateEvidence = {
|
|
118
|
+
uris: [],
|
|
119
|
+
withheld: tied.length - visible.length,
|
|
120
|
+
truncated: false,
|
|
121
|
+
};
|
|
122
|
+
const render = (): string =>
|
|
123
|
+
JSON.stringify(
|
|
124
|
+
tied.length > 0
|
|
125
|
+
? {
|
|
126
|
+
...base,
|
|
127
|
+
candidateCount: tied.length,
|
|
128
|
+
candidates: candidates.uris,
|
|
129
|
+
...(candidates.withheld > 0
|
|
130
|
+
? { candidatesWithheld: candidates.withheld }
|
|
131
|
+
: {}),
|
|
132
|
+
...(candidates.truncated ? { candidatesTruncated: true } : {}),
|
|
133
|
+
}
|
|
134
|
+
: base
|
|
135
|
+
);
|
|
136
|
+
for (const document of visible) {
|
|
137
|
+
candidates.uris.push(document.uri);
|
|
138
|
+
if (Array.from(render()).length > AUDIT_MAX_EVIDENCE_DETAIL_CHARS) {
|
|
139
|
+
candidates.uris.pop();
|
|
140
|
+
candidates.truncated = true;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
candidates.truncated &&
|
|
146
|
+
Array.from(render()).length > AUDIT_MAX_EVIDENCE_DETAIL_CHARS
|
|
147
|
+
) {
|
|
148
|
+
candidates.uris.pop();
|
|
149
|
+
}
|
|
150
|
+
if (candidates.truncated) onEvidenceTruncated();
|
|
151
|
+
if (outsideIndex) {
|
|
152
|
+
return {
|
|
153
|
+
subject: link.sourceUri,
|
|
154
|
+
location: lineLocation(link.startLine, link.startCol),
|
|
155
|
+
severity: "info",
|
|
156
|
+
message: `Link target exists outside the index: ${target}`,
|
|
157
|
+
evidence: [
|
|
158
|
+
{
|
|
159
|
+
kind: "outside-index-target",
|
|
160
|
+
summary: target,
|
|
161
|
+
uri: link.sourceUri,
|
|
162
|
+
path: link.sourceRelPath,
|
|
163
|
+
detail: render(),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
guidance: [
|
|
167
|
+
"No action needed; index the target's folder to add it to the link graph",
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
36
171
|
return {
|
|
37
172
|
subject: link.sourceUri,
|
|
38
173
|
location: lineLocation(link.startLine, link.startCol),
|
|
@@ -46,19 +181,15 @@ const linkFinding = (
|
|
|
46
181
|
summary: target,
|
|
47
182
|
uri: link.sourceUri,
|
|
48
183
|
path: link.sourceRelPath,
|
|
49
|
-
detail:
|
|
50
|
-
anchor: link.targetAnchor,
|
|
51
|
-
endColumn: link.endCol,
|
|
52
|
-
endLine: link.endLine,
|
|
53
|
-
linkType: link.linkType,
|
|
54
|
-
matchCount: link.resolved?.matchCount ?? 0,
|
|
55
|
-
matchRank: link.resolved?.matchRank ?? null,
|
|
56
|
-
normalizedTarget: link.targetRefNorm,
|
|
57
|
-
}),
|
|
184
|
+
detail: render(),
|
|
58
185
|
},
|
|
59
186
|
],
|
|
60
187
|
guidance: ambiguous
|
|
61
|
-
? [
|
|
188
|
+
? [
|
|
189
|
+
tied.length > 0
|
|
190
|
+
? "Use a longer path such as [[Folder/Note]] to pick one target"
|
|
191
|
+
: "Use an explicit collection-relative target path",
|
|
192
|
+
]
|
|
62
193
|
: ["Create the target or correct the local link"],
|
|
63
194
|
};
|
|
64
195
|
};
|
|
@@ -107,25 +238,46 @@ export const evaluateLinkAudit = (
|
|
|
107
238
|
policy: AuditOrphanPolicy
|
|
108
239
|
): AuditRuleContribution[] => {
|
|
109
240
|
const unresolved: AuditFindingDraft[] = [];
|
|
241
|
+
const outsideIndex: AuditFindingDraft[] = [];
|
|
110
242
|
const ambiguous: AuditFindingDraft[] = [];
|
|
111
243
|
const connected = new Set<number>();
|
|
112
244
|
const auditedDocumentIds = new Set(
|
|
113
245
|
snapshot.auditedDocumentIds ?? snapshot.documents.map(({ id }) => id)
|
|
114
246
|
);
|
|
247
|
+
let evidenceTruncated = false;
|
|
248
|
+
const findingContext = {
|
|
249
|
+
documents: new Map(snapshot.documents.map((doc) => [doc.id, doc])),
|
|
250
|
+
scope: snapshot.scopeCollections
|
|
251
|
+
? new Set(snapshot.scopeCollections)
|
|
252
|
+
: null,
|
|
253
|
+
};
|
|
254
|
+
const finding = (link: AuditLinkSnapshot["links"][number]) =>
|
|
255
|
+
linkFinding(link, findingContext, () => {
|
|
256
|
+
evidenceTruncated = true;
|
|
257
|
+
});
|
|
115
258
|
for (const link of snapshot.links) {
|
|
116
259
|
const sourceAudited = auditedDocumentIds.has(link.sourceId);
|
|
117
260
|
if (!link.resolved) {
|
|
118
|
-
if (sourceAudited)
|
|
261
|
+
if (sourceAudited) {
|
|
262
|
+
(link.outsideIndex === true ? outsideIndex : unresolved).push(
|
|
263
|
+
finding(link)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
119
266
|
continue;
|
|
120
267
|
}
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
connected.add(link.
|
|
268
|
+
// A tied workspace link is not a resolved link: it connects nothing.
|
|
269
|
+
if (isTraversableResolution(link.resolved)) {
|
|
270
|
+
if (sourceAudited) connected.add(link.sourceId);
|
|
271
|
+
if (auditedDocumentIds.has(link.resolved.targetId)) {
|
|
272
|
+
connected.add(link.resolved.targetId);
|
|
273
|
+
}
|
|
124
274
|
}
|
|
125
275
|
if (sourceAudited && link.resolved.matchCount > 1) {
|
|
126
|
-
ambiguous.push(
|
|
276
|
+
ambiguous.push(finding(link));
|
|
127
277
|
}
|
|
128
278
|
}
|
|
279
|
+
const maxFindingsPerRule =
|
|
280
|
+
policy.maxFindingsPerRule ?? LINK_AUDIT_MAX_FINDINGS_PER_RULE;
|
|
129
281
|
const roots = new Set(normalizedPrefixes(policy.rootUris));
|
|
130
282
|
const ignorePrefixes = normalizedPrefixes(policy.ignorePathPrefixes);
|
|
131
283
|
const mirroredIds = duplicateMirrorIds(
|
|
@@ -179,7 +331,7 @@ export const evaluateLinkAudit = (
|
|
|
179
331
|
message: partial
|
|
180
332
|
? "Local target scan was truncated"
|
|
181
333
|
: `${unresolved.length} unresolved or broken local links`,
|
|
182
|
-
findings: boundedFindings(unresolved),
|
|
334
|
+
findings: boundedFindings(unresolved, maxFindingsPerRule),
|
|
183
335
|
findingCount: unresolved.length,
|
|
184
336
|
skipReason: partial ? "snapshot_truncated" : null,
|
|
185
337
|
},
|
|
@@ -187,11 +339,12 @@ export const evaluateLinkAudit = (
|
|
|
187
339
|
...common,
|
|
188
340
|
ruleId: "links.ambiguous-targets",
|
|
189
341
|
category: "links",
|
|
342
|
+
evidenceTruncated,
|
|
190
343
|
status: statusFor(ambiguous),
|
|
191
344
|
message: partial
|
|
192
345
|
? "Ambiguous target scan was truncated"
|
|
193
346
|
: `${ambiguous.length} ambiguous local links`,
|
|
194
|
-
findings: boundedFindings(ambiguous),
|
|
347
|
+
findings: boundedFindings(ambiguous, maxFindingsPerRule),
|
|
195
348
|
findingCount: ambiguous.length,
|
|
196
349
|
skipReason: partial ? "snapshot_truncated" : null,
|
|
197
350
|
},
|
|
@@ -203,10 +356,29 @@ export const evaluateLinkAudit = (
|
|
|
203
356
|
message: partial
|
|
204
357
|
? "Orphan scan was truncated"
|
|
205
358
|
: `${orphanFindings.length} policy-defined orphan documents`,
|
|
206
|
-
findings: boundedFindings(orphanFindings),
|
|
359
|
+
findings: boundedFindings(orphanFindings, maxFindingsPerRule),
|
|
207
360
|
findingCount: orphanFindings.length,
|
|
208
361
|
skipReason: partial ? "snapshot_truncated" : null,
|
|
209
362
|
},
|
|
363
|
+
{
|
|
364
|
+
// Informational: Obsidian resolves these links, so they never fail
|
|
365
|
+
// the audit. Info findings keep the rule passing.
|
|
366
|
+
...common,
|
|
367
|
+
ruleId: "links.outside-index",
|
|
368
|
+
category: "links",
|
|
369
|
+
status: partial ? ("inconclusive" as const) : ("pass" as const),
|
|
370
|
+
message: [
|
|
371
|
+
partial
|
|
372
|
+
? "Outside-index scan was truncated"
|
|
373
|
+
: `${outsideIndex.length} links point to existing files outside the index`,
|
|
374
|
+
...(snapshot.outsideIndexDiagnostic
|
|
375
|
+
? [snapshot.outsideIndexDiagnostic]
|
|
376
|
+
: []),
|
|
377
|
+
].join("; "),
|
|
378
|
+
findings: boundedFindings(outsideIndex, maxFindingsPerRule),
|
|
379
|
+
findingCount: outsideIndex.length,
|
|
380
|
+
skipReason: partial ? "snapshot_truncated" : null,
|
|
381
|
+
},
|
|
210
382
|
{
|
|
211
383
|
...common,
|
|
212
384
|
ruleId: "links.parser-boundary",
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existence-only classification of unresolved workspace wiki links whose
|
|
3
|
+
* target is a file inside the link workspace that is not an indexed document
|
|
4
|
+
* (an attachment, or a note in an unindexed or excluded folder). Obsidian
|
|
5
|
+
* resolves such links, so the link audit reports them as `outside-index`
|
|
6
|
+
* instead of unresolved. Only file names are listed: no file is opened,
|
|
7
|
+
* indexed, or returned, and no graph edge is created.
|
|
8
|
+
*
|
|
9
|
+
* @module src/core/audit-outside-index
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
|
|
14
|
+
// node:fs/promises readdir/realpath/stat: directory enumeration and symlink
|
|
15
|
+
// resolution; Bun has no equivalent that can skip an unreadable folder
|
|
16
|
+
// instead of failing the scan.
|
|
17
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
18
|
+
// node:path join: platform path algebra; no Bun equivalent.
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
createWorkspaceFileMatcher,
|
|
25
|
+
loadLinkWorkspaceMemberships,
|
|
26
|
+
} from "../store/sqlite/workspace-link-resolver";
|
|
27
|
+
import { pathContains, placeDocument } from "./link-workspace";
|
|
28
|
+
|
|
29
|
+
/** Upper bound of files listed per workspace; beyond it the listing is partial. */
|
|
30
|
+
export const WORKSPACE_FILE_LISTING_MAX_FILES = 200_000;
|
|
31
|
+
|
|
32
|
+
export interface WorkspaceFileListing {
|
|
33
|
+
/** Workspace-relative POSIX paths (NFC) of every non-hidden file. */
|
|
34
|
+
files: string[];
|
|
35
|
+
/** False when a folder could not be read or the file bound was reached. */
|
|
36
|
+
complete: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface WorkspaceDirectoryEntry {
|
|
40
|
+
name: string;
|
|
41
|
+
isFile(): boolean;
|
|
42
|
+
isDirectory(): boolean;
|
|
43
|
+
isSymbolicLink(): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Filesystem reads the listing needs; injectable for tests. */
|
|
47
|
+
export interface WorkspaceFileSystem {
|
|
48
|
+
readDirectory(path: string): Promise<WorkspaceDirectoryEntry[]>;
|
|
49
|
+
realPath(path: string): Promise<string>;
|
|
50
|
+
isRegularFile(path: string): Promise<boolean>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nodeWorkspaceFileSystem: WorkspaceFileSystem = {
|
|
54
|
+
readDirectory: (path) => readdir(path, { withFileTypes: true }),
|
|
55
|
+
realPath: (path) => realpath(path),
|
|
56
|
+
isRegularFile: async (path) => (await stat(path)).isFile(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** A symlink target that does not exist (or loops) is not a file, not an error. */
|
|
60
|
+
const MISSING_TARGET_CODES = new Set(["ENOENT", "ENOTDIR", "ELOOP"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A symlink counts as a file only when it resolves to an existing regular
|
|
64
|
+
* file inside the workspace. Dangling links, links to folders and links that
|
|
65
|
+
* leave the workspace are not files. Other errors are reported as such.
|
|
66
|
+
*/
|
|
67
|
+
const symlinkIsWorkspaceFile = async (
|
|
68
|
+
fs: WorkspaceFileSystem,
|
|
69
|
+
realRoot: string,
|
|
70
|
+
path: string
|
|
71
|
+
): Promise<"file" | "not-file" | "error"> => {
|
|
72
|
+
try {
|
|
73
|
+
const target = await fs.realPath(path);
|
|
74
|
+
if (!pathContains(realRoot, target)) return "not-file";
|
|
75
|
+
return (await fs.isRegularFile(target)) ? "file" : "not-file";
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
const code = (cause as NodeJS.ErrnoException | undefined)?.code;
|
|
78
|
+
return code !== undefined && MISSING_TARGET_CODES.has(code)
|
|
79
|
+
? "not-file"
|
|
80
|
+
: "error";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List every file below a workspace root once. Hidden files and folders
|
|
86
|
+
* (`.obsidian`, `.trash`, `.git`) are skipped, as Obsidian does; symlinked
|
|
87
|
+
* folders are not descended. Filesystem calls use the names as stored on
|
|
88
|
+
* disk; only the returned paths are NFC-normalized for matching. An
|
|
89
|
+
* unreadable folder is skipped and marks the listing incomplete, so links
|
|
90
|
+
* into it stay unresolved.
|
|
91
|
+
*/
|
|
92
|
+
export const listWorkspaceFiles = async (
|
|
93
|
+
root: string,
|
|
94
|
+
options: {
|
|
95
|
+
maxFiles?: number;
|
|
96
|
+
signal?: AbortSignal;
|
|
97
|
+
fileSystem?: WorkspaceFileSystem;
|
|
98
|
+
} = {}
|
|
99
|
+
): Promise<WorkspaceFileListing> => {
|
|
100
|
+
const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
|
|
101
|
+
const fs = options.fileSystem ?? nodeWorkspaceFileSystem;
|
|
102
|
+
const files: string[] = [];
|
|
103
|
+
let complete = true;
|
|
104
|
+
let realRoot: string;
|
|
105
|
+
try {
|
|
106
|
+
realRoot = await fs.realPath(root);
|
|
107
|
+
} catch {
|
|
108
|
+
return { files, complete: false };
|
|
109
|
+
}
|
|
110
|
+
// Each folder keeps its on-disk segments; matching uses the NFC path.
|
|
111
|
+
const queue: string[][] = [[]];
|
|
112
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
113
|
+
options.signal?.throwIfAborted();
|
|
114
|
+
const segments = queue[head] as string[];
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = await fs.readDirectory(join(root, ...segments));
|
|
118
|
+
} catch {
|
|
119
|
+
complete = false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (entry.name.startsWith(".")) continue;
|
|
124
|
+
const entrySegments = [...segments, entry.name];
|
|
125
|
+
let isFile = entry.isFile();
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
queue.push(entrySegments);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (entry.isSymbolicLink()) {
|
|
131
|
+
const kind = await symlinkIsWorkspaceFile(
|
|
132
|
+
fs,
|
|
133
|
+
realRoot,
|
|
134
|
+
join(root, ...entrySegments)
|
|
135
|
+
);
|
|
136
|
+
if (kind === "error") complete = false;
|
|
137
|
+
isFile = kind === "file";
|
|
138
|
+
}
|
|
139
|
+
if (!isFile) continue;
|
|
140
|
+
if (files.length >= maxFiles) return { files, complete: false };
|
|
141
|
+
files.push(entrySegments.join("/").normalize("NFC"));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { files, complete };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Mark unresolved plain wiki links of audited documents inside a link
|
|
149
|
+
* workspace whose target exists as a workspace file. Each involved workspace
|
|
150
|
+
* is listed once per call. A failed or partial listing never marks a link
|
|
151
|
+
* that is not in it; the snapshot then carries one diagnostic.
|
|
152
|
+
*/
|
|
153
|
+
export const markOutsideIndexLinks = async (
|
|
154
|
+
db: Database,
|
|
155
|
+
snapshot: AuditLinkSnapshot,
|
|
156
|
+
options: { maxFiles?: number; signal?: AbortSignal } = {}
|
|
157
|
+
): Promise<AuditLinkSnapshot> => {
|
|
158
|
+
const audited = snapshot.auditedDocumentIds
|
|
159
|
+
? new Set(snapshot.auditedDocumentIds)
|
|
160
|
+
: null;
|
|
161
|
+
const candidates = snapshot.links
|
|
162
|
+
.map((link, index) => ({ link, index }))
|
|
163
|
+
.filter(
|
|
164
|
+
({ link }) =>
|
|
165
|
+
link.resolved === null &&
|
|
166
|
+
link.linkType === "wiki" &&
|
|
167
|
+
link.explicitCollection !== true &&
|
|
168
|
+
(audited === null || audited.has(link.sourceId))
|
|
169
|
+
);
|
|
170
|
+
if (candidates.length === 0) return snapshot;
|
|
171
|
+
const memberships = loadLinkWorkspaceMemberships(db);
|
|
172
|
+
const documentRelPaths = new Map(
|
|
173
|
+
snapshot.documents.map((document) => [document.id, document.relPath])
|
|
174
|
+
);
|
|
175
|
+
const placed = candidates.flatMap(({ link, index }) => {
|
|
176
|
+
const placement = placeDocument(
|
|
177
|
+
memberships.get(link.sourceCollection),
|
|
178
|
+
documentRelPaths.get(link.sourceId) ?? link.sourceRelPath
|
|
179
|
+
);
|
|
180
|
+
return placement.key === null
|
|
181
|
+
? []
|
|
182
|
+
: [{ link, index, key: placement.key, sourcePath: placement.path }];
|
|
183
|
+
});
|
|
184
|
+
if (placed.length === 0) return snapshot;
|
|
185
|
+
const matchers = new Map<
|
|
186
|
+
string,
|
|
187
|
+
ReturnType<typeof createWorkspaceFileMatcher>
|
|
188
|
+
>();
|
|
189
|
+
const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
|
|
190
|
+
let incomplete = 0;
|
|
191
|
+
for (const key of new Set(placed.map((entry) => entry.key))) {
|
|
192
|
+
const listing = await listWorkspaceFiles(key, {
|
|
193
|
+
maxFiles,
|
|
194
|
+
signal: options.signal,
|
|
195
|
+
});
|
|
196
|
+
if (!listing.complete) incomplete += 1;
|
|
197
|
+
matchers.set(key, createWorkspaceFileMatcher(listing.files));
|
|
198
|
+
}
|
|
199
|
+
const links = [...snapshot.links];
|
|
200
|
+
for (const entry of placed) {
|
|
201
|
+
const matches = matchers.get(entry.key);
|
|
202
|
+
if (matches?.(entry.link.targetRefNorm, entry.sourcePath)) {
|
|
203
|
+
links[entry.index] = { ...entry.link, outsideIndex: true };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
...snapshot,
|
|
208
|
+
links,
|
|
209
|
+
...(incomplete > 0
|
|
210
|
+
? {
|
|
211
|
+
outsideIndexDiagnostic: `The file listing of ${incomplete} link workspace${incomplete === 1 ? " was" : "s were"} incomplete (an unreadable folder or link, or more than ${maxFiles} files); links to files it missed stay unresolved`,
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
214
|
+
};
|
|
215
|
+
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
validateDeclaredRecordProvenance,
|
|
12
12
|
} from "./record-metadata";
|
|
13
13
|
|
|
14
|
+
/** Default per-rule cap; runs pass the effective `maxFindings` instead. */
|
|
14
15
|
export const PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
|
|
15
16
|
|
|
16
17
|
export interface AuditProvenanceDocument {
|
|
@@ -87,7 +88,7 @@ const memoryFinding = (
|
|
|
87
88
|
*/
|
|
88
89
|
export const evaluateMemoryRecordAudit = (
|
|
89
90
|
documents: readonly AuditProvenanceDocument[],
|
|
90
|
-
options: { truncated?: boolean } = {}
|
|
91
|
+
options: { truncated?: boolean; maxFindingsPerRule?: number } = {}
|
|
91
92
|
): AuditRuleContribution => {
|
|
92
93
|
const findings: AuditFindingDraft[] = [];
|
|
93
94
|
let managedDocuments = 0;
|
|
@@ -129,7 +130,10 @@ export const evaluateMemoryRecordAudit = (
|
|
|
129
130
|
: managedDocuments === 0
|
|
130
131
|
? "No memory-managed collections in scope"
|
|
131
132
|
: `${managedDocuments} managed memory record(s) satisfy the contract`,
|
|
132
|
-
findings: findings.slice(
|
|
133
|
+
findings: findings.slice(
|
|
134
|
+
0,
|
|
135
|
+
options.maxFindingsPerRule ?? PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE
|
|
136
|
+
),
|
|
133
137
|
findingCount: findings.length,
|
|
134
138
|
examinedCount: managedDocuments,
|
|
135
139
|
skipReason: truncated
|
|
@@ -145,7 +149,7 @@ export const evaluateMemoryRecordAudit = (
|
|
|
145
149
|
/** Missing provenance is completeness evidence, never a truth judgment. */
|
|
146
150
|
export const evaluateProvenanceAudit = (
|
|
147
151
|
documents: readonly AuditProvenanceDocument[],
|
|
148
|
-
options: { truncated?: boolean } = {}
|
|
152
|
+
options: { truncated?: boolean; maxFindingsPerRule?: number } = {}
|
|
149
153
|
): AuditRuleContribution[] => {
|
|
150
154
|
const captureFindings: AuditFindingDraft[] = [];
|
|
151
155
|
const recordFindings: AuditFindingDraft[] = [];
|
|
@@ -217,7 +221,10 @@ export const evaluateProvenanceAudit = (
|
|
|
217
221
|
: `${findings.length} declared provenance completeness issues`,
|
|
218
222
|
findings: [...findings]
|
|
219
223
|
.sort(compareAuditFindingDrafts)
|
|
220
|
-
.slice(
|
|
224
|
+
.slice(
|
|
225
|
+
0,
|
|
226
|
+
options.maxFindingsPerRule ?? PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE
|
|
227
|
+
),
|
|
221
228
|
findingCount: findings.length,
|
|
222
229
|
examinedCount: documents.length,
|
|
223
230
|
skipReason: truncated
|