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,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, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/'/g, ''');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Inverse of encodeXmlEntity. `&` last so `&lt;` decodes to literal `<`. */
|
|
14
|
+
export function decodeXmlEntity(s: string): string {
|
|
15
|
+
return s
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, "'")
|
|
20
|
+
.replace(/&#(\d+);/g, (_m, d: string) => String.fromCharCode(Number(d)))
|
|
21
|
+
.replace(/&/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
|
+
}
|
package/src/core/args.ts
ADDED
|
@@ -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
|
+
}
|