cans-spec 0.2.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.
- package/README.md +15 -6
- package/bin/cans.js +87 -14
- package/bin/ts-loader.mjs +9 -2
- package/package.json +2 -2
- package/src/cli.ts +6 -1
- package/src/commands/budget.ts +87 -16
- package/src/commands/check.ts +361 -45
- package/src/commands/import.ts +216 -6
- package/src/core/fs.ts +31 -11
- package/src/core/outline.ts +62 -8
- package/src/core/output.ts +202 -57
- package/src/core/overflow.ts +4 -0
- package/src/core/redundancy.ts +63 -4
- package/src/core/refs.ts +356 -42
- package/src/core/report.ts +575 -0
- package/src/core/rules.ts +32 -5
- package/src/core/structure.ts +122 -57
- package/src/core/style.ts +78 -43
- package/src/core/token-budget.ts +41 -5
- package/src/types.ts +29 -1
- package/templates/_rules.yaml +1 -0
package/src/core/structure.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types.ts';
|
|
2
|
-
import { flattenNodes } from './outline.ts';
|
|
2
|
+
import { flattenNodes, isSyntheticNode } from './outline.ts';
|
|
3
3
|
|
|
4
4
|
/** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
|
|
5
|
+
* Both sides of every range are enforced (issue #1 — siblings.min and depth.min
|
|
6
|
+
* were banner-checked but never enforced): node_length, siblings and depth each
|
|
7
|
+
* check their `min` (warning) as well as their `max`.
|
|
5
8
|
* §18 delete-key semantics: a check whose rules key is null/false is OFF — the
|
|
6
9
|
* check is skipped entirely (never compared against null, which would coerce
|
|
7
10
|
* to 0 and flag everything). */
|
|
@@ -14,75 +17,135 @@ export function checkStructure(
|
|
|
14
17
|
|
|
15
18
|
const walk = (list: OutlineNode[]): void => {
|
|
16
19
|
for (const node of list) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
20
|
+
// Issue #8: synthetic "(table)"/"(code fence)" placeholders are not user
|
|
21
|
+
// structure — never flagged themselves (their children, if any, still
|
|
22
|
+
// are: the walk recurses below regardless).
|
|
23
|
+
if (!isSyntheticNode(node)) {
|
|
24
|
+
const len = node.text.length;
|
|
25
|
+
const nl = rules.node_length;
|
|
26
|
+
if (nl !== null && nl.max !== null && len > nl.max) {
|
|
27
|
+
issues.push({
|
|
28
|
+
file,
|
|
29
|
+
line: node.line,
|
|
30
|
+
level: 'error',
|
|
31
|
+
category: 'structure',
|
|
32
|
+
message: `Node too long (${len} > ${nl.max}). Split or move to file.`,
|
|
33
|
+
rule: 'structure.node_length.max', // issue #41: machine-readable rule key
|
|
34
|
+
});
|
|
35
|
+
} else if (nl !== null && nl.min !== null && len < nl.min) {
|
|
36
|
+
issues.push({
|
|
37
|
+
file,
|
|
38
|
+
line: node.line,
|
|
39
|
+
level: 'warning',
|
|
40
|
+
category: 'structure',
|
|
41
|
+
message: `Node too short (${len} < ${nl.min}).`,
|
|
42
|
+
rule: 'structure.node_length.min', // issue #41: machine-readable rule key
|
|
43
|
+
});
|
|
44
|
+
}
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const depth = node.indent + 1;
|
|
47
|
+
const depthMax = rules.depth !== null ? rules.depth.max : null;
|
|
48
|
+
if (depthMax !== null && depth > depthMax) {
|
|
49
|
+
issues.push({
|
|
50
|
+
file,
|
|
51
|
+
line: node.line,
|
|
52
|
+
level: 'error',
|
|
53
|
+
category: 'structure',
|
|
54
|
+
message: `Depth ${depth} exceeds max ${depthMax}. Flatten.`,
|
|
55
|
+
rule: 'structure.depth.max', // issue #41: machine-readable rule key
|
|
56
|
+
});
|
|
57
|
+
}
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const count = node.children.length;
|
|
60
|
+
const siblingsMax = rules.siblings !== null ? rules.siblings.max : null;
|
|
61
|
+
if (siblingsMax !== null && count > siblingsMax) {
|
|
62
|
+
issues.push({
|
|
63
|
+
file,
|
|
64
|
+
line: node.line,
|
|
65
|
+
level: 'warning',
|
|
66
|
+
category: 'structure',
|
|
67
|
+
message: `"${node.text}" has ${count} children (max ${siblingsMax}).`,
|
|
68
|
+
rule: 'structure.siblings.max', // issue #41: machine-readable rule key
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Issue #1: enforce siblings.min — a parent with 0 < count < min children
|
|
72
|
+
// is under the configured fan-out. Warning level, consistent with the
|
|
73
|
+
// siblings.max side above. The single_child_collapse advisory below is a
|
|
74
|
+
// separate check and may fire for the same node — that is acceptable.
|
|
75
|
+
const siblingsMin = rules.siblings !== null ? rules.siblings.min : null;
|
|
76
|
+
if (siblingsMin !== null && count > 0 && count < siblingsMin) {
|
|
77
|
+
issues.push({
|
|
78
|
+
file,
|
|
79
|
+
line: node.line,
|
|
80
|
+
level: 'warning',
|
|
81
|
+
category: 'structure',
|
|
82
|
+
message: `"${node.text}" has ${count} children (min ${siblingsMin}).`,
|
|
83
|
+
rule: 'structure.siblings.min', // issue #41: machine-readable rule key
|
|
84
|
+
});
|
|
85
|
+
}
|
|
60
86
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
if (rules.single_child_collapse && count === 1) {
|
|
88
|
+
issues.push({
|
|
89
|
+
file,
|
|
90
|
+
line: node.line,
|
|
91
|
+
level: 'warning',
|
|
92
|
+
category: 'structure',
|
|
93
|
+
message: `"${node.text}" has exactly 1 child. Collapse.`,
|
|
94
|
+
rule: 'structure.single_child', // issue #41: machine-readable rule key
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (rules.empty_nodes && node.text.trim() === '') {
|
|
99
|
+
issues.push({
|
|
100
|
+
file,
|
|
101
|
+
line: node.line,
|
|
102
|
+
level: 'warning',
|
|
103
|
+
category: 'structure',
|
|
104
|
+
message: 'Empty node.',
|
|
105
|
+
rule: 'structure.empty_node', // issue #41: machine-readable rule key
|
|
106
|
+
});
|
|
107
|
+
}
|
|
69
108
|
}
|
|
70
109
|
|
|
71
|
-
|
|
110
|
+
walk(node.children);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
walk(nodes);
|
|
115
|
+
|
|
116
|
+
// Issue #1: enforce depth.min at file level — a file whose deepest node is
|
|
117
|
+
// above the configured minimum is under-specified. Attached to the first
|
|
118
|
+
// root node's line (there is always at least one node when the file has
|
|
119
|
+
// nodes); an empty tree skips the check entirely.
|
|
120
|
+
// LEVEL RATIONALE: depth.min is a warning — a too-shallow file is advisory
|
|
121
|
+
// (depth.max stays an error because it protects the token budget; making
|
|
122
|
+
// shallow files hard-fail would break legitimately shallow summary files).
|
|
123
|
+
// siblings.min is a warning for the same reason: it matches the warning
|
|
124
|
+
// level of the siblings.max side. Defaults (min: 1 both) can never fire —
|
|
125
|
+
// `0 < count < 1` is impossible and any non-empty file has max depth ≥ 1 —
|
|
126
|
+
// so default-rule workspaces stay clean.
|
|
127
|
+
if (nodes.length > 0) {
|
|
128
|
+
const depthMin = rules.depth !== null ? rules.depth.min : null;
|
|
129
|
+
if (depthMin !== null) {
|
|
130
|
+
let maxNodeDepth = 0;
|
|
131
|
+
for (const n of flattenNodes(nodes)) {
|
|
132
|
+
const d = n.indent + 1; // same convention as the per-node depth check above
|
|
133
|
+
if (d > maxNodeDepth) maxNodeDepth = d;
|
|
134
|
+
}
|
|
135
|
+
if (maxNodeDepth < depthMin) {
|
|
72
136
|
issues.push({
|
|
73
137
|
file,
|
|
74
|
-
line:
|
|
138
|
+
line: nodes[0]!.line,
|
|
75
139
|
level: 'warning',
|
|
76
140
|
category: 'structure',
|
|
77
|
-
message:
|
|
141
|
+
message: `Max depth ${maxNodeDepth} is below min ${depthMin}. Deepen the outline.`,
|
|
142
|
+
suggestion: `add nested sub-levels until the outline reaches depth ${depthMin}, or lower structure.depth.min in _rules.yaml`,
|
|
143
|
+
rule: 'structure.depth.min', // issue #41: machine-readable rule key
|
|
78
144
|
});
|
|
79
145
|
}
|
|
80
|
-
|
|
81
|
-
walk(node.children);
|
|
82
146
|
}
|
|
83
|
-
}
|
|
147
|
+
}
|
|
84
148
|
|
|
85
|
-
walk(nodes);
|
|
86
149
|
return issues;
|
|
87
150
|
}
|
|
88
151
|
|
|
@@ -106,6 +169,7 @@ export function checkTbdPolicy(
|
|
|
106
169
|
level: 'warning',
|
|
107
170
|
category: 'structure',
|
|
108
171
|
message: 'TBD used but content.tbd_allowed is false',
|
|
172
|
+
rule: 'content.tbd.disallowed', // issue #41: machine-readable rule key
|
|
109
173
|
suggestion: 'resolve the TBD nodes or set content.tbd_allowed: true',
|
|
110
174
|
},
|
|
111
175
|
];
|
|
@@ -118,6 +182,7 @@ export function checkTbdPolicy(
|
|
|
118
182
|
level: 'warning',
|
|
119
183
|
category: 'structure',
|
|
120
184
|
message: `${tbdNodes.length} TBD nodes exceed content.max_tbd_per_file (${rules.max_tbd_per_file})`,
|
|
185
|
+
rule: 'content.tbd.max', // issue #41: machine-readable rule key
|
|
121
186
|
suggestion: 'resolve the TBD nodes or raise content.max_tbd_per_file',
|
|
122
187
|
},
|
|
123
188
|
];
|
package/src/core/style.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
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.ts';
|
|
|
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,66 @@ 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
|
+
rule: 'style.prefix.shared', // issue #41: machine-readable rule key
|
|
70
|
+
});
|
|
71
|
+
}
|
|
41
72
|
}
|
|
42
73
|
}
|
|
43
|
-
}
|
|
44
74
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
// §14: "Parent with ≤ force_sibling_below leaf children → collapse to
|
|
76
|
+
// sibling style." `≤` semantics (exactly N is flagged). The file root is
|
|
77
|
+
// exempt — a root concept with few subtopics is the normal spec shape,
|
|
78
|
+
// not unnecessary nesting. A single child is reported by the structure
|
|
79
|
+
// engine ("exactly 1 child"); don't double-report it here.
|
|
80
|
+
const siblingBelow = rules.force_sibling_below;
|
|
81
|
+
// prefer: 'nested' declares a grouped-outline preference — the collapse
|
|
82
|
+
// hint contradicts it and is suppressed ('sibling'/null keep it firing).
|
|
83
|
+
if (
|
|
84
|
+
siblingBelow !== null &&
|
|
85
|
+
rules.prefer !== 'nested' &&
|
|
86
|
+
node.indent > 0 &&
|
|
87
|
+
children.length >= 2 &&
|
|
88
|
+
children.length <= siblingBelow &&
|
|
89
|
+
children.every((c) => c.children.length === 0)
|
|
90
|
+
) {
|
|
91
|
+
issues.push({
|
|
92
|
+
file,
|
|
93
|
+
line: node.line,
|
|
94
|
+
level: 'warning',
|
|
95
|
+
category: 'style',
|
|
96
|
+
// QA-03 F17 pluralization contract (unreachable for 1 — the ≥2 guard
|
|
97
|
+
// above plus the structure engine owns the 1-child case).
|
|
98
|
+
message: `"${node.text}" has ${children.length} ${children.length === 1 ? 'child' : 'children'}. Collapse to sibling style.`,
|
|
99
|
+
rule: 'style.nesting.prefer', // issue #41: machine-readable rule key
|
|
100
|
+
});
|
|
101
|
+
}
|
|
67
102
|
}
|
|
68
103
|
|
|
69
104
|
walk(children);
|
package/src/core/token-budget.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
OutlineNode, BackPointer, TokenBudgetRules,
|
|
5
5
|
BudgetReadPlanItem, BudgetReadResult, BudgetWriteResult,
|
|
6
6
|
} from '../types.ts';
|
|
7
|
-
import { flattenNodes, parseOutline } from './outline.ts';
|
|
7
|
+
import { flattenNodes, parseOutline, isSyntheticNode } from './outline.ts';
|
|
8
8
|
import { targetMatchesKey } from './refs.ts';
|
|
9
9
|
|
|
10
10
|
export function estimateTokens(text: string, charsPerToken: number): number {
|
|
@@ -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
|
}
|
|
@@ -103,9 +108,22 @@ export function buildReadPlan(
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
const backRefFiles = new Set<string>();
|
|
111
|
+
// §26 step 3 forward-ref tier (QA-17 F28): the files the canonical home
|
|
112
|
+
// POINTS TO — targets of see: refs made from the home file — connect at 40.
|
|
113
|
+
// (Back-refs, the files that point AT the home, stay 60; a file that is
|
|
114
|
+
// both keeps the higher tier.)
|
|
115
|
+
const forwardFiles = new Set<string>();
|
|
106
116
|
if (home !== null) {
|
|
107
117
|
for (const bp of backPointers) {
|
|
108
118
|
if (targetMatchesKey(bp.toFile, home.file)) backRefFiles.add(bp.fromFile);
|
|
119
|
+
if (bp.fromFile === home.file) {
|
|
120
|
+
for (const key of allFiles.keys()) {
|
|
121
|
+
if (targetMatchesKey(bp.toFile, key)) {
|
|
122
|
+
forwardFiles.add(key);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
109
127
|
}
|
|
110
128
|
}
|
|
111
129
|
for (const key of allFiles.keys()) {
|
|
@@ -114,6 +132,10 @@ export function buildReadPlan(
|
|
|
114
132
|
items.set(key, { file: key, anchor: null, reason: 'see: back-ref', score: 60, estTokens: tokens(key), rank: 2 });
|
|
115
133
|
continue;
|
|
116
134
|
}
|
|
135
|
+
if (forwardFiles.has(key)) {
|
|
136
|
+
items.set(key, { file: key, anchor: null, reason: 'forward ref', score: 40, estTokens: tokens(key), rank: 3 });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
117
139
|
const mentions = flattenNodes(allFiles.get(key)!).some(n => n.text.toLowerCase().includes(lc));
|
|
118
140
|
if (mentions) {
|
|
119
141
|
items.set(key, { file: key, anchor: null, reason: 'mentions concept', score: 20, estTokens: tokens(key), rank: 3 });
|
|
@@ -177,10 +199,14 @@ export function buildReadPlan(
|
|
|
177
199
|
const plan: BudgetReadPlanItem[] = [];
|
|
178
200
|
const skipped: string[] = [];
|
|
179
201
|
let totalTokens = 0;
|
|
180
|
-
|
|
202
|
+
// §26 step 4 (issue #16): best-effort greedy packing. Items are walked in
|
|
203
|
+
// score order and each item that fits under the remaining budget is
|
|
204
|
+
// planned. An item that does not fit is skipped (listed in `skipped`) but
|
|
205
|
+
// does NOT cut the walk — cheaper lower-scored items that still fit are
|
|
206
|
+
// considered, so a limit below the canonical home (score 100) can still
|
|
207
|
+
// afford a cheaper back-ref (score 60) instead of yielding plan: [].
|
|
181
208
|
for (const item of sorted) {
|
|
182
|
-
if (
|
|
183
|
-
cut = true;
|
|
209
|
+
if (totalTokens + item.estTokens > budgetLimit) {
|
|
184
210
|
skipped.push(item.file);
|
|
185
211
|
continue;
|
|
186
212
|
}
|
|
@@ -193,6 +219,16 @@ export function buildReadPlan(
|
|
|
193
219
|
for (const key of allFiles.keys()) {
|
|
194
220
|
if (!items.has(key)) skipped.push(key);
|
|
195
221
|
}
|
|
222
|
+
// §26 step 4 (QA-17 F25): skipped lists EVERY file not in the plan — active
|
|
223
|
+
// task files are in budget scope (§22), so a task file with no connection
|
|
224
|
+
// to the concept (never scored into `items`) is listed too, never
|
|
225
|
+
// invisible. Planned-but-unaffordable task files already landed in skipped
|
|
226
|
+
// via the packing loop above.
|
|
227
|
+
if (activeTaskPaths !== undefined) {
|
|
228
|
+
for (const taskPath of activeTaskPaths) {
|
|
229
|
+
if (!items.has(taskPath)) skipped.push(taskPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
196
232
|
skipped.sort();
|
|
197
233
|
|
|
198
234
|
const usagePercent = budgetLimit > 0
|
package/src/types.ts
CHANGED
|
@@ -20,12 +20,25 @@ 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 {
|
|
26
33
|
fromFile: string;
|
|
27
34
|
fromLine: number;
|
|
28
35
|
toFile: string;
|
|
36
|
+
/** The anchor side of the back-pointer. For graph-built back-pointers
|
|
37
|
+
* (buildRefGraph): the ref's raw anchor token, null for file-level refs.
|
|
38
|
+
* For extracted `<!-- ref-by: ... -->` comments (extractBackPointers,
|
|
39
|
+
* issue #19): the text of the node whose bullet line carries the comment
|
|
40
|
+
* (INLINE form — a node mark), or null when the comment stands on its own
|
|
41
|
+
* line (STANDALONE form — a file-level mark). */
|
|
29
42
|
toAnchor: string | null;
|
|
30
43
|
}
|
|
31
44
|
|
|
@@ -41,6 +54,10 @@ export interface Issue {
|
|
|
41
54
|
category: IssueCategory;
|
|
42
55
|
message: string;
|
|
43
56
|
suggestion?: string;
|
|
57
|
+
/** issue #41: machine-readable dotted rule key (e.g. "refs.broken.file",
|
|
58
|
+
* "structure.node_length.max") — stable vocabulary for report grouping and
|
|
59
|
+
* agent consumption. Optional so non-engine Issue constructors stay legal. */
|
|
60
|
+
rule?: string;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
// ── Rules ──
|
|
@@ -63,7 +80,9 @@ export interface StructureRules {
|
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
export interface StyleRules {
|
|
66
|
-
/**
|
|
83
|
+
/** Style-guide selector: `sibling` suppresses the nested-grouping hint,
|
|
84
|
+
* `nested` suppresses the sibling-collapse hint; null (deleted, §18) → no
|
|
85
|
+
* prefer-driven modulation — both base style hints fire unchanged. */
|
|
67
86
|
prefer: 'sibling' | 'nested' | null;
|
|
68
87
|
force_nested_above: number | null;
|
|
69
88
|
force_sibling_below: number | null;
|
|
@@ -90,6 +109,8 @@ export interface RedundancyRules {
|
|
|
90
109
|
word_frequency_threshold: number | null;
|
|
91
110
|
phrase_overlap_threshold: number | null;
|
|
92
111
|
cross_file_threshold: number | null;
|
|
112
|
+
/** Layer 3 switch: deleted → false (§18 delete-key semantics). */
|
|
113
|
+
fuzzy: boolean;
|
|
93
114
|
/** Parameters (not checks): keep their defaults when deleted (§18). */
|
|
94
115
|
stopwords: string[];
|
|
95
116
|
synonyms: string[][];
|
|
@@ -149,6 +170,13 @@ export interface CheckResult extends CommandResult {
|
|
|
149
170
|
errorCount: number;
|
|
150
171
|
warningCount: number;
|
|
151
172
|
backPointersUpdated: number;
|
|
173
|
+
/** Issue #11: spec-relative paths of the files --fix actually rewrote
|
|
174
|
+
* (sorted). Empty without --fix or when nothing needed a write; with a
|
|
175
|
+
* [file] filter only matching files can ever appear here. */
|
|
176
|
+
backPointersUpdatedFiles: string[];
|
|
177
|
+
/** issue #41: wall-clock duration of the whole checkWorkspace run,
|
|
178
|
+
* rounded to whole ms (0 for the static checkFail paths). */
|
|
179
|
+
elapsedMs: number;
|
|
152
180
|
/** §22/§36: human-facing one-line summary of the active _rules.yaml limits (QA-02 F17). */
|
|
153
181
|
rulesSummary?: string;
|
|
154
182
|
}
|
package/templates/_rules.yaml
CHANGED