@erclx/canon 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +34 -9
- package/claude/skills/repo-metadata/REQUIREMENT.md +37 -0
- package/claude/skills/repo-metadata/SKILL.md +51 -0
- package/docs/agents/audits.md +6 -6
- package/docs/agents/commands.md +64 -62
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/records.md +17 -7
- package/docs/agents/sandbox.md +3 -1
- package/docs/agents/tasks.md +36 -1
- package/docs/operating-model.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +9 -3
- package/scripts/lib/sandbox-dispatch.sh +182 -0
- package/src/audits/baseline.ts +1 -1
- package/src/claude/cases/misc.ts +4 -0
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +7 -1
- package/src/commands/context.ts +3 -3
- package/src/commands/design.ts +6 -1
- package/src/commands/feedback.ts +5 -1
- package/src/commands/gov.ts +2 -1
- package/src/commands/repo.ts +393 -0
- package/src/commands/slides.ts +6 -1
- package/src/commands/tasks.ts +100 -0
- package/src/context/citations.ts +16 -5
- package/src/context/folders.ts +18 -8
- package/src/gate/measures.ts +1 -1
- package/src/intake/folder.ts +2 -1
- package/src/paths.ts +16 -0
- package/src/record-root.ts +134 -0
- package/src/records/backup.ts +40 -22
- package/src/records/size.ts +12 -7
- package/src/records/validate.ts +35 -17
- package/src/repo/metadata.ts +206 -0
- package/src/tasks/answers.ts +202 -0
- package/src/tasks/archive.ts +33 -30
- package/src/teach/workspace.ts +2 -1
- package/tooling/claude/manifest.toml +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
package/src/paths.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { sep } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a resolved path is the directory itself or sits inside it. The
|
|
5
|
+
* separator guard is the whole of it: a bare prefix test reads
|
|
6
|
+
* `.claude/plans-archive` as living under `.claude/plans`, which is a sibling
|
|
7
|
+
* rather than a child and is exactly the pair the plan folders spell.
|
|
8
|
+
*
|
|
9
|
+
* It lives at the root rather than beside either caller because both resolve
|
|
10
|
+
* plan paths and only one of them may reach `src/tasks/archive.ts`, whose
|
|
11
|
+
* index regeneration pulls in a Bun-only import that a test running under
|
|
12
|
+
* Vitest cannot load.
|
|
13
|
+
*/
|
|
14
|
+
export function isUnder(path: string, dir: string): boolean {
|
|
15
|
+
return path === dir || path.startsWith(`${dir}${sep}`)
|
|
16
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The roots a session record folder is read at, in precedence order.
|
|
6
|
+
*
|
|
7
|
+
* `.canon/` wins because a tree that carries it has been migrated, and reading
|
|
8
|
+
* `.claude/` there would answer from the copy the move left behind. A tree that
|
|
9
|
+
* carries neither is every tree today, which is what keeps this branch a no-op
|
|
10
|
+
* until the move lands.
|
|
11
|
+
*
|
|
12
|
+
* The shape is `readStamp`'s: order the spellings, take the first that exists,
|
|
13
|
+
* and stand the creation default in when none does.
|
|
14
|
+
*/
|
|
15
|
+
export const RECORD_ROOTS = ['.canon', '.claude'] as const
|
|
16
|
+
|
|
17
|
+
export type RecordRoot = (typeof RECORD_ROOTS)[number]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The root a record folder is created at.
|
|
21
|
+
*
|
|
22
|
+
* It disagrees with the read precedence above on purpose, and the disagreement
|
|
23
|
+
* is the whole of this branch. Creating under `.canon/` before the move would
|
|
24
|
+
* write records to a root whose ignore line may not have reached a target yet,
|
|
25
|
+
* and it would split one project's records across two roots with no verb able
|
|
26
|
+
* to reconcile them. The move flips this line and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export const CREATION_ROOT: RecordRoot = '.claude'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The deletable scratch folder, named at the spelling `.claude/` gives it.
|
|
32
|
+
*
|
|
33
|
+
* It is the one folder whose name differs by root. Inside a dotted root the
|
|
34
|
+
* leading dot hides nothing already hidden and costs a bare `ls` that omits the
|
|
35
|
+
* folder, so the move drops it. Every other record folder keeps its name,
|
|
36
|
+
* `.records.git` included, where the dot marks the mechanism apart from a
|
|
37
|
+
* payload rather than hiding it.
|
|
38
|
+
*/
|
|
39
|
+
export const SCRATCH = '.tmp'
|
|
40
|
+
|
|
41
|
+
/** The scratch folder's name under `.canon/`. */
|
|
42
|
+
const CANON_SCRATCH = 'tmp'
|
|
43
|
+
|
|
44
|
+
/** How a root spells a folder name. Only the scratch folder differs. */
|
|
45
|
+
function spell(root: RecordRoot, folder: string): string {
|
|
46
|
+
return root === '.canon' && folder === SCRATCH ? CANON_SCRATCH : folder
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The root a folder resolves at: the first that carries it, and the creation
|
|
51
|
+
* default when neither does.
|
|
52
|
+
*
|
|
53
|
+
* Presence is read on the record folder itself rather than on the full path, so
|
|
54
|
+
* an archive or a payload that does not exist yet still resolves beside the
|
|
55
|
+
* records it belongs to rather than at the creation default.
|
|
56
|
+
*/
|
|
57
|
+
function rootOf(root: string, folder: string): RecordRoot {
|
|
58
|
+
return (
|
|
59
|
+
RECORD_ROOTS.find((candidate) =>
|
|
60
|
+
existsSync(join(root, candidate, spell(candidate, folder))),
|
|
61
|
+
) ?? CREATION_ROOT
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where a record folder is read.
|
|
67
|
+
*
|
|
68
|
+
* `folder` is the record folder itself and `rest` is whatever sits inside it,
|
|
69
|
+
* so a caller spells no root and no folder-name variant of its own. A caller
|
|
70
|
+
* that spells `.claude` by hand is the one thing the move has to find, and one
|
|
71
|
+
* that calls this is one the move never has to open again.
|
|
72
|
+
*/
|
|
73
|
+
export function recordDir(
|
|
74
|
+
root: string,
|
|
75
|
+
folder: string,
|
|
76
|
+
...rest: string[]
|
|
77
|
+
): string {
|
|
78
|
+
const at = rootOf(root, folder)
|
|
79
|
+
|
|
80
|
+
return join(root, at, spell(at, folder), ...rest)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every root a record folder would be read at, in precedence order, whether or
|
|
85
|
+
* not it is on disk.
|
|
86
|
+
*
|
|
87
|
+
* Containment tests take this rather than `recordDir`, since a path written
|
|
88
|
+
* against the root a tree no longer uses is still a path into that folder and
|
|
89
|
+
* reading it as outside would report a shipped plan as still live.
|
|
90
|
+
*/
|
|
91
|
+
export function recordDirs(
|
|
92
|
+
root: string,
|
|
93
|
+
folder: string,
|
|
94
|
+
...rest: string[]
|
|
95
|
+
): string[] {
|
|
96
|
+
return RECORD_ROOTS.map((candidate) =>
|
|
97
|
+
join(root, candidate, spell(candidate, folder), ...rest),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Where a record folder is created, which is the creation default always. */
|
|
102
|
+
export function creationDir(
|
|
103
|
+
root: string,
|
|
104
|
+
folder: string,
|
|
105
|
+
...rest: string[]
|
|
106
|
+
): string {
|
|
107
|
+
return join(root, CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The creation destination relative to the project root, which is the form a
|
|
112
|
+
* message displays and an option default carries.
|
|
113
|
+
*/
|
|
114
|
+
export function creationRel(folder: string, ...rest: string[]): string {
|
|
115
|
+
return join(CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The record root itself, for a caller whose subject is the root rather than a
|
|
120
|
+
* folder inside it.
|
|
121
|
+
*
|
|
122
|
+
* A half-migrated tree resolves here on the root that exists rather than on the
|
|
123
|
+
* folders under it, which is what makes the backup history and its work tree
|
|
124
|
+
* one answer. Splitting them would let a push resolve a history at one root and
|
|
125
|
+
* stage a work tree at the other, which stages the deletion of every folder the
|
|
126
|
+
* move relocated.
|
|
127
|
+
*/
|
|
128
|
+
export function recordRoot(root: string): string {
|
|
129
|
+
return join(
|
|
130
|
+
root,
|
|
131
|
+
RECORD_ROOTS.find((candidate) => existsSync(join(root, candidate))) ??
|
|
132
|
+
CREATION_ROOT,
|
|
133
|
+
)
|
|
134
|
+
}
|
package/src/records/backup.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { join, resolve } from 'node:path'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
|
+
import { recordRoot } from '@/record-root'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The folders a backup carries, relative to
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* The folders a backup carries, relative to the record root `workTree` resolves
|
|
9
|
+
* rather than to `.claude/` specifically, since the same nine names sit under
|
|
10
|
+
* whichever root a tree holds. Most of them are the `# Claude` group the claude
|
|
11
|
+
* manifest ships, minus three: the scratch folder, which is defined as deletable
|
|
12
|
+
* without loss, `worktrees/`, whose contents belong to the enclosing repository
|
|
13
|
+
* already, and `.records.git/`, which is the history the rest are pushed into.
|
|
14
|
+
* The list is spelled out rather than read off that group so adding an ignore
|
|
15
|
+
* entry cannot silently enlarge the payload.
|
|
14
16
|
*
|
|
15
17
|
* `diagrams` is the one name the manifest group does not carry, so a target
|
|
16
18
|
* tracks it where this repository ignores it. That is the second reason to
|
|
@@ -65,10 +67,25 @@ const RETIRED_FOLDERS = [
|
|
|
65
67
|
'task-archive',
|
|
66
68
|
] as const
|
|
67
69
|
|
|
68
|
-
/**
|
|
69
|
-
const
|
|
70
|
+
/** The history directory's own name, which keeps its dot at either record root. */
|
|
71
|
+
const RECORDS_GIT_NAME = '.records.git'
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The tree a backup stages, which is the record root itself.
|
|
75
|
+
*
|
|
76
|
+
* It resolves the root rather than each folder under it, so the history and the
|
|
77
|
+
* work tree are one answer. Resolving them apart would let a half-migrated tree
|
|
78
|
+
* open a history at one root and stage a work tree at the other, which stages
|
|
79
|
+
* the deletion of every folder the move relocated and pushes it.
|
|
80
|
+
*/
|
|
81
|
+
function workTree(root: string): string {
|
|
82
|
+
return recordRoot(root)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
|
|
86
|
+
function recordsGitDir(root: string): string {
|
|
87
|
+
return join(workTree(root), RECORDS_GIT_NAME)
|
|
88
|
+
}
|
|
72
89
|
|
|
73
90
|
/** Both directions name the branch, so a machine whose `init.defaultBranch` differs still lands on it. */
|
|
74
91
|
const RECORDS_BRANCH = 'main'
|
|
@@ -150,11 +167,11 @@ interface GitResult {
|
|
|
150
167
|
* work tree.
|
|
151
168
|
*/
|
|
152
169
|
async function records(root: string, args: string[]): Promise<GitResult> {
|
|
153
|
-
const gitDir = resolve(root
|
|
154
|
-
const
|
|
170
|
+
const gitDir = resolve(recordsGitDir(root))
|
|
171
|
+
const tree = resolve(workTree(root))
|
|
155
172
|
|
|
156
173
|
const result =
|
|
157
|
-
await $`git -C ${
|
|
174
|
+
await $`git -C ${tree} --git-dir=${gitDir} --work-tree=${tree} ${args}`
|
|
158
175
|
.env(gitEnv())
|
|
159
176
|
.quiet()
|
|
160
177
|
.nothrow()
|
|
@@ -237,13 +254,15 @@ async function enclosingRemoteUrls(
|
|
|
237
254
|
* cannot be read is what keeps a failed comparison from reading as a pass.
|
|
238
255
|
*/
|
|
239
256
|
async function resolveRemote(root: string): Promise<string | BackupRefused> {
|
|
240
|
-
|
|
257
|
+
const gitDir = recordsGitDir(root)
|
|
258
|
+
|
|
259
|
+
if (!existsSync(gitDir)) {
|
|
241
260
|
return refuse(
|
|
242
261
|
'no-repository',
|
|
243
262
|
[
|
|
244
|
-
`No records history at ${
|
|
245
|
-
` git --git-dir=${
|
|
246
|
-
` git --git-dir=${
|
|
263
|
+
`No records history at ${relative(root, gitDir)}. Create it once, against a private repository:`,
|
|
264
|
+
` git --git-dir=${gitDir} init`,
|
|
265
|
+
` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
|
|
247
266
|
].join('\n'),
|
|
248
267
|
)
|
|
249
268
|
}
|
|
@@ -254,7 +273,7 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
|
|
|
254
273
|
'no-remote',
|
|
255
274
|
[
|
|
256
275
|
'The records history has no origin. Point it at a private repository:',
|
|
257
|
-
` git --git-dir=${
|
|
276
|
+
` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
|
|
258
277
|
].join('\n'),
|
|
259
278
|
)
|
|
260
279
|
}
|
|
@@ -299,8 +318,7 @@ async function scopedFolders(root: string): Promise<string[]> {
|
|
|
299
318
|
)
|
|
300
319
|
|
|
301
320
|
return [...BACKED_FOLDERS, ...RETIRED_FOLDERS].filter(
|
|
302
|
-
(folder) =>
|
|
303
|
-
existsSync(join(root, WORK_TREE, folder)) || indexed.has(folder),
|
|
321
|
+
(folder) => existsSync(join(workTree(root), folder)) || indexed.has(folder),
|
|
304
322
|
)
|
|
305
323
|
}
|
|
306
324
|
|
|
@@ -311,7 +329,7 @@ function topSegment(path: string): string {
|
|
|
311
329
|
/** What a report names, which is the folders a reader can go and open. */
|
|
312
330
|
function presentFolders(root: string): string[] {
|
|
313
331
|
return BACKED_FOLDERS.filter((folder) =>
|
|
314
|
-
existsSync(join(root,
|
|
332
|
+
existsSync(join(workTree(root), folder)),
|
|
315
333
|
)
|
|
316
334
|
}
|
|
317
335
|
|
package/src/records/size.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync, type Stats } from 'node:fs'
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
|
|
4
5
|
import { BACKED_FOLDERS } from '@/records/backup'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The folders a size reading covers,
|
|
8
|
+
* The folders a size reading covers, named at the record root they sit under.
|
|
8
9
|
*
|
|
9
|
-
* It is the backed set plus
|
|
10
|
+
* It is the backed set plus the scratch folder, which a backup skips because it is
|
|
10
11
|
* deletable without loss and a reading covers because deletable is not the same
|
|
11
12
|
* as empty: the routing handoffs and the memory archive both sit there and both
|
|
12
13
|
* accumulate. `.records.git` stays out because it is the backup history rather
|
|
@@ -14,7 +15,7 @@ import { BACKED_FOLDERS } from '@/records/backup'
|
|
|
14
15
|
* checkout of the enclosing repository with its own removal verb, and one of
|
|
15
16
|
* them outweighs every record folder combined.
|
|
16
17
|
*/
|
|
17
|
-
export const SIZED_FOLDERS = [...BACKED_FOLDERS,
|
|
18
|
+
export const SIZED_FOLDERS = [...BACKED_FOLDERS, SCRATCH] as const
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* The windows a reading reports, in days.
|
|
@@ -34,7 +35,7 @@ export interface WindowCount {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface FolderSize {
|
|
37
|
-
/** Relative to
|
|
38
|
+
/** Relative to the record root, which is the name a reader opens. */
|
|
38
39
|
readonly folder: string
|
|
39
40
|
readonly present: boolean
|
|
40
41
|
readonly files: number
|
|
@@ -170,7 +171,7 @@ async function measure(
|
|
|
170
171
|
folder: string,
|
|
171
172
|
now: number,
|
|
172
173
|
): Promise<FolderSize> {
|
|
173
|
-
const path =
|
|
174
|
+
const path = recordDir(root, folder)
|
|
174
175
|
const empty = GROWTH_WINDOWS.map((days) => ({ days, files: 0 }))
|
|
175
176
|
|
|
176
177
|
if (!existsSync(path)) {
|
|
@@ -212,11 +213,15 @@ export async function sizeRecords(
|
|
|
212
213
|
root: string,
|
|
213
214
|
now: number = Date.now(),
|
|
214
215
|
): Promise<SizeOutcome> {
|
|
215
|
-
|
|
216
|
+
// Either root answers, so a migrated tree is read rather than refused. The
|
|
217
|
+
// roots are tested rather than the folders under them, since a project that
|
|
218
|
+
// holds the root and no records yet is empty rather than absent and the
|
|
219
|
+
// per-folder `present` flags already say which of the ten it carries.
|
|
220
|
+
if (!RECORD_ROOTS.some((name) => existsSync(join(root, name)))) {
|
|
216
221
|
return {
|
|
217
222
|
ok: false,
|
|
218
223
|
reason: 'no-folder',
|
|
219
|
-
message: `No .
|
|
224
|
+
message: `No ${RECORD_ROOTS.join(' or ')} directory at ${root}, so there are no record folders to read.`,
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
|
package/src/records/validate.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { readdir, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
5
|
import { linesOutsideFences } from '@/markdown/scan'
|
|
6
|
+
import {
|
|
7
|
+
recordDir as resolveRecordDir,
|
|
8
|
+
recordDirs as resolveRecordDirs,
|
|
9
|
+
} from '@/record-root'
|
|
6
10
|
import {
|
|
7
11
|
TEACH_GLOSSARY,
|
|
8
12
|
TEACH_MISSION,
|
|
@@ -25,22 +29,22 @@ export const RECORD_KINDS = [
|
|
|
25
29
|
export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
|
-
* The folders
|
|
32
|
+
* The folders standards reads, in precedence order.
|
|
29
33
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
34
|
+
* It carries two because the corpus authors at the project root and installs
|
|
35
|
+
* under `.claude/`. The authoring root wins where both exist, since the
|
|
32
36
|
* installed tree is a generated copy here and a finding fixed there is
|
|
33
37
|
* overwritten by the next regen. A project that consumed the corpus holds only
|
|
34
38
|
* the second, so one order serves both.
|
|
39
|
+
*
|
|
40
|
+
* Every other kind is a session record and takes the record roots instead,
|
|
41
|
+
* resolved by `@/record-root`. Standards is the one kind that is tracked, so it
|
|
42
|
+
* does not move and spells its own two candidates here.
|
|
35
43
|
*/
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
memory: [join('.claude', 'memory')],
|
|
41
|
-
standards: ['standards', join('.claude', 'standards')],
|
|
42
|
-
teach: [join('.claude', 'teach')],
|
|
43
|
-
}
|
|
44
|
+
const STANDARDS_FOLDERS: readonly string[] = [
|
|
45
|
+
'standards',
|
|
46
|
+
join('.claude', 'standards'),
|
|
47
|
+
]
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
50
|
* `unknown-kind` is raised at the argument boundary rather than by the walk, and
|
|
@@ -107,17 +111,31 @@ export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
|
107
111
|
|
|
108
112
|
/** Every folder a kind would accept, whether or not it is on disk. */
|
|
109
113
|
export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
110
|
-
|
|
114
|
+
if (kind === 'standards') {
|
|
115
|
+
return STANDARDS_FOLDERS.map((folder) => join(root, folder))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return resolveRecordDirs(root, kind)
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/**
|
|
114
|
-
* The folder a kind reads. The first candidate on disk wins, and the
|
|
115
|
-
*
|
|
116
|
-
*
|
|
122
|
+
* The folder a kind reads. The first candidate on disk wins, and the creation
|
|
123
|
+
* default stands in when none exists, so a refusal and a test fixture both name
|
|
124
|
+
* the location the kind would be written to.
|
|
125
|
+
*
|
|
126
|
+
* The default is where this parts company with `dirs[0]`, which the record
|
|
127
|
+
* kinds can no longer take: their first candidate is the root the move lands at
|
|
128
|
+
* and nothing creates there yet, so a fallback reading it would name a folder no
|
|
129
|
+
* verb would ever write. Standards has no such split, since it is tracked and
|
|
130
|
+
* its first candidate is where it is authored.
|
|
117
131
|
*/
|
|
118
132
|
export function recordsDir(root: string, kind: RecordKind): string {
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
if (kind === 'standards') {
|
|
134
|
+
const dirs = recordDirs(root, kind)
|
|
135
|
+
return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return resolveRecordDir(root, kind)
|
|
121
139
|
}
|
|
122
140
|
|
|
123
141
|
export function isRecordKind(value: string): value is RecordKind {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What this run can compute from the tree alone. A field stays absent rather
|
|
6
|
+
* than empty when nothing local resolves it, so a caller never reads
|
|
7
|
+
* "nothing to propose" as "propose removing what is already there."
|
|
8
|
+
*/
|
|
9
|
+
export interface MetadataProposal {
|
|
10
|
+
readonly description?: string
|
|
11
|
+
readonly homepage?: string
|
|
12
|
+
readonly topics?: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What the remote already carries, read by the caller rather than here. */
|
|
16
|
+
export interface CurrentMetadata {
|
|
17
|
+
readonly description: string
|
|
18
|
+
readonly homepage: string
|
|
19
|
+
readonly topics: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MetadataDiff {
|
|
23
|
+
readonly description?: { readonly current: string; readonly proposed: string }
|
|
24
|
+
readonly homepage?: { readonly current: string; readonly proposed: string }
|
|
25
|
+
readonly topics?: {
|
|
26
|
+
readonly added: readonly string[]
|
|
27
|
+
readonly removed: readonly string[]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** GitHub's own cap on the About field. */
|
|
32
|
+
const MAX_DESCRIPTION_LENGTH = 350
|
|
33
|
+
|
|
34
|
+
/** GitHub's own cap on topics per repository. */
|
|
35
|
+
const MAX_TOPICS = 20
|
|
36
|
+
|
|
37
|
+
/** GitHub's own shape for a topic: lowercase, alphanumeric, internal hyphens. */
|
|
38
|
+
const TOPIC_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether an already-trimmed, already-lowercased string is a shape GitHub
|
|
42
|
+
* accepts as a topic. Exported so a caller validating an operator-supplied
|
|
43
|
+
* topic list checks against the same rule this reader silently filters
|
|
44
|
+
* `package.json`'s `keywords` through, rather than reimplementing it against
|
|
45
|
+
* a looser test such as non-emptiness alone.
|
|
46
|
+
*/
|
|
47
|
+
export function isValidTopic(topic: string): boolean {
|
|
48
|
+
return TOPIC_PATTERN.test(topic)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A bare image, or an image wrapped in a link, which is the shape a shields.io
|
|
53
|
+
* badge takes. The wrapped alternative goes first, since the bare form would
|
|
54
|
+
* otherwise match its inner image alone and leave the wrapping link behind.
|
|
55
|
+
*/
|
|
56
|
+
const BADGE_TOKEN = /\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)|!\[[^\]]*\]\([^)]*\)/g
|
|
57
|
+
|
|
58
|
+
/** True once every badge token is stripped and nothing remains. */
|
|
59
|
+
function isBadgeLine(line: string): boolean {
|
|
60
|
+
return line.replace(BADGE_TOKEN, '').trim() === ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripInlineMarkdown(line: string): string {
|
|
64
|
+
return line
|
|
65
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
66
|
+
.replace(/(\*\*|__)(.+?)\1/g, '$2')
|
|
67
|
+
.replace(/(\*|_|`)(.+?)\1/g, '$2')
|
|
68
|
+
.trim()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The first prose line past the title and any badge row, stripped of inline
|
|
73
|
+
* markdown and capped at GitHub's About field length.
|
|
74
|
+
*
|
|
75
|
+
* `undefined` only when the file carries nothing past its title and badges,
|
|
76
|
+
* per the skill requirement's refusal boundary: a resolvable line that reads
|
|
77
|
+
* as a title rather than a sentence is still proposed, since rejecting it is
|
|
78
|
+
* a judgment for the person reading the proposal rather than for this reader.
|
|
79
|
+
*/
|
|
80
|
+
export function extractOpeningLine(readme: string): string | undefined {
|
|
81
|
+
for (const raw of readme.split('\n')) {
|
|
82
|
+
const line = raw.trim()
|
|
83
|
+
if (line === '' || line.startsWith('#') || isBadgeLine(line)) continue
|
|
84
|
+
|
|
85
|
+
const stripped = stripInlineMarkdown(line)
|
|
86
|
+
if (stripped === '') continue
|
|
87
|
+
|
|
88
|
+
return stripped.length > MAX_DESCRIPTION_LENGTH
|
|
89
|
+
? `${stripped.slice(0, MAX_DESCRIPTION_LENGTH - 1)}…`
|
|
90
|
+
: stripped
|
|
91
|
+
}
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `package.json`'s `keywords` read as candidate topics, the field the wider
|
|
97
|
+
* npm ecosystem already uses for this. Invalid entries are dropped silently
|
|
98
|
+
* rather than refused, since a manifest mixing free-text keywords with
|
|
99
|
+
* topic-shaped ones is ordinary and only the shaped half transfers.
|
|
100
|
+
*/
|
|
101
|
+
function readTopics(keywords: unknown): readonly string[] | undefined {
|
|
102
|
+
if (!Array.isArray(keywords)) return undefined
|
|
103
|
+
|
|
104
|
+
const topics = new Set<string>()
|
|
105
|
+
for (const entry of keywords) {
|
|
106
|
+
if (typeof entry !== 'string') continue
|
|
107
|
+
const topic = entry.trim().toLowerCase()
|
|
108
|
+
if (isValidTopic(topic)) topics.add(topic)
|
|
109
|
+
if (topics.size === MAX_TOPICS) break
|
|
110
|
+
}
|
|
111
|
+
return [...topics]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface PackageFields {
|
|
115
|
+
readonly homepage?: unknown
|
|
116
|
+
readonly keywords?: unknown
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readManifest(root: string): Promise<PackageFields | undefined> {
|
|
120
|
+
try {
|
|
121
|
+
const parsed: unknown = JSON.parse(
|
|
122
|
+
await readFile(join(root, 'package.json'), 'utf8'),
|
|
123
|
+
)
|
|
124
|
+
return (parsed ?? undefined) as PackageFields | undefined
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Computes what this tree can propose for its own repository metadata, with
|
|
132
|
+
* no network call: an About text from the README's opening line, and a
|
|
133
|
+
* homepage and a topic set from `package.json`, the one manifest every
|
|
134
|
+
* target project this ships to already carries.
|
|
135
|
+
*
|
|
136
|
+
* A field a target project declares nowhere stays absent rather than empty,
|
|
137
|
+
* so the caller comparing this against the remote never treats a field this
|
|
138
|
+
* reader has no opinion on as a proposal to clear it.
|
|
139
|
+
*/
|
|
140
|
+
export async function proposeMetadata(root: string): Promise<MetadataProposal> {
|
|
141
|
+
const [readme, manifest] = await Promise.all([
|
|
142
|
+
readFile(join(root, 'README.md'), 'utf8').catch(() => undefined),
|
|
143
|
+
readManifest(root),
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
const description =
|
|
147
|
+
readme === undefined ? undefined : extractOpeningLine(readme)
|
|
148
|
+
|
|
149
|
+
const homepage =
|
|
150
|
+
typeof manifest?.homepage === 'string' && manifest.homepage.trim() !== ''
|
|
151
|
+
? manifest.homepage.trim()
|
|
152
|
+
: undefined
|
|
153
|
+
|
|
154
|
+
const topics = readTopics(manifest?.keywords)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...(description !== undefined && { description }),
|
|
158
|
+
...(homepage !== undefined && { homepage }),
|
|
159
|
+
...(topics !== undefined && topics.length > 0 && { topics }),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Compares a computed proposal against what the remote already carries.
|
|
165
|
+
*
|
|
166
|
+
* A field the proposal has no opinion on is never diffed, which is what
|
|
167
|
+
* keeps a target project with no `keywords` field from seeing every existing
|
|
168
|
+
* topic reported as a removal.
|
|
169
|
+
*/
|
|
170
|
+
export function compareMetadata(
|
|
171
|
+
current: CurrentMetadata,
|
|
172
|
+
proposed: MetadataProposal,
|
|
173
|
+
): MetadataDiff {
|
|
174
|
+
const diff: {
|
|
175
|
+
description?: { current: string; proposed: string }
|
|
176
|
+
homepage?: { current: string; proposed: string }
|
|
177
|
+
topics?: { added: readonly string[]; removed: readonly string[] }
|
|
178
|
+
} = {}
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
proposed.description !== undefined &&
|
|
182
|
+
proposed.description !== current.description
|
|
183
|
+
) {
|
|
184
|
+
diff.description = {
|
|
185
|
+
current: current.description,
|
|
186
|
+
proposed: proposed.description,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
proposed.homepage !== undefined &&
|
|
192
|
+
proposed.homepage !== current.homepage
|
|
193
|
+
) {
|
|
194
|
+
diff.homepage = { current: current.homepage, proposed: proposed.homepage }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (proposed.topics !== undefined) {
|
|
198
|
+
const currentSet = new Set(current.topics)
|
|
199
|
+
const proposedSet = new Set(proposed.topics)
|
|
200
|
+
const added = proposed.topics.filter((topic) => !currentSet.has(topic))
|
|
201
|
+
const removed = current.topics.filter((topic) => !proposedSet.has(topic))
|
|
202
|
+
if (added.length > 0 || removed.length > 0) diff.topics = { added, removed }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return diff
|
|
206
|
+
}
|