cans-spec 0.3.0 → 0.5.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 +15 -6
- package/bin/cans.js +87 -14
- package/bin/ts-loader.mjs +9 -2
- package/package.json +1 -1
- package/src/cli.ts +6 -1
- package/src/commands/budget.ts +87 -16
- package/src/commands/check.ts +313 -58
- package/src/commands/import.ts +216 -6
- package/src/core/fs.ts +31 -11
- package/src/core/outline.ts +17 -1
- package/src/core/output.ts +202 -57
- package/src/core/overflow.ts +4 -0
- package/src/core/redundancy.ts +5 -0
- package/src/core/refs.ts +197 -25
- package/src/core/report.ts +575 -0
- package/src/core/structure.ts +10 -0
- package/src/core/style.ts +2 -0
- package/src/core/token-budget.ts +34 -3
- package/src/types.ts +17 -0
package/src/commands/import.ts
CHANGED
|
@@ -127,6 +127,136 @@ function isNearMatch(a: string, b: string, minOverlap = 0.75): boolean {
|
|
|
127
127
|
return shared / Math.max(wa.size, wb.size) >= minOverlap;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// ── diverged-sibling guard (issue #20 / §27) ────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** Significant tokens of a node: normalized words of length > 1. */
|
|
133
|
+
function sigTokens(text: string): string[] {
|
|
134
|
+
return normKey(text).split(' ').filter(w => w.length > 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** DIVERGED_STEM: the leading-stem length for the guard — the first TWO
|
|
138
|
+
* significant words when both sides have them (falls back to one). Two, not
|
|
139
|
+
* one: "sign up" vs "sign in" share the first word but are distinct concepts,
|
|
140
|
+
* while "sign up" vs "sign up …" is the same concept reworded. */
|
|
141
|
+
const DIVERGED_STEM = 2;
|
|
142
|
+
|
|
143
|
+
/** DIVERGED_JACCARD_FLOOR: corroborating token-set overlap for the guard.
|
|
144
|
+
* Jaccard (shared / union) is preferred over isNearMatch's shared/max because
|
|
145
|
+
* it does not decay monotonically as one side is lengthened. The floor is
|
|
146
|
+
* deliberately low (0.3): the repro pair "Sign up: TBD" vs "Sign up: DONE -
|
|
147
|
+
* changed externally" scores 2/6 ≈ 0.33, and the stem condition — not the
|
|
148
|
+
* floor — carries the discrimination (see isDivergedSibling). */
|
|
149
|
+
const DIVERGED_JACCARD_FLOOR = 0.3;
|
|
150
|
+
|
|
151
|
+
/** DIVERGED_CONTAINMENT: length-robustness fallback. A rewording that keeps the
|
|
152
|
+
* stem but lengthens far ("Sign up: DONE and the ops team also recorded the
|
|
153
|
+
* external migration notes") decays Jaccard below any floor (2/13 ≈ 0.15),
|
|
154
|
+
* yet at least half of the EXISTING sibling's distinct significant tokens
|
|
155
|
+
* still survive in the import (shared/|existing tokens| ≥ 0.5) — that is the
|
|
156
|
+
* same concept elaborated, not a new one. Round 6 (QA-18 F10/F13): the side
|
|
157
|
+
* is the EXISTING sibling's, per docs §27 — the previous min(|E|,|I|) side
|
|
158
|
+
* fired below the documented floor on import-shortening pairs. */
|
|
159
|
+
const DIVERGED_CONTAINMENT = 0.5;
|
|
160
|
+
|
|
161
|
+
/** TBD_TOKEN: the placeholder token (rules key `content.tbd_allowed` keeps TBD
|
|
162
|
+
* a first-class citizen, and the default scaffold's dominant node shape is
|
|
163
|
+
* "Concept: TBD"). Compared post-normKey, so case-insensitive. */
|
|
164
|
+
const TBD_TOKEN = 'tbd';
|
|
165
|
+
|
|
166
|
+
/** STEM_PREFIX_MIN: minimum word length for the first-word prefix signal
|
|
167
|
+
* ("auth" ≈ "authentication"). Below 4 chars the collision rate is too high
|
|
168
|
+
* ("api" ≁ "apis"). */
|
|
169
|
+
const STEM_PREFIX_MIN = 4;
|
|
170
|
+
|
|
171
|
+
/** First-word prefix equivalence: one word is a ≥ STEM_PREFIX_MIN-char prefix
|
|
172
|
+
* of the other (either direction). */
|
|
173
|
+
function isStemPrefixWord(a: string, b: string): boolean {
|
|
174
|
+
const shorter = a.length <= b.length ? a : b;
|
|
175
|
+
const longer = shorter === a ? b : a;
|
|
176
|
+
return shorter.length >= STEM_PREFIX_MIN && longer.startsWith(shorter);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Diverged-sibling guard (issue #20): true when an import node that escaped all
|
|
181
|
+
* three match layers (exact → near-match ≥ 0.75 → positional ≥ 0.5) is still
|
|
182
|
+
* recognizably the SAME concept as an existing sibling under the same parent
|
|
183
|
+
* (root children are siblings too, so a reworded PARENT is caught the same
|
|
184
|
+
* way — QA-18 F28). Three signals, checked in order:
|
|
185
|
+
*
|
|
186
|
+
* 1. TBD-fill (round 6, QA-18 F25/F26/F38 — the CANONICAL issue-#20 shape):
|
|
187
|
+
* the existing node is UNFINISHED — its trailing significant token is the
|
|
188
|
+
* TBD placeholder — and the incoming node repeats the existing node's
|
|
189
|
+
* concept head (the existing text minus the trailing TBD, e.g. "Sessions")
|
|
190
|
+
* verbatim as its leading words → the same concept filled in → conflict.
|
|
191
|
+
* In "Concept: TBD" the placeholder occupies the stem's second slot, so
|
|
192
|
+
* the stem test below can never catch a real fill; this rule is what
|
|
193
|
+
* protects the 1-word-concept nodes (22 of 25 scaffold children). It is
|
|
194
|
+
* precise: "Sign up: TBD" + "Sign in: social OAuth" does NOT fire (the
|
|
195
|
+
* concept heads differ at word 2).
|
|
196
|
+
* 2. leading stem: the first min(2, ·) significant words are identical
|
|
197
|
+
* ("sign up" == "sign up"), where the FIRST word may instead match by
|
|
198
|
+
* ≥ 4-char prefix ("auth" ≈ "authentication") — the parent-reword
|
|
199
|
+
* signal — AND
|
|
200
|
+
* 3. word-overlap corroboration: token-Jaccard ≥ 0.3, OR at least half of
|
|
201
|
+
* the EXISTING sibling's distinct significant tokens survive in the
|
|
202
|
+
* import (shared/|existing tokens| ≥ 0.5). A first-word prefix match is
|
|
203
|
+
* its own corroboration (the "Authentication" → "Auth and identity"
|
|
204
|
+
* reword shares zero whole tokens, yet is unmistakably the same parent).
|
|
205
|
+
*
|
|
206
|
+
* Why a CONJUNCTION (signals 2 + 3): the repro pair has Jaccard 0.33 while the
|
|
207
|
+
* genuinely distinct "Sign up: TBD" vs "Sign in: TBD" has Jaccard 0.50 — no
|
|
208
|
+
* Jaccard-only floor separates them. The two-word stem does ("sign up" ≠
|
|
209
|
+
* "sign in"), and the overlap metrics corroborate the stem so
|
|
210
|
+
* stem-equal-but-unrelated texts stay distinct. Either signal alone is too
|
|
211
|
+
* noisy; together they catch the reworded re-import without flagging genuinely
|
|
212
|
+
* new siblings.
|
|
213
|
+
*
|
|
214
|
+
* Callers: mergeInto, as the FINAL same-parent check before the new-node append
|
|
215
|
+
* — a hit is a conflict per §27/§35 (recorded, strategy-resolved), never a
|
|
216
|
+
* silent duplicate sibling. Exported for unit tests only.
|
|
217
|
+
*/
|
|
218
|
+
export function isDivergedSibling(existing: string, incoming: string): boolean {
|
|
219
|
+
const ea = sigTokens(existing);
|
|
220
|
+
const ib = sigTokens(incoming);
|
|
221
|
+
if (ea.length === 0 || ib.length === 0) return false;
|
|
222
|
+
|
|
223
|
+
// (1) TBD-fill: an UNFINISHED existing node whose concept head is repeated
|
|
224
|
+
// verbatim as the incoming node's leading words is the same concept filled
|
|
225
|
+
// in — fire regardless of stem/overlap (the head match IS the evidence).
|
|
226
|
+
if (ea[ea.length - 1] === TBD_TOKEN && ea.length > 1) {
|
|
227
|
+
const head = ea.slice(0, -1);
|
|
228
|
+
let headMatches = true;
|
|
229
|
+
for (let i = 0; i < head.length; i++) {
|
|
230
|
+
if (ib[i] !== head[i]) { headMatches = false; break; }
|
|
231
|
+
}
|
|
232
|
+
if (headMatches) return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// (2) leading stem; the first word may match by ≥4-char prefix (parent
|
|
236
|
+
// reword). A prefix match also corroborates (see (3)).
|
|
237
|
+
const stemLen = Math.min(DIVERGED_STEM, ea.length, ib.length);
|
|
238
|
+
let prefixCorroborated = false;
|
|
239
|
+
for (let i = 0; i < stemLen; i++) {
|
|
240
|
+
if (ea[i] === ib[i]) continue; // exact stem word
|
|
241
|
+
if (i === 0 && isStemPrefixWord(ea[i]!, ib[i]!)) {
|
|
242
|
+
prefixCorroborated = true; // "auth" ≈ "authentication" — same leading concept
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
return false; // leading stem differs → distinct concept
|
|
246
|
+
}
|
|
247
|
+
if (prefixCorroborated) return true; // the prefix is itself the corroboration
|
|
248
|
+
|
|
249
|
+
// (3) overlap corroboration, measured on the EXISTING side (docs §27).
|
|
250
|
+
const sa = new Set(ea);
|
|
251
|
+
const sb = new Set(ib);
|
|
252
|
+
let shared = 0;
|
|
253
|
+
for (const w of sa) if (sb.has(w)) shared++;
|
|
254
|
+
const union = sa.size + sb.size - shared;
|
|
255
|
+
const jaccard = union > 0 ? shared / union : 0;
|
|
256
|
+
const containment = sa.size > 0 ? shared / sa.size : 0;
|
|
257
|
+
return jaccard >= DIVERGED_JACCARD_FLOOR || containment >= DIVERGED_CONTAINMENT;
|
|
258
|
+
}
|
|
259
|
+
|
|
130
260
|
/** Source files to import: a single file, or every supported file inside a directory. */
|
|
131
261
|
function sourceFiles(path: string, format: string): string[] | null {
|
|
132
262
|
if (isFile(path)) return [path];
|
|
@@ -148,6 +278,28 @@ function mapText(nodes: ExternalNode[], f: (t: string) => string): ExternalNode[
|
|
|
148
278
|
return nodes.map((n) => ({ ...n, text: f(n.text), children: mapText(n.children, f) }));
|
|
149
279
|
}
|
|
150
280
|
|
|
281
|
+
/** Logseq/Obsidian parsers (§31) yield a FLAT indent-annotated list; the merge
|
|
282
|
+
* walk (mergeInto/mergeNodes) needs a real TREE so the sibling-level match
|
|
283
|
+
* layers (near-match, positional counterpart, diverged guard) scan the ACTUAL
|
|
284
|
+
* sibling set under the matched parent. Without this, every non-root node of
|
|
285
|
+
* a logseq/obsidian import was merged at the ROOT level: the sibling layers
|
|
286
|
+
* never fired below the root, so any diverged nested node fell through to the
|
|
287
|
+
* append branch — the silent duplicate of issue #20 (the appended node only
|
|
288
|
+
* LOOKED correctly placed because serializeToCans writes by `indent`).
|
|
289
|
+
* Stack-attach by indent, same rule as parseFromCans. OPML/Dynalist already
|
|
290
|
+
* build trees (parseOpml), so only the flat formats are normalized. */
|
|
291
|
+
function toTree(flat: ExternalNode[]): ExternalNode[] {
|
|
292
|
+
const roots: ExternalNode[] = [];
|
|
293
|
+
const stack: ExternalNode[] = [];
|
|
294
|
+
for (const n of flat) {
|
|
295
|
+
const node: ExternalNode = { ...n, children: [] };
|
|
296
|
+
while (stack.length > 0 && stack[stack.length - 1]!.indent >= node.indent) stack.pop();
|
|
297
|
+
(stack.length > 0 ? stack[stack.length - 1]!.children : roots).push(node);
|
|
298
|
+
stack.push(node);
|
|
299
|
+
}
|
|
300
|
+
return roots;
|
|
301
|
+
}
|
|
302
|
+
|
|
151
303
|
function parseSource(text: string, format: string): ExternalNode[] {
|
|
152
304
|
if (format === 'opml' || format === 'dynalist') {
|
|
153
305
|
let nodes = parseOpml(text);
|
|
@@ -160,8 +312,8 @@ function parseSource(text: string, format: string): ExternalNode[] {
|
|
|
160
312
|
}
|
|
161
313
|
return nodes;
|
|
162
314
|
}
|
|
163
|
-
if (format === 'logseq') return parseLogseq(text);
|
|
164
|
-
if (format === 'obsidian') return parseObsidian(stripFrontmatter(text));
|
|
315
|
+
if (format === 'logseq') return toTree(parseLogseq(text));
|
|
316
|
+
if (format === 'obsidian') return toTree(parseObsidian(stripFrontmatter(text)));
|
|
165
317
|
return [];
|
|
166
318
|
}
|
|
167
319
|
|
|
@@ -190,14 +342,20 @@ interface MergeOutcome {
|
|
|
190
342
|
}
|
|
191
343
|
|
|
192
344
|
/**
|
|
193
|
-
* Tree-level merge (QA-05 F8/F9). One single-pass walk of the import
|
|
194
|
-
* for each imported node, match it against the existing tree by normalized
|
|
195
|
-
* (global exact index) and, failing that, against its sibling slot by word
|
|
345
|
+
* Tree-level merge (QA-05 F8/F9, issue #20). One single-pass walk of the import
|
|
346
|
+
* tree: for each imported node, match it against the existing tree by normalized
|
|
347
|
+
* text (global exact index) and, failing that, against its sibling slot by word
|
|
196
348
|
* overlap; brand-new nodes are inserted under the CORRECT parent so tree
|
|
197
349
|
* position is preserved (the old flat-append corrupts the hierarchy).
|
|
198
350
|
* exact normalized match → conflict only if text differs
|
|
199
351
|
* (cans-wins keeps the CANS text, import-wins overwrites it)
|
|
200
352
|
* near-match (word overlap ≥ 0.75) → conflict + strategy
|
|
353
|
+
* positional counterpart (overlap ≥ 0.5) → conflict + strategy
|
|
354
|
+
* diverged sibling (TBD-fill / leading stem + word overlap, issue #20;
|
|
355
|
+
* root children are siblings too, so a diverged parent conflicts and its
|
|
356
|
+
* subtree merges under the matched parent) → conflict + strategy — same
|
|
357
|
+
* concept reworded below the overlap layers, NEVER a silent duplicate
|
|
358
|
+
* sibling appended
|
|
201
359
|
* new node → inserted under the matched/near parent; `ask` reports it, no write
|
|
202
360
|
*/
|
|
203
361
|
function mergeInto(
|
|
@@ -287,6 +445,31 @@ function mergeInto(
|
|
|
287
445
|
continue;
|
|
288
446
|
}
|
|
289
447
|
|
|
448
|
+
// Diverged-sibling guard (issue #20): all three match layers missed, but
|
|
449
|
+
// a node that is a TBD-fill of, or shares the leading stem and
|
|
450
|
+
// corroborating word overlap with, an EXISTING sibling under the same
|
|
451
|
+
// parent is the same concept reworded ("Sign up: TBD" → "Sign up: DONE -
|
|
452
|
+
// changed externally"; "Sessions: TBD" → "Sessions: extended - changed
|
|
453
|
+
// externally"; "Authentication" → "Auth and identity" at the root).
|
|
454
|
+
// Per §27/§35 that is a conflict to surface — never a silent duplicate
|
|
455
|
+
// sibling appended. cans-wins keeps the CANS text; import-wins
|
|
456
|
+
// overwrites; ask reports (no write) — same strategy semantics as the
|
|
457
|
+
// near-match and positional layers above.
|
|
458
|
+
const diverged = targetChildren.find(c => isDivergedSibling(c.text, imp.text));
|
|
459
|
+
if (diverged !== undefined) {
|
|
460
|
+
conflicts.push({
|
|
461
|
+
file: relName,
|
|
462
|
+
line: lineOfKey.get(normKey(diverged.text)) ?? 0,
|
|
463
|
+
cansVersion: diverged.text,
|
|
464
|
+
importVersion: imp.text,
|
|
465
|
+
resolution: strategy,
|
|
466
|
+
});
|
|
467
|
+
if (strategy === 'import-wins') diverged.text = imp.text;
|
|
468
|
+
// cans-wins / ask: keep the CANS version
|
|
469
|
+
mergeNodes(imp.children, diverged.children);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
290
473
|
if (strategy === 'ask') {
|
|
291
474
|
// ask = report, don't merge: the would-be addition is surfaced too,
|
|
292
475
|
// otherwise ask would silently drop new content with no trace.
|
|
@@ -337,6 +520,30 @@ async function findExistingByRootText(targetDir: string, imported: ExternalNode[
|
|
|
337
520
|
return null;
|
|
338
521
|
}
|
|
339
522
|
|
|
523
|
+
/** Merge-target fallback (round 6, QA-18 F28): when the imported ROOT itself
|
|
524
|
+
* was reworded ("- Authentication" → "- Auth and identity" in the import
|
|
525
|
+
* file), neither the slug nor the exact root-text lookup finds the original
|
|
526
|
+
* file — the import would silently fork a DUPLICATE HOME as a new spec file
|
|
527
|
+
* with conflicts: []. Root children are siblings too, so file identity gets
|
|
528
|
+
* the SAME diverged-guard semantics as the merge walk: the spec file whose
|
|
529
|
+
* ROOT is the same concept reworded (isDivergedSibling) is the merge target;
|
|
530
|
+
* the walk then records the divergence as a conflict. Deterministic first
|
|
531
|
+
* match in `discoverSpecFiles` order. */
|
|
532
|
+
async function findExistingByDivergedRootText(targetDir: string, imported: ExternalNode[]): Promise<string | null> {
|
|
533
|
+
const rootText = imported[0].text;
|
|
534
|
+
if (normKey(rootText) === '') return null;
|
|
535
|
+
for (const rel of discoverSpecFiles(targetDir)) {
|
|
536
|
+
let text = '';
|
|
537
|
+
try {
|
|
538
|
+
text = await readText(join(targetDir, rel));
|
|
539
|
+
} catch {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (parseFromCans(text).some(n => isDivergedSibling(n.text, rootText))) return rel;
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
340
547
|
/** §27/§28 (QA-09 D12): OPML/Dynalist exports carry the SOURCE SPEC FILENAME in
|
|
341
548
|
* `<head><title>` (e.g. `02-authentication.md`). When the title names a spec
|
|
342
549
|
* file it — not the first node's text — drives merge-target matching and
|
|
@@ -448,10 +655,13 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
448
655
|
// Same-slug spec already present → merge; otherwise fall back to the file
|
|
449
656
|
// already holding the first imported node's text (QA-14 F2 — a diverged
|
|
450
657
|
// re-import must land on the existing outline, not fork a duplicate home);
|
|
658
|
+
// otherwise the file whose ROOT is the same concept reworded (round 6
|
|
659
|
+
// QA-18 F28 — a reworded root still lands on the original outline);
|
|
451
660
|
// otherwise a new NN-slug.md file.
|
|
452
661
|
const existingRel =
|
|
453
662
|
findExistingBySlug(workspace, slug) ??
|
|
454
|
-
(await findExistingByRootText(workspace, imported))
|
|
663
|
+
(await findExistingByRootText(workspace, imported)) ??
|
|
664
|
+
(await findExistingByDivergedRootText(workspace, imported));
|
|
455
665
|
if (existingRel !== null) {
|
|
456
666
|
const absTarget = join(workspace, existingRel);
|
|
457
667
|
const outcome = mergeInto(
|
package/src/core/fs.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { statSync, readdirSync, existsSync, mkdirSync, type Stats } from 'fs';
|
|
2
|
-
import { join, relative, dirname, basename } from 'path';
|
|
2
|
+
import { join, relative, dirname, basename, isAbsolute } from 'path';
|
|
3
3
|
import { globFiles as runtimeGlobFiles } from './runtime.ts';
|
|
4
4
|
|
|
5
5
|
const SPEC_FILE_RE = /^\d{2}-.+\.md$/;
|
|
@@ -48,9 +48,12 @@ export function discoverSpecFiles(root: string): string[] {
|
|
|
48
48
|
return out.sort();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Detect flat-vs-folder conflicts: both
|
|
52
|
-
* §8: "Flat wins
|
|
53
|
-
*
|
|
51
|
+
/** Detect flat-vs-folder conflicts: both `<slug>.md` AND `<slug>/index.md` exist.
|
|
52
|
+
* §8/§11: "Flat wins. Both existing = error." Round 6 (QA-19 F15/F17–19):
|
|
53
|
+
* the condition is slug-AGNOSTIC — any pair (numbered `02-authentication.md` +
|
|
54
|
+
* `02-authentication/index.md`, or plain `auth.md` + `auth/index.md`) is a
|
|
55
|
+
* duplicate home; the old NN--only gate let unnumbered pairs coexist
|
|
56
|
+
* silently. Returns pairs of [flatRel, folderRel]. */
|
|
54
57
|
export function detectFlatFolderConflicts(root: string): Array<[string, string]> {
|
|
55
58
|
const conflicts: Array<[string, string]> = [];
|
|
56
59
|
if (!dirExists(root)) return conflicts;
|
|
@@ -60,7 +63,8 @@ export function detectFlatFolderConflicts(root: string): Array<[string, string]>
|
|
|
60
63
|
|
|
61
64
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
62
65
|
if (entry.name.startsWith('_') || TOOL_ARTIFACTS.has(entry.name)) continue;
|
|
63
|
-
if (entry.isFile() &&
|
|
66
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
67
|
+
// §11 both-existing: ANY spec-shaped .md file, numbered slug or not.
|
|
64
68
|
flatFiles.add(entry.name);
|
|
65
69
|
} else if (entry.isDirectory() && exists(join(root, entry.name, 'index.md'))) {
|
|
66
70
|
folderDirs.add(entry.name);
|
|
@@ -138,13 +142,29 @@ export function discoverAdrs(root: string): string[] {
|
|
|
138
142
|
.sort();
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
/**
|
|
145
|
+
/** Containment guard (issue #10, round-6 port of 3adbc91): a ref candidate
|
|
146
|
+
* must resolve INSIDE the workspace root. `../` traversal, absolute targets
|
|
147
|
+
* and root-aliasing paths (rel === "") are all rejected — null falls through
|
|
148
|
+
* to broken-ref reporting. */
|
|
149
|
+
function isInsideRoot(root: string, candidate: string): boolean {
|
|
150
|
+
const rel = relative(root, candidate);
|
|
151
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolve a ref target: flat file wins, then folder index.md. null when neither exists.
|
|
155
|
+
* Issue #22: trailing slashes are folder-target spelling, not a different
|
|
156
|
+
* path — `auth/` resolves exactly like `auth` / `auth/index.md`.
|
|
157
|
+
* Issue #10 (round-6 port of 3adbc91): the result can never point OUTSIDE
|
|
158
|
+
* `root` — an escaping candidate is rejected even when the file physically
|
|
159
|
+
* exists beyond the workspace. */
|
|
142
160
|
export function resolveSpecFile(root: string, name: string): string | null {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
const clean = name.replace(/\/+$/, '');
|
|
162
|
+
if (clean === '') return null;
|
|
163
|
+
const direct = join(root, clean);
|
|
164
|
+
if (isInsideRoot(root, direct) && exists(direct) && statSync(direct).isFile()) return direct;
|
|
165
|
+
if (clean.endsWith('.md')) {
|
|
166
|
+
const folderIdx = join(root, clean.slice(0, -3), 'index.md');
|
|
167
|
+
if (isInsideRoot(root, folderIdx) && exists(folderIdx)) return folderIdx;
|
|
148
168
|
}
|
|
149
169
|
return null;
|
|
150
170
|
}
|
package/src/core/outline.ts
CHANGED
|
@@ -242,9 +242,25 @@ export function extractBackPointers(source: string, file: string): BackPointer[]
|
|
|
242
242
|
if (fenceOpen) continue;
|
|
243
243
|
const m = lines[i]!.match(REF_BY_RE);
|
|
244
244
|
if (!m) continue;
|
|
245
|
+
// Issue #19: the comment's FORM defines what it answers, and toAnchor
|
|
246
|
+
// records it. INLINE (comment on a bullet line) → a node mark: toAnchor is
|
|
247
|
+
// the text of the node whose line carries the comment, derived exactly the
|
|
248
|
+
// way parseOutline derives node text (bullet → strip comment → strip
|
|
249
|
+
// checkbox). STANDALONE (comment on its own line) → a file-level mark:
|
|
250
|
+
// toAnchor null. `check` compares toAnchor against the referrers' anchors
|
|
251
|
+
// to decide currency; `--fix` writes anchored marks inline and file-level
|
|
252
|
+
// marks as standalone lines.
|
|
253
|
+
const bullet = lines[i]!.match(BULLET_RE);
|
|
254
|
+
let toAnchor: string | null = null;
|
|
255
|
+
if (bullet !== null) {
|
|
256
|
+
let rest = bullet[2]!.replace(REF_BY_RE, '').trim();
|
|
257
|
+
const cb = rest.match(CHECKBOX_RE);
|
|
258
|
+
if (cb !== null) rest = rest.slice(cb[0].length).trim();
|
|
259
|
+
toAnchor = rest;
|
|
260
|
+
}
|
|
245
261
|
const entries = m[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
246
262
|
for (const e of entries) {
|
|
247
|
-
out.push({ fromFile: e, fromLine: i + 1, toFile: file, toAnchor
|
|
263
|
+
out.push({ fromFile: e, fromLine: i + 1, toFile: file, toAnchor });
|
|
248
264
|
}
|
|
249
265
|
}
|
|
250
266
|
return out;
|