gcf-aif-ext 0.2.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 (294) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/commands/gcf.js +173 -0
  4. package/extension.json +24 -0
  5. package/hooks/pre-commit.js +91 -0
  6. package/injections/commit-status.md +6 -0
  7. package/injections/plan-context.md +8 -0
  8. package/injections/review-pillars.md +7 -0
  9. package/lib/anchors.js +109 -0
  10. package/lib/audit-core.js +102 -0
  11. package/lib/context.js +77 -0
  12. package/lib/frontmatter.js +26 -0
  13. package/lib/gcf-paths.js +60 -0
  14. package/lib/git.js +19 -0
  15. package/lib/id.js +42 -0
  16. package/lib/index-gen.js +51 -0
  17. package/lib/linter.js +61 -0
  18. package/lib/playtest-list.js +13 -0
  19. package/lib/safe-path.js +71 -0
  20. package/lib/transitions.js +30 -0
  21. package/lib/validate-card.js +48 -0
  22. package/lib/verdict-fs.js +126 -0
  23. package/node_modules/cnlp-kit/CHANGELOG.md +625 -0
  24. package/node_modules/cnlp-kit/LICENSE +21 -0
  25. package/node_modules/cnlp-kit/README.md +242 -0
  26. package/node_modules/cnlp-kit/bin/cnlp.js +334 -0
  27. package/node_modules/cnlp-kit/cnlp-format.md +470 -0
  28. package/node_modules/cnlp-kit/package.json +40 -0
  29. package/node_modules/cnlp-kit/profiles/adr.md +129 -0
  30. package/node_modules/cnlp-kit/profiles/api.md +87 -0
  31. package/node_modules/cnlp-kit/profiles/concept.md +70 -0
  32. package/node_modules/cnlp-kit/profiles/docset.md +103 -0
  33. package/node_modules/cnlp-kit/profiles/guide.md +51 -0
  34. package/node_modules/cnlp-kit/profiles/profile.md +69 -0
  35. package/node_modules/cnlp-kit/profiles/reference.md +61 -0
  36. package/node_modules/cnlp-kit/profiles/skill.md +75 -0
  37. package/node_modules/cnlp-kit/profiles/task.md +65 -0
  38. package/node_modules/cnlp-kit/quality-rules.md +69 -0
  39. package/node_modules/cnlp-kit/skills/cnlp-docset/SKILL.md +73 -0
  40. package/node_modules/cnlp-kit/skills/cnlp-migrate/SKILL.md +64 -0
  41. package/node_modules/cnlp-kit/src/cnlp.js +534 -0
  42. package/node_modules/cnlp-kit/src/config.js +352 -0
  43. package/node_modules/yaml/LICENSE +13 -0
  44. package/node_modules/yaml/README.md +172 -0
  45. package/node_modules/yaml/bin.mjs +11 -0
  46. package/node_modules/yaml/browser/dist/compose/compose-collection.js +88 -0
  47. package/node_modules/yaml/browser/dist/compose/compose-doc.js +43 -0
  48. package/node_modules/yaml/browser/dist/compose/compose-node.js +109 -0
  49. package/node_modules/yaml/browser/dist/compose/compose-scalar.js +86 -0
  50. package/node_modules/yaml/browser/dist/compose/composer.js +219 -0
  51. package/node_modules/yaml/browser/dist/compose/resolve-block-map.js +115 -0
  52. package/node_modules/yaml/browser/dist/compose/resolve-block-scalar.js +198 -0
  53. package/node_modules/yaml/browser/dist/compose/resolve-block-seq.js +49 -0
  54. package/node_modules/yaml/browser/dist/compose/resolve-end.js +37 -0
  55. package/node_modules/yaml/browser/dist/compose/resolve-flow-collection.js +207 -0
  56. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +226 -0
  57. package/node_modules/yaml/browser/dist/compose/resolve-props.js +146 -0
  58. package/node_modules/yaml/browser/dist/compose/util-contains-newline.js +34 -0
  59. package/node_modules/yaml/browser/dist/compose/util-empty-scalar-position.js +26 -0
  60. package/node_modules/yaml/browser/dist/compose/util-flow-indent-check.js +15 -0
  61. package/node_modules/yaml/browser/dist/compose/util-map-includes.js +13 -0
  62. package/node_modules/yaml/browser/dist/doc/Document.js +335 -0
  63. package/node_modules/yaml/browser/dist/doc/anchors.js +71 -0
  64. package/node_modules/yaml/browser/dist/doc/applyReviver.js +55 -0
  65. package/node_modules/yaml/browser/dist/doc/createNode.js +88 -0
  66. package/node_modules/yaml/browser/dist/doc/directives.js +176 -0
  67. package/node_modules/yaml/browser/dist/errors.js +57 -0
  68. package/node_modules/yaml/browser/dist/index.js +17 -0
  69. package/node_modules/yaml/browser/dist/log.js +11 -0
  70. package/node_modules/yaml/browser/dist/nodes/Alias.js +118 -0
  71. package/node_modules/yaml/browser/dist/nodes/Collection.js +147 -0
  72. package/node_modules/yaml/browser/dist/nodes/Node.js +38 -0
  73. package/node_modules/yaml/browser/dist/nodes/Pair.js +36 -0
  74. package/node_modules/yaml/browser/dist/nodes/Scalar.js +24 -0
  75. package/node_modules/yaml/browser/dist/nodes/YAMLMap.js +144 -0
  76. package/node_modules/yaml/browser/dist/nodes/YAMLSeq.js +113 -0
  77. package/node_modules/yaml/browser/dist/nodes/addPairToJSMap.js +63 -0
  78. package/node_modules/yaml/browser/dist/nodes/identity.js +36 -0
  79. package/node_modules/yaml/browser/dist/nodes/toJS.js +37 -0
  80. package/node_modules/yaml/browser/dist/parse/cst-scalar.js +214 -0
  81. package/node_modules/yaml/browser/dist/parse/cst-stringify.js +61 -0
  82. package/node_modules/yaml/browser/dist/parse/cst-visit.js +97 -0
  83. package/node_modules/yaml/browser/dist/parse/cst.js +98 -0
  84. package/node_modules/yaml/browser/dist/parse/lexer.js +721 -0
  85. package/node_modules/yaml/browser/dist/parse/line-counter.js +39 -0
  86. package/node_modules/yaml/browser/dist/parse/parser.js +975 -0
  87. package/node_modules/yaml/browser/dist/public-api.js +102 -0
  88. package/node_modules/yaml/browser/dist/schema/Schema.js +37 -0
  89. package/node_modules/yaml/browser/dist/schema/common/map.js +17 -0
  90. package/node_modules/yaml/browser/dist/schema/common/null.js +15 -0
  91. package/node_modules/yaml/browser/dist/schema/common/seq.js +17 -0
  92. package/node_modules/yaml/browser/dist/schema/common/string.js +14 -0
  93. package/node_modules/yaml/browser/dist/schema/core/bool.js +19 -0
  94. package/node_modules/yaml/browser/dist/schema/core/float.js +43 -0
  95. package/node_modules/yaml/browser/dist/schema/core/int.js +38 -0
  96. package/node_modules/yaml/browser/dist/schema/core/schema.js +23 -0
  97. package/node_modules/yaml/browser/dist/schema/json/schema.js +62 -0
  98. package/node_modules/yaml/browser/dist/schema/tags.js +96 -0
  99. package/node_modules/yaml/browser/dist/schema/yaml-1.1/binary.js +58 -0
  100. package/node_modules/yaml/browser/dist/schema/yaml-1.1/bool.js +26 -0
  101. package/node_modules/yaml/browser/dist/schema/yaml-1.1/float.js +46 -0
  102. package/node_modules/yaml/browser/dist/schema/yaml-1.1/int.js +71 -0
  103. package/node_modules/yaml/browser/dist/schema/yaml-1.1/merge.js +67 -0
  104. package/node_modules/yaml/browser/dist/schema/yaml-1.1/omap.js +74 -0
  105. package/node_modules/yaml/browser/dist/schema/yaml-1.1/pairs.js +78 -0
  106. package/node_modules/yaml/browser/dist/schema/yaml-1.1/schema.js +39 -0
  107. package/node_modules/yaml/browser/dist/schema/yaml-1.1/set.js +93 -0
  108. package/node_modules/yaml/browser/dist/schema/yaml-1.1/timestamp.js +101 -0
  109. package/node_modules/yaml/browser/dist/stringify/foldFlowLines.js +146 -0
  110. package/node_modules/yaml/browser/dist/stringify/stringify.js +129 -0
  111. package/node_modules/yaml/browser/dist/stringify/stringifyCollection.js +153 -0
  112. package/node_modules/yaml/browser/dist/stringify/stringifyComment.js +20 -0
  113. package/node_modules/yaml/browser/dist/stringify/stringifyDocument.js +85 -0
  114. package/node_modules/yaml/browser/dist/stringify/stringifyNumber.js +25 -0
  115. package/node_modules/yaml/browser/dist/stringify/stringifyPair.js +150 -0
  116. package/node_modules/yaml/browser/dist/stringify/stringifyString.js +336 -0
  117. package/node_modules/yaml/browser/dist/util.js +11 -0
  118. package/node_modules/yaml/browser/dist/visit.js +233 -0
  119. package/node_modules/yaml/browser/index.js +5 -0
  120. package/node_modules/yaml/browser/package.json +3 -0
  121. package/node_modules/yaml/dist/cli.d.ts +8 -0
  122. package/node_modules/yaml/dist/cli.mjs +201 -0
  123. package/node_modules/yaml/dist/compose/compose-collection.d.ts +11 -0
  124. package/node_modules/yaml/dist/compose/compose-collection.js +90 -0
  125. package/node_modules/yaml/dist/compose/compose-doc.d.ts +7 -0
  126. package/node_modules/yaml/dist/compose/compose-doc.js +45 -0
  127. package/node_modules/yaml/dist/compose/compose-node.d.ts +29 -0
  128. package/node_modules/yaml/dist/compose/compose-node.js +112 -0
  129. package/node_modules/yaml/dist/compose/compose-scalar.d.ts +5 -0
  130. package/node_modules/yaml/dist/compose/compose-scalar.js +88 -0
  131. package/node_modules/yaml/dist/compose/composer.d.ts +63 -0
  132. package/node_modules/yaml/dist/compose/composer.js +224 -0
  133. package/node_modules/yaml/dist/compose/resolve-block-map.d.ts +6 -0
  134. package/node_modules/yaml/dist/compose/resolve-block-map.js +117 -0
  135. package/node_modules/yaml/dist/compose/resolve-block-scalar.d.ts +11 -0
  136. package/node_modules/yaml/dist/compose/resolve-block-scalar.js +200 -0
  137. package/node_modules/yaml/dist/compose/resolve-block-seq.d.ts +6 -0
  138. package/node_modules/yaml/dist/compose/resolve-block-seq.js +51 -0
  139. package/node_modules/yaml/dist/compose/resolve-end.d.ts +6 -0
  140. package/node_modules/yaml/dist/compose/resolve-end.js +39 -0
  141. package/node_modules/yaml/dist/compose/resolve-flow-collection.d.ts +7 -0
  142. package/node_modules/yaml/dist/compose/resolve-flow-collection.js +209 -0
  143. package/node_modules/yaml/dist/compose/resolve-flow-scalar.d.ts +10 -0
  144. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +228 -0
  145. package/node_modules/yaml/dist/compose/resolve-props.d.ts +23 -0
  146. package/node_modules/yaml/dist/compose/resolve-props.js +148 -0
  147. package/node_modules/yaml/dist/compose/util-contains-newline.d.ts +2 -0
  148. package/node_modules/yaml/dist/compose/util-contains-newline.js +36 -0
  149. package/node_modules/yaml/dist/compose/util-empty-scalar-position.d.ts +2 -0
  150. package/node_modules/yaml/dist/compose/util-empty-scalar-position.js +28 -0
  151. package/node_modules/yaml/dist/compose/util-flow-indent-check.d.ts +3 -0
  152. package/node_modules/yaml/dist/compose/util-flow-indent-check.js +17 -0
  153. package/node_modules/yaml/dist/compose/util-map-includes.d.ts +4 -0
  154. package/node_modules/yaml/dist/compose/util-map-includes.js +15 -0
  155. package/node_modules/yaml/dist/doc/Document.d.ts +141 -0
  156. package/node_modules/yaml/dist/doc/Document.js +337 -0
  157. package/node_modules/yaml/dist/doc/anchors.d.ts +24 -0
  158. package/node_modules/yaml/dist/doc/anchors.js +76 -0
  159. package/node_modules/yaml/dist/doc/applyReviver.d.ts +9 -0
  160. package/node_modules/yaml/dist/doc/applyReviver.js +57 -0
  161. package/node_modules/yaml/dist/doc/createNode.d.ts +17 -0
  162. package/node_modules/yaml/dist/doc/createNode.js +90 -0
  163. package/node_modules/yaml/dist/doc/directives.d.ts +49 -0
  164. package/node_modules/yaml/dist/doc/directives.js +178 -0
  165. package/node_modules/yaml/dist/errors.d.ts +21 -0
  166. package/node_modules/yaml/dist/errors.js +62 -0
  167. package/node_modules/yaml/dist/index.d.ts +25 -0
  168. package/node_modules/yaml/dist/index.js +50 -0
  169. package/node_modules/yaml/dist/log.d.ts +3 -0
  170. package/node_modules/yaml/dist/log.js +19 -0
  171. package/node_modules/yaml/dist/nodes/Alias.d.ts +29 -0
  172. package/node_modules/yaml/dist/nodes/Alias.js +120 -0
  173. package/node_modules/yaml/dist/nodes/Collection.d.ts +73 -0
  174. package/node_modules/yaml/dist/nodes/Collection.js +151 -0
  175. package/node_modules/yaml/dist/nodes/Node.d.ts +53 -0
  176. package/node_modules/yaml/dist/nodes/Node.js +40 -0
  177. package/node_modules/yaml/dist/nodes/Pair.d.ts +22 -0
  178. package/node_modules/yaml/dist/nodes/Pair.js +39 -0
  179. package/node_modules/yaml/dist/nodes/Scalar.d.ts +47 -0
  180. package/node_modules/yaml/dist/nodes/Scalar.js +27 -0
  181. package/node_modules/yaml/dist/nodes/YAMLMap.d.ts +53 -0
  182. package/node_modules/yaml/dist/nodes/YAMLMap.js +147 -0
  183. package/node_modules/yaml/dist/nodes/YAMLSeq.d.ts +60 -0
  184. package/node_modules/yaml/dist/nodes/YAMLSeq.js +115 -0
  185. package/node_modules/yaml/dist/nodes/addPairToJSMap.d.ts +4 -0
  186. package/node_modules/yaml/dist/nodes/addPairToJSMap.js +65 -0
  187. package/node_modules/yaml/dist/nodes/identity.d.ts +23 -0
  188. package/node_modules/yaml/dist/nodes/identity.js +53 -0
  189. package/node_modules/yaml/dist/nodes/toJS.d.ts +29 -0
  190. package/node_modules/yaml/dist/nodes/toJS.js +39 -0
  191. package/node_modules/yaml/dist/options.d.ts +350 -0
  192. package/node_modules/yaml/dist/parse/cst-scalar.d.ts +64 -0
  193. package/node_modules/yaml/dist/parse/cst-scalar.js +218 -0
  194. package/node_modules/yaml/dist/parse/cst-stringify.d.ts +8 -0
  195. package/node_modules/yaml/dist/parse/cst-stringify.js +63 -0
  196. package/node_modules/yaml/dist/parse/cst-visit.d.ts +39 -0
  197. package/node_modules/yaml/dist/parse/cst-visit.js +99 -0
  198. package/node_modules/yaml/dist/parse/cst.d.ts +109 -0
  199. package/node_modules/yaml/dist/parse/cst.js +112 -0
  200. package/node_modules/yaml/dist/parse/lexer.d.ts +87 -0
  201. package/node_modules/yaml/dist/parse/lexer.js +723 -0
  202. package/node_modules/yaml/dist/parse/line-counter.d.ts +22 -0
  203. package/node_modules/yaml/dist/parse/line-counter.js +41 -0
  204. package/node_modules/yaml/dist/parse/parser.d.ts +84 -0
  205. package/node_modules/yaml/dist/parse/parser.js +980 -0
  206. package/node_modules/yaml/dist/public-api.d.ts +44 -0
  207. package/node_modules/yaml/dist/public-api.js +107 -0
  208. package/node_modules/yaml/dist/schema/Schema.d.ts +17 -0
  209. package/node_modules/yaml/dist/schema/Schema.js +39 -0
  210. package/node_modules/yaml/dist/schema/common/map.d.ts +2 -0
  211. package/node_modules/yaml/dist/schema/common/map.js +19 -0
  212. package/node_modules/yaml/dist/schema/common/null.d.ts +4 -0
  213. package/node_modules/yaml/dist/schema/common/null.js +17 -0
  214. package/node_modules/yaml/dist/schema/common/seq.d.ts +2 -0
  215. package/node_modules/yaml/dist/schema/common/seq.js +19 -0
  216. package/node_modules/yaml/dist/schema/common/string.d.ts +2 -0
  217. package/node_modules/yaml/dist/schema/common/string.js +16 -0
  218. package/node_modules/yaml/dist/schema/core/bool.d.ts +4 -0
  219. package/node_modules/yaml/dist/schema/core/bool.js +21 -0
  220. package/node_modules/yaml/dist/schema/core/float.d.ts +4 -0
  221. package/node_modules/yaml/dist/schema/core/float.js +47 -0
  222. package/node_modules/yaml/dist/schema/core/int.d.ts +4 -0
  223. package/node_modules/yaml/dist/schema/core/int.js +42 -0
  224. package/node_modules/yaml/dist/schema/core/schema.d.ts +1 -0
  225. package/node_modules/yaml/dist/schema/core/schema.js +25 -0
  226. package/node_modules/yaml/dist/schema/json/schema.d.ts +2 -0
  227. package/node_modules/yaml/dist/schema/json/schema.js +64 -0
  228. package/node_modules/yaml/dist/schema/json-schema.d.ts +69 -0
  229. package/node_modules/yaml/dist/schema/tags.d.ts +48 -0
  230. package/node_modules/yaml/dist/schema/tags.js +99 -0
  231. package/node_modules/yaml/dist/schema/types.d.ts +92 -0
  232. package/node_modules/yaml/dist/schema/yaml-1.1/binary.d.ts +2 -0
  233. package/node_modules/yaml/dist/schema/yaml-1.1/binary.js +70 -0
  234. package/node_modules/yaml/dist/schema/yaml-1.1/bool.d.ts +7 -0
  235. package/node_modules/yaml/dist/schema/yaml-1.1/bool.js +29 -0
  236. package/node_modules/yaml/dist/schema/yaml-1.1/float.d.ts +4 -0
  237. package/node_modules/yaml/dist/schema/yaml-1.1/float.js +50 -0
  238. package/node_modules/yaml/dist/schema/yaml-1.1/int.d.ts +5 -0
  239. package/node_modules/yaml/dist/schema/yaml-1.1/int.js +76 -0
  240. package/node_modules/yaml/dist/schema/yaml-1.1/merge.d.ts +9 -0
  241. package/node_modules/yaml/dist/schema/yaml-1.1/merge.js +71 -0
  242. package/node_modules/yaml/dist/schema/yaml-1.1/omap.d.ts +22 -0
  243. package/node_modules/yaml/dist/schema/yaml-1.1/omap.js +77 -0
  244. package/node_modules/yaml/dist/schema/yaml-1.1/pairs.d.ts +10 -0
  245. package/node_modules/yaml/dist/schema/yaml-1.1/pairs.js +82 -0
  246. package/node_modules/yaml/dist/schema/yaml-1.1/schema.d.ts +1 -0
  247. package/node_modules/yaml/dist/schema/yaml-1.1/schema.js +41 -0
  248. package/node_modules/yaml/dist/schema/yaml-1.1/set.d.ts +28 -0
  249. package/node_modules/yaml/dist/schema/yaml-1.1/set.js +96 -0
  250. package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.d.ts +6 -0
  251. package/node_modules/yaml/dist/schema/yaml-1.1/timestamp.js +105 -0
  252. package/node_modules/yaml/dist/stringify/foldFlowLines.d.ts +34 -0
  253. package/node_modules/yaml/dist/stringify/foldFlowLines.js +151 -0
  254. package/node_modules/yaml/dist/stringify/stringify.d.ts +21 -0
  255. package/node_modules/yaml/dist/stringify/stringify.js +132 -0
  256. package/node_modules/yaml/dist/stringify/stringifyCollection.d.ts +17 -0
  257. package/node_modules/yaml/dist/stringify/stringifyCollection.js +155 -0
  258. package/node_modules/yaml/dist/stringify/stringifyComment.d.ts +10 -0
  259. package/node_modules/yaml/dist/stringify/stringifyComment.js +24 -0
  260. package/node_modules/yaml/dist/stringify/stringifyDocument.d.ts +4 -0
  261. package/node_modules/yaml/dist/stringify/stringifyDocument.js +87 -0
  262. package/node_modules/yaml/dist/stringify/stringifyNumber.d.ts +2 -0
  263. package/node_modules/yaml/dist/stringify/stringifyNumber.js +27 -0
  264. package/node_modules/yaml/dist/stringify/stringifyPair.d.ts +3 -0
  265. package/node_modules/yaml/dist/stringify/stringifyPair.js +152 -0
  266. package/node_modules/yaml/dist/stringify/stringifyString.d.ts +9 -0
  267. package/node_modules/yaml/dist/stringify/stringifyString.js +338 -0
  268. package/node_modules/yaml/dist/test-events.d.ts +4 -0
  269. package/node_modules/yaml/dist/test-events.js +134 -0
  270. package/node_modules/yaml/dist/util.d.ts +16 -0
  271. package/node_modules/yaml/dist/util.js +28 -0
  272. package/node_modules/yaml/dist/visit.d.ts +102 -0
  273. package/node_modules/yaml/dist/visit.js +236 -0
  274. package/node_modules/yaml/package.json +97 -0
  275. package/node_modules/yaml/util.js +2 -0
  276. package/package.json +54 -0
  277. package/profiles/card.md +65 -0
  278. package/profiles/pillars.md +31 -0
  279. package/profiles/system.md +56 -0
  280. package/schemas/card.schema.json +30 -0
  281. package/skills/gcf-adopt/SKILL.md +41 -0
  282. package/skills/gcf-audit/SKILL.md +27 -0
  283. package/skills/gcf-card/SKILL.md +35 -0
  284. package/skills/gcf-debate/SKILL.md +33 -0
  285. package/skills/gcf-ingest/SKILL.md +38 -0
  286. package/skills/gcf-init/SKILL.md +32 -0
  287. package/skills/gcf-verdict/SKILL.md +32 -0
  288. package/skills/gcf-vision/SKILL.md +33 -0
  289. package/templates/PILLARS.md +8 -0
  290. package/templates/backlog.md +4 -0
  291. package/templates/card.md +34 -0
  292. package/templates/cnlp.config.json +8 -0
  293. package/templates/config.yml +8 -0
  294. package/templates/system.md +22 -0
