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/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 (deleted force_nested_above / force_sibling_below / prefer or
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
- const nestedAbove = rules.force_nested_above;
25
- if (rules.shared_prefix_detection && nestedAbove !== null && children.length >= nestedAbove) {
26
- const groups = new Map<string, number>();
27
- for (const child of children) {
28
- const word = child.text.split(/\s+/)[0] ?? '';
29
- if (word === '') continue;
30
- groups.set(word, (groups.get(word) ?? 0) + 1);
31
- }
32
- for (const [word, size] of groups) {
33
- if (size >= nestedAbove) {
34
- issues.push({
35
- file,
36
- line: node.line,
37
- level: 'warning',
38
- category: 'style',
39
- message: `${size} siblings share prefix "${word}". Group under nested style.`,
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
- // §14: "Parent with ≤ force_sibling_below leaf children → collapse to
46
- // sibling style." `≤` semantics (exactly N is flagged). The file root is
47
- // exempt — a root concept with few subtopics is the normal spec shape,
48
- // not unnecessary nesting. A single child is reported by the structure
49
- // engine ("exactly 1 child"); don't double-report it here.
50
- const siblingBelow = rules.force_sibling_below;
51
- if (
52
- siblingBelow !== null &&
53
- node.indent > 0 &&
54
- children.length >= 2 &&
55
- children.length <= siblingBelow &&
56
- children.every((c) => c.children.length === 0)
57
- ) {
58
- issues.push({
59
- file,
60
- line: node.line,
61
- level: 'warning',
62
- category: 'style',
63
- // QA-03 F17 pluralization contract (unreachable for 1 — the ≥2 guard
64
- // above plus the structure engine owns the 1-child case).
65
- message: `"${node.text}" has ${children.length} ${children.length === 1 ? 'child' : 'children'}. Collapse to sibling style.`,
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);
@@ -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
- return flattenNodes(nodes).map(n => n.text).join('\n');
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
- /** Deleted `prefer` disables prefer-driven style guidance (§18). */
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[][];
@@ -27,6 +27,7 @@ redundancy:
27
27
  word_frequency_threshold: 4
28
28
  phrase_overlap_threshold: 0.7
29
29
  cross_file_threshold: 2
30
+ fuzzy: true
30
31
  stopwords: [the, a, an, of, to, in, for, and, or, with, must, shall, requires]
31
32
  synonyms:
32
33
  - [postgres, postgresql, pg]