cans-spec 0.1.2 → 0.3.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.
@@ -1,12 +1,13 @@
1
1
  import { join, basename } from 'path';
2
- import type { NewResult } from '../types';
3
- import { resolveWorkspaceRoot, resolveWorkspaceOrCreate, mkdirp, discoverAdrs, dirExists, isFile } from '../core/fs';
4
- import { parseArgs, type FlagSpec } from '../core/args';
2
+ import type { NewResult } from '../types.ts';
3
+ import { readText, writeText, dirFromUrl } from '../core/runtime.ts';
4
+ import { resolveWorkspaceRoot, resolveWorkspaceOrCreate, mkdirp, discoverAdrs, dirExists, isFile } from '../core/fs.ts';
5
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
5
6
 
6
- const TEMPLATES_DIR = join(import.meta.dir, '..', '..', 'templates');
7
+ const TEMPLATES_DIR = join(dirFromUrl(import.meta.url), '..', '..', 'templates');
7
8
 
8
9
  async function readTemplate(name: string): Promise<string> {
9
- return await Bun.file(join(TEMPLATES_DIR, name)).text();
10
+ return await readText(join(TEMPLATES_DIR, name));
10
11
  }
11
12
 
12
13
  /** lowercase → strip double quotes → non-alphanumeric runs → hyphens → trim hyphens.
@@ -58,7 +59,7 @@ async function existingContentGuard(
58
59
  change: string,
59
60
  ): Promise<NewResult | null> {
60
61
  if (!isFile(abs)) return null;
61
- const existing = await Bun.file(abs).text();
62
+ const existing = await readText(abs);
62
63
  if (existing === content) {
63
64
  return { ok: true, command: 'new', exitCode: 0, change, file };
64
65
  }
@@ -116,7 +117,7 @@ export async function run(args: string[]): Promise<NewResult> {
116
117
  const file = join('_tasks', `${slug}.md`);
117
118
  const guard = await existingContentGuard(join(workspace, file), file, content, slug);
118
119
  if (guard !== null) return guard;
119
- await Bun.write(join(workspace, file), content);
120
+ await writeText(join(workspace, file), content);
120
121
  return { ok: true, command: 'new', exitCode: 0, change: slug, file };
121
122
  }
122
123
 
@@ -133,6 +134,6 @@ export async function run(args: string[]): Promise<NewResult> {
133
134
  const file = join('_adr', `${NNN}-${slug}.md`);
134
135
  const guard = await existingContentGuard(join(workspace, file), file, content, slug);
135
136
  if (guard !== null) return guard;
136
- await Bun.write(join(workspace, file), content);
137
+ await writeText(join(workspace, file), content);
137
138
  return { ok: true, command: 'new', exitCode: 0, change: slug, file };
138
139
  }
@@ -1,12 +1,13 @@
1
1
  import { join, basename } from 'path';
2
2
  import { readFileSync } from 'fs';
3
- import type { StatusResult, OutlineNode } from '../types';
3
+ import type { StatusResult, OutlineNode } from '../types.ts';
4
+ import { readText } from '../core/runtime.ts';
4
5
  import {
5
6
  resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks,
6
7
  discoverArchivedTasks, discoverAdrs, dirExists,
7
- } from '../core/fs';
8
- import { parseOutline, flattenNodes } from '../core/outline';
9
- import { parseArgs, type FlagSpec } from '../core/args';
8
+ } from '../core/fs.ts';
9
+ import { parseOutline, flattenNodes } from '../core/outline.ts';
10
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
10
11
 
11
12
  export interface StatusArgs {
12
13
  unclaimed: boolean;
@@ -100,7 +101,7 @@ export async function run(args: string[]): Promise<StatusResult> {
100
101
  for (const rel of activeTasks) {
101
102
  let flat: OutlineNode[] = [];
102
103
  try {
103
- flat = flattenNodes(parseOutline(await Bun.file(join(workspace, rel)).text(), rel));
104
+ flat = flattenNodes(parseOutline(await readText(join(workspace, rel)), rel));
104
105
  } catch {
105
106
  // unparsable task file: contributes nothing but its existence
106
107
  }
@@ -1,4 +1,4 @@
1
- export * from './shared';
2
- export * from './opml';
3
- export * from './logseq';
4
- export * from './obsidian';
1
+ export * from './shared.ts';
2
+ export * from './opml.ts';
3
+ export * from './logseq.ts';
4
+ export * from './obsidian.ts';
@@ -1,8 +1,8 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
  import {
3
3
  convertOwnerMarkers, convertWikiLinks, logseqSlashLinks, parseCheckbox,
4
4
  parseIndent, reverseWikiLinks, stripMetadata,
5
- } from './shared';
5
+ } from './shared.ts';
6
6
 
7
7
  /** Logseq page → flat ExternalNode list (document order; hierarchy via `indent`).
8
8
  * Drops pure `key:: value` property lines (keys may contain spaces — only `::`
@@ -1,8 +1,8 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
  import {
3
3
  convertOwnerMarkers, convertWikiLinks, parseCheckbox, parseIndent,
4
4
  reverseWikiLinks, stripMetadata,
5
- } from './shared';
5
+ } from './shared.ts';
6
6
 
7
7
  /** Remove a leading YAML frontmatter block (`---` fences at very top), fences included. */
