@erclx/canon 4.117.0 → 4.118.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/canon-operator/SKILL.md +1 -1
- package/claude/skills/draft-ready/references/assembly.md +8 -0
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +7 -0
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +3 -1
- package/claude/skills/target-check/references/domains.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/context-audit-checks.md +2 -28
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/install-and-sync.md +23 -17
- package/docs/target-projects.md +2 -2
- package/docs/workflow/operating-model.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/510-context.md +0 -12
- package/governance/rules/core/005-behavior.md +1 -2
- package/governance/rules/core/015-output.md +1 -3
- package/governance/rules/core/035-tasks.md +1 -3
- package/governance/rules/core/045-memory.md +0 -1
- package/governance/rules/core/055-scratch.md +2 -4
- package/governance/rules/core/070-planning.md +1 -4
- package/governance/rules/core/087-git.md +2 -2
- package/governance/rules/core/090-code-comments.md +1 -1
- package/governance/rules/ui/460-design-taste.md +0 -15
- package/package.json +1 -1
- package/src/commands/context.ts +11 -111
- package/src/commands/sync.ts +2 -2
- package/src/context/audit.ts +3 -145
- package/src/gate/measures.ts +37 -0
- package/src/gate/stages.ts +9 -0
- package/src/gov/adapter.ts +8 -6
- package/src/records/validate.ts +5 -1
- package/src/sync/check.ts +7 -1
- package/src/sync/engine.ts +90 -10
- package/src/sync/stamp.ts +44 -0
- package/standards/plan.md +24 -1
- package/src/context/narration.ts +0 -99
package/src/sync/engine.ts
CHANGED
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
type DomainHashes,
|
|
9
9
|
hashFile,
|
|
10
10
|
readStamp,
|
|
11
|
+
type Stamp,
|
|
12
|
+
type StampDomain,
|
|
11
13
|
type StampSource,
|
|
12
14
|
stampedHashes,
|
|
15
|
+
stampedVersion,
|
|
13
16
|
toStampKey,
|
|
14
17
|
writeStamp,
|
|
15
18
|
} from '@/sync/stamp'
|
|
@@ -26,6 +29,8 @@ import {
|
|
|
26
29
|
palette,
|
|
27
30
|
select,
|
|
28
31
|
} from '@/ui'
|
|
32
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
33
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* One installed file in three path flavours: absolute, relative to the
|
|
@@ -58,10 +63,12 @@ export type SyncChange =
|
|
|
58
63
|
* who moved it. `stale` and `customized` need the stamp to tell apart, so
|
|
59
64
|
* `drifted` stays the verdict for a difference no stamp covers.
|
|
60
65
|
*
|
|
61
|
-
* `orphaned` and `stranded`
|
|
62
|
-
* separate because they need
|
|
63
|
-
* orphaned and stays that way forever. A
|
|
64
|
-
*
|
|
66
|
+
* `orphaned`, `retired`, and `stranded` all mean the walk found no source, and
|
|
67
|
+
* they are separate because they need different treatment. A project-authored
|
|
68
|
+
* file is orphaned and stays that way forever. A file under a root the toolkit
|
|
69
|
+
* owns outright is retired, since only the toolkit ever wrote there, and a
|
|
70
|
+
* sync deletes it. A stamped file the toolkit no longer installs to is
|
|
71
|
+
* stranded, which is a relocation waiting on a decision.
|
|
65
72
|
*
|
|
66
73
|
* `missing` is the one state the walk cannot produce on its own, since the
|
|
67
74
|
* walk only iterates files that exist. It comes from `collectMissing`
|
|
@@ -73,6 +80,7 @@ export type EntryState =
|
|
|
73
80
|
| 'customized'
|
|
74
81
|
| 'drifted'
|
|
75
82
|
| 'orphaned'
|
|
83
|
+
| 'retired'
|
|
76
84
|
| 'stranded'
|
|
77
85
|
| 'missing'
|
|
78
86
|
|
|
@@ -152,6 +160,14 @@ export interface SyncAdapter {
|
|
|
152
160
|
* inference orphans a file sitting anywhere else.
|
|
153
161
|
*/
|
|
154
162
|
readonly projectSubdir?: string
|
|
163
|
+
/**
|
|
164
|
+
* Every file under `installedRoot` is the toolkit's, so a walked file with
|
|
165
|
+
* no source is a leftover of a rule the toolkit retired or renamed rather
|
|
166
|
+
* than something a project wrote. Setting it turns that file into a
|
|
167
|
+
* `retired` entry and queues its delete, edited or not, whatever the stamp
|
|
168
|
+
* holds. Unset, the file stays `orphaned` and untouched.
|
|
169
|
+
*/
|
|
170
|
+
readonly ownsInstalledRoot?: boolean
|
|
155
171
|
/** Defaults to applying. */
|
|
156
172
|
readonly nonInteractive?: NonInteractivePolicy
|
|
157
173
|
/** Runs on a completed sync, including one with no changes. */
|
|
@@ -195,15 +211,18 @@ export function listInstalled(
|
|
|
195
211
|
|
|
196
212
|
/**
|
|
197
213
|
* Classifies every installed file against its source without writing anything.
|
|
198
|
-
* A file with no source is
|
|
199
|
-
*
|
|
214
|
+
* A file with no source is deleted only when the adapter owns its installed
|
|
215
|
+
* root outright, and left alone otherwise, which is what keeps a
|
|
216
|
+
* project-authored file alive across a sync of a root the project shares.
|
|
200
217
|
*/
|
|
201
218
|
export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
202
219
|
const entries: ScanEntry[] = []
|
|
203
220
|
const changes: SyncChange[] = []
|
|
204
221
|
const unattributed: UnattributedFile[] = []
|
|
205
222
|
|
|
206
|
-
const
|
|
223
|
+
const stamp = readStamp(target)
|
|
224
|
+
const hashes = stampedHashes(stamp, adapter.stamp?.domain)
|
|
225
|
+
const newerInstall = newerInstallingVersion(stamp, adapter.stamp?.domain)
|
|
207
226
|
const walked = new Set<string>()
|
|
208
227
|
|
|
209
228
|
for (const file of listInstalled(
|
|
@@ -222,7 +241,18 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
222
241
|
const source = adapter.locateSource(file)
|
|
223
242
|
|
|
224
243
|
if (source === undefined || !existsSync(source)) {
|
|
225
|
-
|
|
244
|
+
if (adapter.ownsInstalledRoot !== true) {
|
|
245
|
+
entries.push(misplacedOrphan(adapter, target, file))
|
|
246
|
+
} else if (newerInstall !== undefined) {
|
|
247
|
+
entries.push(heldForNewerInstall(file, newerInstall))
|
|
248
|
+
} else {
|
|
249
|
+
entries.push({
|
|
250
|
+
state: 'retired',
|
|
251
|
+
rel: file.rel,
|
|
252
|
+
notice: `${file.rel} (no longer shipped by the toolkit, removing)`,
|
|
253
|
+
})
|
|
254
|
+
changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
|
|
255
|
+
}
|
|
226
256
|
continue
|
|
227
257
|
}
|
|
228
258
|
|
|
@@ -438,6 +468,8 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
438
468
|
)
|
|
439
469
|
else if (entry.state === 'customized')
|
|
440
470
|
logWarn(`${entry.rel} (locally customized)`)
|
|
471
|
+
else if (entry.state === 'retired')
|
|
472
|
+
logWarn(entry.notice ?? `${entry.rel} (no longer shipped by the toolkit)`)
|
|
441
473
|
else if (entry.state === 'stranded')
|
|
442
474
|
logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
|
|
443
475
|
else if (entry.state === 'missing')
|
|
@@ -528,6 +560,10 @@ function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
|
|
|
528
560
|
* Naming the destination is all this does. Moving the file rewrites a path the
|
|
529
561
|
* project's own rules, skills, and docs may cite, so the sync leaves it where
|
|
530
562
|
* it is.
|
|
563
|
+
*
|
|
564
|
+
* An adapter declaring `ownsInstalledRoot` never reaches this, since owning
|
|
565
|
+
* the root answers the question neither the name nor the stamp can, and its
|
|
566
|
+
* sourceless file is retired instead.
|
|
531
567
|
*/
|
|
532
568
|
function misplacedOrphan(
|
|
533
569
|
adapter: SyncAdapter,
|
|
@@ -549,6 +585,49 @@ function misplacedOrphan(
|
|
|
549
585
|
}
|
|
550
586
|
}
|
|
551
587
|
|
|
588
|
+
/**
|
|
589
|
+
* The release that last wrote this domain, when it is newer than the one
|
|
590
|
+
* running. A binary older than that release cannot tell a rule it retired
|
|
591
|
+
* from one added after it, so it holds every sourceless file rather than
|
|
592
|
+
* deleting rules the newer release installed. An unparseable side holds too,
|
|
593
|
+
* since deleting on a comparison that never ran is the failure this guards.
|
|
594
|
+
* A stamp with no version predates the field and is never newer.
|
|
595
|
+
*/
|
|
596
|
+
function newerInstallingVersion(
|
|
597
|
+
stamp: Stamp | undefined,
|
|
598
|
+
domain: StampDomain | undefined,
|
|
599
|
+
): { readonly stamped: string; readonly running: string } | undefined {
|
|
600
|
+
if (domain === undefined) return undefined
|
|
601
|
+
|
|
602
|
+
const stamped = stampedVersion(stamp, domain)
|
|
603
|
+
if (stamped === undefined) return undefined
|
|
604
|
+
|
|
605
|
+
const running = readInstalled().version ?? UNKNOWN_LABEL
|
|
606
|
+
const parsedStamped = parseVersion(stamped)
|
|
607
|
+
const parsedRunning = parseVersion(running)
|
|
608
|
+
|
|
609
|
+
if (
|
|
610
|
+
parsedStamped !== undefined &&
|
|
611
|
+
parsedRunning !== undefined &&
|
|
612
|
+
compareVersions(parsedStamped, parsedRunning) <= 0
|
|
613
|
+
) {
|
|
614
|
+
return undefined
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { stamped, running }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function heldForNewerInstall(
|
|
621
|
+
file: InstalledFile,
|
|
622
|
+
versions: { readonly stamped: string; readonly running: string },
|
|
623
|
+
): ScanEntry {
|
|
624
|
+
return {
|
|
625
|
+
state: 'orphaned',
|
|
626
|
+
rel: file.rel,
|
|
627
|
+
notice: `${file.rel} (installed by canon ${versions.stamped}, newer than this ${versions.running}. Upgrade canon to sync it.)`,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
552
631
|
function isInside(target: string, path: string): boolean {
|
|
553
632
|
const rel = relative(target, path)
|
|
554
633
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
@@ -567,8 +646,9 @@ function hasUnattributedDrift(plan: SyncPlan): boolean {
|
|
|
567
646
|
/**
|
|
568
647
|
* Records what the toolkit placed, after the copies land, so a partial apply
|
|
569
648
|
* that throws leaves the previous stamp rather than a claim the target does not
|
|
570
|
-
* meet. A file with no source,
|
|
571
|
-
*
|
|
649
|
+
* meet. A file with no source stays out, whether it is project-authored or a
|
|
650
|
+
* retired leftover a declined sync did not delete, and so does one orphaned by
|
|
651
|
+
* location.
|
|
572
652
|
*
|
|
573
653
|
* Reads the installed tree rather than the caller's file list, so a partial
|
|
574
654
|
* install still stamps the domain's whole installed set.
|
package/src/sync/stamp.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { dirname, join, sep } from 'node:path'
|
|
|
5
5
|
import { execa } from 'execa'
|
|
6
6
|
import { resolveExisting } from '@/legacy-path'
|
|
7
7
|
import { recordTarget } from '@/targets/registry'
|
|
8
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
9
|
+
import { readInstalled } from '@/version/installed'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Domains the stamp can record. Governance and design both attribute file by
|
|
@@ -38,6 +40,14 @@ export type DomainHashes = Readonly<Record<string, string>>
|
|
|
38
40
|
export interface DomainStamp {
|
|
39
41
|
/** Toolkit revision this domain last synced from. Absent outside a git clone. */
|
|
40
42
|
readonly commit?: string
|
|
43
|
+
/**
|
|
44
|
+
* Newest canon release that has written this domain. A sync deletes an
|
|
45
|
+
* installed file its toolkit does not ship, and a binary older than the one
|
|
46
|
+
* that installed the file cannot tell a retired file from one added after
|
|
47
|
+
* its own release, so this is what it reads before deleting anything.
|
|
48
|
+
* Absent on a stamp written before the field existed.
|
|
49
|
+
*/
|
|
50
|
+
readonly version?: string
|
|
41
51
|
readonly syncedAt: string
|
|
42
52
|
readonly files: DomainHashes
|
|
43
53
|
/**
|
|
@@ -178,6 +188,14 @@ function readStampFile(path: string): Stamp | undefined {
|
|
|
178
188
|
}
|
|
179
189
|
}
|
|
180
190
|
|
|
191
|
+
/** The newest canon release that has written this domain, when one was recorded. */
|
|
192
|
+
export function stampedVersion(
|
|
193
|
+
stamp: Stamp | undefined,
|
|
194
|
+
domain: StampDomain,
|
|
195
|
+
): string | undefined {
|
|
196
|
+
return stamp?.domains[domain]?.version
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
/** The revision this domain last synced from, which bounds its upstream range. */
|
|
182
200
|
export function stampedCommit(
|
|
183
201
|
stamp: Stamp | undefined,
|
|
@@ -245,9 +263,11 @@ async function putDomain(
|
|
|
245
263
|
const previous = readStamp(target)
|
|
246
264
|
const previousRecord = previous?.domains[source.domain]
|
|
247
265
|
const commit = await toolkitCommit(source.toolkitRoot)
|
|
266
|
+
const version = newerVersion(readInstalled().version, previousRecord?.version)
|
|
248
267
|
|
|
249
268
|
const record: DomainStamp = {
|
|
250
269
|
...(commit === undefined ? {} : { commit }),
|
|
270
|
+
...(version === undefined ? {} : { version }),
|
|
251
271
|
syncedAt: now.toISOString(),
|
|
252
272
|
files: payload.files ?? previousRecord?.files ?? {},
|
|
253
273
|
...resolveChainField(payload.chain, previousRecord?.chain),
|
|
@@ -304,6 +324,27 @@ async function readCommit(root: string): Promise<string | undefined> {
|
|
|
304
324
|
: undefined
|
|
305
325
|
}
|
|
306
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Keeps the newer of the running release and the one already recorded, so an
|
|
329
|
+
* older binary syncing after a newer one cannot lower the mark and let a later
|
|
330
|
+
* run delete a rule the newer release installed. An unparseable side loses to
|
|
331
|
+
* a parseable one, and a recorded value survives an unreadable manifest.
|
|
332
|
+
*/
|
|
333
|
+
function newerVersion(
|
|
334
|
+
running: string | undefined,
|
|
335
|
+
recorded: string | undefined,
|
|
336
|
+
): string | undefined {
|
|
337
|
+
if (running === undefined) return recorded
|
|
338
|
+
if (recorded === undefined) return running
|
|
339
|
+
|
|
340
|
+
const parsedRunning = parseVersion(running)
|
|
341
|
+
const parsedRecorded = parseVersion(recorded)
|
|
342
|
+
if (parsedRecorded === undefined) return running
|
|
343
|
+
if (parsedRunning === undefined) return recorded
|
|
344
|
+
|
|
345
|
+
return compareVersions(parsedRecorded, parsedRunning) > 0 ? recorded : running
|
|
346
|
+
}
|
|
347
|
+
|
|
307
348
|
/**
|
|
308
349
|
* A write naming no chain keeps the domain's previous one rather than dropping
|
|
309
350
|
* it, since `writeStamp` and `writeChainStamp` each touch one half of a
|
|
@@ -365,6 +406,9 @@ function isDomainStamp(value: unknown): value is DomainStamp {
|
|
|
365
406
|
if (value.commit !== undefined && typeof value.commit !== 'string') {
|
|
366
407
|
return false
|
|
367
408
|
}
|
|
409
|
+
if (value.version !== undefined && typeof value.version !== 'string') {
|
|
410
|
+
return false
|
|
411
|
+
}
|
|
368
412
|
if (value.chain !== undefined && !isStringArray(value.chain)) return false
|
|
369
413
|
|
|
370
414
|
return Object.values(value.files).every((hash) => typeof hash === 'string')
|
package/standards/plan.md
CHANGED
|
@@ -31,6 +31,7 @@ A plan works when a session holding none of the conversation that produced it ca
|
|
|
31
31
|
- Which decisions are already settled, and which are still open?
|
|
32
32
|
- For each open decision, what happens when nobody answers it?
|
|
33
33
|
- What is likely to go wrong, and where does the work collide with something already in flight?
|
|
34
|
+
- How is each outcome proven, and which input would break it?
|
|
34
35
|
|
|
35
36
|
A plan failing these is non-conforming even when it satisfies every shape rule below.
|
|
36
37
|
|
|
@@ -53,10 +54,12 @@ The document opens with `# Feature: <short title>` and one paragraph stating wha
|
|
|
53
54
|
| `## Summary` | three to five one-line bullets: the goal, the deliverables, the trade-off | Always |
|
|
54
55
|
| `**Constraints:**` | durable rules the work must respect | When supplied |
|
|
55
56
|
| `**Files to touch:**` | each file with a one-line reason | Always |
|
|
57
|
+
| `**Verification:**` | the test or command that proves each outcome | Always |
|
|
56
58
|
| `**Risks:**` | conflicts, coupling, and the tricky spots | Always |
|
|
59
|
+
| `**Review focus:**` | the inputs or states that would break the change, for the reviewer | Always |
|
|
57
60
|
| `**Questions:**` | numbered open decisions, each with a suggestion and an answer slot | Always |
|
|
58
61
|
|
|
59
|
-
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the
|
|
62
|
+
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the six that follow are labels over lists rather than sections of prose.
|
|
60
63
|
- A section written in the other spelling is still that section. `## Risks` and `**Risks:**` name one thing, and a plan carrying either has stated its risks, so neither reads as an omission. Write the table's form in a new plan and leave an existing plan's spelling alone.
|
|
61
64
|
- Both spellings break a depth run, so `canon markdown audit` reads either as the seam it is and reports a plan for a section that genuinely runs long rather than for its whole body. The markdown standard states the rule.
|
|
62
65
|
- Write `None identified.` under a required section with nothing to report rather than dropping the marker. A dropped section and an unconsidered one read identically.
|
|
@@ -87,11 +90,23 @@ A dead constraint fails silently in the expensive direction. A session honoring
|
|
|
87
90
|
|
|
88
91
|
A constraint naming a `.canon/ready/` folder is a third shape beside the two above. It makes that folder's files the verbatim source for the paths this plan's `**Files to touch:**` lists, per `ready.md`, so the executing session copies those paths rather than authoring them.
|
|
89
92
|
|
|
93
|
+
### Verification
|
|
94
|
+
|
|
95
|
+
- Name, for each outcome, the test or the command that proves it, as one bullet leading with the outcome and ending on the command in backticks.
|
|
96
|
+
- Write a command a later session can run as given, from the project root. A description of what would be checked leaves the executing session to invent the check.
|
|
97
|
+
- Name the visual check where no test reaches, and say it is visual, so a reader can tell a proof from a look.
|
|
98
|
+
|
|
90
99
|
### Risks
|
|
91
100
|
|
|
92
101
|
- Name the collision rather than the category. A risk a reader cannot act on is padding.
|
|
93
102
|
- Where the work establishes a resource with more than one consumer, list the consumers and mark each one read or write. A policy stated over that resource has to hold for the writers and not only for the consumer that prompted it.
|
|
94
103
|
|
|
104
|
+
### Review focus
|
|
105
|
+
|
|
106
|
+
- Name each input or state that would break the change if the implementation got it wrong, one bullet each, with what the change must do there.
|
|
107
|
+
- Write it for the reviewer rather than the builder. The session writing the plan understood the risk before any diff existed, and this section is where that understanding reaches the review pass.
|
|
108
|
+
- Keep it apart from `**Risks:**`. A risk is something the work might collide with, and a focus item is something the finished diff must be shown to handle, so a reviewer confirms the second and the executing session plans around the first.
|
|
109
|
+
|
|
95
110
|
## The suggested-and-answer contract
|
|
96
111
|
|
|
97
112
|
Every question carries a `- Suggested:` line and an empty `- Answer:` slot below it.
|
|
@@ -165,10 +180,18 @@ This contract inverts the one an intake folder keeps, where an empty slot means
|
|
|
165
180
|
|
|
166
181
|
- `<path/to/file>`: <reason>
|
|
167
182
|
|
|
183
|
+
**Verification:**
|
|
184
|
+
|
|
185
|
+
- <outcome>: `<test or command that proves it>`
|
|
186
|
+
|
|
168
187
|
**Risks:**
|
|
169
188
|
|
|
170
189
|
- <conflict, coupling, or tricky spot>
|
|
171
190
|
|
|
191
|
+
**Review focus:**
|
|
192
|
+
|
|
193
|
+
- <input or state that would break the change>: <what the change must do there>
|
|
194
|
+
|
|
172
195
|
**Questions:**
|
|
173
196
|
|
|
174
197
|
1. <question>
|
package/src/context/narration.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { resolve } from 'node:path'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The headings a rule carries to publish the two sets.
|
|
7
|
-
*
|
|
8
|
-
* Discovery is anchored on these rather than on a filename for the reason
|
|
9
|
-
* `src/comments/vocabulary.ts` anchors its own: governance rules are numbered,
|
|
10
|
-
* and a renumber would empty the sets while the check kept reporting clean.
|
|
11
|
-
* Both open with `Narration` so neither loader can read the other's list.
|
|
12
|
-
*/
|
|
13
|
-
export const PRONOUN_HEADING = '## Narration pronouns'
|
|
14
|
-
export const VERB_HEADING = '## Narration verbs'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Roots searched in order. The installed copy wins over the toolkit source, so
|
|
18
|
-
* a target project measures against the rule it actually has rather than one
|
|
19
|
-
* only the toolkit carries.
|
|
20
|
-
*/
|
|
21
|
-
const RULE_ROOTS = ['.claude/rules', 'governance/rules']
|
|
22
|
-
|
|
23
|
-
export interface NarrationTerms {
|
|
24
|
-
/** Back-reference openings, matched at the start of a bullet and cased. */
|
|
25
|
-
readonly pronouns: readonly string[]
|
|
26
|
-
/** Past-tense verbs, matched anywhere in the bullet and uncased. */
|
|
27
|
-
readonly verbs: readonly string[]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Absent is a distinct state from empty, the split the comment vocabulary
|
|
32
|
-
* draws for the same reason: a scan with no terms finds nothing, and reporting
|
|
33
|
-
* that as zero findings claims the corpus is clean when nothing was looked for.
|
|
34
|
-
*/
|
|
35
|
-
export type Narration =
|
|
36
|
-
| ({ readonly kind: 'loaded'; readonly source: string } & NarrationTerms)
|
|
37
|
-
| { readonly kind: 'absent' }
|
|
38
|
-
|
|
39
|
-
/** Pulls the backticked terms out of the bullets under one heading. */
|
|
40
|
-
export function parseTerms(
|
|
41
|
-
markdown: string,
|
|
42
|
-
heading: string,
|
|
43
|
-
): string[] | undefined {
|
|
44
|
-
const lines = markdown.split('\n')
|
|
45
|
-
const start = lines.findIndex((line) => line.trim() === heading)
|
|
46
|
-
if (start === -1) return undefined
|
|
47
|
-
|
|
48
|
-
const terms: string[] = []
|
|
49
|
-
|
|
50
|
-
for (const line of lines.slice(start + 1)) {
|
|
51
|
-
if (line.startsWith('## ')) break
|
|
52
|
-
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
53
|
-
const term = match[1].trim()
|
|
54
|
-
if (term && !terms.includes(term)) terms.push(term)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return terms
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Finds the rule publishing both sets under `root`.
|
|
63
|
-
*
|
|
64
|
-
* Both come from one file rather than from whichever rule carries each. A
|
|
65
|
-
* pronoun set and a verb set are halves of one signal, and pairing them across
|
|
66
|
-
* files would let a renumber recombine them into a signal nobody wrote.
|
|
67
|
-
*/
|
|
68
|
-
export async function loadNarration(root: string): Promise<Narration> {
|
|
69
|
-
for (const ruleRoot of RULE_ROOTS) {
|
|
70
|
-
const dir = resolve(root, ruleRoot)
|
|
71
|
-
if (!existsSync(dir)) continue
|
|
72
|
-
|
|
73
|
-
const paths: string[] = []
|
|
74
|
-
for await (const rel of new Bun.Glob('**/*.md').scan({
|
|
75
|
-
cwd: dir,
|
|
76
|
-
onlyFiles: true,
|
|
77
|
-
})) {
|
|
78
|
-
paths.push(rel)
|
|
79
|
-
}
|
|
80
|
-
paths.sort()
|
|
81
|
-
|
|
82
|
-
for (const rel of paths) {
|
|
83
|
-
const source = await readFile(resolve(dir, rel), 'utf8')
|
|
84
|
-
const pronouns = parseTerms(source, PRONOUN_HEADING)
|
|
85
|
-
const verbs = parseTerms(source, VERB_HEADING)
|
|
86
|
-
|
|
87
|
-
if (pronouns && verbs && pronouns.length > 0 && verbs.length > 0) {
|
|
88
|
-
return {
|
|
89
|
-
kind: 'loaded',
|
|
90
|
-
source: `${ruleRoot}/${rel}`,
|
|
91
|
-
pronouns,
|
|
92
|
-
verbs,
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { kind: 'absent' }
|
|
99
|
-
}
|