cans-spec 0.1.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.
@@ -0,0 +1,284 @@
1
+ import { readFileSync } from 'fs';
2
+ import { basename, relative } from 'path';
3
+ import type {
4
+ OutlineNode, BackPointer, TokenBudgetRules,
5
+ BudgetReadPlanItem, BudgetReadResult, BudgetWriteResult,
6
+ } from '../types';
7
+ import { flattenNodes, parseOutline } from './outline';
8
+ import { targetMatchesKey } from './refs';
9
+
10
+ export function estimateTokens(text: string, charsPerToken: number): number {
11
+ return Math.ceil(text.length / charsPerToken);
12
+ }
13
+
14
+ /** QA-03 F12: file paths in budget output are cwd-relative, never absolute
15
+ * (workspace keys are already relative and pass through untouched). */
16
+ function relPath(file: string): string {
17
+ if (!file.startsWith('/')) return file;
18
+ return relative(process.cwd(), file) || file;
19
+ }
20
+
21
+ function serializedNodeText(nodes: OutlineNode[]): string {
22
+ return flattenNodes(nodes).map(n => n.text).join('\n');
23
+ }
24
+
25
+ /** Token estimate for a workspace key (loaded nodes) or a raw file path (content read). */
26
+ function fileTokens(allFiles: Map<string, OutlineNode[]>, file: string, cpt: number): number {
27
+ const nodes = allFiles.get(file);
28
+ if (nodes !== undefined) return estimateTokens(serializedNodeText(nodes), cpt);
29
+ try {
30
+ return estimateTokens(readFileSync(file, 'utf-8'), cpt);
31
+ } catch {
32
+ return 0;
33
+ }
34
+ }
35
+
36
+ function readNodesFor(file: string, allFiles: Map<string, OutlineNode[]>): OutlineNode[] | null {
37
+ const loaded = allFiles.get(file);
38
+ if (loaded !== undefined) return loaded;
39
+ try {
40
+ return parseOutline(readFileSync(file, 'utf-8'), file);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Canonical home: candidate nodes (text mentions concept) ranked by
47
+ * child count DESC → indent ASC → file ASC → line ASC. */
48
+ export function findCanonicalHome(
49
+ concept: string,
50
+ allFiles: Map<string, OutlineNode[]>,
51
+ ): { file: string; anchor: string | null } | null {
52
+ const lc = concept.toLowerCase();
53
+ const candidates: Array<{ file: string; node: OutlineNode }> = [];
54
+ for (const [file, nodes] of allFiles) {
55
+ for (const node of flattenNodes(nodes)) {
56
+ if (node.text.toLowerCase().includes(lc)) candidates.push({ file, node });
57
+ }
58
+ }
59
+ if (candidates.length === 0) return null;
60
+ candidates.sort((a, b) => {
61
+ const byChildren = b.node.children.length - a.node.children.length;
62
+ if (byChildren !== 0) return byChildren;
63
+ const byIndent = a.node.indent - b.node.indent;
64
+ if (byIndent !== 0) return byIndent;
65
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
66
+ return a.node.line - b.node.line;
67
+ });
68
+ const top = candidates[0];
69
+ return { file: top.file, anchor: top.node.text };
70
+ }
71
+
72
+ interface RankedItem extends BudgetReadPlanItem {
73
+ rank: number; // tie-break after score: active task first, then canonical home
74
+ }
75
+
76
+ export function buildReadPlan(
77
+ concept: string,
78
+ allFiles: Map<string, OutlineNode[]>,
79
+ backPointers: BackPointer[],
80
+ rules: TokenBudgetRules,
81
+ limit?: number,
82
+ taskFile?: string,
83
+ activeTaskPaths?: string[],
84
+ ): BudgetReadResult {
85
+ const lc = concept.toLowerCase();
86
+ const budgetLimit = limit ?? rules.default_limit;
87
+ const cpt = rules.estimate_chars_per_token;
88
+ const home = findCanonicalHome(concept, allFiles);
89
+ const tokens = (f: string): number => fileTokens(allFiles, f, cpt);
90
+ const items = new Map<string, RankedItem>();
91
+
92
+ if (taskFile !== undefined) {
93
+ items.set(taskFile, {
94
+ file: taskFile, anchor: null, reason: 'active task', score: 100,
95
+ estTokens: tokens(taskFile), rank: 0,
96
+ });
97
+ }
98
+ if (home !== null) {
99
+ items.set(home.file, {
100
+ file: home.file, anchor: home.anchor, reason: 'canonical home', score: 100,
101
+ estTokens: tokens(home.file), rank: taskFile !== undefined ? 1 : 0,
102
+ });
103
+ }
104
+
105
+ const backRefFiles = new Set<string>();
106
+ if (home !== null) {
107
+ for (const bp of backPointers) {
108
+ if (targetMatchesKey(bp.toFile, home.file)) backRefFiles.add(bp.fromFile);
109
+ }
110
+ }
111
+ for (const key of allFiles.keys()) {
112
+ if (items.has(key)) continue;
113
+ if (backRefFiles.has(key)) {
114
+ items.set(key, { file: key, anchor: null, reason: 'see: back-ref', score: 60, estTokens: tokens(key), rank: 2 });
115
+ continue;
116
+ }
117
+ const mentions = flattenNodes(allFiles.get(key)!).some(n => n.text.toLowerCase().includes(lc));
118
+ if (mentions) {
119
+ items.set(key, { file: key, anchor: null, reason: 'mentions concept', score: 20, estTokens: tokens(key), rank: 3 });
120
+ }
121
+ }
122
+
123
+ // §26 step 3: active tasks mentioning the concept score 80 and join the plan
124
+ // right after the canonical home.
125
+ if (activeTaskPaths !== undefined) {
126
+ for (const taskPath of activeTaskPaths) {
127
+ if (items.has(taskPath)) continue;
128
+ let content = '';
129
+ try {
130
+ content = readFileSync(taskPath, 'utf-8');
131
+ } catch {
132
+ continue;
133
+ }
134
+ if (content.toLowerCase().includes(lc)) {
135
+ items.set(taskPath, {
136
+ file: taskPath, anchor: null,
137
+ reason: 'active task mentions concept',
138
+ score: 80,
139
+ estTokens: estimateTokens(content, cpt),
140
+ rank: 2,
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ if (taskFile !== undefined) {
147
+ const taskNodes = readNodesFor(taskFile, allFiles);
148
+ if (taskNodes !== null) {
149
+ for (const node of flattenNodes(taskNodes)) {
150
+ for (const ref of node.refs) {
151
+ for (const key of allFiles.keys()) {
152
+ if (!targetMatchesKey(ref.file, key)) continue;
153
+ const existing = items.get(key);
154
+ if (existing !== undefined && (existing.reason === 'canonical home' || existing.reason === 'active task')) continue;
155
+ if (existing !== undefined) {
156
+ if (existing.score < 80) {
157
+ existing.score = 80;
158
+ existing.reason = 'task ref target';
159
+ }
160
+ } else {
161
+ items.set(key, { file: key, anchor: null, reason: 'task ref target', score: 80, estTokens: tokens(key), rank: 2 });
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ const sorted = [...items.values()].sort((a, b) => {
170
+ const byScore = b.score - a.score;
171
+ if (byScore !== 0) return byScore;
172
+ const byRank = a.rank - b.rank;
173
+ if (byRank !== 0) return byRank;
174
+ return a.file < b.file ? -1 : a.file > b.file ? 1 : 0;
175
+ });
176
+
177
+ const plan: BudgetReadPlanItem[] = [];
178
+ const skipped: string[] = [];
179
+ let totalTokens = 0;
180
+ let cut = false;
181
+ for (const item of sorted) {
182
+ if (cut || totalTokens + item.estTokens > budgetLimit) {
183
+ cut = true;
184
+ skipped.push(item.file);
185
+ continue;
186
+ }
187
+ plan.push({
188
+ file: item.file, anchor: item.anchor, reason: item.reason,
189
+ score: item.score, estTokens: item.estTokens,
190
+ });
191
+ totalTokens += item.estTokens;
192
+ }
193
+ for (const key of allFiles.keys()) {
194
+ if (!items.has(key)) skipped.push(key);
195
+ }
196
+ skipped.sort();
197
+
198
+ const usagePercent = budgetLimit > 0
199
+ ? Math.round((totalTokens / budgetLimit) * 1000) / 10
200
+ : 0;
201
+ return {
202
+ ok: true, command: 'budget-read', exitCode: 0, concept,
203
+ plan: plan.map(p => ({ ...p, file: relPath(p.file) })),
204
+ skipped: skipped.map(relPath),
205
+ totalTokens, budgetLimit, usagePercent,
206
+ };
207
+ }
208
+
209
+ function taskMentionsConcept(
210
+ taskPath: string,
211
+ allFiles: Map<string, OutlineNode[]>,
212
+ lc: string,
213
+ ): boolean {
214
+ try {
215
+ return readFileSync(taskPath, 'utf-8').toLowerCase().includes(lc);
216
+ } catch {
217
+ const base = basename(taskPath);
218
+ for (const [key, nodes] of allFiles) {
219
+ if (basename(key) !== base) continue;
220
+ if (flattenNodes(nodes).some(n => n.refs.length === 0 && n.text.toLowerCase().includes(lc))) return true;
221
+ }
222
+ return false;
223
+ }
224
+ }
225
+
226
+ export function buildWritePlan(
227
+ concept: string,
228
+ allFiles: Map<string, OutlineNode[]>,
229
+ backPointers: BackPointer[],
230
+ activeTasks: string[],
231
+ ): BudgetWriteResult {
232
+ const lc = concept.toLowerCase();
233
+ const home = findCanonicalHome(concept, allFiles);
234
+ const homeFile = home === null ? null : home.file;
235
+
236
+ const canEdit: Array<{ file: string; anchor: string | null; reason: string }> = [];
237
+ if (home !== null) {
238
+ canEdit.push({ file: home.file, anchor: home.anchor, reason: 'canonical home' });
239
+ }
240
+ for (const taskPath of activeTasks) {
241
+ if (taskMentionsConcept(taskPath, allFiles, lc)) {
242
+ canEdit.push({ file: taskPath, anchor: null, reason: 'active task' });
243
+ }
244
+ }
245
+
246
+ const mustNotEdit: Array<{ file: string; reason: string }> = [];
247
+ const backPointersToUpdate: Array<{ fromFile: string; fromLine: number; toFile: string }> = [];
248
+ if (homeFile !== null) {
249
+ for (const key of allFiles.keys()) {
250
+ if (key === homeFile) continue;
251
+ let hasBackRef = false;
252
+ for (const bp of backPointers) {
253
+ if ((bp.fromFile === key || targetMatchesKey(bp.fromFile, key)) && targetMatchesKey(bp.toFile, homeFile)) {
254
+ hasBackRef = true;
255
+ break;
256
+ }
257
+ }
258
+ if (!hasBackRef) continue;
259
+ // "mentions" counts only non-ref content: see:/anchor text is a pointer, not a definition.
260
+ const contentMention = flattenNodes(allFiles.get(key)!).some(
261
+ n => n.refs.length === 0 && n.text.toLowerCase().includes(lc),
262
+ );
263
+ if (!contentMention) {
264
+ mustNotEdit.push({ file: key, reason: 'only has see: reference' });
265
+ }
266
+ }
267
+ for (const bp of backPointers) {
268
+ if (targetMatchesKey(bp.toFile, homeFile)) {
269
+ backPointersToUpdate.push({ fromFile: bp.fromFile, fromLine: bp.fromLine, toFile: homeFile });
270
+ }
271
+ }
272
+ }
273
+
274
+ return {
275
+ ok: true, command: 'budget-write', exitCode: 0, concept,
276
+ canEdit: canEdit.map(e => ({ ...e, file: relPath(e.file) })),
277
+ mustNotEdit: mustNotEdit.map(e => ({ ...e, file: relPath(e.file) })),
278
+ backPointersToUpdate: backPointersToUpdate.map(b => ({
279
+ ...b,
280
+ fromFile: relPath(b.fromFile),
281
+ toFile: relPath(b.toFile),
282
+ })),
283
+ };
284
+ }
package/src/types.ts ADDED
@@ -0,0 +1,284 @@
1
+ // ── Outline ──
2
+
3
+ export interface RefTarget {
4
+ raw: string;
5
+ file: string;
6
+ anchor: string | null;
7
+ line: number;
8
+ }
9
+
10
+ export interface OutlineNode {
11
+ text: string;
12
+ line: number;
13
+ indent: number;
14
+ children: OutlineNode[];
15
+ file: string;
16
+ isTask: boolean;
17
+ isDone: boolean;
18
+ owner: string | null;
19
+ isHumanGate: boolean;
20
+ refs: RefTarget[];
21
+ hasCodeFence: boolean;
22
+ hasTable: boolean;
23
+ }
24
+
25
+ export interface BackPointer {
26
+ fromFile: string;
27
+ fromLine: number;
28
+ toFile: string;
29
+ toAnchor: string | null;
30
+ }
31
+
32
+ // ── Issues ──
33
+
34
+ export type IssueLevel = 'error' | 'warning';
35
+ export type IssueCategory = 'structure' | 'style' | 'refs' | 'redundancy' | 'overflow';
36
+
37
+ export interface Issue {
38
+ file: string;
39
+ line: number;
40
+ level: IssueLevel;
41
+ category: IssueCategory;
42
+ message: string;
43
+ suggestion?: string;
44
+ }
45
+
46
+ // ── Rules ──
47
+
48
+ /** §18 delete-key semantics: a check whose mapping key is deleted (or whose
49
+ * section is deleted) is OFF. Off is encoded as `null` members — engines MUST
50
+ * skip the corresponding check when a member is null/false (never compare
51
+ * against null, which would coerce to 0 and flag everything). */
52
+ export interface LengthRange {
53
+ min: number | null;
54
+ max: number | null;
55
+ }
56
+
57
+ export interface StructureRules {
58
+ node_length: LengthRange;
59
+ siblings: LengthRange;
60
+ depth: LengthRange;
61
+ single_child_collapse: boolean;
62
+ empty_nodes: boolean;
63
+ }
64
+
65
+ export interface StyleRules {
66
+ /** Deleted `prefer` disables prefer-driven style guidance (§18). */
67
+ prefer: 'sibling' | 'nested' | null;
68
+ force_nested_above: number | null;
69
+ force_sibling_below: number | null;
70
+ shared_prefix_detection: boolean;
71
+ }
72
+
73
+ export interface ContentRules {
74
+ tbd_allowed: boolean;
75
+ max_tbd_per_file: number | null;
76
+ }
77
+
78
+ export interface ReferenceRules {
79
+ /** Parameter (not a check): keeps its default when deleted (§18). */
80
+ mode: 'pointer';
81
+ back_pointers: boolean;
82
+ /** Deleted → deep-hop check off (null = skip detectDeepHops, §18 strict). */
83
+ max_hops: number | null;
84
+ orphan_check: boolean;
85
+ duplicate_home_check: boolean;
86
+ }
87
+
88
+ export interface RedundancyRules {
89
+ enabled: boolean;
90
+ word_frequency_threshold: number | null;
91
+ phrase_overlap_threshold: number | null;
92
+ cross_file_threshold: number | null;
93
+ /** Parameters (not checks): keep their defaults when deleted (§18). */
94
+ stopwords: string[];
95
+ synonyms: string[][];
96
+ }
97
+
98
+ export interface TokenBudgetRules {
99
+ enabled: boolean;
100
+ /** Parameters (not checks): budget planning keeps defaults when deleted (§18). */
101
+ default_limit: number;
102
+ estimate_chars_per_token: number;
103
+ /** Deleted → usage warning off (null = never warn). */
104
+ warn_threshold: number | null;
105
+ }
106
+
107
+ export interface OverflowRules {
108
+ /** Deleted → char-length check off (§18). */
109
+ max_node_chars: number | null;
110
+ /** Deleted → nothing is forced into files → no content-type flags (§16/§18). */
111
+ force_file_for: string[] | null;
112
+ }
113
+
114
+ export interface Rules {
115
+ structure: StructureRules;
116
+ style: StyleRules;
117
+ content: ContentRules;
118
+ references: ReferenceRules;
119
+ redundancy: RedundancyRules;
120
+ token_budget: TokenBudgetRules;
121
+ overflow: OverflowRules;
122
+ }
123
+
124
+ // ── Command Results ──
125
+
126
+ export interface CommandResult {
127
+ ok: boolean;
128
+ command: string;
129
+ exitCode: number;
130
+ }
131
+
132
+ export interface InitResult extends CommandResult {
133
+ command: 'init';
134
+ created: string[];
135
+ skipped: string[];
136
+ root: string;
137
+ /** §37: set when init refuses (e.g. already inside a cans/ workspace). */
138
+ error?: string;
139
+ }
140
+
141
+ export interface CheckResult extends CommandResult {
142
+ command: 'check';
143
+ files: number;
144
+ nodes: number;
145
+ maxDepth: number;
146
+ refs: { total: number; broken: number; deepHops: number };
147
+ backPointers: { total: number; current: number; stale: number };
148
+ issues: Issue[];
149
+ errorCount: number;
150
+ warningCount: number;
151
+ backPointersUpdated: number;
152
+ /** §22/§36: human-facing one-line summary of the active _rules.yaml limits (QA-02 F17). */
153
+ rulesSummary?: string;
154
+ }
155
+
156
+ export interface NewResult extends CommandResult {
157
+ command: 'new';
158
+ change: string;
159
+ file: string;
160
+ /** §37: real diagnosis for failures (unknown kind, empty slug, no workspace). */
161
+ error?: string;
162
+ /** Set when `new` notices a condition the user should know about. */
163
+ warning?: string;
164
+ }
165
+
166
+ export interface DoneResult extends CommandResult {
167
+ command: 'done';
168
+ change: string;
169
+ gates: { human: number; humanOpen: number; tasks: number; tasksOpen: number };
170
+ /** §36: gate detail lines for human output. Each: { file, line, text } */
171
+ gateDetails?: Array<{ file: string; line: number; text: string }>;
172
+ archived: string | null;
173
+ backPointersUpdated: number;
174
+ /** §37: real diagnosis (task not found, no workspace, parse error). */
175
+ error?: string;
176
+ }
177
+
178
+ export interface StatusResult extends CommandResult {
179
+ command: 'status';
180
+ specFiles: number;
181
+ activeTasks: number;
182
+ archivedTasks: number;
183
+ adrCount: number;
184
+ tasks: { total: number; done: number; unclaimed: number; blocked: number };
185
+ owners: Record<string, { tasks: number; done: number }>;
186
+ taskFiles: Array<{
187
+ name: string;
188
+ tasksDone: number;
189
+ tasksTotal: number;
190
+ gatesDone: number;
191
+ gatesTotal: number;
192
+ blocked: boolean;
193
+ /** Items with `←` but no owner (§25 unclaimed semantics). */
194
+ unclaimed?: number;
195
+ }>;
196
+ conflicts: number;
197
+ /** Set when --unclaimed / --blocked / --owners filters are active.
198
+ * Human printer uses these to restrict output; JSON always has full data. */
199
+ filter?: 'unclaimed' | 'blocked' | 'owners';
200
+ /** §37: set when the workspace is missing. */
201
+ error?: string;
202
+ }
203
+
204
+ export interface BudgetReadPlanItem {
205
+ file: string;
206
+ anchor: string | null;
207
+ reason: string;
208
+ score: number;
209
+ estTokens: number;
210
+ }
211
+
212
+ export interface BudgetReadResult extends CommandResult {
213
+ command: 'budget-read';
214
+ concept: string;
215
+ plan: BudgetReadPlanItem[];
216
+ skipped: string[];
217
+ totalTokens: number;
218
+ budgetLimit: number;
219
+ usagePercent: number;
220
+ /** §37: real diagnosis (usage error, no workspace, no matches). */
221
+ error?: string;
222
+ }
223
+
224
+ export interface BudgetWriteResult extends CommandResult {
225
+ command: 'budget-write';
226
+ concept: string;
227
+ canEdit: Array<{ file: string; anchor: string | null; reason: string }>;
228
+ mustNotEdit: Array<{ file: string; reason: string }>;
229
+ backPointersToUpdate: Array<{ fromFile: string; fromLine: number; toFile: string }>;
230
+ /** §37: real diagnosis (usage error, no workspace, empty scope). */
231
+ error?: string;
232
+ }
233
+
234
+ export interface ImportConflict {
235
+ file: string;
236
+ line: number;
237
+ cansVersion: string;
238
+ importVersion: string;
239
+ resolution: string;
240
+ }
241
+
242
+ export interface ImportResult extends CommandResult {
243
+ command: 'import';
244
+ format: string;
245
+ source: string;
246
+ newFiles: string[];
247
+ merged: string[];
248
+ conflicts: ImportConflict[];
249
+ /** §37: real diagnosis (usage error, source not found, parse failure). */
250
+ error?: string;
251
+ /** Set when the import was a dry run (no files written). */
252
+ dryRun?: boolean;
253
+ }
254
+
255
+ export interface ExportResult extends CommandResult {
256
+ command: 'export';
257
+ format: string;
258
+ outputDir: string;
259
+ filesExported: number;
260
+ /** §37: real diagnosis (usage error, no workspace). */
261
+ error?: string;
262
+ /** Set when the export was a dry run (no files written). */
263
+ dryRun?: boolean;
264
+ }
265
+
266
+ export interface VersionResult extends CommandResult {
267
+ command: 'version';
268
+ version: string;
269
+ }
270
+
271
+ // ── Converters ──
272
+
273
+ export interface ExternalNode {
274
+ text: string;
275
+ indent: number;
276
+ isTask: boolean;
277
+ isDone: boolean;
278
+ children: ExternalNode[];
279
+ metadata: Record<string, string>;
280
+ }
281
+
282
+ export type MergeStrategy = 'cans-wins' | 'import-wins' | 'ask';
283
+ export type ImportFormat = 'opml' | 'dynalist' | 'logseq' | 'obsidian';
284
+ export type ExportFormat = ImportFormat | 'all';