@ape-egg/codecane 0.0.0-stage → 1.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/README.md +151 -2
- package/core/build.js +66 -0
- package/core/check.js +37 -0
- package/core/checks.js +94 -0
- package/core/cli.js +111 -0
- package/core/data.js +64 -0
- package/core/diff.js +43 -0
- package/core/index.js +10 -0
- package/core/modules.js +17 -0
- package/core/patch.js +115 -0
- package/core/rebase.js +80 -0
- package/core/serve.js +170 -0
- package/core/stack.js +199 -0
- package/core/steps.js +102 -0
- package/package.json +31 -4
- package/ui/codecane.css +554 -0
- package/ui/components/Changes.html +25 -0
- package/ui/components/Stage.html +40 -0
- package/ui/components/Story.html +105 -0
- package/ui/components/Timeline.html +25 -0
- package/ui/index.html +20 -0
- package/ui/mounted.js +73 -0
- package/ui/stylecheat.css +8473 -0
- package/ui/viewer.js +295 -0
- package/workflow.md +81 -0
package/README.md
CHANGED
|
@@ -1,3 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ape-egg/codecane
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build a project up step by step. Each step folder holds only what that step adds, plus patches that store just the lines it changes in earlier files. codecane stacks steps 1 through n into a complete, runnable folder for step n, checks that every step still runs, and shows the steps as a timeline with their stories and diffs.
|
|
4
|
+
|
|
5
|
+
codecane works only on folders, so it doesn't care which version control you use, or whether you use any.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add -d @ape-egg/codecane
|
|
9
|
+
bunx codecane check # replays every step and runs its checks, or stops at the first that breaks
|
|
10
|
+
bunx codecane merge <step> <other…> # tries merging timelines, writing nothing
|
|
11
|
+
bunx codecane dev # viewer at http://localhost:3000, or $PORT; rebases later patches as steps change
|
|
12
|
+
bunx codecane build # dist/: stacked steps, data and viewer, every step checked
|
|
13
|
+
bunx codecane stack <step>/<hash>… --out dir # one step on one timeline, ready to deploy
|
|
14
|
+
bunx codecane agents # the workflow section for your AGENTS.md
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## A project
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
steps/
|
|
21
|
+
1/ what step 1 adds
|
|
22
|
+
index.html
|
|
23
|
+
main.js
|
|
24
|
+
1.html its story: <h1>Name</h1> and a few paragraphs
|
|
25
|
+
2/
|
|
26
|
+
score.js
|
|
27
|
+
index.html.patch only the lines step 2 changes in step 1's index.html
|
|
28
|
+
2.html
|
|
29
|
+
hard-mode/ a pivot: another way on from step 1
|
|
30
|
+
hard.js
|
|
31
|
+
index.html.patch
|
|
32
|
+
codecane.json { "parent": "1" }
|
|
33
|
+
hard-mode.html
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A patch is unified-diff hunks, applied exactly at their line numbers:
|
|
37
|
+
|
|
38
|
+
```diff
|
|
39
|
+
@@ -7,4 +7,5 @@
|
|
40
|
+
<link rel="stylesheet" href="style.css">
|
|
41
|
+
<script type="module" src="main.js"></script>
|
|
42
|
+
+ <script type="module" src="score.js"></script>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To write one, copy the earlier file into the step, edit it, and run `codecane patch <step> <path>`. The copy turns into a patch that holds only the changed lines.
|
|
48
|
+
|
|
49
|
+
`codecane.json` holds the step's short hash, and knows four more keys:
|
|
50
|
+
|
|
51
|
+
| key | meaning |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `hash` | The step's short hash, generated once by codecane when it first sees the step. |
|
|
54
|
+
| `parent` | The step this one continues. It defaults to the step before it in natural order, and `null` starts a new root. Any step can be a parent, as long as parents never lead back to the step itself. Steps that share a parent are a pivot, and the one whose id sorts first leads the default timeline. A list of steps continues all of their timelines together. See [Continuing timelines together](#continuing-timelines-together). |
|
|
55
|
+
| `merges` | Steps on other timelines that this step merges into its own. See [Merging timelines](#merging-timelines). |
|
|
56
|
+
| `removes` | Earlier files this step takes away. |
|
|
57
|
+
| `overrides` | Earlier files this step replaces with a full copy. A copy that isn't listed fails the build. Prefer a patch. |
|
|
58
|
+
|
|
59
|
+
Steps can be numbered (`1`, `2`, `10`) or named (`canvas`, `moving-the-center`). Named steps say their `parent`.
|
|
60
|
+
|
|
61
|
+
Stacking is a pure function of the step folders. It walks from the root to the step, and each step drops its `removes`, lays its files on top, and applies its patches exactly where they say. There is no merging, fuzz or guessing, and nothing is generated.
|
|
62
|
+
|
|
63
|
+
`steps/<id>.check.js` is a step's optional check. Its default export gets the stacked step's files as `{ path: text }` and throws when the step doesn't do what it should. A step without a check still stacks and builds, and is marked untested.
|
|
64
|
+
|
|
65
|
+
## Continuing timelines together
|
|
66
|
+
|
|
67
|
+
A pivot's timelines can be alternatives, such as a square and a hex floor for the same game. To write the next steps once for all of them, give a step several parents: `{ "parent": ["hex", "square"] }`. It continues each of those timelines on its own. Nothing is combined: the step and every step after it stack once per timeline, onto that timeline's own files, so each timeline keeps what it chose.
|
|
68
|
+
|
|
69
|
+
- The first parent leads the default timeline.
|
|
70
|
+
- Every step can be visited at `/<step>`, which shows it with the default fork at each fork on the way. Once forks have been chosen, the default one included, its address is `/<step>/<hash>/<hash>…`: the short hashes of the step taken at each fork before it, earliest first, such as `/moving-the-center/05adc6a`. Only forks before the step count, so a step before any fork, or the step a fork takes, never carries one. `codecane stack moving-the-center/05adc6a --out dir` writes that timeline's step alone, ready to deploy.
|
|
71
|
+
- A step's patches have to apply on every timeline it continues, and its checks run on every one. `codecane check` names the timeline where one doesn't.
|
|
72
|
+
- When the timelines differ where the later steps need them, they get a seam: a small internal API that each timeline fills in. The step that owns that code on each timeline fills it in. A step with several parents that holds code of its own is a **seam** (yellow), like a merge step that holds code. It is always needed, because its code is what the timelines share from there on.
|
|
73
|
+
|
|
74
|
+
## Merging timelines
|
|
75
|
+
|
|
76
|
+
Any number of timelines can merge at any step. A merge is one step that names the other timelines' steps in `merges`. It replays their stored code onto its own timeline exactly, and never merges text. codecane decides what kind of step it is by replaying and running every check on both sides:
|
|
77
|
+
|
|
78
|
+
- **Merge**: the step holds no code, everything replays cleanly and every check passes.
|
|
79
|
+
- **Seam** (yellow): the timelines differ, so the step holds a small internal API that each of them fills in, and the viewer shows which steps required it and why.
|
|
80
|
+
- **Seam no longer needed**: replaying without the seam still passes every check, here and in every later step. The timeline leaves it out, and `codecane check` says so.
|
|
81
|
+
|
|
82
|
+
Every step has a short hash, generated once when codecane first sees the step and stored as `hash` in its `codecane.json`. `codecane check`, `build`, `dev` and the other commands give a new step one. A seam lives in `parts/<hash>/`, beside `steps/`, with its story at `parts/<hash>.html`: its short hash is its id and its folder name, and later steps name it by that hash. Its story has no `<h1>`; the viewer titles it `Seam <hash>`. `codecane check` gives a seam written in `steps/` its hash and fails with where in `parts/` to move it, and fails on a step in `parts/` that holds no seam. Sangido's is `parts/612a36a/`.
|
|
83
|
+
|
|
84
|
+
Adding steps later never changes a merge's kind. Editing a step from before it replays the merge again. `codecane merge` tries a merge without writing anything. The viewer shows merges but never makes them. Agents follow the steps in [workflow.md](workflow.md).
|
|
85
|
+
|
|
86
|
+
## Patches always apply
|
|
87
|
+
|
|
88
|
+
The stored steps must stack cleanly at all times. Conflicts are dealt with when an edit is made, never at build time:
|
|
89
|
+
|
|
90
|
+
- **Saving in the viewer** writes the file to the step that last changed it. Every later patch is then rebased onto the new text: each change block (the lines a patch removes, and the gap where it inserts) moves to its new line, and the patch's context is rewritten. If an edit touches lines a later patch removes, or inserts at the exact spot where a later patch inserts, the save is refused with the step and lines, and nothing is written.
|
|
91
|
+
- **`codecane dev` watches `steps/`**, whichever tool writes there. Once the writes settle, it turns copies of earlier files into patches, rebases later patches the same way, and logs every patch it can't rebase. Then it replays from the changed step forward, quietly when everything passes, or stops at the first step that breaks and says why. The open viewer then updates in place: the story, the timeline and the diff follow the new steps, a changed stylesheet is swapped into the running step, and any other changed file restarts the running step.
|
|
92
|
+
- **`codecane check`** replays every step and runs its checks. It fails with the step, the file and the lines of each patch that no longer applies, or stops at the first failing check. Run it after every edit to a step folder.
|
|
93
|
+
|
|
94
|
+
## The check
|
|
95
|
+
|
|
96
|
+
`codecane build` writes every stacked step and links each one with `Bun.build`, the way a browser would load it. Absolute paths resolve against the site, bare imports need an import map, and every `src` and `href` in the step's pages must exist. A call to a function an earlier step renamed, a missing file and an unmapped import all fail the build. The check runs on Bun.
|
|
97
|
+
|
|
98
|
+
## The data
|
|
99
|
+
|
|
100
|
+
The build writes `codecane/index.json`, which is the contract any viewer reads:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"codecane": 1,
|
|
105
|
+
"name": "tally",
|
|
106
|
+
"editable": false,
|
|
107
|
+
"steps": [{ "id": "2", "hash": "6e61352", "parents": ["1"], "merges": [], "title": "Counter", "children": ["3", "history"], "joins": [], "kind": null, "untested": true, "data": { "2": "2.b81cfa88f74a.json" } }],
|
|
108
|
+
"timelines": [["1", "2", "3", "4"], ["1", "2", "history", "history-clear"]],
|
|
109
|
+
"stacks": [["1", "2", "3", "4/6e61352"], ["1", "2", "history", "history-clear/8252275"]]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Each step's file is named by a hash of its content, so it can be cached forever:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"id": "2",
|
|
118
|
+
"address": "2",
|
|
119
|
+
"parents": ["1"],
|
|
120
|
+
"title": "Counter",
|
|
121
|
+
"story": "<h1>Counter</h1>…",
|
|
122
|
+
"convergence": null,
|
|
123
|
+
"untested": true,
|
|
124
|
+
"folder": "../steps/2/",
|
|
125
|
+
"files": [{ "path": "index.html", "owner": "1", "patchedBy": ["2"], "changedBy": "2", "hash": "…", "overrides": null }],
|
|
126
|
+
"dependencies": [],
|
|
127
|
+
"changes": [{ "path": "features/hello.js", "state": "removed", "before": "…", "after": "", "added": 0, "removed": 4, "lines": [["-", "import …"]] }]
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`hash` is the step's short hash. `data` names a file for every address of a step, one per timeline it is on, and `stacks` gives each timeline's addresses, `<step>/<hash>…`, in the same order as `timelines`. A seam that is no longer needed is left out of both. `joins` lists the merge steps that continue a step's timeline, and `kind` is a merge step's kind, `merge`, `seam` or `obsolete`, and `seam` on a step with several parents that holds code. A merge step's `convergence`, and a seam's on a step with several parents, holds its `kind`, its `merges`, the parents it `continues` together, the steps it `brought`, the steps a seam is `requiredBy`, and the `reasons`. `owner` is the step that brought a file, `patchedBy` lists the steps that patched it since, and `changedBy` is the last of them, which is where a fix goes. `dependencies` lists the stacked `package.json`'s dependencies. `changes` is the diff against the parent's stacked step on the same timeline: every added, modified and removed file, with both texts and the line diff (`' '` kept, `'-'` removed, `'+'` added).
|
|
132
|
+
|
|
133
|
+
## The site
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
dist/
|
|
137
|
+
index.html, <address>/index.html the viewer, at / and /<address>
|
|
138
|
+
codecane/index.json, codecane/<address>.<hash>.json
|
|
139
|
+
codecane/ui/ the viewer's files
|
|
140
|
+
steps/<address>/ every step on every timeline, runnable on its own
|
|
141
|
+
node_modules/ the viewer's and the steps' dependencies
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Any static host serves it as is.
|
|
145
|
+
|
|
146
|
+
## The viewer
|
|
147
|
+
|
|
148
|
+
The viewer is built with [Vibe](https://vibe.korte.kim) and reads only the data above. It shows a timeline of the chosen path, a story panel, the step running live, and its diff. When the steps fork, the fork marker in the timeline folds out every path through it, and clicking a step there follows that path. The page's address names the forks chosen before it, and the viewer keeps to the timeline you are on with every click, in the timeline or the story; a step that continues several timelines runs the one of that timeline. With the fork marker unfolded, pressing a fork navigates straight to it when the step you are on comes before it, such as Canvas, or is itself a fork beside it, such as World hex surface beside World triangle surface. From a step after the fork, pressing a step on another fork picks that fork without leaving the step you are on: the address's hashes change to the new fork, such as `/moving-the-center` becoming `/moving-the-center/05adc6a`, and the preview reruns on it. Pressing a step on the picked fork, once it is picked, navigates to that step. A seam shows in the timeline with its number and mirrored fork icon, and no name, and its story panel is titled `Seam <hash>`, above its Seam section and its story. Clicking a file opens it in an editor. The diff redraws as you type, and Run starts the step with your draft. In `codecane dev` the viewer hot-reloads its own files through Vibe's hot module refresh: an edited component is refreshed in place, an edited stylesheet is swapped, and only a change to its scripts reloads the page. You can also save the file to the step that last changed it, and a save that would break a later patch is refused with the step and lines. A patched file shows as modified in the step that patches it, with just those lines in its diff.
|
|
149
|
+
|
|
150
|
+
A merge step, and a step with several parents that holds code, shows the fork icon mirrored, in yellow for a seam, and its story panel says what it continues or brought and why it is the kind it is. A seam that is no longer needed isn't shown.
|
|
151
|
+
|
|
152
|
+
Core (`@ape-egg/codecane`: `read`, `stack`, `replay`, `tree`, `revise`, `rebaseLater`, `patchCopies`, `trial`, `convergence`, `replayed`, `entry`, `index`, `changes`, `build`, `check`, `serve`) never imports the viewer. The viewer only reads core's JSON, so either one can be replaced on its own.
|
package/core/build.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { cp, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { check } from './check.js'
|
|
6
|
+
import { entry, hash, index } from './data.js'
|
|
7
|
+
import { dependencies, located } from './modules.js'
|
|
8
|
+
import { stack, tree } from './stack.js'
|
|
9
|
+
import { read, stamp } from './steps.js'
|
|
10
|
+
|
|
11
|
+
const listed = async dir => (await readdir(dir, { recursive: true, withFileTypes: true }))
|
|
12
|
+
.filter(entry => entry.isFile() && !entry.name.startsWith('.'))
|
|
13
|
+
.map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1))
|
|
14
|
+
|
|
15
|
+
export const written = async (out, files) => Promise.all(Object.entries(files).map(async ([path, body]) => {
|
|
16
|
+
const file = join(out, path)
|
|
17
|
+
if (existsSync(file) && (await readFile(file)).equals(Buffer.from(body))) return
|
|
18
|
+
await mkdir(dirname(file), { recursive: true })
|
|
19
|
+
await writeFile(file, body)
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const viewerFiles = async ({ dir }, ids) => {
|
|
23
|
+
const paths = await listed(dir)
|
|
24
|
+
const shell = await readFile(join(dir, 'index.html'))
|
|
25
|
+
return {
|
|
26
|
+
...Object.fromEntries(await Promise.all(paths.map(async path => [`codecane/ui/${path}`, await readFile(join(dir, path))]))),
|
|
27
|
+
'index.html': shell,
|
|
28
|
+
...Object.fromEntries(ids.map(id => [`${id}/index.html`, shell])),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const build = async ({ project, out = join(project, 'dist'), viewer }) => {
|
|
33
|
+
await stamp(project)
|
|
34
|
+
const steps = await read(project)
|
|
35
|
+
const { name } = JSON.parse(await readFile(join(project, 'package.json'), 'utf8'))
|
|
36
|
+
const { located: placed, addresses: stacked } = tree(steps)
|
|
37
|
+
const stacks = Object.fromEntries(stacked.map(id => [id, stack(steps, placed[id].id, placed[id].through)]))
|
|
38
|
+
const entries = Object.fromEntries(await Promise.all(stacked.map(async id => [id, JSON.stringify(await entry(steps, id))])))
|
|
39
|
+
const data = Object.fromEntries(stacked.map(id => [id, `${id}.${hash(entries[id])}.json`]))
|
|
40
|
+
const modules = new Map([
|
|
41
|
+
...(viewer?.modules ?? []).map(module => [module, located(module, viewer.dir)]),
|
|
42
|
+
...stacked.flatMap(id => dependencies(stacks[id].files)).map(module => [module, located(module, project)]),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
await rm(out, { recursive: true, force: true })
|
|
46
|
+
await written(out, {
|
|
47
|
+
'codecane/index.json': JSON.stringify(await index(steps, { name, address: id => data[id] })),
|
|
48
|
+
...Object.fromEntries(stacked.map(id => [`codecane/${data[id]}`, entries[id]])),
|
|
49
|
+
...Object.fromEntries(stacked.flatMap(id => Object.entries(stacks[id].files).map(([path, body]) => [`steps/${id}/${path}`, body]))),
|
|
50
|
+
...viewer && await viewerFiles(viewer, Object.keys(placed)),
|
|
51
|
+
})
|
|
52
|
+
await Promise.all([...modules].map(async ([module, dir]) => cp(await realpath(dir), join(out, 'node_modules', module), { recursive: true })))
|
|
53
|
+
|
|
54
|
+
const failures = {}
|
|
55
|
+
for (const id of stacked) {
|
|
56
|
+
const problems = await check({ site: out, folder: join(out, 'steps', id), files: stacks[id].files })
|
|
57
|
+
if (problems.length) failures[id] = problems
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
out,
|
|
62
|
+
steps: stacked,
|
|
63
|
+
overrides: steps.flatMap(({ id }) => stacks[stacked.find(address => placed[address].id === id)].overrides.filter(override => override.step === id)),
|
|
64
|
+
failures,
|
|
65
|
+
}
|
|
66
|
+
}
|
package/core/check.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join, posix } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const external = /^([a-z][a-z0-9+.-]*:|\/\/)/i
|
|
5
|
+
const bare = specifier => !external.test(specifier) && !/^\.{0,2}\//.test(specifier)
|
|
6
|
+
|
|
7
|
+
const importMaps = html => [...html.matchAll(/<script type="importmap">([\s\S]*?)<\/script>/g)].map(([, map]) => JSON.parse(map).imports ?? {})
|
|
8
|
+
|
|
9
|
+
const brokenLinks = ({ site, folder, files }) => Object.keys(files).filter(path => path.endsWith('.html')).flatMap(page => [...files[page].toString().matchAll(/\s(?:src|href)="([^"#?]+)/g)]
|
|
10
|
+
.map(([, link]) => link)
|
|
11
|
+
.filter(link => !external.test(link))
|
|
12
|
+
.filter(link => !existsSync(link.startsWith('/') ? join(site, link) : join(folder, posix.dirname(page), link)))
|
|
13
|
+
.map(link => `${page} links ${link}, which is missing`))
|
|
14
|
+
|
|
15
|
+
const browser = ({ site, folder, imports }) => ({
|
|
16
|
+
name: 'browser',
|
|
17
|
+
setup: build => build.onResolve({ filter: /.*/ }, ({ path, importer }) => {
|
|
18
|
+
if (!importer) return undefined
|
|
19
|
+
const mapped = imports[path] ?? path
|
|
20
|
+
if (external.test(mapped)) return { path: mapped, external: true }
|
|
21
|
+
if (bare(mapped)) throw new Error(`${importer.slice(folder.length + 1)} imports ${path}, which no import map in the step names`)
|
|
22
|
+
if (mapped.startsWith('/')) return { path: join(site, mapped) }
|
|
23
|
+
if (mapped !== path) return { path: join(folder, mapped) }
|
|
24
|
+
return undefined
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const check = async ({ site, folder, files }) => {
|
|
29
|
+
const imports = Object.assign({}, ...Object.keys(files).filter(path => path.endsWith('.html')).flatMap(page => importMaps(files[page].toString())))
|
|
30
|
+
const scripts = Object.keys(files).filter(path => path.endsWith('.js')).map(path => join(folder, path))
|
|
31
|
+
const linked = scripts.length ? await Bun.build({ entrypoints: scripts, target: 'browser', throw: false, plugins: [browser({ site, folder, imports })] }) : { logs: [] }
|
|
32
|
+
const local = text => text.replace(/[^\s"]*?steps\/[^/\s"]+\//g, '')
|
|
33
|
+
return [
|
|
34
|
+
...brokenLinks({ site, folder, files }),
|
|
35
|
+
...linked.logs.filter(log => log.level === 'error').map(log => log.position?.line ? `${local(log.message)} (${local(log.position.file)}:${log.position.line})` : local(log.message)),
|
|
36
|
+
]
|
|
37
|
+
}
|
package/core/checks.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { describe, graphOf, layered, merged, ownPaths, replay, tree, unkeyed } from './stack.js'
|
|
5
|
+
|
|
6
|
+
const loaded = async path => (await import(`${path}?${createHash('sha256').update(await readFile(path)).digest('hex').slice(0, 12)}`)).default
|
|
7
|
+
|
|
8
|
+
const texts = files => Object.fromEntries(Object.entries(files).map(([path, body]) => [path, body.toString()]))
|
|
9
|
+
|
|
10
|
+
export const checksAt = (steps, id, through = []) => {
|
|
11
|
+
const step = steps.find(step => step.id === id)
|
|
12
|
+
return (step.merges.length ? tree(steps).lineage(id, through) : [id]).map(id => steps.find(step => step.id === id)).filter(step => step.check)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const untested = (steps, id) => !checksAt(steps, id).length
|
|
16
|
+
|
|
17
|
+
export const converges = step => step.merges.length > 0 || step.parents.length > 1 && ownPaths(step).length > 0
|
|
18
|
+
|
|
19
|
+
export const failuresOf = async (steps, id, files, through = []) => {
|
|
20
|
+
const failures = []
|
|
21
|
+
for (const step of checksAt(steps, id, through)) {
|
|
22
|
+
try {
|
|
23
|
+
await (await loaded(step.check))(texts(files))
|
|
24
|
+
} catch (error) {
|
|
25
|
+
failures.push({ step: step.id, at: id, message: error.message })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return failures
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bare = step => ({ ...step, files: {}, patches: {}, removes: [], overrides: [] })
|
|
32
|
+
|
|
33
|
+
export const trial = async (steps, step, layers = replay(steps)) => {
|
|
34
|
+
const { ways, brought } = graphOf(steps)
|
|
35
|
+
const tried = []
|
|
36
|
+
for (const through of ways(step.id)) {
|
|
37
|
+
const { clashes } = merged(step, through, layers, steps)
|
|
38
|
+
tried.push({ clashes, failures: clashes.length ? [] : await failuresOf(steps, step.id, layered(bare(step), through, layers, steps).files, through) })
|
|
39
|
+
}
|
|
40
|
+
const [clashes, failures] = [tried.flatMap(line => line.clashes), tried.flatMap(line => line.failures)]
|
|
41
|
+
return { brought: [...new Set(ways(step.id).flatMap(through => brought(step.id, through)))], clashes, failures, clean: !clashes.length && !failures.length }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const breaksWithout = async (steps, id) => {
|
|
45
|
+
try {
|
|
46
|
+
const without = step => ({ ...bare(step), merges: tree(steps).brought(id).length ? step.merges : [] })
|
|
47
|
+
const { stop } = await replayed(steps.map(step => step.id === id ? without(step) : step), [id])
|
|
48
|
+
return stop && unkeyed(stop).id
|
|
49
|
+
} catch {
|
|
50
|
+
return id
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const convergence = async (steps, id, layers = replay(steps)) => {
|
|
55
|
+
const step = steps.find(step => step.id === id)
|
|
56
|
+
const continues = step.parents.length > 1 ? step.parents : []
|
|
57
|
+
if (!step.merges.length) return { kind: 'seam', merges: [], continues, brought: [], requiredBy: [], clashes: [], failures: [] }
|
|
58
|
+
const { brought, clashes, failures, clean } = await trial(steps, step, layers)
|
|
59
|
+
const seam = ownPaths(step).length > 0
|
|
60
|
+
const later = seam && clean ? await breaksWithout(steps, id) : null
|
|
61
|
+
return {
|
|
62
|
+
kind: seam ? clean && !later ? 'obsolete' : 'seam' : 'merge',
|
|
63
|
+
merges: step.merges,
|
|
64
|
+
continues,
|
|
65
|
+
brought,
|
|
66
|
+
requiredBy: seam ? [...new Set([...clashes, ...failures].map(problem => problem.step).concat(later ?? []))] : [],
|
|
67
|
+
clashes,
|
|
68
|
+
failures,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const reasonOf = ({ clashes, failures }) => [
|
|
73
|
+
...clashes.map(({ step, path, below }) => `step ${step}'s change to ${path} doesn't replay onto ${path} as step ${below} leaves it`),
|
|
74
|
+
...failures.map(({ step, message }) => `step ${step}'s check fails: ${message}`),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
export const replayed = async (steps, from = steps.map(step => step.id)) => {
|
|
78
|
+
const layers = replay(steps)
|
|
79
|
+
const { lineage, keys } = graphOf(steps)
|
|
80
|
+
const reached = keys.filter(key => lineage(unkeyed(key).id, unkeyed(key).through).some(id => from.includes(id)))
|
|
81
|
+
for (const key of reached) {
|
|
82
|
+
const { id, through } = unkeyed(key)
|
|
83
|
+
const conflicts = layers[key].conflicts.filter(conflict => conflict.step === id)
|
|
84
|
+
if (conflicts.length) return { stop: key, why: conflicts.map(describe) }
|
|
85
|
+
const failures = await failuresOf(steps, id, layers[key].files, through)
|
|
86
|
+
if (failures.length) return { stop: key, why: failures.map(({ step, message }) => `step ${step}'s check fails${through.length ? ` on the timeline through ${through.join(', ')}` : ''}: ${message}`) }
|
|
87
|
+
}
|
|
88
|
+
const affected = steps.filter(step => reached.some(key => unkeyed(key).id === step.id))
|
|
89
|
+
const obsolete = []
|
|
90
|
+
for (const step of affected.filter(step => step.merges.length && ownPaths(step).length)) {
|
|
91
|
+
if ((await convergence(steps, step.id, layers)).kind === 'obsolete') obsolete.push(step.id)
|
|
92
|
+
}
|
|
93
|
+
return { stop: null, obsolete, untested: affected.filter(step => untested(steps, step.id)).map(step => step.id) }
|
|
94
|
+
}
|
package/core/cli.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { watch } from 'node:fs'
|
|
3
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
|
|
7
|
+
import { build, written } from './build.js'
|
|
8
|
+
import { reasonOf, replayed, trial } from './checks.js'
|
|
9
|
+
import { patchCopies } from './rebase.js'
|
|
10
|
+
import { serve } from './serve.js'
|
|
11
|
+
import { conflictsOf, describe, locate, named, replay, stack } from './stack.js'
|
|
12
|
+
import { persisted, read, stamp } from './steps.js'
|
|
13
|
+
|
|
14
|
+
const usage = `codecane check [project] replay every step and run its checks, and stop at the first step that breaks
|
|
15
|
+
codecane merge <step> <step...> [--project dir] try merging timelines into the first step's, without writing anything
|
|
16
|
+
codecane patch <step> [file...] [--project dir] turn a step's copies of earlier files into patches
|
|
17
|
+
codecane dev [project] [--port n] serve the viewer and every stacked step, live from steps/, rebasing later patches as steps change
|
|
18
|
+
codecane build [project] [--out dir] stack every step into dist/, write its data and the viewer, and check that every step runs
|
|
19
|
+
codecane build [project] --bare the same without the viewer
|
|
20
|
+
codecane stack <address> [project] --out dir write one step on one timeline, <forks-step>@<fork-step>@<step>
|
|
21
|
+
codecane stack <address> [project] --out dir --watch the same, and write it again as steps/ changes, touching only the files that changed
|
|
22
|
+
codecane agents print the codecane workflow for a project's AGENTS.md`
|
|
23
|
+
|
|
24
|
+
const root = resolve(import.meta.dirname, '..')
|
|
25
|
+
const { dependencies } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
26
|
+
const viewer = { dir: join(root, 'ui'), modules: Object.keys(dependencies) }
|
|
27
|
+
|
|
28
|
+
const { positionals: [command, ...rest], values } = parseArgs({
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
options: { port: { type: 'string' }, out: { type: 'string' }, bare: { type: 'boolean' }, watch: { type: 'boolean' }, project: { type: 'string' } },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const stamped = async (project = '.') => {
|
|
34
|
+
await stamp(resolve(project))
|
|
35
|
+
return read(resolve(project))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const commands = {
|
|
39
|
+
check: async ([project = '.']) => {
|
|
40
|
+
const steps = await stamped(project)
|
|
41
|
+
const conflicts = conflictsOf(replay(steps))
|
|
42
|
+
conflicts.forEach(conflict => console.error(describe(conflict)))
|
|
43
|
+
if (conflicts.length) {
|
|
44
|
+
console.info(`${conflicts.length} ${conflicts.length === 1 ? 'step no longer replays' : 'steps no longer replay'}`)
|
|
45
|
+
process.exitCode = 1
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const { stop, why, obsolete, untested } = await replayed(steps)
|
|
49
|
+
if (stop) {
|
|
50
|
+
console.error(`the replay stops at step ${named(stop)}:\n ${why.join('\n ')}`)
|
|
51
|
+
process.exitCode = 1
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
obsolete.forEach(id => console.info(`the seam in step ${id} is no longer needed, so the timeline leaves it out.`))
|
|
55
|
+
console.info(`all ${steps.length} steps stack cleanly and pass their checks${untested.length ? `; untested: ${untested.join(', ')}` : ''}`)
|
|
56
|
+
process.exitCode = 0
|
|
57
|
+
},
|
|
58
|
+
merge: async ([into, ...tips]) => {
|
|
59
|
+
const steps = await stamped(values.project)
|
|
60
|
+
const trying = { id: 'codecane', parents: [into], merges: tips, title: 'Merge', story: '', check: null, removes: [], overrides: [], files: {}, patches: {} }
|
|
61
|
+
const result = await trial([...steps, trying], trying)
|
|
62
|
+
const brought = `brings ${result.brought.join(', ')} onto step ${into}`
|
|
63
|
+
if (result.clean) return console.info(`a clean merge: it ${brought}, and every check passes. To merge, add a step whose codecane.json says { "parent": "${into}", "merges": ${JSON.stringify(tips)} }.`)
|
|
64
|
+
console.info(`a seam is needed: it ${brought}, and\n ${reasonOf(result).join('\n ')}\nTo merge, add a step whose codecane.json says { "parent": "${into}", "merges": ${JSON.stringify(tips)} }, holding the seam that makes these replay. Write it in steps/ with the step that needs it, and codecane check gives it its short hash and says where in parts/ it goes.`)
|
|
65
|
+
},
|
|
66
|
+
patch: async ([id, ...paths]) => {
|
|
67
|
+
const { copies, writes } = patchCopies(await stamped(values.project), id, paths)
|
|
68
|
+
await persisted(resolve(values.project ?? '.'), writes)
|
|
69
|
+
console.info(copies.length ? `step ${id} now patches ${copies.join(', ')}` : `step ${id} has no copies of earlier files to turn into patches`)
|
|
70
|
+
},
|
|
71
|
+
dev: async ([project = '.']) => serve({ project: resolve(project), viewer, port: Number(values.port ?? process.env.PORT ?? 3000) }),
|
|
72
|
+
build: async ([project = '.']) => {
|
|
73
|
+
const { out, steps, overrides, failures } = await build({ project: resolve(project), out: values.out && resolve(values.out), viewer: !values.bare && viewer })
|
|
74
|
+
overrides.forEach(({ step, path, over }) => console.info(`override: step ${step} brings its own ${path} over step ${over}'s`))
|
|
75
|
+
Object.entries(failures).forEach(([id, problems]) => console.error(`step ${id} does not run:\n ${problems.join('\n ').replaceAll('\n', '\n ')}`))
|
|
76
|
+
console.info(`${steps.length - Object.keys(failures).length} of ${steps.length} stacked steps run, written to ${out}`)
|
|
77
|
+
process.exitCode = Object.keys(failures).length ? 1 : 0
|
|
78
|
+
},
|
|
79
|
+
stack: async ([address, project = '.']) => {
|
|
80
|
+
const out = resolve(values.out)
|
|
81
|
+
const stackedFiles = async () => {
|
|
82
|
+
const steps = await stamped(project)
|
|
83
|
+
const { id, through } = locate(steps, address)
|
|
84
|
+
return stack(steps, id, through).files
|
|
85
|
+
}
|
|
86
|
+
let files = await stackedFiles()
|
|
87
|
+
await written(out, files)
|
|
88
|
+
console.info(`step ${address} stacked into ${out}`)
|
|
89
|
+
if (!values.watch) return
|
|
90
|
+
const restacked = async () => {
|
|
91
|
+
const next = await stackedFiles()
|
|
92
|
+
const changed = Object.keys(next).filter(path => !(path in files) || !Buffer.from(files[path]).equals(Buffer.from(next[path])))
|
|
93
|
+
const gone = Object.keys(files).filter(path => !(path in next))
|
|
94
|
+
await written(out, Object.fromEntries(changed.map(path => [path, next[path]])))
|
|
95
|
+
await Promise.all(gone.map(path => rm(join(out, path))))
|
|
96
|
+
files = next
|
|
97
|
+
if (changed.length || gone.length) console.info(`step ${address} stacked into ${out} again: ${[...changed, ...gone].join(', ')}`)
|
|
98
|
+
}
|
|
99
|
+
let settling
|
|
100
|
+
let queue = Promise.resolve()
|
|
101
|
+
watch(resolve(project), { recursive: true }, (_, path) => {
|
|
102
|
+
if (!/^(steps|parts)\//.test(path ?? '')) return
|
|
103
|
+
clearTimeout(settling)
|
|
104
|
+
settling = setTimeout(() => queue = queue.then(restacked).catch(error => console.error(error.message)), 300)
|
|
105
|
+
})
|
|
106
|
+
},
|
|
107
|
+
agents: async () => console.info(await readFile(join(root, 'workflow.md'), 'utf8')),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (commands[command]) await commands[command](rest)
|
|
111
|
+
else console.info(usage)
|
package/core/data.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
4
|
+
|
|
5
|
+
import { convergence, converges, reasonOf, untested } from './checks.js'
|
|
6
|
+
import { changes } from './diff.js'
|
|
7
|
+
import { dependencies } from './modules.js'
|
|
8
|
+
import { identical } from './rebase.js'
|
|
9
|
+
import { changerOf, graphOf, locate, replay, stack, tree } from './stack.js'
|
|
10
|
+
import { order } from './steps.js'
|
|
11
|
+
|
|
12
|
+
export const hash = body => createHash('sha256').update(body).digest('hex').slice(0, 12)
|
|
13
|
+
|
|
14
|
+
const converged = async (steps, id) => {
|
|
15
|
+
const { kind, merges, continues, brought, requiredBy, clashes, failures } = await convergence(steps, id)
|
|
16
|
+
return { kind, merges, continues, brought, requiredBy, reasons: reasonOf({ clashes, failures }) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const kinds = async (steps, layers = replay(steps)) => Object.fromEntries(await Promise.all(steps.filter(converges).map(async step => [step.id, (await convergence(steps, step.id, layers)).kind])))
|
|
20
|
+
|
|
21
|
+
export const entry = async (steps, address) => {
|
|
22
|
+
const { id, through } = locate(steps, address)
|
|
23
|
+
const step = steps.find(step => step.id === id)
|
|
24
|
+
const layered = stack(steps, id, through)
|
|
25
|
+
const { files, owners, patchers, overrides } = layered
|
|
26
|
+
const parent = graphOf(steps).parentOf(id, through)
|
|
27
|
+
const before = parent === null ? {} : stack(steps, parent, through).files
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
address,
|
|
31
|
+
parents: step.parents,
|
|
32
|
+
title: step.title,
|
|
33
|
+
story: step.story,
|
|
34
|
+
convergence: converges(step) ? await converged(steps, id) : null,
|
|
35
|
+
untested: untested(steps, id),
|
|
36
|
+
folder: `${'../'.repeat(address.split('/').length)}steps/${address}/`,
|
|
37
|
+
files: Object.keys(files).sort(order).map(path => ({
|
|
38
|
+
path,
|
|
39
|
+
owner: owners[path],
|
|
40
|
+
patchedBy: patchers[path] ?? [],
|
|
41
|
+
changedBy: changerOf(layered, path),
|
|
42
|
+
forks: identical(steps, changerOf(layered, path), path),
|
|
43
|
+
hash: hash(files[path]),
|
|
44
|
+
overrides: overrides.find(override => override.path === path && override.step === owners[path])?.over ?? null,
|
|
45
|
+
})),
|
|
46
|
+
dependencies: dependencies(files),
|
|
47
|
+
changes: changes(before, files, [...new Set([...Object.keys(before), ...Object.keys(files)])].sort(order)),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const index = async (steps, { name, address, editable = false }) => {
|
|
52
|
+
const { children, joins, timelines, stacks, located, addresses } = tree(steps)
|
|
53
|
+
const kind = await kinds(steps)
|
|
54
|
+
const shown = (line, lane) => line.filter((_, depth) => kind[timelines[lane][depth]] !== 'obsolete')
|
|
55
|
+
return {
|
|
56
|
+
codecane: 1,
|
|
57
|
+
version: manifest.version,
|
|
58
|
+
name,
|
|
59
|
+
editable,
|
|
60
|
+
steps: steps.map(({ id, hash, parents, merges, title }) => ({ id, hash, parents, merges, title, children: children(id), joins: joins(id), kind: kind[id] ?? null, untested: untested(steps, id), data: Object.fromEntries(addresses.filter(stacked => located[stacked].id === id).map(stacked => [stacked, address(stacked)])) })),
|
|
61
|
+
timelines: timelines.map(shown),
|
|
62
|
+
stacks: stacks.map(shown),
|
|
63
|
+
}
|
|
64
|
+
}
|
package/core/diff.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const split = text => text ? text.split('\n') : []
|
|
2
|
+
|
|
3
|
+
export const lines = (before, after) => {
|
|
4
|
+
const [one, other] = [split(before), split(after)]
|
|
5
|
+
let head = 0
|
|
6
|
+
let tail = 0
|
|
7
|
+
while (head < one.length && head < other.length && one[head] === other[head]) head++
|
|
8
|
+
while (tail < one.length - head && tail < other.length - head && one.at(-1 - tail) === other.at(-1 - tail)) tail++
|
|
9
|
+
const [a, b] = [one.slice(head, one.length - tail), other.slice(head, other.length - tail)]
|
|
10
|
+
const keptFrom = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1))
|
|
11
|
+
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) keptFrom[i][j] = a[i] === b[j] ? keptFrom[i + 1][j + 1] + 1 : Math.max(keptFrom[i + 1][j], keptFrom[i][j + 1])
|
|
12
|
+
const rows = one.slice(0, head).map(line => [' ', line])
|
|
13
|
+
let i = 0
|
|
14
|
+
let j = 0
|
|
15
|
+
while (i < a.length || j < b.length) {
|
|
16
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
17
|
+
rows.push([' ', a[i++]])
|
|
18
|
+
j++
|
|
19
|
+
} else if (i < a.length && (j === b.length || keptFrom[i + 1][j] >= keptFrom[i][j + 1])) rows.push(['-', a[i++]])
|
|
20
|
+
else rows.push(['+', b[j++]])
|
|
21
|
+
}
|
|
22
|
+
return [...rows, ...one.slice(one.length - tail).map(line => [' ', line])]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const text = body => body && !body.includes(0) ? body.toString('utf8') : undefined
|
|
26
|
+
|
|
27
|
+
export const changes = (before, after, paths) => paths
|
|
28
|
+
.filter(path => !(path in before && path in after && before[path].equals(after[path])))
|
|
29
|
+
.map(path => {
|
|
30
|
+
const state = !(path in before) ? 'added' : !(path in after) ? 'removed' : 'modified'
|
|
31
|
+
const [was, is] = [text(before[path]), text(after[path])]
|
|
32
|
+
if ([before[path], after[path]].some(body => body && text(body) === undefined)) return { path, state, binary: true }
|
|
33
|
+
const rows = lines(was ?? '', is ?? '')
|
|
34
|
+
return {
|
|
35
|
+
path,
|
|
36
|
+
state,
|
|
37
|
+
before: was ?? '',
|
|
38
|
+
after: is ?? '',
|
|
39
|
+
added: rows.filter(([op]) => op === '+').length,
|
|
40
|
+
removed: rows.filter(([op]) => op === '-').length,
|
|
41
|
+
lines: rows,
|
|
42
|
+
}
|
|
43
|
+
})
|
package/core/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { build } from './build.js'
|
|
2
|
+
export { check } from './check.js'
|
|
3
|
+
export { convergence, replayed, trial } from './checks.js'
|
|
4
|
+
export { entry, index } from './data.js'
|
|
5
|
+
export { changes, lines } from './diff.js'
|
|
6
|
+
export { applied, format, made, parse, rebased } from './patch.js'
|
|
7
|
+
export { patchCopies, rebaseLater, revise, writes } from './rebase.js'
|
|
8
|
+
export { serve } from './serve.js'
|
|
9
|
+
export { conflictsOf, describe, graphOf, locate, replay, stack, tree } from './stack.js'
|
|
10
|
+
export { persisted, read } from './steps.js'
|
package/core/modules.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const find = (name, from) => {
|
|
5
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
6
|
+
if (existsSync(join(dir, 'node_modules', name, 'package.json'))) return join(dir, 'node_modules', name)
|
|
7
|
+
if (dir === dirname(dir)) return undefined
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const located = (name, from) => {
|
|
12
|
+
const dir = find(name, from)
|
|
13
|
+
if (!dir) throw new Error(`codecane: ${name} is not installed in ${from} or any folder above it`)
|
|
14
|
+
return dir
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const dependencies = files => 'package.json' in files ? Object.keys(JSON.parse(files['package.json']).dependencies ?? {}) : []
|