@@ -0,0 +1,534 @@
1
+ // The mechanical half of cnlp-format.md. One implementation for every document kind: what
2
+ // differs between kinds is declared in a profile document (profiles/*.md), not branched here.
3
+ // `bin/cnlp.js check` runs it over a project's corpus; a project embedding the checker calls
4
+ // `bodyIssues` directly. Grammar and lexicon only — no caller can judge whether a bullet says
5
+ // something useful.
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ export const DENY = /\b(surface[sd]?|sharpen[s]?|weigh[s]?|leverage[sd]?|robust|sanity-check|ensure[sd]?)\b/i;
12
+ const NUM = '(?:\\d+|one|two|three|four|five|six|seven|eight|nine|ten)';
13
+ /** §5: a threshold is a digit. */
14
+ export const SPELLED_THRESHOLD = /\b(at least|exactly|more than|fewer than|no more than|only) (one|two|three|four|five|six|seven|eight|nine|ten)\b/i;
15
+ /** §5: a limit whose bound the phrase leaves open — the enforced half of the canonical form. */
16
+ export const AMBIGUOUS_LIMIT = new RegExp(`\\b(?:no more than|at most|up to|not exceeding|not exceed|no fewer than|not less than)\\s+${NUM}\\b`, 'i');
17
+ /** §5: an unquantified comparative claims a value the reader cannot check. */
18
+ export const COMPARATIVE = /\b(better|faster|cleaner|significantly|substantially|flexible|scalable|extensible|where possible|if needed|as appropriate|should probably|may want to)\b/i;
19
+ /** §5: the light-verb construction, which is a verb hiding inside an abstract noun. */
20
+ const NOMINAL = 'decision|validation|verification|comparison|description|inspection|check|review|report|record|update|statement|selection|removal|migration|conversion|assessment|evaluation';
21
+ // The noun closes the phrase or takes a preposition: `make a check pass` is a causative carrying a
22
+ // real object, and reading it as a nominalization rejects a line that has nothing wrong with it.
23
+ export const NOMINALIZATION = new RegExp(`\\b(?:make|makes|do|does|perform|performs|provide|provides|carry out|carries out|give|gives)\\s+(?:a|an|the)\\s+(?:${NOMINAL})\\b(?=\\s*(?:$|[,.;:)]|(?:of|on|for|to|about|against|with|in)\\b))`, 'i');
24
+ /** §5: an agentless passive drops the actor an imperative document exists to address. */
25
+ export const PASSIVE = /\b(?:is|are|was|were|be|been|being)\s+(?:not\s+)?([a-z]+ed)\b/i;
26
+ /** §5: a participle naming a state of the subject, which is not an action with an actor to name. */
27
+ const STATE_PARTICIPLE = /^(?:un[a-z]+ed|required|allowed|needed|based|limited|intended|expected)$/i;
28
+ export const HARD_LIMIT = 250; // §4: 150 is the target, 250 is compound whatever it claims
29
+
30
+ const FORMS = new Set(['scalar', 'bullet-list', 'numbered-list', 'record-list', 'keyed-block']);
31
+ const KEY = /^([a-z][a-z0-9_]*):(.*)$/;
32
+
33
+ /**
34
+ * Body split into lines, top-level `key:` sections and `##` headings, with fenced regions
35
+ * blanked — §3: a fence is opaque, its contents are data and never section keys.
36
+ * Leading frontmatter is stripped when present. Line numbers are 1-based within the body.
37
+ */
38
+ export function parseBlocks(raw) {
39
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
40
+ let fenced = false;
41
+ const fences = []; // 1-based line numbers a fence covered: blanked, but not absent
42
+ const lines = body.split(/\r?\n/).map((l, i) => {
43
+ if (/^\s*```/.test(l)) { fenced = !fenced; fences.push(i + 1); return ''; }
44
+ if (fenced) { fences.push(i + 1); return ''; }
45
+ return l;
46
+ });
47
+ const sections = [];
48
+ const headings = [];
49
+ lines.forEach((l, i) => {
50
+ const key = l.match(KEY);
51
+ if (key) sections.push({ key: key[1], value: key[2].trim(), line: i + 1 });
52
+ if (/^##\s/.test(l)) headings.push({ text: l.trim(), line: i + 1 });
53
+ });
54
+ return { lines, sections, headings, fences: new Set(fences) };
55
+ }
56
+
57
+ /** Lines of a section: everything up to the next top-level key or `##` heading. */
58
+ export function itemsOf(lines, at) {
59
+ const out = [];
60
+ for (let i = at; i < lines.length; i += 1) {
61
+ if (KEY.test(lines[i]) || /^##\s/.test(lines[i])) break;
62
+ if (lines[i].trim() !== '') out.push({ text: lines[i], line: i + 1 });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** §3 record-list: `- key: value` opens a record, two-space-indented keys continue it. */
68
+ export function readRecords(items) {
69
+ const records = [];
70
+ const stray = [];
71
+ const repeated = [];
72
+ for (const { text, line } of items) {
73
+ const open = text.match(/^- ([a-z][a-z0-9_]*): ?(.*)$/);
74
+ const sub = text.match(/^ {2}([a-z][a-z0-9_]*): ?(.*)$/);
75
+ if (open) records.push({ line, keys: [open[1]], values: { [open[1]]: open[2].trim() } });
76
+ else if (sub && records.length) {
77
+ const rec = records[records.length - 1];
78
+ // A second copy of a key would silently win: two readers, two interpretations.
79
+ if (rec.keys.includes(sub[1])) repeated.push({ key: sub[1], line });
80
+ rec.keys.push(sub[1]);
81
+ rec.values[sub[1]] = sub[2].trim();
82
+ } else stray.push({ text, line });
83
+ }
84
+ return { records, stray, repeated };
85
+ }
86
+
87
+ const listValues = (items) => items
88
+ .map((i) => i.text.replace(/^[-\d.]+ ?/, '').trim())
89
+ .map((t) => (t.includes(':') ? t.slice(0, t.indexOf(':')).trim() : t))
90
+ .filter((t) => t !== '');
91
+
92
+ /**
93
+ * A value outside its domain is a typo that would otherwise pass silently — `required: ye`
94
+ * reading as `no` disables the check the profile meant to declare.
95
+ */
96
+ function domain(key, field, value, allowed) {
97
+ if (!allowed.includes(value)) {
98
+ throw new Error(`profile section "${key}": ${field} is ${JSON.stringify(value)}, expected one of ${allowed.join(', ')}`);
99
+ }
100
+ return value;
101
+ }
102
+
103
+ /**
104
+ * §7: the closed vocabulary of a record key, written `status = covered, source, avoid` and
105
+ * separated by `;` for a second key. A value outside its vocabulary used to read as whichever
106
+ * value the reader assumed, which is the failure that costs most where a status decides whether
107
+ * a page exists at all.
108
+ */
109
+ function readVocabularies(key, raw) {
110
+ const out = {};
111
+ for (const pair of (raw ?? '').split(';').map((p) => p.trim()).filter(Boolean)) {
112
+ const at = pair.indexOf('=');
113
+ if (at < 0) {
114
+ throw new Error(`profile section "${key}": record_values reads "<key> = <value>, <value>", found ${JSON.stringify(pair)}`);
115
+ }
116
+ const name = pair.slice(0, at).trim();
117
+ const values = pair.slice(at + 1).split(',').map((v) => v.trim()).filter(Boolean);
118
+ if (!name || values.length === 0) {
119
+ throw new Error(`profile section "${key}": record_values names no key or no value in ${JSON.stringify(pair)}`);
120
+ }
121
+ out[name] = values;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** A profile document (profiles/*.md) read into the values the checker needs. */
127
+ export function readProfile(raw) {
128
+ const { lines, sections } = parseBlocks(raw);
129
+ const block = (key) => {
130
+ const at = sections.find((s) => s.key === key);
131
+ return at ? { value: at.value, items: itemsOf(lines, at.line) } : null;
132
+ };
133
+ const names = (key) => {
134
+ const b = block(key);
135
+ if (!b) return [];
136
+ const out = listValues(b.items);
137
+ return out.length === 1 && out[0] === 'none' ? [] : out;
138
+ };
139
+ const declared = readRecords(block('sections')?.items ?? []).records.map((r) => ({
140
+ key: r.values.key,
141
+ form: domain(r.values.key, 'form', r.values.form, [...FORMS]),
142
+ required: domain(r.values.key, 'required', r.values.required, ['yes', 'no']) === 'yes',
143
+ heading: (r.values.heading ?? '').replace(/^"|"$/g, ''),
144
+ recordKeys: (r.values.record_keys ?? '').split(',').map((s) => s.trim()).filter(Boolean),
145
+ optionalRecordKeys: (r.values.optional_record_keys ?? '').split(',').map((s) => s.trim()).filter(Boolean),
146
+ recordValues: readVocabularies(r.values.key, r.values.record_values),
147
+ }));
148
+ return {
149
+ format: block('format')?.value ?? '',
150
+ mood: block('mood')?.value ?? '',
151
+ headings: names('headings').map((h) => (h.startsWith('##') ? h : `## ${h}`)),
152
+ frontmatterFields: names('frontmatter_fields'),
153
+ custom: names('custom_sections'),
154
+ sections: declared,
155
+ };
156
+ }
157
+
158
+ /** This package's own root: where the standard and the profiles that ship with it live. */
159
+ export const PACKAGE_ROOT = new URL('../', import.meta.url);
160
+
161
+ const cache = new Map();
162
+
163
+ /**
164
+ * Absolute path of a CNL-P document: the standard, or `profiles/<name>.md`. `roots` is searched
165
+ * in order and the first root holding the file wins, so a project's own profile directory
166
+ * shadows the one that ships here. With no roots given it resolves inside this package, which
167
+ * is what `cnlp print` serves.
168
+ */
169
+ export function resolveDoc(name = 'format', { roots = [] } = {}) {
170
+ // The name reaches this from a command line, so it names a document and never a path:
171
+ // `profiles/${name}.md` with a `..` in it walks out of the package and reads anything.
172
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
173
+ throw new Error(`not a CNL-P document name: ${JSON.stringify(name)}`);
174
+ }
175
+ const rel = name === 'format' ? 'cnlp-format.md' : `profiles/${name}.md`;
176
+ const search = [...roots, PACKAGE_ROOT];
177
+ for (const root of search) {
178
+ const abs = fileURLToPath(new URL(rel, root));
179
+ if (existsSync(abs)) return abs;
180
+ }
181
+ // Nothing holds it: report the packaged location, which is where the caller expected it.
182
+ return fileURLToPath(new URL(rel, PACKAGE_ROOT));
183
+ }
184
+
185
+ /**
186
+ * Load a profile from a path. The cache is keyed on that path, so a project's own `skill` and
187
+ * the packaged `skill` are two entries rather than whichever was read first.
188
+ */
189
+ export async function loadProfileAt(abs) {
190
+ if (!cache.has(abs)) cache.set(abs, readProfile(await readFile(abs, 'utf8')));
191
+ return cache.get(abs);
192
+ }
193
+
194
+ /** Load a profile by name, resolved through `roots` and then this package. */
195
+ export async function loadProfile(name, opts) {
196
+ return loadProfileAt(resolveDoc(name, opts));
197
+ }
198
+
199
+ /**
200
+ * Body conformance with the profile and with the shared lexicon. Returns [{ line, message }]
201
+ * sorted by line; the caller decides the severity. A line number of 0 means the issue is
202
+ * about the document as a whole.
203
+ */
204
+ export function bodyIssues(raw, profile) {
205
+ const { lines, sections, headings, fences } = parseBlocks(raw);
206
+ const issues = [];
207
+ const at = (line, message) => issues.push({ line, message });
208
+ const byKey = new Map(profile.sections.map((s) => [s.key, s]));
209
+ const order = profile.sections.map((s) => s.key);
210
+
211
+ const seen = headings.map((h) => h.text);
212
+ for (const want of profile.headings) {
213
+ if (!seen.includes(want)) at(0, `missing heading "${want}"`);
214
+ }
215
+ for (const h of headings) {
216
+ if (!profile.headings.includes(h.text)) at(h.line, `unknown heading "${h.text}" — the profile declares ${profile.headings.join(', ') || 'none'}`);
217
+ else if (seen.indexOf(h.text) !== seen.lastIndexOf(h.text)) at(h.line, `heading "${h.text}" appears more than 1 time`);
218
+ }
219
+ const headingOrder = seen.filter((t) => profile.headings.includes(t));
220
+ if (headingOrder.join() !== [...new Set(headingOrder)].sort((a, b) => profile.headings.indexOf(a) - profile.headings.indexOf(b)).join()) {
221
+ at(0, `headings out of order — the profile declares ${profile.headings.join(', ')}`);
222
+ }
223
+
224
+ for (const s of profile.sections) {
225
+ if (s.required && !sections.some((f) => f.key === s.key)) at(0, `missing required block "${s.key}:"`);
226
+ }
227
+
228
+ const declared = new Set();
229
+ for (const { key, line } of sections) {
230
+ if (declared.has(key)) at(line, `block "${key}:" appears more than 1 time — a fact lives in exactly 1 place`);
231
+ declared.add(key);
232
+ if (byKey.has(key) || profile.custom.includes(key)) continue;
233
+ if (profile.frontmatterFields.includes(key)) at(line, `"${key}:" is a frontmatter field, not a body block`);
234
+ else at(line, `unknown block "${key}:" — reuse a name the profile declares`);
235
+ }
236
+
237
+ const found = sections.map((s) => s.key).filter((k) => byKey.has(k));
238
+ const sorted = [...found].sort((a, b) => order.indexOf(a) - order.indexOf(b));
239
+ if (found.join() !== sorted.join()) at(0, `blocks out of order — the profile declares ${order.join(', ')}`);
240
+
241
+ for (const { key, value, line } of sections) {
242
+ const spec = byKey.get(key);
243
+ if (!spec) continue; // a custom section declares no form
244
+ const items = itemsOf(lines, line);
245
+ if (spec.heading) {
246
+ const under = [...headings].reverse().find((h) => h.line < line);
247
+ if (!under) at(line, `"${key}:" belongs under "${spec.heading}", found before any heading`);
248
+ else if (under.text !== spec.heading) at(line, `"${key}:" belongs under "${spec.heading}", found under "${under.text}"`);
249
+ }
250
+ if (spec.form === 'scalar') {
251
+ if (!value) at(line, `"${key}:" is a scalar — its value goes on the key line`);
252
+ continue;
253
+ }
254
+ if (value) at(line, `"${key}:" carries a value on the key line but its form is ${spec.form}`);
255
+ else if (items.length === 0) {
256
+ // A fence is opaque content, not absence: its lines are blanked, so ask the parser.
257
+ if (!coversFence(fences, line, lines)) {
258
+ at(line, `"${key}:" is empty — a required block states "- none" with the reason, an optional one is deleted`);
259
+ }
260
+ continue;
261
+ }
262
+ // "- none: reason" is the declared way to say a required block has nothing, in any form.
263
+ // It is the whole content of a required block: anything else makes it 2 statements.
264
+ const none = items.find((i) => /^- none\b/.test(i.text));
265
+ if (none) {
266
+ if (!spec.required) at(none.line, `"${key}:" is optional and has nothing to say — delete the block instead of stating "- none"`);
267
+ else if (items.length !== 1) at(none.line, `"${key}:" states "- none" alongside other items — the sentinel is the whole content or it is not used`);
268
+ else if (!/^- none: \S/.test(none.text)) at(none.line, `"${key}:" states "- none" without the reason, which is what the reader needs`);
269
+ continue;
270
+ }
271
+ issues.push(...formIssues(key, spec, items));
272
+ }
273
+
274
+ issues.push(...lexiconIssues(lines, profile.mood));
275
+ return issues.sort((a, b) => a.line - b.line);
276
+ }
277
+
278
+ /** Whether the region under a key held a fenced block — §3: a fence is opaque, not empty. */
279
+ function coversFence(fences, at, lines) {
280
+ for (let i = at; i < lines.length; i += 1) {
281
+ if (KEY.test(lines[i]) || /^##\s/.test(lines[i])) return false;
282
+ if (fences.has(i + 1)) return true;
283
+ }
284
+ return false;
285
+ }
286
+
287
+ /** §7: a value the profile closed to a vocabulary, held to it. A key the profile left open passes. */
288
+ function vocabularyIssues(spec, key, name, value, line) {
289
+ const allowed = spec.recordValues?.[name];
290
+ if (!allowed || allowed.includes(value)) return [];
291
+ return [{ line, message: `"${name}:" is ${JSON.stringify(value)} in "${key}:" — the profile declares ${allowed.join(', ')}` }];
292
+ }
293
+
294
+ function formIssues(key, spec, items) {
295
+ const issues = [];
296
+ const at = (line, message) => issues.push({ line, message });
297
+ if (spec.form === 'numbered-list') {
298
+ const bad = items.find((i) => !/^\d+\. /.test(i.text));
299
+ if (bad) at(bad.line, `"${key}:" is a numbered-list — every item opens with "N. "`);
300
+ else {
301
+ // The numbers are the order: a gap or a repeat makes 2 readings of the same sequence.
302
+ items.forEach((i, n) => {
303
+ const got = Number(i.text.match(/^(\d+)\./)[1]);
304
+ if (got !== n + 1) at(i.line, `"${key}:" step ${got} is out of sequence — the steps run 1..${items.length}`);
305
+ });
306
+ }
307
+ return issues;
308
+ }
309
+ if (spec.form === 'bullet-list') {
310
+ const bad = items.find((i) => !/^- /.test(i.text));
311
+ if (bad) at(bad.line, `"${key}:" is a bullet-list — every item opens with "- "`);
312
+ return issues;
313
+ }
314
+ if (spec.form === 'keyed-block') {
315
+ const bad = items.find((i) => !/^ {2}[a-z][a-z0-9_]*:/.test(i.text));
316
+ if (bad) at(bad.line, `"${key}:" is a keyed-block — its sub-keys are two-space-indented`);
317
+ const allowed = [...spec.recordKeys, ...spec.optionalRecordKeys];
318
+ const present = [];
319
+ for (const i of items) {
320
+ const [sub, ...rest] = i.text.trim().split(':');
321
+ if (present.includes(sub)) at(i.line, `"${key}:" carries "${sub}:" more than 1 time`);
322
+ else if (!allowed.includes(sub)) at(i.line, `"${sub}" is not a "${key}:" sub-key — the profile declares ${allowed.join(', ')}`);
323
+ else issues.push(...vocabularyIssues(spec, key, sub, rest.join(':').trim(), i.line));
324
+ present.push(sub);
325
+ }
326
+ for (const want of spec.recordKeys) {
327
+ if (!present.includes(want)) at(items[0].line, `"${key}:" is missing "${want}:"`);
328
+ }
329
+ return issues;
330
+ }
331
+ // record-list
332
+ const { records, stray, repeated } = readRecords(items);
333
+ for (const r of repeated) at(r.line, `a "${key}:" record carries "${r.key}:" more than 1 time`);
334
+ for (const s of stray) at(s.line, `"${key}:" is a record-list — a record opens with "- ${spec.recordKeys[0] ?? 'key'}: …" then two-space-indented keys`);
335
+ const allowed = [...spec.recordKeys, ...spec.optionalRecordKeys];
336
+ for (const rec of records) {
337
+ if (spec.recordKeys.length && rec.keys[0] !== spec.recordKeys[0]) {
338
+ at(rec.line, `a "${key}:" record opens with "- ${spec.recordKeys[0]}: <value>"`);
339
+ }
340
+ for (const want of spec.recordKeys) {
341
+ if (!rec.keys.includes(want)) at(rec.line, `a "${key}:" record is missing "${want}:"`);
342
+ }
343
+ for (const k of rec.keys) {
344
+ if (!allowed.includes(k)) at(rec.line, `"${k}" is not a "${key}:" key — the profile declares ${allowed.join(', ')}`);
345
+ else issues.push(...vocabularyIssues(spec, key, k, rec.values[k], rec.line));
346
+ }
347
+ }
348
+ return issues;
349
+ }
350
+
351
+ /** The keys a record may carry, required ones first, which is the order a record writes them in. */
352
+ const allowedKeys = (spec) => [...spec.recordKeys, ...spec.optionalRecordKeys];
353
+
354
+ function recordLines(spec, rec, indent) {
355
+ const allowed = allowedKeys(spec);
356
+ for (const k of Object.keys(rec)) {
357
+ if (!allowed.includes(k)) throw new Error(`"${k}" is not a "${spec.key}:" key — the profile declares ${allowed.join(', ') || 'none'}`);
358
+ }
359
+ for (const want of spec.recordKeys) {
360
+ if (rec[want] === undefined) throw new Error(`a "${spec.key}:" record is missing "${want}:"`);
361
+ }
362
+ return allowed.filter((k) => rec[k] !== undefined).map((k) => `${indent}${k}: ${rec[k]}`);
363
+ }
364
+
365
+ function sectionLines(spec, value) {
366
+ const { key, form } = spec;
367
+ const wrong = (want) => { throw new Error(`"${key}:" is a ${form}, so its value is ${want}`); };
368
+ if (form === 'scalar') {
369
+ if (typeof value !== 'string') wrong('a string');
370
+ return [`${key}: ${value}`];
371
+ }
372
+ if (form === 'bullet-list' || form === 'numbered-list') {
373
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) wrong('an array of strings');
374
+ return [`${key}:`, ...value.map((v, i) => `${form === 'numbered-list' ? `${i + 1}. ` : '- '}${v}`)];
375
+ }
376
+ if (form === 'keyed-block') {
377
+ if (!value || typeof value !== 'object' || Array.isArray(value)) wrong('an object');
378
+ return [`${key}:`, ...recordLines(spec, value, ' ')];
379
+ }
380
+ if (!Array.isArray(value) || value.some((v) => !v || typeof v !== 'object' || Array.isArray(v))) wrong('an array of objects');
381
+ const records = value.map((rec) => {
382
+ const [first, ...rest] = recordLines(spec, rec, ' ');
383
+ return [`- ${first.trim()}`, ...rest];
384
+ });
385
+ // §3: a blank line separates records that run to 2 lines or more, where the only other boundary
386
+ // is a `- ` a reader has to scan for. A list of one-line records stays tight.
387
+ const spaced = records.length > 1 && records.every((r) => r.length > 1);
388
+ return [`${key}:`, ...records.flatMap((r, i) => (spaced && i > 0 ? ['', ...r] : r))];
389
+ }
390
+
391
+ /**
392
+ * A body written from data, in the order and the forms the profile declares. The profile owns
393
+ * the shape, so this branches on no document kind: hand it a docset index or an ADR and the
394
+ * difference is which profile it read.
395
+ *
396
+ * A block the data does not carry is left out rather than filled with a placeholder. A skeleton
397
+ * that conformed by inventing content would claim a completeness nobody wrote, and `cnlp check`
398
+ * is then the list of what the author still owes — which is the same honesty `status: source`
399
+ * gives a catalog record with no page yet.
400
+ */
401
+ export function renderBody(data, profile) {
402
+ const declared = new Set(profile.sections.map((s) => s.key));
403
+ for (const key of Object.keys(data)) {
404
+ if (declared.has(key) || profile.frontmatterFields.includes(key) || profile.custom.includes(key)) continue;
405
+ throw new Error(`"${key}" is not a block this profile declares — it declares ${[...declared].join(', ')}`);
406
+ }
407
+ const out = [];
408
+ const front = profile.frontmatterFields.filter((f) => data[f] !== undefined);
409
+ if (front.length) out.push('---', ...front.map((f) => `${f}: ${data[f]}`), '---', '');
410
+ let under = '';
411
+ for (const spec of profile.sections) {
412
+ if (data[spec.key] === undefined) continue;
413
+ if (spec.heading && spec.heading !== under) { out.push(spec.heading, ''); under = spec.heading; }
414
+ out.push(...sectionLines(spec, data[spec.key]), '');
415
+ }
416
+ return `${out.join('\n').trimEnd()}\n`;
417
+ }
418
+
419
+ /**
420
+ * What moved between a document and the data a fresh read of its sources produced. A record is
421
+ * identified by the first key the profile declares for its block, so this branches on no document
422
+ * kind: the profile names what identifies a record, and the data names which keys are worth
423
+ * comparing — a key the data leaves out is a key this does not police.
424
+ *
425
+ * A record the data carries a key for and the document does not is reported rather than skipped.
426
+ * A comparison covering fewer records than it claims is the failure the comparison exists to catch,
427
+ * which is the same shape as a catalog whose absent value reads as a present one.
428
+ */
429
+ export function recordDrift(raw, data, profile) {
430
+ const { lines, sections } = parseBlocks(raw);
431
+ const out = [];
432
+ const at = (block, id, change) => out.push({ block, id, change });
433
+ for (const spec of profile.sections) {
434
+ if (spec.form !== 'record-list' || data[spec.key] === undefined) continue;
435
+ const idKey = spec.recordKeys[0];
436
+ if (!idKey) continue;
437
+ const block = sections.find((s) => s.key === spec.key);
438
+ const have = new Map();
439
+ if (block) {
440
+ for (const rec of readRecords(itemsOf(lines, block.line)).records) have.set(rec.values[idKey], rec.values);
441
+ }
442
+ const want = new Map(data[spec.key].map((rec) => [String(rec[idKey]), rec]));
443
+ for (const [id, rec] of want) {
444
+ const got = have.get(id);
445
+ if (!got) { at(spec.key, id, 'added — the document has no record for it'); continue; }
446
+ for (const key of Object.keys(rec)) {
447
+ if (key === idKey) continue;
448
+ if (got[key] === undefined) at(spec.key, id, `carries no "${key}:", so a change to it goes unreported`);
449
+ else if (String(got[key]) !== String(rec[key])) at(spec.key, id, `"${key}:" moved`);
450
+ }
451
+ }
452
+ for (const id of have.keys()) if (!want.has(id)) at(spec.key, id, 'removed — the source states it no longer');
453
+ }
454
+ return out;
455
+ }
456
+
457
+ /** §9: the 3 keys that carry a reference. A name anywhere else is a mention, not a reference. */
458
+ const REFERENCE = /^\s*-?\s*(page|by|docset):\s*(\S+)\s*$/;
459
+
460
+ /**
461
+ * §9 references a document makes, each with the block it sits in, so a caller can hold a
462
+ * profile's own meaning for one — an `overrides:` `page:` is not a `see_also:` `page:`. Fenced
463
+ * regions are already blank here, so a `page:` line inside an example is not a reference.
464
+ */
465
+ export function pageRefs(raw) {
466
+ const { lines, sections } = parseBlocks(raw);
467
+ const out = [];
468
+ for (const { key, line } of sections) {
469
+ for (const item of itemsOf(lines, line)) {
470
+ const ref = item.text.match(REFERENCE);
471
+ if (ref) out.push({ block: key, key: ref[1], target: ref[2], line: item.line });
472
+ }
473
+ }
474
+ return out;
475
+ }
476
+
477
+ /** §5: quoted or backticked text is data, not a claim, so the lexicon does not police it. */
478
+ const unquoted = (l) => l.replace(/`[^`]*`|"[^"]*"|'[^']*'/g, ' ');
479
+
480
+ /** Delimiters that open and close on the same line. A backtick is its own closer. */
481
+ const UNCLOSED = [
482
+ ['backtick', /`/g, null],
483
+ ['parenthesis', /\(/g, /\)/g],
484
+ ['bracket', /\[/g, /\]/g],
485
+ ];
486
+
487
+ /**
488
+ * §5 lexicon. One rule set for every profile, except the active voice: `mood` decides whether the
489
+ * document has an actor to name, so a caller reaching this directly passes the profile's mood or
490
+ * gets every rule that holds whatever the register is.
491
+ */
492
+ export function lexiconIssues(lines, mood = '') {
493
+ const issues = [];
494
+ lines.forEach((l, i) => {
495
+ const line = i + 1;
496
+ const text = unquoted(l);
497
+ // Every line, not only a bullet: a scalar or a record sub-key holds one idea too.
498
+ if (l.length > HARD_LIMIT) {
499
+ issues.push({ line, message: `${l.length} chars exceeds the ${HARD_LIMIT} hard limit — it is holding more than one idea` });
500
+ }
501
+ if (/^- never\b/.test(l)) issues.push({ line, message: 'a prohibition opens with "do not", not "never"' });
502
+ // A converted document is where these 2 turn up: a mechanical split reads an ordinal as a
503
+ // sentence and leaves `- 4` behind, and cutting a line to the hard limit lands mid-span.
504
+ const item = l.match(/^\s*(?:[-*]|\d+\.)\s+(.*)$/);
505
+ if (item && !/[a-z]/i.test(item[1])) issues.push({ line, message: 'the item carries no word — it states no claim' });
506
+ for (const [name, open, close] of UNCLOSED) {
507
+ const n = (l.match(open) || []).length;
508
+ if (close ? n !== (l.match(close) || []).length : n % 2) {
509
+ issues.push({ line, message: `unclosed ${name} — 1 idea per line and no wrapping, so this line was cut` });
510
+ }
511
+ }
512
+ const deny = text.match(DENY);
513
+ if (deny) issues.push({ line, message: `deny-list word "${deny[0]}"` });
514
+ const spelled = text.match(SPELLED_THRESHOLD);
515
+ if (spelled) issues.push({ line, message: `"${spelled[0]}" — a threshold is a digit` });
516
+ const limit = text.match(AMBIGUOUS_LIMIT);
517
+ if (limit) issues.push({ line, message: `"${limit[0]}" leaves its bound open — state the limit as "<subject> <= <value> <unit>"` });
518
+ const comp = text.match(COMPARATIVE);
519
+ if (comp) issues.push({ line, message: `unquantified comparative "${comp[0]}" — name the property and its bound` });
520
+ // Not the corpus this ships with, which is clean: like the unclosed-span checks above, this is
521
+ // what a document arriving from prose carries, where the actor was never on the line.
522
+ const nom = text.match(NOMINALIZATION);
523
+ if (nom) issues.push({ line, message: `"${nom[0]}" hides a verb in a noun — write the verb` });
524
+ if (mood === 'imperative') {
525
+ const passive = text.match(PASSIVE);
526
+ if (passive && !STATE_PARTICIPLE.test(passive[1])) {
527
+ issues.push({ line, message: `"${passive[0]}" is passive and names no actor — an imperative document says who acts` });
528
+ }
529
+ }
530
+ });
531
+ return issues;
532
+ }
533
+
534
+ export { FORMS };