dotmd-cli 0.33.0 → 0.35.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 +7 -1
- package/bin/dotmd.mjs +11 -11
- package/package.json +1 -1
- package/src/glossary.mjs +4 -2
- package/src/index.mjs +30 -3
- package/src/lifecycle.mjs +1 -1
- package/src/new.mjs +7 -3
- package/src/query.mjs +22 -1
- package/src/util.mjs +36 -0
- package/src/validate.mjs +84 -5
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ dotmd query [filters] Filtered search
|
|
|
179
179
|
dotmd plans List all plans
|
|
180
180
|
dotmd stale List stale docs
|
|
181
181
|
dotmd actionable List docs with next steps
|
|
182
|
-
dotmd index [--
|
|
182
|
+
dotmd index [--print] Generate/update docs.md index block
|
|
183
183
|
dotmd hud Actionable triage (silent when clean — ideal SessionStart hook)
|
|
184
184
|
dotmd pickup <file> Pick up a plan (in-session + print body)
|
|
185
185
|
dotmd release [<file>] Release in-session lease (alias: unpickup)
|
|
@@ -757,6 +757,12 @@ export const referenceFields = {
|
|
|
757
757
|
bidirectional: ['related_plans'], // warn if A→B but B↛A
|
|
758
758
|
unidirectional: ['supports_plans'], // one-way, no symmetry check
|
|
759
759
|
};
|
|
760
|
+
// Per-ref opt-out: prefix any value with `>` to mark that specific ref one-way
|
|
761
|
+
// without changing the field's default. Useful for leaf→upstream-parent refs
|
|
762
|
+
// (audits, hub docs) where a back-ref would force editing a stable parent.
|
|
763
|
+
// related_docs:
|
|
764
|
+
// - docs/sibling-design.md # bidirectional (default for the field)
|
|
765
|
+
// - "> docs/audit-beyond-platform.md" # one-way upstream — no back-ref expected
|
|
760
766
|
|
|
761
767
|
export const index = {
|
|
762
768
|
path: 'docs/docs.md',
|
package/bin/dotmd.mjs
CHANGED
|
@@ -58,7 +58,7 @@ Lifecycle:
|
|
|
58
58
|
|
|
59
59
|
Create & Export:
|
|
60
60
|
new <type> <name> [body] Create doc of given type (plan, doc, prompt)
|
|
61
|
-
index [--
|
|
61
|
+
index [--print] Generate/update docs.md index block
|
|
62
62
|
export [--format md|html|json] Export docs as markdown, HTML, or JSON
|
|
63
63
|
notion import|export|sync [db-id] Notion database integration
|
|
64
64
|
|
|
@@ -354,12 +354,12 @@ date to match the last git commit date, fixing date drift warnings.
|
|
|
354
354
|
|
|
355
355
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
356
356
|
|
|
357
|
-
index: `dotmd index [--
|
|
357
|
+
index: `dotmd index [--print] — generate/update docs.md index
|
|
358
358
|
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
Updates the configured index file in place (writes by default as of 0.34.0).
|
|
360
|
+
Use --print to dump the regenerated content to stdout without writing.
|
|
361
361
|
|
|
362
|
-
Use --dry-run (-n)
|
|
362
|
+
Use --dry-run (-n) to preview without writing.`,
|
|
363
363
|
|
|
364
364
|
new: `dotmd new <type> <name> [body] — create a new document
|
|
365
365
|
|
|
@@ -954,16 +954,16 @@ async function main() {
|
|
|
954
954
|
if (!config.indexPath) {
|
|
955
955
|
die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
|
|
956
956
|
}
|
|
957
|
-
const
|
|
957
|
+
const print = args.includes('--print');
|
|
958
958
|
const { renderIndexFile, writeIndex } = await import('../src/index-file.mjs');
|
|
959
959
|
const rendered = renderIndexFile(index, config);
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
} else if (write && dryRun) {
|
|
960
|
+
if (print) {
|
|
961
|
+
process.stdout.write(rendered);
|
|
962
|
+
} else if (dryRun) {
|
|
964
963
|
process.stdout.write(`[dry-run] Would update ${config.indexPath}\n`);
|
|
965
964
|
} else {
|
|
966
|
-
|
|
965
|
+
writeIndex(rendered, config);
|
|
966
|
+
process.stdout.write(`Updated ${config.indexPath}\n`);
|
|
967
967
|
}
|
|
968
968
|
return;
|
|
969
969
|
}
|
package/package.json
CHANGED
package/src/glossary.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { buildIndex } from './index.mjs';
|
|
4
|
-
import { die, warn } from './util.mjs';
|
|
4
|
+
import { die, warn, suggestCandidates } from './util.mjs';
|
|
5
5
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
6
|
|
|
7
7
|
function parseGlossaryTable(content, sectionHeading) {
|
|
@@ -217,7 +217,9 @@ export function runGlossary(argv, config) {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
if (matches.length === 0) {
|
|
220
|
-
|
|
220
|
+
const suggestions = suggestCandidates(term, entries.map(e => e.term));
|
|
221
|
+
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
222
|
+
process.stdout.write(dim(`No glossary match for "${term}".${hint}`) + '\n');
|
|
221
223
|
return;
|
|
222
224
|
}
|
|
223
225
|
|
package/src/index.mjs
CHANGED
|
@@ -3,12 +3,18 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
6
|
-
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
10
10
|
export function buildIndex(config) {
|
|
11
11
|
const docs = collectDocFiles(config).map(f => parseDocFile(f, config));
|
|
12
|
+
// Per-file validation (validateDoc) ran during parse without sibling
|
|
13
|
+
// visibility. Now that the full index is materialized, enrich
|
|
14
|
+
// unresolved-ref entries with "Did you mean..." candidates drawn from the
|
|
15
|
+
// index — mutates doc.errors/doc.warnings in place so the aggregations
|
|
16
|
+
// below pick up the enriched messages.
|
|
17
|
+
enrichRefErrorSuggestions(docs, config);
|
|
12
18
|
const warnings = [];
|
|
13
19
|
const errors = [];
|
|
14
20
|
|
|
@@ -169,10 +175,30 @@ export function parseDocFile(filePath, config) {
|
|
|
169
175
|
const bodyLinks = extractBodyLinks(body);
|
|
170
176
|
const hasCloseout = /^##\s+Closeout/m.test(body);
|
|
171
177
|
|
|
172
|
-
// Dynamic reference field extraction
|
|
178
|
+
// Dynamic reference field extraction. A leading `>` on a value (e.g.
|
|
179
|
+
// `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
|
|
180
|
+
// the prefix is stripped so path resolution still works, and the direction
|
|
181
|
+
// is recorded on a parallel `refFieldDirections[field]` array indexed the
|
|
182
|
+
// same as `refFields[field]`. Bidirectional reciprocity checks consume the
|
|
183
|
+
// directions to skip outbound entries that opted out of expecting a back-ref.
|
|
173
184
|
const refFields = {};
|
|
185
|
+
const refFieldDirections = {};
|
|
174
186
|
for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
|
|
175
|
-
|
|
187
|
+
const raw = normalizeStringList(parsedFrontmatter[field]);
|
|
188
|
+
const paths = [];
|
|
189
|
+
const directions = [];
|
|
190
|
+
for (const entry of raw) {
|
|
191
|
+
const oneWay = entry.match(/^>\s*(.+)$/);
|
|
192
|
+
if (oneWay) {
|
|
193
|
+
paths.push(oneWay[1].trim());
|
|
194
|
+
directions.push('one-way');
|
|
195
|
+
} else {
|
|
196
|
+
paths.push(entry);
|
|
197
|
+
directions.push('two-way');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
refFields[field] = paths;
|
|
201
|
+
refFieldDirections[field] = directions;
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
// Tag doc with its root
|
|
@@ -209,6 +235,7 @@ export function parseDocFile(filePath, config) {
|
|
|
209
235
|
checklist,
|
|
210
236
|
bodyLinks,
|
|
211
237
|
refFields,
|
|
238
|
+
refFieldDirections,
|
|
212
239
|
checklistCompletionRate: computeChecklistCompletionRate(checklist),
|
|
213
240
|
hasCloseout,
|
|
214
241
|
hasNextStep: Boolean(nextStep),
|
package/src/lifecycle.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export function regenIndex(config) {
|
|
|
35
35
|
const index = buildIndex(config);
|
|
36
36
|
writeIndex(renderIndexFile(index, config), config);
|
|
37
37
|
} catch (err) {
|
|
38
|
-
warn(`Could not regenerate index (run \`dotmd index
|
|
38
|
+
warn(`Could not regenerate index (run \`dotmd index\`): ${err.message}`);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
package/src/new.mjs
CHANGED
|
@@ -51,6 +51,9 @@ ${ctx?.bodyInput?.trim() ?? ''}
|
|
|
51
51
|
dir: 'plans',
|
|
52
52
|
targetRoot: 'plans',
|
|
53
53
|
defaultStatus: 'active',
|
|
54
|
+
// Body input lands in the Problem section. Plans don't have an Overview;
|
|
55
|
+
// Problem is the established opening section in the build-up shape.
|
|
56
|
+
acceptsBody: true,
|
|
54
57
|
frontmatter: (s, d) => [
|
|
55
58
|
'type: plan',
|
|
56
59
|
`status: ${s}`,
|
|
@@ -73,7 +76,7 @@ ${ctx?.bodyInput?.trim() ?? ''}
|
|
|
73
76
|
|
|
74
77
|
## Problem
|
|
75
78
|
|
|
76
|
-
|
|
79
|
+
${ctx?.bodyInput?.trim() ?? ''}
|
|
77
80
|
|
|
78
81
|
## Goals
|
|
79
82
|
|
|
@@ -242,8 +245,9 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
242
245
|
|
|
243
246
|
// Fail-fast when the user passes body input to a template that doesn't
|
|
244
247
|
// consume it — silently discarding heredoc content is the worst UX.
|
|
245
|
-
// Templates opt in via `acceptsBody: true` or `requiresBody: true`.
|
|
246
|
-
//
|
|
248
|
+
// Templates opt in via `acceptsBody: true` or `requiresBody: true`. All
|
|
249
|
+
// built-in templates (doc, plan, prompt) accept body; this guard fires
|
|
250
|
+
// only for custom templates that opt out.
|
|
247
251
|
if (bodyInput !== null && !template.acceptsBody && !template.requiresBody) {
|
|
248
252
|
const accepting = Object.entries(BUILTIN_TEMPLATES)
|
|
249
253
|
.filter(([, t]) => t.acceptsBody || t.requiresBody)
|
package/src/query.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { capitalize, toSlug, truncate, warn } from './util.mjs';
|
|
3
|
+
import { capitalize, toSlug, truncate, warn, suggestCandidates } from './util.mjs';
|
|
4
4
|
import { renderProgressBar, formatCurrentState } from './render.mjs';
|
|
5
5
|
import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
|
|
6
6
|
import { getGitLastModifiedBatch } from './git.mjs';
|
|
@@ -90,10 +90,31 @@ export function runQuery(index, argv, config, opts = {}) {
|
|
|
90
90
|
|
|
91
91
|
if (opts.preset === 'plans' || opts.preset === 'prompts') {
|
|
92
92
|
renderPlansOutput(docs, filters, config, { noun: opts.preset });
|
|
93
|
+
if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
renderQueryResults(docs, filters, config);
|
|
98
|
+
if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// When a query returns nothing AND a value-shaped filter (currently --module)
|
|
102
|
+
// names a value that doesn't exist anywhere in the index, the empty result is
|
|
103
|
+
// almost certainly a typo rather than a combination miss. Surface a hint so
|
|
104
|
+
// the agent doesn't have to grep modules to discover the right spelling.
|
|
105
|
+
function writeUnknownFilterValueHint(filters, index) {
|
|
106
|
+
if (filters.module) {
|
|
107
|
+
const allModules = new Set();
|
|
108
|
+
for (const d of index.docs) {
|
|
109
|
+
for (const m of (d.modules ?? [])) allModules.add(m);
|
|
110
|
+
}
|
|
111
|
+
const exists = [...allModules].some(m => m.toLowerCase() === filters.module.toLowerCase());
|
|
112
|
+
if (!exists) {
|
|
113
|
+
const suggestions = suggestCandidates(filters.module, [...allModules]);
|
|
114
|
+
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
115
|
+
process.stdout.write(dim(`No module \`${filters.module}\` in index.${hint}`) + '\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
export function parseQueryArgs(argv) {
|
package/src/util.mjs
CHANGED
|
@@ -89,6 +89,42 @@ export function levenshtein(a, b) {
|
|
|
89
89
|
return matrix[b.length][a.length];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Top-N candidates from a list, ranked for "did you mean" hints. Substring
|
|
93
|
+
// match wins (cheap and intent-revealing for typos that share a prefix or
|
|
94
|
+
// stem); Levenshtein distance ≤3 catches transpositions and small edits.
|
|
95
|
+
// Results are deduped and capped. Empty input or empty candidate list returns
|
|
96
|
+
// an empty array — callers should suppress the hint line in that case.
|
|
97
|
+
export function suggestCandidates(query, candidates, max = 3) {
|
|
98
|
+
if (!query || !candidates?.length) return [];
|
|
99
|
+
const lower = String(query).toLowerCase();
|
|
100
|
+
const scored = new Map();
|
|
101
|
+
|
|
102
|
+
for (const cand of candidates) {
|
|
103
|
+
if (!cand) continue;
|
|
104
|
+
const candLower = String(cand).toLowerCase();
|
|
105
|
+
if (candLower === lower) continue;
|
|
106
|
+
if (candLower.includes(lower) || lower.includes(candLower)) {
|
|
107
|
+
// Substring hits sort first; shorter candidates rank higher within that bucket.
|
|
108
|
+
scored.set(cand, Math.min(scored.get(cand) ?? Infinity, candLower.length));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (scored.size < max) {
|
|
113
|
+
for (const cand of candidates) {
|
|
114
|
+
if (!cand || scored.has(cand)) continue;
|
|
115
|
+
const candLower = String(cand).toLowerCase();
|
|
116
|
+
if (candLower === lower) continue;
|
|
117
|
+
const dist = levenshtein(lower, candLower);
|
|
118
|
+
if (dist <= 3) scored.set(cand, 1000 + dist);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return [...scored.entries()]
|
|
123
|
+
.sort((a, b) => a[1] - b[1])
|
|
124
|
+
.slice(0, max)
|
|
125
|
+
.map(([cand]) => cand);
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
export function resolveDocPath(input, config) {
|
|
93
129
|
if (!input) return null;
|
|
94
130
|
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|
package/src/validate.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { asString, resolveRefPath } from './util.mjs';
|
|
2
|
+
import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
|
|
3
3
|
import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
4
4
|
import { toRepoPath } from './util.mjs';
|
|
5
5
|
|
|
@@ -176,7 +176,12 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
176
176
|
for (const field of allRefFields) {
|
|
177
177
|
for (const relPath of (doc.refFields[field] || [])) {
|
|
178
178
|
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
179
|
-
doc.errors.push({
|
|
179
|
+
doc.errors.push({
|
|
180
|
+
path: doc.path,
|
|
181
|
+
level: 'error',
|
|
182
|
+
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
183
|
+
meta: { kind: 'ref-resolution', field, relPath },
|
|
184
|
+
});
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
}
|
|
@@ -184,37 +189,111 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
184
189
|
// Validate body links resolve to existing files
|
|
185
190
|
for (const link of (doc.bodyLinks || [])) {
|
|
186
191
|
if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
|
|
187
|
-
doc.warnings.push({
|
|
192
|
+
doc.warnings.push({
|
|
193
|
+
path: doc.path,
|
|
194
|
+
level: 'warning',
|
|
195
|
+
message: `body link \`${link.href}\` does not resolve to an existing file.`,
|
|
196
|
+
meta: { kind: 'ref-resolution', field: 'body-link', relPath: link.href },
|
|
197
|
+
});
|
|
188
198
|
}
|
|
189
199
|
}
|
|
190
200
|
}
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
// Guess which doc type a ref field expects so suggestions don't propose
|
|
204
|
+
// nonsense (a `related_plans` ref shouldn't suggest doc filenames). Heuristic:
|
|
205
|
+
// match common substrings in the field name. When ambiguous, return null so
|
|
206
|
+
// the caller suggests across all types.
|
|
207
|
+
function inferRefFieldType(field) {
|
|
208
|
+
if (!field) return null;
|
|
209
|
+
const f = field.toLowerCase();
|
|
210
|
+
if (f.includes('plan')) return 'plan';
|
|
211
|
+
if (f.includes('doc')) return 'doc';
|
|
212
|
+
if (f.includes('prompt')) return 'prompt';
|
|
213
|
+
if (f.includes('research')) return 'research';
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function candidatePathsForType(docs, type) {
|
|
218
|
+
// When the field implies a type, exclude docs that explicitly declare a
|
|
219
|
+
// different type. Untyped docs (no frontmatter `type:`) are kept — we
|
|
220
|
+
// don't know they aren't the target, so suggesting them is still useful.
|
|
221
|
+
const matches = type ? docs.filter(d => !d.type || d.type === type) : docs;
|
|
222
|
+
// Suggest by basename (the friendly handle agents/humans actually type) and
|
|
223
|
+
// by repo-relative path (covers cases where multiple files share a basename
|
|
224
|
+
// and the disambiguator is the directory).
|
|
225
|
+
const out = new Set();
|
|
226
|
+
for (const d of matches) {
|
|
227
|
+
out.add(path.basename(d.path));
|
|
228
|
+
out.add(d.path);
|
|
229
|
+
}
|
|
230
|
+
return [...out];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Post-pass enrichment: walk every doc's errors/warnings for unresolved-ref
|
|
234
|
+
// entries and append `Did you mean: a, b, c?` using the full index. Runs
|
|
235
|
+
// after parsing so it has the global doc list (validateDoc runs per-file and
|
|
236
|
+
// can't see siblings). Filters candidates by ref-field type when the field
|
|
237
|
+
// name implies one (e.g. `related_plans` → plans only).
|
|
238
|
+
export function enrichRefErrorSuggestions(docs, config) {
|
|
239
|
+
const enrich = (entry) => {
|
|
240
|
+
if (!entry?.meta || entry.meta.kind !== 'ref-resolution') return;
|
|
241
|
+
if (entry._suggested) return;
|
|
242
|
+
const inferred = inferRefFieldType(entry.meta.field);
|
|
243
|
+
const candidates = candidatePathsForType(docs, inferred);
|
|
244
|
+
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidates);
|
|
245
|
+
entry._suggested = true;
|
|
246
|
+
if (suggestions.length === 0) return;
|
|
247
|
+
entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
for (const doc of docs) {
|
|
251
|
+
for (const e of (doc.errors || [])) enrich(e);
|
|
252
|
+
for (const w of (doc.warnings || [])) enrich(w);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
193
256
|
export function checkBidirectionalReferences(docs, config) {
|
|
194
257
|
const warnings = [];
|
|
195
258
|
const biFields = config.referenceFields.bidirectional || [];
|
|
196
259
|
if (!biFields.length) return { warnings, errors: [] };
|
|
197
260
|
|
|
261
|
+
// refMap stores `Set<targetPath>` keyed by the source doc path — used to
|
|
262
|
+
// answer "does B reference A?" via .has(). oneWayMap stores the subset of A's
|
|
263
|
+
// outbound refs that opted out of expecting a back-ref (via the `>` prefix in
|
|
264
|
+
// frontmatter, parsed in src/index.mjs:parseDocFile). We split these so the
|
|
265
|
+
// membership check stays cheap (Set.has) while the per-ref directionality
|
|
266
|
+
// filter only consults oneWayMap when iterating outbound edges.
|
|
198
267
|
const refMap = new Map();
|
|
268
|
+
const oneWayMap = new Map();
|
|
199
269
|
for (const doc of docs) {
|
|
200
270
|
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
201
271
|
const refs = new Set();
|
|
272
|
+
const oneWay = new Set();
|
|
202
273
|
for (const field of biFields) {
|
|
203
|
-
|
|
274
|
+
const entries = doc.refFields[field] || [];
|
|
275
|
+
const dirs = doc.refFieldDirections?.[field] || [];
|
|
276
|
+
for (let i = 0; i < entries.length; i++) {
|
|
277
|
+
const relPath = entries[i];
|
|
204
278
|
// Use the same doc-relative-then-repo-root fallback as validateDoc so
|
|
205
279
|
// both styles produce identical refMap keys; otherwise an entry like
|
|
206
280
|
// `docs/foo.md` (repo-root style) gets keyed as
|
|
207
281
|
// `<doc-parent>/docs/foo.md` and never matches the target's repo path.
|
|
208
282
|
const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
|
|
209
283
|
?? path.resolve(docDir, relPath);
|
|
210
|
-
|
|
284
|
+
const targetPath = toRepoPath(resolved, config.repoRoot);
|
|
285
|
+
refs.add(targetPath);
|
|
286
|
+
if (dirs[i] === 'one-way') oneWay.add(targetPath);
|
|
211
287
|
}
|
|
212
288
|
}
|
|
213
289
|
refMap.set(doc.path, refs);
|
|
290
|
+
oneWayMap.set(doc.path, oneWay);
|
|
214
291
|
}
|
|
215
292
|
|
|
216
293
|
for (const [docPath, refs] of refMap) {
|
|
294
|
+
const oneWay = oneWayMap.get(docPath);
|
|
217
295
|
for (const targetPath of refs) {
|
|
296
|
+
if (oneWay.has(targetPath)) continue;
|
|
218
297
|
const targetRefs = refMap.get(targetPath);
|
|
219
298
|
if (targetRefs && !targetRefs.has(docPath)) {
|
|
220
299
|
warnings.push({ path: docPath, level: 'warning',
|