@erclx/canon 5.17.0 → 5.19.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +3 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/skill-map.md +9 -9
- package/package.json +2 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +13 -7
- package/standards/requirements.md +4 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
package/src/commands/teach.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
2
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { LiveStream } from '@/serve/live'
|
|
3
5
|
import {
|
|
4
6
|
logServing,
|
|
5
7
|
parsePort,
|
|
6
8
|
serveFields,
|
|
7
9
|
waitForInterrupt,
|
|
8
10
|
} from '@/serve/report'
|
|
9
|
-
import { DEFAULT_PORT, startServer } from '@/serve/static'
|
|
11
|
+
import { DEFAULT_PORT, LIVE_EVENTS_PATH, startServer } from '@/serve/static'
|
|
12
|
+
import { compileScript } from '@/teach/browser/compile'
|
|
13
|
+
import { reload } from '@/teach/browser/reload'
|
|
10
14
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
11
15
|
import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
|
|
12
16
|
import type { RenderOutcome } from '@/teach/render'
|
|
@@ -942,8 +946,17 @@ async function runUp(
|
|
|
942
946
|
? 'index.html'
|
|
943
947
|
: relative(dir, resolve(root, contents)).split(sep).join('/')
|
|
944
948
|
|
|
945
|
-
const
|
|
949
|
+
const stream = refreshOnChange(root, dir, topic, emitJson)
|
|
950
|
+
const served = startServer(dir, {
|
|
951
|
+
port,
|
|
952
|
+
entry,
|
|
953
|
+
live: {
|
|
954
|
+
stream,
|
|
955
|
+
script: compileScript(reload, [LIVE_EVENTS_PATH]),
|
|
956
|
+
},
|
|
957
|
+
})
|
|
946
958
|
if (!served.ok) {
|
|
959
|
+
stream.close()
|
|
947
960
|
// A serve refusal carries a reason the teach refusals do not list, so it
|
|
948
961
|
// is written here in the teach record's shape rather than through one.
|
|
949
962
|
if (emitJson) {
|
|
@@ -970,10 +983,105 @@ async function runUp(
|
|
|
970
983
|
logServing(served)
|
|
971
984
|
}
|
|
972
985
|
|
|
973
|
-
await waitForInterrupt(
|
|
986
|
+
await waitForInterrupt(async () => {
|
|
987
|
+
stream.close()
|
|
988
|
+
await served.stop()
|
|
989
|
+
})
|
|
974
990
|
return 0
|
|
975
991
|
}
|
|
976
992
|
|
|
993
|
+
/**
|
|
994
|
+
* An editor's swap or backup file, or anything under a dot folder. It changes
|
|
995
|
+
* nothing a page shows, so it neither starts a pass nor counts as served.
|
|
996
|
+
*/
|
|
997
|
+
function isScratch(path: string): boolean {
|
|
998
|
+
return path
|
|
999
|
+
.split(/[\\/]/)
|
|
1000
|
+
.some((segment) => segment.startsWith('.') || segment.endsWith('~'))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* What the served folder holds, as each file's path, size, and mtime. A pass
|
|
1005
|
+
* whose `nav` wrote nothing leaves it unchanged, which is how the echo of a
|
|
1006
|
+
* pass's own writes settles without a second reload.
|
|
1007
|
+
*/
|
|
1008
|
+
function servedState(dir: string): string {
|
|
1009
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
1010
|
+
.filter((entry) => entry.isFile())
|
|
1011
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)))
|
|
1012
|
+
.filter((path) => !isScratch(path))
|
|
1013
|
+
.sort()
|
|
1014
|
+
.map((path) => {
|
|
1015
|
+
const stat = statSync(join(dir, path), { throwIfNoEntry: false })
|
|
1016
|
+
return `${path}:${stat?.size}:${stat?.mtimeMs}`
|
|
1017
|
+
})
|
|
1018
|
+
.join('\n')
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Re-runs `nav` on each settled change under the teach folder and tells every
|
|
1023
|
+
* open page to reload once a pass changed what is served. Passes run one at a
|
|
1024
|
+
* time, since `nav` is not reentrant over the files it rewrites, and a change
|
|
1025
|
+
* arriving mid-pass is coalesced into exactly one more. A refused or failed
|
|
1026
|
+
* pass sends no reload, so a page never reloads onto a half-written state.
|
|
1027
|
+
*/
|
|
1028
|
+
function refreshOnChange(
|
|
1029
|
+
root: string,
|
|
1030
|
+
dir: string,
|
|
1031
|
+
topic: string | undefined,
|
|
1032
|
+
emitJson: boolean,
|
|
1033
|
+
): LiveStream<string> {
|
|
1034
|
+
let isRunning = false
|
|
1035
|
+
let isDirty = false
|
|
1036
|
+
let state = servedState(dir)
|
|
1037
|
+
|
|
1038
|
+
const report = (message: string, isWarning: boolean) => {
|
|
1039
|
+
if (emitJson) process.stderr.write(`${message}\n`)
|
|
1040
|
+
else if (isWarning) logWarn(message)
|
|
1041
|
+
else logInfo(message)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const pass = async (): Promise<void> => {
|
|
1045
|
+
const nav = await generateNav(root, topic)
|
|
1046
|
+
if (!nav.ok) {
|
|
1047
|
+
report(`Refresh refused: ${nav.message}`, true)
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
const next = servedState(dir)
|
|
1051
|
+
if (next === state) return
|
|
1052
|
+
state = next
|
|
1053
|
+
stream.send({ reload: true })
|
|
1054
|
+
report(`Refreshed ${nav.lessons} lessons, reloading open pages`, false)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const schedule = async (): Promise<void> => {
|
|
1058
|
+
if (isRunning) {
|
|
1059
|
+
isDirty = true
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
isRunning = true
|
|
1063
|
+
do {
|
|
1064
|
+
isDirty = false
|
|
1065
|
+
// A throw, such as the teach folder removed mid-pass, is reported and
|
|
1066
|
+
// the loop carries on, since a rejection left here would end the
|
|
1067
|
+
// process and a stuck flag would end every later refresh.
|
|
1068
|
+
try {
|
|
1069
|
+
await pass()
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
const code = (error as NodeJS.ErrnoException).code ?? String(error)
|
|
1072
|
+
report(`Refresh failed: ${code}`, true)
|
|
1073
|
+
}
|
|
1074
|
+
} while (isDirty)
|
|
1075
|
+
isRunning = false
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const stream = new LiveStream<string>(dir, {
|
|
1079
|
+
map: (path) => (isScratch(path) ? undefined : path),
|
|
1080
|
+
onChange: () => void schedule(),
|
|
1081
|
+
})
|
|
1082
|
+
return stream
|
|
1083
|
+
}
|
|
1084
|
+
|
|
977
1085
|
function reportRefusal(
|
|
978
1086
|
title: string,
|
|
979
1087
|
refused: TeachRefused,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { AUDITS } from '@/audits/catalog'
|
|
4
|
-
import { bodyLines } from '@/markdown/scan'
|
|
4
|
+
import { type BodyLine, bodyLines } from '@/markdown/scan'
|
|
5
5
|
import { surfaceDir } from '@/surface-root'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -58,9 +58,9 @@ export interface DecisionReport {
|
|
|
58
58
|
*/
|
|
59
59
|
readonly checks: readonly string[]
|
|
60
60
|
/**
|
|
61
|
-
* The entry's
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* The entry's prose word count, its heading and fenced lines left out. Gates
|
|
62
|
+
* only against a per-decision cap the record states, so the report and the
|
|
63
|
+
* stage read one number.
|
|
64
64
|
*/
|
|
65
65
|
readonly words: number
|
|
66
66
|
/**
|
|
@@ -82,6 +82,12 @@ export interface ArchitectureReport {
|
|
|
82
82
|
* only what is still open, so its weight is read the same way the file's is.
|
|
83
83
|
*/
|
|
84
84
|
readonly risksWords?: number
|
|
85
|
+
/**
|
|
86
|
+
* List items in the Risks section, nested ones included, since a nested
|
|
87
|
+
* bullet is weight the reader pays for. Absent alongside `risksWords`, so a
|
|
88
|
+
* record with no section never reads as one holding zero bullets.
|
|
89
|
+
*/
|
|
90
|
+
readonly risksBullets?: number
|
|
85
91
|
/** What the record declared, absent when it states no length rule. */
|
|
86
92
|
readonly allowances?: Allowances
|
|
87
93
|
/** The frame plus the per-decision allowance, absent alongside it. */
|
|
@@ -92,6 +98,10 @@ export interface ArchitectureReport {
|
|
|
92
98
|
* project that never adopted a cap is measured and never gated.
|
|
93
99
|
*/
|
|
94
100
|
readonly entryCap?: number
|
|
101
|
+
/** The most words a decision may hold, absent when the record states none. */
|
|
102
|
+
readonly wordCap?: number
|
|
103
|
+
/** The most Risks bullets the record allows, absent when it states none. */
|
|
104
|
+
readonly riskCap?: number
|
|
95
105
|
/**
|
|
96
106
|
* Whether the record states that every decision closes with a revisit
|
|
97
107
|
* sentence. Declared by the record for the reason the cap is, so a record
|
|
@@ -103,7 +113,9 @@ export interface ArchitectureReport {
|
|
|
103
113
|
|
|
104
114
|
const DECISION_HEADING = /^###\s+(.+?)\s*$/
|
|
105
115
|
const SECTION_HEADING = /^##\s+\S/
|
|
106
|
-
/**
|
|
116
|
+
/** A markdown list item at any depth, which is what a risk bullet is. */
|
|
117
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\S/
|
|
118
|
+
/** The H2 whose bullets the standard's `## Caps` section counts. */
|
|
107
119
|
const RISKS_HEADING = /^##\s+Risks\s*\/\s*open questions\s*$/i
|
|
108
120
|
const CODE_SPAN = /`[^`]*`/g
|
|
109
121
|
/** Dropped ahead of the figure scan, since an anchor date is not a claim. */
|
|
@@ -291,13 +303,12 @@ export function splitDecisions(source: string): RawDecision[] {
|
|
|
291
303
|
}
|
|
292
304
|
|
|
293
305
|
/**
|
|
294
|
-
* Counts whitespace-delimited tokens,
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
* for by reading, the same trade the line count above it already takes.
|
|
306
|
+
* Counts whitespace-delimited tokens, the unit a record's word caps are stated
|
|
307
|
+
* in. A code span counts as the tokens it holds, so a decision citing a long
|
|
308
|
+
* path pays for it the way a reader does. A caller gating on the count hands
|
|
309
|
+
* in prose with fenced lines already dropped.
|
|
299
310
|
*/
|
|
300
|
-
function wordCount(text: string): number {
|
|
311
|
+
export function wordCount(text: string): number {
|
|
301
312
|
return text.match(/\S+/g)?.length ?? 0
|
|
302
313
|
}
|
|
303
314
|
|
|
@@ -310,14 +321,30 @@ function wordCount(text: string): number {
|
|
|
310
321
|
* when the section is last, which is where the standard's template puts it.
|
|
311
322
|
*/
|
|
312
323
|
export function risksSection(source: string): string | undefined {
|
|
313
|
-
|
|
314
|
-
|
|
324
|
+
return risksLines(source)
|
|
325
|
+
?.map((line) => line.text)
|
|
326
|
+
.join('\n')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Counts the list items in the Risks section, nested ones included, or
|
|
331
|
+
* nothing when the record carries no such heading. A fenced line is skipped,
|
|
332
|
+
* since a template bullet is not a risk.
|
|
333
|
+
*/
|
|
334
|
+
export function riskBullets(source: string): number | undefined {
|
|
335
|
+
return risksLines(source)?.filter(
|
|
336
|
+
(line) => !line.fenced && LIST_ITEM.test(line.text),
|
|
337
|
+
).length
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function risksLines(source: string): BodyLine[] | undefined {
|
|
341
|
+
const body: BodyLine[] = []
|
|
315
342
|
let capturing = false
|
|
316
343
|
let found = false
|
|
317
344
|
|
|
318
|
-
for (const line of
|
|
345
|
+
for (const line of bodyLines(source)) {
|
|
319
346
|
if (line.fenced) {
|
|
320
|
-
if (capturing) body.push(line
|
|
347
|
+
if (capturing) body.push(line)
|
|
321
348
|
continue
|
|
322
349
|
}
|
|
323
350
|
|
|
@@ -332,10 +359,10 @@ export function risksSection(source: string): string | undefined {
|
|
|
332
359
|
continue
|
|
333
360
|
}
|
|
334
361
|
|
|
335
|
-
if (capturing) body.push(line
|
|
362
|
+
if (capturing) body.push(line)
|
|
336
363
|
}
|
|
337
364
|
|
|
338
|
-
return found ? body
|
|
365
|
+
return found ? body : undefined
|
|
339
366
|
}
|
|
340
367
|
|
|
341
368
|
/** Cardinals a record spells rather than writes, which the corpus does for both. */
|
|
@@ -376,6 +403,14 @@ const ENTRY_CAP_CLAUSE = new RegExp(
|
|
|
376
403
|
String.raw`\bat\s+most\s+${CARDINAL}\s+decisions?\b`,
|
|
377
404
|
'i',
|
|
378
405
|
)
|
|
406
|
+
const WORD_CAP_CLAUSE = new RegExp(
|
|
407
|
+
String.raw`\bat\s+most\s+${CARDINAL}\s+words\s+a\s+decision\b`,
|
|
408
|
+
'i',
|
|
409
|
+
)
|
|
410
|
+
const RISK_CAP_CLAUSE = new RegExp(
|
|
411
|
+
String.raw`\bat\s+most\s+${CARDINAL}\s+risk\s+bullets?\b`,
|
|
412
|
+
'i',
|
|
413
|
+
)
|
|
379
414
|
|
|
380
415
|
function readCardinal(token: string | undefined): number | undefined {
|
|
381
416
|
if (token === undefined) return undefined
|
|
@@ -413,6 +448,20 @@ export function readEntryCap(source: string): number | undefined {
|
|
|
413
448
|
return readCardinal(source.match(ENTRY_CAP_CLAUSE)?.[1])
|
|
414
449
|
}
|
|
415
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Reads the per-decision word cap a record declares, spelled `at most <n>
|
|
453
|
+
* words a decision`, or nothing. One spelling, the way the line allowance
|
|
454
|
+
* reads `<n> lines a decision`, so a drift falls back to reporting.
|
|
455
|
+
*/
|
|
456
|
+
export function readWordCap(source: string): number | undefined {
|
|
457
|
+
return readCardinal(source.match(WORD_CAP_CLAUSE)?.[1])
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Reads the Risks cap a record declares, spelled `at most <n> risk bullets`. */
|
|
461
|
+
export function readRiskCap(source: string): number | undefined {
|
|
462
|
+
return readCardinal(source.match(RISK_CAP_CLAUSE)?.[1])
|
|
463
|
+
}
|
|
464
|
+
|
|
416
465
|
const REVISIT_CLAUSE =
|
|
417
466
|
/\bevery\s+decision\s+closes\s+with\s+a\s+revisit\s+sentence\b/i
|
|
418
467
|
/**
|
|
@@ -486,6 +535,8 @@ export async function measureArchitecture(
|
|
|
486
535
|
|
|
487
536
|
const allowances = readAllowances(source)
|
|
488
537
|
const entryCap = readEntryCap(source)
|
|
538
|
+
const wordCap = readWordCap(source)
|
|
539
|
+
const riskCap = readRiskCap(source)
|
|
489
540
|
const revisitRequired = readRevisitRule(source)
|
|
490
541
|
const raw = splitDecisions(source)
|
|
491
542
|
const decisions = await Promise.all(
|
|
@@ -498,13 +549,14 @@ export async function measureArchitecture(
|
|
|
498
549
|
figures,
|
|
499
550
|
...(quantified !== undefined && { quantified }),
|
|
500
551
|
checks: await namedChecks(root, entry.body),
|
|
501
|
-
words: wordCount(entry.
|
|
552
|
+
words: wordCount(entry.prose),
|
|
502
553
|
revisit: hasRevisitSentence(entry.prose),
|
|
503
554
|
}
|
|
504
555
|
}),
|
|
505
556
|
)
|
|
506
557
|
|
|
507
558
|
const risks = risksSection(source)
|
|
559
|
+
const bullets = riskBullets(source)
|
|
508
560
|
|
|
509
561
|
return {
|
|
510
562
|
rel,
|
|
@@ -515,11 +567,14 @@ export async function measureArchitecture(
|
|
|
515
567
|
.join('\n'),
|
|
516
568
|
),
|
|
517
569
|
...(risks !== undefined && { risksWords: wordCount(risks) }),
|
|
570
|
+
...(bullets !== undefined && { risksBullets: bullets }),
|
|
518
571
|
...(allowances !== undefined && {
|
|
519
572
|
allowances,
|
|
520
573
|
ceiling: ceilingFor(allowances, raw.length),
|
|
521
574
|
}),
|
|
522
575
|
...(entryCap !== undefined && { entryCap }),
|
|
576
|
+
...(wordCap !== undefined && { wordCap }),
|
|
577
|
+
...(riskCap !== undefined && { riskCap }),
|
|
523
578
|
revisitRequired,
|
|
524
579
|
decisions,
|
|
525
580
|
}
|
|
@@ -538,6 +593,31 @@ export function isOverCount(report: ArchitectureReport): boolean {
|
|
|
538
593
|
)
|
|
539
594
|
}
|
|
540
595
|
|
|
596
|
+
/**
|
|
597
|
+
* The decisions holding more prose words than the cap the record states.
|
|
598
|
+
*
|
|
599
|
+
* Empty for a record stating no word cap, for the reason `isOverCount` gives.
|
|
600
|
+
*/
|
|
601
|
+
export function overWords(report: ArchitectureReport): DecisionReport[] {
|
|
602
|
+
const cap = report.wordCap
|
|
603
|
+
if (cap === undefined) return []
|
|
604
|
+
return report.decisions.filter((entry) => entry.words > cap)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Whether the Risks section holds more bullets than the cap the record states.
|
|
609
|
+
*
|
|
610
|
+
* False for a record stating no cap, and false for one carrying no Risks
|
|
611
|
+
* section, which the stage reports as absent rather than as zero bullets.
|
|
612
|
+
*/
|
|
613
|
+
export function isOverRisks(report: ArchitectureReport): boolean {
|
|
614
|
+
return (
|
|
615
|
+
report.riskCap !== undefined &&
|
|
616
|
+
report.risksBullets !== undefined &&
|
|
617
|
+
report.risksBullets > report.riskCap
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
541
621
|
/**
|
|
542
622
|
* The headings of decisions lacking a revisit sentence the record requires.
|
|
543
623
|
*
|
package/src/context/gate.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface GateInput {
|
|
|
25
25
|
* same two cases, plus a record stating no such clause.
|
|
26
26
|
*/
|
|
27
27
|
readonly recordMissingRevisit: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Whether a decision holds more words than the cap the record states, which
|
|
30
|
+
* gates under the same two modes and is false in the same two cases, plus a
|
|
31
|
+
* record stating no word cap.
|
|
32
|
+
*/
|
|
33
|
+
readonly recordOverWords: boolean
|
|
34
|
+
/** The same reading for the Risks section's bullet cap. */
|
|
35
|
+
readonly recordOverRisks: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Whether the requirements record holds more words than the cap it states,
|
|
38
|
+
* which gates for the reason the architecture record's caps do.
|
|
39
|
+
*/
|
|
40
|
+
readonly requirementsOverWords: boolean
|
|
28
41
|
readonly sections: readonly SectionFinding[]
|
|
29
42
|
readonly drift: readonly FolderDrift[]
|
|
30
43
|
/**
|
|
@@ -79,8 +92,10 @@ export function hasSketchWithEvidence(
|
|
|
79
92
|
* Whether the audit found something that should fail the caller.
|
|
80
93
|
*
|
|
81
94
|
* An unresolved citation is a broken pointer and gates unconditionally, and so
|
|
82
|
-
* does a record past its own ceiling
|
|
83
|
-
* revisit sentence it requires
|
|
95
|
+
* does a record past its own ceiling, entry cap, word cap, or bullet cap, or
|
|
96
|
+
* missing a revisit sentence it requires, and a requirements record past its
|
|
97
|
+
* own word cap: each record states its rules for itself, which makes those the
|
|
98
|
+
* measures here that are facts
|
|
84
99
|
* rather than thresholds a reader weighs. The findings `--gate` adds are
|
|
85
100
|
* the ones answerable from the file itself: a required section it does not
|
|
86
101
|
* declare, an index disagreeing with its folder, and a wireframe's States
|
|
@@ -95,13 +110,25 @@ export function isGating({
|
|
|
95
110
|
recordOverLength,
|
|
96
111
|
recordOverCount,
|
|
97
112
|
recordMissingRevisit,
|
|
113
|
+
recordOverWords,
|
|
114
|
+
recordOverRisks,
|
|
115
|
+
requirementsOverWords,
|
|
98
116
|
sections,
|
|
99
117
|
drift,
|
|
100
118
|
wireframes,
|
|
101
119
|
widened,
|
|
102
120
|
}: GateInput): boolean {
|
|
103
121
|
if (unresolvedCitations > 0) return true
|
|
104
|
-
if (
|
|
122
|
+
if (
|
|
123
|
+
recordOverLength ||
|
|
124
|
+
recordOverCount ||
|
|
125
|
+
recordMissingRevisit ||
|
|
126
|
+
recordOverWords ||
|
|
127
|
+
recordOverRisks ||
|
|
128
|
+
requirementsOverWords
|
|
129
|
+
) {
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
105
132
|
if (!widened) return false
|
|
106
133
|
|
|
107
134
|
return sections.length > 0 || hasDrift(drift) || hasStatesMismatch(wireframes)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { wordCount } from '@/context/architecture'
|
|
4
|
+
import { bodyLines } from '@/markdown/scan'
|
|
5
|
+
import { surfaceDir } from '@/surface-root'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The record this measures, relative to `root`, resolved at either surface
|
|
9
|
+
* root for the reason `architectureRel` gives.
|
|
10
|
+
*/
|
|
11
|
+
export function requirementsRel(root: string): string {
|
|
12
|
+
return relative(root, surfaceDir(root, 'REQUIREMENTS.md'))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RequirementsReport {
|
|
16
|
+
readonly rel: string
|
|
17
|
+
/** The whole record's word count, frontmatter left out. */
|
|
18
|
+
readonly words: number
|
|
19
|
+
/**
|
|
20
|
+
* The most words the record says it holds, absent when it states no cap.
|
|
21
|
+
* Declared by the record rather than held here, so a project that never
|
|
22
|
+
* adopted a cap is measured and never gated.
|
|
23
|
+
*/
|
|
24
|
+
readonly wordCap?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WORD_CAP_CLAUSE = /\bat\s+most\s+(\d+)\s+words\b/i
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads the word cap a record declares, spelled `at most <n> words`, or
|
|
31
|
+
* nothing. Digits only, since a cap in the hundreds is never spelled out.
|
|
32
|
+
*/
|
|
33
|
+
export function readRequirementsWordCap(source: string): number | undefined {
|
|
34
|
+
const digits = source.match(WORD_CAP_CLAUSE)?.[1]
|
|
35
|
+
return digits === undefined ? undefined : Number.parseInt(digits, 10)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isMissing(error: unknown): boolean {
|
|
39
|
+
const code = (error as { code?: unknown }).code
|
|
40
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Measures the record, or reports nothing when the project carries none.
|
|
45
|
+
*
|
|
46
|
+
* Absent rather than zeroed, for the reason `measureArchitecture` gives, and a
|
|
47
|
+
* record present and unreadable propagates the same way.
|
|
48
|
+
*/
|
|
49
|
+
export async function measureRequirements(
|
|
50
|
+
root: string,
|
|
51
|
+
): Promise<RequirementsReport | undefined> {
|
|
52
|
+
const rel = requirementsRel(root)
|
|
53
|
+
|
|
54
|
+
let source: string
|
|
55
|
+
try {
|
|
56
|
+
source = await readFile(join(root, rel), 'utf8')
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!isMissing(error)) throw error
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const wordCap = readRequirementsWordCap(source)
|
|
63
|
+
return {
|
|
64
|
+
rel,
|
|
65
|
+
words: wordCount(
|
|
66
|
+
bodyLines(source)
|
|
67
|
+
.map((line) => line.text)
|
|
68
|
+
.join('\n'),
|
|
69
|
+
),
|
|
70
|
+
...(wordCap !== undefined && { wordCap }),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the record holds more words than the cap it states. */
|
|
75
|
+
export function isOverWordCap(report: RequirementsReport): boolean {
|
|
76
|
+
return report.wordCap !== undefined && report.words > report.wordCap
|
|
77
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
isOverCount,
|
|
12
12
|
isOverLength,
|
|
13
|
+
isOverRisks,
|
|
13
14
|
measureArchitecture,
|
|
14
15
|
missingRevisit,
|
|
16
|
+
overWords,
|
|
15
17
|
} from '@/context/architecture'
|
|
18
|
+
import { isOverWordCap, measureRequirements } from '@/context/requirements'
|
|
16
19
|
import {
|
|
17
20
|
auditSkills,
|
|
18
21
|
PRACTICE_LEDGER,
|
|
@@ -246,42 +249,80 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
|
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
/**
|
|
249
|
-
* The
|
|
250
|
-
* line ceiling its allowances derive,
|
|
251
|
-
* closing each decision
|
|
252
|
+
* The canonical records against the caps each states for itself. The
|
|
253
|
+
* architecture record carries five: the line ceiling its allowances derive,
|
|
254
|
+
* the entry cap, the revisit sentence closing each decision, the words a
|
|
255
|
+
* decision may hold, and the bullets its Risks section may hold. The
|
|
256
|
+
* requirements record carries one, its own word count.
|
|
252
257
|
*
|
|
253
258
|
* Read in-process rather than through `context audit`, whose one gating stage
|
|
254
|
-
* here runs `--citations-only` and never opens
|
|
255
|
-
* line ceiling went unenforced by `bun run check` until this stage. A
|
|
256
|
-
* carrying
|
|
257
|
-
* each rule belongs to the record rather than to the toolkit.
|
|
259
|
+
* here runs `--citations-only` and never opens either record, which is why
|
|
260
|
+
* the line ceiling went unenforced by `bun run check` until this stage. A
|
|
261
|
+
* project carrying neither record, or records stating none of the caps,
|
|
262
|
+
* passes, since each rule belongs to the record rather than to the toolkit.
|
|
258
263
|
*/
|
|
259
|
-
export const
|
|
260
|
-
const report = await
|
|
264
|
+
export const canonicalRecords: Measure = async (ctx) => {
|
|
265
|
+
const [report, requirements] = await Promise.all([
|
|
266
|
+
measureArchitecture(ctx.root),
|
|
267
|
+
measureRequirements(ctx.root),
|
|
268
|
+
])
|
|
269
|
+
|
|
270
|
+
const failures: string[] = []
|
|
271
|
+
const emissions: Emission[] = []
|
|
272
|
+
|
|
261
273
|
if (report === undefined) {
|
|
262
|
-
|
|
274
|
+
emissions.push(info('No architecture record to measure'))
|
|
275
|
+
} else {
|
|
276
|
+
const decisions = report.decisions.length
|
|
277
|
+
const lackingRevisit = missingRevisit(report)
|
|
278
|
+
const longDecisions = overWords(report)
|
|
279
|
+
failures.push(
|
|
280
|
+
...[
|
|
281
|
+
isOverCount(report) &&
|
|
282
|
+
`${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
|
|
283
|
+
isOverLength(report) &&
|
|
284
|
+
`${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
|
|
285
|
+
lackingRevisit.length > 0 &&
|
|
286
|
+
`No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
|
|
287
|
+
longDecisions.length > 0 &&
|
|
288
|
+
`${longDecisions.map((entry) => `"${entry.heading}" (${entry.words})`).join(', ')} past ${report.wordCap} words a decision in ${report.rel}. Cut the reasoning to the trade, never split one decision across two headings.`,
|
|
289
|
+
isOverRisks(report) &&
|
|
290
|
+
`${report.risksBullets} risk bullets against a cap of ${report.riskCap} in ${report.rel}. Drop what is no longer open.`,
|
|
291
|
+
].filter((failure): failure is string => typeof failure === 'string'),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const cap =
|
|
295
|
+
report.entryCap === undefined
|
|
296
|
+
? 'no entry cap stated'
|
|
297
|
+
: `a cap of ${report.entryCap}`
|
|
298
|
+
emissions.push(info(`${decisions} decisions against ${cap}`))
|
|
299
|
+
if (report.riskCap !== undefined && report.risksBullets === undefined) {
|
|
300
|
+
emissions.push(
|
|
301
|
+
info(
|
|
302
|
+
`No Risks section to count against a cap of ${report.riskCap} in ${report.rel}`,
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
263
306
|
}
|
|
264
307
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
`${
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
308
|
+
if (requirements === undefined) {
|
|
309
|
+
emissions.push(info('No requirements record to measure'))
|
|
310
|
+
} else if (isOverWordCap(requirements)) {
|
|
311
|
+
failures.push(
|
|
312
|
+
`${requirements.words} words against a cap of ${requirements.wordCap} in ${requirements.rel}.`,
|
|
313
|
+
)
|
|
314
|
+
} else {
|
|
315
|
+
const cap =
|
|
316
|
+
requirements.wordCap === undefined
|
|
317
|
+
? 'no word cap stated'
|
|
318
|
+
: `a cap of ${requirements.wordCap}`
|
|
319
|
+
emissions.push(
|
|
320
|
+
info(`${requirements.words} requirements words against ${cap}`),
|
|
321
|
+
)
|
|
322
|
+
}
|
|
275
323
|
|
|
276
324
|
if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
|
|
277
|
-
|
|
278
|
-
const cap =
|
|
279
|
-
report.entryCap === undefined
|
|
280
|
-
? 'no entry cap stated'
|
|
281
|
-
: `a cap of ${report.entryCap}`
|
|
282
|
-
return {
|
|
283
|
-
emissions: [info(`${decisions} decisions against ${cap}`)],
|
|
284
|
-
}
|
|
325
|
+
return { emissions }
|
|
285
326
|
}
|
|
286
327
|
|
|
287
328
|
/**
|
package/src/gate/stages.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
skillPaths,
|
|
7
7
|
} from '@/gate/boundaries'
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
canonicalRecords,
|
|
10
10
|
auditSet,
|
|
11
11
|
captureStamps,
|
|
12
12
|
clientCommandCitations,
|
|
@@ -346,11 +346,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
346
346
|
success: 'Context citations resolve',
|
|
347
347
|
},
|
|
348
348
|
{
|
|
349
|
-
//
|
|
349
|
+
// Each record's caps are facts it states about itself, unlike the
|
|
350
350
|
// judgment thresholds the stage above leaves out, so they gate here.
|
|
351
|
-
id: '
|
|
352
|
-
label: '
|
|
353
|
-
checks: [{ kind: 'measure', measure:
|
|
351
|
+
id: 'canonical-records',
|
|
352
|
+
label: 'Canonical records',
|
|
353
|
+
checks: [{ kind: 'measure', measure: canonicalRecords }],
|
|
354
354
|
},
|
|
355
355
|
{
|
|
356
356
|
// Unscoped, since any branch can grow any document. A document past the
|