@thinkingsage/kanon 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
@@ -0,0 +1,25 @@
1
+ import { distance } from "fastest-levenshtein";
2
+
3
+ /**
4
+ * Suggests the closest matching command name if within Levenshtein distance ≤ 2.
5
+ * Returns null if no command is close enough.
6
+ */
7
+ export function suggestCommand(
8
+ input: string,
9
+ validCommands: string[],
10
+ ): string | null {
11
+ if (validCommands.length === 0) return null;
12
+
13
+ let bestMatch: string | null = null;
14
+ let bestDistance = Infinity;
15
+
16
+ for (const cmd of validCommands) {
17
+ const d = distance(input, cmd);
18
+ if (d < bestDistance) {
19
+ bestDistance = d;
20
+ bestMatch = cmd;
21
+ }
22
+ }
23
+
24
+ return bestDistance <= 2 ? bestMatch : null;
25
+ }
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Pure boolean-expression engine for DES-style hook gates and postconditions
3
+ * (Req 3.1, 3.7).
4
+ *
5
+ * The grammar is a small boolean expression language over two reference kinds:
6
+ *
7
+ * - **state keys** referenced as `state.<key>`
8
+ * - **built-in predicates** (`tests_pass`, `files_exist`, `lint_clean`)
9
+ *
10
+ * Supported operators, from lowest to highest precedence:
11
+ *
12
+ * `||` → `&&` → `!` → equality (`==`, `!=`) against a string/boolean
13
+ * literal, and parentheses for grouping.
14
+ *
15
+ * Every function in this module is PURE: no I/O, no mutation of inputs,
16
+ * deterministic for a given input. Predicate *resolution* (actually running the
17
+ * test suite, checking files, running the linter) is I/O and happens in the
18
+ * orchestration layer, which passes a resolved `predicateValues` map into
19
+ * `evaluateExpression`.
20
+ */
21
+
22
+ /** The fixed set of built-in predicate names recognized in expressions (Req 3.1). */
23
+ export const BUILTIN_PREDICATES = [
24
+ "tests_pass",
25
+ "files_exist",
26
+ "lint_clean",
27
+ ] as const;
28
+
29
+ export type BuiltinPredicate = (typeof BUILTIN_PREDICATES)[number];
30
+
31
+ // --- AST ---
32
+
33
+ /** A reference to a declared state key, written `state.<key>` in source. */
34
+ export interface StateRefNode {
35
+ type: "stateRef";
36
+ key: string;
37
+ }
38
+
39
+ /** A reference to a built-in predicate, written as a bare identifier. */
40
+ export interface PredicateNode {
41
+ type: "predicate";
42
+ name: string;
43
+ }
44
+
45
+ /** A string or boolean literal, only valid on the right-hand side of equality. */
46
+ export interface LiteralNode {
47
+ type: "literal";
48
+ value: string | boolean;
49
+ }
50
+
51
+ /** Equality / inequality of a reference against a literal. */
52
+ export interface EqualityNode {
53
+ type: "equality";
54
+ op: "==" | "!=";
55
+ left: StateRefNode | PredicateNode;
56
+ right: LiteralNode;
57
+ }
58
+
59
+ export interface NotNode {
60
+ type: "not";
61
+ operand: ExprNode;
62
+ }
63
+
64
+ export interface AndNode {
65
+ type: "and";
66
+ left: ExprNode;
67
+ right: ExprNode;
68
+ }
69
+
70
+ export interface OrNode {
71
+ type: "or";
72
+ left: ExprNode;
73
+ right: ExprNode;
74
+ }
75
+
76
+ export type ExprNode =
77
+ | OrNode
78
+ | AndNode
79
+ | NotNode
80
+ | EqualityNode
81
+ | StateRefNode
82
+ | PredicateNode;
83
+
84
+ /** Parsed boolean expression AST returned by {@link parseExpression}. */
85
+ export type ParsedExpression = ExprNode;
86
+
87
+ // --- Tokenizer ---
88
+
89
+ type TokenType =
90
+ | "and"
91
+ | "or"
92
+ | "not"
93
+ | "eq"
94
+ | "neq"
95
+ | "lparen"
96
+ | "rparen"
97
+ | "ident"
98
+ | "string"
99
+ | "bool";
100
+
101
+ interface Token {
102
+ type: TokenType;
103
+ value: string;
104
+ /** 0-based offset into the source string, for error messages. */
105
+ pos: number;
106
+ }
107
+
108
+ const IDENT_START = /[A-Za-z_]/;
109
+ const IDENT_REST = /[A-Za-z0-9_.]/;
110
+
111
+ function tokenize(expr: string): Token[] {
112
+ const tokens: Token[] = [];
113
+ let i = 0;
114
+ const n = expr.length;
115
+
116
+ while (i < n) {
117
+ const ch = expr[i];
118
+
119
+ // Whitespace
120
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
121
+ i++;
122
+ continue;
123
+ }
124
+
125
+ // Two-character operators
126
+ if (ch === "&" && expr[i + 1] === "&") {
127
+ tokens.push({ type: "and", value: "&&", pos: i });
128
+ i += 2;
129
+ continue;
130
+ }
131
+ if (ch === "|" && expr[i + 1] === "|") {
132
+ tokens.push({ type: "or", value: "||", pos: i });
133
+ i += 2;
134
+ continue;
135
+ }
136
+ if (ch === "=" && expr[i + 1] === "=") {
137
+ tokens.push({ type: "eq", value: "==", pos: i });
138
+ i += 2;
139
+ continue;
140
+ }
141
+ if (ch === "!" && expr[i + 1] === "=") {
142
+ tokens.push({ type: "neq", value: "!=", pos: i });
143
+ i += 2;
144
+ continue;
145
+ }
146
+
147
+ // Single-character tokens
148
+ if (ch === "!") {
149
+ tokens.push({ type: "not", value: "!", pos: i });
150
+ i++;
151
+ continue;
152
+ }
153
+ if (ch === "(") {
154
+ tokens.push({ type: "lparen", value: "(", pos: i });
155
+ i++;
156
+ continue;
157
+ }
158
+ if (ch === ")") {
159
+ tokens.push({ type: "rparen", value: ")", pos: i });
160
+ i++;
161
+ continue;
162
+ }
163
+
164
+ // String literal (double-quoted)
165
+ if (ch === '"') {
166
+ let j = i + 1;
167
+ let str = "";
168
+ while (j < n && expr[j] !== '"') {
169
+ // Support simple backslash escapes (\" and \\).
170
+ if (expr[j] === "\\" && j + 1 < n) {
171
+ str += expr[j + 1];
172
+ j += 2;
173
+ continue;
174
+ }
175
+ str += expr[j];
176
+ j++;
177
+ }
178
+ if (j >= n) {
179
+ throw new ExpressionSyntaxError(
180
+ `Unterminated string literal starting at position ${i}`,
181
+ expr,
182
+ );
183
+ }
184
+ tokens.push({ type: "string", value: str, pos: i });
185
+ i = j + 1; // skip closing quote
186
+ continue;
187
+ }
188
+
189
+ // Identifier / keyword (state refs, predicates, boolean literals)
190
+ if (IDENT_START.test(ch)) {
191
+ let j = i;
192
+ let ident = "";
193
+ while (j < n && IDENT_REST.test(expr[j])) {
194
+ ident += expr[j];
195
+ j++;
196
+ }
197
+ if (ident === "true" || ident === "false") {
198
+ tokens.push({ type: "bool", value: ident, pos: i });
199
+ } else {
200
+ tokens.push({ type: "ident", value: ident, pos: i });
201
+ }
202
+ i = j;
203
+ continue;
204
+ }
205
+
206
+ throw new ExpressionSyntaxError(
207
+ `Unexpected character '${ch}' at position ${i}`,
208
+ expr,
209
+ );
210
+ }
211
+
212
+ return tokens;
213
+ }
214
+
215
+ /** Thrown when {@link parseExpression} encounters a syntax error. */
216
+ export class ExpressionSyntaxError extends Error {
217
+ readonly expression: string;
218
+ constructor(message: string, expression: string) {
219
+ super(`Invalid expression: ${message} (in "${expression}")`);
220
+ this.name = "ExpressionSyntaxError";
221
+ this.expression = expression;
222
+ }
223
+ }
224
+
225
+ // --- Parser (recursive descent) ---
226
+
227
+ /**
228
+ * Parse an expression string into an AST. Throws {@link ExpressionSyntaxError}
229
+ * on syntax error. Pure.
230
+ */
231
+ export function parseExpression(expr: string): ParsedExpression {
232
+ const tokens = tokenize(expr);
233
+ if (tokens.length === 0) {
234
+ throw new ExpressionSyntaxError("expression is empty", expr);
235
+ }
236
+
237
+ let pos = 0;
238
+
239
+ const peek = (): Token | undefined => tokens[pos];
240
+ const next = (): Token => {
241
+ const t = tokens[pos];
242
+ if (!t) {
243
+ throw new ExpressionSyntaxError("unexpected end of expression", expr);
244
+ }
245
+ pos++;
246
+ return t;
247
+ };
248
+
249
+ // orExpr := andExpr ('||' andExpr)*
250
+ function parseOr(): ExprNode {
251
+ let left = parseAnd();
252
+ while (peek()?.type === "or") {
253
+ next();
254
+ const right = parseAnd();
255
+ left = { type: "or", left, right };
256
+ }
257
+ return left;
258
+ }
259
+
260
+ // andExpr := notExpr ('&&' notExpr)*
261
+ function parseAnd(): ExprNode {
262
+ let left = parseNot();
263
+ while (peek()?.type === "and") {
264
+ next();
265
+ const right = parseNot();
266
+ left = { type: "and", left, right };
267
+ }
268
+ return left;
269
+ }
270
+
271
+ // notExpr := '!' notExpr | comparison
272
+ function parseNot(): ExprNode {
273
+ if (peek()?.type === "not") {
274
+ next();
275
+ return { type: "not", operand: parseNot() };
276
+ }
277
+ return parseComparison();
278
+ }
279
+
280
+ // comparison := primary (('==' | '!=') literal)?
281
+ function parseComparison(): ExprNode {
282
+ const left = parsePrimary();
283
+ const op = peek();
284
+ if (op && (op.type === "eq" || op.type === "neq")) {
285
+ if (left.type !== "stateRef" && left.type !== "predicate") {
286
+ throw new ExpressionSyntaxError(
287
+ `left side of '${op.value}' must be a state reference or predicate (at position ${op.pos})`,
288
+ expr,
289
+ );
290
+ }
291
+ next();
292
+ const right = parseLiteral();
293
+ return {
294
+ type: "equality",
295
+ op: op.type === "eq" ? "==" : "!=",
296
+ left,
297
+ right,
298
+ };
299
+ }
300
+ return left;
301
+ }
302
+
303
+ // literal := string | bool
304
+ function parseLiteral(): LiteralNode {
305
+ const t = next();
306
+ if (t.type === "string") {
307
+ return { type: "literal", value: t.value };
308
+ }
309
+ if (t.type === "bool") {
310
+ return { type: "literal", value: t.value === "true" };
311
+ }
312
+ throw new ExpressionSyntaxError(
313
+ `expected a string or boolean literal at position ${t.pos}, got '${t.value}'`,
314
+ expr,
315
+ );
316
+ }
317
+
318
+ // primary := '(' orExpr ')' | stateRef | predicate
319
+ function parsePrimary(): ExprNode {
320
+ const t = peek();
321
+ if (!t) {
322
+ throw new ExpressionSyntaxError("unexpected end of expression", expr);
323
+ }
324
+ if (t.type === "lparen") {
325
+ next();
326
+ const inner = parseOr();
327
+ const close = peek();
328
+ if (close?.type !== "rparen") {
329
+ throw new ExpressionSyntaxError(
330
+ `expected ')' at position ${close?.pos ?? expr.length}`,
331
+ expr,
332
+ );
333
+ }
334
+ next();
335
+ return inner;
336
+ }
337
+ if (t.type === "ident") {
338
+ next();
339
+ if (t.value.startsWith("state.")) {
340
+ const key = t.value.slice("state.".length);
341
+ if (key.length === 0 || key.includes(".")) {
342
+ throw new ExpressionSyntaxError(
343
+ `malformed state reference '${t.value}' at position ${t.pos}`,
344
+ expr,
345
+ );
346
+ }
347
+ return { type: "stateRef", key };
348
+ }
349
+ if (t.value.includes(".")) {
350
+ throw new ExpressionSyntaxError(
351
+ `unknown reference '${t.value}' at position ${t.pos} (state references must use 'state.<key>')`,
352
+ expr,
353
+ );
354
+ }
355
+ return { type: "predicate", name: t.value };
356
+ }
357
+ throw new ExpressionSyntaxError(
358
+ `unexpected token '${t.value}' at position ${t.pos}`,
359
+ expr,
360
+ );
361
+ }
362
+
363
+ const ast = parseOr();
364
+ if (pos < tokens.length) {
365
+ const t = tokens[pos];
366
+ throw new ExpressionSyntaxError(
367
+ `unexpected token '${t.value}' at position ${t.pos}`,
368
+ expr,
369
+ );
370
+ }
371
+ return ast;
372
+ }
373
+
374
+ // --- Reference collection ---
375
+
376
+ /**
377
+ * Extract all state keys and predicate names referenced by an expression.
378
+ * Returned arrays are deduplicated and sorted, so the result is deterministic
379
+ * for a given AST. Pure. (Req 3.7)
380
+ */
381
+ export function collectReferences(expr: ParsedExpression): {
382
+ stateKeys: string[];
383
+ predicates: string[];
384
+ } {
385
+ const stateKeys = new Set<string>();
386
+ const predicates = new Set<string>();
387
+
388
+ const walk = (node: ExprNode): void => {
389
+ switch (node.type) {
390
+ case "or":
391
+ case "and":
392
+ walk(node.left);
393
+ walk(node.right);
394
+ break;
395
+ case "not":
396
+ walk(node.operand);
397
+ break;
398
+ case "equality":
399
+ walk(node.left);
400
+ break;
401
+ case "stateRef":
402
+ stateKeys.add(node.key);
403
+ break;
404
+ case "predicate":
405
+ predicates.add(node.name);
406
+ break;
407
+ }
408
+ };
409
+
410
+ walk(expr);
411
+
412
+ return {
413
+ stateKeys: [...stateKeys].sort(),
414
+ predicates: [...predicates].sort(),
415
+ };
416
+ }
417
+
418
+ // --- Reference validation ---
419
+
420
+ /**
421
+ * Validate that every referenced state key is declared in `declaredStateKeys`
422
+ * and every predicate is a built-in. Returns the undefined references (sorted,
423
+ * deduplicated). Pure. (Req 3.7)
424
+ */
425
+ export function validateReferences(
426
+ expr: ParsedExpression,
427
+ declaredStateKeys: Set<string>,
428
+ ): { undefinedStateKeys: string[]; undefinedPredicates: string[] } {
429
+ const { stateKeys, predicates } = collectReferences(expr);
430
+ const builtins = new Set<string>(BUILTIN_PREDICATES);
431
+
432
+ return {
433
+ undefinedStateKeys: stateKeys.filter((k) => !declaredStateKeys.has(k)),
434
+ undefinedPredicates: predicates.filter((p) => !builtins.has(p)),
435
+ };
436
+ }
437
+
438
+ // --- Evaluation ---
439
+
440
+ /** Coerce a state value (string | boolean) to a boolean for bare references. */
441
+ function coerceBoolean(value: string | boolean | undefined): boolean {
442
+ if (typeof value === "boolean") return value;
443
+ if (typeof value === "string") return value.length > 0;
444
+ return false;
445
+ }
446
+
447
+ /**
448
+ * Evaluate an expression to a boolean given resolved predicate values and the
449
+ * current state context. Pure. (Req 3.1, 3.3)
450
+ *
451
+ * - A built-in predicate resolves to `predicateValues[name]` (false if absent).
452
+ * - A bare `state.<key>` reference coerces its value to boolean (boolean as-is,
453
+ * non-empty string → true, absent → false).
454
+ * - An equality compares the resolved left value to the literal; mismatched
455
+ * types (string vs boolean) compare unequal.
456
+ */
457
+ export function evaluateExpression(
458
+ expr: ParsedExpression,
459
+ predicateValues: Record<string, boolean>,
460
+ state: Record<string, string | boolean>,
461
+ ): boolean {
462
+ const resolveRefValue = (
463
+ node: StateRefNode | PredicateNode,
464
+ ): string | boolean => {
465
+ if (node.type === "stateRef") {
466
+ return state[node.key];
467
+ }
468
+ return predicateValues[node.name] ?? false;
469
+ };
470
+
471
+ const evalNode = (node: ExprNode): boolean => {
472
+ switch (node.type) {
473
+ case "or":
474
+ return evalNode(node.left) || evalNode(node.right);
475
+ case "and":
476
+ return evalNode(node.left) && evalNode(node.right);
477
+ case "not":
478
+ return !evalNode(node.operand);
479
+ case "equality": {
480
+ const leftValue = resolveRefValue(node.left);
481
+ const rightValue = node.right.value;
482
+ const equal = leftValue === rightValue;
483
+ return node.op === "==" ? equal : !equal;
484
+ }
485
+ case "stateRef":
486
+ return coerceBoolean(state[node.key]);
487
+ case "predicate":
488
+ return predicateValues[node.name] ?? false;
489
+ }
490
+ };
491
+
492
+ return evalNode(expr);
493
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pure DES-style hook execution pipeline (Req 3.2, 3.4, 3.8, 3.9).
3
+ *
4
+ * `runPipeline` evaluates a list of canonical hooks in declaration order over a
5
+ * shared, copy-on-write state context. For each hook it processes the fixed
6
+ * order **gate → action → postcondition** (Req 3.8):
7
+ *
8
+ * 1. **Gate** — if the hook declares a `gate` and it evaluates false, the
9
+ * hook's action is skipped and a `skipped` outcome records the failing gate
10
+ * expression (Req 3.2). The hook's `state` writes are NOT applied.
11
+ * 2. **Action** — when the gate passes (or there is no gate), the hook's
12
+ * `state` writes are merged into the shared context and the action is
13
+ * considered executed. The action itself is I/O and is injected at the
14
+ * edges in the real runner; this pure pipeline models its effect purely via
15
+ * state writes and the injected `resolvePredicates` callback.
16
+ * 3. **Postcondition** — if the hook declares a `postcondition`, it is
17
+ * evaluated after the action. A true result yields a `postcondition-passed`
18
+ * outcome; a false result yields a `halted` outcome and stops the run with
19
+ * a non-zero result (Req 3.4).
20
+ *
21
+ * State writes from earlier hooks are visible to the gate and postcondition
22
+ * expressions of later hooks (Req 3.9), because the shared context is threaded
23
+ * through the loop.
24
+ *
25
+ * This module is PURE: it performs no I/O and does not mutate its inputs. The
26
+ * only source of outside information is the injected `resolvePredicates`
27
+ * callback, which resolves built-in predicates (`tests_pass`, `files_exist`,
28
+ * `lint_clean`) against the current state. The shared state context is updated
29
+ * copy-on-write (a fresh object per write), so callers' inputs are never
30
+ * mutated.
31
+ */
32
+
33
+ import type { CanonicalHook } from "../schemas";
34
+ import { evaluateExpression, parseExpression } from "./expression";
35
+
36
+ /**
37
+ * The outcome of processing a single hook in the pipeline.
38
+ *
39
+ * - `skipped` — the hook declared a gate that evaluated false; its action was
40
+ * skipped and the failing gate expression is recorded (Req 3.2).
41
+ * - `executed` — the gate passed (or was absent) and the hook declared no
42
+ * postcondition, so the action ran with no post-assertion.
43
+ * - `postcondition-passed` — the action ran and the declared postcondition held.
44
+ * - `halted` — the action ran but the declared postcondition evaluated false,
45
+ * halting the run; the postcondition expression and its actual evaluated
46
+ * result are recorded (Req 3.4).
47
+ */
48
+ export type HookStepOutcome =
49
+ | { hook: string; status: "skipped"; failedGate: string }
50
+ | { hook: string; status: "executed" }
51
+ | { hook: string; status: "postcondition-passed" }
52
+ | { hook: string; status: "halted"; postcondition: string; actual: boolean };
53
+
54
+ /** The result of running a hook pipeline over a shared state context. */
55
+ export interface PipelineResult {
56
+ /** One outcome per hook processed, in declaration order. */
57
+ steps: HookStepOutcome[];
58
+ /** The shared state context after the run (writes from executed hooks). */
59
+ finalState: Record<string, string | boolean>;
60
+ /** True when a postcondition failure halted the run before all hooks ran. */
61
+ halted: boolean;
62
+ }
63
+
64
+ /**
65
+ * Resolve the built-in predicate values for a hook given the current state.
66
+ *
67
+ * Injected by the caller so the pure pipeline never performs I/O. In the real
68
+ * runner this actually runs the test suite, checks files, and runs the linter;
69
+ * in tests it is a deterministic stub. Called at gate-evaluation time (before
70
+ * the action) and again at postcondition-evaluation time (after the action and
71
+ * its state writes), so predicate values may differ across the two phases.
72
+ */
73
+ export type ResolvePredicates = (
74
+ hook: CanonicalHook,
75
+ state: Record<string, string | boolean>,
76
+ ) => Record<string, boolean>;
77
+
78
+ /**
79
+ * Run hooks in declaration order over a shared, copy-on-write state context.
80
+ * Pure. See the module doc comment for the full gate → action → postcondition
81
+ * semantics (Req 3.2, 3.4, 3.8, 3.9).
82
+ */
83
+ export function runPipeline(
84
+ hooks: CanonicalHook[],
85
+ resolvePredicates: ResolvePredicates,
86
+ ): PipelineResult {
87
+ const steps: HookStepOutcome[] = [];
88
+ let state: Record<string, string | boolean> = {};
89
+ let halted = false;
90
+
91
+ for (const hook of hooks) {
92
+ // 1. Gate (Req 3.8 fixed order): evaluate the precondition against the
93
+ // state as visible *before* this hook's action and writes.
94
+ if (hook.gate !== undefined) {
95
+ const gateAst = parseExpression(hook.gate);
96
+ const gatePredicates = resolvePredicates(hook, state);
97
+ const gatePass = evaluateExpression(gateAst, gatePredicates, state);
98
+ if (!gatePass) {
99
+ // Gate failed: skip the action, do not apply state writes (Req 3.2).
100
+ steps.push({
101
+ hook: hook.name,
102
+ status: "skipped",
103
+ failedGate: hook.gate,
104
+ });
105
+ continue;
106
+ }
107
+ }
108
+
109
+ // 2. Action: apply the hook's state writes (copy-on-write so inputs and
110
+ // earlier snapshots are never mutated), making them visible to later
111
+ // hooks' expressions (Req 3.9). The action's side effects are modeled
112
+ // purely via these writes and the injected predicate resolver.
113
+ if (hook.state !== undefined) {
114
+ state = { ...state, ...hook.state };
115
+ }
116
+
117
+ // 3. Postcondition (Req 3.8 fixed order): evaluate against the post-action
118
+ // state. Failure halts the run with details (Req 3.4).
119
+ if (hook.postcondition !== undefined) {
120
+ const postAst = parseExpression(hook.postcondition);
121
+ const postPredicates = resolvePredicates(hook, state);
122
+ const postPass = evaluateExpression(postAst, postPredicates, state);
123
+ if (postPass) {
124
+ steps.push({ hook: hook.name, status: "postcondition-passed" });
125
+ } else {
126
+ steps.push({
127
+ hook: hook.name,
128
+ status: "halted",
129
+ postcondition: hook.postcondition,
130
+ actual: postPass,
131
+ });
132
+ halted = true;
133
+ break;
134
+ }
135
+ } else {
136
+ steps.push({ hook: hook.name, status: "executed" });
137
+ }
138
+ }
139
+
140
+ return { steps, finalState: state, halted };
141
+ }