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.
- package/LICENSE +21 -0
- package/README.md +339 -0
- package/package.json +38 -0
- package/src/cli.ts +48 -0
- package/src/commands/budget.ts +238 -0
- package/src/commands/check.ts +422 -0
- package/src/commands/done.ts +171 -0
- package/src/commands/export.ts +223 -0
- package/src/commands/import.ts +436 -0
- package/src/commands/init.ts +184 -0
- package/src/commands/new.ts +138 -0
- package/src/commands/status.ts +152 -0
- package/src/converters/index.ts +4 -0
- package/src/converters/logseq.ts +42 -0
- package/src/converters/obsidian.ts +95 -0
- package/src/converters/opml.ts +143 -0
- package/src/converters/shared.ts +268 -0
- package/src/core/args.ts +79 -0
- package/src/core/fs.ts +309 -0
- package/src/core/index.ts +10 -0
- package/src/core/outline.ts +237 -0
- package/src/core/output.ts +300 -0
- package/src/core/overflow.ts +75 -0
- package/src/core/redundancy.ts +261 -0
- package/src/core/refs.ts +275 -0
- package/src/core/rules.ts +483 -0
- package/src/core/structure.ts +86 -0
- package/src/core/style.ts +75 -0
- package/src/core/token-budget.ts +284 -0
- package/src/types.ts +284 -0
- package/templates/AGENTS.md +209 -0
- package/templates/_rules.yaml +46 -0
- package/templates/adr-template.md +28 -0
- package/templates/task-template.md +15 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import type { CheckResult, Issue, OutlineNode } from '../types';
|
|
3
|
+
import {
|
|
4
|
+
discoverSpecFiles, discoverActiveTasks, discoverAdrs, resolveWorkspaceRoot,
|
|
5
|
+
dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
|
|
6
|
+
} from '../core/fs';
|
|
7
|
+
import {
|
|
8
|
+
parseOutline, extractBackPointers, flattenNodes, maxDepth as outlineMaxDepth,
|
|
9
|
+
type ParseWarning,
|
|
10
|
+
} from '../core/outline';
|
|
11
|
+
import { loadRules } from '../core/rules';
|
|
12
|
+
import { checkStructure } from '../core/structure';
|
|
13
|
+
import { checkStyle } from '../core/style';
|
|
14
|
+
import { checkOverflow, checkNoChaining } from '../core/overflow';
|
|
15
|
+
import { checkRedundancy } from '../core/redundancy';
|
|
16
|
+
import {
|
|
17
|
+
buildRefGraph, checkRefs, detectDeepHops, detectOrphans,
|
|
18
|
+
rebuildBackPointers, targetMatchesKey,
|
|
19
|
+
} from '../core/refs';
|
|
20
|
+
import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args';
|
|
21
|
+
|
|
22
|
+
export interface CheckArgs {
|
|
23
|
+
fix: boolean;
|
|
24
|
+
strict: boolean;
|
|
25
|
+
refsOnly: boolean;
|
|
26
|
+
noRedundancy: boolean;
|
|
27
|
+
file: string | null;
|
|
28
|
+
json: boolean;
|
|
29
|
+
/** §24 (done): the archiving task's parsed nodes, injected under their
|
|
30
|
+
* former `_tasks/<name>.md` identity so refs held by the archived task
|
|
31
|
+
* still count for the back-pointer rebuild. Never set by `check` itself. */
|
|
32
|
+
extraReferrer?: { key: string; nodes: OutlineNode[] } | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CHECK_FLAGS: FlagSpec[] = [
|
|
36
|
+
{ name: 'fix', boolean: true },
|
|
37
|
+
{ name: 'strict', boolean: true },
|
|
38
|
+
{ name: 'refs-only', boolean: true },
|
|
39
|
+
{ name: 'no-redundancy', boolean: true },
|
|
40
|
+
{ name: 'json', boolean: true },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
|
|
44
|
+
|
|
45
|
+
// globFiles throws ENOENT on missing dirs — guard the optional ones.
|
|
46
|
+
function safeActiveTasks(root: string): string[] {
|
|
47
|
+
return dirExists(join(root, '_tasks')) ? discoverActiveTasks(root) : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeAdrs(root: string): string[] {
|
|
51
|
+
return dirExists(join(root, '_adr')) ? discoverAdrs(root) : [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** §20: route check's args through the shared parser — `--flag value` only,
|
|
55
|
+
* `[file]` is the sole positional. Unknown flags, short flags, `--flag=value`
|
|
56
|
+
* and extra positionals are user errors, never silently ignored. */
|
|
57
|
+
export function parseCheckArgs(args: string[]): CheckArgs & { errors: string[] } {
|
|
58
|
+
const parsed = parseArgs(args, CHECK_FLAGS);
|
|
59
|
+
const errors = [...parsed.errors];
|
|
60
|
+
const positional = parsed.positional;
|
|
61
|
+
const file = positional.length > 0 ? positional[0]! : null;
|
|
62
|
+
if (positional.length > 1) {
|
|
63
|
+
errors.push(`unexpected argument "${positional[1]}" — check takes a single optional [file]`);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
fix: parsed.flags.has('fix'),
|
|
67
|
+
strict: parsed.flags.has('strict'),
|
|
68
|
+
refsOnly: parsed.flags.has('refs-only'),
|
|
69
|
+
noRedundancy: parsed.flags.has('no-redundancy'),
|
|
70
|
+
json: parsed.flags.has('json'),
|
|
71
|
+
file,
|
|
72
|
+
errors,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function zeroedCounts(): Omit<CheckResult, 'ok' | 'command' | 'exitCode'> {
|
|
77
|
+
return {
|
|
78
|
+
files: 0,
|
|
79
|
+
nodes: 0,
|
|
80
|
+
maxDepth: 0,
|
|
81
|
+
refs: { total: 0, broken: 0, deepHops: 0 },
|
|
82
|
+
backPointers: { total: 0, current: 0, stale: 0 },
|
|
83
|
+
issues: [],
|
|
84
|
+
errorCount: 0,
|
|
85
|
+
warningCount: 0,
|
|
86
|
+
backPointersUpdated: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** §37: check-level failure (no workspace, invalid rules, unknown flag, file
|
|
91
|
+
* filter matched nothing). The diagnosis rides in `error` so the human printer
|
|
92
|
+
* can show it standalone — never inside a report-shaped body. */
|
|
93
|
+
function checkFail(message: string): CheckResult & { error: string } {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
command: 'check',
|
|
97
|
+
exitCode: 1,
|
|
98
|
+
...zeroedCounts(),
|
|
99
|
+
issues: [{ file: '', line: 0, level: 'error', category: 'refs', message }],
|
|
100
|
+
errorCount: 1,
|
|
101
|
+
error: message,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Does raw ref target `name` point at workspace key `key`? (flat + folder layouts) */
|
|
106
|
+
function refTargetKey(name: string, keys: Iterable<string>): string | null {
|
|
107
|
+
for (const key of keys) {
|
|
108
|
+
if (targetMatchesKey(name, key)) return key;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
|
|
114
|
+
* Replaces the first existing comment's content, drops duplicates/stale ones,
|
|
115
|
+
* or inserts a fresh comment line right after the first root bullet. */
|
|
116
|
+
function rewriteRefBy(source: string, body: string | null): string {
|
|
117
|
+
const lines: Array<string | null> = source.split('\n');
|
|
118
|
+
const comment = body !== null && body !== '' ? `<!-- ref-by: ${body} -->` : null;
|
|
119
|
+
const hits: number[] = [];
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
if (lines[i] !== null && REF_BY_RE.test(lines[i]!)) hits.push(i);
|
|
122
|
+
}
|
|
123
|
+
if (hits.length > 0) {
|
|
124
|
+
for (let j = 0; j < hits.length; j++) {
|
|
125
|
+
const i = hits[j];
|
|
126
|
+
const raw = lines[i]!;
|
|
127
|
+
if (j === 0 && comment !== null) {
|
|
128
|
+
lines[i] = raw.replace(REF_BY_RE, comment);
|
|
129
|
+
} else {
|
|
130
|
+
const isBullet = /^\s*-\s/.test(raw);
|
|
131
|
+
lines[i] = isBullet ? raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '') : null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else if (comment !== null) {
|
|
135
|
+
let insertAt = lines.length;
|
|
136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
137
|
+
if (/^- /.test(lines[i]!)) {
|
|
138
|
+
insertAt = i + 1;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lines.splice(insertAt, 0, comment);
|
|
143
|
+
}
|
|
144
|
+
return lines.filter((l): l is string => l !== null).join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The shared engine orchestrator used by `cans check` and `cans done`. */
|
|
148
|
+
export async function checkWorkspace(root: string, opts: CheckArgs): Promise<CheckResult> {
|
|
149
|
+
let rules;
|
|
150
|
+
try {
|
|
151
|
+
rules = loadRules(root);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return checkFail(`invalid _rules.yaml: ${e instanceof Error ? e.message : String(e)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const issues: Issue[] = [];
|
|
157
|
+
|
|
158
|
+
// §37: malformed workspace entries (directories named like spec files) are
|
|
159
|
+
// reported, never silently skipped.
|
|
160
|
+
for (const name of detectMalformedSpecDirs(root)) {
|
|
161
|
+
issues.push({
|
|
162
|
+
file: name, line: 0, level: 'warning', category: 'structure',
|
|
163
|
+
message: `malformed workspace entry: directory "${name}" looks like a spec file — rename it or use folder mode (${name.replace(/\.md$/, '')}/index.md)`,
|
|
164
|
+
suggestion: `remove or rename the directory cans/${name}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// §8: "Flat wins over folder. If both exist, `cans check` flags error."
|
|
169
|
+
for (const [flat, folder] of detectFlatFolderConflicts(root)) {
|
|
170
|
+
issues.push({
|
|
171
|
+
file: flat, line: 0, level: 'error', category: 'structure',
|
|
172
|
+
message: `duplicate home: both ${flat} and ${folder} exist — flat wins, remove the folder`,
|
|
173
|
+
suggestion: `delete ${folder} (or merge its content into ${flat})`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Spec files: full checks.
|
|
178
|
+
const specRel = discoverSpecFiles(root);
|
|
179
|
+
const specFiles = new Map<string, OutlineNode[]>();
|
|
180
|
+
const specSources = new Map<string, string>();
|
|
181
|
+
for (const rel of specRel) {
|
|
182
|
+
let text = '';
|
|
183
|
+
try {
|
|
184
|
+
text = await Bun.file(join(root, rel)).text();
|
|
185
|
+
} catch (e) {
|
|
186
|
+
issues.push({
|
|
187
|
+
file: rel, line: 0, level: 'error', category: 'structure',
|
|
188
|
+
message: `unreadable spec file: ${e instanceof Error ? e.message : String(e)}`,
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
specSources.set(rel, text);
|
|
193
|
+
const fileWarnings: ParseWarning[] = [];
|
|
194
|
+
try {
|
|
195
|
+
specFiles.set(rel, parseOutline(text, rel, fileWarnings));
|
|
196
|
+
} catch (e) {
|
|
197
|
+
issues.push({
|
|
198
|
+
file: rel, line: 0, level: 'error', category: 'structure',
|
|
199
|
+
message: `parse error: ${e instanceof Error ? e.message : String(e)}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// Odd (non-2-multiple) indentation silently re-parents nodes — surface it.
|
|
203
|
+
for (const pw of fileWarnings) {
|
|
204
|
+
issues.push({
|
|
205
|
+
file: rel, line: pw.line, level: 'warning', category: 'structure',
|
|
206
|
+
message: pw.message,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Task + ADR sources: parsed for ref extraction only (no structure/style/etc checks).
|
|
212
|
+
const auxFiles = new Map<string, OutlineNode[]>();
|
|
213
|
+
for (const rel of [...safeActiveTasks(root), ...safeAdrs(root)]) {
|
|
214
|
+
try {
|
|
215
|
+
const text = await Bun.file(join(root, rel)).text();
|
|
216
|
+
auxFiles.set(rel, parseOutline(text, rel));
|
|
217
|
+
} catch {
|
|
218
|
+
// unreadable/unparseable aux file: its refs are simply not counted
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const allFiles = new Map<string, OutlineNode[]>([...specFiles, ...auxFiles]);
|
|
223
|
+
// §24 (done): the archiving task has already been renamed into _archive/, so
|
|
224
|
+
// its parsed nodes join the graph here under their former _tasks/ identity —
|
|
225
|
+
// its see: refs still earn their targets' ref-by marks.
|
|
226
|
+
if (opts.extraReferrer !== undefined && opts.extraReferrer !== null) {
|
|
227
|
+
allFiles.set(opts.extraReferrer.key, opts.extraReferrer.nodes);
|
|
228
|
+
}
|
|
229
|
+
const graph = buildRefGraph(allFiles, root);
|
|
230
|
+
|
|
231
|
+
// File filter: restrict structure/style/overflow/redundancy to one file (refs stay global).
|
|
232
|
+
const checkable = opts.file !== null
|
|
233
|
+
? [...specFiles.keys()].filter(k => targetMatchesKey(opts.file!, k))
|
|
234
|
+
: [...specFiles.keys()];
|
|
235
|
+
// §37: a file filter that matches nothing is a user-correctable mistake —
|
|
236
|
+
// never a silently-empty clean check (missing Part-4 item, QA-02 F13).
|
|
237
|
+
if (opts.file !== null && checkable.length === 0) {
|
|
238
|
+
return checkFail(
|
|
239
|
+
`no spec file matches "${opts.file}" — pass a spec filename like 04-api.md or run \`cans status\` to list files`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const checkableMap = new Map<string, OutlineNode[]>(
|
|
243
|
+
checkable.map(k => [k, specFiles.get(k)!]),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const deepHops = detectDeepHops(graph, rules.references.max_hops);
|
|
247
|
+
|
|
248
|
+
if (!opts.refsOnly) {
|
|
249
|
+
for (const key of checkable) {
|
|
250
|
+
issues.push(...checkStructure(specFiles.get(key)!, key, rules.structure));
|
|
251
|
+
}
|
|
252
|
+
for (const key of checkable) {
|
|
253
|
+
issues.push(...checkStyle(specFiles.get(key)!, key, rules.style));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
issues.push(...checkRefs(allFiles, graph, root));
|
|
258
|
+
issues.push(...deepHops);
|
|
259
|
+
if (rules.references.orphan_check) {
|
|
260
|
+
issues.push(...detectOrphans(specFiles, graph));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Back-pointers: ref-by comments in spec sources vs actual incoming refs.
|
|
264
|
+
// §18: `references.back_pointers` false (explicit or deleted key) turns the
|
|
265
|
+
// back-pointer check OFF — no stale warnings, and --fix writes nothing.
|
|
266
|
+
const backPointersOn = rules.references.back_pointers;
|
|
267
|
+
let bpTotal = 0;
|
|
268
|
+
let bpCurrent = 0;
|
|
269
|
+
let bpStale = 0;
|
|
270
|
+
if (backPointersOn) {
|
|
271
|
+
for (const [rel, source] of specSources) {
|
|
272
|
+
for (const bp of extractBackPointers(source, rel)) {
|
|
273
|
+
bpTotal++;
|
|
274
|
+
const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
|
|
275
|
+
const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
|
|
276
|
+
const isCurrent =
|
|
277
|
+
fromKey !== null &&
|
|
278
|
+
fromRefs !== undefined &&
|
|
279
|
+
fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
|
|
280
|
+
if (isCurrent) {
|
|
281
|
+
bpCurrent++;
|
|
282
|
+
} else {
|
|
283
|
+
bpStale++;
|
|
284
|
+
issues.push({
|
|
285
|
+
file: rel, line: bp.fromLine, level: 'warning', category: 'refs',
|
|
286
|
+
message: `stale back-pointer: ${bp.fromFile} no longer refs ${rel}`,
|
|
287
|
+
suggestion: 'remove the ref-by comment (or re-run cans check --fix)',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!opts.refsOnly) {
|
|
295
|
+
if (!opts.noRedundancy && rules.redundancy.enabled) {
|
|
296
|
+
issues.push(...checkRedundancy(checkableMap, rules.redundancy, rules.references.duplicate_home_check));
|
|
297
|
+
}
|
|
298
|
+
for (const key of checkable) {
|
|
299
|
+
issues.push(...checkOverflow(specFiles.get(key)!, key, rules.overflow));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// §16 no-chaining: overflow target files (spec subfolder content) must not
|
|
303
|
+
// contain their own see: refs.
|
|
304
|
+
const targetFiles = new Map<string, OutlineNode[]>();
|
|
305
|
+
for (const rel of discoverOverflowTargets(root)) {
|
|
306
|
+
try {
|
|
307
|
+
targetFiles.set(rel, parseOutline(await Bun.file(join(root, rel)).text(), rel));
|
|
308
|
+
} catch {
|
|
309
|
+
// unreadable overflow target: skipped
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
issues.push(...checkNoChaining(targetFiles));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --fix: rewrite ref-by comments ONLY, in spec files ONLY.
|
|
316
|
+
// §18/§17: with the back-pointer check off (back_pointers false or deleted),
|
|
317
|
+
// --fix must not write anything — backPointersUpdated stays 0, no file touched.
|
|
318
|
+
let backPointersUpdated = 0;
|
|
319
|
+
if (opts.fix && backPointersOn) {
|
|
320
|
+
const desired = rebuildBackPointers(allFiles, graph);
|
|
321
|
+
for (const [rel, source] of specSources) {
|
|
322
|
+
const body = desired.get(rel) ?? null;
|
|
323
|
+
const rewritten = rewriteRefBy(source, body);
|
|
324
|
+
if (rewritten !== source) {
|
|
325
|
+
await Bun.write(join(root, rel), rewritten);
|
|
326
|
+
specSources.set(rel, rewritten);
|
|
327
|
+
backPointersUpdated++;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// §35 check-fix.json reports the POST-fix state: recompute back-pointer
|
|
332
|
+
// counts from the rewritten sources and drop now-fixed stale issues.
|
|
333
|
+
bpTotal = 0;
|
|
334
|
+
bpCurrent = 0;
|
|
335
|
+
bpStale = 0;
|
|
336
|
+
for (const [rel, source] of specSources) {
|
|
337
|
+
for (const bp of extractBackPointers(source, rel)) {
|
|
338
|
+
bpTotal++;
|
|
339
|
+
const fromKey = refTargetKey(bp.fromFile, allFiles.keys());
|
|
340
|
+
const fromRefs = fromKey !== null ? graph.forward.get(fromKey) : undefined;
|
|
341
|
+
const isCurrent =
|
|
342
|
+
fromKey !== null &&
|
|
343
|
+
fromRefs !== undefined &&
|
|
344
|
+
fromRefs.some(t => refTargetKey(t.file, allFiles.keys()) === rel);
|
|
345
|
+
if (isCurrent) {
|
|
346
|
+
bpCurrent++;
|
|
347
|
+
} else {
|
|
348
|
+
bpStale++;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (let i = issues.length - 1; i >= 0; i--) {
|
|
353
|
+
if (issues[i]!.category === 'refs' && issues[i]!.message.startsWith('stale back-pointer:')) {
|
|
354
|
+
issues.splice(i, 1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let nodeCount = 0;
|
|
360
|
+
let depthMax = 0;
|
|
361
|
+
for (const nodes of specFiles.values()) {
|
|
362
|
+
nodeCount += flattenNodes(nodes).length;
|
|
363
|
+
depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
|
|
364
|
+
}
|
|
365
|
+
const refsTotal = [...graph.forward.values()].reduce((a, ts) => a + ts.length, 0);
|
|
366
|
+
const broken = issues.filter(
|
|
367
|
+
i => i.category === 'refs' && i.level === 'error' && i.message.startsWith('broken ref:'),
|
|
368
|
+
).length;
|
|
369
|
+
|
|
370
|
+
const errorCount = issues.filter(i => i.level === 'error').length;
|
|
371
|
+
const warningCount = issues.filter(i => i.level === 'warning').length;
|
|
372
|
+
const ok = errorCount === 0 && (!opts.strict || warningCount === 0);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
ok,
|
|
376
|
+
command: 'check',
|
|
377
|
+
exitCode: ok ? 0 : 1,
|
|
378
|
+
files: specFiles.size,
|
|
379
|
+
nodes: nodeCount,
|
|
380
|
+
// §35: maxDepth is 1-based (a 4-level chain reports 4); 0 for an empty workspace.
|
|
381
|
+
maxDepth: nodeCount === 0 ? 0 : depthMax + 1,
|
|
382
|
+
refs: { total: refsTotal, broken, deepHops: deepHops.length },
|
|
383
|
+
backPointers: { total: bpTotal, current: bpCurrent, stale: bpStale },
|
|
384
|
+
issues,
|
|
385
|
+
errorCount,
|
|
386
|
+
warningCount,
|
|
387
|
+
backPointersUpdated,
|
|
388
|
+
// §22: fixed report order ends with a Rules section before the summary (QA-02 F17).
|
|
389
|
+
// §18 delete-key semantics: a deleted range key shows as "off", never a raw null.
|
|
390
|
+
rulesSummary:
|
|
391
|
+
`node_length: ${fmtRange(rules.structure.node_length)}` +
|
|
392
|
+
` | siblings: ${fmtRange(rules.structure.siblings)}` +
|
|
393
|
+
` | depth: ${fmtRange(rules.structure.depth)}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** "3–120" for an active range; "off" when §18 delete-key semantics nulled it. */
|
|
398
|
+
function fmtRange(r: { min: number | null; max: number | null }): string {
|
|
399
|
+
if (r.min === null || r.max === null) return 'off';
|
|
400
|
+
return `${r.min}\u2013${r.max}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function run(args: string[]): Promise<CheckResult> {
|
|
404
|
+
// §20/§36: --help/-h show help — they never execute the check.
|
|
405
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
406
|
+
const help = { ok: true, command: 'help', exitCode: 0 };
|
|
407
|
+
return help as CheckResult;
|
|
408
|
+
}
|
|
409
|
+
const opts = parseCheckArgs(args);
|
|
410
|
+
|
|
411
|
+
// §20/§37: unknown flags, short flags, --flag=value, extra positionals —
|
|
412
|
+
// surface the real problem and never run a check on malformed args.
|
|
413
|
+
if (opts.errors.length > 0) {
|
|
414
|
+
return checkFail(formatArgErrors(opts.errors, 'check'));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const root = resolveWorkspaceRoot();
|
|
418
|
+
if (root === null) {
|
|
419
|
+
return checkFail('no cans workspace found — run `cans init` or cd into a project with a cans/ directory');
|
|
420
|
+
}
|
|
421
|
+
return checkWorkspace(root, opts);
|
|
422
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { renameSync } from 'fs';
|
|
3
|
+
import type { DoneResult, OutlineNode } from '../types';
|
|
4
|
+
import { resolveWorkspaceRoot, mkdirp, isFile, dirExists, globFiles } from '../core/fs';
|
|
5
|
+
import { parseOutline, flattenNodes } from '../core/outline';
|
|
6
|
+
import { checkWorkspace, type CheckArgs } from './check';
|
|
7
|
+
import { parseArgs, type FlagSpec } from '../core/args';
|
|
8
|
+
|
|
9
|
+
export interface DoneArgs {
|
|
10
|
+
name: string;
|
|
11
|
+
allowIncomplete: boolean;
|
|
12
|
+
skipCheck: boolean;
|
|
13
|
+
json: boolean;
|
|
14
|
+
errors: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DONE_FLAGS: FlagSpec[] = [
|
|
18
|
+
{ name: 'allow-incomplete', boolean: true },
|
|
19
|
+
{ name: 'skip-check', boolean: true },
|
|
20
|
+
{ name: 'json', boolean: true },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function parseDoneArgs(args: string[]): DoneArgs {
|
|
24
|
+
const parsed = parseArgs(args, DONE_FLAGS);
|
|
25
|
+
return {
|
|
26
|
+
name: parsed.positional[0] ?? '',
|
|
27
|
+
allowIncomplete: parsed.flags.has('allow-incomplete'),
|
|
28
|
+
skipCheck: parsed.flags.has('skip-check'),
|
|
29
|
+
json: parsed.flags.has('json'),
|
|
30
|
+
errors: parsed.errors,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ZERO_GATES = { human: 0, humanOpen: 0, tasks: 0, tasksOpen: 0 };
|
|
35
|
+
|
|
36
|
+
const ZERO_CHECK_ARGS: CheckArgs = {
|
|
37
|
+
fix: false, strict: false, refsOnly: false, noRedundancy: false, file: null, json: false,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** §37: every done failure carries the real diagnosis, not a fake check diagnosis. */
|
|
41
|
+
function failResult(name: string, error: string): DoneResult {
|
|
42
|
+
return {
|
|
43
|
+
ok: false, command: 'done', exitCode: 1, change: name,
|
|
44
|
+
gates: { ...ZERO_GATES }, archived: null, backPointersUpdated: 0,
|
|
45
|
+
error,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function today(): string {
|
|
50
|
+
return new Date().toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** §24: archive record names are YYYY-MM-DD-<name>.md. A second same-day done of a
|
|
54
|
+
* recreated task must never clobber the earlier archived record — pick the first
|
|
55
|
+
* non-colliding name (-2, -3, …) instead. */
|
|
56
|
+
function pickArchiveName(archiveDir: string, name: string): string {
|
|
57
|
+
const base = `${today()}-${name}.md`;
|
|
58
|
+
if (!isFile(join(archiveDir, base))) return base;
|
|
59
|
+
for (let i = 2; ; i++) {
|
|
60
|
+
const candidate = `${today()}-${name}-${i}.md`;
|
|
61
|
+
if (!isFile(join(archiveDir, candidate))) return candidate;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function run(args: string[]): Promise<DoneResult> {
|
|
66
|
+
const opts = parseDoneArgs(args);
|
|
67
|
+
if (opts.errors.length > 0) {
|
|
68
|
+
return failResult('', opts.errors[0]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { name, allowIncomplete, skipCheck } = opts;
|
|
72
|
+
|
|
73
|
+
if (name === '') {
|
|
74
|
+
return failResult('', 'usage: cans done <task-name>');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const workspace = resolveWorkspaceRoot();
|
|
78
|
+
if (workspace === null) {
|
|
79
|
+
return failResult(name, 'no cans workspace found — run `cans init` first');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// §24: task names resolve ONLY inside _tasks/. A name carrying path
|
|
83
|
+
// separators or traversal segments can never name a task there — refuse
|
|
84
|
+
// with the standard not-found error BEFORE any gate/read/rename logic
|
|
85
|
+
// (QA-08 A13/A14: no gate evaluation outside _tasks/, no raw ENOENT, nothing
|
|
86
|
+
// moved, exit 1).
|
|
87
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
88
|
+
return failResult(name, `task "${name}" not found in _tasks/ — run \`cans status\` to list active tasks`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const taskFile = join(workspace, '_tasks', `${name}.md`);
|
|
92
|
+
if (!isFile(taskFile)) {
|
|
93
|
+
// Distinguish "already archived" from "never existed" (§24: the archive is
|
|
94
|
+
// the only history `done` keeps — say so instead of a generic failure).
|
|
95
|
+
const archiveDir = join(workspace, '_tasks', '_archive');
|
|
96
|
+
if (dirExists(archiveDir)) {
|
|
97
|
+
const archived = globFiles(archiveDir, `*-${name}.md`);
|
|
98
|
+
if (archived.length > 0) {
|
|
99
|
+
return failResult(name, `task "${name}" is already archived (_tasks/_archive/${archived[0]})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return failResult(name, `task "${name}" not found in _tasks/ — run \`cans status\` to list active tasks`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let taskNodes: OutlineNode[] = [];
|
|
106
|
+
let flat: OutlineNode[] = [];
|
|
107
|
+
try {
|
|
108
|
+
taskNodes = parseOutline(await Bun.file(taskFile).text(), `_tasks/${name}.md`);
|
|
109
|
+
flat = flattenNodes(taskNodes);
|
|
110
|
+
} catch {
|
|
111
|
+
return failResult(name, `cannot parse _tasks/${name}.md — check for tab indentation or malformed content`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const humanGates = flat.filter(n => n.isTask && n.isHumanGate);
|
|
115
|
+
const humanOpen = humanGates.filter(n => !n.isDone).length;
|
|
116
|
+
const tasks = flat.filter(n => n.isTask && !n.isHumanGate);
|
|
117
|
+
const tasksOpen = tasks.filter(n => !n.isDone).length;
|
|
118
|
+
const gates = {
|
|
119
|
+
human: humanGates.length,
|
|
120
|
+
humanOpen,
|
|
121
|
+
tasks: tasks.length,
|
|
122
|
+
tasksOpen,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// §36: gate detail lines for human output (file:line — text).
|
|
126
|
+
const gateDetails = flat
|
|
127
|
+
.filter(n => n.isTask && !n.isDone)
|
|
128
|
+
.map(n => ({ file: `_tasks/${name}.md`, line: n.line, text: n.text }));
|
|
129
|
+
|
|
130
|
+
const blocked = (): DoneResult => ({
|
|
131
|
+
ok: false, command: 'done', exitCode: 1, change: name,
|
|
132
|
+
gates, gateDetails, archived: null, backPointersUpdated: 0,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Gate 1: unchecked ← @human gates always block.
|
|
136
|
+
if (humanOpen > 0) return blocked();
|
|
137
|
+
|
|
138
|
+
// Gate 2: open tasks block unless --allow-incomplete.
|
|
139
|
+
if (tasksOpen > 0 && !allowIncomplete) return blocked();
|
|
140
|
+
|
|
141
|
+
// Gate 3: final cans check must pass unless --skip-check.
|
|
142
|
+
if (!skipCheck) {
|
|
143
|
+
const check = await checkWorkspace(workspace, ZERO_CHECK_ARGS);
|
|
144
|
+
if (check.errorCount > 0) return blocked();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Archive: _tasks/<name>.md → _tasks/_archive/YYYY-MM-DD-<name>.md
|
|
148
|
+
// (§24: never overwrite an earlier same-day archive record).
|
|
149
|
+
const archiveDir = join(workspace, '_tasks', '_archive');
|
|
150
|
+
mkdirp(archiveDir);
|
|
151
|
+
const archivedRel = join('_tasks', '_archive', pickArchiveName(archiveDir, name));
|
|
152
|
+
renameSync(taskFile, join(workspace, archivedRel));
|
|
153
|
+
|
|
154
|
+
// §24: "Updates back-pointers if needed." Reuse the check engine's --fix pass
|
|
155
|
+
// (strictly ref-by comment rewrites in spec files) and report the count.
|
|
156
|
+
// The task has just been renamed into _archive/, so its parsed nodes are
|
|
157
|
+
// injected under their former _tasks/<name>.md identity — refs held by the
|
|
158
|
+
// archived task still earn their targets' ref-by marks (QA-04 #10). The pass
|
|
159
|
+
// is gated on §18 references.back_pointers inside checkWorkspace (off →
|
|
160
|
+
// zero writes, backPointersUpdated 0).
|
|
161
|
+
const fixRun = await checkWorkspace(workspace, {
|
|
162
|
+
...ZERO_CHECK_ARGS,
|
|
163
|
+
fix: true,
|
|
164
|
+
extraReferrer: { key: `_tasks/${name}.md`, nodes: taskNodes },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: true, command: 'done', exitCode: 0, change: name,
|
|
169
|
+
gates, archived: archivedRel, backPointersUpdated: fixRun.backPointersUpdated,
|
|
170
|
+
};
|
|
171
|
+
}
|