@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.
Files changed (57) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  3. package/claude/skills/deploy-app/SKILL.md +45 -0
  4. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  5. package/claude/skills/deploy-app/references/vercel.md +72 -0
  6. package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
  7. package/claude/skills/draft-figure/SKILL.md +19 -4
  8. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  9. package/claude/skills/teach-workspace/SKILL.md +1 -1
  10. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  11. package/docs/agents/architecture-record.md +12 -4
  12. package/docs/agents/canvas.md +13 -1
  13. package/docs/agents/commands.md +2 -2
  14. package/docs/agents/context-audit-checks.md +1 -1
  15. package/docs/agents/context-audit.md +4 -4
  16. package/docs/agents/demo.md +3 -1
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/teach.md +2 -2
  19. package/docs/workflow/skill-map.md +9 -9
  20. package/docs/workflow/visual-design-workflow.md +8 -15
  21. package/governance/rules/claude/566-output.md +3 -5
  22. package/governance/rules/claude/575-hooks.md +1 -1
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +46 -1
  25. package/src/canvas/address.ts +73 -39
  26. package/src/canvas/client/inspector.tsx +241 -33
  27. package/src/canvas/client/pages-panel.tsx +51 -0
  28. package/src/canvas/client/shell.css +126 -1
  29. package/src/canvas/client/state.ts +120 -4
  30. package/src/canvas/client/theme-panel.tsx +55 -0
  31. package/src/canvas/content.ts +14 -3
  32. package/src/canvas/edit.ts +360 -0
  33. package/src/canvas/server.ts +50 -5
  34. package/src/canvas/tokens.ts +82 -0
  35. package/src/claude/cases/misc.ts +5 -1
  36. package/src/commands/canvas.ts +95 -1
  37. package/src/commands/context.ts +85 -5
  38. package/src/commands/demo.ts +2 -0
  39. package/src/commands/teach.ts +3 -1
  40. package/src/context/architecture.ts +98 -18
  41. package/src/context/gate.ts +30 -3
  42. package/src/context/requirements.ts +77 -0
  43. package/src/demo/drive.ts +36 -7
  44. package/src/demo/timeline.ts +64 -0
  45. package/src/gate/measures.ts +69 -28
  46. package/src/gate/stages.ts +5 -5
  47. package/src/teach/workspace.ts +61 -6
  48. package/standards/architecture.md +13 -7
  49. package/standards/figures.md +1 -0
  50. package/standards/requirements.md +4 -0
  51. package/tooling/claude/reference.md +1 -1
  52. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
  53. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
  54. package/tooling/claude/seeds/.claude/settings.json +9 -0
  55. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  56. package/tooling/cloudflare/reference.md +1 -1
  57. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -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, its own entry cap, and its word weight, and wireframe states against their evidence folders',
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 none of the three is reported and never',
129
- 'gated. --gate widens the gate to the other two',
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')}, read alongside the weight judgment a session makes by reading the file. This never gates.`,
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')}, weighed the same way and read alongside the same judgment.`,
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
 
@@ -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 } : {}),
@@ -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 own word count, read alongside the weight judgment the
62
- * standard asks a session to make by reading the file rather than counting
63
- * it. Never gates, per `standards/architecture.md`'s `## Length` section.
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
- /** The one H2 the standard's `## Length` section asks to be weighed by words. */
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, which is the unit the standard's
295
- * `## Length` section reads alongside the weight judgment a session makes by
296
- * reading the file. It is a report figure rather than a gate, so a fenced
297
- * example or a code span inflating the count costs nothing a reader corrects
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
- const lines = bodyLines(source)
314
- const body: string[] = []
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 lines) {
345
+ for (const line of bodyLines(source)) {
319
346
  if (line.fenced) {
320
- if (capturing) body.push(line.text)
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.text)
362
+ if (capturing) body.push(line)
336
363
  }
337
364
 
338
- return found ? body.join('\n') : undefined
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.body),
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
  *
@@ -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 or its own entry cap, or missing a
83
- * revisit sentence it requires: the record states each rule for itself, which makes those the measures here that are facts
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 (recordOverLength || recordOverCount || recordMissingRevisit) return true
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
+ }