@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,300 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC archive handler — moves terminal-status RFC files into status-specific
5
+ subdirectories under docs/rfcs/archive/ and moves non-terminal files found
6
+ in subdirectories back to the root.
7
+ </purpose>
8
+ <non-goals>
9
+ <item>Does not validate RFC content — use rfc.validate for that.</item>
10
+ <item>Does not change RFC frontmatter — only moves files on disk.</item>
11
+ </non-goals>
12
+ </MODULE_CONTRACT>
13
+ <CHANGE_SUMMARY>
14
+ <item>RFC-0367: implement rfc.archive command for terminal-status file archiving.</item>
15
+ <item>RFC-0733: add pinned-files pre-check — skip pinned files with warning instead of moving them.</item>
16
+ <item>Gap fix: automatically rebuild docs/rfcs/index.yaml after moves to prevent stale index.</item>
17
+ </CHANGE_SUMMARY>
18
+ */
19
+
20
+ import fs from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { stringify as yamlStringify } from "yaml";
23
+ import { listRfcFiles, readAndParseRfc } from "../frontmatter-io.ts";
24
+ import type {
25
+ ForgeCommandInput,
26
+ ForgeCommandResult,
27
+ ForgeRuntimeContext,
28
+ } from "../../../src/types.ts";
29
+ import { RFC_DIR } from "../types.ts";
30
+ import { toIsoDate } from "./shared.ts";
31
+ import { loadPinnedManifest, isPinned, isIntraDirMove } from "../../core/handlers/pinned-check.ts";
32
+
33
+ export const RFC_TERMINAL_STATUSES = ["implemented", "rejected", "superseded"] as const;
34
+
35
+ export interface ArchiveMove {
36
+ id: string;
37
+ file: string;
38
+ status: string;
39
+ from: string;
40
+ to: string;
41
+ direction: "into-archive" | "out-of-archive";
42
+ }
43
+
44
+ export interface ArchiveSkip {
45
+ id: string;
46
+ file: string;
47
+ reason: string;
48
+ }
49
+
50
+ export interface RfcArchiveResult {
51
+ command: "rfc.archive";
52
+ status: "ok";
53
+ moved: ArchiveMove[];
54
+ skipped: ArchiveSkip[];
55
+ dryRun: boolean;
56
+ indexRefreshed: boolean;
57
+ }
58
+
59
+ export async function runRfcArchive(
60
+ input: ForgeCommandInput,
61
+ context: ForgeRuntimeContext,
62
+ ): Promise<ForgeCommandResult<RfcArchiveResult>> {
63
+ const { workspaceRoot, logger, outputFormat } = context;
64
+ const rfcDirPath = path.join(workspaceRoot, RFC_DIR);
65
+
66
+ const dryRun = context.dryRun || input.flags["dry-run"] === true;
67
+ const statusFilter = input.flags["status"] as string | undefined;
68
+
69
+ if (statusFilter && !RFC_TERMINAL_STATUSES.includes(statusFilter as never)) {
70
+ throw new Error(
71
+ `Invalid --status "${statusFilter}". Must be one of: ${RFC_TERMINAL_STATUSES.join(", ")}`,
72
+ );
73
+ }
74
+
75
+ const files = await listRfcFiles(rfcDirPath);
76
+ const moved: ArchiveMove[] = [];
77
+ const skipped: ArchiveSkip[] = [];
78
+
79
+ // RFC-0733: Load pinned manifest once per invocation (null = no manifest, protection inactive)
80
+ let pinnedManifest = null;
81
+ try {
82
+ pinnedManifest = await loadPinnedManifest(workspaceRoot);
83
+ } catch {
84
+ // Malformed manifest — skip pre-check, let pinned.validate report it
85
+ }
86
+
87
+ for (const fileName of files) {
88
+ const result = await readAndParseRfc(rfcDirPath, fileName);
89
+ if (!result || "error" in result) {
90
+ skipped.push({
91
+ id: "UNKNOWN",
92
+ file: path.join(RFC_DIR, fileName),
93
+ reason: "unreadable frontmatter",
94
+ });
95
+ continue;
96
+ }
97
+
98
+ const fm = result.parsed.frontmatter;
99
+ const id = String(fm["id"] ?? "UNKNOWN");
100
+ const status = String(fm["status"] ?? "").trim();
101
+ const isTerminal = status === "implemented" || status === "rejected" || status === "superseded";
102
+ const isInArchive = fileName.includes("/");
103
+ const relFile = path.join(RFC_DIR, fileName);
104
+ const basename = path.basename(fileName);
105
+
106
+ if (statusFilter && status !== statusFilter) {
107
+ skipped.push({
108
+ id,
109
+ file: relFile,
110
+ reason: `status ${status} does not match --status ${statusFilter}`,
111
+ });
112
+ continue;
113
+ }
114
+
115
+ if (isTerminal && !isInArchive) {
116
+ const targetDir = path.join(rfcDirPath, "archive", status);
117
+ const targetPath = path.join(targetDir, basename);
118
+ const targetRel = path.join(RFC_DIR, "archive", status, basename);
119
+
120
+ // RFC-0733: Check if file is pinned before moving
121
+ // Gap fix: exempt intra-directory moves (file stays within the same pinned dir)
122
+ if (
123
+ pinnedManifest &&
124
+ isPinned(pinnedManifest, relFile) &&
125
+ !isIntraDirMove(pinnedManifest, relFile, targetRel)
126
+ ) {
127
+ skipped.push({ id, file: relFile, reason: "pinned (protected by .forge/pinned.yaml)" });
128
+ if (outputFormat === "pretty") {
129
+ logger.warn(` pinned: skipping ${relFile} (protected)`);
130
+ }
131
+ continue;
132
+ }
133
+
134
+ try {
135
+ await fs.access(targetPath);
136
+ skipped.push({ id, file: relFile, reason: "destination exists" });
137
+ continue;
138
+ } catch {
139
+ // destination doesn't exist — proceed
140
+ }
141
+
142
+ if (!dryRun) {
143
+ await fs.mkdir(targetDir, { recursive: true });
144
+ try {
145
+ await fs.rename(path.join(rfcDirPath, fileName), targetPath);
146
+ } catch (err) {
147
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
148
+ skipped.push({ id, file: relFile, reason: "already moved by another process" });
149
+ continue;
150
+ }
151
+ throw err;
152
+ }
153
+ }
154
+
155
+ moved.push({
156
+ id,
157
+ file: targetRel,
158
+ status,
159
+ from: relFile,
160
+ to: targetRel,
161
+ direction: "into-archive",
162
+ });
163
+ } else if (!isTerminal && isInArchive) {
164
+ const targetPath = path.join(rfcDirPath, basename);
165
+ const targetRel = path.join(RFC_DIR, basename);
166
+
167
+ // RFC-0733: Check if file is pinned before moving
168
+ // Gap fix: exempt intra-directory moves (file stays within the same pinned dir)
169
+ if (
170
+ pinnedManifest &&
171
+ isPinned(pinnedManifest, relFile) &&
172
+ !isIntraDirMove(pinnedManifest, relFile, targetRel)
173
+ ) {
174
+ skipped.push({ id, file: relFile, reason: "pinned (protected by .forge/pinned.yaml)" });
175
+ if (outputFormat === "pretty") {
176
+ logger.warn(` pinned: skipping ${relFile} (protected)`);
177
+ }
178
+ continue;
179
+ }
180
+
181
+ try {
182
+ await fs.access(targetPath);
183
+ skipped.push({ id, file: relFile, reason: "destination exists" });
184
+ continue;
185
+ } catch {
186
+ // destination doesn't exist — proceed
187
+ }
188
+
189
+ if (!dryRun) {
190
+ try {
191
+ await fs.rename(path.join(rfcDirPath, fileName), targetPath);
192
+ } catch (err) {
193
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
194
+ skipped.push({ id, file: relFile, reason: "already moved by another process" });
195
+ continue;
196
+ }
197
+ throw err;
198
+ }
199
+ }
200
+
201
+ moved.push({
202
+ id,
203
+ file: targetRel,
204
+ status,
205
+ from: relFile,
206
+ to: targetRel,
207
+ direction: "out-of-archive",
208
+ });
209
+ } else if (isTerminal && isInArchive) {
210
+ skipped.push({ id, file: relFile, reason: `already archived (${status})` });
211
+ } else {
212
+ skipped.push({ id, file: relFile, reason: `status ${status} is non-terminal` });
213
+ }
214
+ }
215
+
216
+ if (outputFormat === "pretty") {
217
+ if (dryRun) {
218
+ logger.info(
219
+ `[dry-run] rfc.archive: would move ${moved.length} file(s), skip ${skipped.length}`,
220
+ );
221
+ } else {
222
+ logger.success(`rfc.archive: moved ${moved.length} file(s), skipped ${skipped.length}`);
223
+ }
224
+ for (const m of moved) {
225
+ logger.info(` ${m.direction}: ${m.id} (${m.status}) ${m.from} → ${m.to}`);
226
+ }
227
+ }
228
+
229
+ // Gap fix: rebuild docs/rfcs/index.yaml after moves to prevent stale index.
230
+ // Previously, rfc.archive moved files but left index.yaml out of date —
231
+ // archived RFCs disappeared from the index silently.
232
+ let indexRefreshed = false;
233
+ if (!dryRun && moved.length > 0) {
234
+ try {
235
+ const allFiles = await listRfcFiles(rfcDirPath);
236
+ const entries: Array<Record<string, unknown>> = [];
237
+ for (const fileName of allFiles) {
238
+ const result = await readAndParseRfc(rfcDirPath, fileName);
239
+ if (!result || "error" in result) continue;
240
+ const fm = result.parsed.frontmatter;
241
+ const arr = (k: string): string[] =>
242
+ Array.isArray(fm[k]) ? (fm[k] as unknown[]).map(String) : [];
243
+ entries.push({
244
+ id: String(fm["id"] ?? ""),
245
+ title: String(fm["title"] ?? ""),
246
+ status: String(fm["status"] ?? ""),
247
+ kind: String(fm["kind"] ?? ""),
248
+ createdAt: String(fm["createdAt"] ?? ""),
249
+ implementedAt: String(fm["implementedAt"] ?? ""),
250
+ closedAt: String(fm["closedAt"] ?? ""),
251
+ supersedes: arr("supersedes"),
252
+ supersededBy: String(fm["supersededBy"] ?? ""),
253
+ amends: arr("amends"),
254
+ amendedBy: arr("amendedBy"),
255
+ related: arr("related"),
256
+ file: path.join(RFC_DIR, fileName),
257
+ });
258
+ }
259
+ entries.sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
260
+ const outRel = path.join(RFC_DIR, "index.yaml");
261
+ const payload = {
262
+ command: "rfc.index.generate",
263
+ generatedAt: toIsoDate(new Date()),
264
+ count: entries.length,
265
+ entries,
266
+ };
267
+ await fs.writeFile(path.join(workspaceRoot, outRel), `${yamlStringify(payload)}`, "utf-8");
268
+ indexRefreshed = true;
269
+ if (outputFormat === "pretty") {
270
+ logger.info(`rfc.archive: refreshed ${outRel} (${entries.length} entries)`);
271
+ }
272
+ } catch (refreshErr) {
273
+ if (outputFormat === "pretty") {
274
+ logger.warn(
275
+ `rfc.archive: index refresh failed — run 'pnpm exec werkstatt run rfc.index.generate --write' manually: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`,
276
+ );
277
+ }
278
+ }
279
+ }
280
+
281
+ return {
282
+ data: {
283
+ command: "rfc.archive",
284
+ status: "ok",
285
+ moved,
286
+ skipped,
287
+ dryRun,
288
+ indexRefreshed,
289
+ },
290
+ summary: dryRun
291
+ ? `[dry-run] Would move ${moved.length} file(s), skip ${skipped.length}`
292
+ : `Moved ${moved.length} file(s), skipped ${skipped.length}`,
293
+ nextSteps: [
294
+ {
295
+ action: "Run: pnpm exec werkstatt run rfc.list --json to verify archive status",
296
+ kind: "optional",
297
+ },
298
+ ],
299
+ };
300
+ }
@@ -0,0 +1,238 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC artifact check handler — validates declared files and feature flags exist on disk.</purpose>
4
+ <non-goals>
5
+ <item>Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0303: extracted check handler from handlers.ts into handlers/check.ts.</item>
10
+ </CHANGE_SUMMARY>
11
+ */
12
+
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+
16
+ import { listRfcFiles, readAndParseRfc } from "../frontmatter-io.ts";
17
+ import type {
18
+ ForgeCommandInput,
19
+ ForgeCommandResult,
20
+ ForgeRuntimeContext,
21
+ } from "../../../src/types.ts";
22
+ import type { RfcStatus, RfcCheckViolation, RfcCheckResult } from "../types.ts";
23
+ import { RFC_DIR } from "../types.ts";
24
+ import { isLiteralFsPath } from "./shared.ts";
25
+
26
+ const RFC_CHECK_DEFAULT_STATUSES: readonly RfcStatus[] = ["accepted", "implemented"];
27
+
28
+ function extractFileSystemPaths(body: string): string[] {
29
+ const sectionRegex =
30
+ /###?\s+File system responsibilities\s*\n[^\n]*\|[^\n]*Path[^\n]*\|\s*\n\|[-\s|]+\|\s*\n((?:\|[^\n]+\n?)*)/i;
31
+ const sectionMatch = body.match(sectionRegex);
32
+ if (!sectionMatch) return [];
33
+
34
+ const tableBody = sectionMatch[1] ?? "";
35
+ const paths: string[] = [];
36
+
37
+ for (const line of tableBody.split("\n")) {
38
+ if (!line.trim().startsWith("|")) continue;
39
+ const cells = line.split("|").map((c) => c.trim());
40
+ const rawPath = cells[1];
41
+ if (!rawPath) continue;
42
+ const cleanPath = rawPath.replace(/`/g, "").trim();
43
+ if (!cleanPath || cleanPath === "Path" || cleanPath.startsWith("---")) continue;
44
+ paths.push(cleanPath);
45
+ }
46
+
47
+ return paths;
48
+ }
49
+
50
+ function extractFeatureFlagReferences(body: string): string[] {
51
+ const flags = new Set<string>();
52
+ const regex = /\bfeatures\.([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*)\b/g;
53
+ const FILE_EXTENSIONS = new Set(["ts", "js", "css", "astro", "md", "mjs", "cjs", "json"]);
54
+
55
+ for (const match of body.matchAll(regex)) {
56
+ const flagPath = match[1]!;
57
+ const segments = flagPath.split(".");
58
+
59
+ if (segments.length === 1 && FILE_EXTENSIONS.has(segments[0]!)) continue;
60
+ if (segments.some((s) => /^[A-Z]$/.test(s))) continue;
61
+ if (segments.length < 2) continue;
62
+
63
+ flags.add(flagPath);
64
+ }
65
+ return [...flags];
66
+ }
67
+
68
+ function extractDefinedFeatureKeysFromSource(source: string): Set<string> {
69
+ const keys = new Set<string>();
70
+ const startMatch = /export const features\s*=\s*\{/.exec(source);
71
+ if (!startMatch) return keys;
72
+
73
+ const lines = source.slice(startMatch.index + startMatch[0].length).split("\n");
74
+ const pathStack: string[] = [];
75
+ let depth = 0;
76
+
77
+ for (const line of lines) {
78
+ const openCount = (line.match(/\{/g) ?? []).length;
79
+ const closeCount = (line.match(/\}/g) ?? []).length;
80
+
81
+ const leafMatch = /^\s+(\w+)\s*:\s*defineFeature\s*\(/.exec(line);
82
+ const nestedMatch = !line.includes("defineFeature") ? /^\s+(\w+)\s*:\s*\{/.exec(line) : null;
83
+
84
+ if (leafMatch) {
85
+ const key = leafMatch[1]!;
86
+ const fullPath = pathStack.length > 0 ? [...pathStack, key].join(".") : key;
87
+ keys.add(fullPath);
88
+ } else if (nestedMatch) {
89
+ pathStack.push(nestedMatch[1]!);
90
+ }
91
+
92
+ depth += openCount - closeCount;
93
+
94
+ while (pathStack.length > 0 && pathStack.length > Math.max(0, depth)) {
95
+ pathStack.pop();
96
+ }
97
+
98
+ if (depth < 0) break;
99
+ }
100
+
101
+ return keys;
102
+ }
103
+
104
+ async function loadFeatureKeysForApps(
105
+ workspaceRoot: string,
106
+ appsImpacted: string[],
107
+ ): Promise<Map<string, Set<string>>> {
108
+ const result = new Map<string, Set<string>>();
109
+
110
+ for (const siteName of appsImpacted) {
111
+ const featuresPath = path.join(
112
+ workspaceRoot,
113
+ "apps",
114
+ siteName,
115
+ "src",
116
+ "configure",
117
+ "features.ts",
118
+ );
119
+ try {
120
+ const source = await fs.readFile(featuresPath, "utf-8");
121
+ result.set(siteName, extractDefinedFeatureKeysFromSource(source));
122
+ } catch {
123
+ // features.ts not found for this app — skip
124
+ }
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ export async function runRfcCheck(
131
+ input: ForgeCommandInput,
132
+ context: ForgeRuntimeContext,
133
+ ): Promise<ForgeCommandResult<RfcCheckResult>> {
134
+ const { workspaceRoot, logger, outputFormat } = context;
135
+ const rfcDirPath = path.join(workspaceRoot, RFC_DIR);
136
+
137
+ const statusFlag = input.flags["status"];
138
+ const statusFilter: string[] = statusFlag
139
+ ? (Array.isArray(statusFlag) ? statusFlag : [statusFlag]).filter(
140
+ (s): s is string => typeof s === "string",
141
+ )
142
+ : [...RFC_CHECK_DEFAULT_STATUSES];
143
+
144
+ const files = await listRfcFiles(rfcDirPath);
145
+ const violations: RfcCheckViolation[] = [];
146
+ let checkedRfcs = 0;
147
+
148
+ for (const fileName of files) {
149
+ const result = await readAndParseRfc(rfcDirPath, fileName);
150
+ if (!result || "error" in result) continue;
151
+
152
+ const fm = result.parsed.frontmatter;
153
+ const body = result.parsed.body;
154
+ const rfcId = String(fm["id"] ?? "UNKNOWN");
155
+ const status = String(fm["status"] ?? "");
156
+ const relFile = path.join(RFC_DIR, fileName);
157
+
158
+ if (!statusFilter.includes(status)) continue;
159
+
160
+ checkedRfcs += 1;
161
+
162
+ // C-01: Check file existence from File system responsibilities table
163
+ const declaredPaths = extractFileSystemPaths(body);
164
+ for (const declaredPath of declaredPaths) {
165
+ if (!isLiteralFsPath(declaredPath)) continue;
166
+ const absPath = path.join(workspaceRoot, declaredPath);
167
+ try {
168
+ await fs.access(absPath);
169
+ } catch {
170
+ violations.push({
171
+ rfcId,
172
+ file: relFile,
173
+ kind: "missing-file",
174
+ artifact: declaredPath,
175
+ message: `File "${declaredPath}" declared in ${rfcId} does not exist`,
176
+ });
177
+ }
178
+ }
179
+
180
+ // C-02: Check feature flag references
181
+ const flagRefs = extractFeatureFlagReferences(body);
182
+ if (flagRefs.length > 0) {
183
+ const appsImpacted = Array.isArray(fm["appsImpacted"])
184
+ ? (fm["appsImpacted"] as string[])
185
+ : [];
186
+
187
+ const featureKeysByApp = await loadFeatureKeysForApps(workspaceRoot, appsImpacted);
188
+
189
+ for (const flagRef of flagRefs) {
190
+ let definedInAny = false;
191
+ for (const [, keys] of featureKeysByApp) {
192
+ if (keys.has(flagRef)) {
193
+ definedInAny = true;
194
+ break;
195
+ }
196
+ }
197
+
198
+ if (!definedInAny && appsImpacted.length > 0) {
199
+ violations.push({
200
+ rfcId,
201
+ file: relFile,
202
+ kind: "missing-feature-flag",
203
+ artifact: flagRef,
204
+ message: `Feature flag "features.${flagRef}" referenced in ${rfcId} is not defined in any impacted app's features.ts`,
205
+ });
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ const hasErrors = violations.length > 0;
212
+ const resultStatus = hasErrors ? "fail" : "pass";
213
+
214
+ if (outputFormat === "pretty") {
215
+ if (violations.length === 0) {
216
+ logger.success(`All ${checkedRfcs} RFC(s) passed artifact check.`);
217
+ } else {
218
+ logger.section(`RFC Artifact Check (${checkedRfcs} RFC(s))`);
219
+ for (const v of violations) {
220
+ logger.error(`[${v.kind}] ${v.rfcId}: ${v.message}`);
221
+ }
222
+ logger.error(`${violations.length} violation(s) found`);
223
+ }
224
+ }
225
+
226
+ return {
227
+ data: {
228
+ command: "rfc.check",
229
+ status: resultStatus,
230
+ checkedRfcs,
231
+ violations,
232
+ },
233
+ exitCode: hasErrors ? 1 : 0,
234
+ summary: hasErrors
235
+ ? `${violations.length} artifact violation(s) found across ${checkedRfcs} RFC(s)`
236
+ : `All ${checkedRfcs} RFC(s) passed artifact check`,
237
+ };
238
+ }