@warpgogol/forge 2.8.0 → 2.8.2

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 (271) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/os/session/handlers/validate.d.ts.map +1 -1
  5. package/dist/os/session/handlers/validate.js +23 -1
  6. package/dist/os/session/handlers/validate.js.map +1 -1
  7. package/dist/os/session/types.d.ts +31 -1
  8. package/dist/os/session/types.d.ts.map +1 -1
  9. package/dist/os/session/types.js +7 -0
  10. package/dist/os/session/types.js.map +1 -1
  11. package/os/adr/adr.module.ts +158 -0
  12. package/os/adr/frontmatter-io.ts +80 -0
  13. package/os/adr/handlers/archive.ts +244 -0
  14. package/os/adr/handlers/implement-stamp.ts +331 -0
  15. package/os/adr/handlers/list-create.ts +207 -0
  16. package/os/adr/handlers/validate.test.ts +232 -0
  17. package/os/adr/handlers/validate.ts +429 -0
  18. package/os/adr/index.ts +33 -0
  19. package/os/adr/types.ts +141 -0
  20. package/os/audit/audit.module.ts +48 -0
  21. package/os/audit/frontmatter-io.ts +84 -0
  22. package/os/audit/handlers/archive.ts +239 -0
  23. package/os/audit/index.ts +23 -0
  24. package/os/audit/types.ts +29 -0
  25. package/os/compass/compass.module.ts +186 -0
  26. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  27. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  28. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  29. package/os/compass/handlers/compass-inventory.ts +521 -0
  30. package/os/compass/handlers/git-revision.ts +137 -0
  31. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  32. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  33. package/os/compass/index.ts +33 -0
  34. package/os/core/core.module.ts +948 -0
  35. package/os/core/handlers/assets-check.ts +122 -0
  36. package/os/core/handlers/assets-helpers.ts +174 -0
  37. package/os/core/handlers/assets-list.ts +99 -0
  38. package/os/core/handlers/build.ts +155 -0
  39. package/os/core/handlers/determinism-check.ts +354 -0
  40. package/os/core/handlers/dev.ts +127 -0
  41. package/os/core/handlers/invariant-engine.test.ts +681 -0
  42. package/os/core/handlers/knowledge-compact.ts +248 -0
  43. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  44. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  45. package/os/core/handlers/note-link-validate.test.ts +117 -0
  46. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  47. package/os/core/handlers/package-health.test.ts +226 -0
  48. package/os/core/handlers/package-health.ts +231 -0
  49. package/os/core/handlers/pinned-check.ts +200 -0
  50. package/os/core/handlers/pinned-init.ts +333 -0
  51. package/os/core/handlers/pinned-types.ts +50 -0
  52. package/os/core/handlers/pinned-validate.ts +301 -0
  53. package/os/core/handlers/profile-resolve.ts +92 -0
  54. package/os/core/handlers/release-prepare.ts +294 -0
  55. package/os/core/handlers/release-publish.ts +211 -0
  56. package/os/core/handlers/validate.ts +262 -0
  57. package/os/core/handlers/workspace-deps.ts +55 -0
  58. package/os/core/index.ts +13 -0
  59. package/os/exploration/exploration.module.ts +90 -0
  60. package/os/exploration/frontmatter-io.ts +77 -0
  61. package/os/exploration/handlers/archive.ts +159 -0
  62. package/os/exploration/handlers/list.ts +77 -0
  63. package/os/exploration/handlers/show.ts +107 -0
  64. package/os/exploration/index.ts +25 -0
  65. package/os/exploration/types.ts +65 -0
  66. package/os/mission/handlers/archive.test.ts +391 -0
  67. package/os/mission/handlers/archive.ts +453 -0
  68. package/os/mission/index.ts +1 -0
  69. package/os/mission/mission.module.ts +48 -0
  70. package/os/mission/types.ts +41 -0
  71. package/os/naming/index.ts +14 -0
  72. package/os/naming/naming-convention.test.ts +163 -0
  73. package/os/naming/naming-convention.ts +378 -0
  74. package/os/naming/naming.module.ts +36 -0
  75. package/os/notes/index.ts +10 -0
  76. package/os/notes/notes.module.ts +109 -0
  77. package/os/plan/frontmatter-io.ts +82 -0
  78. package/os/plan/handlers/archive.ts +238 -0
  79. package/os/plan/index.ts +23 -0
  80. package/os/plan/plan.module.ts +48 -0
  81. package/os/plan/types.ts +29 -0
  82. package/os/program/discovery.ts +294 -0
  83. package/os/program/handlers/complete.ts +419 -0
  84. package/os/program/handlers/lease.ts +433 -0
  85. package/os/program/handlers/seal.ts +332 -0
  86. package/os/program/handlers/validate.ts +213 -0
  87. package/os/program/lease.ts +156 -0
  88. package/os/program/program.module.ts +212 -0
  89. package/os/program/schemas.ts +218 -0
  90. package/os/program/state.ts +264 -0
  91. package/os/rfc/acceptance.ts +344 -0
  92. package/os/rfc/decision-log.ts +310 -0
  93. package/os/rfc/dna-trace.ts +280 -0
  94. package/os/rfc/frontmatter-io.test.ts +132 -0
  95. package/os/rfc/frontmatter-io.ts +130 -0
  96. package/os/rfc/handlers/archive.ts +300 -0
  97. package/os/rfc/handlers/check.ts +238 -0
  98. package/os/rfc/handlers/implement-stamp.ts +515 -0
  99. package/os/rfc/handlers/index-graph.ts +237 -0
  100. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  101. package/os/rfc/handlers/lifecycle.ts +249 -0
  102. package/os/rfc/handlers/list-create.ts +296 -0
  103. package/os/rfc/handlers/pipeline-status.ts +173 -0
  104. package/os/rfc/handlers/shared.ts +121 -0
  105. package/os/rfc/handlers/supersede-propose.ts +241 -0
  106. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  107. package/os/rfc/handlers/validate-rules.ts +984 -0
  108. package/os/rfc/handlers/validate.ts +166 -0
  109. package/os/rfc/handlers.ts +19 -0
  110. package/os/rfc/index.ts +97 -0
  111. package/os/rfc/rfc.module.ts +416 -0
  112. package/os/rfc/types.ts +596 -0
  113. package/os/rfc/verification-evidence.ts +244 -0
  114. package/os/session/atif-parser.ts +166 -0
  115. package/os/session/frontmatter-io.ts +163 -0
  116. package/os/session/handlers/archive.ts +288 -0
  117. package/os/session/handlers/list.ts +174 -0
  118. package/os/session/handlers/save.ts +348 -0
  119. package/os/session/handlers/validate.ts +250 -0
  120. package/os/session/index.ts +50 -0
  121. package/os/session/session.module.ts +126 -0
  122. package/os/session/types.ts +203 -0
  123. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  124. package/os/spec/live-spec-list.ts +78 -0
  125. package/os/spec/live-spec-merge.test.ts +261 -0
  126. package/os/spec/live-spec-merge.ts +413 -0
  127. package/os/spec/live-spec-show.ts +117 -0
  128. package/os/spec/live-spec-types.ts +93 -0
  129. package/os/spec/live-spec-validate.ts +170 -0
  130. package/os/spec/spec-materialize.ts +390 -0
  131. package/os/spec/spec-schema.ts +174 -0
  132. package/os/spec/spec-status.ts +284 -0
  133. package/os/spec/spec-validate.ts +513 -0
  134. package/os/spec/spec.module.ts +135 -0
  135. package/os/werkstatt/handlers/lock.ts +168 -0
  136. package/os/werkstatt/handlers/schema.ts +47 -0
  137. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  138. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  139. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  140. package/os/werkstatt/index.ts +39 -0
  141. package/os/werkstatt/werkstatt.module.ts +62 -0
  142. package/os/workflow/handlers.ts +304 -0
  143. package/os/workflow/index.ts +28 -0
  144. package/os/workflow/types.ts +99 -0
  145. package/os/workflow/workflow.module.ts +54 -0
  146. package/package.json +3 -3
  147. package/profiles/godot-csharp.yaml +4 -4
  148. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  149. package/profiles/phaser-turborepo.yaml +5 -5
  150. package/skills/_shared/fo-session-summary.md +132 -16
  151. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  152. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  153. package/skills/fo/fo-handoff/SKILL.md +5 -0
  154. package/skills/fo/fo-idea/SKILL.md +1 -1
  155. package/skills/fo/fo-review/SKILL.md +1 -1
  156. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  157. package/skills/fo/fo-session-save/SKILL.md +8 -0
  158. package/src/cli-output.ts +59 -0
  159. package/src/config/__tests__/resolve-terminology.test.ts +45 -0
  160. package/src/config/forge-config.ts +524 -0
  161. package/src/forge-module.ts +39 -0
  162. package/src/index.ts +172 -0
  163. package/src/knowledge/__tests__/promote.test.ts +219 -0
  164. package/src/knowledge/__tests__/serialize.test.ts +192 -0
  165. package/src/knowledge/budgets.ts +202 -0
  166. package/src/knowledge/compact.ts +405 -0
  167. package/src/knowledge/index.ts +53 -0
  168. package/src/knowledge/parse.ts +227 -0
  169. package/src/knowledge/promote.ts +156 -0
  170. package/src/knowledge/schema.ts +112 -0
  171. package/src/knowledge/serialize.ts +72 -0
  172. package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
  173. package/src/migration-adapters/__tests__/registry.test.ts +131 -0
  174. package/src/migration-adapters/__tests__/types.test.ts +86 -0
  175. package/src/migration-adapters/git-utils.ts +71 -0
  176. package/src/migration-adapters/ignored-files.ts +221 -0
  177. package/src/migration-adapters/index.ts +21 -0
  178. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  179. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  180. package/src/migration-adapters/registry.ts +61 -0
  181. package/src/migration-adapters/types.ts +68 -0
  182. package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
  183. package/src/onboarding/agents-generate.ts +443 -0
  184. package/src/onboarding/create.ts +427 -0
  185. package/src/onboarding/doctor.ts +1297 -0
  186. package/src/onboarding/init.ts +337 -0
  187. package/src/onboarding/invariant-engine.ts +448 -0
  188. package/src/onboarding/memory-scaffold.ts +189 -0
  189. package/src/onboarding/nested-agents-generate.ts +93 -0
  190. package/src/onboarding/nested-agents-templates.ts +233 -0
  191. package/src/onboarding/profile-validate.ts +139 -0
  192. package/src/onboarding/scaffold-project.ts +299 -0
  193. package/src/onboarding/scaffold.ts +143 -0
  194. package/src/onboarding/upgrade.ts +558 -0
  195. package/src/onboarding/workspace-discovery.ts +167 -0
  196. package/src/profiles/__tests__/profile-schema.test.ts +228 -0
  197. package/src/profiles/__tests__/stack-profile.test.ts +190 -0
  198. package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
  199. package/src/profiles/profile-schema.ts +350 -0
  200. package/src/profiles/stack-profile.ts +188 -0
  201. package/src/profiles/terminology-utils.ts +36 -0
  202. package/src/registry.ts +185 -0
  203. package/src/skill-schema.ts +37 -0
  204. package/src/tests/acceptance-criteria.test.ts +99 -0
  205. package/src/tests/adr-implement-stamp.test.ts +368 -0
  206. package/src/tests/agents-generate-domain.test.ts +317 -0
  207. package/src/tests/agents-generate.test.ts +458 -0
  208. package/src/tests/bindings-schema.test.ts +314 -0
  209. package/src/tests/budgets.test.ts +213 -0
  210. package/src/tests/cli-output.test.ts +196 -0
  211. package/src/tests/compact.test.ts +796 -0
  212. package/src/tests/create.test.ts +289 -0
  213. package/src/tests/doctor-autonomy.test.ts +98 -0
  214. package/src/tests/doctor-bindings.test.ts +286 -0
  215. package/src/tests/doctor-domain.test.ts +113 -0
  216. package/src/tests/exploration-handlers.test.ts +256 -0
  217. package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
  218. package/src/tests/forge-config.test.ts +420 -0
  219. package/src/tests/fs-atomic.test.ts +96 -0
  220. package/src/tests/fs.test.ts +103 -0
  221. package/src/tests/generated-marker.test.ts +161 -0
  222. package/src/tests/hash.test.ts +37 -0
  223. package/src/tests/implement-stamp.test.ts +505 -0
  224. package/src/tests/init-bindings.test.ts +183 -0
  225. package/src/tests/knowledge-parse.test.ts +307 -0
  226. package/src/tests/knowledge-pbt.test.ts +164 -0
  227. package/src/tests/memory-scaffold.test.ts +192 -0
  228. package/src/tests/migration-adapters.test.ts +480 -0
  229. package/src/tests/package-files.test.ts +54 -0
  230. package/src/tests/pinned-check.test.ts +194 -0
  231. package/src/tests/pinned-init.test.ts +126 -0
  232. package/src/tests/profile-schema.test.ts +194 -0
  233. package/src/tests/profile-validate.test.ts +120 -0
  234. package/src/tests/program-lease.test.ts +245 -0
  235. package/src/tests/program-paths-extended.test.ts +117 -0
  236. package/src/tests/program-paths.test.ts +98 -0
  237. package/src/tests/program-property.test.ts +139 -0
  238. package/src/tests/program-schemas.test.ts +445 -0
  239. package/src/tests/program-spec-node.test.ts +250 -0
  240. package/src/tests/promote.test.ts +481 -0
  241. package/src/tests/registry.test.ts +73 -0
  242. package/src/tests/scaffold-project.test.ts +150 -0
  243. package/src/tests/session-handlers.test.ts +503 -0
  244. package/src/tests/session-pbt.test.ts +190 -0
  245. package/src/tests/skill-schema.test.ts +146 -0
  246. package/src/tests/skill-validate-knowledge.test.ts +26 -0
  247. package/src/tests/skill-validate.test.ts +254 -0
  248. package/src/tests/stack-profile.test.ts +221 -0
  249. package/src/tests/string-utils.test.ts +42 -0
  250. package/src/tests/upgrade.test.ts +428 -0
  251. package/src/tests/werkstatt-lock.test.ts +343 -0
  252. package/src/tests/workspace-discovery-domain.test.ts +112 -0
  253. package/src/tests/workspace-discovery.test.ts +135 -0
  254. package/src/types.ts +221 -0
  255. package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
  256. package/src/utils/__tests__/fs-trash.test.ts +45 -0
  257. package/src/utils/fs-atomic.ts +93 -0
  258. package/src/utils/fs-idempotent.ts +41 -0
  259. package/src/utils/fs-trash-sync.ts +37 -0
  260. package/src/utils/fs-trash.ts +24 -0
  261. package/src/utils/fs.ts +74 -0
  262. package/src/utils/generated-marker.ts +166 -0
  263. package/src/utils/hash.ts +19 -0
  264. package/src/utils/index.ts +29 -0
  265. package/src/utils/string-utils.ts +19 -0
  266. package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
  267. package/src/validators/note-frontmatter-validate.ts +142 -0
  268. package/src/validators/note-link-validate.ts +145 -0
  269. package/src/validators/note-orphan-detect.ts +146 -0
  270. package/src/validators/port-validate.ts +97 -0
  271. package/src/validators/skill-validate.ts +831 -0
