@ersbeth/codelife 0.0.0-bootstrap.0 → 0.11.1
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/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/agents/evaluate.md +97 -0
- package/agents/ground.md +49 -0
- package/agents/implementer.md +49 -0
- package/agents/judge.md +128 -0
- package/agents/planner.md +45 -0
- package/agents/prerequisites.md +62 -0
- package/agents/reader.md +40 -0
- package/agents/reproduce.md +65 -0
- package/bin/codelife +4 -0
- package/package.json +40 -1
- package/presets/tsconfig.json +14 -0
- package/presets/vite.config.d.mts +3 -0
- package/presets/vite.config.mjs +49 -0
- package/scripts/audit/cadence.mjs +24 -0
- package/scripts/audit/close.mjs +57 -0
- package/scripts/audit/file.mjs +70 -0
- package/scripts/audit/fold.mjs +48 -0
- package/scripts/audit/lib.mjs +93 -0
- package/scripts/audit/prepare.mjs +180 -0
- package/scripts/audit/table.mjs +65 -0
- package/scripts/brainstorm/decided.mjs +6 -0
- package/scripts/brainstorm/lib.mjs +33 -0
- package/scripts/brainstorm/named.mjs +9 -0
- package/scripts/changelog.mjs +100 -0
- package/scripts/check-tag.mjs +19 -0
- package/scripts/checks.mjs +53 -0
- package/scripts/codelife.mjs +42 -0
- package/scripts/commit-msg.mjs +43 -0
- package/scripts/contract.mjs +179 -0
- package/scripts/former-form.mjs +170 -0
- package/scripts/git/branches.mjs +6 -0
- package/scripts/git/commit.mjs +40 -0
- package/scripts/git/diff.mjs +17 -0
- package/scripts/git/diff_lines.mjs +5 -0
- package/scripts/git/held.mjs +53 -0
- package/scripts/git/is_ancestor.mjs +28 -0
- package/scripts/git/lib.mjs +20 -0
- package/scripts/git/log_since.mjs +27 -0
- package/scripts/git/parent.mjs +51 -0
- package/scripts/git/show.mjs +4 -0
- package/scripts/git/status.mjs +25 -0
- package/scripts/git/submodules.mjs +5 -0
- package/scripts/init.mjs +21 -0
- package/scripts/install.mjs +32 -0
- package/scripts/lenses/A11Y.md +91 -0
- package/scripts/lenses/ARCH.md +90 -0
- package/scripts/lenses/BUG.md +52 -0
- package/scripts/lenses/BUILD.md +40 -0
- package/scripts/lenses/COMPAT.md +67 -0
- package/scripts/lenses/CONF.md +49 -0
- package/scripts/lenses/DEPS.md +43 -0
- package/scripts/lenses/DOC.md +73 -0
- package/scripts/lenses/FAIL.md +57 -0
- package/scripts/lenses/GOV.md +56 -0
- package/scripts/lenses/GUARD.md +47 -0
- package/scripts/lenses/INTENT.md +52 -0
- package/scripts/lenses/LEAN.md +49 -0
- package/scripts/lenses/LINES.md +48 -0
- package/scripts/lenses/PERF.md +61 -0
- package/scripts/lenses/PKG.md +58 -0
- package/scripts/lenses/SEC.md +49 -0
- package/scripts/lenses/STANDARDS.md +62 -0
- package/scripts/lenses/TYPE.md +56 -0
- package/scripts/lenses/VERIF.md +81 -0
- package/scripts/link.mjs +40 -0
- package/scripts/methods/cause.md +21 -0
- package/scripts/methods/comments.md +157 -0
- package/scripts/methods/description.md +67 -0
- package/scripts/methods/message.md +24 -0
- package/scripts/methods/red-green.md +28 -0
- package/scripts/methods/refusal.md +22 -0
- package/scripts/mr/open.mjs +219 -0
- package/scripts/node-floor.mjs +19 -0
- package/scripts/onboard/prep.mjs +93 -0
- package/scripts/overview/codelife-line.mjs +33 -0
- package/scripts/overview/next.mjs +76 -0
- package/scripts/overview/overview.mjs +308 -0
- package/scripts/package-manager.mjs +20 -0
- package/scripts/port/item.mjs +94 -0
- package/scripts/port/lib.mjs +194 -0
- package/scripts/port/prep.mjs +33 -0
- package/scripts/port/version.mjs +50 -0
- package/scripts/pre-commit.mjs +43 -0
- package/scripts/pre-push.mjs +53 -0
- package/scripts/prepare-lib.mjs +81 -0
- package/scripts/read-roadmap/lib.mjs +116 -0
- package/scripts/read-roadmap/moved.mjs +70 -0
- package/scripts/read-roadmap/range.mjs +15 -0
- package/scripts/release/cut.mjs +120 -0
- package/scripts/release/entry.mjs +52 -0
- package/scripts/release/lib.mjs +136 -0
- package/scripts/release/prep.mjs +58 -0
- package/scripts/review-mr/prepare.mjs +163 -0
- package/scripts/roadmap/assemble.mjs +269 -0
- package/scripts/roadmap/cut.mjs +88 -0
- package/scripts/roadmap/prepare.mjs +249 -0
- package/scripts/roadmap/roadmap.md +30 -0
- package/scripts/roadmap/write.mjs +53 -0
- package/scripts/search.mjs +29 -0
- package/scripts/setup/contract.mjs +25 -0
- package/scripts/setup/item.mjs +166 -0
- package/scripts/setup/lib.mjs +205 -0
- package/scripts/setup/prep.mjs +106 -0
- package/scripts/spec-form.mjs +444 -0
- package/scripts/spec.mjs +56 -0
- package/scripts/tags.mjs +27 -0
- package/scripts/target/target-app.md +134 -0
- package/scripts/target/target-base.md +127 -0
- package/scripts/target/target-lib.md +143 -0
- package/scripts/target/templates/audit/README.md +21 -0
- package/scripts/target/templates/characterisation/bundle.test.ts +43 -0
- package/scripts/target/templates/characterisation/declarations.test.ts +44 -0
- package/scripts/target/templates/characterisation/entries.ts +50 -0
- package/scripts/target/templates/characterisation/examples.test.ts +18 -0
- package/scripts/target/templates/characterisation/import.test.ts +22 -0
- package/scripts/target/templates/characterisation/surface.test.ts +68 -0
- package/scripts/target/templates/cliff.toml +61 -0
- package/scripts/target/templates/githooks/commit-msg +9 -0
- package/scripts/target/templates/githooks/pre-commit +9 -0
- package/scripts/target/templates/githooks/pre-push +9 -0
- package/scripts/target/templates/playground/index.html +11 -0
- package/scripts/target/templates/playground/main.ts +9 -0
- package/scripts/target/templates/spec/README.md +35 -0
- package/scripts/target/templates/spec/capability.md +36 -0
- package/scripts/target/templates/spec/glossary.md +5 -0
- package/scripts/target/templates/spec/journey.md +29 -0
- package/scripts/target/templates/spec/principles.md +15 -0
- package/scripts/target/templates/spec/users.md +15 -0
- package/scripts/target/templates/tsconfig.json +9 -0
- package/scripts/target/templates/vite.config.ts +4 -0
- package/scripts/text.mjs +6 -0
- package/scripts/tracker/account.mjs +5 -0
- package/scripts/tracker/auth.mjs +9 -0
- package/scripts/tracker/cli.version.mjs +5 -0
- package/scripts/tracker/close.mjs +20 -0
- package/scripts/tracker/create.mjs +47 -0
- package/scripts/tracker/issue.section.mjs +25 -0
- package/scripts/tracker/issue.update.mjs +13 -0
- package/scripts/tracker/issue.view.mjs +16 -0
- package/scripts/tracker/lib.mjs +130 -0
- package/scripts/tracker/list.mjs +14 -0
- package/scripts/tracker/list.whole.mjs +20 -0
- package/scripts/tracker/lot.row.mjs +42 -0
- package/scripts/tracker/mr.checkout_command.mjs +5 -0
- package/scripts/tracker/mr.comments.mjs +50 -0
- package/scripts/tracker/mr.create.mjs +12 -0
- package/scripts/tracker/mr.description.mjs +8 -0
- package/scripts/tracker/mr.fetch_head.mjs +5 -0
- package/scripts/tracker/mr.find_for_branch.mjs +9 -0
- package/scripts/tracker/mr.post.mjs +40 -0
- package/scripts/tracker/mr.update_description.mjs +8 -0
- package/scripts/tracker/mr.verdict.mjs +34 -0
- package/scripts/tracker/mr.view.mjs +20 -0
- package/scripts/tracker/none.mjs +79 -0
- package/scripts/tracker/note.mjs +12 -0
- package/scripts/tracker/reopen.mjs +8 -0
- package/scripts/tracker/search.mjs +8 -0
- package/scripts/tracker/search_id.mjs +18 -0
- package/scripts/tracker/templates/bug.md +35 -0
- package/scripts/tracker/templates/feature.md +40 -0
- package/scripts/tracker/templates/lot.md +46 -0
- package/scripts/tracker/title.join.mjs +6 -0
- package/scripts/tracker/title.leave.mjs +7 -0
- package/scripts/tracker/title.lot_of.mjs +8 -0
- package/scripts/tracker/title.search_id_filter.mjs +17 -0
- package/scripts/tracker/title.sentence_of.mjs +5 -0
- package/scripts/tracker/view.mjs +25 -0
- package/scripts/verbs.mjs +579 -0
- package/scripts/verify/BUILD.mjs +33 -0
- package/scripts/verify/DEPS.mjs +15 -0
- package/scripts/verify/GOV.mjs +34 -0
- package/scripts/verify/GUARD.mjs +34 -0
- package/scripts/verify/LINES.mjs +22 -0
- package/scripts/verify/PKG.mjs +56 -0
- package/scripts/verify/TYPE.mjs +34 -0
- package/scripts/verify/cut.mjs +73 -0
- package/scripts/verify/history.mjs +32 -0
- package/scripts/verify/lib.mjs +129 -0
- package/scripts/verify/report.mjs +104 -0
- package/scripts/verify/timing.mjs +19 -0
- package/scripts/verify.mjs +29 -0
- package/scripts/work/branch.mjs +81 -0
- package/scripts/work/close.mjs +74 -0
- package/scripts/work/member.mjs +73 -0
- package/scripts/work/prepare.mjs +116 -0
- package/scripts/work/reopen.mjs +40 -0
- package/scripts/work-feedback/prepare.mjs +96 -0
- package/skills/audit/SKILL.md +110 -0
- package/skills/audit/templates/index.md +36 -0
- package/skills/audit/templates/lens.md +44 -0
- package/skills/brainstorm/SKILL.md +144 -0
- package/skills/brainstorm/references/exit.md +57 -0
- package/skills/brainstorm/templates/note.md +79 -0
- package/skills/commit/SKILL.md +68 -0
- package/skills/mr/SKILL.md +82 -0
- package/skills/new-issue/SKILL.md +87 -0
- package/skills/onboard/SKILL.md +103 -0
- package/skills/onboard/templates/CLAUDE.md +31 -0
- package/skills/onboard/templates/CONTRIBUTING.md +34 -0
- package/skills/onboard/templates/README.md +30 -0
- package/skills/onboard/templates/SKILL.md +24 -0
- package/skills/onboard/templates/package-CLAUDE.md +15 -0
- package/skills/overview/SKILL.md +59 -0
- package/skills/port-toolchain/SKILL.md +127 -0
- package/skills/release/SKILL.md +78 -0
- package/skills/review-mr/SKILL.md +106 -0
- package/skills/review-mr/templates/pass.md +29 -0
- package/skills/roadmap/SKILL.md +137 -0
- package/skills/setup/SKILL.md +112 -0
- package/skills/work/SKILL.md +139 -0
- package/skills/work-feedback/SKILL.md +113 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Holds the form of docs/spec/ and of the notes folded into it, run from the repository's root:
|
|
3
|
+
// `node node_modules/@ersbeth/codelife/scripts/spec-form.mjs spec`, `note <note>` before its writing, or
|
|
4
|
+
// `fold <note> [--base <rev>]` after it; `cases` prints what a plan is cut from and holds nothing.
|
|
5
|
+
// A blocking rule prints `<file>:<line>: <rule>: <what is wrong>` and the exit code is 1; a report
|
|
6
|
+
// prints the same way and changes nothing. No dependency, no build: built-in modules only.
|
|
7
|
+
import './node-floor.mjs'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
import { lf } from './text.mjs'
|
|
12
|
+
|
|
13
|
+
const SPEC = 'docs/spec'
|
|
14
|
+
const VERBS = ['creates', 'changes', 'retires', 'renames']
|
|
15
|
+
const ENTRIES = ['README.md', 'glossary.md', 'journeys', 'principles.md', 'product', 'system', 'users.md']
|
|
16
|
+
const CLOSING = ['What stays open', 'Rejected']
|
|
17
|
+
const LETTER = { persona: 'P', journey: 'J', capability: 'K', rule: 'R', case: 'C' }
|
|
18
|
+
const SECTIONS = {
|
|
19
|
+
capability: ['What it is for, and for whom', 'Behaviour', 'Internal design', ...CLOSING],
|
|
20
|
+
journey: ['Steps', 'Success criteria', 'Out of this journey', ...CLOSING],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let red = false
|
|
24
|
+
const asGiven = new Set() // a note is named by its own path; a document by its path under docs/spec/
|
|
25
|
+
const say = (file, line, rule, what) => console.log(`${asGiven.has(file) ? file : `${SPEC}/${file}`}:${line}: ${rule}: ${what}`)
|
|
26
|
+
const block = (...a) => { red = true; say(...a) }
|
|
27
|
+
|
|
28
|
+
// An address, `<path> § <heading>` in a code span — between double backticks when the heading
|
|
29
|
+
// holds code, as CommonMark writes such a span. `addressed` reads either form from a match whose
|
|
30
|
+
// first group is the path.
|
|
31
|
+
const ADDRESS = '``([^`§]*?\\.md) §\\s+((?:[^`]|`(?!`))+?)``|`([^`§]*?\\.md) §\\s+([^`]+)`'
|
|
32
|
+
const addressed = (m, at = 1) => ({
|
|
33
|
+
file: path.posix.normalize((m[at] || m[at + 2]).trim().replace(/^docs\/spec\//, '')),
|
|
34
|
+
heading: (m[at + 1] || m[at + 3]).replace(/\s+/g, ' ').trim(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
38
|
+
|
|
39
|
+
// Every file under a directory, as paths relative to it; a `.gitkeep` is not a document.
|
|
40
|
+
function walk(dir, under = '') {
|
|
41
|
+
return fs.readdirSync(path.join(dir, under), { withFileTypes: true }).flatMap((e) => {
|
|
42
|
+
const rel = path.posix.join(under, e.name)
|
|
43
|
+
return e.isDirectory() ? walk(dir, rel) : e.name === '.gitkeep' ? [] : [rel]
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A document: its text, its lines, and its headings outside fenced blocks — each with its level,
|
|
48
|
+
// its full text, the identifier that opens it when there is one, and the heading after it.
|
|
49
|
+
const read = (file, dir = SPEC) => parse(file, fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
50
|
+
|
|
51
|
+
function parse(file, crlf) {
|
|
52
|
+
const text = lf(crlf), lines = text.split('\n')
|
|
53
|
+
const headings = []
|
|
54
|
+
let fenced = false
|
|
55
|
+
lines.forEach((l, i) => {
|
|
56
|
+
if (/^\s*```/.test(l)) fenced = !fenced
|
|
57
|
+
const h = !fenced && /^(#{1,6}) +(.+?) *$/.exec(l)
|
|
58
|
+
if (!h) return
|
|
59
|
+
const id = /^([PJKRC]-\d+) — (.+)$/.exec(h[2])
|
|
60
|
+
headings.push({ level: h[1].length, text: h[2], id: id && id[1], title: id ? id[2] : h[2], line: i + 1 })
|
|
61
|
+
})
|
|
62
|
+
return { file, text, lines, headings }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const kind = (file) =>
|
|
66
|
+
/^(product|system)\//.test(file) ? 'capability' : file.startsWith('journeys/') ? 'journey' : 'entry'
|
|
67
|
+
|
|
68
|
+
// The headings of one `##` section, and the line after its last one.
|
|
69
|
+
function section(doc, name) {
|
|
70
|
+
const from = doc.headings.findIndex((h) => h.level === 2 && h.text === name)
|
|
71
|
+
if (from < 0) return { headings: [], start: 0, end: 0 }
|
|
72
|
+
const next = doc.headings.findIndex((h, i) => i > from && h.level <= 2)
|
|
73
|
+
const inside = doc.headings.slice(from + 1, next < 0 ? undefined : next)
|
|
74
|
+
return { headings: inside, start: doc.headings[from].line, end: next < 0 ? doc.lines.length : doc.headings[next].line - 1 }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The list items between two lines, a wrapped item joined into one string.
|
|
78
|
+
function items(doc, start, end) {
|
|
79
|
+
const out = []
|
|
80
|
+
for (let i = start; i < end; i++) {
|
|
81
|
+
const l = doc.lines[i]
|
|
82
|
+
if (/^- /.test(l)) out.push({ text: l.slice(2), line: i + 1 })
|
|
83
|
+
else if (/^\s+\S/.test(l) && out.length) out[out.length - 1].text += ' ' + l.trim()
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A specification is in the form once its seven entries all exist. Until then it is being brought
|
|
89
|
+
// to it — or /setup just gave it its README.md — and nothing blocks, or a migration could not be
|
|
90
|
+
// committed; the commit that completes the seven is the first one held.
|
|
91
|
+
const formed = () => ENTRIES.every((e) => fs.existsSync(path.join(SPEC, e)))
|
|
92
|
+
|
|
93
|
+
// No entry beside the seven.
|
|
94
|
+
function sevenEntries() {
|
|
95
|
+
for (const n of fs.readdirSync(SPEC).filter((n) => !n.startsWith('.'))) {
|
|
96
|
+
if (!ENTRIES.includes(n)) block(n, 1, 'seven-entries', 'not one of the seven entries')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A capability sits at <branch>/<domain>/<capability>.md and a journey at journeys/<journey>.md:
|
|
101
|
+
// the domain level is mandatory and never nested.
|
|
102
|
+
function domainLevel(files) {
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
const depth = f.split('/').length
|
|
105
|
+
if (/^(product|system)\//.test(f) && depth !== 3) block(f, 1, 'domain-level', 'a capability is <branch>/<domain>/<capability>.md')
|
|
106
|
+
if (f.startsWith('journeys/') && depth !== 2) block(f, 1, 'domain-level', 'a journey is journeys/<journey>.md')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The entry index links every document, and links nothing that is not there.
|
|
111
|
+
function indexTree(files, docs) {
|
|
112
|
+
const index = docs.get('README.md')
|
|
113
|
+
if (!index) return
|
|
114
|
+
const linked = new Map()
|
|
115
|
+
index.lines.forEach((l, i) => {
|
|
116
|
+
for (const m of l.matchAll(/\]\(([^)#\s]+\.md)(#[^)]*)?\)/g)) if (!/^[a-z]+:/.test(m[1])) linked.set(path.posix.normalize(m[1]), i + 1)
|
|
117
|
+
})
|
|
118
|
+
for (const f of files) if (f !== 'README.md' && !linked.has(f)) block('README.md', 1, 'index-tree', `${f} is in the tree and not in the index`)
|
|
119
|
+
for (const [f, line] of linked) if (!files.includes(f)) block('README.md', line, 'index-tree', `${f} is in the index and not in the tree`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A capability and a journey have exactly their `##` sections, in order; users.md and
|
|
123
|
+
// principles.md end on the two closing ones.
|
|
124
|
+
function sections(doc) {
|
|
125
|
+
const have = doc.headings.filter((h) => h.level === 2).map((h) => h.text)
|
|
126
|
+
const want = SECTIONS[kind(doc.file)]
|
|
127
|
+
const okay = want
|
|
128
|
+
? have.join('\n') === want.join('\n')
|
|
129
|
+
: !['users.md', 'principles.md'].includes(doc.file) || have.slice(-2).join('\n') === CLOSING.join('\n')
|
|
130
|
+
if (!okay) block(doc.file, 1, 'sections', `expected ${(want || CLOSING).join(' · ')}${want ? '' : ' to close the document'}, found ${have.join(' · ') || 'none'}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function givenWhenThen(doc, h, end) {
|
|
134
|
+
const steps = doc.lines.slice(h.line, end).map((l) => /^(Given|When|Then) /.exec(l)).filter(Boolean).map((m) => m[1])
|
|
135
|
+
if (steps.join(' ') !== 'Given When Then') block(doc.file, h.line, 'given-when-then', `"${h.text}" reads ${steps.join(', ') || 'none of them'}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Behaviour is rules: each `###` has at least one `####` case, and each case reads Given, When, Then
|
|
139
|
+
// in that order.
|
|
140
|
+
function behaviour(doc) {
|
|
141
|
+
const b = section(doc, 'Behaviour')
|
|
142
|
+
b.headings.forEach((h, i) => {
|
|
143
|
+
const next = b.headings[i + 1]
|
|
144
|
+
const end = next ? next.line - 1 : b.end
|
|
145
|
+
if (h.level === 3) {
|
|
146
|
+
if (!next || next.level !== 4) block(doc.file, h.line, 'rule-without-case', `"${h.text}" has no case`)
|
|
147
|
+
} else if (h.level === 4) givenWhenThen(doc, h, end)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A heading is unique in its file, being the address of what carries no identifier.
|
|
152
|
+
function duplicateHeading(doc) {
|
|
153
|
+
const seen = new Set()
|
|
154
|
+
for (const h of doc.headings) {
|
|
155
|
+
if (seen.has(h.text)) block(doc.file, h.line, 'duplicate-heading', `"${h.text}" is written twice`)
|
|
156
|
+
seen.add(h.text)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Every `<path> § <heading>` address resolves, and every `- § <heading> — …` line of a
|
|
161
|
+
// `## Rejected` names a heading of its own file.
|
|
162
|
+
function addresses(doc, docs) {
|
|
163
|
+
const has = heading
|
|
164
|
+
for (const m of doc.text.matchAll(new RegExp(ADDRESS, 'g'))) {
|
|
165
|
+
const { file, heading } = addressed(m)
|
|
166
|
+
const line = doc.text.slice(0, m.index).split('\n').length
|
|
167
|
+
if (!docs.has(file)) block(doc.file, line, 'address', `${file} is not in the tree`)
|
|
168
|
+
else if (!has(docs.get(file), heading)) block(doc.file, line, 'address', `${file} has no heading "${heading}"`)
|
|
169
|
+
}
|
|
170
|
+
const r = section(doc, 'Rejected')
|
|
171
|
+
for (const it of items(doc, r.start, r.end)) {
|
|
172
|
+
if (!it.text.startsWith('§ ')) continue
|
|
173
|
+
const parts = it.text.slice(2).split(' — ')
|
|
174
|
+
const found = parts.some((_, n) => has(doc, parts.slice(0, n + 1).join(' — ')))
|
|
175
|
+
if (!found) block(doc.file, it.line, 'address', `no heading here is "${parts[0]}"`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// What each heading of a document defines: a persona, a journey, a capability, a rule or a case.
|
|
180
|
+
function defined(doc) {
|
|
181
|
+
const k = kind(doc.file)
|
|
182
|
+
const out = []
|
|
183
|
+
if (doc.file === 'users.md') for (const h of doc.headings) if (h.level === 2 && !CLOSING.includes(h.text)) out.push({ h, what: 'persona' })
|
|
184
|
+
if (k !== 'entry' && doc.headings[0] && doc.headings[0].level === 1) out.push({ h: doc.headings[0], what: k })
|
|
185
|
+
if (k === 'capability') for (const h of section(doc, 'Behaviour').headings) {
|
|
186
|
+
if (h.level === 3) out.push({ h, what: 'rule' })
|
|
187
|
+
if (h.level === 4) out.push({ h, what: 'case' })
|
|
188
|
+
}
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Where each identifier is defined; `report` hears of a heading that carries none of its letter,
|
|
193
|
+
// or one already taken, and of a rule or a case outside a capability's Behaviour, which nothing
|
|
194
|
+
// reads.
|
|
195
|
+
function definitions(docs, report = () => {}) {
|
|
196
|
+
const at = new Map()
|
|
197
|
+
for (const doc of docs.values()) {
|
|
198
|
+
const own = defined(doc)
|
|
199
|
+
for (const h of doc.headings) if (/^[RC]-/.test(h.id) && !own.some((d) => d.h === h)) report(doc.file, h.line, 'outside-behaviour', `${h.id} opens a heading outside Behaviour, where no rule or case is read`)
|
|
200
|
+
for (const { h, what } of own) {
|
|
201
|
+
if (!h.id || h.id[0] !== LETTER[what]) { report(doc.file, h.line, 'identifier', `a ${what} opens on ${LETTER[what]}-<number> — <heading>`); continue }
|
|
202
|
+
if (at.has(h.id)) report(doc.file, h.line, 'identifier', `${h.id} is also ${SPEC}/${at.get(h.id).file}:${at.get(h.id).line}`)
|
|
203
|
+
else at.set(h.id, { file: doc.file, line: h.line, title: h.title })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return at
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Everything that is cited opens its heading on an identifier of its letter, unique under
|
|
210
|
+
// docs/spec/. Every identifier cited has such a heading, except one written `retired <id>` that
|
|
211
|
+
// stands in a line of a `## Rejected` — a retired one bare, or `retired` beside a living one, is
|
|
212
|
+
// refused — and where ` — ` follows a cited identifier, its real heading follows, except in a
|
|
213
|
+
// `## Rejected`, whose lines open on the identifier alone.
|
|
214
|
+
function identifiers(docs) {
|
|
215
|
+
const at = definitions(docs, block)
|
|
216
|
+
const gone = new Set()
|
|
217
|
+
const ranges = new Map([...docs.values()].map((d) => [d, section(d, 'Rejected')]))
|
|
218
|
+
for (const [doc, r] of ranges) for (const it of items(doc, r.start, r.end)) { const m = it.text.match(/^([PJKRC]-\d+) — /); if (m) gone.add(m[1]) }
|
|
219
|
+
for (const doc of docs.values()) {
|
|
220
|
+
const r = ranges.get(doc)
|
|
221
|
+
doc.lines.forEach((l, i) => {
|
|
222
|
+
if (/^#{1,6} /.test(l)) return
|
|
223
|
+
const rejected = i + 1 > r.start && i < r.end
|
|
224
|
+
for (const m of l.matchAll(/\b[PJKRC]-\d+\b/g)) {
|
|
225
|
+
const real = at.get(m[0])
|
|
226
|
+
const retired = gone.has(m[0])
|
|
227
|
+
const said = /\bretired\s+$/i.test(`${doc.lines[i - 1] ?? ''} ${l.slice(0, m.index)}`)
|
|
228
|
+
if (!rejected && (real ? said : !(said && retired))) block(doc.file, i + 1, 'identifier', real ? `${m[0]} is not retired, and "retired" stands beside it` : retired ? `${m[0]} is retired: write "retired ${m[0]}", or cite what replaced it` : `${m[0]} resolves to nothing`)
|
|
229
|
+
const after = (l.slice(m.index + m[0].length) + ' ' + doc.lines.slice(i + 1, i + 3).join(' ')).replace(/\s+/g, ' ')
|
|
230
|
+
if (!real || rejected || !after.startsWith(' — ')) continue
|
|
231
|
+
const beside = after.slice(3)
|
|
232
|
+
if (!beside.startsWith(real.title) || /^\w/.test(beside.slice(real.title.length))) block(doc.file, i + 1, 'identifier', `${m[0]} is "${real.title}", which is not what is written beside it`)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A journey without a persona, and a product capability no journey walks unless its *What it is for*
|
|
239
|
+
// says so on a `**No journey.**` line, block (#1035). A system capability that no product
|
|
240
|
+
// capability, no other system capability and no principle cites is reported only, since a note may
|
|
241
|
+
// rightly add it before the product one that uses it.
|
|
242
|
+
function orphans(docs) {
|
|
243
|
+
const all = [...docs.values()]
|
|
244
|
+
const cited = (from, id) => from.some((d) => new RegExp(`\\b${id}\\b`).test(d.text))
|
|
245
|
+
const journeys = all.filter((d) => kind(d.file) === 'journey')
|
|
246
|
+
const product = all.filter((d) => d.file.startsWith('product/'))
|
|
247
|
+
for (const j of journeys) {
|
|
248
|
+
if (!/^\*\*Persona\.\*\*.*\bP-\d+\b/m.test(j.text)) block(j.file, 1, 'orphan', 'a journey without a persona')
|
|
249
|
+
}
|
|
250
|
+
for (const d of product) {
|
|
251
|
+
const cases = d.headings.filter((h) => h.level === 4 && h.id)
|
|
252
|
+
if (!cases.some((c) => cited(journeys, c.id)) && !/^\*\*No journey\.\*\* \S/m.test(d.text)) block(d.file, 1, 'orphan', 'a product capability no journey walks, and no `**No journey.**` line saying why')
|
|
253
|
+
}
|
|
254
|
+
for (const d of all.filter((x) => x.file.startsWith('system/'))) {
|
|
255
|
+
const id = d.headings[0] && d.headings[0].id
|
|
256
|
+
const users = all.filter((x) => x !== d && (kind(x.file) === 'capability' || x.file === 'principles.md'))
|
|
257
|
+
if (!id || !cited(users, id)) say(d.file, 1, 'orphan', 'a system capability nothing uses')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tree = () => new Map(walk(SPEC).filter((f) => f.endsWith('.md')).map((f) => [f, read(f)]))
|
|
262
|
+
const heading = (doc, text) => doc.headings.some((h) => h.text === text || h.title === text)
|
|
263
|
+
|
|
264
|
+
function spec() {
|
|
265
|
+
if (!formed()) return console.log(`${SPEC}/ is not in the form — nothing is held`)
|
|
266
|
+
sevenEntries()
|
|
267
|
+
const docs = tree()
|
|
268
|
+
const files = [...docs.keys()]
|
|
269
|
+
domainLevel(files)
|
|
270
|
+
indexTree(files, docs)
|
|
271
|
+
for (const doc of docs.values()) {
|
|
272
|
+
sections(doc)
|
|
273
|
+
if (kind(doc.file) === 'capability') behaviour(doc)
|
|
274
|
+
duplicateHeading(doc)
|
|
275
|
+
addresses(doc, docs)
|
|
276
|
+
}
|
|
277
|
+
identifiers(docs)
|
|
278
|
+
orphans(docs)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The lines from a `**<Name>.**` marker to the next marker or heading, inside [from, to).
|
|
282
|
+
function part(doc, name, from, to) {
|
|
283
|
+
const start = doc.lines.findIndex((l, i) => i >= from && i < to && l.startsWith(`**${name}.**`))
|
|
284
|
+
if (start < 0) return null
|
|
285
|
+
let end = start + 1
|
|
286
|
+
while (end < to && !/^(\*\*[A-Z][a-z ]*\.\*\*|#{1,6} )/.test(doc.lines[end])) end++
|
|
287
|
+
return { start, end }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// One address of a **Target.**: what is changed, retired or renamed exists today, by identifier or
|
|
291
|
+
// by `<path> § <heading>`; what is created does not.
|
|
292
|
+
function target(doc, it, docs, at) {
|
|
293
|
+
const m = new RegExp(`^(${VERBS.join('|')}) (.+)$`).exec(it.text)
|
|
294
|
+
if (!m) return block(doc.file, it.line, 'note-target', `opens on none of ${VERBS.join(', ')}`)
|
|
295
|
+
if (!docs) return m
|
|
296
|
+
const address = m[2].split(' → ')[0]
|
|
297
|
+
const id = /^([PJKRC]-\d+) — (.+)$/.exec(address)
|
|
298
|
+
const by = new RegExp(`^(?:${ADDRESS})$`).exec(address)
|
|
299
|
+
const { file, heading: title } = by ? addressed(by) : {}
|
|
300
|
+
const exists = id ? at.has(id[1]) && id[2] === at.get(id[1]).title : by && docs.has(file) && heading(docs.get(file), title)
|
|
301
|
+
if (!id && !by) block(doc.file, it.line, 'note-target', 'names neither an identifier with its heading nor a `<path> § <heading>`')
|
|
302
|
+
else if (m[1] === 'creates' && exists) block(doc.file, it.line, 'note-target', 'creates what already exists')
|
|
303
|
+
else if (m[1] !== 'creates' && !exists) block(doc.file, it.line, 'note-target', `${m[1]} what does not exist today`)
|
|
304
|
+
return m
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A note's own shape, read against the specification before its writing: every decision has its
|
|
308
|
+
// Target, its Decision and its Rejected with an `<option> — <cost>` line; every target resolves; a
|
|
309
|
+
// decision on a rule, or with **Cases.**, has a Given / When / Then case, the case headings unique,
|
|
310
|
+
// and one with cases names exactly one rule to write them under; a note creates a heading once;
|
|
311
|
+
// every term its definition.
|
|
312
|
+
function shape(doc, docs, at) {
|
|
313
|
+
const terms = part(doc, 'Terms', 0, doc.lines.length)
|
|
314
|
+
for (const it of terms ? items(doc, terms.start, terms.end) : []) if (!/^\*\*[^*]+\*\* — \S/.test(it.text)) block(doc.file, it.line, 'note-terms', 'a term with no definition')
|
|
315
|
+
const d = section(doc, 'Decisions')
|
|
316
|
+
const decisions = d.headings.filter((h) => h.level === 3)
|
|
317
|
+
const seen = new Set()
|
|
318
|
+
const created = new Map()
|
|
319
|
+
decisions.forEach((h, n) => {
|
|
320
|
+
const end = decisions[n + 1] ? decisions[n + 1].line - 1 : d.end
|
|
321
|
+
const [t, dec, rej] = ['Target', 'Decision', 'Rejected'].map((name) => part(doc, name, h.line, end))
|
|
322
|
+
for (const [name, p] of [['Target', t], ['Decision', dec], ['Rejected', rej]]) if (!p) block(doc.file, h.line, 'note-decision', `"${h.text}" has no ${name}`)
|
|
323
|
+
if (rej && !items(doc, rej.start, rej.end).some((it) => / — \S/.test(it.text))) block(doc.file, rej.start + 1, 'note-decision', 'Rejected holds no `<option> — <cost>` line')
|
|
324
|
+
const targets = t ? items(doc, t.start, t.end).map((it) => target(doc, it, docs, at)).filter(Boolean) : []
|
|
325
|
+
if (t && !targets.length) block(doc.file, t.start + 1, 'note-target', 'Target names no address')
|
|
326
|
+
const cases = d.headings.filter((c) => c.level === 4 && c.line > h.line && c.line <= end)
|
|
327
|
+
if (!cases.length && (part(doc, 'Cases', h.line, end) || targets.some((m) => /^R-\d+ — /.test(m[2])))) block(doc.file, h.line, 'note-cases', `"${h.text}" changes a behaviour and carries no case`)
|
|
328
|
+
const rules = new Set(targets.filter((m) => m[1] === 'creates' || (m[1] !== 'retires' && /^R-\d+ — /.test(m[2])))
|
|
329
|
+
.map((m) => (/^R-\d+(?= — )/.exec(m[2]) || [m[2]])[0]))
|
|
330
|
+
if (cases.length && rules.size !== 1) block(doc.file, h.line, 'note-cases', `"${h.text}" carries cases and names ${rules.size || 'no'} rule${rules.size === 1 ? '' : 's'} to write them under`)
|
|
331
|
+
for (const m of targets.filter((m) => m[1] === 'creates')) {
|
|
332
|
+
if (created.has(m[2])) block(doc.file, h.line, 'note-target', `"${h.text}" creates ${m[2]}, which "${created.get(m[2])}" already creates`)
|
|
333
|
+
else created.set(m[2], h.text)
|
|
334
|
+
}
|
|
335
|
+
cases.forEach((c, i) => {
|
|
336
|
+
givenWhenThen(doc, c, cases[i + 1] ? cases[i + 1].line - 1 : end)
|
|
337
|
+
if (seen.has(c.text)) block(doc.file, c.line, 'note-cases', `"${c.text}" is written twice`)
|
|
338
|
+
seen.add(c.text)
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// docs/spec/ as a revision holds it, read relative to where the script runs.
|
|
344
|
+
function committed(base) {
|
|
345
|
+
const files = git('ls-tree', '-r', '-z', '--name-only', base, '--', SPEC).split('\0').filter((f) => f.endsWith('.md'))
|
|
346
|
+
return new Map(files.map((f) => [f.slice(SPEC.length + 1), parse(f.slice(SPEC.length + 1), git('show', `${base}:./${f}`))]))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// With the note still on disk, its shape holds against the base, and the fold lost nothing: every
|
|
350
|
+
// case heading of the note is under docs/spec/, the identifier the fold assigned set aside, and
|
|
351
|
+
// every refusal line in a `## Rejected`
|
|
352
|
+
// as written — of a former state, which the fold rewrites "in force <from> → <to>", the option;
|
|
353
|
+
// no heading of the base revision is gone or reworded unless the note retires or renames it; and an
|
|
354
|
+
// identifier gone stands in a `- <id> — …` line of a `## Rejected`.
|
|
355
|
+
function fold(args) {
|
|
356
|
+
const base = args.includes('--base') ? args[args.indexOf('--base') + 1] : 'HEAD'
|
|
357
|
+
if (!args[0] || args[0].startsWith('--')) { console.error('fold: which note?'); process.exit(2) }
|
|
358
|
+
asGiven.add(args[0])
|
|
359
|
+
const doc = read(args[0], '')
|
|
360
|
+
if (!formed()) return block(args[0], 1, 'fold', `${SPEC}/ is not in the form — nothing is folded there`)
|
|
361
|
+
const after = [...tree().values()]
|
|
362
|
+
const now = definitions(new Map(after.map((d) => [d.file, d])))
|
|
363
|
+
const squeeze = (s) => s.replace(/\s+/g, ' ').trim()
|
|
364
|
+
const rejected = after.flatMap((d) => { const r = section(d, 'Rejected'); return items(d, r.start, r.end).map((it) => squeeze(it.text)) })
|
|
365
|
+
const d = section(doc, 'Decisions')
|
|
366
|
+
const released = new Set()
|
|
367
|
+
for (const c of d.headings.filter((h) => h.level === 4)) {
|
|
368
|
+
if (!after.some((a) => a.headings.some((h) => h.level === 4 && h.title === c.text))) block(args[0], c.line, 'fold-case', `"${c.text}" is not under ${SPEC}/`)
|
|
369
|
+
}
|
|
370
|
+
d.headings.filter((h) => h.level === 3).forEach((h, n, all) => {
|
|
371
|
+
const end = all[n + 1] ? all[n + 1].line - 1 : d.end
|
|
372
|
+
const [t, r] = ['Target', 'Rejected'].map((name) => part(doc, name, h.line, end))
|
|
373
|
+
for (const it of t ? items(doc, t.start, t.end) : []) {
|
|
374
|
+
const m = new RegExp(`^(retires|renames) (?:([PJKRC]-\\d+) — |${ADDRESS})`).exec(it.text)
|
|
375
|
+
const by = m && !m[2] && addressed(m, 3)
|
|
376
|
+
if (m) released.add(m[2] || `${by.file} § ${by.heading}`)
|
|
377
|
+
}
|
|
378
|
+
for (const it of r ? items(doc, r.start, r.end) : []) {
|
|
379
|
+
const line = squeeze(it.text)
|
|
380
|
+
const needle = / — in force since /.test(line) ? line.split(' — ')[0] : line
|
|
381
|
+
if (!rejected.some((x) => x.includes(needle))) block(args[0], it.line, 'fold-refusal', `"${needle}" is in no Rejected under ${SPEC}/`)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
const then = committed(base)
|
|
385
|
+
const before = definitions(then)
|
|
386
|
+
shape(doc, then, before)
|
|
387
|
+
for (const [id, was] of before) {
|
|
388
|
+
const is = now.get(id)
|
|
389
|
+
if (!is && !rejected.some((x) => x.startsWith(`${id} — `))) block(was.file, was.line, 'fold-identifier', `${id} is gone and stands in no line of Rejected`)
|
|
390
|
+
if ((!is || is.title !== was.title) && !released.has(id)) block(was.file, was.line, 'fold-heading', `"${id} — ${was.title}" is gone, and the note neither retires nor renames it`)
|
|
391
|
+
}
|
|
392
|
+
for (const was of then.values()) {
|
|
393
|
+
const is = after.find((a) => a.file === was.file)
|
|
394
|
+
for (const h of is ? was.headings.filter((x) => !x.id) : []) {
|
|
395
|
+
if (!heading(is, h.text) && !released.has(`${was.file} § ${h.text}`)) block(was.file, h.line, 'fold-heading', `"${h.text}" is gone, and the note neither retires nor renames it`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Before the writing, a note's own shape against docs/spec/ as it stands: what `fold` holds it to
|
|
401
|
+
// after, read while its author can still answer.
|
|
402
|
+
function note(args) {
|
|
403
|
+
if (!args[0]) { console.error('note: which note?'); process.exit(2) }
|
|
404
|
+
asGiven.add(args[0])
|
|
405
|
+
const doc = read(args[0], '')
|
|
406
|
+
if (!formed()) return block(args[0], 1, 'note', `${SPEC}/ is not in the form — no note is written there`)
|
|
407
|
+
const docs = tree()
|
|
408
|
+
shape(doc, docs, definitions(docs))
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// What a plan is cut from, one line per fact, its kind first and tab-separated: every rule and
|
|
412
|
+
// case with its capability, a case with its rule; the cases each journey's steps cite; and the
|
|
413
|
+
// index of every rule and case heading, the one place a heading is written. Blocks nothing,
|
|
414
|
+
// writes nothing.
|
|
415
|
+
function cases() {
|
|
416
|
+
if (!formed()) return console.log(`${SPEC}/ is not in the form — nothing is held`)
|
|
417
|
+
const docs = [...tree().values()].sort((a, b) => (a.file < b.file ? -1 : 1))
|
|
418
|
+
const out = { facts: [], journey: [], index: [] }
|
|
419
|
+
const cited = (doc) => {
|
|
420
|
+
const s = section(doc, 'Steps')
|
|
421
|
+
return [...new Set(doc.lines.slice(s.start, s.end).join('\n').match(/\bC-\d+\b/g))]
|
|
422
|
+
}
|
|
423
|
+
for (const doc of docs) {
|
|
424
|
+
const own = doc.headings[0] && doc.headings[0].id
|
|
425
|
+
if (kind(doc.file) === 'journey') out.journey.push(['journey', own, cited(doc).join(' ')])
|
|
426
|
+
if (kind(doc.file) !== 'capability') continue
|
|
427
|
+
let rule
|
|
428
|
+
for (const { h, what } of defined(doc).filter((d) => d.h.id && (d.what === 'rule' || d.what === 'case'))) {
|
|
429
|
+
if (what === 'rule') rule = h.id
|
|
430
|
+
out.facts.push(what === 'rule' ? ['rule', h.id, own] : ['case', h.id, own, rule])
|
|
431
|
+
out.index.push(['index', `${h.id} — ${h.title}`])
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const l of Object.values(out).flat()) console.log(l.filter(Boolean).join('\t'))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const modes = { spec, note, fold, cases }
|
|
438
|
+
const mode = modes[process.argv[2]]
|
|
439
|
+
if (!mode) {
|
|
440
|
+
console.error(`usage: node ${path.basename(process.argv[1])} ${Object.keys(modes).join(' | ')}`)
|
|
441
|
+
process.exit(2)
|
|
442
|
+
}
|
|
443
|
+
mode(process.argv.slice(3))
|
|
444
|
+
process.exitCode = red ? 1 : 0
|
package/scripts/spec.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `codelife spec <identifier>` (K-43, § The specification is read by identifier): one entry of
|
|
3
|
+
// docs/spec/ by its identifier, never a document whole. Run from the repository's root.
|
|
4
|
+
// A heading below the title — a rule, a case, a persona — prints its text down to the next heading
|
|
5
|
+
// of its level or above, a rule's cases included; a title — a capability, a journey — prints the
|
|
6
|
+
// outline of its headings. Then the lines of `## Rejected` opening on the identifier; a retired one
|
|
7
|
+
// prints those alone. Each part opens on `<file>:<line>`. Exit 1 when the identifier resolves to
|
|
8
|
+
// nothing, 2 on a malformed one or no docs/spec/.
|
|
9
|
+
import './node-floor.mjs'
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { lf } from './text.mjs'
|
|
13
|
+
|
|
14
|
+
const SPEC = 'docs/spec'
|
|
15
|
+
const id = process.argv[2] ?? ''
|
|
16
|
+
if (!/^[PJKRC]-\d+$/.test(id)) {
|
|
17
|
+
console.error('usage: codelife spec <identifier>, as R-12 or C-340')
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
20
|
+
if (!existsSync(SPEC)) {
|
|
21
|
+
console.error(`codelife spec: no ${SPEC}/ here — run it from the repository's root`)
|
|
22
|
+
process.exit(2)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = (d) => readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
|
26
|
+
.flatMap((e) => (e.isDirectory() ? files(join(d, e.name)) : e.name.endsWith('.md') ? [join(d, e.name)] : []))
|
|
27
|
+
// levels <lines> — each line's heading level, 0 for a line that is none: a `#` line inside a fenced
|
|
28
|
+
// block is the block's text.
|
|
29
|
+
const levels = (lines) => {
|
|
30
|
+
let fenced = false
|
|
31
|
+
return lines.map((l) => (/^\s*```/.test(l) ? ((fenced = !fenced), 0) : fenced ? 0 : (l.match(/^(#{1,6}) /) ?? [, ''])[1].length))
|
|
32
|
+
}
|
|
33
|
+
const out = []
|
|
34
|
+
for (const f of files(SPEC)) {
|
|
35
|
+
const lines = lf(readFileSync(f, 'utf8')).split('\n'), heads = levels(lines), level = (_, i) => heads[i]
|
|
36
|
+
const at = lines.findIndex((l, i) => level(l, i) && l.slice(level(l, i) + 1).startsWith(`${id} — `))
|
|
37
|
+
if (at >= 0) {
|
|
38
|
+
const own = level(lines[at], at)
|
|
39
|
+
const end = lines.findIndex((l, i) => i > at && level(l, i) && level(l, i) <= own)
|
|
40
|
+
const body = own === 1
|
|
41
|
+
? lines.filter((l, i) => i >= at && level(l, i) > 1 && level(l, i) <= 4)
|
|
42
|
+
: lines.slice(at + 1, end < 0 ? undefined : end)
|
|
43
|
+
out.push(`${f}:${at + 1}`, lines[at], ...body)
|
|
44
|
+
while (out.at(-1) === '') out.pop()
|
|
45
|
+
}
|
|
46
|
+
let rejected = false
|
|
47
|
+
lines.forEach((l, i) => {
|
|
48
|
+
if (level(l, i)) rejected = l === '## Rejected'
|
|
49
|
+
if (rejected && l.startsWith(`- ${id} — `)) out.push(`${f}:${i + 1}: ${l}`)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
if (!out.length) {
|
|
53
|
+
console.error(`codelife spec: ${id} resolves to nothing under ${SPEC}/`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
console.log(out.join('\n'))
|
package/scripts/tags.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A release's tag, the one place its spelling is written (R-88): `codelife release cut` writes it,
|
|
2
|
+
// scripts/changelog.mjs and `codelife overview` read whether a version is tagged, and
|
|
3
|
+
// `codelife release prep` finds a line's last tag. Built-in modules only: changelog.mjs runs in a
|
|
4
|
+
// hook of the repository it was written into.
|
|
5
|
+
import './node-floor.mjs'
|
|
6
|
+
|
|
7
|
+
// The tag a release writes: an annotated `v<version>`, in every repository.
|
|
8
|
+
export const tagFor = (version) => `v${version}`
|
|
9
|
+
|
|
10
|
+
// The tags that say <version> is released: `v<version>`, or `<name>--v<version>` for a repository
|
|
11
|
+
// tagged that way before every tag was `v` (codelife up to 0.10.0), its name read without an npm
|
|
12
|
+
// scope; another name's tag at that version says nothing.
|
|
13
|
+
export const spellings = (version, name = '') => [tagFor(version), ...(name ? [`${name.split('/').pop()}--v${version}`] : [])]
|
|
14
|
+
|
|
15
|
+
// The patterns `git describe --match` takes for a line's last tag: both spellings above, and the
|
|
16
|
+
// `r<version>` tags older lines spelled (R-242), read as history and never written.
|
|
17
|
+
export const PATTERNS = ['v[0-9]*', '*--v[0-9]*', 'r[0-9]*']
|
|
18
|
+
|
|
19
|
+
// The version a release tag names, whichever spelling it has.
|
|
20
|
+
export const versionOf = (tag) => tag.replace(/^.*--v/, '').replace(/^[vr]/, '')
|
|
21
|
+
|
|
22
|
+
// semver's order, a prerelease before its release: 1.0.0-rc.1 < 1.0.0 (#1068).
|
|
23
|
+
const parse = (v) => { const [core, pre] = v.split(/-(.*)/); return { n: core.split('.').map(Number), pre } }
|
|
24
|
+
export const compare = (a, b) => {
|
|
25
|
+
const [x, y] = [parse(a), parse(b)]
|
|
26
|
+
return x.n[0] - y.n[0] || x.n[1] - y.n[1] || x.n[2] - y.n[2] || (x.pre === undefined) - (y.pre === undefined) || (x.pre ?? '').localeCompare(y.pre ?? '', 'en', { numeric: true })
|
|
27
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# A conforming application — on top of the base
|
|
2
|
+
|
|
3
|
+
Everything in `target-base.md` applies here in full; this file adds the toolchain, and nothing it
|
|
4
|
+
says replaces a base row.
|
|
5
|
+
|
|
6
|
+
An application is a repository nothing installs. That is the only thing distinguishing it from a
|
|
7
|
+
library here, and it decides every row below: the build is a bundle rather than a `pack`, the
|
|
8
|
+
output is not versioned because no consumer resolves it, and the dev server is the application
|
|
9
|
+
itself rather than a page written to exercise something else.
|
|
10
|
+
|
|
11
|
+
**This file describes a toolchain, never a stack.**
|
|
12
|
+
The framework, the router, the CSS approach, the i18n and the deployment target are decided at each
|
|
13
|
+
application's first port and recorded in that repository's `CLAUDE.md`. A choice three applications
|
|
14
|
+
have made the same way may move here; until then, a row naming one would make CONF report a drift
|
|
15
|
+
no skill remedies.
|
|
16
|
+
|
|
17
|
+
## Package manager
|
|
18
|
+
|
|
19
|
+
pnpm, pinned in `packageManager`, with `minimumReleaseAge` set explicitly in `pnpm-workspace.yaml`,
|
|
20
|
+
on the library's rule for both: an application with in-house dependencies
|
|
21
|
+
is a workspace — `pnpm-workspace.yaml` with `packages: [submodules/*]`, each in-house library a
|
|
22
|
+
**root** submodule initialised non-recursively, and `pnpm.overrides` sending every in-house package
|
|
23
|
+
to `workspace:*` for as long as those libraries are not published.
|
|
24
|
+
|
|
25
|
+
## Toolchain
|
|
26
|
+
|
|
27
|
+
Vite+, invoked as `vp`: the `vite-plus` devDependency, installed with the repository's other
|
|
28
|
+
dependencies and run through its scripts — not a machine binary. One `vite.config.ts` at the root,
|
|
29
|
+
carrying the plugin of the framework the application uses — the one row of this file whose content
|
|
30
|
+
the repository supplies, which is why the package's vite preset serves a library and
|
|
31
|
+
none serves an application: a template naming a framework would be the stack this file refuses to name.
|
|
32
|
+
|
|
33
|
+
The target commits to the **configuration surface** and the `vp` scripts, not to Vite+ itself,
|
|
34
|
+
which is beta: if it stalls, the same configuration runs under Vite, Vitest, Oxlint and Oxfmt
|
|
35
|
+
installed separately, which changes this file and not the target.
|
|
36
|
+
|
|
37
|
+
## Build
|
|
38
|
+
|
|
39
|
+
`vp build`, Rolldown behind it, and `vp preview` to serve what it produced. The output is **not
|
|
40
|
+
versioned** — nothing installs an application from the forge, so a build in the history would be a
|
|
41
|
+
generated artefact no consumer ever reads, going stale in every branch.
|
|
42
|
+
|
|
43
|
+
Correspondingly, the contract of a conforming application declares **no `generated`** entry. That
|
|
44
|
+
is a value, not an omission: `/audit`'s derived-file lens reads it and reports nothing rather than
|
|
45
|
+
reporting an unknown.
|
|
46
|
+
|
|
47
|
+
## Types, lint, format
|
|
48
|
+
|
|
49
|
+
The library's rules, from the same source: the package's `presets/tsconfig.json`, which the
|
|
50
|
+
`tsconfig.json` template extends for both kinds, and Oxlint and Oxfmt through
|
|
51
|
+
`vp check`. They are stated here by reference and not restated, so that one flag never has two
|
|
52
|
+
descriptions to be at odds.
|
|
53
|
+
|
|
54
|
+
## Tests
|
|
55
|
+
|
|
56
|
+
Vitest through `vp test` for the pure modules — computations, state, anything that runs without a
|
|
57
|
+
document — with `test/` mirroring `src/` as it does in a library.
|
|
58
|
+
|
|
59
|
+
**Browser tests are set up by default**, Vitest browser mode on Playwright Chromium, and the
|
|
60
|
+
start-up smoke written when the repository was ported is their first case: the application mounts
|
|
61
|
+
without a console error. An application's interesting failures are in the browser, so a repository
|
|
62
|
+
where a browser test is an afternoon's setup away is one where nobody writes the first one.
|
|
63
|
+
|
|
64
|
+
The characterisation tests of the port — that smoke, and the pure modules captured by snapshot —
|
|
65
|
+
are kept as the **floor**: they describe what the application already did, so they are extended,
|
|
66
|
+
never weakened to let a change pass.
|
|
67
|
+
|
|
68
|
+
## Dev mode
|
|
69
|
+
|
|
70
|
+
`vp dev`, serving the application itself. There is no `playground/`: a library needs a page written
|
|
71
|
+
to exercise it, and an application is one.
|
|
72
|
+
|
|
73
|
+
## Hooks
|
|
74
|
+
|
|
75
|
+
The base's three, unchanged, and that is the whole row: an application versions no build, so its
|
|
76
|
+
`pre-commit` builds nothing.
|
|
77
|
+
|
|
78
|
+
## MCP server
|
|
79
|
+
|
|
80
|
+
A conforming application carries **no** `.mcp.json`: the browser server serves only the agent, so
|
|
81
|
+
the plugin declares it, in its manifest, pinned to an exact version, and it asks for the approval a
|
|
82
|
+
project's servers get when the folder is trusted. The demo credentials the server substitutes sit in
|
|
83
|
+
`.claude/codelife.secrets.env`, git-ignored, the path the plugin fixes.
|
|
84
|
+
|
|
85
|
+
`.claude/settings.local.json` is **not** part of the target: it is not versioned and it is the
|
|
86
|
+
user's, so per-tool permissions stay theirs to grant.
|
|
87
|
+
|
|
88
|
+
## Application facts in the contract
|
|
89
|
+
|
|
90
|
+
A conforming application's contract carries an `app:` block — the facts about *this* application
|
|
91
|
+
that the plugin cannot decide and that differ in every repository:
|
|
92
|
+
|
|
93
|
+
```markdown
|
|
94
|
+
app:
|
|
95
|
+
url: <where the running application is reached, port included>
|
|
96
|
+
start: <the command that starts it, and the directory it runs in when that is not the root>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
One block rather than two top-level fields: it is present exactly when `kind: app`, which is one
|
|
100
|
+
validation rule instead of two, and the two facts are meaningless apart — a URL nobody starts starts
|
|
101
|
+
nothing. The demo credentials are no fact of the contract: they sit in `.claude/codelife.secrets.env`,
|
|
102
|
+
the path the plugin's browser server reads.
|
|
103
|
+
|
|
104
|
+
The block is written once by `/setup` and read by whatever drives the running application.
|
|
105
|
+
|
|
106
|
+
## Per application, at the first port
|
|
107
|
+
|
|
108
|
+
The framework, the router, the CSS approach, the i18n and the deployment target — decided in that
|
|
109
|
+
repository and recorded in its `CLAUDE.md`, not here. `codelife port prep` does not measure them, and
|
|
110
|
+
an application answering each of them differently is conforming.
|
|
111
|
+
|
|
112
|
+
## Standard scripts
|
|
113
|
+
|
|
114
|
+
The base's five names, with an application's commands, verbatim — `test:one` takes the file as its
|
|
115
|
+
argument, and `verify:release` is the push tier itself, which the `pre-push` hook and `/release` run
|
|
116
|
+
alone on a version tag:
|
|
117
|
+
|
|
118
|
+
| Script | Runs |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `verify:commit` | `vp check && vp test` |
|
|
121
|
+
| `verify:push` | `vp check && vp test && vp test --browser && vp build` |
|
|
122
|
+
| `verify:release` | `pnpm verify:push` |
|
|
123
|
+
| `test:all` | `vp test` |
|
|
124
|
+
| `test:one` | `vp test` |
|
|
125
|
+
|
|
126
|
+
**Five names, not six.** The browser suite is a command inside the push tier and never a sixth
|
|
127
|
+
standard script: the base fixes those five for every kind, so a sixth would be a base change
|
|
128
|
+
reaching `none` and `lib` too, for a suite neither of them has. A repository keeping a
|
|
129
|
+
`test:browser` of its own for convenience is neither required to nor forbidden from doing so — it
|
|
130
|
+
is simply not what a skill invokes.
|
|
131
|
+
|
|
132
|
+
`--browser` and `build` are in the push tier and not the commit tier, on the library's reasoning:
|
|
133
|
+
the commit tier runs on every commit, and a browser run plus a bundle there costs more than it
|
|
134
|
+
catches, while a push is what makes the result someone else's problem.
|