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,143 @@
1
+ import type { ExternalNode } from '../types';
2
+
3
+ /** Escape the five XML entities. `&` first so output is never double-encoded. */
4
+ export function encodeXmlEntity(s: string): string {
5
+ return s
6
+ .replace(/&/g, '&')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&apos;');
11
+ }
12
+
13
+ /** Inverse of encodeXmlEntity. `&amp;` last so `&amp;lt;` decodes to literal `&lt;`. */
14
+ export function decodeXmlEntity(s: string): string {
15
+ return s
16
+ .replace(/&lt;/g, '<')
17
+ .replace(/&gt;/g, '>')
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&apos;/g, "'")
20
+ .replace(/&#(\d+);/g, (_m, d: string) => String.fromCharCode(Number(d)))
21
+ .replace(/&amp;/g, '&');
22
+ }
23
+
24
+ /** §28 import inverse (OPML/Dynalist): export encodes CANS `see: X.md#Y` as the
25
+ * text `→ X.md#Y`; import must restore the ref or the round-trip kills it
26
+ * (QA-05 F16 / QA-09 D9). Prose arrows are untouched — the target must be an
27
+ * `.md` path (`Draft → Tested` stays prose). */
28
+ export function convertArrowRefs(text: string): string {
29
+ return text.replace(
30
+ /→\s*([A-Za-z0-9._/-]+\.md)(#([^\s]+))?/g,
31
+ (_m, file: string, _anchorPart: string | undefined, anchor: string | undefined) =>
32
+ anchor !== undefined ? `see: ${file}#${anchor}` : `see: ${file}`,
33
+ );
34
+ }
35
+
36
+ /** Extract the `<head><title>` text (XML-decoded). `cans export` writes the
37
+ * SOURCE SPEC FILENAME there (e.g. `02-authentication.md`), so import can
38
+ * match the merge target and preserve file identity instead of renumbering
39
+ * from the first node (QA-09 D12). null when the document has no title. */
40
+ export function parseOpmlTitle(source: string): string | null {
41
+ const m = source.match(/<title[^>]*>([\s\S]*?)<\/title\s*>/i);
42
+ return m ? decodeXmlEntity(m[1]).trim() : null;
43
+ }
44
+
45
+ function readAttr(attrs: string, name: string): string | undefined {
46
+ const m =
47
+ attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, 'i')) ??
48
+ attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, 'i'));
49
+ return m ? m[1] : undefined;
50
+ }
51
+
52
+ /** §31: OPML is XML — reject non-OPML input with a real diagnosis instead of
53
+ * silently returning an empty outline (QA-05 F12). Regex-based strictness,
54
+ * no XML parser dependency: root element must exist and outline tags must
55
+ * balance. Throws Error with an `invalid OPML: …` message. */
56
+ function assertOpml(source: string): void {
57
+ if (!/<opml[\s>]/i.test(source)) {
58
+ throw new Error('invalid OPML: missing <opml> root element (not XML)');
59
+ }
60
+ if (!/<opml\b[^>]*>[\s\S]*<\/opml\s*>/i.test(source)) {
61
+ throw new Error('invalid OPML: <opml> element is not closed');
62
+ }
63
+ const opens = (source.match(/<outline\b(?![^>]*\/\s*>)[^>]*>/gi) ?? []).length;
64
+ const closes = (source.match(/<\/outline\s*>/gi) ?? []).length;
65
+ if (opens !== closes) {
66
+ throw new Error(`invalid OPML: unbalanced <outline> tags (${opens} opened, ${closes} closed)`);
67
+ }
68
+ }
69
+
70
+ /** Regex-based OPML walk: top level = outlines directly under `<body>`; nesting from tag structure.
71
+ * Throws on non-OPML / malformed XML input (see assertOpml). */
72
+ export function parseOpml(source: string): ExternalNode[] {
73
+ assertOpml(source);
74
+ const body = source.match(/<body[^>]*>([\s\S]*?)<\/body\s*>/i);
75
+ const region = body ? body[1] : source;
76
+ const roots: ExternalNode[] = [];
77
+ const stack: ExternalNode[] = [];
78
+ const tag = /<outline\b[^>]*>|<\/outline\s*>/gi;
79
+ let m: RegExpExecArray | null;
80
+ while ((m = tag.exec(region)) !== null) {
81
+ if (m[0][1] === '/') {
82
+ stack.pop();
83
+ continue;
84
+ }
85
+ const selfClosing = /\/\s*>$/.test(m[0]);
86
+ const inner = m[0].slice(8, -1);
87
+ const node: ExternalNode = {
88
+ text: decodeXmlEntity(readAttr(inner, 'text') ?? ''),
89
+ indent: stack.length,
90
+ isTask: false,
91
+ isDone: false,
92
+ children: [],
93
+ metadata: {},
94
+ };
95
+ const note = readAttr(inner, '_note') ?? readAttr(inner, 'note');
96
+ if (note !== undefined) node.metadata.note = decodeXmlEntity(note);
97
+ (stack.length > 0 ? stack[stack.length - 1].children : roots).push(node);
98
+ if (!selfClosing) stack.push(node);
99
+ }
100
+ return roots;
101
+ }
102
+
103
+ /** Accept a tree (children) or a flat indent-based list; rebuild a proper tree for tag nesting. */
104
+ function normalizeTree(nodes: ExternalNode[]): ExternalNode[] {
105
+ const flat: ExternalNode[] = [];
106
+ const dfs = (list: ExternalNode[]): void => {
107
+ for (const n of list) {
108
+ flat.push(n);
109
+ dfs(n.children);
110
+ }
111
+ };
112
+ dfs(nodes);
113
+ const roots: ExternalNode[] = [];
114
+ const stack: ExternalNode[] = [];
115
+ for (const n of flat) {
116
+ const node: ExternalNode = { ...n, children: [] };
117
+ while (stack.length > 0 && stack[stack.length - 1].indent >= node.indent) stack.pop();
118
+ (stack.length > 0 ? stack[stack.length - 1].children : roots).push(node);
119
+ stack.push(node);
120
+ }
121
+ return roots;
122
+ }
123
+
124
+ /** ExternalNode tree → valid OPML 2.0 document (leaves self-close, parents pair). */
125
+ export function serializeOpml(nodes: ExternalNode[], title: string): string {
126
+ const outline = (n: ExternalNode, depth: number): string => {
127
+ const pad = ' '.repeat(depth);
128
+ const note = n.metadata?.note;
129
+ // §28: checkbox state survives the round-trip (`- [ ] task` → `- [ ] task` in text)
130
+ const textContent = n.isTask ? (n.isDone ? '[x] ' : '[ ] ') + n.text : n.text;
131
+ const attrs = `text="${encodeXmlEntity(textContent)}"${note !== undefined ? ` _note="${encodeXmlEntity(note)}"` : ''}`;
132
+ if (n.children.length === 0) return `${pad}<outline ${attrs}/>\n`;
133
+ return `${pad}<outline ${attrs}>\n${n.children.map((c) => outline(c, depth + 1)).join('')}${pad}</outline>\n`;
134
+ };
135
+ return (
136
+ `<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n` +
137
+ `<head><title>${encodeXmlEntity(title)}</title></head>\n<body>\n` +
138
+ normalizeTree(nodes)
139
+ .map((n) => outline(n, 1))
140
+ .join('') +
141
+ `</body>\n</opml>\n`
142
+ );
143
+ }
@@ -0,0 +1,268 @@
1
+ import type { ExternalNode } from '../types';
2
+
3
+ /** Leading whitespace → indent units. 2 spaces per level; each tab counts as 2 spaces. */
4
+ export function parseIndent(raw: string): number {
5
+ let spaces = 0;
6
+ for (const ch of raw) {
7
+ if (ch === ' ') spaces += 1;
8
+ else if (ch === '\t') spaces += 2;
9
+ else break;
10
+ }
11
+ return Math.floor(spaces / 2);
12
+ }
13
+
14
+ /** Input includes the leading `- ` prefix. `- [ ]`/`- [x]`/`- [X]` and TODO/DOING/DONE markers. */
15
+ export function parseCheckbox(text: string): { isTask: boolean; isDone: boolean; clean: string } {
16
+ const s = text.replace(/^\s*-\s+/, '');
17
+ const box = s.match(/^\[([ xX])\]\s?/);
18
+ if (box) return { isTask: true, isDone: box[1] !== ' ', clean: s.slice(box[0].length).trim() };
19
+ const kw = s.match(/^(TODO|DOING|DONE)\b\s?/);
20
+ if (kw) return { isTask: true, isDone: kw[1] === 'DONE', clean: s.slice(kw[0].length).trim() };
21
+ return { isTask: false, isDone: false, clean: s };
22
+ }
23
+
24
+ /** A fenced code block extracted during import into an overflow file (§27). */
25
+ export interface OverflowExtraction {
26
+ /** Overflow file path relative to the workspace (e.g. `cb-note/request-schema.json`). */
27
+ overflowFile: string;
28
+ /** The raw fenced body. */
29
+ content: string;
30
+ /** The bullet text the fence hung under. */
31
+ parentText: string;
32
+ }
33
+
34
+ /** `[[X]]` → `see: X.md`; `[[X#Y]]` → `see: X.md#Y`; labels (`|label`) are discarded.
35
+ * §4/§27 canonical ref form carries the `.md` suffix — a `.md`-less target is a
36
+ * guaranteed broken ref (QA-08 E7). Pages that already name a file with an
37
+ * extension (attachments, `X.md`) are kept verbatim. Same-line whitespace right
38
+ * after the link is consumed and re-emitted as a single separator, keeping the
39
+ * ref token clean and trailing prose as node content (QA-05 F3). Newlines are
40
+ * never consumed. */
41
+ export function convertWikiLinks(text: string): string {
42
+ return text.replace(
43
+ /\[\[([^[\]|#]*)(?:#([^[\]|#]*))?(?:\|[^[\]]*)?\]\]([ \t]*)/g,
44
+ (_m, page: string, anchor: string | undefined, trail: string) => {
45
+ const target = /\.[A-Za-z0-9]{1,8}$/.test(page) ? page : `${page}.md`;
46
+ const ref = anchor ? `see: ${target}#${anchor}` : `see: ${target}`;
47
+ return trail.length > 0 ? `${ref} ` : ref;
48
+ }
49
+ );
50
+ }
51
+
52
+ /** Logseq `[[Page/Anchor]]` is the §28 export encoding of CANS `see: Page.md#Anchor`.
53
+ * Rewrite it to `[[Page#Anchor]]` — splitting on the FIRST `/` so `[[X/Y]]` becomes
54
+ * page `X`, anchor `Y` — before the generic wiki-link conversion runs (QA-09 D8:
55
+ * the slashed form must never leak into the workspace as the dead ref
56
+ * `see: X/Y.md`). Plain page links without a slash are untouched. */
57
+ export function logseqSlashLinks(text: string): string {
58
+ return text.replace(
59
+ /\[\[([^[\]|#/]+)\/([^[\]|]+?)\]\]/g,
60
+ (_m, page: string, anchor: string) => `[[${page}#${anchor.trim()}]]`,
61
+ );
62
+ }
63
+
64
+ /** §28 import inverse for owner/gate markers (QA-09 D5): external task-state
65
+ * emoji must come back as CANS owner arrows, or round-trips destroy
66
+ * owner/gate state. Obsidian: `🤖 agent-1` → `← agent-1`, `⏳ Human` → `← @human`.
67
+ * Logseq shares the human-gate row: `⏳ Human` → `← @human`. */
68
+ export function convertOwnerMarkers(text: string, format: string): string {
69
+ const f = format.toLowerCase();
70
+ let s = text;
71
+ if (f === 'obsidian') {
72
+ s = s.replace(/🤖\s*(\S+)/g, '← $1');
73
+ }
74
+ if (f === 'obsidian' || f === 'logseq') {
75
+ s = s.replace(/⏳\s*Human/i, '← @human');
76
+ }
77
+ return s;
78
+ }
79
+
80
+ /** Inverse of convertWikiLinks: `see: X.md#Y` → `[[X#Y]]` (`.md` stripped for wiki form). */
81
+ export function reverseWikiLinks(text: string): string {
82
+ return text.replace(/\bsee:\s+([^\s]+)/g, (_m, target: string) => {
83
+ const cleaned = target.replace(/\.md(?=#|$)/, '');
84
+ return `[[${cleaned}]]`;
85
+ });
86
+ }
87
+
88
+ /** Remove app cruft: logseq `key:: value` props, dynalist `^block-ids`, obsidian `#tags`, `*`/`_` emphasis. */
89
+ export function stripMetadata(text: string, format: string): string {
90
+ const f = format.toLowerCase();
91
+ const lenient = f !== 'logseq' && f !== 'opml' && f !== 'dynalist' && f !== 'obsidian';
92
+ let s = text;
93
+ if (f === 'logseq' || lenient) s = s.replace(/\s*[\w-]+::(?:\s.*|$)/g, '');
94
+ if (f === 'opml' || f === 'dynalist' || lenient) s = s.replace(/(?:\s*\^[\w-]+)+$/g, '');
95
+ if (f === 'obsidian' || f === 'dynalist' || lenient) s = s.replace(/(^|\s)#[\w/-]+/g, '$1');
96
+ s = s
97
+ .replace(/\*\*\*([^*]+)\*\*\*/g, '$1')
98
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
99
+ .replace(/\*([^*]+)\*/g, '$1')
100
+ .replace(/__([^_]+)__/g, '$1')
101
+ .replace(/_([^_]+)_/g, '$1');
102
+ return s.replace(/\s{2,}/g, ' ').trim();
103
+ }
104
+
105
+ /** ExternalNode tree → CANS markdown bullets. DFS; each node at `' '.repeat(node.indent)`. */
106
+ export function serializeToCans(nodes: ExternalNode[]): string {
107
+ const lines: string[] = [];
108
+ const walk = (list: ExternalNode[]): void => {
109
+ for (const n of list) {
110
+ const box = n.isTask ? (n.isDone ? '[x] ' : '[ ] ') : '';
111
+ lines.push(' '.repeat(n.indent) + '- ' + box + n.text);
112
+ // §31: OPML `_note` content is spec content — emit as indented child nodes.
113
+ if (n.metadata?.note !== undefined && n.metadata.note !== '') {
114
+ for (const nl of n.metadata.note.split('\n')) {
115
+ if (nl.trim() === '') continue;
116
+ lines.push(' '.repeat(n.indent + 1) + '- ' + nl.trim());
117
+ }
118
+ }
119
+ walk(n.children);
120
+ }
121
+ };
122
+ walk(nodes);
123
+ return lines.length > 0 ? lines.join('\n') + '\n' : '';
124
+ }
125
+
126
+ /** CANS markdown → ExternalNode tree (reverse of serializeToCans; stack-attach by indent). */
127
+ export function parseFromCans(source: string): ExternalNode[] {
128
+ const roots: ExternalNode[] = [];
129
+ const stack: ExternalNode[] = [];
130
+ for (const raw of source.split(/\r?\n/)) {
131
+ const m = raw.match(/^\s*-\s+(.*)$/);
132
+ if (!m) continue;
133
+ const { isTask, isDone, clean } = parseCheckbox(raw);
134
+ const node: ExternalNode = { text: clean, indent: parseIndent(raw), isTask, isDone, children: [], metadata: {} };
135
+ while (stack.length > 0 && stack[stack.length - 1].indent >= node.indent) stack.pop();
136
+ (stack.length > 0 ? stack[stack.length - 1].children : roots).push(node);
137
+ stack.push(node);
138
+ }
139
+ return roots;
140
+ }
141
+
142
+ function slugFor(text: string): string {
143
+ return text
144
+ .trim()
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9]+/g, '-')
147
+ .replace(/^-+|-+$/g, '');
148
+ }
149
+
150
+ /** §27 "Extract code blocks → overflow files" (§16: extracted to a separate file
151
+ * and referenced via see:). A proper fence state machine — every fence shape
152
+ * that appears in imported outline notes is handled, and NO line is ever
153
+ * silently dropped (QA-08 E9 / QA-05 F4+F5):
154
+ * - plain fence lines ( ```json … ```) hanging under a bullet,
155
+ * - fence-as-bullet (` - ```python … ```) used by Obsidian/Logseq notes,
156
+ * - interior lines at any indentation, closing fences at any indentation,
157
+ * - unterminated fences at EOF (flushed, not swallowed).
158
+ * Each fence is extracted to `<baseSlug>/<node-slug>.<ext>` and replaced by a
159
+ * `see:` reference node, so the fence's presence survives and content AFTER
160
+ * the closing fence keeps parsing as normal bullets. Returns the cleaned
161
+ * bullet source plus the extractions to write. */
162
+ export function extractOverflowContent(
163
+ source: string,
164
+ baseSlug: string,
165
+ ): { cleanedSource: string; extractions: OverflowExtraction[] } {
166
+ const lines = source.split(/\r?\n/);
167
+ const outLines: string[] = [];
168
+ const extractions: OverflowExtraction[] = [];
169
+ const usedNames = new Set<string>();
170
+
171
+ let inFence = false;
172
+ let fenceLang = '';
173
+ let fenceBody: string[] = [];
174
+ let fenceAsBullet = false; // fence opened on a `- ```lang` bullet line
175
+ let fenceBulletIndent = ''; // indent of that bullet line
176
+ let lastBulletText = ''; // text of the last bullet seen outside fences
177
+ let lastBulletIdx = -1; // index of that bullet within outLines
178
+ let extractionIndex = 0;
179
+
180
+ /** Overflow paths are workspace-relative and collision-free (two fences under
181
+ * the same bullet must never overwrite each other's extraction). */
182
+ const uniqueOverflowFile = (slug: string, ext: string): string => {
183
+ let name = `${baseSlug}/${slug}.${ext}`;
184
+ let n = 2;
185
+ while (usedNames.has(name)) name = `${baseSlug}/${slug}-${n++}.${ext}`;
186
+ usedNames.add(name);
187
+ return name;
188
+ };
189
+
190
+ /** Close the open fence: extract the body to an overflow file and leave a
191
+ * `see:` reference where the fence hung. */
192
+ const closeFence = (): void => {
193
+ // Sanitize the fence language into a safe file extension: plain
194
+ // alphanumerics only (a crafted fence like ``` ../../../evil must
195
+ // never escape the workspace overflow directory).
196
+ const rawExt = fenceLang !== '' ? fenceLang : 'md';
197
+ const ext = /^[A-Za-z0-9]{1,12}$/.test(rawExt) ? rawExt : 'md';
198
+ const slug = lastBulletText !== '' ? slugFor(lastBulletText) : `block-${extractionIndex}`;
199
+ const overflowFile = uniqueOverflowFile(slug, ext);
200
+ extractions.push({
201
+ overflowFile,
202
+ content: fenceBody.join('\n'),
203
+ parentText: lastBulletText,
204
+ });
205
+ if (fenceAsBullet) {
206
+ // The fence was itself a bullet → that bullet becomes the see: reference
207
+ // node, at the fence bullet's own indent (hierarchy preserved).
208
+ outLines.push(`${fenceBulletIndent}- see ${overflowFile}`);
209
+ } else if (lastBulletIdx >= 0 && lastBulletIdx === outLines.length - 1) {
210
+ // Fence hangs directly under the last bullet → reference replaces it.
211
+ const indent = outLines[lastBulletIdx].match(/^\s*/)?.[0] ?? '';
212
+ outLines[lastBulletIdx] = `${indent}- ${lastBulletText}: see ${overflowFile}`;
213
+ } else {
214
+ // Fence detached from any bullet → reference after the block.
215
+ const anchorLine = outLines[outLines.length - 1] ?? '';
216
+ const indent = anchorLine.match(/^\s*/)?.[0] ?? '';
217
+ const label = lastBulletText !== '' ? `${lastBulletText}: ` : '';
218
+ outLines.push(`${indent}- ${label}see ${overflowFile}`);
219
+ }
220
+ // A following fence must never clobber the reference just written.
221
+ lastBulletIdx = -1;
222
+ extractionIndex++;
223
+ };
224
+
225
+ for (const line of lines) {
226
+ const trimmed = line.trim();
227
+ if (inFence) {
228
+ if (/^`{3,}\s*$/.test(trimmed)) {
229
+ closeFence();
230
+ inFence = false;
231
+ fenceBody = [];
232
+ } else {
233
+ fenceBody.push(line);
234
+ }
235
+ continue;
236
+ }
237
+ // Fence-as-bullet: `- ```lang` — the fence line is itself an outline bullet.
238
+ const bulletFence = line.match(/^(\s*)-\s+(`{3,}.*)$/);
239
+ if (bulletFence !== null) {
240
+ inFence = true;
241
+ fenceAsBullet = true;
242
+ fenceBulletIndent = bulletFence[1] ?? '';
243
+ fenceLang = (bulletFence[2] ?? '').slice(3).trim();
244
+ fenceBody = [];
245
+ continue;
246
+ }
247
+ // Plain fence line (possibly indented).
248
+ if (trimmed.startsWith('```')) {
249
+ inFence = true;
250
+ fenceAsBullet = false;
251
+ fenceLang = trimmed.slice(3).trim();
252
+ fenceBody = [];
253
+ continue;
254
+ }
255
+ const bulletMatch = line.match(/^\s*-\s+(.*)/);
256
+ if (bulletMatch) {
257
+ lastBulletText = bulletMatch[1].trim();
258
+ lastBulletIdx = outLines.length;
259
+ }
260
+ outLines.push(line);
261
+ }
262
+ if (inFence) {
263
+ // Unterminated fence at EOF: flush the extraction rather than dropping it.
264
+ closeFence();
265
+ }
266
+
267
+ return { cleanedSource: outLines.join('\n'), extractions };
268
+ }
@@ -0,0 +1,79 @@
1
+ /** Centralized arg parser enforcing §20 contract:
2
+ * `--flag value` only. No `--flag=value`, no short flags, no combined flags.
3
+ * Unknown flags are errors, not silently ignored. */
4
+
5
+ export interface ParsedArgs {
6
+ positional: string[];
7
+ flags: Map<string, string | true>;
8
+ errors: string[];
9
+ }
10
+
11
+ export interface FlagSpec {
12
+ /** flag name without `--` */
13
+ name: string;
14
+ /** true = boolean flag (no value), false = requires value */
15
+ boolean: boolean;
16
+ }
17
+
18
+ /**
19
+ * Parse args against a known flag set.
20
+ * Returns errors for: unknown flags, `--flag=value` form, short flags,
21
+ * missing values, combined flags.
22
+ */
23
+ export function parseArgs(args: string[], spec: FlagSpec[]): ParsedArgs {
24
+ const known = new Map<string, FlagSpec>();
25
+ for (const f of spec) known.set(f.name, f);
26
+
27
+ const positional: string[] = [];
28
+ const flags = new Map<string, string | true>();
29
+ const errors: string[] = [];
30
+
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i];
33
+
34
+ // Reject --flag=value form
35
+ if (a.startsWith('--') && a.includes('=')) {
36
+ errors.push(`invalid flag form "${a}" — use "--${a.slice(2).split('=')[0]} <value>"`);
37
+ continue;
38
+ }
39
+
40
+ // Reject short flags / combined flags
41
+ if (/^-[a-zA-Z]/.test(a) && !a.startsWith('--')) {
42
+ errors.push(`unknown flag "${a}" — no short flags supported`);
43
+ continue;
44
+ }
45
+
46
+ if (a.startsWith('--')) {
47
+ const name = a.slice(2);
48
+ const flagSpec = known.get(name);
49
+ if (flagSpec === undefined) {
50
+ errors.push(`unknown flag "--${name}"`);
51
+ continue;
52
+ }
53
+ if (flagSpec.boolean) {
54
+ flags.set(name, true);
55
+ } else {
56
+ const val = args[i + 1];
57
+ if (val === undefined || val.startsWith('--')) {
58
+ errors.push(`flag "--${name}" requires a value`);
59
+ } else {
60
+ flags.set(name, val);
61
+ i++; // consume value
62
+ }
63
+ }
64
+ continue;
65
+ }
66
+
67
+ positional.push(a);
68
+ }
69
+
70
+ return { positional, flags, errors };
71
+ }
72
+
73
+ /** Format arg errors per §37: what / where / what to do — WITHOUT the `✗` mark
74
+ * (error fields carry raw text; the human printer adds the mark). */
75
+ export function formatArgErrors(errors: string[], command: string): string {
76
+ return errors
77
+ .map(e => `${e}\n Run \`cans help\` for valid ${command} flags.`)
78
+ .join('\n');
79
+ }