@aisystemresources/emdee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/bin/emdee.js +196 -0
- package/package.json +78 -0
- package/src/cli/read-commands.ts +64 -0
- package/src/core/indexer.ts +346 -0
- package/src/core/parseEdges.ts +106 -0
- package/src/core/resolveLink.ts +136 -0
- package/src/core/siblings.ts +65 -0
- package/src/core/syncDocEdges.ts +427 -0
- package/src/lib/cache/bust.ts +44 -0
- package/src/lib/cache/invalidation.ts +28 -0
- package/src/lib/mcp/activity.ts +254 -0
- package/src/lib/mcp/tools/add_association.ts +311 -0
- package/src/lib/mcp/tools/append_doc.ts +70 -0
- package/src/lib/mcp/tools/append_section.ts +121 -0
- package/src/lib/mcp/tools/create_child.ts +319 -0
- package/src/lib/mcp/tools/delete_doc.ts +68 -0
- package/src/lib/mcp/tools/distill_doc.ts +262 -0
- package/src/lib/mcp/tools/filename.ts +63 -0
- package/src/lib/mcp/tools/get_context.ts +227 -0
- package/src/lib/mcp/tools/get_doc.ts +91 -0
- package/src/lib/mcp/tools/get_image.ts +62 -0
- package/src/lib/mcp/tools/get_neighbors.ts +96 -0
- package/src/lib/mcp/tools/get_summary.ts +18 -0
- package/src/lib/mcp/tools/index.ts +26 -0
- package/src/lib/mcp/tools/lint.ts +552 -0
- package/src/lib/mcp/tools/lint_doc.ts +76 -0
- package/src/lib/mcp/tools/lint_gate.ts +49 -0
- package/src/lib/mcp/tools/list_docs.ts +18 -0
- package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
- package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
- package/src/lib/mcp/tools/move_doc.ts +439 -0
- package/src/lib/mcp/tools/patch_preamble.ts +145 -0
- package/src/lib/mcp/tools/patch_section.ts +113 -0
- package/src/lib/mcp/tools/read_doc_section.ts +70 -0
- package/src/lib/mcp/tools/rename_doc.ts +167 -0
- package/src/lib/mcp/tools/restore_doc.ts +41 -0
- package/src/lib/mcp/tools/search.ts +36 -0
- package/src/lib/mcp/tools/sections.ts +130 -0
- package/src/lib/mcp/tools/split_doc.ts +129 -0
- package/src/lib/mcp/tools/trash_doc.ts +116 -0
- package/src/lib/mcp/tools/types.ts +7 -0
- package/src/lib/mcp/tools/upload_image.ts +77 -0
- package/src/lib/mcp/tools/validate_args.ts +36 -0
- package/src/lib/mcp/tools/vault.ts +430 -0
- package/src/lib/mcp/tools/write_doc.ts +81 -0
- package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
- package/src/lib/owner/identity.ts +65 -0
- package/src/lib/storage/FilesystemStorage.ts +88 -0
- package/src/lib/storage/SupabaseStorage.ts +358 -0
- package/src/lib/storage/VaultStorage.ts +35 -0
- package/src/lib/storage/index.ts +41 -0
- package/src/lib/supabase/admin.ts +16 -0
- package/src/lib/supabase/client.ts +8 -0
- package/src/lib/supabase/oauth.ts +296 -0
- package/src/lib/supabase/server.ts +22 -0
- package/src/lib/system-nodes.ts +68 -0
- package/src/lib/trash/state.ts +88 -0
- package/src/mcp/server.ts +380 -0
- package/templates/types/CONCEPT.md +21 -0
- package/templates/types/HACKATHON.md +28 -0
- package/templates/types/NOVEL/CHARACTERS.md +29 -0
- package/templates/types/NOVEL/DRAFT.md +15 -0
- package/templates/types/NOVEL/EDITS.md +19 -0
- package/templates/types/NOVEL/INBOX.md +15 -0
- package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
- package/templates/types/NOVEL/LEARNINGS.md +9 -0
- package/templates/types/NOVEL/OUTBOX.md +15 -0
- package/templates/types/NOVEL/PLOT.md +27 -0
- package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
- package/templates/types/NOVEL.md +32 -0
- package/templates/types/PERSON.md +27 -0
- package/templates/types/PROJECT/BRAND.md +23 -0
- package/templates/types/PROJECT/BUILD.md +9 -0
- package/templates/types/PROJECT/IDEAS.md +11 -0
- package/templates/types/PROJECT/INBOX.md +15 -0
- package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
- package/templates/types/PROJECT/LEARNINGS.md +9 -0
- package/templates/types/PROJECT/LOGS.md +9 -0
- package/templates/types/PROJECT/OUTBOX.md +15 -0
- package/templates/types/PROJECT/SPRINT.md +56 -0
- package/templates/types/PROJECT.md +26 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// Shared lint engine — one implementation, three callsites: the standalone
|
|
2
|
+
// lint_doc MCP tool, and the post-write response paths of write_doc and
|
|
3
|
+
// patch_section. Returns warnings + structural info; never throws on a
|
|
4
|
+
// "bad" doc. Lint is signal, not gate.
|
|
5
|
+
//
|
|
6
|
+
// Single-doc rules (always run): missing_preamble,
|
|
7
|
+
// inline_mention_without_declared_edge, multiple_child_of.
|
|
8
|
+
// Cross-doc rules (run only when a vault context is passed):
|
|
9
|
+
// asymmetric_parent_edge, asymmetric_child_edge. These need the vault's
|
|
10
|
+
// title→doc map to check that every declared edge has a reciprocal
|
|
11
|
+
// declaration in the target doc.
|
|
12
|
+
|
|
13
|
+
const FENCE_RE = /^\s*(?:```|~~~)/;
|
|
14
|
+
const H1_RE = /^#\s+(.+?)\s*$/;
|
|
15
|
+
const H2_RE = /^##\s+(.+?)\s*$/;
|
|
16
|
+
const H3_RE = /^###\s+(.+?)\s*$/;
|
|
17
|
+
const BULLET_RE = /^\s*[-*]\s+/;
|
|
18
|
+
const WIKI_LINK_RE = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/;
|
|
19
|
+
const DECLARED_EDGE_HEADINGS = new Set(["child of", "parent of", "associated with"]);
|
|
20
|
+
const INLINE_MENTION_THRESHOLD = 3;
|
|
21
|
+
|
|
22
|
+
// Distillation thresholds. A doc with 4+ H3 sub-sections each carrying
|
|
23
|
+
// substantive content (~150 words = roughly one solid paragraph) is a
|
|
24
|
+
// candidate for split_doc — each H3 is competing to be its own atomic
|
|
25
|
+
// concept node. A `## Parent of` H3 subgroup with 3+ items is a
|
|
26
|
+
// candidate for materialize_subgroup — the user has already done the
|
|
27
|
+
// semantic grouping, we just need to promote it to a real intermediate
|
|
28
|
+
// parent doc.
|
|
29
|
+
const SPLIT_SUBSECTION_MIN_WORDS = 150;
|
|
30
|
+
const SPLIT_SUBSECTION_COUNT_THRESHOLD = 4;
|
|
31
|
+
const SUBGROUP_BULLET_THRESHOLD = 3;
|
|
32
|
+
|
|
33
|
+
export interface LintWarning {
|
|
34
|
+
code:
|
|
35
|
+
| "missing_preamble"
|
|
36
|
+
| "inline_mention_without_declared_edge"
|
|
37
|
+
| "multiple_child_of"
|
|
38
|
+
| "asymmetric_parent_edge"
|
|
39
|
+
| "asymmetric_child_edge"
|
|
40
|
+
| "associate_duplicates_hierarchy"
|
|
41
|
+
| "sibling_assoc_redundant"
|
|
42
|
+
| "split_candidate"
|
|
43
|
+
| "subgroup_materialization_candidate"
|
|
44
|
+
| "media_asset_missing_url"
|
|
45
|
+
| "filename_not_uppercase";
|
|
46
|
+
message: string;
|
|
47
|
+
suggestion: string;
|
|
48
|
+
title?: string;
|
|
49
|
+
count?: number;
|
|
50
|
+
asymmetric_target?: string;
|
|
51
|
+
/** For split_candidate / subgroup_materialization_candidate — the
|
|
52
|
+
* subsection/subgroup headings that triggered the warning. */
|
|
53
|
+
candidates?: string[];
|
|
54
|
+
/** 1-indexed source line where the violation lives. Null for
|
|
55
|
+
* doc-level codes (split_candidate, subgroup_materialization_candidate)
|
|
56
|
+
* that don't have a single anchoring line. */
|
|
57
|
+
line: number | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LintInfo {
|
|
61
|
+
has_preamble: boolean;
|
|
62
|
+
preamble_word_count: number;
|
|
63
|
+
has_child_of: boolean;
|
|
64
|
+
child_of_count: number;
|
|
65
|
+
declared_edges_total: number;
|
|
66
|
+
inline_mentions: Array<{ title: string; count: number }>;
|
|
67
|
+
section_count: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LintResult {
|
|
71
|
+
warnings: LintWarning[];
|
|
72
|
+
info: LintInfo;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface LintDocInfo {
|
|
76
|
+
path: string;
|
|
77
|
+
title: string;
|
|
78
|
+
declaredParents: string[];
|
|
79
|
+
declaredChildren: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Minimal vault context the cross-doc rules need. */
|
|
83
|
+
export interface LintVaultContext {
|
|
84
|
+
/** Path of the doc being linted, so cross-doc checks can locate "me" by path. */
|
|
85
|
+
selfPath: string;
|
|
86
|
+
/** This doc's resolved parent paths (from `## Child of`). Used by the
|
|
87
|
+
* sibling-assoc check to detect when an assoc target is actually a sibling
|
|
88
|
+
* (i.e. shares one of these parents). */
|
|
89
|
+
selfDeclaredParents: string[];
|
|
90
|
+
/** Resolve a wiki-link target (raw title or slug text) to a doc info entry
|
|
91
|
+
* using the locality-aware resolver. Returns null for dangling links.
|
|
92
|
+
* Cross-doc checks (asymmetric edges, sibling assoc) MUST use this
|
|
93
|
+
* rather than a title-only map so that links like `[[GBI-DAY3]]` —
|
|
94
|
+
* where the slug matches but the title is "GBI — DAY3" — resolve
|
|
95
|
+
* correctly, and so that ambiguous titles get disambiguated by the
|
|
96
|
+
* linking doc's path. */
|
|
97
|
+
resolveTarget: (target: string) => LintDocInfo | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findPreambleBlock(content: string): { body: string; h1LineIdx: number } | null {
|
|
101
|
+
const lines = content.split("\n");
|
|
102
|
+
let inFence = false;
|
|
103
|
+
let h1Idx = -1;
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
106
|
+
if (inFence) continue;
|
|
107
|
+
if (H1_RE.test(lines[i])) { h1Idx = i; break; }
|
|
108
|
+
}
|
|
109
|
+
if (h1Idx === -1) return null;
|
|
110
|
+
|
|
111
|
+
let firstH2Idx = lines.length;
|
|
112
|
+
inFence = false;
|
|
113
|
+
for (let i = h1Idx + 1; i < lines.length; i++) {
|
|
114
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
115
|
+
if (inFence) continue;
|
|
116
|
+
if (H2_RE.test(lines[i])) { firstH2Idx = i; break; }
|
|
117
|
+
}
|
|
118
|
+
const body = lines.slice(h1Idx + 1, firstH2Idx).join("\n").trim();
|
|
119
|
+
return { body, h1LineIdx: h1Idx };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Collect titles referenced under each declared-edge section, with bullet
|
|
124
|
+
* counts per section so we can detect `multiple_child_of` precisely. The
|
|
125
|
+
* bullet count is what matters for the lint, not the number of links —
|
|
126
|
+
* `* [[A]] — collaborated with [[B]]` is one declared edge to A, even
|
|
127
|
+
* though two wiki-links appear.
|
|
128
|
+
*
|
|
129
|
+
* For each declared-edge heading we also track:
|
|
130
|
+
* - `bulletLines`: 1-indexed line numbers of every leading-wiki-link bullet,
|
|
131
|
+
* in source order, so callers (notably the `multiple_child_of` rule) can
|
|
132
|
+
* point at the offending line.
|
|
133
|
+
* - `titleLines`: first-seen 1-indexed line for each lowercase title
|
|
134
|
+
* appearing under the heading, so per-title warnings (assoc-duplicates,
|
|
135
|
+
* sibling-assoc, asymmetric edges) can anchor on the actual bullet.
|
|
136
|
+
*/
|
|
137
|
+
function collectDeclaredEdges(content: string): {
|
|
138
|
+
titlesByHeading: Map<string, Set<string>>;
|
|
139
|
+
bulletCountByHeading: Map<string, number>;
|
|
140
|
+
bulletLinesByHeading: Map<string, number[]>;
|
|
141
|
+
titleLinesByHeading: Map<string, Map<string, number>>;
|
|
142
|
+
allTitles: Set<string>;
|
|
143
|
+
} {
|
|
144
|
+
const lines = content.split("\n");
|
|
145
|
+
const titlesByHeading = new Map<string, Set<string>>();
|
|
146
|
+
const bulletCountByHeading = new Map<string, number>();
|
|
147
|
+
const bulletLinesByHeading = new Map<string, number[]>();
|
|
148
|
+
const titleLinesByHeading = new Map<string, Map<string, number>>();
|
|
149
|
+
const allTitles = new Set<string>();
|
|
150
|
+
let inFence = false;
|
|
151
|
+
let currentHeading: string | null = null;
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < lines.length; i++) {
|
|
154
|
+
const line = lines[i];
|
|
155
|
+
if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
|
|
156
|
+
if (inFence) continue;
|
|
157
|
+
const h2 = line.match(H2_RE);
|
|
158
|
+
if (h2) {
|
|
159
|
+
currentHeading = h2[1].trim().toLowerCase();
|
|
160
|
+
if (DECLARED_EDGE_HEADINGS.has(currentHeading)) {
|
|
161
|
+
if (!titlesByHeading.has(currentHeading)) titlesByHeading.set(currentHeading, new Set());
|
|
162
|
+
if (!bulletCountByHeading.has(currentHeading)) bulletCountByHeading.set(currentHeading, 0);
|
|
163
|
+
if (!bulletLinesByHeading.has(currentHeading)) bulletLinesByHeading.set(currentHeading, []);
|
|
164
|
+
if (!titleLinesByHeading.has(currentHeading)) titleLinesByHeading.set(currentHeading, new Map());
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!currentHeading || !DECLARED_EDGE_HEADINGS.has(currentHeading)) continue;
|
|
169
|
+
|
|
170
|
+
let isBullet = false;
|
|
171
|
+
if (BULLET_RE.test(line)) {
|
|
172
|
+
// Only count bullets whose leading link is a wiki-link — defensive
|
|
173
|
+
// against random "*" lines that aren't edge declarations.
|
|
174
|
+
const leading = line.replace(BULLET_RE, "").match(WIKI_LINK_RE);
|
|
175
|
+
if (leading) {
|
|
176
|
+
bulletCountByHeading.set(currentHeading, (bulletCountByHeading.get(currentHeading) ?? 0) + 1);
|
|
177
|
+
bulletLinesByHeading.get(currentHeading)!.push(i + 1);
|
|
178
|
+
isBullet = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const m of line.matchAll(/\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g)) {
|
|
183
|
+
const title = m[1].trim().toLowerCase();
|
|
184
|
+
titlesByHeading.get(currentHeading)!.add(title);
|
|
185
|
+
allTitles.add(title);
|
|
186
|
+
// Track the first line we saw this title on, so warnings can anchor
|
|
187
|
+
// on the original source line even if the title repeats.
|
|
188
|
+
const titleLines = titleLinesByHeading.get(currentHeading)!;
|
|
189
|
+
if (!titleLines.has(title) && isBullet) titleLines.set(title, i + 1);
|
|
190
|
+
else if (!titleLines.has(title)) titleLines.set(title, i + 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { titlesByHeading, bulletCountByHeading, bulletLinesByHeading, titleLinesByHeading, allTitles };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function collectInlineMentions(content: string): Map<string, { count: number; firstLine: number }> {
|
|
197
|
+
const lines = content.split("\n");
|
|
198
|
+
const counts = new Map<string, { count: number; firstLine: number }>();
|
|
199
|
+
let inFence = false;
|
|
200
|
+
let currentHeading: string | null = null;
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
const line = lines[i];
|
|
203
|
+
if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
|
|
204
|
+
if (inFence) continue;
|
|
205
|
+
const h2 = line.match(H2_RE);
|
|
206
|
+
if (h2) {
|
|
207
|
+
currentHeading = h2[1].trim().toLowerCase();
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (currentHeading && DECLARED_EDGE_HEADINGS.has(currentHeading)) continue;
|
|
211
|
+
|
|
212
|
+
for (const m of line.matchAll(/\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g)) {
|
|
213
|
+
const title = m[1].trim().toLowerCase();
|
|
214
|
+
const prev = counts.get(title);
|
|
215
|
+
if (prev) prev.count++;
|
|
216
|
+
else counts.set(title, { count: 1, firstLine: i + 1 });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return counts;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function countSections(content: string): number {
|
|
223
|
+
const lines = content.split("\n");
|
|
224
|
+
let count = 0;
|
|
225
|
+
let inFence = false;
|
|
226
|
+
for (const line of lines) {
|
|
227
|
+
if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
|
|
228
|
+
if (inFence) continue;
|
|
229
|
+
if (H2_RE.test(line)) count++;
|
|
230
|
+
}
|
|
231
|
+
return count;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Collect H3 sub-sections living under non-edge H2s (Notes, free-form
|
|
236
|
+
* body sections), with a word count of each sub-section's body. Used by
|
|
237
|
+
* the `split_candidate` rule — H3s carrying substantive content are
|
|
238
|
+
* effectively atomic concepts waiting to be extracted.
|
|
239
|
+
*/
|
|
240
|
+
function collectSubstantiveSubsections(content: string): Array<{ heading: string; wordCount: number }> {
|
|
241
|
+
const lines = content.split("\n");
|
|
242
|
+
const out: Array<{ heading: string; wordCount: number }> = [];
|
|
243
|
+
let inFence = false;
|
|
244
|
+
let inEdgeSection = false;
|
|
245
|
+
let currentH3: { heading: string; words: string[] } | null = null;
|
|
246
|
+
const flush = () => {
|
|
247
|
+
if (currentH3) {
|
|
248
|
+
out.push({ heading: currentH3.heading, wordCount: currentH3.words.filter(Boolean).length });
|
|
249
|
+
currentH3 = null;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
|
|
254
|
+
if (inFence) {
|
|
255
|
+
// Code-fenced content counts toward the word total of the open H3.
|
|
256
|
+
if (currentH3) currentH3.words.push(...line.split(/\s+/));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const h2 = line.match(H2_RE);
|
|
260
|
+
if (h2) {
|
|
261
|
+
flush();
|
|
262
|
+
inEdgeSection = DECLARED_EDGE_HEADINGS.has(h2[1].trim().toLowerCase());
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (inEdgeSection) continue;
|
|
266
|
+
const h3 = line.match(H3_RE);
|
|
267
|
+
if (h3) {
|
|
268
|
+
flush();
|
|
269
|
+
currentH3 = { heading: h3[1].trim(), words: [] };
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (currentH3) currentH3.words.push(...line.split(/\s+/));
|
|
273
|
+
}
|
|
274
|
+
flush();
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Collect H3 subgroups inside the `## Parent of` section, with the
|
|
280
|
+
* bullet count per subgroup. Used by `subgroup_materialization_candidate`
|
|
281
|
+
* and (eventually) the `materialize_subgroup` MCP tool.
|
|
282
|
+
*/
|
|
283
|
+
function collectParentOfSubgroups(content: string): Array<{ heading: string; bulletCount: number }> {
|
|
284
|
+
const lines = content.split("\n");
|
|
285
|
+
const out: Array<{ heading: string; bulletCount: number }> = [];
|
|
286
|
+
let inFence = false;
|
|
287
|
+
let inParentOf = false;
|
|
288
|
+
let currentSubgroup: { heading: string; bulletCount: number } | null = null;
|
|
289
|
+
const flush = () => {
|
|
290
|
+
if (currentSubgroup) {
|
|
291
|
+
out.push(currentSubgroup);
|
|
292
|
+
currentSubgroup = null;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
for (const line of lines) {
|
|
296
|
+
if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
|
|
297
|
+
if (inFence) continue;
|
|
298
|
+
const h2 = line.match(H2_RE);
|
|
299
|
+
if (h2) {
|
|
300
|
+
flush();
|
|
301
|
+
inParentOf = h2[1].trim().toLowerCase() === "parent of";
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!inParentOf) continue;
|
|
305
|
+
const h3 = line.match(H3_RE);
|
|
306
|
+
if (h3) {
|
|
307
|
+
flush();
|
|
308
|
+
currentSubgroup = { heading: h3[1].trim(), bulletCount: 0 };
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!currentSubgroup) continue;
|
|
312
|
+
if (BULLET_RE.test(line)) {
|
|
313
|
+
const leading = line.replace(BULLET_RE, "").match(WIKI_LINK_RE);
|
|
314
|
+
if (leading) currentSubgroup.bulletCount++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
flush();
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function lintDocContent(content: string, ctx?: LintVaultContext): LintResult {
|
|
322
|
+
const preamble = findPreambleBlock(content);
|
|
323
|
+
const has_preamble = !!preamble && preamble.body.length > 0 && !/^>\s*$/m.test(preamble.body)
|
|
324
|
+
? /^>\s*\S/.test(preamble.body)
|
|
325
|
+
: false;
|
|
326
|
+
const preamble_word_count = preamble
|
|
327
|
+
? preamble.body.replace(/^>\s*/gm, "").trim().split(/\s+/).filter(Boolean).length
|
|
328
|
+
: 0;
|
|
329
|
+
|
|
330
|
+
const { titlesByHeading, bulletCountByHeading, bulletLinesByHeading, titleLinesByHeading } =
|
|
331
|
+
collectDeclaredEdges(content);
|
|
332
|
+
const declaredTitles = new Set<string>();
|
|
333
|
+
for (const titles of titlesByHeading.values()) for (const t of titles) declaredTitles.add(t);
|
|
334
|
+
|
|
335
|
+
const declared_edges_total = Array.from(bulletCountByHeading.values()).reduce((a, b) => a + b, 0);
|
|
336
|
+
const child_of_count = bulletCountByHeading.get("child of") ?? 0;
|
|
337
|
+
const has_child_of = child_of_count > 0;
|
|
338
|
+
|
|
339
|
+
// Pass the original content (not noFenceContent) so reported line numbers
|
|
340
|
+
// match the source. collectInlineMentions handles fences internally.
|
|
341
|
+
const inlineCounts = collectInlineMentions(content);
|
|
342
|
+
|
|
343
|
+
const warnings: LintWarning[] = [];
|
|
344
|
+
|
|
345
|
+
// SPRINT-055 (SIG-004): filename casing rule. The on-disk filename should
|
|
346
|
+
// be all-caps + ASCII (CLAUDE.md, SPRINT-029.md). Display titles stay
|
|
347
|
+
// free-form because wiki-links are case-insensitive. ctx.selfPath required
|
|
348
|
+
// because this is a path-level check; standalone lint of content alone
|
|
349
|
+
// has no filename to evaluate.
|
|
350
|
+
if (ctx?.selfPath) {
|
|
351
|
+
const base = ctx.selfPath.split("/").pop() ?? ctx.selfPath;
|
|
352
|
+
const ok = /^[A-Z0-9._-]+\.md$/.test(base);
|
|
353
|
+
if (!ok) {
|
|
354
|
+
warnings.push({
|
|
355
|
+
code: "filename_not_uppercase",
|
|
356
|
+
message:
|
|
357
|
+
`Filename \`${base}\` isn't uppercase ASCII. EMDEE convention: on-disk filenames are all-caps (the H1 display title can stay free-form — wiki-links are case-insensitive).`,
|
|
358
|
+
suggestion:
|
|
359
|
+
`Rename via \`rename_doc\` so the basename matches \`[A-Z0-9._-]+\\.md\`. Inbound \`[[wiki-links]]\` reference the H1 title, not the path, so the rename leaves them intact.`,
|
|
360
|
+
title: base,
|
|
361
|
+
line: 1,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!has_preamble && preamble !== null) {
|
|
367
|
+
warnings.push({
|
|
368
|
+
code: "missing_preamble",
|
|
369
|
+
message:
|
|
370
|
+
"No `>` blockquote summary found directly under the H1. The MCP `get_summary` tool returns empty for this doc — it'll be invisible to cheap retrieval.",
|
|
371
|
+
suggestion:
|
|
372
|
+
"Add a `> one-line summary` line immediately after the H1, then a blank line, then the body. Keep it to 1–3 sentences.",
|
|
373
|
+
line: preamble.h1LineIdx + 1,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Hierarchy beats associate: a target that appears in `## Child of` or
|
|
378
|
+
// `## Parent of` shouldn't also appear in `## Associated with` for the
|
|
379
|
+
// same doc — the hierarchy edge already conveys the connection, and
|
|
380
|
+
// duplicating it as an associate just clutters the graph and the
|
|
381
|
+
// neighbours list. Suppressed at the edge-building layer; surfaced
|
|
382
|
+
// here so the user can clean the markdown when convenient.
|
|
383
|
+
const childOfTitles = titlesByHeading.get("child of") ?? new Set<string>();
|
|
384
|
+
const parentOfTitles = titlesByHeading.get("parent of") ?? new Set<string>();
|
|
385
|
+
const assocTitles = titlesByHeading.get("associated with") ?? new Set<string>();
|
|
386
|
+
const assocTitleLines = titleLinesByHeading.get("associated with") ?? new Map<string, number>();
|
|
387
|
+
for (const t of assocTitles) {
|
|
388
|
+
if (childOfTitles.has(t) || parentOfTitles.has(t)) {
|
|
389
|
+
warnings.push({
|
|
390
|
+
code: "associate_duplicates_hierarchy",
|
|
391
|
+
message: `\`[[${t}]]\` appears in both \`## Associated with\` and the hierarchy (Child of / Parent of). The hierarchy edge already covers it.`,
|
|
392
|
+
suggestion: `Remove \`[[${t}]]\` from \`## Associated with\` — the parent/child relationship is the canonical link and the assoc bullet is being suppressed in the graph anyway.`,
|
|
393
|
+
title: t,
|
|
394
|
+
line: assocTitleLines.get(t) ?? null,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Sibling assoc redundancy: when an associate target shares one of
|
|
400
|
+
// this doc's parents, the two are siblings. The shared-parent edge
|
|
401
|
+
// already implies the relationship — `## Associated with` is for
|
|
402
|
+
// cross-tree links (e.g. project↔person), not for linking peers under
|
|
403
|
+
// the same parent. Indexer suppresses these in the graph; this lint
|
|
404
|
+
// surfaces them so the markdown can be cleaned.
|
|
405
|
+
if (ctx && ctx.selfDeclaredParents.length > 0) {
|
|
406
|
+
const selfParents = new Set(ctx.selfDeclaredParents);
|
|
407
|
+
for (const t of assocTitles) {
|
|
408
|
+
// Skip pairs already flagged by the hierarchy-overlap rule above.
|
|
409
|
+
if (childOfTitles.has(t) || parentOfTitles.has(t)) continue;
|
|
410
|
+
const target = ctx.resolveTarget(t);
|
|
411
|
+
if (!target) continue;
|
|
412
|
+
const sharedParent = target.declaredParents.find((p) => selfParents.has(p));
|
|
413
|
+
if (sharedParent) {
|
|
414
|
+
warnings.push({
|
|
415
|
+
code: "sibling_assoc_redundant",
|
|
416
|
+
message: `\`[[${t}]]\` is listed in \`## Associated with\` but shares the parent \`${sharedParent}\` with this doc — the two are siblings, already related through their common parent.`,
|
|
417
|
+
suggestion: `Remove \`[[${t}]]\` from \`## Associated with\`. \`## Associated with\` is for cross-tree links (e.g. project↔person, sprint↔learning), not for connecting docs that share a parent.`,
|
|
418
|
+
title: t,
|
|
419
|
+
line: assocTitleLines.get(t) ?? null,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (child_of_count > 1) {
|
|
426
|
+
// Anchor on the SECOND bullet — that's the violating one (the first
|
|
427
|
+
// bullet is the legitimate canonical parent).
|
|
428
|
+
const childOfBulletLines = bulletLinesByHeading.get("child of") ?? [];
|
|
429
|
+
warnings.push({
|
|
430
|
+
code: "multiple_child_of",
|
|
431
|
+
message: `\`## Child of\` declares ${child_of_count} parents. The vault convention is single-parent — a doc lives under one canonical parent and uses \`## Associated with\` for cross-cutting connections.`,
|
|
432
|
+
suggestion:
|
|
433
|
+
"Keep one parent in `## Child of` (the canonical hierarchy placement) and move the others to `## Associated with` with a short prose note explaining the connection.",
|
|
434
|
+
count: child_of_count,
|
|
435
|
+
line: childOfBulletLines[1] ?? null,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Distillation candidates. Detection-only — execution lives in the
|
|
440
|
+
// split_doc and materialize_subgroup MCP tools, run by a human.
|
|
441
|
+
const substantiveSubsections = collectSubstantiveSubsections(content)
|
|
442
|
+
.filter((s) => s.wordCount >= SPLIT_SUBSECTION_MIN_WORDS);
|
|
443
|
+
if (substantiveSubsections.length >= SPLIT_SUBSECTION_COUNT_THRESHOLD) {
|
|
444
|
+
warnings.push({
|
|
445
|
+
code: "split_candidate",
|
|
446
|
+
message: `This doc has ${substantiveSubsections.length} H3 sub-sections with substantive content (≥${SPLIT_SUBSECTION_MIN_WORDS} words each). Each one is large enough to live as its own atomic concept node.`,
|
|
447
|
+
suggestion: `Plan an extraction with the \`distill_doc\` MCP, then execute with \`split_doc\`. Each H3 → its own doc, with the source rewritten as a thin index of wiki-links.`,
|
|
448
|
+
count: substantiveSubsections.length,
|
|
449
|
+
candidates: substantiveSubsections.map((s) => s.heading),
|
|
450
|
+
line: null,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const subgroups = collectParentOfSubgroups(content);
|
|
455
|
+
const materializable = subgroups.filter((g) => g.bulletCount >= SUBGROUP_BULLET_THRESHOLD);
|
|
456
|
+
if (materializable.length > 0) {
|
|
457
|
+
warnings.push({
|
|
458
|
+
code: "subgroup_materialization_candidate",
|
|
459
|
+
message: `\`## Parent of\` contains ${materializable.length} H3 subgroup(s) with ≥${SUBGROUP_BULLET_THRESHOLD} items: ${materializable.map((g) => `"${g.heading}" (${g.bulletCount})`).join(", ")}. Each is large enough to become its own intermediate parent doc.`,
|
|
460
|
+
suggestion: `Run \`materialize_subgroup\` per subgroup. Each promoted H3 becomes a real intermediate node; its bullets move under it and their \`## Child of\` rewires from this doc to the new intermediate.`,
|
|
461
|
+
count: materializable.length,
|
|
462
|
+
candidates: materializable.map((g) => g.heading),
|
|
463
|
+
line: null,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Cross-doc rules — only run when caller provided vault context.
|
|
468
|
+
if (ctx) {
|
|
469
|
+
const parentOfTitleLines = titleLinesByHeading.get("parent of") ?? new Map<string, number>();
|
|
470
|
+
const childOfTitleLines = titleLinesByHeading.get("child of") ?? new Map<string, number>();
|
|
471
|
+
|
|
472
|
+
const declaredChildrenTitles = titlesByHeading.get("parent of") ?? new Set<string>();
|
|
473
|
+
for (const childTitle of declaredChildrenTitles) {
|
|
474
|
+
const child = ctx.resolveTarget(childTitle);
|
|
475
|
+
if (!child) continue; // dangling link — separate concern, not asymmetric
|
|
476
|
+
const childDeclaresMe = child.declaredParents.some((p) => p === ctx.selfPath);
|
|
477
|
+
if (!childDeclaresMe) {
|
|
478
|
+
warnings.push({
|
|
479
|
+
code: "asymmetric_parent_edge",
|
|
480
|
+
message: `This doc lists \`[[${childTitle}]]\` in Parent of, but [[${child.title}]] doesn't declare this doc back in its Child of. The edge is one-sided.`,
|
|
481
|
+
suggestion: `Either remove \`[[${childTitle}]]\` from this doc's Parent of, or add this doc to ${child.title}'s Child of so the edge is reciprocal.`,
|
|
482
|
+
asymmetric_target: child.title,
|
|
483
|
+
line: parentOfTitleLines.get(childTitle) ?? null,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const declaredParentTitles = titlesByHeading.get("child of") ?? new Set<string>();
|
|
489
|
+
for (const parentTitle of declaredParentTitles) {
|
|
490
|
+
const parent = ctx.resolveTarget(parentTitle);
|
|
491
|
+
if (!parent) continue;
|
|
492
|
+
const parentDeclaresMe = parent.declaredChildren.some((c) => c === ctx.selfPath);
|
|
493
|
+
if (!parentDeclaresMe) {
|
|
494
|
+
warnings.push({
|
|
495
|
+
code: "asymmetric_child_edge",
|
|
496
|
+
message: `This doc lists \`[[${parentTitle}]]\` in Child of, but [[${parent.title}]] doesn't list this doc back in its Parent of. The edge is one-sided.`,
|
|
497
|
+
suggestion: `Either remove \`[[${parentTitle}]]\` from this doc's Child of, or add this doc to ${parent.title}'s Parent of so the edge is reciprocal.`,
|
|
498
|
+
asymmetric_target: parent.title,
|
|
499
|
+
line: childOfTitleLines.get(parentTitle) ?? null,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Media asset rule: an ## Asset section must contain a `url:` line.
|
|
506
|
+
// Fires on any doc that has the section (not just images/ paths) so the
|
|
507
|
+
// rule works whether the doc is linted individually or batch-linted.
|
|
508
|
+
{
|
|
509
|
+
const assetMatch = content.match(/^## Asset\s*\n([\s\S]*?)(?=^## |\z)/m);
|
|
510
|
+
if (assetMatch) {
|
|
511
|
+
const assetBody = assetMatch[1];
|
|
512
|
+
const assetHeadingLine = content.split("\n").findIndex((l) => /^## Asset\s*$/.test(l)) + 1;
|
|
513
|
+
if (!/^\s*url\s*:/m.test(assetBody)) {
|
|
514
|
+
warnings.push({
|
|
515
|
+
code: "media_asset_missing_url",
|
|
516
|
+
message: "`## Asset` block is missing a required `url:` line. Consumers cannot find the binary without a stable public URL.",
|
|
517
|
+
suggestion: "Add `url: https://...` as the first line of the `## Asset` block — use the Supabase Storage public URL returned by `/api/media` or `/api/image`.",
|
|
518
|
+
line: assetHeadingLine || null,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const inline_mentions: Array<{ title: string; count: number }> = [];
|
|
525
|
+
for (const [title, { count, firstLine }] of inlineCounts) {
|
|
526
|
+
inline_mentions.push({ title, count });
|
|
527
|
+
if (count >= INLINE_MENTION_THRESHOLD && !declaredTitles.has(title)) {
|
|
528
|
+
warnings.push({
|
|
529
|
+
code: "inline_mention_without_declared_edge",
|
|
530
|
+
message: `\`[[${title}]]\` is mentioned ${count} times inline but is not declared in Child of / Parent of / Associated with.`,
|
|
531
|
+
suggestion: `Consider adding \`* [[${title}]]\` to the Associated with section if this is a real cross-cutting connection.`,
|
|
532
|
+
title,
|
|
533
|
+
count,
|
|
534
|
+
line: firstLine,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
inline_mentions.sort((a, b) => b.count - a.count);
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
warnings,
|
|
542
|
+
info: {
|
|
543
|
+
has_preamble,
|
|
544
|
+
preamble_word_count,
|
|
545
|
+
has_child_of,
|
|
546
|
+
child_of_count,
|
|
547
|
+
declared_edges_total,
|
|
548
|
+
inline_mentions,
|
|
549
|
+
section_count: countSections(content),
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { validatePath, readVaultFile, loadVaultIndex } from "./vault";
|
|
2
|
+
import { lintDocContent, type LintVaultContext, type LintDocInfo } from "./lint";
|
|
3
|
+
import { resolveWikiLink } from "../../../core/resolveLink";
|
|
4
|
+
import type { DocIndex } from "../../../core/indexer";
|
|
5
|
+
import type { ToolContext } from "./types";
|
|
6
|
+
|
|
7
|
+
// Re-exported for callsites in other tools that need the same context
|
|
8
|
+
// shape — keeps lint_doc the single authority for the resolver wiring.
|
|
9
|
+
export type { LintVaultContext } from "./lint";
|
|
10
|
+
|
|
11
|
+
function json(value: unknown) {
|
|
12
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a LintVaultContext for a given doc using a pre-loaded vault
|
|
17
|
+
* index. Extracted so the lint gate, add_association, and any other
|
|
18
|
+
* caller that already has the index can reuse the resolveTarget closure
|
|
19
|
+
* without re-walking the index. The closure resolves wiki-link targets
|
|
20
|
+
* through the locality-aware resolveWikiLink — same behaviour the
|
|
21
|
+
* lintDoc tool uses.
|
|
22
|
+
*/
|
|
23
|
+
export function buildLintVaultContext(index: DocIndex, selfPath: string): LintVaultContext {
|
|
24
|
+
const docInfoByPath = new Map<string, LintDocInfo>();
|
|
25
|
+
for (const d of index.docs) {
|
|
26
|
+
const declaredParents = d.parents
|
|
27
|
+
.map((l) => resolveWikiLink(index, l.title, d.path)?.path)
|
|
28
|
+
.filter((p): p is string => !!p);
|
|
29
|
+
const declaredChildren = d.children
|
|
30
|
+
.map((l) => resolveWikiLink(index, l.title, d.path)?.path)
|
|
31
|
+
.filter((p): p is string => !!p);
|
|
32
|
+
docInfoByPath.set(d.path, {
|
|
33
|
+
path: d.path,
|
|
34
|
+
title: d.title,
|
|
35
|
+
declaredParents,
|
|
36
|
+
declaredChildren,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const selfInfo = docInfoByPath.get(selfPath);
|
|
40
|
+
const selfDeclaredParents = selfInfo?.declaredParents ?? [];
|
|
41
|
+
const resolveTarget = (target: string): LintDocInfo | null => {
|
|
42
|
+
const resolved = resolveWikiLink(index, target, selfPath);
|
|
43
|
+
if (!resolved) return null;
|
|
44
|
+
return docInfoByPath.get(resolved.path) ?? null;
|
|
45
|
+
};
|
|
46
|
+
return { selfPath, selfDeclaredParents, resolveTarget };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Audit a doc for known quality defects. Returns warnings + structural info.
|
|
51
|
+
* Never throws on a "bad" doc — lint is a signal, not a gate.
|
|
52
|
+
*
|
|
53
|
+
* Single-doc rules (missing preamble, undeclared inline mentions,
|
|
54
|
+
* multi-parent, distillation candidates) and cross-doc rules (asymmetric
|
|
55
|
+
* Parent/Child edges, sibling-assoc redundancy) both run here — this
|
|
56
|
+
* tool loads the full vault index, so the cross-doc checks have the
|
|
57
|
+
* context they need. The post-write fast paths in write_doc and
|
|
58
|
+
* patch_section skip the cross-doc rules to stay cheap.
|
|
59
|
+
*
|
|
60
|
+
* Cross-doc resolution goes through the locality-aware resolveWikiLink,
|
|
61
|
+
* so a `[[GBI-DAY3]]` link resolves via slug match to the file named
|
|
62
|
+
* `GBI-DAY3.md` even though its H1 title is `GBI — DAY3`. A title-only
|
|
63
|
+
* map (the previous implementation) would have left every such link
|
|
64
|
+
* unresolved and falsely flagged the edges as asymmetric.
|
|
65
|
+
*/
|
|
66
|
+
export async function lintDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
67
|
+
const rel = String(args.path);
|
|
68
|
+
validatePath(rel);
|
|
69
|
+
const content = await readVaultFile(ctx, rel);
|
|
70
|
+
if (content === null) return json({ error: "doc_not_found", path: rel });
|
|
71
|
+
|
|
72
|
+
const index = await loadVaultIndex(ctx);
|
|
73
|
+
const lintCtx = buildLintVaultContext(index, rel);
|
|
74
|
+
const result = lintDocContent(content, lintCtx);
|
|
75
|
+
return json({ path: rel, ...result });
|
|
76
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPRINT-019 Phase A — pre-write lint gate. The write tools call
|
|
2
|
+
// evaluateLintGate against proposed-but-not-yet-persisted content, with a
|
|
3
|
+
// caller-supplied list of warning codes that should HARD-BLOCK the write.
|
|
4
|
+
// If any of those codes fire, the gate returns the structured fixes and
|
|
5
|
+
// the write is skipped. With an empty gate list (the default everywhere),
|
|
6
|
+
// every write proceeds and warnings remain advisory — the existing
|
|
7
|
+
// post-write lint envelope behaviour is unchanged.
|
|
8
|
+
//
|
|
9
|
+
// The shape `{ ok: true } | { ok: false; ... }` is deliberately the same
|
|
10
|
+
// envelope each write tool serialises into its response so the caller
|
|
11
|
+
// can branch on `ok` alone.
|
|
12
|
+
|
|
13
|
+
import { lintDocContent, type LintVaultContext, type LintWarning } from "./lint";
|
|
14
|
+
|
|
15
|
+
export interface LintFix {
|
|
16
|
+
code: LintWarning["code"];
|
|
17
|
+
line: number | null;
|
|
18
|
+
fix_suggestion: string;
|
|
19
|
+
original_message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type LintGateResult =
|
|
23
|
+
| { ok: true }
|
|
24
|
+
| { ok: false; fixes: LintFix[]; original_warnings: LintWarning[] };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run the lint engine on `proposedContent` and decide whether the write
|
|
28
|
+
* should proceed. Cross-doc rules (asymmetric_*, sibling_assoc_redundant)
|
|
29
|
+
* only fire when `vaultCtx` is supplied — callers that want to gate on
|
|
30
|
+
* those must pass it in. Single-doc codes work without a vault context.
|
|
31
|
+
*/
|
|
32
|
+
export function evaluateLintGate(
|
|
33
|
+
proposedContent: string,
|
|
34
|
+
gateOnCodes: string[],
|
|
35
|
+
vaultCtx?: LintVaultContext,
|
|
36
|
+
): LintGateResult {
|
|
37
|
+
if (gateOnCodes.length === 0) return { ok: true };
|
|
38
|
+
const { warnings } = lintDocContent(proposedContent, vaultCtx);
|
|
39
|
+
const gateSet = new Set(gateOnCodes);
|
|
40
|
+
const offending = warnings.filter((w) => gateSet.has(w.code));
|
|
41
|
+
if (offending.length === 0) return { ok: true };
|
|
42
|
+
const fixes: LintFix[] = offending.map((w) => ({
|
|
43
|
+
code: w.code,
|
|
44
|
+
line: w.line ?? null,
|
|
45
|
+
fix_suggestion: w.suggestion,
|
|
46
|
+
original_message: w.message,
|
|
47
|
+
}));
|
|
48
|
+
return { ok: false, fixes, original_warnings: offending };
|
|
49
|
+
}
|