@erclx/canon 5.16.0 → 5.18.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/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/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +2 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +9 -9
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -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/demo.ts +2 -0
- package/src/commands/teach.ts +3 -1
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/teach/workspace.ts +61 -6
- package/standards/architecture.md +13 -7
- package/standards/figures.md +1 -0
- package/standards/requirements.md +4 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -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/context.ts
CHANGED
|
@@ -24,8 +24,10 @@ import {
|
|
|
24
24
|
coveredCount,
|
|
25
25
|
isOverCount,
|
|
26
26
|
isOverLength,
|
|
27
|
+
isOverRisks,
|
|
27
28
|
measureArchitecture,
|
|
28
29
|
missingRevisit,
|
|
30
|
+
overWords,
|
|
29
31
|
testableCount,
|
|
30
32
|
} from '@/context/architecture'
|
|
31
33
|
import { auditCitations, type CitationReport } from '@/context/citations'
|
|
@@ -56,6 +58,12 @@ import {
|
|
|
56
58
|
resolveFolders,
|
|
57
59
|
} from '@/context/folders'
|
|
58
60
|
import { isGating } from '@/context/gate'
|
|
61
|
+
import {
|
|
62
|
+
isOverWordCap,
|
|
63
|
+
measureRequirements,
|
|
64
|
+
requirementsRel,
|
|
65
|
+
type RequirementsReport,
|
|
66
|
+
} from '@/context/requirements'
|
|
59
67
|
import { auditIndexes, type FolderDrift } from '@/context/index-drift'
|
|
60
68
|
import {
|
|
61
69
|
measureWireframeFolder,
|
|
@@ -96,7 +104,7 @@ export function register(program: Command): void {
|
|
|
96
104
|
context
|
|
97
105
|
.command('audit')
|
|
98
106
|
.description(
|
|
99
|
-
'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling,
|
|
107
|
+
'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling, entry cap, word cap, and risk cap, the requirements record against its own word cap, and wireframe states against their evidence folders',
|
|
100
108
|
)
|
|
101
109
|
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
102
110
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -125,8 +133,10 @@ export function register(program: Command): void {
|
|
|
125
133
|
'measures it. A record stating an entry cap gates the same way when',
|
|
126
134
|
'it holds more decisions than the cap, and a record stating that',
|
|
127
135
|
'every decision closes with a revisit sentence gates when one lacks',
|
|
128
|
-
'it. A record stating
|
|
129
|
-
'
|
|
136
|
+
'it. A record stating a word cap a decision or a risk bullet cap',
|
|
137
|
+
'gates past either, and a requirements record stating a word cap',
|
|
138
|
+
'gates past it. A record stating none of these is reported and',
|
|
139
|
+
'never gated. --gate widens the gate to the other two',
|
|
130
140
|
'findings that are facts rather than judgments: a missing required',
|
|
131
141
|
'section and index drift. A context entry requires Overview and',
|
|
132
142
|
'Layout, and a wireframe requires Regions, States, Copy, and Not on',
|
|
@@ -658,6 +668,7 @@ async function runAudit(
|
|
|
658
668
|
// record. A run that never looked and a project with nothing to look at are
|
|
659
669
|
// different answers, and one value for both reports the second as the first.
|
|
660
670
|
const record = gateOnly ? undefined : await measureArchitecture(root)
|
|
671
|
+
const requirements = gateOnly ? undefined : await measureRequirements(root)
|
|
661
672
|
const wireframes = gateOnly
|
|
662
673
|
? []
|
|
663
674
|
: (
|
|
@@ -681,6 +692,7 @@ async function runAudit(
|
|
|
681
692
|
reportProvenance(entries, folders)
|
|
682
693
|
reportDrift(drift)
|
|
683
694
|
reportRecord(record, root)
|
|
695
|
+
reportRequirements(requirements, root)
|
|
684
696
|
reportWireframeStates(wireframes)
|
|
685
697
|
outro()
|
|
686
698
|
}
|
|
@@ -719,6 +731,8 @@ async function runAudit(
|
|
|
719
731
|
// target that never wrote one is entitled to. Absent says the run
|
|
720
732
|
// never looked, which is `--citations-only`.
|
|
721
733
|
architecture: gateOnly ? undefined : (record ?? null),
|
|
734
|
+
// The same three states for the requirements record.
|
|
735
|
+
requirements: gateOnly ? undefined : (requirements ?? null),
|
|
722
736
|
// Absent under `--citations-only`, for the same reason as above. An
|
|
723
737
|
// empty array under the ordinary run says the project carries no
|
|
724
738
|
// wireframes folder or no entry carrying a States table, which is a
|
|
@@ -744,6 +758,10 @@ async function runAudit(
|
|
|
744
758
|
recordOverCount: record !== undefined && isOverCount(record),
|
|
745
759
|
recordMissingRevisit:
|
|
746
760
|
record !== undefined && missingRevisit(record).length > 0,
|
|
761
|
+
recordOverWords: record !== undefined && overWords(record).length > 0,
|
|
762
|
+
recordOverRisks: record !== undefined && isOverRisks(record),
|
|
763
|
+
requirementsOverWords:
|
|
764
|
+
requirements !== undefined && isOverWordCap(requirements),
|
|
747
765
|
sections,
|
|
748
766
|
drift,
|
|
749
767
|
wireframes,
|
|
@@ -1152,11 +1170,44 @@ function reportRecord(
|
|
|
1152
1170
|
const { allowances } = report
|
|
1153
1171
|
|
|
1154
1172
|
logInfo(
|
|
1155
|
-
`${plural(report.words, 'word')} across ${plural(report.lines, 'line')}
|
|
1173
|
+
`${plural(report.words, 'word')} across ${plural(report.lines, 'line')}. The whole file's count never gates.`,
|
|
1156
1174
|
)
|
|
1157
1175
|
if (report.risksWords !== undefined) {
|
|
1158
1176
|
logInfo(
|
|
1159
|
-
`\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}
|
|
1177
|
+
`\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}.`,
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const longDecisions = overWords(report)
|
|
1182
|
+
if (report.wordCap === undefined) {
|
|
1183
|
+
logInfo(
|
|
1184
|
+
'No word cap a decision stated, so each decision word count is reported and never gated.',
|
|
1185
|
+
)
|
|
1186
|
+
} else if (longDecisions.length > 0) {
|
|
1187
|
+
for (const entry of longDecisions) {
|
|
1188
|
+
logError(
|
|
1189
|
+
`"${entry.heading}" holds ${entry.words} words against a cap of ${report.wordCap} a decision.`,
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
} else {
|
|
1193
|
+
logInfo(
|
|
1194
|
+
`Every decision fits the cap of ${report.wordCap} words a decision.`,
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (report.riskCap === undefined) {
|
|
1199
|
+
logInfo('No risk bullet cap stated, so the Risks section is never gated.')
|
|
1200
|
+
} else if (report.risksBullets === undefined) {
|
|
1201
|
+
logWarn(
|
|
1202
|
+
`The record states a cap of ${report.riskCap} risk bullets and carries no \`## Risks / open questions\` section to count.`,
|
|
1203
|
+
)
|
|
1204
|
+
} else if (isOverRisks(report)) {
|
|
1205
|
+
logError(
|
|
1206
|
+
`${plural(report.risksBullets, 'risk bullet')} against a cap of ${report.riskCap}, nested bullets counted.`,
|
|
1207
|
+
)
|
|
1208
|
+
} else {
|
|
1209
|
+
logInfo(
|
|
1210
|
+
`${plural(report.risksBullets, 'risk bullet')} against a cap of ${report.riskCap}, nested bullets counted.`,
|
|
1160
1211
|
)
|
|
1161
1212
|
}
|
|
1162
1213
|
|
|
@@ -1257,6 +1308,35 @@ function reportRecord(
|
|
|
1257
1308
|
)
|
|
1258
1309
|
}
|
|
1259
1310
|
|
|
1311
|
+
/** Reports the requirements record against the word cap it states, if any. */
|
|
1312
|
+
function reportRequirements(
|
|
1313
|
+
report: RequirementsReport | undefined,
|
|
1314
|
+
root: string,
|
|
1315
|
+
): void {
|
|
1316
|
+
logStep('Requirements record')
|
|
1317
|
+
|
|
1318
|
+
if (report === undefined) {
|
|
1319
|
+
logInfo(
|
|
1320
|
+
`Out of scope. The project carries no ${requirementsRel(root)}, so there was no record to measure.`,
|
|
1321
|
+
)
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (report.wordCap === undefined) {
|
|
1326
|
+
logInfo(
|
|
1327
|
+
`${plural(report.words, 'word')} in ${report.rel} and no word cap stated, so the count is reported and never gated.`,
|
|
1328
|
+
)
|
|
1329
|
+
} else if (isOverWordCap(report)) {
|
|
1330
|
+
logError(
|
|
1331
|
+
`${plural(report.words, 'word')} against a cap of ${report.wordCap} in ${report.rel}.`,
|
|
1332
|
+
)
|
|
1333
|
+
} else {
|
|
1334
|
+
logInfo(
|
|
1335
|
+
`${plural(report.words, 'word')} against a cap of ${report.wordCap} in ${report.rel}.`,
|
|
1336
|
+
)
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1260
1340
|
function reportDrift(drift: readonly FolderDrift[]): void {
|
|
1261
1341
|
logStep('Index drift')
|
|
1262
1342
|
|
package/src/commands/demo.ts
CHANGED
|
@@ -333,6 +333,7 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
333
333
|
|
|
334
334
|
if (result.videoPath) logInfo(display(result.videoPath))
|
|
335
335
|
if (result.stillPath) logInfo(`${display(result.stillPath)} still`)
|
|
336
|
+
if (result.timelinePath) logInfo(`${display(result.timelinePath)} timeline`)
|
|
336
337
|
logInfo(
|
|
337
338
|
`${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
|
|
338
339
|
)
|
|
@@ -380,6 +381,7 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
380
381
|
mp4: mp4Path ?? null,
|
|
381
382
|
gif: gifPath ?? null,
|
|
382
383
|
still: result.stillPath ?? null,
|
|
384
|
+
timeline: result.timelinePath ?? null,
|
|
383
385
|
steps: result.steps,
|
|
384
386
|
durationMs: result.durationMs,
|
|
385
387
|
...(mp4Reason ? { mp4Reason } : {}),
|
package/src/commands/teach.ts
CHANGED
|
@@ -237,7 +237,9 @@ export function register(program: Command): void {
|
|
|
237
237
|
'only place a later session reads that from.',
|
|
238
238
|
'',
|
|
239
239
|
'A URL already listed under either heading is refused rather than',
|
|
240
|
-
'repeated, because two entries split what rests on one source.',
|
|
240
|
+
'repeated, because two entries split what rests on one source. The one',
|
|
241
|
+
'exception is a --read URL listed under Leads, which moves to Read under',
|
|
242
|
+
'its new title.',
|
|
241
243
|
'',
|
|
242
244
|
'Examples:',
|
|
243
245
|
' canon teach resource regular-expressions --read "MDN regular expressions=https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions"',
|
|
@@ -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
|
+
}
|