cans-spec 0.1.1 → 0.2.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 +9 -6
- package/src/cli.ts +19 -14
- package/src/commands/budget.ts +8 -7
- package/src/commands/check.ts +15 -14
- 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 +1 -1
- package/src/core/output.ts +1 -1
- package/src/core/overflow.ts +2 -2
- package/src/core/redundancy.ts +2 -2
- package/src/core/refs.ts +3 -3
- package/src/core/rules.ts +1 -1
- package/src/core/runtime.ts +110 -0
- package/src/core/structure.ts +2 -2
- package/src/core/style.ts +1 -1
- package/src/core/token-budget.ts +3 -3
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.
|
|
4
|
-
"description": "Canonical Agent-Native Spec
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Canonical Agent-Native Spec \u2014 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,23 +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';
|
|
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';
|
|
5
7
|
|
|
6
|
-
/** §44: version line
|
|
7
|
-
|
|
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;
|
|
8
13
|
|
|
9
|
-
const [cmd, ...args] =
|
|
14
|
+
const [cmd, ...args] = argv();
|
|
10
15
|
|
|
11
16
|
async function dispatch(): Promise<CommandResult> {
|
|
12
17
|
switch (cmd) {
|
|
13
|
-
case 'init': return (await import('./commands/init')).run(args);
|
|
14
|
-
case 'check': return (await import('./commands/check')).run(args);
|
|
15
|
-
case 'new': return (await import('./commands/new')).run(args);
|
|
16
|
-
case 'done': return (await import('./commands/done')).run(args);
|
|
17
|
-
case 'status': return (await import('./commands/status')).run(args);
|
|
18
|
-
case 'budget': return (await import('./commands/budget')).run(args);
|
|
19
|
-
case 'import': return (await import('./commands/import')).run(args);
|
|
20
|
-
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);
|
|
21
26
|
case 'help':
|
|
22
27
|
case '-h': // §20/§44: conventional help shortcuts
|
|
23
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
9
|
parseOutline, extractBackPointers, flattenNodes, 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;
|
|
@@ -181,7 +182,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
181
182
|
for (const rel of specRel) {
|
|
182
183
|
let text = '';
|
|
183
184
|
try {
|
|
184
|
-
text = await
|
|
185
|
+
text = await readText(join(root, rel));
|
|
185
186
|
} catch (e) {
|
|
186
187
|
issues.push({
|
|
187
188
|
file: rel, line: 0, level: 'error', category: 'structure',
|
|
@@ -212,7 +213,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
212
213
|
const auxFiles = new Map<string, OutlineNode[]>();
|
|
213
214
|
for (const rel of [...safeActiveTasks(root), ...safeAdrs(root)]) {
|
|
214
215
|
try {
|
|
215
|
-
const text = await
|
|
216
|
+
const text = await readText(join(root, rel));
|
|
216
217
|
auxFiles.set(rel, parseOutline(text, rel));
|
|
217
218
|
} catch {
|
|
218
219
|
// unreadable/unparseable aux file: its refs are simply not counted
|
|
@@ -308,7 +309,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
308
309
|
const targetFiles = new Map<string, OutlineNode[]>();
|
|
309
310
|
for (const rel of discoverOverflowTargets(root)) {
|
|
310
311
|
try {
|
|
311
|
-
targetFiles.set(rel, parseOutline(await
|
|
312
|
+
targetFiles.set(rel, parseOutline(await readText(join(root, rel)), rel));
|
|
312
313
|
} catch {
|
|
313
314
|
// unreadable overflow target: skipped
|
|
314
315
|
}
|
|
@@ -326,7 +327,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
326
327
|
const body = desired.get(rel) ?? null;
|
|
327
328
|
const rewritten = rewriteRefBy(source, body);
|
|
328
329
|
if (rewritten !== source) {
|
|
329
|
-
await
|
|
330
|
+
await writeText(join(root, rel), rewritten);
|
|
330
331
|
specSources.set(rel, rewritten);
|
|
331
332
|
backPointersUpdated++;
|
|
332
333
|
}
|
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
|
|
package/src/commands/new.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { join, basename } from 'path';
|
|
2
|
-
import type { NewResult } from '../types';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import type { NewResult } from '../types.ts';
|
|
3
|
+
import { readText, writeText, dirFromUrl } from '../core/runtime.ts';
|
|
4
|
+
import { resolveWorkspaceRoot, resolveWorkspaceOrCreate, mkdirp, discoverAdrs, dirExists, isFile } 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
|
/** lowercase → strip double quotes → non-alphanumeric runs → hyphens → trim hyphens.
|
|
@@ -58,7 +59,7 @@ async function existingContentGuard(
|
|
|
58
59
|
change: string,
|
|
59
60
|
): Promise<NewResult | null> {
|
|
60
61
|
if (!isFile(abs)) return null;
|
|
61
|
-
const existing = await
|
|
62
|
+
const existing = await readText(abs);
|
|
62
63
|
if (existing === content) {
|
|
63
64
|
return { ok: true, command: 'new', exitCode: 0, change, file };
|
|
64
65
|
}
|
|
@@ -116,7 +117,7 @@ export async function run(args: string[]): Promise<NewResult> {
|
|
|
116
117
|
const file = join('_tasks', `${slug}.md`);
|
|
117
118
|
const guard = await existingContentGuard(join(workspace, file), file, content, slug);
|
|
118
119
|
if (guard !== null) return guard;
|
|
119
|
-
await
|
|
120
|
+
await writeText(join(workspace, file), content);
|
|
120
121
|
return { ok: true, command: 'new', exitCode: 0, change: slug, file };
|
|
121
122
|
}
|
|
122
123
|
|
|
@@ -133,6 +134,6 @@ export async function run(args: string[]): Promise<NewResult> {
|
|
|
133
134
|
const file = join('_adr', `${NNN}-${slug}.md`);
|
|
134
135
|
const guard = await existingContentGuard(join(workspace, file), file, content, slug);
|
|
135
136
|
if (guard !== null) return guard;
|
|
136
|
-
await
|
|
137
|
+
await writeText(join(workspace, file), content);
|
|
137
138
|
return { ok: true, command: 'new', exitCode: 0, change: slug, file };
|
|
138
139
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { join, basename } from 'path';
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
-
import type { StatusResult, OutlineNode } from '../types';
|
|
3
|
+
import type { StatusResult, OutlineNode } from '../types.ts';
|
|
4
|
+
import { readText } from '../core/runtime.ts';
|
|
4
5
|
import {
|
|
5
6
|
resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks,
|
|
6
7
|
discoverArchivedTasks, discoverAdrs, dirExists,
|
|
7
|
-
} from '../core/fs';
|
|
8
|
-
import { parseOutline, flattenNodes } from '../core/outline';
|
|
9
|
-
import { parseArgs, type FlagSpec } from '../core/args';
|
|
8
|
+
} from '../core/fs.ts';
|
|
9
|
+
import { parseOutline, flattenNodes } from '../core/outline.ts';
|
|
10
|
+
import { parseArgs, type FlagSpec } from '../core/args.ts';
|
|
10
11
|
|
|
11
12
|
export interface StatusArgs {
|
|
12
13
|
unclaimed: boolean;
|
|
@@ -100,7 +101,7 @@ export async function run(args: string[]): Promise<StatusResult> {
|
|
|
100
101
|
for (const rel of activeTasks) {
|
|
101
102
|
let flat: OutlineNode[] = [];
|
|
102
103
|
try {
|
|
103
|
-
flat = flattenNodes(parseOutline(await
|
|
104
|
+
flat = flattenNodes(parseOutline(await readText(join(workspace, rel)), rel));
|
|
104
105
|
} catch {
|
|
105
106
|
// unparsable task file: contributes nothing but its existence
|
|
106
107
|
}
|
package/src/converters/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export * from './shared';
|
|
2
|
-
export * from './opml';
|
|
3
|
-
export * from './logseq';
|
|
4
|
-
export * from './obsidian';
|
|
1
|
+
export * from './shared.ts';
|
|
2
|
+
export * from './opml.ts';
|
|
3
|
+
export * from './logseq.ts';
|
|
4
|
+
export * from './obsidian.ts';
|
package/src/converters/logseq.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ExternalNode } from '../types';
|
|
1
|
+
import type { ExternalNode } from '../types.ts';
|
|
2
2
|
import {
|
|
3
3
|
convertOwnerMarkers, convertWikiLinks, logseqSlashLinks, parseCheckbox,
|
|
4
4
|
parseIndent, reverseWikiLinks, stripMetadata,
|
|
5
|
-
} from './shared';
|
|
5
|
+
} from './shared.ts';
|
|
6
6
|
|
|
7
7
|
/** Logseq page → flat ExternalNode list (document order; hierarchy via `indent`).
|
|
8
8
|
* Drops pure `key:: value` property lines (keys may contain spaces — only `::`
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ExternalNode } from '../types';
|
|
1
|
+
import type { ExternalNode } from '../types.ts';
|
|
2
2
|
import {
|
|
3
3
|
convertOwnerMarkers, convertWikiLinks, parseCheckbox, parseIndent,
|
|
4
4
|
reverseWikiLinks, stripMetadata,
|
|
5
|
-
} from './shared';
|
|
5
|
+
} from './shared.ts';
|
|
6
6
|
|
|
7
7
|
/** Remove a leading YAML frontmatter block (`---` fences at very top), fences included. */
|
|
8
8
|
export function stripFrontmatter(source: string): string {
|
package/src/converters/opml.ts
CHANGED
package/src/converters/shared.ts
CHANGED
package/src/core/fs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { statSync, readdirSync, existsSync, mkdirSync, type Stats } from 'fs';
|
|
2
2
|
import { join, relative, dirname, basename } from 'path';
|
|
3
|
+
import { globFiles as runtimeGlobFiles } from './runtime.ts';
|
|
3
4
|
|
|
4
5
|
const SPEC_FILE_RE = /^\d{2}-.+\.md$/;
|
|
5
6
|
|
|
@@ -26,10 +27,8 @@ export function mkdirp(p: string): void {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function globFiles(dir: string, pattern: string): string[] {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const out = [...g.scanSync({ cwd: dir, onlyFiles: true })] as string[];
|
|
32
|
-
return out.sort();
|
|
30
|
+
// Delegated to the runtime shim (issue #12): Bun fast path, node:fs fallback.
|
|
31
|
+
return runtimeGlobFiles(dir, pattern);
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
/** Spec files: root-level *.md (excluding _-prefixed, AGENTS.md and other tool
|
package/src/core/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export * from './output';
|
|
2
|
-
export * from './fs';
|
|
3
|
-
export * from './outline';
|
|
4
|
-
export * from './refs';
|
|
5
|
-
export * from './structure';
|
|
6
|
-
export * from './style';
|
|
7
|
-
export * from './redundancy';
|
|
8
|
-
export * from './overflow';
|
|
9
|
-
export * from './rules';
|
|
10
|
-
export * from './token-budget';
|
|
1
|
+
export * from './output.ts';
|
|
2
|
+
export * from './fs.ts';
|
|
3
|
+
export * from './outline.ts';
|
|
4
|
+
export * from './refs.ts';
|
|
5
|
+
export * from './structure.ts';
|
|
6
|
+
export * from './style.ts';
|
|
7
|
+
export * from './redundancy.ts';
|
|
8
|
+
export * from './overflow.ts';
|
|
9
|
+
export * from './rules.ts';
|
|
10
|
+
export * from './token-budget.ts';
|
package/src/core/outline.ts
CHANGED
package/src/core/output.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CommandResult, CheckResult, Issue, InitResult, NewResult, DoneResult, StatusResult,
|
|
3
3
|
BudgetReadResult, BudgetWriteResult, ImportResult, ExportResult, VersionResult,
|
|
4
|
-
} from '../types';
|
|
4
|
+
} from '../types.ts';
|
|
5
5
|
|
|
6
6
|
/** Single emission point. Commands never console.log or process.exit directly.
|
|
7
7
|
* `refsOnly` (check only, §22/§36): human output is scoped to the References
|
package/src/core/overflow.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, OverflowRules } from '../types';
|
|
2
|
-
import { flattenNodes } from './outline';
|
|
1
|
+
import type { OutlineNode, Issue, OverflowRules } from '../types.ts';
|
|
2
|
+
import { flattenNodes } from './outline.ts';
|
|
3
3
|
|
|
4
4
|
/** Overflow checks: code fences, tables, over-long nodes. All errors.
|
|
5
5
|
* §18: `force_file_for` lists the content categories forced into files —
|
package/src/core/redundancy.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, RedundancyRules } from '../types';
|
|
2
|
-
import { flattenNodes } from './outline';
|
|
1
|
+
import type { OutlineNode, Issue, RedundancyRules } from '../types.ts';
|
|
2
|
+
import { flattenNodes } from './outline.ts';
|
|
3
3
|
|
|
4
4
|
interface NodeRef {
|
|
5
5
|
text: string;
|
package/src/core/refs.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types';
|
|
4
|
-
import { flattenNodes, parseOutline } from './outline';
|
|
5
|
-
import { resolveSpecFile, toRelative, isFile } from './fs';
|
|
3
|
+
import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types.ts';
|
|
4
|
+
import { flattenNodes, parseOutline } from './outline.ts';
|
|
5
|
+
import { resolveSpecFile, toRelative, isFile } from './fs.ts';
|
|
6
6
|
|
|
7
7
|
export interface RefGraph {
|
|
8
8
|
forward: Map<string, RefTarget[]>;
|
package/src/core/rules.ts
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Runtime shim — the ONLY module allowed to touch `Bun.*` APIs (issue #12).
|
|
2
|
+
*
|
|
3
|
+
* Bun stays the primary runtime (fast path, behavior byte-identical to the
|
|
4
|
+
* pre-#12 code); Node.js ≥22.6 is the fallback (Termux / no-Bun harnesses).
|
|
5
|
+
* The Node path uses only `node:` builtins — the zero-dependency law holds on
|
|
6
|
+
* both runtimes. Anything outside this module that needs a runtime-sensitive
|
|
7
|
+
* operation MUST go through one of these exports; a repo self-audit test
|
|
8
|
+
* (test/node-fallback.test.ts) enforces `Bun\.` never appears elsewhere.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
/** Minimal structural type for the slice of the Bun global we use. Kept local
|
|
16
|
+
* so the shim also type-checks without relying on ambient `@types/bun`. */
|
|
17
|
+
interface BunGlobal {
|
|
18
|
+
file(path: string): { text(): Promise<string> };
|
|
19
|
+
write(path: string, data: string): Promise<number>;
|
|
20
|
+
Glob: new (pattern: string) => {
|
|
21
|
+
scanSync(opts: { cwd: string; onlyFiles: boolean }): Iterable<string>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BUN: BunGlobal | undefined = (globalThis as { Bun?: BunGlobal }).Bun;
|
|
26
|
+
|
|
27
|
+
/** Which runtime is executing: `'bun'` (primary) or `'node'` (fallback). */
|
|
28
|
+
export const RUNTIME: 'bun' | 'node' = BUN ? 'bun' : 'node';
|
|
29
|
+
|
|
30
|
+
/** CLI arguments, portable across runtimes (`Bun.argv` is an alias of
|
|
31
|
+
* `process.argv`, so the Node form works on both). */
|
|
32
|
+
export function argv(): string[] {
|
|
33
|
+
return process.argv.slice(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Directory of the module file that passed its `import.meta.url` — the
|
|
37
|
+
* portable replacement for Bun's `import.meta.dir` (Node has no equivalent
|
|
38
|
+
* property; both runtimes support `node:url.fileURLToPath`). */
|
|
39
|
+
export function dirFromUrl(moduleUrl: string): string {
|
|
40
|
+
return dirname(fileURLToPath(moduleUrl));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a UTF-8 text file. Bun: `Bun.file(path).text()` (unchanged fast path).
|
|
44
|
+
* Node: `fs/promises.readFile`. Rejects on missing files on both runtimes. */
|
|
45
|
+
export async function readText(path: string): Promise<string> {
|
|
46
|
+
if (BUN) return BUN.file(path).text();
|
|
47
|
+
return readFile(path, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write a UTF-8 text file, creating missing parent directories — `Bun.write`
|
|
51
|
+
* auto-creates parents, so the Node path replicates that (plain
|
|
52
|
+
* `writeFile` would not) to keep behavior identical across runtimes. */
|
|
53
|
+
export async function writeText(path: string, data: string): Promise<void> {
|
|
54
|
+
if (BUN) {
|
|
55
|
+
await BUN.write(path, data);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
await writeFile(path, data, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isDir(p: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
return statSync(p).isDirectory();
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Glob relative file paths under `dir`, sorted lexicographically — portable
|
|
71
|
+
* replacement for `new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })`.
|
|
72
|
+
* Supported forms: single-segment (`*.md`) and recursive (a two-star prefix
|
|
73
|
+
* followed by a slash, e.g. "**" + "/" + "*.md") wildcard patterns; `*` never
|
|
74
|
+
* crosses `/` (same as Bun.Glob). Returns paths relative to `dir`, sorted.
|
|
75
|
+
* Empty array when `dir` does not exist (unchanged contract used by
|
|
76
|
+
* `_tasks`/`_adr` discovery). */
|
|
77
|
+
export function globFiles(dir: string, pattern: string): string[] {
|
|
78
|
+
if (!isDir(dir)) return [];
|
|
79
|
+
if (BUN) {
|
|
80
|
+
const g = new BUN.Glob(pattern);
|
|
81
|
+
return [...g.scanSync({ cwd: dir, onlyFiles: true }) as Iterable<string>].sort();
|
|
82
|
+
}
|
|
83
|
+
const recursive = pattern.startsWith('**/');
|
|
84
|
+
const base = recursive ? pattern.slice(3) : pattern;
|
|
85
|
+
const re = new RegExp(
|
|
86
|
+
`^(?:${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')})$`,
|
|
87
|
+
);
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
if (recursive) {
|
|
90
|
+
const walk = (d: string, prefix: string): void => {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
const rel = prefix === '' ? e.name : `${prefix}/${e.name}`;
|
|
99
|
+
if (e.isFile() && re.test(e.name)) out.push(rel);
|
|
100
|
+
else if (e.isDirectory()) walk(join(d, e.name), rel);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
walk(dir, '');
|
|
104
|
+
} else {
|
|
105
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
106
|
+
if (e.isFile() && re.test(e.name)) out.push(e.name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out.sort();
|
|
110
|
+
}
|
package/src/core/structure.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types';
|
|
2
|
-
import { flattenNodes } from './outline';
|
|
1
|
+
import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types.ts';
|
|
2
|
+
import { flattenNodes } from './outline.ts';
|
|
3
3
|
|
|
4
4
|
/** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
|
|
5
5
|
* §18 delete-key semantics: a check whose rules key is null/false is OFF — the
|
package/src/core/style.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, StyleRules } from '../types';
|
|
1
|
+
import type { OutlineNode, Issue, StyleRules } from '../types.ts';
|
|
2
2
|
|
|
3
3
|
/** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint.
|
|
4
4
|
* SEVERITY NOTE (arbitration, same class as the refs-severity decision): §14/§36
|
package/src/core/token-budget.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { basename, relative } from 'path';
|
|
|
3
3
|
import type {
|
|
4
4
|
OutlineNode, BackPointer, TokenBudgetRules,
|
|
5
5
|
BudgetReadPlanItem, BudgetReadResult, BudgetWriteResult,
|
|
6
|
-
} from '../types';
|
|
7
|
-
import { flattenNodes, parseOutline } from './outline';
|
|
8
|
-
import { targetMatchesKey } from './refs';
|
|
6
|
+
} from '../types.ts';
|
|
7
|
+
import { flattenNodes, parseOutline } from './outline.ts';
|
|
8
|
+
import { targetMatchesKey } from './refs.ts';
|
|
9
9
|
|
|
10
10
|
export function estimateTokens(text: string, charsPerToken: number): number {
|
|
11
11
|
return Math.ceil(text.length / charsPerToken);
|