usecontextual 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +21 -2
- package/bin/usecontextual.js +8 -0
- package/package.json +25 -4
- package/src/check.js +371 -0
- package/src/cli.js +116 -0
- package/src/context.js +177 -0
- package/src/files.js +48 -0
- package/src/glob.js +46 -0
- package/src/which.js +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikael Endale
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# usecontextual
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Contextual CLI: everything a script can do, with no AI. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
> **Warning:** The package is `usecontextual`. `npx contextual` runs a different, unrelated package.
|
|
6
|
+
|
|
7
|
+
Contextual isn't released yet. Two commands work in this version:
|
|
8
|
+
|
|
9
|
+
| Command | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `usecontextual check [--branch <name>]` | Checks the context against the spec (section 6.1). Exit code 1 when there are errors |
|
|
12
|
+
| `usecontextual which <path>` | Shows who owns a file: a feature, the `shared:` line, or no one (exit code 1) |
|
|
13
|
+
|
|
14
|
+
`init`, `update`, `check --fix` and `check --staged` print "not released yet" and exit. The design is in sections 6 and 7 of [the spec](https://github.com/mikaelendale/contextual/blob/main/docs/spec.md).
|
|
15
|
+
|
|
16
|
+
## Tests
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The tests use Node's built-in runner (Node 20 or newer). The main one is that `check` passes on [`example/`](https://github.com/mikaelendale/contextual/tree/main/example) with 0 errors. The rest break copies of the example and confirm `check` catches each break, test `which`, and check that the example still matches the templates.
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usecontextual",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The Contextual CLI: adds, checks and updates the plain-markdown context that coding agents read.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"usecontextual": "bin/usecontextual.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test \"test/*.test.js\""
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Mikael Endale",
|
|
21
|
+
"homepage": "https://contextual.mikaelen.site",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/mikaelendale/contextual.git",
|
|
25
|
+
"directory": "packages/cli"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `usecontextual check`: validates a project's context against the spec
|
|
3
|
+
* (docs/spec.md, section 6.1). It only reads; it never changes a file.
|
|
4
|
+
* Not built yet: --fix and --staged (spec 6.2).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
8
|
+
import { dirname, join, relative } from "node:path"
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
BLOCK_END,
|
|
12
|
+
BLOCK_START,
|
|
13
|
+
FEATURE_FILES,
|
|
14
|
+
REQUIRED_FIELDS,
|
|
15
|
+
STATUSES,
|
|
16
|
+
buildIndex,
|
|
17
|
+
contextualBlock,
|
|
18
|
+
hashText,
|
|
19
|
+
lineCount,
|
|
20
|
+
phaseIsDone,
|
|
21
|
+
readFeatures,
|
|
22
|
+
readPlan,
|
|
23
|
+
readShared,
|
|
24
|
+
readText,
|
|
25
|
+
statusFileFor,
|
|
26
|
+
} from "./context.js"
|
|
27
|
+
import { currentBranch, listFiles } from "./files.js"
|
|
28
|
+
import { matches } from "./glob.js"
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CAPS = { agentsBlockLines: 60, statusLines: 40, featureReadmeLines: 60, startTokens: 3000 }
|
|
31
|
+
const REQUIRED_CONTEXT_FILES = [
|
|
32
|
+
"build-plan.md",
|
|
33
|
+
"architecture.md",
|
|
34
|
+
"conventions.md",
|
|
35
|
+
"status/main.md",
|
|
36
|
+
"features/_index.md",
|
|
37
|
+
"stack/_index.md",
|
|
38
|
+
"stack/overview.md",
|
|
39
|
+
"stack/requirements.md",
|
|
40
|
+
"stack/deploy.md",
|
|
41
|
+
"design/deslop.md",
|
|
42
|
+
"design/principles.md",
|
|
43
|
+
"design/layouts.md",
|
|
44
|
+
"design/components.md",
|
|
45
|
+
]
|
|
46
|
+
const META_KEYS = ["contextual", "defaultBranch", "cli", "skillDirs", "lastSync", "hashes"]
|
|
47
|
+
const PORTABLE_SKILL_FIELDS = new Set(["name", "description", "license", "compatibility", "metadata"])
|
|
48
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
49
|
+
const LOG_HEADING = /^(\d{4}-\d{2}-\d{2}) · \S+ · \S.*$/
|
|
50
|
+
const DECISION_FILE = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
51
|
+
const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, "")
|
|
52
|
+
|
|
53
|
+
function walkMarkdown(dir) {
|
|
54
|
+
if (!existsSync(dir)) return []
|
|
55
|
+
return readdirSync(dir).flatMap((name) => {
|
|
56
|
+
const path = join(dir, name)
|
|
57
|
+
if (statSync(path).isDirectory()) return walkMarkdown(path)
|
|
58
|
+
return name.endsWith(".md") ? [path] : []
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {{ root: string, branch?: string | null }} options
|
|
64
|
+
* @returns {{ root: string, branch: string | null, findings: { level: "error" | "warning", file: string | null, message: string }[] }}
|
|
65
|
+
*/
|
|
66
|
+
export function check({ root, branch = null }) {
|
|
67
|
+
const findings = []
|
|
68
|
+
const error = (file, message) => findings.push({ level: "error", file, message })
|
|
69
|
+
const warn = (file, message) => findings.push({ level: "warning", file, message })
|
|
70
|
+
const ctx = join(root, "context")
|
|
71
|
+
const rel = (path) => relative(root, path).split("\\").join("/")
|
|
72
|
+
|
|
73
|
+
// --- .meta.json
|
|
74
|
+
let meta = {}
|
|
75
|
+
try {
|
|
76
|
+
meta = JSON.parse(readText(join(ctx, ".meta.json")))
|
|
77
|
+
} catch (e) {
|
|
78
|
+
error("context/.meta.json", `isn't valid JSON (${e.message})`)
|
|
79
|
+
}
|
|
80
|
+
for (const key of META_KEYS) if (!(key in meta)) error("context/.meta.json", `is missing "${key}"`)
|
|
81
|
+
if (meta.lastSync && !("commit" in meta.lastSync && "date" in meta.lastSync)) {
|
|
82
|
+
error("context/.meta.json", `"lastSync" needs both "commit" and "date" (spec 3.7)`)
|
|
83
|
+
}
|
|
84
|
+
const caps = { ...DEFAULT_CAPS, ...(meta.caps ?? {}) }
|
|
85
|
+
const cli = meta.cli ?? "npx usecontextual"
|
|
86
|
+
const defaultBranch = meta.defaultBranch ?? "main"
|
|
87
|
+
const skillDirs = Array.isArray(meta.skillDirs) ? meta.skillDirs : []
|
|
88
|
+
|
|
89
|
+
// --- AGENTS.md and CLAUDE.md
|
|
90
|
+
const agentsPath = join(root, "AGENTS.md")
|
|
91
|
+
if (!existsSync(agentsPath)) {
|
|
92
|
+
error("AGENTS.md", "is missing")
|
|
93
|
+
} else {
|
|
94
|
+
const text = readText(agentsPath)
|
|
95
|
+
const block = contextualBlock(text)
|
|
96
|
+
if (!block) {
|
|
97
|
+
error("AGENTS.md", `has no Contextual block (${BLOCK_START} … ${BLOCK_END})`)
|
|
98
|
+
} else {
|
|
99
|
+
if (text.split(BLOCK_START).length > 2 || text.split(BLOCK_END).length > 2) error("AGENTS.md", "has more than one Contextual block")
|
|
100
|
+
const n = lineCount(block)
|
|
101
|
+
if (n > caps.agentsBlockLines) error("AGENTS.md", `the Contextual block is ${n} lines; the cap is ${caps.agentsBlockLines}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const claudePath = join(root, "CLAUDE.md")
|
|
105
|
+
if (!existsSync(claudePath)) {
|
|
106
|
+
error("CLAUDE.md", "is missing, so Claude Code won't read AGENTS.md. It needs the line @AGENTS.md")
|
|
107
|
+
} else if (!/^@AGENTS\.md[ \t]*$/m.test(readText(claudePath))) {
|
|
108
|
+
error("CLAUDE.md", "doesn't import AGENTS.md. Add the line @AGENTS.md")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- required files
|
|
112
|
+
for (const file of REQUIRED_CONTEXT_FILES) {
|
|
113
|
+
if (!existsSync(join(ctx, file))) error(`context/${file}`, "is missing")
|
|
114
|
+
}
|
|
115
|
+
const setupDone = existsSync(join(ctx, "idea.md"))
|
|
116
|
+
if (!setupDone) warn("context/idea.md", "doesn't exist yet: contextual-setup writes it")
|
|
117
|
+
|
|
118
|
+
// --- features
|
|
119
|
+
const features = readFeatures(root)
|
|
120
|
+
const byName = new Map(features.map((f) => [f.name, f]))
|
|
121
|
+
for (const f of features) {
|
|
122
|
+
const where = `context/features/${f.name}`
|
|
123
|
+
const readme = `${where}/README.md`
|
|
124
|
+
if (!KEBAB.test(f.name)) error(where, "feature folders are named in kebab-case (lowercase words joined by -)")
|
|
125
|
+
if (!f.readme) {
|
|
126
|
+
error(readme, "is missing")
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
for (const field of REQUIRED_FIELDS) if (!(field in f.fields)) error(readme, `is missing its "${field}:" line`)
|
|
130
|
+
if ("status" in f.fields && !STATUSES.includes(f.status)) error(readme, `has status "${f.status}"; use one of: ${STATUSES.join(", ")}`)
|
|
131
|
+
if ("phase" in f.fields && f.phase === null) error(readme, `has phase "${f.fields.phase}"; use the phase number`)
|
|
132
|
+
if ("covers" in f.fields && f.covers.length === 0) error(readme, `"covers:" lists no paths`)
|
|
133
|
+
for (const dep of f.dependsOn) if (!byName.has(dep)) error(readme, `depends on "${dep}", which isn't a feature folder`)
|
|
134
|
+
if (!f.summary) error(readme, `needs a "## What it does" section; its first sentence goes in the index`)
|
|
135
|
+
if (f.acceptanceTotal === 0) error(readme, `needs at least one checkbox under "## Acceptance"`)
|
|
136
|
+
if (f.status === "shipped" && f.acceptanceTicked < f.acceptanceTotal) {
|
|
137
|
+
error(readme, `is shipped, but ${f.acceptanceTotal - f.acceptanceTicked} acceptance box(es) are unticked`)
|
|
138
|
+
}
|
|
139
|
+
const n = lineCount(f.readme)
|
|
140
|
+
if (n > caps.featureReadmeLines) error(readme, `is ${n} lines; the cap is ${caps.featureReadmeLines}. Split the feature into smaller ones`)
|
|
141
|
+
|
|
142
|
+
if (STATUSES.includes(f.status)) {
|
|
143
|
+
const expected = f.status === "planned" ? ["README.md"] : FEATURE_FILES
|
|
144
|
+
for (const file of expected) {
|
|
145
|
+
if (!f.files.includes(file)) error(`${where}/${file}`, `is missing (a ${f.status} feature has ${expected.join(", ")})`)
|
|
146
|
+
}
|
|
147
|
+
if (f.status === "planned") {
|
|
148
|
+
for (const file of FEATURE_FILES.slice(1)) {
|
|
149
|
+
if (f.files.includes(file)) error(`${where}/${file}`, "shouldn't exist yet: a planned feature has only README.md")
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const file of f.files) {
|
|
153
|
+
if (!FEATURE_FILES.includes(file)) warn(`${where}/${file}`, "isn't one of the feature files (README, build, tests, notes, log)")
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- build plan
|
|
159
|
+
let phases = []
|
|
160
|
+
if (existsSync(join(ctx, "build-plan.md"))) {
|
|
161
|
+
const plan = readPlan(readText(join(ctx, "build-plan.md")))
|
|
162
|
+
phases = plan.phases
|
|
163
|
+
for (const problem of plan.problems) error("context/build-plan.md", problem)
|
|
164
|
+
const planned = new Map()
|
|
165
|
+
for (const phase of phases) {
|
|
166
|
+
for (const item of phase.items) {
|
|
167
|
+
if (planned.has(item.name)) error("context/build-plan.md", `lists features/${item.name} more than once`)
|
|
168
|
+
planned.set(item.name, phase)
|
|
169
|
+
const f = byName.get(item.name)
|
|
170
|
+
if (!f) {
|
|
171
|
+
error("context/build-plan.md", `lists features/${item.name}, which has no folder`)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (f.phase !== null && f.phase !== phase.number) {
|
|
175
|
+
error(`context/features/${f.name}/README.md`, `says phase ${f.phase}, but build-plan.md has it in phase ${phase.number}`)
|
|
176
|
+
}
|
|
177
|
+
if (item.ticked && !["shipped", "deprecated"].includes(f.status)) {
|
|
178
|
+
error("context/build-plan.md", `features/${f.name} is ticked, but its status is ${f.status}`)
|
|
179
|
+
}
|
|
180
|
+
if (!item.ticked && f.status === "shipped") error("context/build-plan.md", `features/${f.name} is shipped but not ticked`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const f of features) if (!planned.has(f.name)) error(`context/features/${f.name}`, "isn't in build-plan.md")
|
|
184
|
+
for (const phase of phases) {
|
|
185
|
+
const done = phaseIsDone(phase, byName)
|
|
186
|
+
if (done && phase.marker !== "done") error("context/build-plan.md", `every feature in Phase ${phase.number} is finished; mark it [done]`)
|
|
187
|
+
if (!done && phase.marker === "done") error("context/build-plan.md", `Phase ${phase.number} is marked [done], but not every feature in it is finished`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- the feature index is derived, so it must match what would be rebuilt
|
|
192
|
+
const indexPath = join(ctx, "features", "_index.md")
|
|
193
|
+
if (existsSync(indexPath) && readText(indexPath) !== buildIndex({ cli, phases, featuresByName: byName })) {
|
|
194
|
+
error("context/features/_index.md", "is out of date: it doesn't match the feature READMEs and build-plan.md")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- status files
|
|
198
|
+
const statusDir = join(ctx, "status")
|
|
199
|
+
if (existsSync(statusDir)) {
|
|
200
|
+
for (const name of readdirSync(statusDir).filter((n) => n.endsWith(".md"))) {
|
|
201
|
+
const n = lineCount(readText(join(statusDir, name)))
|
|
202
|
+
if (n > caps.statusLines) error(`context/status/${name}`, `is ${n} lines; the cap is ${caps.statusLines}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const resolvedBranch = branch ?? currentBranch(root)
|
|
206
|
+
if (!resolvedBranch) {
|
|
207
|
+
warn(null, "couldn't tell the current branch, so its status file wasn't checked (pass --branch <name>)")
|
|
208
|
+
} else {
|
|
209
|
+
const file = statusFileFor(resolvedBranch, defaultBranch)
|
|
210
|
+
if (!existsSync(join(statusDir, file))) error(`context/status/${file}`, `is missing: the branch ${resolvedBranch} needs its status file`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- every markdown file: links, leftover guidance, log entries
|
|
214
|
+
const markdown = [...(existsSync(agentsPath) ? [agentsPath] : []), ...walkMarkdown(ctx)]
|
|
215
|
+
for (const path of markdown) {
|
|
216
|
+
const text = readText(path)
|
|
217
|
+
const file = rel(path)
|
|
218
|
+
const visible = stripComments(text)
|
|
219
|
+
for (const m of visible.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) {
|
|
220
|
+
const target = m[1]
|
|
221
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) continue
|
|
222
|
+
const targetPath = target.split("#")[0].split("?")[0]
|
|
223
|
+
let decoded = targetPath
|
|
224
|
+
try {
|
|
225
|
+
decoded = decodeURI(targetPath)
|
|
226
|
+
} catch {
|
|
227
|
+
// keep it as written
|
|
228
|
+
}
|
|
229
|
+
if (targetPath && !existsSync(join(dirname(path), decoded))) error(file, `links to ${target}, which doesn't exist`)
|
|
230
|
+
}
|
|
231
|
+
if (setupDone && text.includes("<!-- fill:")) warn(file, "still has template guidance (<!-- fill: … -->) to replace")
|
|
232
|
+
if (path.endsWith(`${"log.md"}`) && file.startsWith("context/features/")) {
|
|
233
|
+
let previous = null
|
|
234
|
+
for (const m of visible.matchAll(/^## (.+)$/gm)) {
|
|
235
|
+
const heading = m[1].trim()
|
|
236
|
+
const date = heading.match(LOG_HEADING)?.[1]
|
|
237
|
+
if (!date) {
|
|
238
|
+
warn(file, `"## ${heading}" should read "## YYYY-MM-DD · agent · what was done"`)
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (previous && date > previous) warn(file, `entries should be newest first ("${heading}" comes after an older entry)`)
|
|
242
|
+
previous = date
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const decisionsDir = join(ctx, "decisions")
|
|
248
|
+
if (existsSync(decisionsDir)) {
|
|
249
|
+
for (const name of readdirSync(decisionsDir)) {
|
|
250
|
+
if (name.endsWith(".md") && name !== "README.md" && !DECISION_FILE.test(name)) {
|
|
251
|
+
warn(`context/decisions/${name}`, "decision files are named YYYY-MM-DD-<slug>.md")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- hashes (warnings only)
|
|
257
|
+
if (setupDone) {
|
|
258
|
+
const ideaHash = hashText(readText(join(ctx, "idea.md")))
|
|
259
|
+
if (!meta.hashes?.idea) warn("context/.meta.json", "has no hash for idea.md yet")
|
|
260
|
+
else if (meta.hashes.idea !== ideaHash) warn("context/idea.md", "has changed since setup. It holds the owner's own words and shouldn't be edited")
|
|
261
|
+
}
|
|
262
|
+
const deslopPath = join(ctx, "design", "deslop.md")
|
|
263
|
+
if (existsSync(deslopPath) && meta.hashes?.deslop && meta.hashes.deslop !== hashText(readText(deslopPath))) {
|
|
264
|
+
warn("context/design/deslop.md", `differs from the version Contextual ships; \`${cli} update\` restores it`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- skills: present, valid, identical in every folder
|
|
268
|
+
const present = skillDirs.filter((d) => existsSync(join(root, d)))
|
|
269
|
+
if (skillDirs.length > 0 && present.length === 0) {
|
|
270
|
+
warn(null, `the skills aren't installed (${skillDirs.join(", ")}); \`${cli} update\` installs them`)
|
|
271
|
+
} else {
|
|
272
|
+
for (const d of skillDirs) if (!present.includes(d)) warn(d, "is missing; .meta.json lists it as a skill folder")
|
|
273
|
+
for (const d of present) {
|
|
274
|
+
for (const name of readdirSync(join(root, d))) {
|
|
275
|
+
if (statSync(join(root, d, name)).isDirectory()) checkSkill(join(root, d, name), `${d}/${name}`, name, error)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const d of present.slice(1)) compareTrees(join(root, present[0]), join(root, d), present[0], d, error)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- who owns each file (spec 3.8)
|
|
282
|
+
const architecturePath = join(ctx, "architecture.md")
|
|
283
|
+
const shared = existsSync(architecturePath) ? readShared(readText(architecturePath)) : null
|
|
284
|
+
if (existsSync(architecturePath) && shared === null) {
|
|
285
|
+
warn("context/architecture.md", `has no "shared:" line, so shared code can't be told apart from code no one owns`)
|
|
286
|
+
}
|
|
287
|
+
const ownFile = (p) =>
|
|
288
|
+
p.startsWith("context/") || p === "AGENTS.md" || p === "CLAUDE.md" || skillDirs.some((d) => p.startsWith(`${d.replace(/\/$/, "")}/`))
|
|
289
|
+
const projectFiles = listFiles(root).filter((p) => !ownFile(p))
|
|
290
|
+
const codeFiles = projectFiles.filter((p) => p.includes("/") || !/^(LICENSE.*|[^/]+\.(md|txt))$/i.test(p))
|
|
291
|
+
if (codeFiles.length === 0) {
|
|
292
|
+
warn(null, "found no code outside context/, so the covers: and shared: paths weren't checked")
|
|
293
|
+
} else {
|
|
294
|
+
for (const f of features) {
|
|
295
|
+
if (f.status !== "shipped" && f.status !== "in-progress") continue
|
|
296
|
+
for (const glob of f.covers) {
|
|
297
|
+
if (projectFiles.some((p) => matches(p, glob))) continue
|
|
298
|
+
const report = f.status === "shipped" ? error : warn
|
|
299
|
+
report(`context/features/${f.name}/README.md`, `covers ${glob}, which matches no files`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const glob of shared ?? []) {
|
|
303
|
+
if (!projectFiles.some((p) => matches(p, glob))) warn("context/architecture.md", `shared: ${glob} matches no files`)
|
|
304
|
+
}
|
|
305
|
+
for (const p of projectFiles) {
|
|
306
|
+
const owners = features.filter((f) => f.covers.some((g) => matches(p, g))).map((f) => f.name)
|
|
307
|
+
const isShared = (shared ?? []).some((g) => matches(p, g))
|
|
308
|
+
if (owners.length > 1) error(p, `is covered by more than one feature: ${owners.join(", ")}`)
|
|
309
|
+
else if (owners.length === 1 && isShared) error(p, `is covered by ${owners[0]} and is also on the shared: line`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { root, branch: resolvedBranch, findings }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readFrontmatter(text) {
|
|
317
|
+
if (!text.startsWith("---\n")) return null
|
|
318
|
+
const end = text.indexOf("\n---", 3)
|
|
319
|
+
if (end === -1) return null
|
|
320
|
+
const fields = {}
|
|
321
|
+
const lines = text.slice(4, end).split("\n")
|
|
322
|
+
for (let i = 0; i < lines.length; i++) {
|
|
323
|
+
const m = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
|
324
|
+
if (!m) continue
|
|
325
|
+
let value = m[2].trim()
|
|
326
|
+
if (/^[>|][+-]?$/.test(value)) {
|
|
327
|
+
const block = []
|
|
328
|
+
while (i + 1 < lines.length && /^\s+\S|^\s*$/.test(lines[i + 1])) block.push(lines[++i].trim())
|
|
329
|
+
value = block.join(" ").trim()
|
|
330
|
+
}
|
|
331
|
+
fields[m[1]] = value.replace(/^(["'])(.*)\1$/, "$2")
|
|
332
|
+
}
|
|
333
|
+
return fields
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function checkSkill(dir, label, folderName, error) {
|
|
337
|
+
const skillPath = join(dir, "SKILL.md")
|
|
338
|
+
if (!existsSync(skillPath)) {
|
|
339
|
+
error(`${label}/SKILL.md`, "is missing")
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const fields = readFrontmatter(readText(skillPath))
|
|
343
|
+
if (!fields) {
|
|
344
|
+
error(`${label}/SKILL.md`, "needs frontmatter that starts on line 1 with --- and closes with ---")
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
for (const key of Object.keys(fields)) {
|
|
348
|
+
if (!PORTABLE_SKILL_FIELDS.has(key)) error(`${label}/SKILL.md`, `uses "${key}", which not every harness accepts (use name, description, license, compatibility, metadata)`)
|
|
349
|
+
}
|
|
350
|
+
const name = fields.name ?? ""
|
|
351
|
+
if (name !== folderName) error(`${label}/SKILL.md`, `name "${name}" must equal the folder name "${folderName}"`)
|
|
352
|
+
if (!KEBAB.test(name) || name.length > 64) error(`${label}/SKILL.md`, "name must be lowercase letters, digits and single hyphens, 64 characters at most")
|
|
353
|
+
const description = fields.description ?? ""
|
|
354
|
+
if (description.length === 0 || description.length > 1024) error(`${label}/SKILL.md`, `description must be 1 to 1,024 characters (it is ${description.length})`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function compareTrees(a, b, labelA, labelB, error) {
|
|
358
|
+
const list = (dir, prefix = "") =>
|
|
359
|
+
readdirSync(dir)
|
|
360
|
+
.sort()
|
|
361
|
+
.flatMap((name) => (statSync(join(dir, name)).isDirectory() ? list(join(dir, name), `${prefix}${name}/`) : [`${prefix}${name}`]))
|
|
362
|
+
const filesA = list(a)
|
|
363
|
+
const filesB = list(b)
|
|
364
|
+
for (const f of new Set([...filesA, ...filesB])) {
|
|
365
|
+
if (!filesA.includes(f) || !filesB.includes(f)) {
|
|
366
|
+
error(`${filesA.includes(f) ? labelB : labelA}/${f}`, `is missing; the skill copies in ${labelA} and ${labelB} must be identical`)
|
|
367
|
+
} else if (readText(join(a, f)) !== readText(join(b, f))) {
|
|
368
|
+
error(`${labelB}/${f}`, `differs from ${labelA}/${f}; the skill copies must be identical`)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { basename } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { check } from "./check.js"
|
|
5
|
+
import { findRoot } from "./context.js"
|
|
6
|
+
import { which } from "./which.js"
|
|
7
|
+
|
|
8
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
|
|
9
|
+
const DOCS = "https://github.com/mikaelendale/contextual"
|
|
10
|
+
|
|
11
|
+
const HELP = `usecontextual ${version}
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
usecontextual check [--branch <name>] check the context in this repo
|
|
15
|
+
usecontextual which <path> show which feature owns a file
|
|
16
|
+
|
|
17
|
+
Not released yet: init, update, check --fix, check --staged.
|
|
18
|
+
Docs: ${DOCS}`
|
|
19
|
+
|
|
20
|
+
function parseArgs(args) {
|
|
21
|
+
const flags = { _: [] }
|
|
22
|
+
for (let i = 0; i < args.length; i++) {
|
|
23
|
+
const arg = args[i]
|
|
24
|
+
if (arg.startsWith("--")) {
|
|
25
|
+
const [key, inline] = arg.slice(2).split("=", 2)
|
|
26
|
+
if (inline !== undefined) flags[key] = inline
|
|
27
|
+
else if (args[i + 1] !== undefined && !args[i + 1].startsWith("--") && key === "branch") flags[key] = args[++i]
|
|
28
|
+
else flags[key] = true
|
|
29
|
+
} else {
|
|
30
|
+
flags._.push(arg)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return flags
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
37
|
+
|
|
38
|
+
function notReleased(what, err) {
|
|
39
|
+
err(`usecontextual ${what}: not released yet. See ${DOCS}`)
|
|
40
|
+
return 2
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Runs the CLI and returns its exit code: 0 ok, 1 problems found, 2 usage errors and commands that aren't released yet. */
|
|
44
|
+
export function runCli(argv, { cwd, out, err }) {
|
|
45
|
+
const [command, ...rest] = argv
|
|
46
|
+
const flags = parseArgs(rest)
|
|
47
|
+
|
|
48
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
49
|
+
out(HELP)
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
if (command === "--version" || command === "-v") {
|
|
53
|
+
out(version)
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
if (command === "init" || command === "update") return notReleased(command, err)
|
|
57
|
+
if (command === "check" && (flags.fix || flags.staged)) return notReleased(`check --${flags.fix ? "fix" : "staged"}`, err)
|
|
58
|
+
if (command !== "check" && command !== "which") {
|
|
59
|
+
err(`Unknown command "${command}". Run usecontextual --help.`)
|
|
60
|
+
return 2
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const root = findRoot(cwd)
|
|
64
|
+
if (!root) {
|
|
65
|
+
err("No context/.meta.json here or in any folder above it. Run `npx usecontextual init` first.")
|
|
66
|
+
return 2
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (command === "check") {
|
|
70
|
+
if (flags.branch === true) {
|
|
71
|
+
err("--branch needs a branch name.")
|
|
72
|
+
return 2
|
|
73
|
+
}
|
|
74
|
+
const result = check({ root, branch: typeof flags.branch === "string" ? flags.branch : null })
|
|
75
|
+
const errors = result.findings.filter((f) => f.level === "error")
|
|
76
|
+
const warnings = result.findings.filter((f) => f.level === "warning")
|
|
77
|
+
out(`usecontextual check · ${basename(root)}${result.branch ? ` · branch ${result.branch}` : ""}`)
|
|
78
|
+
for (const f of [...errors, ...warnings]) {
|
|
79
|
+
out(` ${f.level.padEnd(7)} ${f.file ? `${f.file}: ` : ""}${f.message}`)
|
|
80
|
+
}
|
|
81
|
+
out(`${plural(errors.length, "error")}, ${plural(warnings.length, "warning")}`)
|
|
82
|
+
return errors.length > 0 ? 1 : 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const target = flags._[0]
|
|
86
|
+
if (!target) {
|
|
87
|
+
err("Usage: usecontextual which <path>")
|
|
88
|
+
return 2
|
|
89
|
+
}
|
|
90
|
+
const result = which({ root, path: target, cwd })
|
|
91
|
+
if (result.outside) {
|
|
92
|
+
err(`${target} isn't inside this project.`)
|
|
93
|
+
return 2
|
|
94
|
+
}
|
|
95
|
+
if (result.contextual) {
|
|
96
|
+
out(`${result.path} is one of Contextual's own files; it needs no owner.`)
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
if (result.owners.length > 1) {
|
|
100
|
+
for (const f of result.owners) out(`${f.name} context/features/${f.name}/README.md (${f.status})`)
|
|
101
|
+
out("More than one feature covers this path. `usecontextual check` reports it as an error.")
|
|
102
|
+
return 2
|
|
103
|
+
}
|
|
104
|
+
if (result.owners.length === 1) {
|
|
105
|
+
const f = result.owners[0]
|
|
106
|
+
out(`${f.name} context/features/${f.name}/README.md (${f.status})`)
|
|
107
|
+
if (result.shared) out("It's also on the shared: line, which `usecontextual check` reports as an error.")
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
if (result.shared) {
|
|
111
|
+
out("shared (the shared: line in context/architecture.md)")
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
out("no feature owns this path")
|
|
115
|
+
return 1
|
|
116
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reading a project's context: feature READMEs, the build plan, the shared
|
|
3
|
+
* list, .meta.json, the AGENTS.md block. Pure parsing, no judgment.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto"
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
8
|
+
import { dirname, join, resolve } from "node:path"
|
|
9
|
+
|
|
10
|
+
export const STATUSES = ["planned", "in-progress", "shipped", "deprecated"]
|
|
11
|
+
export const FEATURE_FILES = ["README.md", "build.md", "tests.md", "notes.md", "log.md"]
|
|
12
|
+
export const REQUIRED_FIELDS = ["status", "phase", "covers", "depends-on"]
|
|
13
|
+
export const BLOCK_START = "<!-- contextual:start -->"
|
|
14
|
+
export const BLOCK_END = "<!-- contextual:end -->"
|
|
15
|
+
|
|
16
|
+
/** File text with line endings normalized to LF. */
|
|
17
|
+
export function readText(path) {
|
|
18
|
+
return readFileSync(path, "utf8").replace(/\r\n/g, "\n")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function lineCount(text) {
|
|
22
|
+
if (!text) return 0
|
|
23
|
+
return text.replace(/\n$/, "").split("\n").length
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** sha256 of the LF-normalized text, as stored in .meta.json. */
|
|
27
|
+
export function hashText(text) {
|
|
28
|
+
return `sha256:${createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The nearest folder at or above `start` that has context/.meta.json. */
|
|
32
|
+
export function findRoot(start) {
|
|
33
|
+
let dir = resolve(start)
|
|
34
|
+
for (;;) {
|
|
35
|
+
if (existsSync(join(dir, "context", ".meta.json"))) return dir
|
|
36
|
+
const parent = dirname(dir)
|
|
37
|
+
if (parent === dir) return null
|
|
38
|
+
dir = parent
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** feat/boards → feat-boards (spec 3.4). */
|
|
43
|
+
export function branchSlug(branch) {
|
|
44
|
+
return branch.toLowerCase().replace(/[^a-z0-9._-]/g, "-")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The status file name for a branch: the default branch always uses main.md. */
|
|
48
|
+
export function statusFileFor(branch, defaultBranch) {
|
|
49
|
+
return branch === defaultBranch ? "main.md" : `${branchSlug(branch)}.md`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function splitList(value) {
|
|
53
|
+
return (value ?? "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The first sentence of a paragraph, which becomes the feature's line in the index. */
|
|
60
|
+
export function firstSentence(paragraph) {
|
|
61
|
+
const text = paragraph.trim().split("\n\n")[0].replace(/\s*\n\s*/g, " ")
|
|
62
|
+
const match = text.match(/^.*?[.!?](?=\s|$)/)
|
|
63
|
+
return (match ? match[0] : text).trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readFeature(featuresDir, name) {
|
|
67
|
+
const dir = join(featuresDir, name)
|
|
68
|
+
const files = readdirSync(dir).sort()
|
|
69
|
+
const feature = { name, dir, files, fields: {}, readme: null }
|
|
70
|
+
const readmePath = join(dir, "README.md")
|
|
71
|
+
if (!existsSync(readmePath)) return feature
|
|
72
|
+
|
|
73
|
+
const text = readText(readmePath)
|
|
74
|
+
feature.readme = text
|
|
75
|
+
feature.title = (text.match(/^# (.+)$/m) ?? [])[1] ?? null
|
|
76
|
+
const head = text.split(/^## /m)[0]
|
|
77
|
+
for (const line of head.split("\n")) {
|
|
78
|
+
const m = line.match(/^([a-z-]+):\s*(.*)$/)
|
|
79
|
+
if (m) feature.fields[m[1]] = m[2].trim()
|
|
80
|
+
}
|
|
81
|
+
feature.status = feature.fields.status
|
|
82
|
+
feature.phase = /^\d+$/.test(feature.fields.phase ?? "") ? Number(feature.fields.phase) : null
|
|
83
|
+
feature.covers = splitList(feature.fields.covers)
|
|
84
|
+
feature.dependsOn = splitList(feature.fields["depends-on"]).filter((d) => d !== "none")
|
|
85
|
+
|
|
86
|
+
const what = text.split(/^## What it does\n/m)[1]
|
|
87
|
+
feature.summary = what ? firstSentence(what.split(/^## /m)[0]) : null
|
|
88
|
+
|
|
89
|
+
const acceptance = (text.split(/^## Acceptance\n/m)[1] ?? "").split(/^## /m)[0]
|
|
90
|
+
feature.acceptanceTotal = (acceptance.match(/^- \[[ x]\] /gm) ?? []).length
|
|
91
|
+
feature.acceptanceTicked = (acceptance.match(/^- \[x\] /gm) ?? []).length
|
|
92
|
+
return feature
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readFeatures(root) {
|
|
96
|
+
const featuresDir = join(root, "context", "features")
|
|
97
|
+
if (!existsSync(featuresDir)) return []
|
|
98
|
+
return readdirSync(featuresDir)
|
|
99
|
+
.filter((name) => statSync(join(featuresDir, name)).isDirectory())
|
|
100
|
+
.sort()
|
|
101
|
+
.map((name) => readFeature(featuresDir, name))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* build-plan.md → phases. Headings that don't parse come back in `problems`
|
|
106
|
+
* rather than being guessed at.
|
|
107
|
+
*/
|
|
108
|
+
export function readPlan(text) {
|
|
109
|
+
const phases = []
|
|
110
|
+
const problems = []
|
|
111
|
+
for (const block of text.split(/^## /m).slice(1)) {
|
|
112
|
+
const heading = block.split("\n")[0].trim()
|
|
113
|
+
const m = heading.match(/^Phase (\d+) — (.+?)\s+\[(planned|in progress|done)\]$/)
|
|
114
|
+
if (!m) {
|
|
115
|
+
problems.push(`can't read the phase heading "## ${heading}" (expected "## Phase 1 — Name [planned]")`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const items = []
|
|
119
|
+
for (const line of block.split("\n").slice(1)) {
|
|
120
|
+
const item = line.match(/^- \[([ x])\] features\/(\S+)/)
|
|
121
|
+
if (item) items.push({ name: item[2], ticked: item[1] === "x" })
|
|
122
|
+
}
|
|
123
|
+
phases.push({ number: Number(m[1]), name: m[2], marker: m[3], items })
|
|
124
|
+
}
|
|
125
|
+
return { phases, problems }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The globs on the `shared:` line of architecture.md, or null when there is no such line. */
|
|
129
|
+
export function readShared(architectureText) {
|
|
130
|
+
const m = architectureText.match(/^shared:[ \t]*(.*)$/m)
|
|
131
|
+
return m ? splitList(m[1]) : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The Contextual block of AGENTS.md, markers included, or null. */
|
|
135
|
+
export function contextualBlock(agentsText) {
|
|
136
|
+
const start = agentsText.indexOf(BLOCK_START)
|
|
137
|
+
const end = agentsText.indexOf(BLOCK_END)
|
|
138
|
+
if (start === -1 || end === -1 || end < start) return null
|
|
139
|
+
return agentsText.slice(start, end + BLOCK_END.length)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DONE = new Set(["shipped", "deprecated"])
|
|
143
|
+
|
|
144
|
+
/** Is every feature in this phase shipped or deprecated? */
|
|
145
|
+
export function phaseIsDone(phase, featuresByName) {
|
|
146
|
+
return phase.items.length > 0 && phase.items.every((item) => DONE.has(featuresByName.get(item.name)?.status))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The feature index, rebuilt from the READMEs and the plan (spec 4.3): a
|
|
151
|
+
* finished phase is one line; an open phase is a heading and a table.
|
|
152
|
+
*/
|
|
153
|
+
export function buildIndex({ cli, phases, featuresByName }) {
|
|
154
|
+
const header = `# Features\nRebuilt by \`${cli} check --fix\` from each feature's README and \`build-plan.md\`. Don't edit by hand.\n`
|
|
155
|
+
if (phases.length === 0) {
|
|
156
|
+
return `${header}\nNo features yet. \`contextual-setup\` adds them from the build plan.\n`
|
|
157
|
+
}
|
|
158
|
+
const collapsed = []
|
|
159
|
+
const open = []
|
|
160
|
+
for (const phase of phases) {
|
|
161
|
+
if (phaseIsDone(phase, featuresByName)) {
|
|
162
|
+
collapsed.push(`Phase ${phase.number} — ${phase.name}: done · ${phase.items.map((i) => i.name).join(", ")}`)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const rows = phase.items
|
|
166
|
+
.filter((item) => featuresByName.has(item.name))
|
|
167
|
+
.map((item) => {
|
|
168
|
+
const f = featuresByName.get(item.name)
|
|
169
|
+
return `| [${f.name}](${f.name}/README.md) | ${f.status} | ${f.summary} |`
|
|
170
|
+
})
|
|
171
|
+
open.push(`## Phase ${phase.number} — ${phase.name}\n| Feature | Status | What it does |\n|---|---|---|\n${rows.join("\n")}`)
|
|
172
|
+
}
|
|
173
|
+
let out = header
|
|
174
|
+
if (collapsed.length) out += `\n${collapsed.join("\n")}\n`
|
|
175
|
+
for (const section of open) out += `\n${section}\n`
|
|
176
|
+
return out
|
|
177
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { readdirSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".turbo", ".vercel", "dist", "build", "coverage"])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every file in the project, relative to `root`, with forward slashes. Uses git
|
|
9
|
+
* when it can, so `.gitignore` applies; otherwise walks the folder.
|
|
10
|
+
*/
|
|
11
|
+
export function listFiles(root) {
|
|
12
|
+
try {
|
|
13
|
+
const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
14
|
+
cwd: root,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
})
|
|
18
|
+
return out.split("\0").filter(Boolean)
|
|
19
|
+
} catch {
|
|
20
|
+
const files = []
|
|
21
|
+
const walk = (dir, prefix) => {
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
if (!SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name), `${prefix}${entry.name}/`)
|
|
25
|
+
} else {
|
|
26
|
+
files.push(`${prefix}${entry.name}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
walk(root, "")
|
|
31
|
+
return files
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The current git branch in `root`, or null when git can't tell (not a repo, detached HEAD). */
|
|
36
|
+
export function currentBranch(root) {
|
|
37
|
+
try {
|
|
38
|
+
const out = execFileSync("git", ["branch", "--show-current"], {
|
|
39
|
+
cwd: root,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
}).trim()
|
|
43
|
+
if (out) return out
|
|
44
|
+
} catch {
|
|
45
|
+
// not a git repo
|
|
46
|
+
}
|
|
47
|
+
return process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || null
|
|
48
|
+
}
|
package/src/glob.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Glob matching for `covers:` and `shared:` lines.
|
|
3
|
+
*
|
|
4
|
+
* `**` matches any number of folders (including none), `*` matches within one
|
|
5
|
+
* path segment, `?` matches one character. Everything else is literal,
|
|
6
|
+
* including the brackets and parentheses Next.js uses in route folders, so
|
|
7
|
+
* `src/app/(auth)/**` and `src/app/boards/[boardId]/page.tsx` mean what they say.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const cache = new Map()
|
|
11
|
+
|
|
12
|
+
export function globToRegExp(glob) {
|
|
13
|
+
let re = ""
|
|
14
|
+
for (let i = 0; i < glob.length; i++) {
|
|
15
|
+
const c = glob[i]
|
|
16
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
17
|
+
if (glob[i + 2] === "/") {
|
|
18
|
+
re += "(?:.*/)?" // "**/" is zero or more folders
|
|
19
|
+
i += 2
|
|
20
|
+
} else {
|
|
21
|
+
re += ".*" // a trailing "**" is everything below
|
|
22
|
+
i += 1
|
|
23
|
+
}
|
|
24
|
+
} else if (c === "*") {
|
|
25
|
+
re += "[^/]*"
|
|
26
|
+
} else if (c === "?") {
|
|
27
|
+
re += "[^/]"
|
|
28
|
+
} else {
|
|
29
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return new RegExp(`^${re}$`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function matches(path, glob) {
|
|
36
|
+
let re = cache.get(glob)
|
|
37
|
+
if (!re) {
|
|
38
|
+
re = globToRegExp(glob)
|
|
39
|
+
cache.set(glob, re)
|
|
40
|
+
}
|
|
41
|
+
return re.test(path)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function matchesAny(path, globs) {
|
|
45
|
+
return globs.some((glob) => matches(path, glob))
|
|
46
|
+
}
|
package/src/which.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `usecontextual which <path>`: who owns a file (spec 3.8). A feature through
|
|
3
|
+
* its `covers:` globs, the `shared:` line in architecture.md, or no one.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import { isAbsolute, join, relative, resolve } from "node:path"
|
|
8
|
+
|
|
9
|
+
import { readFeatures, readShared, readText } from "./context.js"
|
|
10
|
+
import { matches } from "./glob.js"
|
|
11
|
+
|
|
12
|
+
export function which({ root, path, cwd = root }) {
|
|
13
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path)
|
|
14
|
+
const rel = relative(root, absolute).split("\\").join("/")
|
|
15
|
+
if (rel === "" || rel.startsWith("../") || rel === ".." || isAbsolute(rel)) return { path: rel, outside: true }
|
|
16
|
+
|
|
17
|
+
let skillDirs = []
|
|
18
|
+
try {
|
|
19
|
+
skillDirs = JSON.parse(readText(join(root, "context", ".meta.json"))).skillDirs ?? []
|
|
20
|
+
} catch {
|
|
21
|
+
// check reports a broken .meta.json
|
|
22
|
+
}
|
|
23
|
+
const contextual =
|
|
24
|
+
rel.startsWith("context/") || rel === "AGENTS.md" || rel === "CLAUDE.md" || skillDirs.some((d) => rel.startsWith(`${d.replace(/\/$/, "")}/`))
|
|
25
|
+
if (contextual) return { path: rel, contextual: true, owners: [], shared: false }
|
|
26
|
+
|
|
27
|
+
const owners = readFeatures(root).filter((f) => f.covers.some((glob) => matches(rel, glob)))
|
|
28
|
+
const architecture = join(root, "context", "architecture.md")
|
|
29
|
+
const shared = existsSync(architecture) ? (readShared(readText(architecture)) ?? []) : []
|
|
30
|
+
return { path: rel, owners, shared: shared.some((glob) => matches(rel, glob)) }
|
|
31
|
+
}
|