@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/core/patch.js ADDED
@@ -0,0 +1,115 @@
1
+ import { lines, split } from './diff.js'
2
+
3
+ const context = 3
4
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
5
+
6
+ const before = hunk => hunk.rows.filter(([op]) => op !== '+').map(([, line]) => line)
7
+ const after = hunk => hunk.rows.filter(([op]) => op !== '-').map(([, line]) => line)
8
+ const startOf = hunk => hunk.from - (before(hunk).length ? 1 : 0)
9
+
10
+ export const parse = text => {
11
+ const hunks = split(text.replace(/\n$/, '')).reduce((hunks, line) => {
12
+ const opened = line.match(header)
13
+ if (opened) return [...hunks, { from: Number(opened[1]), was: Number(opened[2] ?? 1), is: Number(opened[4] ?? 1), rows: [] }]
14
+ if (!hunks.length || line.startsWith('\\')) return hunks
15
+ hunks.at(-1).rows.push(line === '' ? [' ', ''] : [line[0], line.slice(1)])
16
+ return hunks
17
+ }, [])
18
+ const malformed = hunks.find(hunk => hunk.was !== before(hunk).length || hunk.is !== after(hunk).length || hunk.rows.some(([op]) => !' -+'.includes(op)))
19
+ if (malformed) throw new Error(`codecane: the hunk @@ -${malformed.from},${malformed.was} @@ has lines that don't match its counts or don't start with a space, - or +`)
20
+ return hunks.map(({ from, rows }) => ({ from, rows }))
21
+ }
22
+
23
+ export const format = hunks => {
24
+ let shift = 0
25
+ return hunks.map(hunk => {
26
+ const [was, is] = [before(hunk).length, after(hunk).length]
27
+ const to = hunk.from + shift + (was ? 0 : 1) - (is ? 0 : 1)
28
+ shift += is - was
29
+ return [`@@ -${hunk.from},${was} +${to},${is} @@`, ...hunk.rows.map(([op, line]) => `${op}${line}`)].join('\n')
30
+ }).join('\n') + (hunks.length ? '\n' : '')
31
+ }
32
+
33
+ export const made = (base, text) => {
34
+ const rows = lines(base, text)
35
+ const changed = rows.map(([op], at) => op !== ' ' ? at : -1).filter(at => at >= 0)
36
+ const ranges = changed.reduce((ranges, at) => {
37
+ const last = ranges.at(-1)
38
+ if (last && at - last[1] <= 2 * context + 1) last[1] = at
39
+ else ranges.push([at, at])
40
+ return ranges
41
+ }, [])
42
+ return ranges.map(([first, last]) => {
43
+ const [start, end] = [Math.max(0, first - context), Math.min(rows.length - 1, last + context)]
44
+ const kept = rows.slice(0, start).filter(([op]) => op !== '+').length
45
+ const hunk = rows.slice(start, end + 1)
46
+ return { from: kept + (hunk.some(([op]) => op !== '+') ? 1 : 0), rows: hunk }
47
+ })
48
+ }
49
+
50
+ export const applied = (hunks, text) => {
51
+ const base = split(text)
52
+ const result = []
53
+ let at = 0
54
+ for (const hunk of hunks) {
55
+ const start = startOf(hunk)
56
+ const expected = before(hunk)
57
+ const found = base.slice(start, start + expected.length)
58
+ if (start < at || start > base.length || expected.some((line, index) => line !== found[index])) return { conflict: { line: start + 1, expected, found } }
59
+ result.push(...base.slice(at, start), ...after(hunk))
60
+ at = start + expected.length
61
+ }
62
+ return { text: [...result, ...base.slice(at)].join('\n') }
63
+ }
64
+
65
+ const blocks = hunk => {
66
+ const found = []
67
+ let old = startOf(hunk)
68
+ let current = null
69
+ hunk.rows.forEach(([op, line]) => {
70
+ if (op === ' ') {
71
+ current = null
72
+ old++
73
+ return
74
+ }
75
+ if (!current) found.push(current = { at: old, removed: [], added: [] })
76
+ if (op === '-') {
77
+ current.removed.push(line)
78
+ old++
79
+ } else current.added.push(line)
80
+ })
81
+ return found
82
+ }
83
+
84
+ export const rebased = (hunks, was, is) => {
85
+ const [old, base] = [split(was), split(is)]
86
+ const mapped = { [-1]: -1, [old.length]: base.length }
87
+ let [at, now] = [0, 0]
88
+ lines(was, is).forEach(([op]) => {
89
+ if (op === ' ') mapped[at] = now
90
+ if (op !== '+') at++
91
+ if (op !== '-') now++
92
+ })
93
+ const placed = []
94
+ for (const block of hunks.flatMap(blocks)) {
95
+ const span = block.removed.length
96
+ const target = span ? mapped[block.at] : mapped[block.at - 1] + 1
97
+ const intact = span
98
+ ? block.removed.every((_, index) => mapped[block.at + index] === target + index)
99
+ : mapped[block.at] === target
100
+ if (!intact || Number.isNaN(target) || target === undefined) {
101
+ const first = span ? block.at : Math.max(0, block.at - 1)
102
+ const expected = old.slice(first, span ? block.at + span : block.at + 1)
103
+ const anchor = expected.map((_, index) => mapped[first + index] - index).find(position => position >= 0) ?? first
104
+ return { conflict: { line: first + 1, expected, found: base.slice(anchor, anchor + expected.length) } }
105
+ }
106
+ placed.push({ ...block, target })
107
+ }
108
+ let cursor = 0
109
+ const result = placed.flatMap(block => {
110
+ const kept = base.slice(cursor, block.target)
111
+ cursor = block.target + block.removed.length
112
+ return [...kept, ...block.added]
113
+ })
114
+ return { hunks: made(is, [...result, ...base.slice(cursor)].join('\n')) }
115
+ }
package/core/rebase.js ADDED
@@ -0,0 +1,80 @@
1
+ import { format, made, parse, rebased } from './patch.js'
2
+ import { beneath, changerOf, graphOf, layered, locate, replay, same, stack, under, unkeyed } from './stack.js'
3
+
4
+ export const writes = (from, to) => {
5
+ const was = Object.fromEntries(from.map(step => [step.id, step]))
6
+ return Object.fromEntries(to.flatMap(step => {
7
+ const old = was[step.id]
8
+ const files = [...new Set([...Object.keys(old.files), ...Object.keys(step.files)])]
9
+ .filter(path => !same(old.files[path], step.files[path]))
10
+ .map(path => [`${step.folder}/${path}`, step.files[path] ?? null])
11
+ const patches = [...new Set([...Object.keys(old.patches), ...Object.keys(step.patches)])]
12
+ .filter(path => old.patches[path] !== step.patches[path])
13
+ .map(path => [`${step.folder}/${path}.patch`, step.patches[path] ?? null])
14
+ return [...files, ...patches]
15
+ }))
16
+ }
17
+
18
+ export const rebaseLater = (before, after) => {
19
+ const old = replay(before)
20
+ const was = Object.fromEntries(before.map(step => [step.id, step]))
21
+ const { ways, keyOf } = graphOf(after)
22
+ const now = {}
23
+ const moved = []
24
+ const conflicts = []
25
+ const steps = after.map(step => {
26
+ const lines = ways(step.id).map(through => [beneath(step, through, now, after), was[step.id] && beneath(was[step.id], through, old, before)])
27
+ const patches = Object.fromEntries(Object.entries(step.patches).map(([path, patch]) => {
28
+ const [below, oldBelow] = lines.find(([below, oldBelow]) => oldBelow?.files[path] && below.files[path] && !same(oldBelow.files[path], below.files[path])) ?? []
29
+ if (was[step.id]?.patches[path] !== patch || !below) return [path, patch]
30
+ const result = rebased(parse(patch), oldBelow.files[path].toString(), below.files[path].toString())
31
+ if (result.conflict) {
32
+ conflicts.push({ step: step.id, path, below: changerOf(below, path), ...result.conflict })
33
+ return [path, patch]
34
+ }
35
+ moved.push({ step: step.id, path })
36
+ return [path, format(result.hunks)]
37
+ }))
38
+ const rebasedStep = { ...step, patches }
39
+ ways(step.id).forEach(through => now[keyOf(step.id, through)] = layered(rebasedStep, through, now, after))
40
+ return rebasedStep
41
+ })
42
+ const strict = Object.entries(now).flatMap(([key, layered]) => layered.conflicts.filter(conflict => conflict.step === unkeyed(key).id))
43
+ return {
44
+ steps,
45
+ moved,
46
+ conflicts: [...conflicts, ...strict.filter(conflict => !conflicts.some(other => other.step === conflict.step && other.path === conflict.path))],
47
+ }
48
+ }
49
+
50
+ export const identical = (steps, id, path) => {
51
+ const step = steps.find(step => step.id === id)
52
+ return steps.filter(other => other.parents.join() === step.parents.join() && (path in step.patches ? other.patches[path] === step.patches[path] : same(other.files[path], step.files[path]))).map(other => other.id)
53
+ }
54
+
55
+ export const revise = (steps, address, path, text, forks = false) => {
56
+ const { id, through } = locate(steps, address)
57
+ const target = changerOf(stack(steps, id, through), path)
58
+ const targets = forks ? identical(steps, target, path) : [target]
59
+ const step = steps.find(step => step.id === target)
60
+ const patch = path in step.files ? '' : format(made(under(steps, target, through).files[path].toString(), text))
61
+ const edited = other => path in other.files
62
+ ? { ...other, files: { ...other.files, [path]: Buffer.from(text) } }
63
+ : { ...other, patches: Object.fromEntries(Object.entries({ ...other.patches, [path]: patch }).filter(([, body]) => body)) }
64
+ const result = rebaseLater(steps, steps.map(other => targets.includes(other.id) ? edited(other) : other))
65
+ return { targets, ...result, writes: writes(steps, result.steps) }
66
+ }
67
+
68
+ export const patchCopies = (steps, id, paths) => {
69
+ const step = steps.find(step => step.id === id)
70
+ if (!step) throw new Error(`codecane: there is no step ${id}`)
71
+ const below = under(steps, id)
72
+ const copies = (paths.length ? paths : Object.keys(step.files)).filter(path => path in below.files && path in step.files && !step.overrides.includes(path))
73
+ const edited = {
74
+ ...step,
75
+ files: Object.fromEntries(Object.entries(step.files).filter(([path]) => !copies.includes(path))),
76
+ patches: { ...step.patches, ...Object.fromEntries(copies.map(path => [path, format(made(below.files[path].toString(), step.files[path].toString()))]).filter(([, patch]) => patch)) },
77
+ }
78
+ const patched = steps.map(other => other.id === id ? edited : other)
79
+ return { copies, steps: patched, writes: writes(steps, patched) }
80
+ }
package/core/serve.js ADDED
@@ -0,0 +1,170 @@
1
+ import { existsSync, watch } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { createServer } from 'node:http'
4
+ import { extname, join, normalize } from 'node:path'
5
+
6
+ import { replayed } from './checks.js'
7
+ import { entry, index } from './data.js'
8
+ import { find } from './modules.js'
9
+ import { patchCopies, rebaseLater, revise, writes } from './rebase.js'
10
+ import { describe, locate, named, stack, tree } from './stack.js'
11
+ import { persisted, read, stamp } from './steps.js'
12
+
13
+ const types = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.mjs': 'text/javascript; charset=utf-8',
17
+ '.css': 'text/css; charset=utf-8',
18
+ '.json': 'application/json; charset=utf-8',
19
+ '.svg': 'image/svg+xml',
20
+ '.png': 'image/png',
21
+ '.jpg': 'image/jpeg',
22
+ '.webp': 'image/webp',
23
+ '.woff2': 'font/woff2',
24
+ '.wasm': 'application/wasm',
25
+ }
26
+
27
+ const body = request => new Promise(done => {
28
+ const chunks = []
29
+ request.on('data', chunk => chunks.push(chunk)).on('end', () => done(Buffer.concat(chunks)))
30
+ })
31
+
32
+ const inside = path => {
33
+ if (normalize(path).split('/').includes('..')) throw new Error(`codecane: ${path} reaches outside its folder`)
34
+ return normalize(path)
35
+ }
36
+
37
+ const settle = 300
38
+
39
+ const shapeOf = step => JSON.stringify([step.parents, step.merges, step.removes, step.overrides, step.story, step.check])
40
+ const changedSince = (before, step) => {
41
+ const old = before.find(other => other.id === step.id)
42
+ return !old || shapeOf(old) !== shapeOf(step) || Object.keys(writes([old], [step])).length > 0
43
+ }
44
+
45
+ const reported = ({ stop, why, obsolete }) => {
46
+ if (stop) return console.error(`codecane: the replay stops at step ${named(stop)}:\n ${why.join('\n ').replaceAll('\n', '\n ')}`)
47
+ obsolete.forEach(id => console.info(`codecane: the seam in step ${id} is no longer needed, so the timeline leaves it out.`))
48
+ }
49
+
50
+ const stacked = (steps, address) => tree(steps).addresses.includes(address)
51
+ const stackedAt = (steps, path) => tree(steps).addresses.filter(address => path.startsWith(`${address}/`)).sort((a, b) => b.length - a.length)[0]
52
+
53
+ const scriptsOf = html => (html.match(/<script\b[\s\S]*?<\/script>/g) ?? []).join('\n')
54
+
55
+ export const serve = async ({ project, viewer, port }) => {
56
+ const listeners = new Set()
57
+ const announce = (event, data = {}) => listeners.forEach(response => response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
58
+ let snapshot = []
59
+ let queue = Promise.resolve()
60
+ const queued = work => {
61
+ const run = queue.then(work)
62
+ queue = Promise.allSettled([run])
63
+ return run
64
+ }
65
+
66
+ const synced = async () => {
67
+ await stamp(project)
68
+ const landed = await read(project)
69
+ const changed = landed.filter(step => changedSince(snapshot, step)).map(step => step.id)
70
+ if (!changed.length) return
71
+ const patched = changed.reduce((steps, id) => patchCopies(steps, id, []).steps, landed)
72
+ const { steps, moved, conflicts } = rebaseLater(snapshot, patched)
73
+ await persisted(project, writes(landed, steps))
74
+ moved.forEach(({ step, path }) => console.info(`codecane: rebased step ${step}'s patch of ${path}`))
75
+ conflicts.forEach(conflict => console.error(`codecane: ${describe(conflict)}`))
76
+ if (conflicts.length) return
77
+ snapshot = steps
78
+ reported(await replayed(steps, changed))
79
+ announce('steps-update')
80
+ }
81
+
82
+ await queued(synced)
83
+
84
+ let settling
85
+ watch(project, { recursive: true }, (_, path) => {
86
+ if (!/^(steps|parts)\//.test(path ?? '')) return
87
+ clearTimeout(settling)
88
+ settling = setTimeout(() => queued(synced).catch(error => console.error(error.message)), settle)
89
+ })
90
+
91
+ let shellScripts = scriptsOf(await readFile(join(viewer.dir, 'index.html'), 'utf8'))
92
+ const viewerChanged = async path => {
93
+ if (path.startsWith('components/') && path.endsWith('.html')) return announce('component-update', { path: `/codecane/ui/${path}` })
94
+ if (path.endsWith('.css')) return announce('style-update', { path: `/codecane/ui/${path}` })
95
+ if (path.endsWith('.js')) return announce('page-update', { scriptsChanged: true })
96
+ if (path !== 'index.html') return
97
+ const scripts = scriptsOf(await readFile(join(viewer.dir, 'index.html'), 'utf8'))
98
+ announce('page-update', { scriptsChanged: scripts !== shellScripts })
99
+ shellScripts = scripts
100
+ }
101
+
102
+ watch(viewer.dir, { recursive: true }, (_, path) => viewerChanged(path).catch(error => console.error(error.message)))
103
+
104
+ const saved = async (address, path, text, forks) => {
105
+ const steps = await read(project)
106
+ const { targets, moved, conflicts, writes: edits, steps: revised } = revise(steps, address, path, text, forks)
107
+ if (conflicts.length) return [409, [`Not saved: saving ${path} to ${targets.length > 1 ? 'steps' : 'step'} ${targets.join(', ')} would break later patches.`, ...conflicts.map(describe)].join('\n')]
108
+ await persisted(project, edits)
109
+ snapshot = revised
110
+ reported(await replayed(revised, targets))
111
+ return [200, JSON.stringify({ targets, moved }), '.json']
112
+ }
113
+
114
+ const routes = [
115
+ [/^\/codecane\/index\.json$/, async ({ steps, name }) => [JSON.stringify(await index(steps, { name, address: id => `${id}.json`, editable: true })), '.json']],
116
+ [/^\/codecane\/ui\/(.+)$/, async (_, path) => existsSync(join(viewer.dir, inside(path))) && [await readFile(join(viewer.dir, inside(path))), extname(path)]],
117
+ [/^\/codecane\/(.+)\.json$/, async ({ steps }, address) => stacked(steps, address) && [JSON.stringify(await entry(steps, address)), '.json']],
118
+ [/^\/steps\/(.+)$/, async ({ steps }, path) => {
119
+ const address = stackedAt(steps, path)
120
+ if (!address) return false
121
+ const { id, through } = locate(steps, address)
122
+ const { files } = stack(steps, id, through)
123
+ const file = path.slice(address.length + 1) || 'index.html'
124
+ return file in files && [files[file], extname(file)]
125
+ }],
126
+ [/^\/node_modules\/((?:@[^/]+\/)?[^/]+)\/(.+)$/, async (_, module, path) => {
127
+ const dir = find(module, project) ?? find(module, viewer.dir)
128
+ return dir && existsSync(join(dir, inside(path))) && [await readFile(join(dir, inside(path))), extname(path)]
129
+ }],
130
+ [/^\/(.*?)\/?$/, async ({ steps }, address) => (!address || address in tree(steps).located) && [await readFile(join(viewer.dir, 'index.html')), '.html']],
131
+ ]
132
+
133
+ return createServer(async (request, response) => {
134
+ const send = (status, content = '', extension = '.txt') => {
135
+ response.writeHead(status, { 'content-type': types[extension] ?? 'application/octet-stream', 'cache-control': 'no-store' })
136
+ response.end(content)
137
+ }
138
+ try {
139
+ const url = new URL(request.url, 'http://codecane')
140
+ const path = decodeURIComponent(url.pathname)
141
+ if (path === '/codecane/events') {
142
+ response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' }).flushHeaders()
143
+ listeners.add(response)
144
+ return request.on('close', () => listeners.delete(response))
145
+ }
146
+ const steps = await read(project)
147
+ const { name } = JSON.parse(await readFile(join(project, 'package.json'), 'utf8'))
148
+ if (request.method === 'PUT') {
149
+ const [, file] = path.match(/^\/codecane\/save\/(.+)$/) ?? []
150
+ const address = url.searchParams.get('at')
151
+ if (!stacked(steps, address)) return send(404, `codecane: there is no step ${address}`)
152
+ const text = (await body(request)).toString()
153
+ return send(...await queued(() => saved(address, inside(file), text, url.searchParams.has('forks'))))
154
+ }
155
+ if (path.startsWith('/steps/') && stacked(steps, path.slice('/steps/'.length))) {
156
+ response.writeHead(301, { location: `${path}/` })
157
+ return response.end()
158
+ }
159
+ for (const [pattern, route] of routes) {
160
+ const match = path.match(pattern)
161
+ const found = match && await route({ steps, name }, ...match.slice(1))
162
+ if (found) return send(200, ...found)
163
+ }
164
+ send(404, `codecane: nothing at ${path}`)
165
+ } catch (error) {
166
+ console.error(error)
167
+ send(500, error.message)
168
+ }
169
+ }).listen(port, () => console.info(`codecane: serving ${project} at http://localhost:${port}`))
170
+ }
package/core/stack.js ADDED
@@ -0,0 +1,199 @@
1
+ import { applied, parse } from './patch.js'
2
+
3
+ const order = (a, b) => a.localeCompare(b, undefined, { numeric: true })
4
+
5
+ export const same = (one, other) => one === other || Boolean(one && other && one.equals(other))
6
+
7
+ export const unkeyed = key => {
8
+ const [id, ...through] = key.split('|')
9
+ return { id, through }
10
+ }
11
+
12
+ export const named = key => {
13
+ const { id, through } = unkeyed(key)
14
+ return through.length ? `${id} through ${through.join(', ')}` : id
15
+ }
16
+
17
+ const graphs = new WeakMap()
18
+
19
+ export const graphOf = steps => {
20
+ if (graphs.has(steps)) return graphs.get(steps)
21
+ const byId = Object.fromEntries(steps.map(step => [step.id, step]))
22
+ const parentOf = (id, through = []) => byId[id].parents.find(parent => through.includes(parent)) ?? byId[id].parents[0] ?? null
23
+ const picks = (id, through) => {
24
+ const parent = parentOf(id, through)
25
+ return parent === null ? [] : [...picks(parent, through), ...parent === byId[id].parents[0] ? [] : [parent]]
26
+ }
27
+ const keyOf = (id, through = []) => [id, ...picks(id, through)].join('|')
28
+ const known = {}
29
+ const lineage = (id, through = []) => id === null ? [] : known[keyOf(id, through)] ??= (() => {
30
+ const own = picks(id, through)
31
+ return [...lineage(parentOf(id, own), own), ...brought(id, own), id]
32
+ })()
33
+ const brought = (id, through = []) => [...new Set(byId[id].merges.flatMap(other => lineage(other, through)))].filter(other => !lineage(parentOf(id, through), through).includes(other))
34
+ const paths = {}
35
+ const ways = id => paths[id] ??= byId[id].parents.length ? byId[id].parents.flatMap((parent, at) => ways(parent).map(through => at ? [...through, parent] : through)) : [[]]
36
+ const graph = { byId, parentOf, keyOf, lineage, brought, ways, keys: steps.flatMap(step => ways(step.id).map(through => [step.id, ...through].join('|'))) }
37
+ graphs.set(steps, graph)
38
+ return graph
39
+ }
40
+
41
+ export const tree = steps => {
42
+ const at = Object.fromEntries(steps.map((step, index) => [step.id, index]))
43
+ const orphan = steps.find(step => step.parents.some(parent => !(at[parent] < at[step.id])))
44
+ if (orphan) throw new Error(`codecane: step ${orphan.id} names parent ${orphan.parents.join(', ')}, which must be earlier steps`)
45
+ const late = steps.find(step => step.merges.some(id => !(at[id] < at[step.id])))
46
+ if (late) throw new Error(`codecane: step ${late.id} merges ${late.merges.join(', ')}, which must be earlier steps`)
47
+ const { byId, lineage, brought } = graphOf(steps)
48
+ const idle = steps.find(step => step.merges.length && !brought(step.id).length && !ownPaths(step).length)
49
+ if (idle) throw new Error(`codecane: step ${idle.id} merges ${idle.merges.join(', ')}, which its timeline already holds, and holds no seam`)
50
+ const children = id => steps.filter(step => id === null ? !step.parents.length : step.parents.includes(id)).map(step => step.id).sort(order)
51
+ const joins = id => steps.filter(step => step.merges.includes(id) && brought(step.id).length).map(step => step.id).sort(order)
52
+ const lines = id => {
53
+ const next = [...children(id), ...joins(id)]
54
+ return next.length ? next.flatMap(other => lines(other).map(line => [id, ...line])) : [[id]]
55
+ }
56
+ const loose = steps.find(step => seamed(step) && !step.part)
57
+ if (loose) throw new Error(`codecane: step ${loose.id} is a seam, so it lives in parts/${loose.hash}: move ${loose.folder} there, with its story, take the hash out of its codecane.json, and name ${loose.hash} wherever a step names ${loose.id}`)
58
+ const stray = steps.find(step => step.part && !seamed(step))
59
+ if (stray) throw new Error(`codecane: ${stray.folder} holds no seam, and parts/ holds only seams; move it into steps/ under a name`)
60
+ const timelines = children(null).flatMap(lines)
61
+ const ways = id => children(id).length + joins(id).length
62
+ const addressed = timeline => timeline.map((id, depth) => [id, ...timeline.slice(1, depth).filter((_, at) => ways(timeline[at]) > 1).map(chosen => byId[chosen].hash)].join('/'))
63
+ const stacks = timelines.map(addressed)
64
+ const place = (timeline, depth) => ({ id: timeline[depth], through: timeline.slice(0, depth).filter((parent, step) => byId[timeline[step + 1]].parents.indexOf(parent) > 0) })
65
+ const first = id => timelines.find(timeline => timeline.includes(id))
66
+ const located = {
67
+ ...Object.fromEntries(steps.map(({ id }) => [id, place(first(id), first(id).indexOf(id))])),
68
+ ...Object.fromEntries(timelines.flatMap((timeline, lane) => timeline.map((_, depth) => [stacks[lane][depth], place(timeline, depth)]))),
69
+ }
70
+ return { children, joins, lineage, brought, timelines, stacks, located, addresses: [...new Set(stacks.flat())] }
71
+ }
72
+
73
+ export const seamed = step => (step.merges.length > 0 || step.parents.length > 1) && ownPaths(step).length > 0
74
+
75
+ export const locate = (steps, address) => {
76
+ const { located, stacks } = tree(steps)
77
+ if (!located[address]) throw new Error(`codecane: there is no step ${address}; the steps on the timelines are ${[...new Set(stacks.flat())].join(', ')}`)
78
+ return located[address]
79
+ }
80
+
81
+ export const empty = { files: {}, owners: {}, patchers: {}, overrides: [], conflicts: [] }
82
+
83
+ export const changerOf = (layered, path) => layered.patchers[path]?.at(-1) ?? layered.owners[path]
84
+
85
+ export const layer = (step, below, through = []) => {
86
+ const files = { ...below.files }
87
+ const owners = { ...below.owners }
88
+ const patchers = { ...below.patchers }
89
+ const overrides = [...below.overrides]
90
+ const conflicts = [...below.conflicts]
91
+ const fail = message => { throw new Error(`codecane: step ${step.id} ${message}`) }
92
+
93
+ step.removes.forEach(path => {
94
+ if (!(path in files)) fail(`removes ${path}, which no earlier step has`)
95
+ if (path in step.files) fail(`removes ${path} and brings it again; list it in overrides instead`)
96
+ delete files[path]
97
+ delete owners[path]
98
+ delete patchers[path]
99
+ })
100
+
101
+ Object.entries(step.files).forEach(([path, body]) => {
102
+ if (path in step.patches) fail(`brings ${path} and patches it too`)
103
+ if (path in files && !step.overrides.includes(path)) fail(`brings its own copy of ${path} from step ${changerOf({ owners, patchers }, path)}; turn it into a patch with codecane patch ${step.id} ${path}, or list it in overrides in its codecane.json if a full copy is deliberate`)
104
+ if (!(path in files) && step.overrides.includes(path)) fail(`overrides ${path}, which no earlier step has`)
105
+ if (path in files) overrides.push({ path, step: step.id, over: changerOf({ owners, patchers }, path) })
106
+ files[path] = body
107
+ owners[path] = step.id
108
+ delete patchers[path]
109
+ })
110
+
111
+ Object.entries(step.patches).forEach(([path, patch]) => {
112
+ if (!(path in files)) return conflicts.push({ step: step.id, through, path, missing: true })
113
+ const result = applied(parse(patch), files[path].toString())
114
+ if (result.conflict) return conflicts.push({ step: step.id, through, path, below: changerOf({ owners, patchers }, path), ...result.conflict })
115
+ files[path] = Buffer.from(result.text)
116
+ patchers[path] = [...(patchers[path] ?? []), step.id]
117
+ })
118
+
119
+ return { files, owners, patchers, overrides, conflicts }
120
+ }
121
+
122
+ const parentLayer = (step, through, layers, steps) => {
123
+ const { parentOf, keyOf } = graphOf(steps)
124
+ const parent = parentOf(step.id, through)
125
+ return parent === null ? empty : layers[keyOf(parent, through)]
126
+ }
127
+
128
+ const replayed = (below, step, through, layers, steps) => {
129
+ const [before, after] = [parentLayer(step, through, layers, steps), layers[graphOf(steps).keyOf(step.id, through)]]
130
+ const files = { ...below.files }
131
+ const owners = { ...below.owners }
132
+ const patchers = { ...below.patchers }
133
+ const clashes = []
134
+ const set = (path, body, owned) => {
135
+ if (!body) return [files, owners, patchers].forEach(map => delete map[path])
136
+ files[path] = body
137
+ if (!owned) return patchers[path] = [...(patchers[path] ?? []), step.id]
138
+ owners[path] = step.id
139
+ delete patchers[path]
140
+ }
141
+ ;[...new Set([...Object.keys(before.files), ...Object.keys(after.files)])].filter(path => !same(before.files[path], after.files[path])).forEach(path => {
142
+ const [was, now, here] = [before.files[path], after.files[path], files[path]]
143
+ if (same(here, now)) return
144
+ if (same(here, was)) return set(path, now, !was || path in step.files)
145
+ const patch = here && step.patches[path] && applied(parse(step.patches[path]), here.toString())
146
+ if (patch && !patch.conflict) return set(path, Buffer.from(patch.text), false)
147
+ clashes.push({ step: step.id, path, below: changerOf({ owners, patchers }, path) })
148
+ })
149
+ return { ...below, files, owners, patchers, clashes: [...below.clashes, ...clashes] }
150
+ }
151
+
152
+ export const merged = (step, through, layers, steps) => graphOf(steps).brought(step.id, through)
153
+ .map(id => steps.find(other => other.id === id))
154
+ .reduce((below, other) => replayed(below, other, through, layers, steps), { ...parentLayer(step, through, layers, steps), clashes: [] })
155
+
156
+ export const ownPaths = step => [...Object.keys(step.files), ...Object.keys(step.patches), ...step.removes]
157
+
158
+ export const beneath = (step, through, layers, steps) => {
159
+ if (!step.merges.length) return parentLayer(step, through, layers, steps)
160
+ const { clashes, ...below } = merged(step, through, layers, steps)
161
+ return below
162
+ }
163
+
164
+ export const layered = (step, through, layers, steps) => {
165
+ const own = unkeyed(graphOf(steps).keyOf(step.id, through)).through
166
+ if (!step.merges.length) return layer(step, parentLayer(step, own, layers, steps), own)
167
+ const { clashes, ...below } = merged(step, own, layers, steps)
168
+ const result = layer(step, below, own)
169
+ const open = clashes.filter(clash => !ownPaths(step).includes(clash.path))
170
+ return { ...result, conflicts: [...result.conflicts, ...open.map(clash => ({ ...clash, brought: clash.step, step: step.id, through: own, clash: true }))] }
171
+ }
172
+
173
+ export const describe = ({ step, through = [], path, below, line, expected, found, missing, clash, brought }) => {
174
+ const on = through.length ? ` on the timeline through ${through.join(', ')}` : ''
175
+ if (clash) return `step ${step} merges step ${brought}, whose change to ${path} doesn't replay onto ${path} as step ${below} leaves it${on}; the timelines differ there, so this merge needs a seam`
176
+ if (missing) return `step ${step} patches ${path}, which no earlier step on its timeline${on} has`
177
+ return [
178
+ `step ${step}'s patch of ${path} no longer applies at line ${line}, against ${path} as step ${below} leaves it${on}:`,
179
+ ...expected.map((text, index) => [text, found[index], line + index]).filter(([text, there]) => text !== there).map(([text, there, at]) => ` line ${at}: the patch expects ${JSON.stringify(text)}, the file has ${there === undefined ? 'no such line' : JSON.stringify(there)}`),
180
+ ].join('\n')
181
+ }
182
+
183
+ export const replay = steps => {
184
+ tree(steps)
185
+ const { keys, byId } = graphOf(steps)
186
+ return keys.map(unkeyed).reduce((layers, { id, through }) => ({ ...layers, [[id, ...through].join('|')]: layered(byId[id], through, layers, steps) }), {})
187
+ }
188
+
189
+ export const conflictsOf = layers => Object.entries(layers).flatMap(([key, layered]) => layered.conflicts.filter(conflict => conflict.step === unkeyed(key).id))
190
+
191
+ const layersOf = (steps, ids, through) => steps.filter(step => ids.includes(step.id)).reduce((layers, step) => ({ ...layers, [graphOf(steps).keyOf(step.id, through)]: layered(step, through, layers, steps) }), {})
192
+
193
+ export const under = (steps, id, through = []) => beneath(steps.find(step => step.id === id), through, layersOf(steps, tree(steps).lineage(id, through).slice(0, -1), through), steps)
194
+
195
+ export const stack = (steps, id, through = []) => {
196
+ const result = layersOf(steps, tree(steps).lineage(id, through), through)[graphOf(steps).keyOf(id, through)]
197
+ if (result.conflicts.length) throw new Error(`codecane: the steps don't stack. Run codecane check.\n${result.conflicts.map(describe).join('\n')}`)
198
+ return result
199
+ }
package/core/steps.js ADDED
@@ -0,0 +1,102 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ export const order = (a, b) => a.localeCompare(b, undefined, { numeric: true })
7
+
8
+ export const patched = '.patch'
9
+
10
+ const reserved = ['codecane', 'steps', 'parts', 'node_modules', 'index']
11
+ const keys = ['hash', 'parent', 'merges', 'removes', 'overrides']
12
+
13
+ const listed = async dir => (await readdir(dir, { recursive: true, withFileTypes: true }))
14
+ .filter(entry => entry.isFile() && !entry.name.startsWith('.'))
15
+ .map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1))
16
+ .filter(path => path !== 'codecane.json')
17
+ .sort(order)
18
+
19
+ const configured = async dir => {
20
+ if (!existsSync(join(dir, 'codecane.json'))) return {}
21
+ const config = JSON.parse(await readFile(join(dir, 'codecane.json'), 'utf8'))
22
+ const unknown = Object.keys(config).filter(key => !keys.includes(key))
23
+ if (unknown.length) throw new Error(`codecane: ${dir}/codecane.json has unknown keys ${unknown.join(', ')}; it knows ${keys.join(', ')}`)
24
+ return config
25
+ }
26
+
27
+ export const parentsFirst = steps => {
28
+ const known = new Set(steps.map(step => step.id))
29
+ const orphan = steps.find(step => step.parents.some(parent => !known.has(parent)))
30
+ if (orphan) throw new Error(`codecane: step ${orphan.id} names parent ${orphan.parents.filter(parent => !known.has(parent)).join(', ')}, which is no step`)
31
+ const children = id => steps.filter(step => id === null ? !step.parents.length : step.parents.includes(id))
32
+ const walked = id => children(id).flatMap(step => [step, ...walked(step.id)])
33
+ const pending = [...new Set(walked(null))]
34
+ const looped = steps.filter(step => !pending.includes(step)).map(step => step.id)
35
+ if (looped.length) throw new Error(`codecane: steps ${looped.join(', ')} name parents that lead back to themselves`)
36
+ const unknown = steps.find(step => step.merges.some(id => !known.has(id)))
37
+ if (unknown) throw new Error(`codecane: step ${unknown.id} merges ${unknown.merges.filter(id => !known.has(id)).join(', ')}, which is no step`)
38
+ const ordered = []
39
+ const placed = id => ordered.some(step => step.id === id)
40
+ while (pending.length) {
41
+ const ready = pending.findIndex(step => [...step.parents, ...step.merges].every(placed))
42
+ if (ready === -1) throw new Error(`codecane: steps ${pending.map(step => step.id).join(', ')} merge timelines that lead back to themselves`)
43
+ ordered.push(...pending.splice(ready, 1))
44
+ }
45
+ return ordered
46
+ }
47
+
48
+ const folders = async root => existsSync(root) ? (await readdir(root, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => entry.name).sort(order) : []
49
+
50
+ export const stamp = async project => {
51
+ const named = await folders(join(project, 'steps'))
52
+ const configs = await Promise.all(named.map(id => configured(join(project, 'steps', id))))
53
+ const taken = new Set([...configs.map(config => config.hash), ...await folders(join(project, 'parts'))])
54
+ const fresh = () => {
55
+ const hash = randomBytes(4).toString('hex').slice(0, 7)
56
+ return taken.has(hash) ? fresh() : taken.add(hash) && hash
57
+ }
58
+ await Promise.all(named.map((id, at) => !configs[at].hash && writeFile(join(project, 'steps', id, 'codecane.json'), `${JSON.stringify({ hash: fresh(), ...configs[at] }, null, 2)}\n`)))
59
+ }
60
+
61
+ export const read = async project => {
62
+ const named = await folders(join(project, 'steps'))
63
+ const taken = named.filter(id => reserved.includes(id))
64
+ if (taken.length) throw new Error(`codecane: ${taken.join(', ')} is reserved, rename the step`)
65
+ const loaded = async (folder, id, before) => {
66
+ const dir = join(project, folder)
67
+ const config = await configured(dir)
68
+ const part = folder.startsWith('parts/')
69
+ if (!part && !config.hash) throw new Error(`codecane: ${folder} has no hash yet; codecane check, build and dev give every new step one`)
70
+ const story = existsSync(`${dir}.html`) ? await readFile(`${dir}.html`, 'utf8') : ''
71
+ const paths = await listed(dir)
72
+ const file = async path => [path, await readFile(join(dir, path))]
73
+ return {
74
+ id,
75
+ hash: part ? id : config.hash,
76
+ folder,
77
+ part,
78
+ parents: [('parent' in config ? config.parent : before) ?? []].flat(),
79
+ merges: config.merges ?? [],
80
+ title: part ? `Seam ${id}` : story.match(/<h1[^>]*>(.*?)<\/h1>/s)?.[1].replace(/<[^>]+>/g, '').trim() ?? id,
81
+ story,
82
+ check: existsSync(`${dir}.check.js`) ? `${dir}.check.js` : null,
83
+ removes: config.removes ?? [],
84
+ overrides: config.overrides ?? [],
85
+ files: Object.fromEntries(await Promise.all(paths.filter(path => !path.endsWith(patched)).map(file))),
86
+ patches: Object.fromEntries((await Promise.all(paths.filter(path => path.endsWith(patched)).map(file))).map(([path, body]) => [path.slice(0, -patched.length), body.toString()])),
87
+ }
88
+ }
89
+ const steps = await Promise.all([
90
+ ...named.map((id, at) => loaded(`steps/${id}`, id, named[at - 1])),
91
+ ...(await folders(join(project, 'parts'))).map(hash => loaded(`parts/${hash}`, hash)),
92
+ ])
93
+ const twice = steps.find((step, at) => steps.findIndex(other => other.hash === step.hash) !== at)
94
+ if (twice) throw new Error(`codecane: ${twice.folder} has hash ${twice.hash}, which another step has too; give it a new one`)
95
+ return parentsFirst(steps)
96
+ }
97
+
98
+ export const persisted = (root, writes) => Promise.all(Object.entries(writes).map(async ([path, body]) => {
99
+ if (body === null) return rm(join(root, path))
100
+ await mkdir(dirname(join(root, path)), { recursive: true })
101
+ await writeFile(join(root, path), body)
102
+ }))