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/core/style.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, StyleRules } from '../types';
|
|
1
|
+
import type { OutlineNode, Issue, StyleRules } from '../types.ts';
|
|
2
|
+
import { isSyntheticNode } from './outline.ts';
|
|
2
3
|
|
|
3
|
-
/** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint
|
|
4
|
+
/** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint,
|
|
5
|
+
* modulated by `style.prefer` (issue #2 — the key was parsed and reconciled but
|
|
6
|
+
* never read, so `prefer: nested` still advised "Collapse to sibling style.").
|
|
7
|
+
*
|
|
8
|
+
* Prefer semantics:
|
|
9
|
+
* - `prefer: 'sibling'` — the author prefers flat sibling lists: the
|
|
10
|
+
* shared-prefix "Group under nested style." hint is suppressed (it argues
|
|
11
|
+
* against the declared preference); the collapse-to-sibling hint still
|
|
12
|
+
* fires (it agrees with it).
|
|
13
|
+
* - `prefer: 'nested'` — the author prefers grouped outlines: the
|
|
14
|
+
* "Collapse to sibling style." hint is suppressed; the shared-prefix
|
|
15
|
+
* grouping hint still fires.
|
|
16
|
+
* - `prefer: null` (key deleted, §18) — NO prefer-driven modulation: both
|
|
17
|
+
* base hints fire exactly as they did before the prefer wiring existed.
|
|
18
|
+
* "Deleted `prefer` disables prefer-driven style guidance" therefore means
|
|
19
|
+
* the prefer-driven MODULATION is off, not that the base guidance is off.
|
|
20
|
+
*
|
|
4
21
|
* SEVERITY NOTE (arbitration, same class as the refs-severity decision): §14/§36
|
|
5
22
|
* show ✗ for style flags, but the frozen §35/§18 fixtures (flat-project,
|
|
6
23
|
* folder-project, init templates) structurally trigger the ≤N-leaf rule and the
|
|
@@ -8,8 +25,9 @@ import type { OutlineNode, Issue, StyleRules } from '../types';
|
|
|
8
25
|
* findings stay `warning`-level. Changing test fixtures is out of bounds
|
|
9
26
|
* (test/ is frozen).
|
|
10
27
|
* §18 delete-key semantics: a style rule whose key is null/false no longer
|
|
11
|
-
* fires
|
|
12
|
-
* shared_prefix_detection: false skip their rules entirely
|
|
28
|
+
* fires — deleted force_nested_above / force_sibling_below or
|
|
29
|
+
* shared_prefix_detection: false skip their rules entirely (deleted `prefer`
|
|
30
|
+
* is the modulation-off case documented above; it skips nothing). */
|
|
13
31
|
export function checkStyle(
|
|
14
32
|
nodes: OutlineNode[],
|
|
15
33
|
file: string,
|
|
@@ -21,49 +39,64 @@ export function checkStyle(
|
|
|
21
39
|
for (const node of list) {
|
|
22
40
|
const children = node.children;
|
|
23
41
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
// Issue #8: synthetic "(table)"/"(code fence)" placeholders are not user
|
|
43
|
+
// structure — never flagged themselves (their children, if any, still
|
|
44
|
+
// are: the walk recurses below regardless).
|
|
45
|
+
if (!isSyntheticNode(node)) {
|
|
46
|
+
const nestedAbove = rules.force_nested_above;
|
|
47
|
+
// prefer: 'sibling' declares a flat-list preference — the grouping hint
|
|
48
|
+
// contradicts it and is suppressed ('nested'/null keep it firing).
|
|
49
|
+
if (
|
|
50
|
+
rules.shared_prefix_detection &&
|
|
51
|
+
rules.prefer !== 'sibling' &&
|
|
52
|
+
nestedAbove !== null &&
|
|
53
|
+
children.length >= nestedAbove
|
|
54
|
+
) {
|
|
55
|
+
const groups = new Map<string, number>();
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
const word = child.text.split(/\s+/)[0] ?? '';
|
|
58
|
+
if (word === '') continue;
|
|
59
|
+
groups.set(word, (groups.get(word) ?? 0) + 1);
|
|
60
|
+
}
|
|
61
|
+
for (const [word, size] of groups) {
|
|
62
|
+
if (size >= nestedAbove) {
|
|
63
|
+
issues.push({
|
|
64
|
+
file,
|
|
65
|
+
line: node.line,
|
|
66
|
+
level: 'warning',
|
|
67
|
+
category: 'style',
|
|
68
|
+
message: `${size} siblings share prefix "${word}". Group under nested style.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
41
71
|
}
|
|
42
72
|
}
|
|
43
|
-
}
|
|
44
73
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
// §14: "Parent with ≤ force_sibling_below leaf children → collapse to
|
|
75
|
+
// sibling style." `≤` semantics (exactly N is flagged). The file root is
|
|
76
|
+
// exempt — a root concept with few subtopics is the normal spec shape,
|
|
77
|
+
// not unnecessary nesting. A single child is reported by the structure
|
|
78
|
+
// engine ("exactly 1 child"); don't double-report it here.
|
|
79
|
+
const siblingBelow = rules.force_sibling_below;
|
|
80
|
+
// prefer: 'nested' declares a grouped-outline preference — the collapse
|
|
81
|
+
// hint contradicts it and is suppressed ('sibling'/null keep it firing).
|
|
82
|
+
if (
|
|
83
|
+
siblingBelow !== null &&
|
|
84
|
+
rules.prefer !== 'nested' &&
|
|
85
|
+
node.indent > 0 &&
|
|
86
|
+
children.length >= 2 &&
|
|
87
|
+
children.length <= siblingBelow &&
|
|
88
|
+
children.every((c) => c.children.length === 0)
|
|
89
|
+
) {
|
|
90
|
+
issues.push({
|
|
91
|
+
file,
|
|
92
|
+
line: node.line,
|
|
93
|
+
level: 'warning',
|
|
94
|
+
category: 'style',
|
|
95
|
+
// QA-03 F17 pluralization contract (unreachable for 1 — the ≥2 guard
|
|
96
|
+
// above plus the structure engine owns the 1-child case).
|
|
97
|
+
message: `"${node.text}" has ${children.length} ${children.length === 1 ? 'child' : 'children'}. Collapse to sibling style.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
67
100
|
}
|
|
68
101
|
|
|
69
102
|
walk(children);
|
package/src/core/token-budget.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { basename, relative } from 'path';
|
|
|
3
3
|
import type {
|
|
4
4
|
OutlineNode, BackPointer, TokenBudgetRules,
|
|
5
5
|
BudgetReadPlanItem, BudgetReadResult, BudgetWriteResult,
|
|
6
|
-
} from '../types';
|
|
7
|
-
import { flattenNodes, parseOutline } from './outline';
|
|
8
|
-
import { targetMatchesKey } from './refs';
|
|
6
|
+
} from '../types.ts';
|
|
7
|
+
import { flattenNodes, parseOutline, isSyntheticNode } from './outline.ts';
|
|
8
|
+
import { targetMatchesKey } from './refs.ts';
|
|
9
9
|
|
|
10
10
|
export function estimateTokens(text: string, charsPerToken: number): number {
|
|
11
11
|
return Math.ceil(text.length / charsPerToken);
|
|
@@ -19,7 +19,10 @@ function relPath(file: string): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function serializedNodeText(nodes: OutlineNode[]): string {
|
|
22
|
-
|
|
22
|
+
// Issue #8: synthetic "(table)"/"(code fence)" placeholders are not content —
|
|
23
|
+
// including them inflated every token estimate for files opening with a
|
|
24
|
+
// table/fence.
|
|
25
|
+
return flattenNodes(nodes).filter(n => !isSyntheticNode(n)).map(n => n.text).join('\n');
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/** Token estimate for a workspace key (loaded nodes) or a raw file path (content read). */
|
|
@@ -53,6 +56,8 @@ export function findCanonicalHome(
|
|
|
53
56
|
const candidates: Array<{ file: string; node: OutlineNode }> = [];
|
|
54
57
|
for (const [file, nodes] of allFiles) {
|
|
55
58
|
for (const node of flattenNodes(nodes)) {
|
|
59
|
+
// Issue #8: a synthetic placeholder must never be the canonical home.
|
|
60
|
+
if (isSyntheticNode(node)) continue;
|
|
56
61
|
if (node.text.toLowerCase().includes(lc)) candidates.push({ file, node });
|
|
57
62
|
}
|
|
58
63
|
}
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,13 @@ export interface OutlineNode {
|
|
|
20
20
|
refs: RefTarget[];
|
|
21
21
|
hasCodeFence: boolean;
|
|
22
22
|
hasTable: boolean;
|
|
23
|
+
/** Issue #8: true ONLY for parser-created placeholder nodes (text "(table)"
|
|
24
|
+
* or "(code fence)") that represent a table/fence appearing BEFORE the
|
|
25
|
+
* first bullet of a file. They carry the hasTable/hasCodeFence signal but
|
|
26
|
+
* are not user content — consumers that count nodes or compare node text
|
|
27
|
+
* as content must exclude them (isSyntheticNode). Never set on nodes
|
|
28
|
+
* parsed from real bullets. */
|
|
29
|
+
synthetic?: boolean;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export interface BackPointer {
|
|
@@ -63,7 +70,9 @@ export interface StructureRules {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
export interface StyleRules {
|
|
66
|
-
/**
|
|
73
|
+
/** Style-guide selector: `sibling` suppresses the nested-grouping hint,
|
|
74
|
+
* `nested` suppresses the sibling-collapse hint; null (deleted, §18) → no
|
|
75
|
+
* prefer-driven modulation — both base style hints fire unchanged. */
|
|
67
76
|
prefer: 'sibling' | 'nested' | null;
|
|
68
77
|
force_nested_above: number | null;
|
|
69
78
|
force_sibling_below: number | null;
|
|
@@ -90,6 +99,8 @@ export interface RedundancyRules {
|
|
|
90
99
|
word_frequency_threshold: number | null;
|
|
91
100
|
phrase_overlap_threshold: number | null;
|
|
92
101
|
cross_file_threshold: number | null;
|
|
102
|
+
/** Layer 3 switch: deleted → false (§18 delete-key semantics). */
|
|
103
|
+
fuzzy: boolean;
|
|
93
104
|
/** Parameters (not checks): keep their defaults when deleted (§18). */
|
|
94
105
|
stopwords: string[];
|
|
95
106
|
synonyms: string[][];
|
package/templates/_rules.yaml
CHANGED