@@ -0,0 +1,280 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-0331: DNA-trace builder, validate command, and generate command.
5
+ Parses DNA invariants from docs/architecture-dna.md, collects satisfies
6
+ entries from all RFCs, and builds a bidirectional trace matrix.
7
+ </purpose>
8
+ <non-goals>
9
+ <item>Do not validate registry establishment — that is dna.registry.validate (RFC-0158).</item>
10
+ <item>Do not backfill satisfies for pre-cutoff RFCs.</item>
11
+ </non-goals>
12
+ </MODULE_CONTRACT>
13
+ <CHANGE_SUMMARY>
14
+ <item>RFC-0331: initial implementation.</item>
15
+ </CHANGE_SUMMARY>
16
+ */
17
+
18
+ import { readFile } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+
21
+ import { writeFileAtomic } from "../../src/utils/fs-atomic.ts";
22
+ import { buildGeneratedHeader } from "../../src/utils/generated-marker.ts";
23
+ import { stringify as yamlStringify } from "yaml";
24
+ import { listRfcFiles, readAndParseRfc } from "./frontmatter-io.ts";
25
+ import { RFC_DIR } from "./types.ts";
26
+ import type {
27
+ Diagnostic,
28
+ ForgeCommandInput,
29
+ ForgeCommandResult,
30
+ ForgeRuntimeContext,
31
+ } from "../../src/types.ts";
32
+
33
+ const DNA_REGISTRY_PATH = "docs/architecture-dna.md";
34
+ const DNA_HEADING_RE = /^##\s+DNA-(\d+)\b/gm;
35
+
36
+ export interface DnaTraceEntry {
37
+ dnaId: string;
38
+ satisfiedBy: {
39
+ implemented: string[];
40
+ accepted: string[];
41
+ draft: string[];
42
+ other: string[];
43
+ };
44
+ }
45
+
46
+ export interface DnaTraceResult {
47
+ command: "rfc.dna.trace.validate";
48
+ status: "pass" | "fail";
49
+ dnaCount: number;
50
+ tracedDnaCount: number;
51
+ entries: DnaTraceEntry[];
52
+ diagnostics: Diagnostic[];
53
+ written?: string;
54
+ }
55
+
56
+ async function parseDnaRegistry(workspaceRoot: string): Promise<Set<string>> {
57
+ const ids = new Set<string>();
58
+ try {
59
+ const src = await readFile(join(workspaceRoot, DNA_REGISTRY_PATH), "utf-8");
60
+ for (const m of src.matchAll(DNA_HEADING_RE)) {
61
+ ids.add(`DNA-${m[1]}`);
62
+ }
63
+ } catch {
64
+ // handled by caller
65
+ }
66
+ return ids;
67
+ }
68
+
69
+ interface RfcSatisfiesEntry {
70
+ rfcId: string;
71
+ status: string;
72
+ satisfies: string[];
73
+ }
74
+
75
+ async function collectSatisfies(workspaceRoot: string): Promise<RfcSatisfiesEntry[]> {
76
+ const rfcDirPath = join(workspaceRoot, RFC_DIR);
77
+ const files = await listRfcFiles(rfcDirPath);
78
+ const entries: RfcSatisfiesEntry[] = [];
79
+ for (const fileName of files) {
80
+ const result = await readAndParseRfc(rfcDirPath, fileName);
81
+ if (!result || "error" in result) continue;
82
+ const fm = result.parsed.frontmatter;
83
+ const rfcId = String(fm["id"] ?? "");
84
+ const status = String(fm["status"] ?? "");
85
+ const satisfies = Array.isArray(fm["satisfies"])
86
+ ? (fm["satisfies"] as unknown[]).map(String)
87
+ : [];
88
+ if (satisfies.length > 0) {
89
+ entries.push({ rfcId, status, satisfies });
90
+ }
91
+ }
92
+ return entries;
93
+ }
94
+
95
+ export async function buildDnaTrace(
96
+ workspaceRoot: string,
97
+ ): Promise<{ entries: DnaTraceEntry[]; diagnostics: Diagnostic[]; registryIds: Set<string> }> {
98
+ const registryIds = await parseDnaRegistry(workspaceRoot);
99
+ const rfcEntries = await collectSatisfies(workspaceRoot);
100
+ const diagnostics: Diagnostic[] = [];
101
+
102
+ if (registryIds.size === 0) {
103
+ diagnostics.push({
104
+ ruleId: "DNA-TRACE-00",
105
+ severity: "error",
106
+ file: DNA_REGISTRY_PATH,
107
+ message: `DNA registry not found or unreadable at ${DNA_REGISTRY_PATH}.`,
108
+ });
109
+ }
110
+
111
+ // Build entries for every registry DNA id
112
+ const entries: DnaTraceEntry[] = [];
113
+ for (const dnaId of [...registryIds].sort()) {
114
+ const satisfiedBy = {
115
+ implemented: [] as string[],
116
+ accepted: [] as string[],
117
+ draft: [] as string[],
118
+ other: [] as string[],
119
+ };
120
+ for (const rfc of rfcEntries) {
121
+ if (!rfc.satisfies.includes(dnaId)) continue;
122
+ switch (rfc.status) {
123
+ case "implemented":
124
+ satisfiedBy.implemented.push(rfc.rfcId);
125
+ break;
126
+ case "accepted":
127
+ satisfiedBy.accepted.push(rfc.rfcId);
128
+ break;
129
+ case "draft":
130
+ case "reviewing":
131
+ satisfiedBy.draft.push(rfc.rfcId);
132
+ break;
133
+ default:
134
+ satisfiedBy.other.push(rfc.rfcId);
135
+ break;
136
+ }
137
+ }
138
+ entries.push({ dnaId, satisfiedBy });
139
+ }
140
+
141
+ // DNA-TRACE-01: satisfies entry references a DNA id absent from the registry
142
+ for (const rfc of rfcEntries) {
143
+ for (const id of rfc.satisfies) {
144
+ if (!/^DNA-\d+$/.test(id)) continue; // V-24 handles format
145
+ if (!registryIds.has(id)) {
146
+ diagnostics.push({
147
+ ruleId: "DNA-TRACE-01",
148
+ severity: "error",
149
+ message: `${rfc.rfcId} satisfies "${id}" but this DNA id is not in the registry (${DNA_REGISTRY_PATH}).`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ // DNA-TRACE-02: DNA invariant with zero accepted/implemented satisfiers
156
+ for (const entry of entries) {
157
+ const hasCoverage =
158
+ entry.satisfiedBy.implemented.length > 0 || entry.satisfiedBy.accepted.length > 0;
159
+ if (!hasCoverage) {
160
+ diagnostics.push({
161
+ ruleId: "DNA-TRACE-02",
162
+ severity: "info",
163
+ file: DNA_REGISTRY_PATH,
164
+ message: `${entry.dnaId} has no accepted/implemented RFC referencing it via satisfies (historical RFCs are exempt).`,
165
+ });
166
+ }
167
+ }
168
+
169
+ // DNA-TRACE-03: satisfies on a rejected RFC
170
+ for (const rfc of rfcEntries) {
171
+ if (rfc.status === "rejected") {
172
+ for (const id of rfc.satisfies) {
173
+ diagnostics.push({
174
+ ruleId: "DNA-TRACE-03",
175
+ severity: "warning",
176
+ message: `${rfc.rfcId} (status: rejected) still declares satisfies "${id}" — a rejected RFC satisfies nothing; the claim is stale.`,
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ return { entries, diagnostics, registryIds };
183
+ }
184
+
185
+ export async function runRfcDnaTraceValidate(
186
+ input: ForgeCommandInput,
187
+ context: ForgeRuntimeContext,
188
+ ): Promise<ForgeCommandResult<DnaTraceResult>> {
189
+ const { workspaceRoot, logger, outputFormat } = context;
190
+ const dnaFilter = input.flags["dna"] as string | undefined;
191
+
192
+ const { entries, diagnostics, registryIds } = await buildDnaTrace(workspaceRoot);
193
+
194
+ let filteredEntries = entries;
195
+ if (dnaFilter) {
196
+ filteredEntries = entries.filter((e) => e.dnaId === dnaFilter);
197
+ if (filteredEntries.length === 0) {
198
+ diagnostics.push({
199
+ ruleId: "DNA-TRACE-INFO",
200
+ severity: "info",
201
+ message: `--dna ${dnaFilter} matched no registry entries.`,
202
+ });
203
+ }
204
+ }
205
+
206
+ const tracedDnaCount = entries.filter(
207
+ (e) => e.satisfiedBy.implemented.length > 0 || e.satisfiedBy.accepted.length > 0,
208
+ ).length;
209
+
210
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
211
+ const status: DnaTraceResult["status"] = hasErrors ? "fail" : "pass";
212
+
213
+ if (outputFormat === "pretty") {
214
+ logger.section(`DNA trace (${registryIds.size} invariants, ${tracedDnaCount} traced)`);
215
+ for (const entry of filteredEntries) {
216
+ const impl = entry.satisfiedBy.implemented.join(", ");
217
+ const acc = entry.satisfiedBy.accepted.join(", ");
218
+ logger.info(`${entry.dnaId}: implemented=[${impl}] accepted=[${acc}]`);
219
+ }
220
+ for (const d of diagnostics) {
221
+ if (d.severity === "error") logger.error(`[${d.ruleId}] ${d.message}`);
222
+ else if (d.severity === "warning") logger.warn(`[${d.ruleId}] ${d.message}`);
223
+ }
224
+ }
225
+
226
+ return {
227
+ data: {
228
+ command: "rfc.dna.trace.validate",
229
+ status,
230
+ dnaCount: registryIds.size,
231
+ tracedDnaCount,
232
+ entries: filteredEntries,
233
+ diagnostics,
234
+ },
235
+ exitCode: hasErrors ? 1 : 0,
236
+ summary: `rfc.dna.trace.validate: ${registryIds.size} DNA invariants, ${tracedDnaCount} traced, ${diagnostics.filter((d) => d.severity === "error").length} error(s)`,
237
+ };
238
+ }
239
+
240
+ export async function runRfcDnaTraceGenerate(
241
+ _input: ForgeCommandInput,
242
+ context: ForgeRuntimeContext,
243
+ ): Promise<ForgeCommandResult<DnaTraceResult>> {
244
+ const { workspaceRoot, logger, outputFormat } = context;
245
+
246
+ const { entries, diagnostics, registryIds } = await buildDnaTrace(workspaceRoot);
247
+
248
+ const tracedDnaCount = entries.filter(
249
+ (e) => e.satisfiedBy.implemented.length > 0 || e.satisfiedBy.accepted.length > 0,
250
+ ).length;
251
+
252
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
253
+ const status: DnaTraceResult["status"] = hasErrors ? "fail" : "pass";
254
+
255
+ const outRel = join(RFC_DIR, "dna-trace.generated.yaml");
256
+ const payload: DnaTraceResult = {
257
+ command: "rfc.dna.trace.validate",
258
+ status,
259
+ dnaCount: registryIds.size,
260
+ tracedDnaCount,
261
+ entries,
262
+ diagnostics,
263
+ written: outRel,
264
+ };
265
+ const jsonContent = `${buildGeneratedHeader({ filePath: outRel, ownerCommand: "rfc.dna.trace.generate" })}${yamlStringify({ ...payload })}\n`;
266
+ await writeFileAtomic(join(workspaceRoot, outRel), jsonContent);
267
+
268
+ if (outputFormat === "pretty") {
269
+ logger.section(
270
+ `DNA trace generated (${registryIds.size} invariants, ${tracedDnaCount} traced)`,
271
+ );
272
+ logger.success(`Wrote ${outRel}`);
273
+ }
274
+
275
+ return {
276
+ data: payload,
277
+ exitCode: hasErrors ? 1 : 0,
278
+ summary: `rfc.dna.trace.generate: wrote ${outRel} (${registryIds.size} DNA, ${tracedDnaCount} traced)`,
279
+ };
280
+ }
@@ -0,0 +1,132 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { listRfcFiles, readAndParseRfc, parseRfcFile } from "./frontmatter-io.ts";
6
+
7
+ describe("listRfcFiles — recursive scanning (RFC-0491)", () => {
8
+ test("scans archive/ subdirectories recursively", async () => {
9
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-test-"));
10
+ try {
11
+ // Create top-level RFC files
12
+ await fs.writeFile(
13
+ path.join(tmpDir, "rfc-0100-active.md"),
14
+ "---\nid: RFC-0100\n---\n# RFC-0100\n",
15
+ );
16
+ // Create archive subdirectory with a higher-numbered RFC
17
+ await fs.mkdir(path.join(tmpDir, "archive", "implemented"), { recursive: true });
18
+ await fs.writeFile(
19
+ path.join(tmpDir, "archive", "implemented", "rfc-0500-archived.md"),
20
+ "---\nid: RFC-0500\n---\n# RFC-0500\n",
21
+ );
22
+
23
+ const files = await listRfcFiles(tmpDir);
24
+
25
+ // Both the top-level and archived RFC must be found
26
+ expect(files).toContain("rfc-0100-active.md");
27
+ expect(files).toContain("archive/implemented/rfc-0500-archived.md");
28
+ expect(files.length).toBe(2);
29
+ } finally {
30
+ await fs.rm(tmpDir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test("returns empty array when directory does not exist", async () => {
35
+ const files = await listRfcFiles(path.join(os.tmpdir(), "nonexistent-rfc-dir-xyz"));
36
+ expect(files).toEqual([]);
37
+ });
38
+
39
+ test("excludes rfc-0000 template and README.md", async () => {
40
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-test-"));
41
+ try {
42
+ await fs.writeFile(
43
+ path.join(tmpDir, "rfc-0000-template.md"),
44
+ "---\nid: RFC-0000\n---\n# RFC-0000\n",
45
+ );
46
+ await fs.writeFile(path.join(tmpDir, "README.md"), "# RFC docs\n");
47
+ await fs.writeFile(
48
+ path.join(tmpDir, "rfc-0100-real.md"),
49
+ "---\nid: RFC-0100\n---\n# RFC-0100\n",
50
+ );
51
+
52
+ const files = await listRfcFiles(tmpDir);
53
+
54
+ expect(files).toContain("rfc-0100-real.md");
55
+ expect(files).not.toContain("rfc-0000-template.md");
56
+ expect(files).not.toContain("README.md");
57
+ } finally {
58
+ await fs.rm(tmpDir, { recursive: true, force: true });
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("readAndParseRfc — YAML parse error handling (RFC-0755)", () => {
64
+ test("returns error variant for malformed YAML frontmatter", async () => {
65
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-parse-"));
66
+ try {
67
+ const malformedYaml =
68
+ "---\nid: RFC-0100\ntitle: Test\n bad: indentation: here\n---\n# Body\n";
69
+ await fs.writeFile(path.join(tmpDir, "rfc-0100-bad.md"), malformedYaml);
70
+
71
+ const result = await readAndParseRfc(tmpDir, "rfc-0100-bad.md");
72
+
73
+ expect(result).toBeDefined();
74
+ expect(result && "error" in result).toBe(true);
75
+ if (result && "error" in result) {
76
+ expect(result.error).toContain("YAML parse error");
77
+ expect(result.fileName).toBe("rfc-0100-bad.md");
78
+ }
79
+ } finally {
80
+ await fs.rm(tmpDir, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("returns parsed variant for valid YAML frontmatter", async () => {
85
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-parse-"));
86
+ try {
87
+ const validYaml = "---\nid: RFC-0100\ntitle: Test\nstatus: draft\n---\n# Body\n";
88
+ await fs.writeFile(path.join(tmpDir, "rfc-0100-ok.md"), validYaml);
89
+
90
+ const result = await readAndParseRfc(tmpDir, "rfc-0100-ok.md");
91
+
92
+ expect(result).toBeDefined();
93
+ expect(result && "parsed" in result).toBe(true);
94
+ if (result && "parsed" in result) {
95
+ expect(result.parsed.frontmatter["id"]).toBe("RFC-0100");
96
+ expect(result.parsed.body).toContain("# Body");
97
+ }
98
+ } finally {
99
+ await fs.rm(tmpDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("returns error variant for non-existent file", async () => {
104
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-parse-"));
105
+ try {
106
+ const result = await readAndParseRfc(tmpDir, "nonexistent.md");
107
+ expect(result).toBeDefined();
108
+ expect(result && "error" in result).toBe(true);
109
+ if (result && "error" in result) {
110
+ expect(result.error).toContain("YAML parse error");
111
+ }
112
+ } finally {
113
+ await fs.rm(tmpDir, { recursive: true, force: true });
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("parseRfcFile — basic parsing", () => {
119
+ test("parses valid frontmatter and body", () => {
120
+ const source = "---\nid: RFC-0100\n---\n# RFC-0100\n\nBody text.\n";
121
+ const result = parseRfcFile(source);
122
+ expect(result.frontmatter["id"]).toBe("RFC-0100");
123
+ expect(result.body).toContain("Body text.");
124
+ });
125
+
126
+ test("returns empty frontmatter when no frontmatter block", () => {
127
+ const source = "# Just a title\n\nNo frontmatter here.\n";
128
+ const result = parseRfcFile(source);
129
+ expect(result.frontmatter).toEqual({});
130
+ expect(result.body).toBe(source);
131
+ });
132
+ });
@@ -0,0 +1,130 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ Shared RFC frontmatter file I/O — extracted from handlers.ts so both
5
+ handlers.ts and acceptance.ts (RFC-0268) can read RFC files without a
6
+ circular import between the two.
7
+ </purpose>
8
+ <non-goals>
9
+ <item>Do not validate frontmatter shape — that is handlers.ts / acceptance.ts.</item>
10
+ </non-goals>
11
+ </MODULE_CONTRACT>
12
+ <CHANGE_SUMMARY>
13
+ <item>RFC-0268: extracted from handlers.ts to break a circular import with acceptance.ts.</item>
14
+ <item>Post-refactor hardening: added basename-based RFC id matching for archived RFC paths.</item>
15
+ <item>RFC-0521: added getRfcStatusById and loadRfcStatusMap helpers for plan/audit archive commands.</item>
16
+ <item>RFC-0755: readAndParseRfc now returns { fileName, error } on YAML parse failure instead of undefined.</item>
17
+ </CHANGE_SUMMARY>
18
+ */
19
+
20
+ import fs from "node:fs/promises";
21
+ import path from "node:path";
22
+ import YAML from "yaml";
23
+
24
+ export interface ParsedRfc {
25
+ frontmatter: Record<string, unknown>;
26
+ body: string;
27
+ }
28
+
29
+ export function parseRfcFile(source: string): ParsedRfc {
30
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
31
+ if (!match) {
32
+ return { frontmatter: {}, body: source };
33
+ }
34
+ return {
35
+ frontmatter: (YAML.parse(match[1]!) ?? {}) as Record<string, unknown>,
36
+ body: match[2] ?? "",
37
+ };
38
+ }
39
+
40
+ export async function listRfcFiles(rfcDirPath: string): Promise<string[]> {
41
+ const results: string[] = [];
42
+
43
+ async function scanDir(dirPath: string, relativePrefix: string): Promise<void> {
44
+ try {
45
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
46
+ for (const entry of entries) {
47
+ const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
48
+ if (entry.isDirectory()) {
49
+ await scanDir(path.join(dirPath, entry.name), relativePath);
50
+ } else if (
51
+ entry.isFile() &&
52
+ entry.name.endsWith(".md") &&
53
+ !entry.name.startsWith("rfc-0000") &&
54
+ entry.name !== "README.md"
55
+ ) {
56
+ results.push(relativePath);
57
+ }
58
+ }
59
+ } catch {
60
+ // Directory doesn't exist or is unreadable — return empty
61
+ }
62
+ }
63
+
64
+ await scanDir(rfcDirPath, "");
65
+ return results.sort();
66
+ }
67
+
68
+ export function rfcFileMatchesId(fileName: string, targetId: string): boolean {
69
+ return path.basename(fileName).toLowerCase().startsWith(targetId.toLowerCase());
70
+ }
71
+
72
+ export type ReadAndParseRfcResult =
73
+ { fileName: string; parsed: ParsedRfc } | { fileName: string; error: string };
74
+
75
+ export async function readAndParseRfc(
76
+ rfcDirPath: string,
77
+ fileName: string,
78
+ ): Promise<ReadAndParseRfcResult | undefined> {
79
+ try {
80
+ const filePath = path.join(rfcDirPath, fileName);
81
+ const content = await fs.readFile(filePath, "utf-8");
82
+ return { fileName, parsed: parseRfcFile(content) };
83
+ } catch (e) {
84
+ if (e instanceof Error) {
85
+ const linePos = (e as Error & { linePos?: Array<{ line: number; col: number }> }).linePos;
86
+ if (linePos?.length) {
87
+ const pos = linePos[0]!;
88
+ return {
89
+ fileName,
90
+ error: `YAML parse error at line ${pos.line}, column ${pos.col}: ${e.message}`,
91
+ };
92
+ }
93
+ return { fileName, error: `YAML parse error: ${e.message}` };
94
+ }
95
+ return { fileName, error: `YAML parse error: ${String(e)}` };
96
+ }
97
+ }
98
+
99
+ export async function getRfcStatusById(
100
+ rfcDirPath: string,
101
+ rfcId: string,
102
+ ): Promise<string | undefined> {
103
+ const files = await listRfcFiles(rfcDirPath);
104
+ for (const fileName of files) {
105
+ if (rfcFileMatchesId(fileName, rfcId)) {
106
+ const result = await readAndParseRfc(rfcDirPath, fileName);
107
+ if (result && "parsed" in result) {
108
+ const status = String(result.parsed.frontmatter["status"] ?? "").trim();
109
+ if (status) return status;
110
+ }
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ export async function loadRfcStatusMap(rfcDirPath: string): Promise<Map<string, string>> {
117
+ const files = await listRfcFiles(rfcDirPath);
118
+ const statusMap = new Map<string, string>();
119
+ for (const fileName of files) {
120
+ const result = await readAndParseRfc(rfcDirPath, fileName);
121
+ if (result && "parsed" in result) {
122
+ const id = String(result.parsed.frontmatter["id"] ?? "").trim();
123
+ const status = String(result.parsed.frontmatter["status"] ?? "").trim();
124
+ if (id && status) {
125
+ statusMap.set(id, status);
126
+ }
127
+ }
128
+ }
129
+ return statusMap;
130
+ }