cans-spec 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/bin/cans.js +76 -0
- package/bin/ts-loader.mjs +34 -0
- package/package.json +8 -5
- package/src/cli.ts +19 -15
- package/src/commands/budget.ts +8 -7
- package/src/commands/check.ts +83 -21
- package/src/commands/done.ts +7 -6
- package/src/commands/export.ts +9 -8
- package/src/commands/import.ts +14 -13
- package/src/commands/init.ts +7 -6
- package/src/commands/new.ts +9 -8
- package/src/commands/status.ts +6 -5
- package/src/converters/index.ts +4 -4
- package/src/converters/logseq.ts +2 -2
- package/src/converters/obsidian.ts +2 -2
- package/src/converters/opml.ts +1 -1
- package/src/converters/shared.ts +1 -1
- package/src/core/fs.ts +3 -4
- package/src/core/index.ts +10 -10
- package/src/core/outline.ts +46 -8
- package/src/core/output.ts +1 -1
- package/src/core/overflow.ts +2 -2
- package/src/core/redundancy.ts +59 -5
- package/src/core/refs.ts +169 -27
- package/src/core/rules.ts +33 -6
- package/src/core/runtime.ts +110 -0
- package/src/core/structure.ts +113 -58
- package/src/core/style.ts +77 -44
- package/src/core/token-budget.ts +9 -4
- package/src/types.ts +12 -1
- package/templates/_rules.yaml +1 -0
package/README.md
CHANGED
|
@@ -169,7 +169,15 @@ This is the most important section.
|
|
|
169
169
|
|
|
170
170
|
## Install
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
No build step. Source IS distribution. Two runtimes are supported — Bun is the
|
|
173
|
+
primary, Node.js is the fallback (issue #12):
|
|
174
|
+
|
|
175
|
+
- **Bun ≥ 1.0 (recommended, primary)** — fastest path, used automatically when
|
|
176
|
+
Bun is on your PATH.
|
|
177
|
+
- **Node.js ≥ 23.2 (fallback)** — for Termux / Android harnesses and any
|
|
178
|
+
environment without Bun. Termux: `pkg install nodejs` (ships Node 26.x ✓).
|
|
179
|
+
Node's builtin TypeScript stripping runs the sources directly; running from
|
|
180
|
+
a source checkout also works on Node ≥ 22.6 via `--experimental-strip-types`.
|
|
173
181
|
|
|
174
182
|
**npm**
|
|
175
183
|
|
|
@@ -187,6 +195,8 @@ bun install -g cans-spec
|
|
|
187
195
|
|
|
188
196
|
```bash
|
|
189
197
|
npx -p cans-spec cans init
|
|
198
|
+
# or, with Bun installed:
|
|
199
|
+
bunx cans-spec init
|
|
190
200
|
```
|
|
191
201
|
|
|
192
202
|
All three give you the same `cans` command:
|
|
@@ -315,19 +325,21 @@ cans/
|
|
|
315
325
|
|
|
316
326
|
## Requirements
|
|
317
327
|
|
|
318
|
-
- [Bun](https://bun.sh) ≥ 1.0
|
|
328
|
+
- [Bun](https://bun.sh) ≥ 1.0 (primary) **or** Node.js ≥ 23.2 (fallback, no Bun needed — Termux: `pkg install nodejs`)
|
|
319
329
|
- Git (for coordination; not required by CANS itself)
|
|
320
330
|
- An AI agent that can read markdown (all of them)
|
|
321
331
|
|
|
322
|
-
No
|
|
332
|
+
No Python. No `package-lock.json`. No build step. The `dependencies` field in `package.json` does not exist — zero runtime dependencies on both runtimes.
|
|
323
333
|
|
|
324
334
|
---
|
|
325
335
|
|
|
326
336
|
## Contributing
|
|
327
337
|
|
|
328
|
-
The entire codebase is ~
|
|
338
|
+
The entire codebase is ~1100 lines of TypeScript across 20 files. Read it in an afternoon.
|
|
329
339
|
|
|
330
|
-
- `bun test` runs the suite (
|
|
340
|
+
- `bun test` runs the suite on the primary runtime; `npm run test:node` runs the same suite on the Node fallback (node:test via a tiny bun:test compat shim in `test/node-compat.ts`)
|
|
341
|
+
- CI runs both (`test-bun` + `test-node` matrix on Node 22/24)
|
|
342
|
+
- All runtime-sensitive IO goes through one shim, `src/core/runtime.ts` — the only file allowed to touch Bun APIs; a self-audit test keeps it that way
|
|
331
343
|
- No build step. Edit `src/`, run `bun test`, done.
|
|
332
344
|
- PRs that add features get closed. PRs that delete code get merged.
|
|
333
345
|
- If you want a new command, open an issue and justify it. Most get rejected. That's the point.
|
package/bin/cans.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Portable launcher for the `cans` bin (issue #12).
|
|
4
|
+
*
|
|
5
|
+
* Priority: Bun stays the primary runtime.
|
|
6
|
+
* 1. Already executing under Bun (`bunx cans-spec`, `bun run`) → run the CLI
|
|
7
|
+
* in-process; the runtime shim's Bun fast paths apply.
|
|
8
|
+
* 2. Bun on PATH but this launcher was started by Node (`npm i -g cans-spec`
|
|
9
|
+
* then `cans ...`) → re-exec `bun src/cli.ts` so Bun fast paths apply.
|
|
10
|
+
* 3. No Bun → run the CLI in-process on Node, stripping TypeScript via
|
|
11
|
+
* Node's builtin `stripTypeScriptTypes` through bin/ts-loader.mjs (Node's
|
|
12
|
+
* default stripping refuses files under node_modules — see issue #12 QA).
|
|
13
|
+
*
|
|
14
|
+
* This file is plain JavaScript ON PURPOSE: it must execute on any Node that
|
|
15
|
+
* npm can provide, before any TypeScript capability is available.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { register } from 'node:module';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const cli = join(here, '..', 'src', 'cli.ts');
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
|
|
26
|
+
/** Run cli.ts in the current process; it calls process.exit() itself, but a
|
|
27
|
+
* load-time failure (unreadable package.json, syntax) must still report. */
|
|
28
|
+
async function runInProcess() {
|
|
29
|
+
try {
|
|
30
|
+
await import(pathToFileURL(cli).href);
|
|
31
|
+
process.exit(process.exitCode ?? 0);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`✗ Internal error: ${e instanceof Error ? e.message : e}`);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1. Already under Bun — in-process, Bun fast paths.
|
|
39
|
+
if (typeof globalThis.Bun !== 'undefined') {
|
|
40
|
+
await runInProcess();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Bun on PATH — re-exec so Bun stays the primary runtime.
|
|
44
|
+
const probe = spawnSync('bun', ['--version'], { stdio: 'ignore' });
|
|
45
|
+
if (!probe.error && probe.status === 0) {
|
|
46
|
+
const r = spawnSync('bun', [cli, ...args], { stdio: 'inherit' });
|
|
47
|
+
if (r.error) {
|
|
48
|
+
console.error(`✗ Internal error: failed to launch bun: ${r.error.message}`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
process.exit(r.status ?? 130);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Node fallback — needs node:module.stripTypeScriptTypes (Node ≥23.2; made
|
|
55
|
+
// stable in 23.6). Termux `pkg install nodejs` ships 26.x, which qualifies.
|
|
56
|
+
// Namespace access (not a named import) so older Nodes link this file fine.
|
|
57
|
+
const { stripTypeScriptTypes } = await import('node:module');
|
|
58
|
+
if (typeof stripTypeScriptTypes !== 'function') {
|
|
59
|
+
console.error(
|
|
60
|
+
`✗ cans requires Bun >= 1.0 (primary) or Node >= 23.2 with builtin TypeScript stripping; found Node ${process.versions.node} without Bun.`,
|
|
61
|
+
);
|
|
62
|
+
console.error(' Install Bun: https://bun.sh — Termux: pkg install nodejs.');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Node prints an ExperimentalWarning for stripTypeScriptTypes on every run;
|
|
67
|
+
// suppress just that one and pass everything else through untouched.
|
|
68
|
+
process.removeAllListeners('warning');
|
|
69
|
+
process.on('warning', (w) => {
|
|
70
|
+
if (w && w.name === 'ExperimentalWarning' && String(w.message).includes('stripTypeScriptTypes')) return;
|
|
71
|
+
console.error(`(node:${process.pid}) [${w?.name ?? 'Warning'}] ${w?.message ?? w}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 4. Register the .ts loader hook, then execute the CLI in-process.
|
|
75
|
+
register(new URL('./ts-loader.mjs', import.meta.url));
|
|
76
|
+
await runInProcess();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESM loader hook for the Node fallback runtime (issue #12).
|
|
3
|
+
*
|
|
4
|
+
* Node's built-in TypeScript type-stripping refuses to process files under
|
|
5
|
+
* `node_modules`, which breaks `npm i -g cans-spec` on machines without Bun —
|
|
6
|
+
* the exact Termux scenario this package must support. This hook performs the
|
|
7
|
+
* SAME erasable-syntax stripping ourselves via Node's builtin
|
|
8
|
+
* `node:module.stripTypeScriptTypes` (no dependencies, no build step — source
|
|
9
|
+
* stays distribution), bypassing only that path-based veto.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { stripTypeScriptTypes } from 'node:module';
|
|
14
|
+
|
|
15
|
+
// This hook runs inside Node's loader thread, whose default handler prints the
|
|
16
|
+
// `stripTypeScriptTypes` ExperimentalWarning on every CLI run. Suppress warnings
|
|
17
|
+
// scoped to THIS thread only (CANS_DEBUG_WARNINGS=1 restores them).
|
|
18
|
+
if (process.env.CANS_DEBUG_WARNINGS !== '1') {
|
|
19
|
+
process.removeAllListeners('warning');
|
|
20
|
+
process.on('warning', () => {});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function load(url, context, nextLoad) {
|
|
24
|
+
if (url.endsWith('.ts')) {
|
|
25
|
+
const file = fileURLToPath(url);
|
|
26
|
+
const code = await readFile(file, 'utf8');
|
|
27
|
+
// Node ≥23.2 returns an object ({ source }); some versions return the
|
|
28
|
+
// stripped string directly — accept both shapes.
|
|
29
|
+
const result = stripTypeScriptTypes(code, { sourceUrl: file });
|
|
30
|
+
const source = typeof result === 'string' ? result : result?.source;
|
|
31
|
+
return { format: 'module', source, shortCircuit: true };
|
|
32
|
+
}
|
|
33
|
+
return nextLoad(url, context);
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cans-spec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
|
|
5
5
|
"bin": {
|
|
6
|
-
"cans": "./
|
|
6
|
+
"cans": "./bin/cans.js"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|
|
10
|
-
"bun": ">=1.0.0"
|
|
10
|
+
"bun": ">=1.0.0",
|
|
11
|
+
"node": ">=23.2.0"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
13
|
-
"typecheck": "
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
14
15
|
"test": "bun test",
|
|
15
|
-
"
|
|
16
|
+
"test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\"",
|
|
17
|
+
"prepublishOnly": "npm run typecheck && bun test"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
20
|
+
"bin/",
|
|
18
21
|
"src/",
|
|
19
22
|
"templates/",
|
|
20
23
|
"README.md",
|
package/src/cli.ts
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import type { CommandResult } from './types';
|
|
4
|
-
import { emit } from './core/output';
|
|
5
|
-
import
|
|
3
|
+
import type { CommandResult } from './types.ts';
|
|
4
|
+
import { emit } from './core/output.ts';
|
|
5
|
+
import { argv, readText, dirFromUrl } from './core/runtime.ts';
|
|
6
|
+
import { join } from 'node:path';
|
|
6
7
|
|
|
7
|
-
/** §44: version line — derived from package.json so it can never drift.
|
|
8
|
-
|
|
8
|
+
/** §44: version line — derived from package.json so it can never drift.
|
|
9
|
+
* Read through the runtime shim (issue #12): a bare JSON import has no
|
|
10
|
+
* import attributes and is therefore not portable to the Node fallback. */
|
|
11
|
+
const PKG_FILE = join(dirFromUrl(import.meta.url), '..', 'package.json');
|
|
12
|
+
const VERSION: string = (JSON.parse(await readText(PKG_FILE)) as { version: string }).version;
|
|
9
13
|
|
|
10
|
-
const [cmd, ...args] =
|
|
14
|
+
const [cmd, ...args] = argv();
|
|
11
15
|
|
|
12
16
|
async function dispatch(): Promise<CommandResult> {
|
|
13
17
|
switch (cmd) {
|
|
14
|
-
case 'init': return (await import('./commands/init')).run(args);
|
|
15
|
-
case 'check': return (await import('./commands/check')).run(args);
|
|
16
|
-
case 'new': return (await import('./commands/new')).run(args);
|
|
17
|
-
case 'done': return (await import('./commands/done')).run(args);
|
|
18
|
-
case 'status': return (await import('./commands/status')).run(args);
|
|
19
|
-
case 'budget': return (await import('./commands/budget')).run(args);
|
|
20
|
-
case 'import': return (await import('./commands/import')).run(args);
|
|
21
|
-
case 'export': return (await import('./commands/export')).run(args);
|
|
18
|
+
case 'init': return (await import('./commands/init.ts')).run(args);
|
|
19
|
+
case 'check': return (await import('./commands/check.ts')).run(args);
|
|
20
|
+
case 'new': return (await import('./commands/new.ts')).run(args);
|
|
21
|
+
case 'done': return (await import('./commands/done.ts')).run(args);
|
|
22
|
+
case 'status': return (await import('./commands/status.ts')).run(args);
|
|
23
|
+
case 'budget': return (await import('./commands/budget.ts')).run(args);
|
|
24
|
+
case 'import': return (await import('./commands/import.ts')).run(args);
|
|
25
|
+
case 'export': return (await import('./commands/export.ts')).run(args);
|
|
22
26
|
case 'help':
|
|
23
27
|
case '-h': // §20/§44: conventional help shortcuts
|
|
24
28
|
case '--help':
|
package/src/commands/budget.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { join, basename } from 'path';
|
|
2
|
-
import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types.ts';
|
|
3
|
+
import { readText } from '../core/runtime.ts';
|
|
4
|
+
import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs.ts';
|
|
5
|
+
import { parseOutline } from '../core/outline.ts';
|
|
6
|
+
import { loadRules } from '../core/rules.ts';
|
|
7
|
+
import { buildRefGraph } from '../core/refs.ts';
|
|
8
|
+
import { buildReadPlan, buildWritePlan } from '../core/token-budget.ts';
|
|
8
9
|
|
|
9
10
|
export interface BudgetArgs {
|
|
10
11
|
mode: 'read' | 'write';
|
|
@@ -164,7 +165,7 @@ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWrit
|
|
|
164
165
|
const files = new Map<string, OutlineNode[]>();
|
|
165
166
|
for (const rel of discoverSpecFiles(workspace)) {
|
|
166
167
|
try {
|
|
167
|
-
files.set(rel, parseOutline(await
|
|
168
|
+
files.set(rel, parseOutline(await readText(join(workspace, rel)), rel));
|
|
168
169
|
} catch {
|
|
169
170
|
// unreadable spec file: excluded from the plan
|
|
170
171
|
}
|
package/src/commands/check.ts
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
|
-
import type { CheckResult, Issue, OutlineNode } from '../types';
|
|
2
|
+
import type { CheckResult, Issue, OutlineNode } from '../types.ts';
|
|
3
|
+
import { readText, writeText } from '../core/runtime.ts';
|
|
3
4
|
import {
|
|
4
5
|
discoverSpecFiles, discoverActiveTasks, discoverAdrs, resolveWorkspaceRoot,
|
|
5
6
|
dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
|
|
6
|
-
} from '../core/fs';
|
|
7
|
+
} from '../core/fs.ts';
|
|
7
8
|
import {
|
|
8
|
-
parseOutline, extractBackPointers,
|
|
9
|
+
parseOutline, extractBackPointers, realNodes, maxDepth as outlineMaxDepth,
|
|
9
10
|
type ParseWarning,
|
|
10
|
-
} from '../core/outline';
|
|
11
|
-
import { loadRules } from '../core/rules';
|
|
12
|
-
import { checkStructure, checkTbdPolicy } from '../core/structure';
|
|
13
|
-
import { checkStyle } from '../core/style';
|
|
14
|
-
import { checkOverflow, checkNoChaining } from '../core/overflow';
|
|
15
|
-
import { checkRedundancy } from '../core/redundancy';
|
|
11
|
+
} from '../core/outline.ts';
|
|
12
|
+
import { loadRules } from '../core/rules.ts';
|
|
13
|
+
import { checkStructure, checkTbdPolicy } from '../core/structure.ts';
|
|
14
|
+
import { checkStyle } from '../core/style.ts';
|
|
15
|
+
import { checkOverflow, checkNoChaining } from '../core/overflow.ts';
|
|
16
|
+
import { checkRedundancy } from '../core/redundancy.ts';
|
|
16
17
|
import {
|
|
17
18
|
buildRefGraph, checkRefs, detectDeepHops, detectOrphans,
|
|
18
19
|
rebuildBackPointers, targetMatchesKey,
|
|
19
|
-
} from '../core/refs';
|
|
20
|
-
import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args';
|
|
20
|
+
} from '../core/refs.ts';
|
|
21
|
+
import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args.ts';
|
|
21
22
|
|
|
22
23
|
export interface CheckArgs {
|
|
23
24
|
fix: boolean;
|
|
@@ -41,6 +42,10 @@ const CHECK_FLAGS: FlagSpec[] = [
|
|
|
41
42
|
];
|
|
42
43
|
|
|
43
44
|
const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
|
|
45
|
+
// Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
|
|
46
|
+
// trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
|
|
47
|
+
// the outline.ts FENCE_RE convention; the two must never diverge.
|
|
48
|
+
const FENCE_RE = /^```/;
|
|
44
49
|
|
|
45
50
|
// globFiles throws ENOENT on missing dirs — guard the optional ones.
|
|
46
51
|
function safeActiveTasks(root: string): string[] {
|
|
@@ -110,15 +115,56 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
|
|
|
110
115
|
return null;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
/** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
|
|
119
|
+
* keeping the surrounding prose. Collapses the double space left where the
|
|
120
|
+
* comment sat (one seam space absorbed) and trims trailing whitespace. The
|
|
121
|
+
* caller drops the line only when nothing but the comment remains. */
|
|
122
|
+
function stripRefByKeepProse(raw: string): string {
|
|
123
|
+
const m = raw.match(REF_BY_RE);
|
|
124
|
+
if (m === null || m.index === undefined) return raw;
|
|
125
|
+
let out = raw.slice(0, m.index) + raw.slice(m.index + m[0].length);
|
|
126
|
+
if (out.charAt(m.index - 1) === ' ' && out.charAt(m.index) === ' ') {
|
|
127
|
+
out = out.slice(0, m.index - 1) + out.slice(m.index);
|
|
128
|
+
}
|
|
129
|
+
return out.replace(/[ \t]+$/, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
/** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
|
|
133
|
+
* Fence-aware and prose-preserving (issue #6):
|
|
134
|
+
* - Lines inside ``` fences (and the fence markers themselves) are preserved
|
|
135
|
+
* byte-for-byte: never scanned as hits, never rewritten, never dropped, and
|
|
136
|
+
* fenced "- fake bullets" are never insertion anchors.
|
|
137
|
+
* - Replaces the first existing comment's content; duplicates/stale hits keep
|
|
138
|
+
* their line — bullets are stripped as before, non-bullet prose loses only
|
|
139
|
+
* the comment substring (the line is dropped only when bare).
|
|
140
|
+
* - With no hits and a desired body, inserts a fresh comment line right after
|
|
141
|
+
* the first root bullet outside any fence, appended at end when none exists
|
|
142
|
+
* — unless the file ends inside an unterminated fence, in which case the
|
|
143
|
+
* comment is inserted before the fence opener (never inside a fence).
|
|
144
|
+
* Exported for regression tests (issue #6); behavior lives in this module. */
|
|
145
|
+
export function rewriteRefBy(source: string, body: string | null): string {
|
|
117
146
|
const lines: Array<string | null> = source.split('\n');
|
|
118
147
|
const comment = body !== null && body !== '' ? `<!-- ref-by: ${body} -->` : null;
|
|
119
148
|
const hits: number[] = [];
|
|
149
|
+
let fenceOpen = false;
|
|
150
|
+
let fenceOpener = -1;
|
|
151
|
+
const inFence: boolean[] = new Array(lines.length).fill(false);
|
|
120
152
|
for (let i = 0; i < lines.length; i++) {
|
|
121
|
-
|
|
153
|
+
const raw = lines[i]!;
|
|
154
|
+
if (FENCE_RE.test(raw.trim())) {
|
|
155
|
+
// Fence marker: toggles state; never a hit, never an anchor, never touched.
|
|
156
|
+
inFence[i] = true;
|
|
157
|
+
if (fenceOpen) {
|
|
158
|
+
fenceOpen = false;
|
|
159
|
+
} else {
|
|
160
|
+
fenceOpen = true;
|
|
161
|
+
fenceOpener = i;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
inFence[i] = fenceOpen;
|
|
166
|
+
if (fenceOpen) continue;
|
|
167
|
+
if (REF_BY_RE.test(raw)) hits.push(i);
|
|
122
168
|
}
|
|
123
169
|
if (hits.length > 0) {
|
|
124
170
|
for (let j = 0; j < hits.length; j++) {
|
|
@@ -128,17 +174,27 @@ function rewriteRefBy(source: string, body: string | null): string {
|
|
|
128
174
|
lines[i] = raw.replace(REF_BY_RE, comment);
|
|
129
175
|
} else {
|
|
130
176
|
const isBullet = /^\s*-\s/.test(raw);
|
|
131
|
-
|
|
177
|
+
if (isBullet) {
|
|
178
|
+
lines[i] = raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '');
|
|
179
|
+
} else {
|
|
180
|
+
// Issue #6: never delete a prose line whole — strip the comment only.
|
|
181
|
+
const stripped = stripRefByKeepProse(raw);
|
|
182
|
+
lines[i] = stripped.trim() === '' ? null : stripped;
|
|
183
|
+
}
|
|
132
184
|
}
|
|
133
185
|
}
|
|
134
186
|
} else if (comment !== null) {
|
|
135
187
|
let insertAt = lines.length;
|
|
136
188
|
for (let i = 0; i < lines.length; i++) {
|
|
189
|
+
if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
|
|
137
190
|
if (/^- /.test(lines[i]!)) {
|
|
138
191
|
insertAt = i + 1;
|
|
139
192
|
break;
|
|
140
193
|
}
|
|
141
194
|
}
|
|
195
|
+
// Issue #6: never insert inside an open fence — appending at EOF while a
|
|
196
|
+
// fence is unterminated would corrupt the fenced region.
|
|
197
|
+
if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
|
|
142
198
|
lines.splice(insertAt, 0, comment);
|
|
143
199
|
}
|
|
144
200
|
return lines.filter((l): l is string => l !== null).join('\n');
|
|
@@ -181,7 +237,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
181
237
|
for (const rel of specRel) {
|
|
182
238
|
let text = '';
|
|
183
239
|
try {
|
|
184
|
-
text = await
|
|
240
|
+
text = await readText(join(root, rel));
|
|
185
241
|
} catch (e) {
|
|
186
242
|
issues.push({
|
|
187
243
|
file: rel, line: 0, level: 'error', category: 'structure',
|
|
@@ -212,7 +268,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
212
268
|
const auxFiles = new Map<string, OutlineNode[]>();
|
|
213
269
|
for (const rel of [...safeActiveTasks(root), ...safeAdrs(root)]) {
|
|
214
270
|
try {
|
|
215
|
-
const text = await
|
|
271
|
+
const text = await readText(join(root, rel));
|
|
216
272
|
auxFiles.set(rel, parseOutline(text, rel));
|
|
217
273
|
} catch {
|
|
218
274
|
// unreadable/unparseable aux file: its refs are simply not counted
|
|
@@ -308,7 +364,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
308
364
|
const targetFiles = new Map<string, OutlineNode[]>();
|
|
309
365
|
for (const rel of discoverOverflowTargets(root)) {
|
|
310
366
|
try {
|
|
311
|
-
targetFiles.set(rel, parseOutline(await
|
|
367
|
+
targetFiles.set(rel, parseOutline(await readText(join(root, rel)), rel));
|
|
312
368
|
} catch {
|
|
313
369
|
// unreadable overflow target: skipped
|
|
314
370
|
}
|
|
@@ -326,7 +382,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
326
382
|
const body = desired.get(rel) ?? null;
|
|
327
383
|
const rewritten = rewriteRefBy(source, body);
|
|
328
384
|
if (rewritten !== source) {
|
|
329
|
-
await
|
|
385
|
+
await writeText(join(root, rel), rewritten);
|
|
330
386
|
specSources.set(rel, rewritten);
|
|
331
387
|
backPointersUpdated++;
|
|
332
388
|
}
|
|
@@ -363,7 +419,13 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
363
419
|
let nodeCount = 0;
|
|
364
420
|
let depthMax = 0;
|
|
365
421
|
for (const nodes of specFiles.values()) {
|
|
366
|
-
|
|
422
|
+
// Issue #8: report REAL nodes only — synthetic "(table)"/"(code fence)"
|
|
423
|
+
// placeholders (leading table/fence before the first bullet) are not
|
|
424
|
+
// user content and inflated the header count and budget estimates.
|
|
425
|
+
nodeCount += realNodes(nodes).length;
|
|
426
|
+
// Depth keeps the full tree: a synthetic root sits at indent 0, so it can
|
|
427
|
+
// never raise depthMax; a workspace containing only phantoms now has
|
|
428
|
+
// nodeCount 0 and correctly reports maxDepth 0 (§35 empty-workspace case).
|
|
367
429
|
depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
|
|
368
430
|
}
|
|
369
431
|
const refsTotal = [...graph.forward.values()].reduce((a, ts) => a + ts.length, 0);
|
package/src/commands/done.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import { renameSync } from 'fs';
|
|
3
|
-
import type { DoneResult, OutlineNode } from '../types';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import type { DoneResult, OutlineNode } from '../types.ts';
|
|
4
|
+
import { readText } from '../core/runtime.ts';
|
|
5
|
+
import { resolveWorkspaceRoot, mkdirp, isFile, dirExists, globFiles } from '../core/fs.ts';
|
|
6
|
+
import { parseOutline, flattenNodes } from '../core/outline.ts';
|
|
7
|
+
import { checkWorkspace, type CheckArgs } from './check.ts';
|
|
8
|
+
import { parseArgs, type FlagSpec } from '../core/args.ts';
|
|
8
9
|
|
|
9
10
|
export interface DoneArgs {
|
|
10
11
|
name: string;
|
|
@@ -105,7 +106,7 @@ export async function run(args: string[]): Promise<DoneResult> {
|
|
|
105
106
|
let taskNodes: OutlineNode[] = [];
|
|
106
107
|
let flat: OutlineNode[] = [];
|
|
107
108
|
try {
|
|
108
|
-
taskNodes = parseOutline(await
|
|
109
|
+
taskNodes = parseOutline(await readText(taskFile), `_tasks/${name}.md`);
|
|
109
110
|
flat = flattenNodes(taskNodes);
|
|
110
111
|
} catch {
|
|
111
112
|
return failResult(name, `cannot parse _tasks/${name}.md — check for tab indentation or malformed content`);
|
package/src/commands/export.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { join, basename, relative } from 'path';
|
|
2
|
-
import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types';
|
|
2
|
+
import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types.ts';
|
|
3
|
+
import { readText, writeText } from '../core/runtime.ts';
|
|
3
4
|
import {
|
|
4
5
|
resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, discoverAdrs,
|
|
5
6
|
mkdirp, dirExists, exists,
|
|
6
|
-
} from '../core/fs';
|
|
7
|
-
import { parseOutline } from '../core/outline';
|
|
8
|
-
import { serializeOpml } from '../converters/opml';
|
|
9
|
-
import { serializeLogseq } from '../converters/logseq';
|
|
10
|
-
import { serializeObsidian } from '../converters/obsidian';
|
|
7
|
+
} from '../core/fs.ts';
|
|
8
|
+
import { parseOutline } from '../core/outline.ts';
|
|
9
|
+
import { serializeOpml } from '../converters/opml.ts';
|
|
10
|
+
import { serializeLogseq } from '../converters/logseq.ts';
|
|
11
|
+
import { serializeObsidian } from '../converters/obsidian.ts';
|
|
11
12
|
|
|
12
13
|
export interface ExportArgs {
|
|
13
14
|
format: ExportFormat;
|
|
@@ -185,7 +186,7 @@ export async function run(args: string[]): Promise<ExportResult> {
|
|
|
185
186
|
for (const rel of sources) {
|
|
186
187
|
let text = '';
|
|
187
188
|
try {
|
|
188
|
-
text = await
|
|
189
|
+
text = await readText(join(workspace, rel));
|
|
189
190
|
} catch {
|
|
190
191
|
continue;
|
|
191
192
|
}
|
|
@@ -202,7 +203,7 @@ export async function run(args: string[]): Promise<ExportResult> {
|
|
|
202
203
|
if (!opts.dryRun) {
|
|
203
204
|
try {
|
|
204
205
|
mkdirp(fmtDir);
|
|
205
|
-
await
|
|
206
|
+
await writeText(join(fmtDir, outputFileName(rel, f)), content);
|
|
206
207
|
} catch (e) {
|
|
207
208
|
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
208
209
|
if (code === 'ENOTDIR' || code === 'EEXIST') return notDirError(fmtDir);
|
package/src/commands/import.ts
CHANGED
|
@@ -2,15 +2,16 @@ import { join, basename, dirname } from 'path';
|
|
|
2
2
|
import { readdirSync } from 'fs';
|
|
3
3
|
import type {
|
|
4
4
|
ImportResult, ImportFormat, ImportConflict, MergeStrategy, ExternalNode,
|
|
5
|
-
} from '../types';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
5
|
+
} from '../types.ts';
|
|
6
|
+
import { readText, writeText } from '../core/runtime.ts';
|
|
7
|
+
import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs.ts';
|
|
8
|
+
import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml.ts';
|
|
9
|
+
import { parseLogseq } from '../converters/logseq.ts';
|
|
10
|
+
import { parseObsidian, stripFrontmatter } from '../converters/obsidian.ts';
|
|
10
11
|
import {
|
|
11
12
|
serializeToCans, parseFromCans, stripMetadata, parseCheckbox,
|
|
12
13
|
extractOverflowContent, type OverflowExtraction,
|
|
13
|
-
} from '../converters/shared';
|
|
14
|
+
} from '../converters/shared.ts';
|
|
14
15
|
|
|
15
16
|
export interface ImportArgs {
|
|
16
17
|
format: ImportFormat;
|
|
@@ -325,7 +326,7 @@ async function findExistingByRootText(targetDir: string, imported: ExternalNode[
|
|
|
325
326
|
for (const rel of discoverSpecFiles(targetDir)) {
|
|
326
327
|
let text = '';
|
|
327
328
|
try {
|
|
328
|
-
text = await
|
|
329
|
+
text = await readText(join(targetDir, rel));
|
|
329
330
|
} catch {
|
|
330
331
|
continue;
|
|
331
332
|
}
|
|
@@ -400,7 +401,7 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
400
401
|
for (const src of files) {
|
|
401
402
|
let text = '';
|
|
402
403
|
try {
|
|
403
|
-
text = await
|
|
404
|
+
text = await readText(src);
|
|
404
405
|
} catch {
|
|
405
406
|
continue;
|
|
406
407
|
}
|
|
@@ -454,7 +455,7 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
454
455
|
if (existingRel !== null) {
|
|
455
456
|
const absTarget = join(workspace, existingRel);
|
|
456
457
|
const outcome = mergeInto(
|
|
457
|
-
await
|
|
458
|
+
await readText(absTarget),
|
|
458
459
|
imported,
|
|
459
460
|
opts.mergeStrategy,
|
|
460
461
|
existingRel,
|
|
@@ -462,11 +463,11 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
462
463
|
conflicts.push(...outcome.conflicts);
|
|
463
464
|
if (outcome.content !== null) {
|
|
464
465
|
if (!opts.dryRun) {
|
|
465
|
-
await
|
|
466
|
+
await writeText(absTarget, canonicalizeRefTargets(outcome.content));
|
|
466
467
|
for (const ovf of overflow) {
|
|
467
468
|
const ovfAbs = join(workspace, ovf.overflowFile);
|
|
468
469
|
mkdirp(dirname(ovfAbs));
|
|
469
|
-
await
|
|
470
|
+
await writeText(ovfAbs, `${ovf.content}\n`);
|
|
470
471
|
}
|
|
471
472
|
}
|
|
472
473
|
merged.push(existingRel);
|
|
@@ -485,11 +486,11 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
485
486
|
const cansText = canonicalizeRefTargets(serializeToCans(imported));
|
|
486
487
|
if (!opts.dryRun) {
|
|
487
488
|
mkdirp(dirname(absTarget));
|
|
488
|
-
await
|
|
489
|
+
await writeText(absTarget, cansText);
|
|
489
490
|
for (const ovf of overflow) {
|
|
490
491
|
const ovfAbs = join(workspace, ovf.overflowFile);
|
|
491
492
|
mkdirp(dirname(ovfAbs));
|
|
492
|
-
await
|
|
493
|
+
await writeText(ovfAbs, `${ovf.content}\n`);
|
|
493
494
|
}
|
|
494
495
|
}
|
|
495
496
|
newFiles.push(relName);
|
package/src/commands/init.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { basename, dirname, join, relative } from 'path';
|
|
2
|
-
import type { InitResult } from '../types';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import type { InitResult } from '../types.ts';
|
|
3
|
+
import { readText, writeText, dirFromUrl } from '../core/runtime.ts';
|
|
4
|
+
import { resolveWorkspaceRoot, resolveInitTarget, mkdirp, exists, dirExists } from '../core/fs.ts';
|
|
5
|
+
import { parseArgs, type FlagSpec } from '../core/args.ts';
|
|
5
6
|
|
|
6
|
-
const TEMPLATES_DIR = join(import.meta.
|
|
7
|
+
const TEMPLATES_DIR = join(dirFromUrl(import.meta.url), '..', '..', 'templates');
|
|
7
8
|
|
|
8
9
|
async function readTemplate(name: string): Promise<string> {
|
|
9
|
-
return await
|
|
10
|
+
return await readText(join(TEMPLATES_DIR, name));
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface InitArgs {
|
|
@@ -168,7 +169,7 @@ export async function run(args: string[]): Promise<InitResult> {
|
|
|
168
169
|
skipped.push(entry.path);
|
|
169
170
|
continue;
|
|
170
171
|
}
|
|
171
|
-
await
|
|
172
|
+
await writeText(abs, entry.content ?? '');
|
|
172
173
|
created.push(entry.path);
|
|
173
174
|
}
|
|
174
175
|
|