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.
@@ -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 tree:
194
- * for each imported node, match it against the existing tree by normalized text
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 `NN-name.md` AND `NN-name/index.md` exist.
52
- * §8: "Flat wins over folder. If both exist, `cans check` flags error."
53
- * Returns pairs of [flatRel, folderRel]. */
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() && SPEC_FILE_RE.test(entry.name)) {
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
- /** Resolve a ref target: flat file wins, then folder index.md. null when neither exists. */
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 direct = join(root, name);
144
- if (exists(direct) && statSync(direct).isFile()) return direct;
145
- if (name.endsWith('.md')) {
146
- const folderIdx = join(root, name.slice(0, -3), 'index.md');
147
- if (exists(folderIdx)) return folderIdx;
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
  }
@@ -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: null });
263
+ out.push({ fromFile: e, fromLine: i + 1, toFile: file, toAnchor });
248
264
  }
249
265
  }
250
266
  return out;