@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,681 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for the invariant enforcement engine (RFC-0675).</purpose>
4
+ <non-goals>
5
+ <item>Do not test domain-specific invariant logic — all rules are profile-driven.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0675: initial invariant engine unit tests.</item>
10
+ <item>RFC-0691: add html-attribute-pattern check kind tests.</item>
11
+ <item>RFC-0694: replace html-attribute-pattern tests with attribute-pattern (elements array) tests, add JSX syntax test.</item>
12
+ <item>RFC-0808: add link-resolution, frontmatter-required, path-exclusion check kind tests.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import { checkInvariants } from "../../../src/onboarding/invariant-engine.ts";
21
+ import type { StackProfile } from "../../../src/profiles/stack-profile.ts";
22
+
23
+ function createTempDir(): string {
24
+ return fs.mkdtempSync(path.join(os.tmpdir(), "forge-invariant-test-"));
25
+ }
26
+
27
+ function writeFile(dir: string, relPath: string, content: string): void {
28
+ const fullPath = path.join(dir, relPath);
29
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
30
+ fs.writeFileSync(fullPath, content);
31
+ }
32
+
33
+ function cleanup(dir: string): void {
34
+ fs.rmSync(dir, { recursive: true, force: true });
35
+ }
36
+
37
+ const baseProfile: StackProfile = {
38
+ schema: "forge/stack-profile@1",
39
+ id: "test-profile",
40
+ displayName: "Test Profile",
41
+ detect: { anyOf: [] },
42
+ workspace: { dirs: [], files: [] },
43
+ install: [],
44
+ invariants: [],
45
+ };
46
+
47
+ describe("invariant-engine", () => {
48
+ let tempDir: string;
49
+
50
+ beforeEach(() => {
51
+ tempDir = createTempDir();
52
+ });
53
+
54
+ afterEach(() => {
55
+ cleanup(tempDir);
56
+ });
57
+
58
+ it("filename-pattern detects non-kebab-case filenames", () => {
59
+ writeFile(tempDir, "compositions/my-video.html", "<html></html>");
60
+ writeFile(tempDir, "compositions/My Video.html", "<html></html>");
61
+
62
+ const profile: StackProfile = {
63
+ ...baseProfile,
64
+ invariants: [
65
+ {
66
+ id: "VIDEO-01",
67
+ rule: "Composition filenames must use kebab-case",
68
+ severity: "error",
69
+ check: {
70
+ kind: "filename-pattern",
71
+ glob: "compositions/**/*.html",
72
+ pattern: "^[a-z0-9-]+\\.html$",
73
+ },
74
+ },
75
+ ],
76
+ };
77
+
78
+ const results = checkInvariants(profile, tempDir);
79
+ expect(results).toHaveLength(1);
80
+ expect(results[0].checked).toBe(true);
81
+ expect(results[0].violations).toHaveLength(1);
82
+ expect(results[0].violations[0].invariantId).toBe("VIDEO-01");
83
+ expect(results[0].violations[0].file).toBe("compositions/My Video.html");
84
+ });
85
+
86
+ it("file-contains detects missing required elements", () => {
87
+ writeFile(tempDir, "compositions/video1.html", "<html><body>hello</body></html>");
88
+ writeFile(tempDir, "compositions/video2.html", "<html><ef-captions>text</ef-captions></html>");
89
+
90
+ const profile: StackProfile = {
91
+ ...baseProfile,
92
+ invariants: [
93
+ {
94
+ id: "VIDEO-03",
95
+ rule: "All speech audio elements must have corresponding ef-captions",
96
+ severity: "error",
97
+ check: {
98
+ kind: "file-contains",
99
+ glob: "compositions/**/*.html",
100
+ pattern: "ef-captions",
101
+ },
102
+ },
103
+ ],
104
+ };
105
+
106
+ const results = checkInvariants(profile, tempDir);
107
+ expect(results).toHaveLength(1);
108
+ expect(results[0].violations).toHaveLength(1);
109
+ expect(results[0].violations[0].file).toBe("compositions/video1.html");
110
+ });
111
+
112
+ it("file-not-contains detects forbidden content", () => {
113
+ writeFile(tempDir, "compositions/good.html", "<html>clean</html>");
114
+ writeFile(tempDir, "compositions/bad.html", "<html><script>eval('evil')</script></html>");
115
+
116
+ const profile: StackProfile = {
117
+ ...baseProfile,
118
+ invariants: [
119
+ {
120
+ id: "SEC-01",
121
+ rule: "No inline scripts",
122
+ severity: "error",
123
+ check: {
124
+ kind: "file-not-contains",
125
+ glob: "compositions/**/*.html",
126
+ negatedPattern: "eval\\(",
127
+ },
128
+ },
129
+ ],
130
+ };
131
+
132
+ const results = checkInvariants(profile, tempDir);
133
+ expect(results).toHaveLength(1);
134
+ expect(results[0].violations).toHaveLength(1);
135
+ expect(results[0].violations[0].file).toBe("compositions/bad.html");
136
+ });
137
+
138
+ it("invariants without check field remain advisory", () => {
139
+ writeFile(tempDir, "compositions/My Video.html", "<html></html>");
140
+
141
+ const profile: StackProfile = {
142
+ ...baseProfile,
143
+ invariants: [
144
+ {
145
+ id: "VIDEO-01",
146
+ rule: "Composition filenames must use kebab-case",
147
+ severity: "error",
148
+ },
149
+ ],
150
+ };
151
+
152
+ const results = checkInvariants(profile, tempDir);
153
+ expect(results).toHaveLength(1);
154
+ expect(results[0].checked).toBe(false);
155
+ expect(results[0].violations).toHaveLength(0);
156
+ });
157
+
158
+ it("glob matching no files results in no violations", () => {
159
+ const profile: StackProfile = {
160
+ ...baseProfile,
161
+ invariants: [
162
+ {
163
+ id: "VIDEO-01",
164
+ rule: "Composition filenames must use kebab-case",
165
+ severity: "error",
166
+ check: {
167
+ kind: "filename-pattern",
168
+ glob: "compositions/**/*.html",
169
+ pattern: "^[a-z0-9-]+\\.html$",
170
+ },
171
+ },
172
+ ],
173
+ };
174
+
175
+ const results = checkInvariants(profile, tempDir);
176
+ expect(results).toHaveLength(1);
177
+ expect(results[0].checked).toBe(true);
178
+ expect(results[0].violations).toHaveLength(0);
179
+ });
180
+
181
+ it("malformed regex pattern produces a warning violation", () => {
182
+ const profile: StackProfile = {
183
+ ...baseProfile,
184
+ invariants: [
185
+ {
186
+ id: "VIDEO-01",
187
+ rule: "Test rule",
188
+ severity: "error",
189
+ check: {
190
+ kind: "filename-pattern",
191
+ glob: "compositions/**/*.html",
192
+ pattern: "[invalid",
193
+ },
194
+ },
195
+ ],
196
+ };
197
+
198
+ const results = checkInvariants(profile, tempDir);
199
+ expect(results).toHaveLength(1);
200
+ expect(results[0].violations).toHaveLength(1);
201
+ expect(results[0].violations[0].severity).toBe("warning");
202
+ expect(results[0].violations[0].message).toContain("invalid check pattern");
203
+ });
204
+
205
+ it("empty invariants array returns empty results", () => {
206
+ const profile: StackProfile = {
207
+ ...baseProfile,
208
+ invariants: [],
209
+ };
210
+
211
+ const results = checkInvariants(profile, tempDir);
212
+ expect(results).toHaveLength(0);
213
+ });
214
+
215
+ it("attribute-pattern detects invalid attribute values", () => {
216
+ writeFile(
217
+ tempDir,
218
+ "compositions/video.html",
219
+ '<ef-timegroup duration="5">hello</ef-timegroup>',
220
+ );
221
+
222
+ const profile: StackProfile = {
223
+ ...baseProfile,
224
+ invariants: [
225
+ {
226
+ id: "VIDEO-05",
227
+ rule: "All duration values must be valid CSS time strings",
228
+ severity: "error",
229
+ check: {
230
+ kind: "attribute-pattern",
231
+ glob: "compositions/**/*.html",
232
+ elements: ["ef-timegroup"],
233
+ attribute: "duration",
234
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
235
+ },
236
+ },
237
+ ],
238
+ };
239
+
240
+ const results = checkInvariants(profile, tempDir);
241
+ expect(results).toHaveLength(1);
242
+ expect(results[0].violations).toHaveLength(1);
243
+ expect(results[0].violations[0].invariantId).toBe("VIDEO-05");
244
+ expect(results[0].violations[0].file).toBe("compositions/video.html");
245
+ expect(results[0].violations[0].message).toContain("does not match pattern");
246
+ });
247
+
248
+ it("attribute-pattern passes valid attribute values", () => {
249
+ writeFile(
250
+ tempDir,
251
+ "compositions/video.html",
252
+ '<ef-timegroup duration="5s">hello</ef-timegroup>',
253
+ );
254
+
255
+ const profile: StackProfile = {
256
+ ...baseProfile,
257
+ invariants: [
258
+ {
259
+ id: "VIDEO-05",
260
+ rule: "All duration values must be valid CSS time strings",
261
+ severity: "error",
262
+ check: {
263
+ kind: "attribute-pattern",
264
+ glob: "compositions/**/*.html",
265
+ elements: ["ef-timegroup"],
266
+ attribute: "duration",
267
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
268
+ },
269
+ },
270
+ ],
271
+ };
272
+
273
+ const results = checkInvariants(profile, tempDir);
274
+ expect(results).toHaveLength(1);
275
+ expect(results[0].violations).toHaveLength(0);
276
+ });
277
+
278
+ it("attribute-pattern skips elements without the declared attribute", () => {
279
+ writeFile(
280
+ tempDir,
281
+ "compositions/video.html",
282
+ '<ef-timegroup mode="sequence">hello</ef-timegroup>',
283
+ );
284
+
285
+ const profile: StackProfile = {
286
+ ...baseProfile,
287
+ invariants: [
288
+ {
289
+ id: "VIDEO-05",
290
+ rule: "All duration values must be valid CSS time strings",
291
+ severity: "error",
292
+ check: {
293
+ kind: "attribute-pattern",
294
+ glob: "compositions/**/*.html",
295
+ elements: ["ef-timegroup"],
296
+ attribute: "duration",
297
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
298
+ },
299
+ },
300
+ ],
301
+ };
302
+
303
+ const results = checkInvariants(profile, tempDir);
304
+ expect(results).toHaveLength(1);
305
+ expect(results[0].violations).toHaveLength(0);
306
+ });
307
+
308
+ it("attribute-pattern handles multiple elements in one file", () => {
309
+ writeFile(
310
+ tempDir,
311
+ "compositions/video.html",
312
+ '<ef-timegroup duration="5s">hello</ef-timegroup><ef-timegroup duration="3">world</ef-timegroup>',
313
+ );
314
+
315
+ const profile: StackProfile = {
316
+ ...baseProfile,
317
+ invariants: [
318
+ {
319
+ id: "VIDEO-05",
320
+ rule: "All duration values must be valid CSS time strings",
321
+ severity: "error",
322
+ check: {
323
+ kind: "attribute-pattern",
324
+ glob: "compositions/**/*.html",
325
+ elements: ["ef-timegroup"],
326
+ attribute: "duration",
327
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
328
+ },
329
+ },
330
+ ],
331
+ };
332
+
333
+ const results = checkInvariants(profile, tempDir);
334
+ expect(results).toHaveLength(1);
335
+ expect(results[0].violations).toHaveLength(1);
336
+ expect(results[0].violations[0].message).toContain("'3'");
337
+ });
338
+
339
+ it("attribute-pattern handles single-quoted attribute values", () => {
340
+ writeFile(
341
+ tempDir,
342
+ "compositions/video.html",
343
+ "<ef-timegroup duration='5s'>hello</ef-timegroup>",
344
+ );
345
+
346
+ const profile: StackProfile = {
347
+ ...baseProfile,
348
+ invariants: [
349
+ {
350
+ id: "VIDEO-05",
351
+ rule: "All duration values must be valid CSS time strings",
352
+ severity: "error",
353
+ check: {
354
+ kind: "attribute-pattern",
355
+ glob: "compositions/**/*.html",
356
+ elements: ["ef-timegroup"],
357
+ attribute: "duration",
358
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
359
+ },
360
+ },
361
+ ],
362
+ };
363
+
364
+ const results = checkInvariants(profile, tempDir);
365
+ expect(results).toHaveLength(1);
366
+ expect(results[0].violations).toHaveLength(0);
367
+ });
368
+
369
+ it("attribute-pattern with missing elements/attribute produces warning violation", () => {
370
+ const profile: StackProfile = {
371
+ ...baseProfile,
372
+ invariants: [
373
+ {
374
+ id: "VIDEO-05",
375
+ rule: "All duration values must be valid CSS time strings",
376
+ severity: "error",
377
+ check: {
378
+ kind: "attribute-pattern" as const,
379
+ glob: "compositions/**/*.html",
380
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
381
+ },
382
+ },
383
+ ],
384
+ };
385
+
386
+ const results = checkInvariants(profile, tempDir);
387
+ expect(results).toHaveLength(1);
388
+ expect(results[0].violations).toHaveLength(1);
389
+ expect(results[0].violations[0].severity).toBe("warning");
390
+ expect(results[0].violations[0].message).toContain("without elements or attribute");
391
+ });
392
+
393
+ it("attribute-pattern detects JSX syntax with elements array", () => {
394
+ writeFile(tempDir, "compositions/video.tsx", '<Timegroup duration="5s">hello</Timegroup>');
395
+
396
+ const profile: StackProfile = {
397
+ ...baseProfile,
398
+ invariants: [
399
+ {
400
+ id: "VIDEO-05",
401
+ rule: "All duration values must be valid CSS time strings",
402
+ severity: "error",
403
+ check: {
404
+ kind: "attribute-pattern",
405
+ glob: "compositions/**/*.tsx",
406
+ elements: ["ef-timegroup", "Timegroup"],
407
+ attribute: "duration",
408
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
409
+ },
410
+ },
411
+ ],
412
+ };
413
+
414
+ const results = checkInvariants(profile, tempDir);
415
+ expect(results).toHaveLength(1);
416
+ expect(results[0].violations).toHaveLength(0);
417
+ });
418
+
419
+ it("attribute-pattern detects invalid JSX attribute values", () => {
420
+ writeFile(tempDir, "compositions/video.tsx", '<Timegroup duration="5">hello</Timegroup>');
421
+
422
+ const profile: StackProfile = {
423
+ ...baseProfile,
424
+ invariants: [
425
+ {
426
+ id: "VIDEO-05",
427
+ rule: "All duration values must be valid CSS time strings",
428
+ severity: "error",
429
+ check: {
430
+ kind: "attribute-pattern",
431
+ glob: "compositions/**/*.tsx",
432
+ elements: ["ef-timegroup", "Timegroup"],
433
+ attribute: "duration",
434
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
435
+ },
436
+ },
437
+ ],
438
+ };
439
+
440
+ const results = checkInvariants(profile, tempDir);
441
+ expect(results).toHaveLength(1);
442
+ expect(results[0].violations).toHaveLength(1);
443
+ expect(results[0].violations[0].file).toBe("compositions/video.tsx");
444
+ expect(results[0].violations[0].message).toContain("<Timegroup>");
445
+ });
446
+
447
+ it("attribute-pattern with empty elements array produces warning violation", () => {
448
+ const profile: StackProfile = {
449
+ ...baseProfile,
450
+ invariants: [
451
+ {
452
+ id: "VIDEO-05",
453
+ rule: "All duration values must be valid CSS time strings",
454
+ severity: "error",
455
+ check: {
456
+ kind: "attribute-pattern" as const,
457
+ glob: "compositions/**/*.tsx",
458
+ elements: [],
459
+ attribute: "duration",
460
+ pattern: "^\\d+(\\.\\d+)?(s|ms)$",
461
+ },
462
+ },
463
+ ],
464
+ };
465
+
466
+ const results = checkInvariants(profile, tempDir);
467
+ expect(results).toHaveLength(1);
468
+ expect(results[0].violations).toHaveLength(1);
469
+ expect(results[0].violations[0].severity).toBe("warning");
470
+ expect(results[0].violations[0].message).toContain("without elements or attribute");
471
+ });
472
+
473
+ it("link-resolution detects broken wikilinks", () => {
474
+ writeFile(
475
+ tempDir,
476
+ "vault/note-1.md",
477
+ "---\ntitle: Note 1\n---\nLink to [[note-2]] and [[missing-note]].",
478
+ );
479
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nBack to [[note-1]].");
480
+
481
+ const profile: StackProfile = {
482
+ ...baseProfile,
483
+ invariants: [
484
+ {
485
+ id: "NOTE-01",
486
+ rule: "All wikilinks must resolve",
487
+ severity: "error",
488
+ check: {
489
+ kind: "link-resolution",
490
+ glob: "vault/**/*.md",
491
+ },
492
+ },
493
+ ],
494
+ };
495
+
496
+ const results = checkInvariants(profile, tempDir);
497
+ expect(results).toHaveLength(1);
498
+ expect(results[0].violations).toHaveLength(1);
499
+ expect(results[0].violations[0].invariantId).toBe("NOTE-01");
500
+ expect(results[0].violations[0].file).toBe("vault/note-1.md");
501
+ expect(results[0].violations[0].message).toContain("missing-note");
502
+ });
503
+
504
+ it("link-resolution passes when all links resolve", () => {
505
+ writeFile(tempDir, "vault/note-1.md", "---\ntitle: Note 1\n---\nLink to [[note-2]].");
506
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nBack to [[note-1]].");
507
+
508
+ const profile: StackProfile = {
509
+ ...baseProfile,
510
+ invariants: [
511
+ {
512
+ id: "NOTE-01",
513
+ rule: "All wikilinks must resolve",
514
+ severity: "error",
515
+ check: {
516
+ kind: "link-resolution",
517
+ glob: "vault/**/*.md",
518
+ },
519
+ },
520
+ ],
521
+ };
522
+
523
+ const results = checkInvariants(profile, tempDir);
524
+ expect(results).toHaveLength(1);
525
+ expect(results[0].violations).toHaveLength(0);
526
+ });
527
+
528
+ it("link-resolution resolves aliases", () => {
529
+ writeFile(
530
+ tempDir,
531
+ "vault/note-1.md",
532
+ "---\ntitle: Note 1\naliases: [alias-1]\n---\nLink to [[alias-1]].",
533
+ );
534
+
535
+ const profile: StackProfile = {
536
+ ...baseProfile,
537
+ invariants: [
538
+ {
539
+ id: "NOTE-01",
540
+ rule: "All wikilinks must resolve",
541
+ severity: "error",
542
+ check: {
543
+ kind: "link-resolution",
544
+ glob: "vault/**/*.md",
545
+ },
546
+ },
547
+ ],
548
+ };
549
+
550
+ const results = checkInvariants(profile, tempDir);
551
+ expect(results).toHaveLength(1);
552
+ expect(results[0].violations).toHaveLength(0);
553
+ });
554
+
555
+ it("frontmatter-required detects missing title field", () => {
556
+ writeFile(tempDir, "vault/note-1.md", "---\ntags: [a]\n---\nNo title here.");
557
+ writeFile(tempDir, "vault/note-2.md", "---\ntitle: Note 2\n---\nHas title.");
558
+
559
+ const profile: StackProfile = {
560
+ ...baseProfile,
561
+ invariants: [
562
+ {
563
+ id: "NOTE-02",
564
+ rule: "Every note must have a title",
565
+ severity: "warning",
566
+ check: {
567
+ kind: "frontmatter-required",
568
+ glob: "vault/**/*.md",
569
+ fields: ["title"],
570
+ },
571
+ },
572
+ ],
573
+ };
574
+
575
+ const results = checkInvariants(profile, tempDir);
576
+ expect(results).toHaveLength(1);
577
+ expect(results[0].violations).toHaveLength(1);
578
+ expect(results[0].violations[0].invariantId).toBe("NOTE-02");
579
+ expect(results[0].violations[0].file).toBe("vault/note-1.md");
580
+ expect(results[0].violations[0].message).toContain("title");
581
+ });
582
+
583
+ it("frontmatter-required falls back to first H1 for title", () => {
584
+ writeFile(tempDir, "vault/note-1.md", "---\ntags: [a]\n---\n# My Title\nContent.");
585
+
586
+ const profile: StackProfile = {
587
+ ...baseProfile,
588
+ invariants: [
589
+ {
590
+ id: "NOTE-02",
591
+ rule: "Every note must have a title",
592
+ severity: "warning",
593
+ check: {
594
+ kind: "frontmatter-required",
595
+ glob: "vault/**/*.md",
596
+ fields: ["title"],
597
+ },
598
+ },
599
+ ],
600
+ };
601
+
602
+ const results = checkInvariants(profile, tempDir);
603
+ expect(results).toHaveLength(1);
604
+ expect(results[0].violations).toHaveLength(0);
605
+ });
606
+
607
+ it("frontmatter-required flags files without frontmatter", () => {
608
+ writeFile(tempDir, "vault/note-1.md", "No frontmatter here.");
609
+
610
+ const profile: StackProfile = {
611
+ ...baseProfile,
612
+ invariants: [
613
+ {
614
+ id: "NOTE-02",
615
+ rule: "Every note must have a title",
616
+ severity: "warning",
617
+ check: {
618
+ kind: "frontmatter-required",
619
+ glob: "vault/**/*.md",
620
+ fields: ["title"],
621
+ },
622
+ },
623
+ ],
624
+ };
625
+
626
+ const results = checkInvariants(profile, tempDir);
627
+ expect(results).toHaveLength(1);
628
+ expect(results[0].violations).toHaveLength(1);
629
+ expect(results[0].violations[0].message).toContain("no frontmatter");
630
+ });
631
+
632
+ it("path-exclusion detects code files in vault", () => {
633
+ writeFile(tempDir, "vault/note.md", "---\ntitle: Note\n---\nContent.");
634
+ writeFile(tempDir, "vault/script.ts", "export const x = 1;");
635
+ writeFile(tempDir, "vault/helper.py", "print('hello')");
636
+
637
+ const profile: StackProfile = {
638
+ ...baseProfile,
639
+ invariants: [
640
+ {
641
+ id: "NOTE-03",
642
+ rule: "No executable code files in vault",
643
+ severity: "error",
644
+ check: {
645
+ kind: "path-exclusion",
646
+ glob: "vault/**/*.{ts,mjs,js,py,sh}",
647
+ },
648
+ },
649
+ ],
650
+ };
651
+
652
+ const results = checkInvariants(profile, tempDir);
653
+ expect(results).toHaveLength(1);
654
+ expect(results[0].violations).toHaveLength(2);
655
+ const files = results[0].violations.map((v) => v.file).sort();
656
+ expect(files).toEqual(["vault/helper.py", "vault/script.ts"]);
657
+ });
658
+
659
+ it("path-exclusion passes when no code files exist", () => {
660
+ writeFile(tempDir, "vault/note.md", "---\ntitle: Note\n---\nContent.");
661
+
662
+ const profile: StackProfile = {
663
+ ...baseProfile,
664
+ invariants: [
665
+ {
666
+ id: "NOTE-03",
667
+ rule: "No executable code files in vault",
668
+ severity: "error",
669
+ check: {
670
+ kind: "path-exclusion",
671
+ glob: "vault/**/*.{ts,mjs,js,py,sh}",
672
+ },
673
+ },
674
+ ],
675
+ };
676
+
677
+ const results = checkInvariants(profile, tempDir);
678
+ expect(results).toHaveLength(1);
679
+ expect(results[0].violations).toHaveLength(0);
680
+ });
681
+ });