8
8
  export function stripFrontmatter(source: string): string {
@@ -1,4 +1,4 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
 
3
3
  /** Escape the five XML entities. `&` first so output is never double-encoded. */
4
4
  export function encodeXmlEntity(s: string): string {
@@ -1,4 +1,4 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
 
3
3
  /** Leading whitespace → indent units. 2 spaces per level; each tab counts as 2 spaces. */
4
4
  export function parseIndent(raw: string): number {
package/src/core/fs.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { statSync, readdirSync, existsSync, mkdirSync, type Stats } from 'fs';
2
2
  import { join, relative, dirname, basename } from 'path';
3
+ import { globFiles as runtimeGlobFiles } from './runtime.ts';
3
4
 
4
5
  const SPEC_FILE_RE = /^\d{2}-.+\.md$/;
5
6
 
@@ -26,10 +27,8 @@ export function mkdirp(p: string): void {
26
27
  }
27
28
 
28
29
  export function globFiles(dir: string, pattern: string): string[] {
29
- if (!dirExists(dir)) return [];
30
- const g = new Bun.Glob(pattern);
31
- const out = [...g.scanSync({ cwd: dir, onlyFiles: true })] as string[];
32
- return out.sort();
30
+ // Delegated to the runtime shim (issue #12): Bun fast path, node:fs fallback.
31
+ return runtimeGlobFiles(dir, pattern);
33
32
  }
34
33
 
35
34
  /** Spec files: root-level *.md (excluding _-prefixed, AGENTS.md and other tool
package/src/core/index.ts CHANGED
@@ -1,10 +1,10 @@
1
- export * from './output';
2
- export * from './fs';
3
- export * from './outline';
4
- export * from './refs';
5
- export * from './structure';
6
- export * from './style';
7
- export * from './redundancy';
8
- export * from './overflow';
9
- export * from './rules';
10
- export * from './token-budget';
1
+ export * from './output.ts';
2
+ export * from './fs.ts';
3
+ export * from './outline.ts';
4
+ export * from './refs.ts';
5
+ export * from './structure.ts';
6
+ export * from './style.ts';
7
+ export * from './redundancy.ts';
8
+ export * from './overflow.ts';
9
+ export * from './rules.ts';
10
+ export * from './token-budget.ts';
@@ -1,4 +1,4 @@
1
- import type { OutlineNode, BackPointer, RefTarget } from '../types';
1
+ import type { OutlineNode, BackPointer, RefTarget } from '../types.ts';
2
2
 
3
3
  const BULLET_RE = /^(\s*)-\s+(.*)$/;
4
4
  const CHECKBOX_RE = /^\[( |x|X)\]\s+/;
@@ -55,6 +55,9 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
55
55
  refs: [],
56
56
  hasCodeFence: false,
57
57
  hasTable: false,
58
+ // Issue #8: real bullets are never synthetic; the two placeholder
59
+ // synthesis sites below flip this to true after makeNode.
60
+ synthetic: false,
58
61
  });
59
62
 
60
63
  for (let i = 0; i < lines.length; i++) {
@@ -78,6 +81,7 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
78
81
  } else {
79
82
  const n = makeNode('(code fence)', fenceStartLine, 0);
80
83
  n.hasCodeFence = true;
84
+ n.synthetic = true; // issue #8: placeholder, not user content
81
85
  roots.push(n);
82
86
  stack.length = 0;
83
87
  stack.push(n);
@@ -97,6 +101,7 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
97
101
  } else {
98
102
  const n = makeNode('(table)', lineNo, 0);
99
103
  n.hasTable = true;
104
+ n.synthetic = true; // issue #8: placeholder, not user content
100
105
  roots.push(n);
101
106
  stack.length = 0;
102
107
  stack.push(n);
@@ -170,20 +175,29 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
170
175
  top.children.push(node);
171
176
  stack.push(node);
172
177
  } else {
173
- // shallower: pop until we find the parent level
174
- while (stack.length > 1 && stack[stack.length - 1].indent > indent) {
178
+ // shallower: pop until we find the parent level — all the way to an
179
+ // empty stack, not just down to stack[0] (issue #7). The stack bottom
180
+ // is only a real parent when the file's first bullet sits at column
181
+ // 0; when it opens indented (e.g. under a `#` heading) its indent
182
+ // must not swallow bullets that dedent past it, or the whole tree
183
+ // silently re-parents under that first node.
184
+ while (stack.length > 0 && stack[stack.length - 1].indent > indent) {
175
185
  stack.pop();
176
186
  }
177
- const candidate = stack[stack.length - 1];
178
- if (candidate.indent === indent) {
187
+ if (stack.length === 0) {
188
+ // dedented past every open node: a root sibling
189
+ roots.push(node);
190
+ stack.push(node);
191
+ } else if (stack[stack.length - 1].indent === indent) {
192
+ // sibling of the deepest open node at this level
179
193
  stack.pop();
180
194
  const parent = stack[stack.length - 1];
181
195
  if (parent) parent.children.push(node);
182
196
  else roots.push(node);
183
197
  stack.push(node);
184
198
  } else {
185
- // indented jump deeper than expected under candidate
186
- candidate.children.push(node);
199
+ // indented jump deeper than expected under that node
200
+ stack[stack.length - 1].children.push(node);
187
201
  stack.push(node);
188
202
  }
189
203
  }
@@ -194,6 +208,12 @@ export function parseOutline(source: string, file: string, warnings?: ParseWarni
194
208
  return roots;
195
209
  }
196
210
 
211
+ /** Issue #8: true only for parser-created placeholder nodes ("(table)" /
212
+ * "(code fence)") representing a leading table/fence — never user content. */
213
+ export function isSyntheticNode(n: OutlineNode): boolean {
214
+ return n.synthetic === true;
215
+ }
216
+
197
217
  export function flattenNodes(nodes: OutlineNode[]): OutlineNode[] {
198
218
  const out: OutlineNode[] = [];
199
219
  const walk = (ns: OutlineNode[]): void => {
@@ -209,8 +229,18 @@ export function flattenNodes(nodes: OutlineNode[]): OutlineNode[] {
209
229
  export function extractBackPointers(source: string, file: string): BackPointer[] {
210
230
  const out: BackPointer[] = [];
211
231
  const lines = normalizeEol(source).split('\n');
232
+ // Issue #6: fence awareness — a `<!-- ref-by: ... -->` quoted inside a fenced
233
+ // example is documentation, not a real back-pointer. Same toggle rule as
234
+ // parseOutline (trimmed line starts with ```); the marker line itself is
235
+ // fence infrastructure and never carries a counted comment either.
236
+ let fenceOpen = false;
212
237
  for (let i = 0; i < lines.length; i++) {
213
- const m = lines[i].match(REF_BY_RE);
238
+ if (FENCE_RE.test(lines[i]!.trim())) {
239
+ fenceOpen = !fenceOpen;
240
+ continue;
241
+ }
242
+ if (fenceOpen) continue;
243
+ const m = lines[i]!.match(REF_BY_RE);
214
244
  if (!m) continue;
215
245
  const entries = m[1].split(',').map(s => s.trim()).filter(Boolean);
216
246
  for (const e of entries) {
@@ -220,6 +250,14 @@ export function extractBackPointers(source: string, file: string): BackPointer[]
220
250
  return out;
221
251
  }
222
252
 
253
+ /** Flattened tree WITHOUT synthetic placeholder nodes — the view every
254
+ * node-count and content-comparison consumer should use (issue #8).
255
+ * flattenNodes itself is unchanged: the tree shape keeps the placeholders
256
+ * (they carry hasTable/hasCodeFence for the overflow engine). */
257
+ export function realNodes(nodes: OutlineNode[]): OutlineNode[] {
258
+ return flattenNodes(nodes).filter(n => !isSyntheticNode(n));
259
+ }
260
+
223
261
  export function countNodes(nodes: OutlineNode[]): number {
224
262
  return flattenNodes(nodes).length;
225
263
  }
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  CommandResult, CheckResult, Issue, InitResult, NewResult, DoneResult, StatusResult,
3
3
  BudgetReadResult, BudgetWriteResult, ImportResult, ExportResult, VersionResult,
4
- } from '../types';
4
+ } from '../types.ts';
5
5
 
6
6
  /** Single emission point. Commands never console.log or process.exit directly.
7
7
  * `refsOnly` (check only, §22/§36): human output is scoped to the References
@@ -1,5 +1,5 @@
1
- import type { OutlineNode, Issue, OverflowRules } from '../types';
2
- import { flattenNodes } from './outline';
1
+ import type { OutlineNode, Issue, OverflowRules } from '../types.ts';
2
+ import { flattenNodes } from './outline.ts';
3
3
 
4
4
  /** Overflow checks: code fences, tables, over-long nodes. All errors.
5
5
  * §18: `force_file_for` lists the content categories forced into files —
@@ -1,5 +1,5 @@
1
- import type { OutlineNode, Issue, RedundancyRules } from '../types';
2
- import { flattenNodes } from './outline';
1
+ import type { OutlineNode, Issue, RedundancyRules } from '../types.ts';
2
+ import { flattenNodes, isSyntheticNode } from './outline.ts';
3
3
 
4
4
  interface NodeRef {
5
5
  text: string;
@@ -133,21 +133,63 @@ export function phraseOverlap(
133
133
  return issues;
134
134
  }
135
135
 
136
+ /** Light English suffixes for the inflection check (issue #3), longest first —
137
+ * stripped iteratively from the end so "carries" → "carri" (→ i↔y → "carry"). */
138
+ const INFLECTION_SUFFIXES = ['ing', 'ers', 'er', 'ed', 'es', 'ly', 's'];
139
+
140
+ /** Reduce a word to a rough stem by iteratively stripping common English
141
+ * suffixes, then a trailing `e`, folding a trailing `i` onto `y` (so
142
+ * deny/denied → deny, approve/approved → approv). Deliberately NOT a real
143
+ * stemmer — only good enough to recognize pure suffix inflections. */
144
+ function lightStem(word: string): string {
145
+ let w = word;
146
+ for (;;) {
147
+ const suffix = INFLECTION_SUFFIXES.find(s => w.length > s.length && w.endsWith(s));
148
+ if (suffix === undefined) break;
149
+ w = w.slice(0, w.length - suffix.length);
150
+ }
151
+ if (w.endsWith('i')) w = `${w.slice(0, -1)}y`;
152
+ if (w.endsWith('e')) w = w.slice(0, -1);
153
+ return w;
154
+ }
155
+
156
+ /** Issue #3: is `a` a pure suffix-inflection of `b` (or vice versa)? Both words
157
+ * are reduced with lightStem; equal stems — or a stem equal to the other word's
158
+ * unstemmed form — mean the pair differs only by an English inflection
159
+ * (approve/approved, session/sessions, deny/denied), not by a typo. Stems
160
+ * shorter than 3 chars are treated as unsafe and the pair is NOT skipped
161
+ * (e.g. sing/singe stays a typo candidate). Genuine near-misses with no suffix
162
+ * relation (flavour/flavor, table/tabble) keep different stems → still flagged. */
163
+ export function isInflectionOf(a: string, b: string): boolean {
164
+ const sa = lightStem(a);
165
+ const sb = lightStem(b);
166
+ if (sa.length < 3 || sb.length < 3) return false;
167
+ return sa === sb || sa === b || sb === a;
168
+ }
169
+
136
170
  /** Layer 3 — near-miss word forms (Levenshtein <= 2, both words > 4 chars) → possible typo.
137
171
  * §13: "NOT ALREADY SYNONYM-MATCHED" — words are normalized with the rules'
138
172
  * synonym groups first, so members of the same group collapse to one word and
139
- * never pair up as typos. */
173
+ * never pair up as typos. Issue #3: the layer now applies the SAME collection
174
+ * filter as the other layers — the configured `redundancy.stopwords` and the
175
+ * §8/§13 ref-syntax tokens (`see`, `md`) are never collected — and skips
176
+ * candidate pairs that are pure suffix inflections of each other
177
+ * (isInflectionOf) before the Levenshtein comparison, so English inflections
178
+ * (approved/approve, sessions/session) are no longer reported as typos. */
140
179
  export function fuzzyDistance(
141
180
  nodes: NodeRef[],
142
181
  rules?: RedundancyRules,
143
182
  ): Issue[] {
144
183
  const synonyms = rules ? rules.synonyms : [];
184
+ const stopwords = rules ? rules.stopwords : [];
145
185
  const words: NodeRef[] = [];
146
186
  const seen = new Set<string>();
147
187
  for (const node of nodes) {
148
188
  for (const raw of tokenize(node.text)) {
149
189
  const w = normalizeWord(raw, synonyms);
150
190
  if (w.length === 0 || seen.has(w)) continue;
191
+ if (REF_SYNTAX_TOKENS.has(w)) continue;
192
+ if (stopwords.includes(w)) continue;
151
193
  seen.add(w);
152
194
  words.push({ text: w, file: node.file, line: node.line });
153
195
  }
@@ -159,6 +201,7 @@ export function fuzzyDistance(
159
201
  const b = words[j];
160
202
  if (a.text.length <= 4 || b.text.length <= 4) continue;
161
203
  if (Math.abs(a.text.length - b.text.length) > 2) continue;
204
+ if (isInflectionOf(a.text, b.text)) continue;
162
205
  const d = levenshtein(a.text, b.text);
163
206
  if (d <= 2) {
164
207
  issues.push({
@@ -208,6 +251,10 @@ export function crossFileCanonicality(
208
251
  const concepts = new Map<string, { files: Set<string>; first: NodeRef }>();
209
252
  for (const [key, nodes] of allFiles) {
210
253
  for (const node of flattenNodes(nodes)) {
254
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
255
+ // concepts — comparing them across files fabricated "canonical home"
256
+ // warnings with nonsense advice.
257
+ if (isSyntheticNode(node)) continue;
211
258
  if (node.indent > 1) continue;
212
259
  const text = node.text.trim().toLowerCase();
213
260
  if (text.length === 0) continue;
@@ -240,7 +287,9 @@ export function crossFileCanonicality(
240
287
  }
241
288
 
242
289
  /** All four redundancy layers over every loaded spec node.
243
- * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4. */
290
+ * `duplicateHomeCheck` (§18 references.duplicate_home_check) gates layer 4.
291
+ * Issue #3: `redundancy.fuzzy` (§18 delete-key semantics: deleted → false)
292
+ * gates layer 3 independently of `enabled`, which still covers all layers. */
244
293
  export function checkRedundancy(
245
294
  allFiles: Map<string, OutlineNode[]>,
246
295
  rules: RedundancyRules,
@@ -249,13 +298,18 @@ export function checkRedundancy(
249
298
  const nodes: NodeRef[] = [];
250
299
  for (const [file, tree] of allFiles) {
251
300
  for (const node of flattenNodes(tree)) {
301
+ // Issue #8: synthetic "(table)"/"(code fence)" placeholders are not
302
+ // content — excluded from all three text-comparison layers (they made
303
+ // any two table-opening files 100%-overlapping and inflated the
304
+ // "table"/"code"/"fence" word-frequency counts).
305
+ if (isSyntheticNode(node)) continue;
252
306
  nodes.push({ text: node.text, file, line: node.line });
253
307
  }
254
308
  }
255
309
  return [
256
310
  ...wordFrequency(nodes, rules),
257
311
  ...phraseOverlap(nodes, rules.phrase_overlap_threshold, rules),
258
- ...fuzzyDistance(nodes, rules),
312
+ ...(rules.fuzzy !== false ? fuzzyDistance(nodes, rules) : []),
259
313
  ...(duplicateHomeCheck ? crossFileCanonicality(allFiles, rules.cross_file_threshold) : []),
260
314
  ];
261
315
  }