dotmd-cli 0.34.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 CHANGED
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -175,10 +175,30 @@ export function parseDocFile(filePath, config) {
175
175
  const bodyLinks = extractBodyLinks(body);
176
176
  const hasCloseout = /^##\s+Closeout/m.test(body);
177
177
 
178
- // 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.
179
184
  const refFields = {};
185
+ const refFieldDirections = {};
180
186
  for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
181
- refFields[field] = normalizeStringList(parsedFrontmatter[field]);
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;
182
202
  }
183
203
 
184
204
  // Tag doc with its root
@@ -215,6 +235,7 @@ export function parseDocFile(filePath, config) {
215
235
  checklist,
216
236
  bodyLinks,
217
237
  refFields,
238
+ refFieldDirections,
218
239
  checklistCompletionRate: computeChecklistCompletionRate(checklist),
219
240
  hasCloseout,
220
241
  hasNextStep: Boolean(nextStep),
package/src/validate.mjs CHANGED
@@ -258,26 +258,42 @@ export function checkBidirectionalReferences(docs, config) {
258
258
  const biFields = config.referenceFields.bidirectional || [];
259
259
  if (!biFields.length) return { warnings, errors: [] };
260
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.
261
267
  const refMap = new Map();
268
+ const oneWayMap = new Map();
262
269
  for (const doc of docs) {
263
270
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
264
271
  const refs = new Set();
272
+ const oneWay = new Set();
265
273
  for (const field of biFields) {
266
- for (const relPath of (doc.refFields[field] || [])) {
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];
267
278
  // Use the same doc-relative-then-repo-root fallback as validateDoc so
268
279
  // both styles produce identical refMap keys; otherwise an entry like
269
280
  // `docs/foo.md` (repo-root style) gets keyed as
270
281
  // `<doc-parent>/docs/foo.md` and never matches the target's repo path.
271
282
  const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
272
283
  ?? path.resolve(docDir, relPath);
273
- refs.add(toRepoPath(resolved, config.repoRoot));
284
+ const targetPath = toRepoPath(resolved, config.repoRoot);
285
+ refs.add(targetPath);
286
+ if (dirs[i] === 'one-way') oneWay.add(targetPath);
274
287
  }
275
288
  }
276
289
  refMap.set(doc.path, refs);
290
+ oneWayMap.set(doc.path, oneWay);
277
291
  }
278
292
 
279
293
  for (const [docPath, refs] of refMap) {
294
+ const oneWay = oneWayMap.get(docPath);
280
295
  for (const targetPath of refs) {
296
+ if (oneWay.has(targetPath)) continue;
281
297
  const targetRefs = refMap.get(targetPath);
282
298
  if (targetRefs && !targetRefs.has(docPath)) {
283
299
  warnings.push({ path: docPath, level: 'warning',