@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.
- package/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- 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
|
+
}
|