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.
- package/README.md +17 -5
- package/bin/cans.js +76 -0
- package/bin/ts-loader.mjs +34 -0
- package/package.json +8 -5
- package/src/cli.ts +19 -15
- package/src/commands/budget.ts +8 -7
- package/src/commands/check.ts +83 -21
- package/src/commands/done.ts +7 -6
- package/src/commands/export.ts +9 -8
- package/src/commands/import.ts +14 -13
- package/src/commands/init.ts +7 -6
- package/src/commands/new.ts +9 -8
- package/src/commands/status.ts +6 -5
- package/src/converters/index.ts +4 -4
- package/src/converters/logseq.ts +2 -2
- package/src/converters/obsidian.ts +2 -2
- package/src/converters/opml.ts +1 -1
- package/src/converters/shared.ts +1 -1
- package/src/core/fs.ts +3 -4
- package/src/core/index.ts +10 -10
- package/src/core/outline.ts +46 -8
- package/src/core/output.ts +1 -1
- package/src/core/overflow.ts +2 -2
- package/src/core/redundancy.ts +59 -5
- package/src/core/refs.ts +169 -27
- package/src/core/rules.ts +33 -6
- package/src/core/runtime.ts +110 -0
- package/src/core/structure.ts +113 -58
- package/src/core/style.ts +77 -44
- package/src/core/token-budget.ts +9 -4
- package/src/types.ts +12 -1
- package/templates/_rules.yaml +1 -0
package/src/commands/new.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { join, basename } from 'path';
|
|
2
|
-
import type { NewResult } from '../types';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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.
|
|
7
|
+
const TEMPLATES_DIR = join(dirFromUrl(import.meta.url), '..', '..', 'templates');
|
|
7
8
|
|
|
8
9
|
async function readTemplate(name: string): Promise<string> {
|
|
9
|
-
return await
|
|
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
|
|
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
|
|
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
|
|
137
|
+
await writeText(join(workspace, file), content);
|
|
137
138
|
return { ok: true, command: 'new', exitCode: 0, change: slug, file };
|
|
138
139
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/converters/index.ts
CHANGED
|
@@ -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';
|
package/src/converters/logseq.ts
CHANGED
|
@@ -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 {
|
package/src/converters/opml.ts
CHANGED
package/src/converters/shared.ts
CHANGED
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
|
-
|
|
30
|
-
|
|
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';
|
package/src/core/outline.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
186
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/output.ts
CHANGED
|
@@ -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
|
package/src/core/overflow.ts
CHANGED
|
@@ -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 —
|
package/src/core/redundancy.ts
CHANGED
|
@@ -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
|
}
|