@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
package/src/validate.ts
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import { exists, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { CAPABILITY_MATRIX, validateMatrixSync } from "./adapters/capabilities";
|
|
5
|
+
import { adapterRegistry } from "./adapters/index";
|
|
6
|
+
import { resolveKiroInclusion } from "./adapters/kiro-inclusion";
|
|
7
|
+
import {
|
|
8
|
+
ASSET_CONVENTION_RULES,
|
|
9
|
+
ASSET_CONVENTIONS,
|
|
10
|
+
documentsAgentLoop,
|
|
11
|
+
} from "./asset-conventions";
|
|
12
|
+
import { generateCatalog } from "./catalog";
|
|
13
|
+
import { loadCollections, validateArtifactCollectionRefs } from "./collections";
|
|
14
|
+
import { HARNESS_FORMAT_REGISTRY, resolveFormat } from "./format-registry";
|
|
15
|
+
import {
|
|
16
|
+
BUILTIN_PREDICATES,
|
|
17
|
+
ExpressionSyntaxError,
|
|
18
|
+
type ParsedExpression,
|
|
19
|
+
parseExpression,
|
|
20
|
+
validateReferences,
|
|
21
|
+
} from "./hooks/expression";
|
|
22
|
+
import { aggregateOutcomes, runRegistryCheck } from "./outcomes/registry";
|
|
23
|
+
import {
|
|
24
|
+
isParseError,
|
|
25
|
+
parseHooksYaml,
|
|
26
|
+
parseKnowledgeMd,
|
|
27
|
+
parseMcpServersYaml,
|
|
28
|
+
} from "./parser";
|
|
29
|
+
import {
|
|
30
|
+
type CanonicalHook,
|
|
31
|
+
isStdioServer,
|
|
32
|
+
type Outcome,
|
|
33
|
+
SUPPORTED_HARNESSES,
|
|
34
|
+
type ValidationError,
|
|
35
|
+
type ValidationResult,
|
|
36
|
+
type ValidationWarning,
|
|
37
|
+
} from "./schemas";
|
|
38
|
+
import { loadWorkspaceConfig, validateWorkspaceConfig } from "./workspace";
|
|
39
|
+
|
|
40
|
+
export type { ValidationError, ValidationResult, ValidationWarning };
|
|
41
|
+
|
|
42
|
+
// ── Security validation ───────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Prompt injection and override patterns — errors, not warnings. */
|
|
45
|
+
const PROMPT_INJECTION_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
|
46
|
+
{
|
|
47
|
+
pattern: /ignore\s+(previous|prior|all)\s+instructions/i,
|
|
48
|
+
message:
|
|
49
|
+
'Contains prompt injection pattern: "ignore previous instructions"',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
pattern:
|
|
53
|
+
/disregard\s+(your|all)\s+(guidelines|rules|training|instructions)/i,
|
|
54
|
+
message: 'Contains prompt injection pattern: "disregard your guidelines"',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pattern: /you\s+are\s+now\s+(?!a\s+(?:skill|power|workflow|agent|prompt))/i,
|
|
58
|
+
message: 'Contains identity override pattern: "you are now"',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
pattern: /act\s+as\s+(?:if\s+you\s+have\s+no|without)\s+restrictions/i,
|
|
62
|
+
message:
|
|
63
|
+
'Contains restriction bypass pattern: "act as if you have no restrictions"',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
pattern: /\[SYSTEM\]/,
|
|
67
|
+
message: "Contains fake system prompt marker: [SYSTEM]",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
pattern: /\bDAN\b|\bDo Anything Now\b/i,
|
|
71
|
+
message: "Contains known jailbreak marker: DAN / Do Anything Now",
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Hook command patterns that suggest exfiltration or remote code execution. */
|
|
76
|
+
const DANGEROUS_HOOK_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
|
77
|
+
{
|
|
78
|
+
pattern: /curl\s+.*(?:\$\{?[A-Z_]{3,}\}?|\$\()/,
|
|
79
|
+
message:
|
|
80
|
+
"Hook command uses curl with an environment variable or command substitution — potential exfiltration",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
pattern: /wget\s+.*https?:\/\//,
|
|
84
|
+
message: "Hook command makes outbound request via wget",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
pattern: /\bnc\b.*\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/,
|
|
88
|
+
message: "Hook command uses netcat with a remote IP address",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
pattern: /python.*-c.*(?:exec|eval|__import__)/,
|
|
92
|
+
message: "Hook command runs inline Python with exec/eval",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
pattern: /node\s+-e\s+/,
|
|
96
|
+
message: "Hook command runs inline Node.js code",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
pattern: /bash\s+-c\s+.*base64/,
|
|
100
|
+
message: "Hook command runs base64-encoded payload via bash",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** MCP server command patterns that indicate risky execution. */
|
|
105
|
+
const DANGEROUS_MCP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
|
106
|
+
{
|
|
107
|
+
pattern: /^(?:bash|sh|zsh|fish)$/,
|
|
108
|
+
message:
|
|
109
|
+
"MCP server uses a shell interpreter as its command — high-risk execution surface",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
pattern: /^python\d*$|^node$|^ruby$|^perl$/,
|
|
113
|
+
message:
|
|
114
|
+
"MCP server uses a language runtime as its command without a specific script path",
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** Hidden/obfuscated content patterns — warnings. */
|
|
119
|
+
const OBFUSCATION_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
|
120
|
+
{
|
|
121
|
+
pattern: /[\u200B-\u200D\uFEFF\u2060]/,
|
|
122
|
+
message: "Body contains zero-width or invisible Unicode characters",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
pattern: /[A-Za-z0-9+/]{80,}={0,2}/,
|
|
126
|
+
message:
|
|
127
|
+
"Body contains a long base64-like string that may encode hidden instructions",
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* SPDX license identifiers (or families) whose terms require attribution be
|
|
133
|
+
* preserved. Kept small and explicit (ADR-0064); `CC-BY*` matches by prefix.
|
|
134
|
+
* Used only to raise an advisory under-credited warning, never to fail a build.
|
|
135
|
+
*/
|
|
136
|
+
const ATTRIBUTION_REQUIRED_LICENSES = new Set([
|
|
137
|
+
"MPL-2.0",
|
|
138
|
+
"Apache-2.0",
|
|
139
|
+
"BSD-3-Clause",
|
|
140
|
+
"BSD-2-Clause",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
function isAttributionRequiredLicense(license: string): boolean {
|
|
144
|
+
const id = license.trim();
|
|
145
|
+
if (id.length === 0) return false;
|
|
146
|
+
if (/^CC-BY/i.test(id)) return true;
|
|
147
|
+
return ATTRIBUTION_REQUIRED_LICENSES.has(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run security-focused checks on an artifact.
|
|
152
|
+
* Returns additional errors and warnings to merge into the validation result.
|
|
153
|
+
*/
|
|
154
|
+
export async function validateArtifactSecurity(
|
|
155
|
+
artifactPath: string,
|
|
156
|
+
): Promise<{ errors: ValidationError[]; warnings: ValidationWarning[] }> {
|
|
157
|
+
const errors: ValidationError[] = [];
|
|
158
|
+
const warnings: ValidationWarning[] = [];
|
|
159
|
+
const knowledgeMdPath = join(artifactPath, "knowledge.md");
|
|
160
|
+
const hooksPath = join(artifactPath, "hooks.yaml");
|
|
161
|
+
const mcpPath = join(artifactPath, "mcp-servers.yaml");
|
|
162
|
+
|
|
163
|
+
// ── knowledge.md body ───────────────────────────────────────────────────────
|
|
164
|
+
const mdResult = await parseKnowledgeMd(knowledgeMdPath);
|
|
165
|
+
if (!isParseError(mdResult)) {
|
|
166
|
+
const body = mdResult.data.body;
|
|
167
|
+
|
|
168
|
+
for (const { pattern, message } of PROMPT_INJECTION_PATTERNS) {
|
|
169
|
+
if (pattern.test(body)) {
|
|
170
|
+
errors.push({ field: "body", message, filePath: knowledgeMdPath });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const { pattern, message } of OBFUSCATION_PATTERNS) {
|
|
175
|
+
if (pattern.test(body)) {
|
|
176
|
+
warnings.push({ field: "body", message, filePath: knowledgeMdPath });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── hooks.yaml ──────────────────────────────────────────────────────────────
|
|
182
|
+
if (await exists(hooksPath)) {
|
|
183
|
+
const hooksResult = await parseHooksYaml(hooksPath);
|
|
184
|
+
if (!isParseError(hooksResult)) {
|
|
185
|
+
for (const hook of hooksResult.data) {
|
|
186
|
+
if (hook.action.type === "run_command") {
|
|
187
|
+
const cmd = hook.action.command;
|
|
188
|
+
for (const { pattern, message } of DANGEROUS_HOOK_PATTERNS) {
|
|
189
|
+
if (pattern.test(cmd)) {
|
|
190
|
+
errors.push({
|
|
191
|
+
field: `hooks[${hook.name}].action.command`,
|
|
192
|
+
message,
|
|
193
|
+
filePath: hooksPath,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── mcp-servers.yaml ────────────────────────────────────────────────────────
|
|
203
|
+
if (await exists(mcpPath)) {
|
|
204
|
+
const mcpResult = await parseMcpServersYaml(mcpPath);
|
|
205
|
+
if (!isParseError(mcpResult)) {
|
|
206
|
+
for (const server of mcpResult.data) {
|
|
207
|
+
if (isStdioServer(server)) {
|
|
208
|
+
for (const { pattern, message } of DANGEROUS_MCP_PATTERNS) {
|
|
209
|
+
if (pattern.test(server.command)) {
|
|
210
|
+
warnings.push({
|
|
211
|
+
field: `mcp-servers[${server.name}].command`,
|
|
212
|
+
message,
|
|
213
|
+
filePath: mcpPath,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Flag servers with env vars that look like credentials
|
|
219
|
+
for (const [key] of Object.entries(server.env)) {
|
|
220
|
+
if (/(?:key|secret|token|password|credential)/i.test(key)) {
|
|
221
|
+
warnings.push({
|
|
222
|
+
field: `mcp-servers[${server.name}].env.${key}`,
|
|
223
|
+
message: `MCP server references a credential-like env var "${key}" — ensure this is not hardcoded`,
|
|
224
|
+
filePath: mcpPath,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { errors, warnings };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Detect cycles in a dependency graph using DFS.
|
|
237
|
+
* @param graph - Map from artifact name to its direct dependencies
|
|
238
|
+
* @returns Array of cycles, each cycle is an array of artifact names forming a loop
|
|
239
|
+
*/
|
|
240
|
+
export function detectDependencyCycles(
|
|
241
|
+
graph: Map<string, string[]>,
|
|
242
|
+
): string[][] {
|
|
243
|
+
const cycles: string[][] = [];
|
|
244
|
+
const visited = new Set<string>();
|
|
245
|
+
const inStack = new Set<string>();
|
|
246
|
+
|
|
247
|
+
function dfs(node: string, path: string[]): void {
|
|
248
|
+
if (inStack.has(node)) {
|
|
249
|
+
// Found a cycle — extract the cycle portion
|
|
250
|
+
const cycleStart = path.indexOf(node);
|
|
251
|
+
cycles.push(path.slice(cycleStart));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (visited.has(node)) return;
|
|
255
|
+
|
|
256
|
+
visited.add(node);
|
|
257
|
+
inStack.add(node);
|
|
258
|
+
path.push(node);
|
|
259
|
+
|
|
260
|
+
for (const dep of graph.get(node) ?? []) {
|
|
261
|
+
dfs(dep, [...path]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
inStack.delete(node);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const node of graph.keys()) {
|
|
268
|
+
if (!visited.has(node)) {
|
|
269
|
+
dfs(node, []);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return cycles;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Hook gate/postcondition reference validation (Req 3.7).
|
|
278
|
+
*
|
|
279
|
+
* For a parsed `hooks.yaml`, collect the union of all declared `state` keys
|
|
280
|
+
* across every hook in the file, then for each hook's `gate` and
|
|
281
|
+
* `postcondition` expression parse it and run {@link validateReferences}
|
|
282
|
+
* against that declared-key set. Any undefined state key or unknown predicate
|
|
283
|
+
* (not in {@link BUILTIN_PREDICATES}) yields a {@link ValidationError}
|
|
284
|
+
* identifying the hook name and the offending field. Malformed expressions are
|
|
285
|
+
* reported as validation errors too, so a broken gate/postcondition rejects the
|
|
286
|
+
* hook file rather than throwing.
|
|
287
|
+
*
|
|
288
|
+
* Pure: takes the already-parsed hooks and returns errors; no I/O.
|
|
289
|
+
*/
|
|
290
|
+
export function validateHookReferences(
|
|
291
|
+
hooks: CanonicalHook[],
|
|
292
|
+
filePath: string,
|
|
293
|
+
): ValidationError[] {
|
|
294
|
+
const errors: ValidationError[] = [];
|
|
295
|
+
|
|
296
|
+
// Union of all declared state keys across every hook in the file (Req 3.7).
|
|
297
|
+
const declaredStateKeys = new Set<string>();
|
|
298
|
+
for (const hook of hooks) {
|
|
299
|
+
if (hook.state) {
|
|
300
|
+
for (const key of Object.keys(hook.state)) {
|
|
301
|
+
declaredStateKeys.add(key);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const checkField = (
|
|
307
|
+
hook: CanonicalHook,
|
|
308
|
+
field: "gate" | "postcondition",
|
|
309
|
+
expr: string | undefined,
|
|
310
|
+
): void => {
|
|
311
|
+
if (!expr) return;
|
|
312
|
+
|
|
313
|
+
let parsed: ParsedExpression;
|
|
314
|
+
try {
|
|
315
|
+
parsed = parseExpression(expr);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
errors.push({
|
|
318
|
+
field: `hooks[${hook.name}].${field}`,
|
|
319
|
+
message:
|
|
320
|
+
err instanceof ExpressionSyntaxError
|
|
321
|
+
? err.message
|
|
322
|
+
: `Failed to parse ${field} expression "${expr}"`,
|
|
323
|
+
filePath,
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const { undefinedStateKeys, undefinedPredicates } = validateReferences(
|
|
329
|
+
parsed,
|
|
330
|
+
declaredStateKeys,
|
|
331
|
+
);
|
|
332
|
+
for (const key of undefinedStateKeys) {
|
|
333
|
+
errors.push({
|
|
334
|
+
field: `hooks[${hook.name}].${field}`,
|
|
335
|
+
message: `${field} references undefined state key "state.${key}". Declare it in a hook's "state" field.`,
|
|
336
|
+
filePath,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
for (const pred of undefinedPredicates) {
|
|
340
|
+
errors.push({
|
|
341
|
+
field: `hooks[${hook.name}].${field}`,
|
|
342
|
+
message: `${field} references unknown predicate "${pred}". Built-in predicates are: ${BUILTIN_PREDICATES.join(", ")}.`,
|
|
343
|
+
filePath,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
for (const hook of hooks) {
|
|
349
|
+
checkField(hook, "gate", hook.gate);
|
|
350
|
+
checkField(hook, "postcondition", hook.postcondition);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return errors;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function validateArtifact(
|
|
357
|
+
artifactPath: string,
|
|
358
|
+
): Promise<ValidationResult> {
|
|
359
|
+
const errors: ValidationError[] = [];
|
|
360
|
+
const artifactName = artifactPath.split("/").pop() || artifactPath;
|
|
361
|
+
|
|
362
|
+
// Check knowledge.md exists
|
|
363
|
+
const knowledgeMdPath = join(artifactPath, "knowledge.md");
|
|
364
|
+
if (!(await exists(knowledgeMdPath))) {
|
|
365
|
+
errors.push({
|
|
366
|
+
field: "knowledge.md",
|
|
367
|
+
message: "Missing required knowledge.md file",
|
|
368
|
+
filePath: knowledgeMdPath,
|
|
369
|
+
});
|
|
370
|
+
return { artifactName, valid: false, errors };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Validate frontmatter
|
|
374
|
+
const warnings: ValidationWarning[] = [];
|
|
375
|
+
const mdResult = await parseKnowledgeMd(knowledgeMdPath);
|
|
376
|
+
if (isParseError(mdResult)) {
|
|
377
|
+
errors.push(...mdResult.errors);
|
|
378
|
+
} else {
|
|
379
|
+
// Validate harness names
|
|
380
|
+
const fm = mdResult.data.frontmatter;
|
|
381
|
+
for (const h of fm.harnesses) {
|
|
382
|
+
if (!(SUPPORTED_HARNESSES as readonly string[]).includes(h)) {
|
|
383
|
+
errors.push({
|
|
384
|
+
field: "harnesses",
|
|
385
|
+
message: `Unrecognized harness name: "${h}"`,
|
|
386
|
+
filePath: knowledgeMdPath,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Validate harness-config keys reference harnesses in the list
|
|
392
|
+
const harnessConfig = mdResult.data.harnessConfig;
|
|
393
|
+
if (harnessConfig && typeof harnessConfig === "object") {
|
|
394
|
+
for (const key of Object.keys(harnessConfig)) {
|
|
395
|
+
const harnessKey = key as (typeof fm.harnesses)[number];
|
|
396
|
+
if (!fm.harnesses.includes(harnessKey)) {
|
|
397
|
+
// This is a warning, not an error — but we track it as a non-fatal error
|
|
398
|
+
errors.push({
|
|
399
|
+
field: "harness-config",
|
|
400
|
+
message: `harness-config key "${key}" references a harness not in the harnesses list`,
|
|
401
|
+
filePath: knowledgeMdPath,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Lifecycle: deprecated assets must declare a successor (promoted to error in Phase 3)
|
|
408
|
+
if (fm.maturity === "deprecated" && !fm.successor) {
|
|
409
|
+
errors.push({
|
|
410
|
+
field: "successor",
|
|
411
|
+
message: `Asset has maturity "deprecated" but no "successor" field. Set "successor" to point to the replacement artifact.`,
|
|
412
|
+
filePath: knowledgeMdPath,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Governance lane rules
|
|
417
|
+
if ((fm.trust === "official" || fm.trust === "partner") && !fm.license) {
|
|
418
|
+
warnings.push({
|
|
419
|
+
field: "license",
|
|
420
|
+
message: `Artifacts with trust "${fm.trust}" should declare a "license" field (e.g. MIT, Apache-2.0).`,
|
|
421
|
+
filePath: knowledgeMdPath,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (fm["risk-level"] === "high" && !fm.trust) {
|
|
426
|
+
warnings.push({
|
|
427
|
+
field: "trust",
|
|
428
|
+
message: `Artifact has risk-level "high" but no "trust" lane set. Set "trust" to signal governance oversight.`,
|
|
429
|
+
filePath: knowledgeMdPath,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Attribution warnings (ADR-0064; advisory, never fail the build) ──────
|
|
434
|
+
// 1. Un-credited import: has machine provenance but no human attribution.
|
|
435
|
+
const attribution = fm.attribution;
|
|
436
|
+
if (fm.provenance && !attribution?.upstream?.length) {
|
|
437
|
+
warnings.push({
|
|
438
|
+
field: "attribution",
|
|
439
|
+
message: `Artifact "${artifactName}" has a provenance record (imported) but no "attribution.upstream" credit. Run the import wizard or backfill to record upstream authorship.`,
|
|
440
|
+
filePath: knowledgeMdPath,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
// 2. Under-credited attribution-required license: an upstream work under an
|
|
444
|
+
// attribution-required license with neither per-entry authors nor a
|
|
445
|
+
// block-level notice.
|
|
446
|
+
if (attribution?.upstream?.length) {
|
|
447
|
+
for (const [i, u] of attribution.upstream.entries()) {
|
|
448
|
+
if (
|
|
449
|
+
u.license &&
|
|
450
|
+
isAttributionRequiredLicense(u.license) &&
|
|
451
|
+
u.authors.length === 0 &&
|
|
452
|
+
!attribution.notice
|
|
453
|
+
) {
|
|
454
|
+
warnings.push({
|
|
455
|
+
field: `attribution.upstream[${i}]`,
|
|
456
|
+
message: `Upstream work "${u.work}" is under attribution-required license "${u.license}" but has no authors and no block-level notice. Add authors or a notice to satisfy the license.`,
|
|
457
|
+
filePath: knowledgeMdPath,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Asset-type-aware validation rules
|
|
464
|
+
const convention = ASSET_CONVENTIONS[fm.type];
|
|
465
|
+
if (convention) {
|
|
466
|
+
for (const ruleKey of convention.validationRuleKeys) {
|
|
467
|
+
switch (ruleKey) {
|
|
468
|
+
case "reference-pack-must-be-manual":
|
|
469
|
+
if (fm.inclusion !== "manual") {
|
|
470
|
+
warnings.push({
|
|
471
|
+
field: "inclusion",
|
|
472
|
+
message: ASSET_CONVENTION_RULES[ruleKey],
|
|
473
|
+
filePath: knowledgeMdPath,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
|
|
478
|
+
case "workflow-should-have-workflows-dir": {
|
|
479
|
+
const workflowsDir = join(artifactPath, "workflows");
|
|
480
|
+
if (!(await exists(workflowsDir))) {
|
|
481
|
+
warnings.push({
|
|
482
|
+
field: "workflows",
|
|
483
|
+
message: ASSET_CONVENTION_RULES[ruleKey],
|
|
484
|
+
filePath: knowledgeMdPath,
|
|
485
|
+
});
|
|
486
|
+
} else {
|
|
487
|
+
const wfEntries = await readdir(workflowsDir);
|
|
488
|
+
const mdFiles = wfEntries.filter((f) => f.endsWith(".md"));
|
|
489
|
+
if (mdFiles.length === 0) {
|
|
490
|
+
warnings.push({
|
|
491
|
+
field: "workflows",
|
|
492
|
+
message: ASSET_CONVENTION_RULES[ruleKey],
|
|
493
|
+
filePath: knowledgeMdPath,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
case "type-power-deprecated":
|
|
501
|
+
warnings.push({
|
|
502
|
+
field: "type",
|
|
503
|
+
message: ASSET_CONVENTION_RULES[ruleKey],
|
|
504
|
+
filePath: knowledgeMdPath,
|
|
505
|
+
});
|
|
506
|
+
break;
|
|
507
|
+
|
|
508
|
+
case "prompt-body-too-short":
|
|
509
|
+
case "agent-should-document-loop":
|
|
510
|
+
// body is checked separately after parsing knowledge.md
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Prompt body length check (needs body from parse result, outside the if block)
|
|
518
|
+
if (!isParseError(mdResult)) {
|
|
519
|
+
const fm = mdResult.data.frontmatter;
|
|
520
|
+
if (fm.type === "prompt" && mdResult.data.body.length < 50) {
|
|
521
|
+
warnings.push({
|
|
522
|
+
field: "body",
|
|
523
|
+
message: ASSET_CONVENTION_RULES["prompt-body-too-short"],
|
|
524
|
+
filePath: knowledgeMdPath,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Agent loop documentation check (needs body from parse result, outside the if block)
|
|
530
|
+
if (!isParseError(mdResult)) {
|
|
531
|
+
const fm = mdResult.data.frontmatter;
|
|
532
|
+
if (fm.type === "agent" && !documentsAgentLoop(mdResult.data.body)) {
|
|
533
|
+
warnings.push({
|
|
534
|
+
field: "body",
|
|
535
|
+
message: ASSET_CONVENTION_RULES["agent-should-document-loop"],
|
|
536
|
+
filePath: knowledgeMdPath,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── Kiro Progressive Steering validation ────────────────────────────────────
|
|
542
|
+
if (!isParseError(mdResult)) {
|
|
543
|
+
const fm = mdResult.data.frontmatter;
|
|
544
|
+
if (fm.harnesses.includes("kiro")) {
|
|
545
|
+
// Construct a minimal artifact-like object for resolveKiroInclusion
|
|
546
|
+
const artifactForResolver = {
|
|
547
|
+
frontmatter: fm as Record<string, unknown> & { inclusion: string },
|
|
548
|
+
} as Parameters<typeof resolveKiroInclusion>[0];
|
|
549
|
+
|
|
550
|
+
const resolved = resolveKiroInclusion(artifactForResolver);
|
|
551
|
+
|
|
552
|
+
// Access kiro harness-config for cross-field checks
|
|
553
|
+
const harnessConfig = fm["harness-config"] as
|
|
554
|
+
| Record<string, Record<string, unknown>>
|
|
555
|
+
| undefined;
|
|
556
|
+
const kiroConfig = harnessConfig?.kiro;
|
|
557
|
+
|
|
558
|
+
// Req 1.4: fileMatch with absent/empty fileMatchPattern → error
|
|
559
|
+
if (kiroConfig && typeof kiroConfig === "object") {
|
|
560
|
+
const hcInclusion = kiroConfig.inclusion;
|
|
561
|
+
if (hcInclusion === "fileMatch") {
|
|
562
|
+
const fmp = kiroConfig.fileMatchPattern;
|
|
563
|
+
if (!fmp || (typeof fmp === "string" && fmp.length === 0)) {
|
|
564
|
+
errors.push({
|
|
565
|
+
field: "harness-config.kiro.fileMatchPattern",
|
|
566
|
+
message:
|
|
567
|
+
'fileMatchPattern is required when harness-config.kiro.inclusion is "fileMatch"',
|
|
568
|
+
filePath: knowledgeMdPath,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Req 1.5: always/manual with non-empty fileMatchPattern → warning
|
|
574
|
+
if (
|
|
575
|
+
(hcInclusion === "always" || hcInclusion === "manual") &&
|
|
576
|
+
typeof kiroConfig.fileMatchPattern === "string" &&
|
|
577
|
+
kiroConfig.fileMatchPattern.length > 0
|
|
578
|
+
) {
|
|
579
|
+
warnings.push({
|
|
580
|
+
field: "harness-config.kiro.fileMatchPattern",
|
|
581
|
+
message:
|
|
582
|
+
'fileMatchPattern is ignored unless harness-config.kiro.inclusion is "fileMatch"',
|
|
583
|
+
filePath: knowledgeMdPath,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Req 4.1: reference-pack + resolved always → warning
|
|
589
|
+
if (fm.type === "reference-pack" && resolved.mode === "always") {
|
|
590
|
+
warnings.push({
|
|
591
|
+
field: "harness-config.kiro.inclusion",
|
|
592
|
+
message: ASSET_CONVENTION_RULES["reference-pack-must-be-manual"],
|
|
593
|
+
filePath: knowledgeMdPath,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Req 4.2 & 4.3: power-format + resolved always → warning(s)
|
|
598
|
+
const kiroFormat = resolveFormat(
|
|
599
|
+
"kiro",
|
|
600
|
+
kiroConfig as Record<string, unknown> | undefined,
|
|
601
|
+
);
|
|
602
|
+
if (kiroFormat.format === "power" && resolved.mode === "always") {
|
|
603
|
+
warnings.push({
|
|
604
|
+
field: "harness-config.kiro.inclusion",
|
|
605
|
+
message: ASSET_CONVENTION_RULES["kiro-power-should-be-progressive"],
|
|
606
|
+
filePath: knowledgeMdPath,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// Req 4.3: additionally warn if the artifact ships workflow files
|
|
610
|
+
const workflowsDir = join(artifactPath, "workflows");
|
|
611
|
+
if (await exists(workflowsDir)) {
|
|
612
|
+
const wfEntries = await readdir(workflowsDir);
|
|
613
|
+
const mdFiles = wfEntries.filter((f) => f.endsWith(".md"));
|
|
614
|
+
if (mdFiles.length > 0) {
|
|
615
|
+
warnings.push({
|
|
616
|
+
field: "harness-config.kiro.inclusion",
|
|
617
|
+
message:
|
|
618
|
+
ASSET_CONVENTION_RULES[
|
|
619
|
+
"kiro-power-workflow-should-be-progressive"
|
|
620
|
+
],
|
|
621
|
+
filePath: knowledgeMdPath,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Req 8.2: default inclusion → informational warning (suppressed by explicit "always" per Req 8.3)
|
|
628
|
+
if (resolved.source === "default") {
|
|
629
|
+
warnings.push({
|
|
630
|
+
field: "harness-config.kiro.inclusion",
|
|
631
|
+
message:
|
|
632
|
+
ASSET_CONVENTION_RULES["kiro-default-inclusion-informational"],
|
|
633
|
+
filePath: knowledgeMdPath,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Validate hooks.yaml if present
|
|
640
|
+
const hooksPath = join(artifactPath, "hooks.yaml");
|
|
641
|
+
if (await exists(hooksPath)) {
|
|
642
|
+
const hooksResult = await parseHooksYaml(hooksPath);
|
|
643
|
+
if (isParseError(hooksResult)) {
|
|
644
|
+
errors.push(...hooksResult.errors);
|
|
645
|
+
} else {
|
|
646
|
+
// Reference validation for gate/postcondition expressions (Req 3.7):
|
|
647
|
+
// every state key and predicate must be declared/built-in.
|
|
648
|
+
errors.push(...validateHookReferences(hooksResult.data, hooksPath));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Validate mcp-servers.yaml if present
|
|
653
|
+
const mcpPath = join(artifactPath, "mcp-servers.yaml");
|
|
654
|
+
if (await exists(mcpPath)) {
|
|
655
|
+
const mcpResult = await parseMcpServersYaml(mcpPath);
|
|
656
|
+
if (isParseError(mcpResult)) {
|
|
657
|
+
errors.push(...mcpResult.errors);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const result: ValidationResult = {
|
|
662
|
+
artifactName,
|
|
663
|
+
valid: errors.length === 0,
|
|
664
|
+
errors,
|
|
665
|
+
};
|
|
666
|
+
if (warnings.length > 0) {
|
|
667
|
+
result.warnings = warnings;
|
|
668
|
+
}
|
|
669
|
+
return result;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Collect all {artifactPath, artifactName} pairs from one or more source dirs.
|
|
674
|
+
* Mirrors the layout detection logic in catalog.ts and build.ts.
|
|
675
|
+
*/
|
|
676
|
+
async function collectArtifactPaths(
|
|
677
|
+
sourceDirs: string[],
|
|
678
|
+
): Promise<Array<{ path: string; name: string }>> {
|
|
679
|
+
const artifacts: Array<{ path: string; name: string }> = [];
|
|
680
|
+
|
|
681
|
+
for (const sourceDir of sourceDirs) {
|
|
682
|
+
if (!(await exists(sourceDir))) continue;
|
|
683
|
+
|
|
684
|
+
const dirEntries = await readdir(sourceDir, { withFileTypes: true });
|
|
685
|
+
const subdirs = dirEntries
|
|
686
|
+
.filter((e) => e.isDirectory())
|
|
687
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
688
|
+
|
|
689
|
+
for (const subdir of subdirs) {
|
|
690
|
+
const subdirPath = join(sourceDir, subdir.name);
|
|
691
|
+
|
|
692
|
+
if (await exists(join(subdirPath, "knowledge.md"))) {
|
|
693
|
+
artifacts.push({ path: subdirPath, name: subdir.name });
|
|
694
|
+
} else {
|
|
695
|
+
// Namespaced layout — recurse one level
|
|
696
|
+
const inner = await readdir(subdirPath, { withFileTypes: true });
|
|
697
|
+
const innerDirs = inner
|
|
698
|
+
.filter((e) => e.isDirectory())
|
|
699
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
700
|
+
for (const innerDir of innerDirs) {
|
|
701
|
+
const artifactPath = join(subdirPath, innerDir.name);
|
|
702
|
+
if (await exists(join(artifactPath, "knowledge.md"))) {
|
|
703
|
+
artifacts.push({ path: artifactPath, name: innerDir.name });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return artifacts;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Cross-artifact outcomes collision detection (Req 2F).
|
|
715
|
+
*
|
|
716
|
+
* Aggregates every `outcomes` declaration across all artifacts and runs the
|
|
717
|
+
* pure `runRegistryCheck` from the outcomes registry. This function is the thin
|
|
718
|
+
* I/O shell described in ADR-0041: it reads frontmatter, delegates all
|
|
719
|
+
* comparison logic to the pure core, and maps the resulting findings to
|
|
720
|
+
* validation errors and warnings.
|
|
721
|
+
*
|
|
722
|
+
* - `collision` (non-acknowledged) -> {@link ValidationError} including both
|
|
723
|
+
* outcome ids, both artifact names, the matched normalized shapes, and the
|
|
724
|
+
* keyword Jaccard score (Req 2F.2).
|
|
725
|
+
* - `duplicate-id` -> {@link ValidationError} (ids must be globally unique,
|
|
726
|
+
* Req 2F.4).
|
|
727
|
+
* - `ambiguous` -> {@link ValidationWarning} (does not block validation,
|
|
728
|
+
* Req 2F.3).
|
|
729
|
+
* - `acknowledged-overlap` -> no finding.
|
|
730
|
+
*
|
|
731
|
+
* Returns a single synthetic {@link ValidationResult} (artifact name
|
|
732
|
+
* `[outcomes-registry]`) when any finding is produced, or `null` when there is
|
|
733
|
+
* nothing to report. Errors mark the result invalid, which drives the existing
|
|
734
|
+
* non-zero exit path in {@link validateCommand} (Req 2F.2).
|
|
735
|
+
*/
|
|
736
|
+
async function validateOutcomes(
|
|
737
|
+
artifactList: Array<{ path: string; name: string }>,
|
|
738
|
+
): Promise<ValidationResult | null> {
|
|
739
|
+
// Aggregate outcomes from each artifact's (already schema-validated)
|
|
740
|
+
// frontmatter, and remember each artifact's knowledge.md path for diagnostics.
|
|
741
|
+
const artifactsWithOutcomes: Array<{ name: string; outcomes: Outcome[] }> =
|
|
742
|
+
[];
|
|
743
|
+
const knowledgeMdByName = new Map<string, string>();
|
|
744
|
+
|
|
745
|
+
for (const { path: artifactPath, name } of artifactList) {
|
|
746
|
+
const knowledgeMdPath = join(artifactPath, "knowledge.md");
|
|
747
|
+
knowledgeMdByName.set(name, knowledgeMdPath);
|
|
748
|
+
|
|
749
|
+
const mdResult = await parseKnowledgeMd(knowledgeMdPath);
|
|
750
|
+
if (isParseError(mdResult)) continue;
|
|
751
|
+
|
|
752
|
+
const outcomes = mdResult.data.frontmatter.outcomes ?? [];
|
|
753
|
+
if (outcomes.length > 0) {
|
|
754
|
+
artifactsWithOutcomes.push({ name, outcomes });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const report = runRegistryCheck(aggregateOutcomes(artifactsWithOutcomes));
|
|
759
|
+
if (report.findings.length === 0) return null;
|
|
760
|
+
|
|
761
|
+
const errors: ValidationError[] = [];
|
|
762
|
+
const warnings: ValidationWarning[] = [];
|
|
763
|
+
|
|
764
|
+
for (const finding of report.findings) {
|
|
765
|
+
const filePath =
|
|
766
|
+
knowledgeMdByName.get(finding.a.artifactName) ?? "knowledge.md";
|
|
767
|
+
const aId = finding.a.outcome.id;
|
|
768
|
+
const bId = finding.b.outcome.id;
|
|
769
|
+
const aArtifact = finding.a.artifactName;
|
|
770
|
+
const bArtifact = finding.b.artifactName;
|
|
771
|
+
|
|
772
|
+
switch (finding.kind) {
|
|
773
|
+
case "duplicate-id":
|
|
774
|
+
errors.push({
|
|
775
|
+
field: "outcomes",
|
|
776
|
+
message: `Duplicate outcome id "${aId}" declared in both "${aArtifact}" and "${bArtifact}". Outcome ids must be globally unique.`,
|
|
777
|
+
filePath,
|
|
778
|
+
});
|
|
779
|
+
break;
|
|
780
|
+
|
|
781
|
+
case "collision":
|
|
782
|
+
errors.push({
|
|
783
|
+
field: "outcomes",
|
|
784
|
+
message: `Outcome collision: "${aId}" (${aArtifact}) and "${bId}" (${bArtifact}) share normalized shapes ("${finding.inputShape}" → "${finding.outputShape}") with keyword Jaccard ${finding.jaccard?.toFixed(2)}. Acknowledge intentional overlap by adding each id to the other's "related" field.`,
|
|
785
|
+
filePath,
|
|
786
|
+
});
|
|
787
|
+
break;
|
|
788
|
+
|
|
789
|
+
case "ambiguous":
|
|
790
|
+
warnings.push({
|
|
791
|
+
field: "outcomes",
|
|
792
|
+
message: `Ambiguous outcome overlap: "${aId}" (${aArtifact}) and "${bId}" (${bArtifact}) match on exactly one tier (keyword Jaccard ${finding.jaccard?.toFixed(2)}). Review whether these outcomes overlap.`,
|
|
793
|
+
filePath,
|
|
794
|
+
});
|
|
795
|
+
break;
|
|
796
|
+
|
|
797
|
+
// "acknowledged-overlap" is intentional — no finding reported.
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const result: ValidationResult = {
|
|
802
|
+
artifactName: "[outcomes-registry]",
|
|
803
|
+
valid: errors.length === 0,
|
|
804
|
+
errors,
|
|
805
|
+
};
|
|
806
|
+
if (warnings.length > 0) {
|
|
807
|
+
result.warnings = warnings;
|
|
808
|
+
}
|
|
809
|
+
return result;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function validateAll(
|
|
813
|
+
knowledgeDirs: string | string[],
|
|
814
|
+
): Promise<ValidationResult[]> {
|
|
815
|
+
const sourceDirs = Array.isArray(knowledgeDirs)
|
|
816
|
+
? knowledgeDirs
|
|
817
|
+
: [knowledgeDirs];
|
|
818
|
+
const results: ValidationResult[] = [];
|
|
819
|
+
|
|
820
|
+
const artifactList = await collectArtifactPaths(sourceDirs);
|
|
821
|
+
|
|
822
|
+
for (const { path: artifactPath } of artifactList) {
|
|
823
|
+
const result = await validateArtifact(artifactPath);
|
|
824
|
+
results.push(result);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Cross-artifact dependency reference resolution across all source dirs
|
|
828
|
+
const artifactNames = new Set<string>(artifactList.map((a) => a.name));
|
|
829
|
+
|
|
830
|
+
for (const { path: artifactPath, name: artifactName } of artifactList) {
|
|
831
|
+
const knowledgeMdPath = join(artifactPath, "knowledge.md");
|
|
832
|
+
const mdResult = await parseKnowledgeMd(knowledgeMdPath);
|
|
833
|
+
|
|
834
|
+
if (isParseError(mdResult)) continue;
|
|
835
|
+
|
|
836
|
+
const fm = mdResult.data.frontmatter;
|
|
837
|
+
const warnings: ValidationWarning[] = [];
|
|
838
|
+
|
|
839
|
+
for (const dep of fm.depends) {
|
|
840
|
+
if (!artifactNames.has(dep)) {
|
|
841
|
+
warnings.push({
|
|
842
|
+
field: "depends",
|
|
843
|
+
message: `Unresolved dependency reference: "${dep}"`,
|
|
844
|
+
filePath: knowledgeMdPath,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
for (const enh of fm.enhances) {
|
|
850
|
+
if (!artifactNames.has(enh)) {
|
|
851
|
+
warnings.push({
|
|
852
|
+
field: "enhances",
|
|
853
|
+
message: `Unresolved enhances reference: "${enh}"`,
|
|
854
|
+
filePath: knowledgeMdPath,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (warnings.length > 0) {
|
|
860
|
+
const matchingResult = results.find(
|
|
861
|
+
(r) => r.artifactName === artifactName,
|
|
862
|
+
);
|
|
863
|
+
if (matchingResult) {
|
|
864
|
+
matchingResult.warnings = [
|
|
865
|
+
...(matchingResult.warnings ?? []),
|
|
866
|
+
...warnings,
|
|
867
|
+
];
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Cycle detection across the depends graph
|
|
873
|
+
const dependsMap = new Map<string, string[]>();
|
|
874
|
+
for (const { path: artifactPath, name: artifactName } of artifactList) {
|
|
875
|
+
const knowledgeMdPath = join(artifactPath, "knowledge.md");
|
|
876
|
+
const mdResult = await parseKnowledgeMd(knowledgeMdPath);
|
|
877
|
+
if (!isParseError(mdResult)) {
|
|
878
|
+
dependsMap.set(artifactName, mdResult.data.frontmatter.depends);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const cycles = detectDependencyCycles(dependsMap);
|
|
882
|
+
if (cycles.length > 0) {
|
|
883
|
+
for (const cycle of cycles) {
|
|
884
|
+
const cycleStr = [...cycle, cycle[0]].join(" → ");
|
|
885
|
+
// Attach the error to the first artifact in the cycle
|
|
886
|
+
const matchingResult = results.find((r) => r.artifactName === cycle[0]);
|
|
887
|
+
if (matchingResult) {
|
|
888
|
+
matchingResult.valid = false;
|
|
889
|
+
matchingResult.errors = [
|
|
890
|
+
...matchingResult.errors,
|
|
891
|
+
{
|
|
892
|
+
field: "depends",
|
|
893
|
+
message: `Dependency cycle detected: ${cycleStr}`,
|
|
894
|
+
filePath: join(
|
|
895
|
+
artifactList.find((a) => a.name === cycle[0])?.path ?? "",
|
|
896
|
+
"knowledge.md",
|
|
897
|
+
),
|
|
898
|
+
},
|
|
899
|
+
];
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Cross-artifact outcomes collision detection (Req 2F). Aggregates all
|
|
905
|
+
// declared outcomes and runs the pure registry check; collisions and
|
|
906
|
+
// duplicate ids surface as errors, ambiguous overlaps as warnings.
|
|
907
|
+
const outcomesResult = await validateOutcomes(artifactList);
|
|
908
|
+
if (outcomesResult) {
|
|
909
|
+
results.push(outcomesResult);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Collection cross-check: warn if artifacts declare unknown collection names
|
|
913
|
+
const collectionsDir = "collections";
|
|
914
|
+
const collectionManifests = await loadCollections(collectionsDir);
|
|
915
|
+
if (collectionManifests.length > 0) {
|
|
916
|
+
// Build lightweight CatalogEntry-like objects from the validate results
|
|
917
|
+
const catalogEntries = await generateCatalog(sourceDirs);
|
|
918
|
+
const collectionWarnings = validateArtifactCollectionRefs(
|
|
919
|
+
catalogEntries,
|
|
920
|
+
collectionManifests,
|
|
921
|
+
);
|
|
922
|
+
for (const w of collectionWarnings) {
|
|
923
|
+
// Map to the matching result by artifact path prefix
|
|
924
|
+
const matchingResult = results.find((r) =>
|
|
925
|
+
w.filePath.includes(r.artifactName),
|
|
926
|
+
);
|
|
927
|
+
if (matchingResult) {
|
|
928
|
+
matchingResult.warnings = [...(matchingResult.warnings ?? []), w];
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Capability matrix validation: ensure matrix harnesses match adapter registry and format registry
|
|
934
|
+
const matrixSync = validateMatrixSync(
|
|
935
|
+
Object.keys(CAPABILITY_MATRIX),
|
|
936
|
+
Object.keys(adapterRegistry),
|
|
937
|
+
Object.keys(HARNESS_FORMAT_REGISTRY),
|
|
938
|
+
);
|
|
939
|
+
if (matrixSync.missing.length > 0 || matrixSync.extra.length > 0) {
|
|
940
|
+
// Create a synthetic validation result for the capability matrix
|
|
941
|
+
const matrixErrors: ValidationError[] = [];
|
|
942
|
+
for (const h of matrixSync.missing) {
|
|
943
|
+
matrixErrors.push({
|
|
944
|
+
field: "CAPABILITY_MATRIX",
|
|
945
|
+
message: `Missing capability matrix entry for harness: "${h}"`,
|
|
946
|
+
filePath: "src/adapters/capabilities.ts",
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
for (const h of matrixSync.extra) {
|
|
950
|
+
matrixErrors.push({
|
|
951
|
+
field: "CAPABILITY_MATRIX",
|
|
952
|
+
message: `Extra capability matrix entry for unknown harness: "${h}"`,
|
|
953
|
+
filePath: "src/adapters/capabilities.ts",
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
results.push({
|
|
957
|
+
artifactName: "[capability-matrix]",
|
|
958
|
+
valid: false,
|
|
959
|
+
errors: matrixErrors,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Workspace config validation: validate workspace config if present
|
|
964
|
+
try {
|
|
965
|
+
const wsResult = await loadWorkspaceConfig(process.cwd());
|
|
966
|
+
if (wsResult) {
|
|
967
|
+
const wsErrors = await validateWorkspaceConfig(
|
|
968
|
+
wsResult.config,
|
|
969
|
+
process.cwd(),
|
|
970
|
+
artifactNames,
|
|
971
|
+
);
|
|
972
|
+
if (wsErrors.length > 0) {
|
|
973
|
+
const prefixedErrors: ValidationError[] = wsErrors.map((e) => ({
|
|
974
|
+
...e,
|
|
975
|
+
message: `kanon.config.yaml: ${e.message}`,
|
|
976
|
+
}));
|
|
977
|
+
results.push({
|
|
978
|
+
artifactName: "[workspace-config]",
|
|
979
|
+
valid: false,
|
|
980
|
+
errors: prefixedErrors,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
} catch {
|
|
985
|
+
// If workspace config loading fails (e.g., parse error), report it
|
|
986
|
+
results.push({
|
|
987
|
+
artifactName: "[workspace-config]",
|
|
988
|
+
valid: false,
|
|
989
|
+
errors: [
|
|
990
|
+
{
|
|
991
|
+
field: "workspace-config",
|
|
992
|
+
message: "kanon.config.yaml: Failed to load workspace configuration",
|
|
993
|
+
filePath: "kanon.config.yaml",
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return results;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export const SOURCE_DIRS = ["knowledge", "packages"] as const;
|
|
1003
|
+
|
|
1004
|
+
export async function validateCommand(
|
|
1005
|
+
artifactPath?: string,
|
|
1006
|
+
options: { security?: boolean } = {},
|
|
1007
|
+
): Promise<void> {
|
|
1008
|
+
let results: ValidationResult[];
|
|
1009
|
+
|
|
1010
|
+
if (artifactPath) {
|
|
1011
|
+
const result = await validateArtifact(artifactPath);
|
|
1012
|
+
results = [result];
|
|
1013
|
+
} else {
|
|
1014
|
+
results = await validateAll([...SOURCE_DIRS]);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Overlay security checks if --security flag is set
|
|
1018
|
+
if (options.security) {
|
|
1019
|
+
const sourceDirs = artifactPath ? [] : [...SOURCE_DIRS];
|
|
1020
|
+
const artifactPaths: string[] = [];
|
|
1021
|
+
|
|
1022
|
+
if (artifactPath) {
|
|
1023
|
+
artifactPaths.push(artifactPath);
|
|
1024
|
+
} else {
|
|
1025
|
+
for (const dir of sourceDirs) {
|
|
1026
|
+
if (!(await exists(dir))) continue;
|
|
1027
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1028
|
+
for (const entry of entries.filter((e) => e.isDirectory())) {
|
|
1029
|
+
artifactPaths.push(join(dir, entry.name));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
for (const aPath of artifactPaths) {
|
|
1035
|
+
const aName = aPath.split("/").pop() ?? aPath;
|
|
1036
|
+
const secResult = await validateArtifactSecurity(aPath);
|
|
1037
|
+
const matching = results.find((r) => r.artifactName === aName);
|
|
1038
|
+
if (matching) {
|
|
1039
|
+
if (secResult.errors.length > 0) {
|
|
1040
|
+
matching.errors.push(...secResult.errors);
|
|
1041
|
+
matching.valid = false;
|
|
1042
|
+
}
|
|
1043
|
+
if (secResult.warnings.length > 0) {
|
|
1044
|
+
matching.warnings = [
|
|
1045
|
+
...(matching.warnings ?? []),
|
|
1046
|
+
...secResult.warnings,
|
|
1047
|
+
];
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (results.length === 0) {
|
|
1054
|
+
console.error(chalk.yellow("No artifacts found to validate."));
|
|
1055
|
+
process.exit(0);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
let hasErrors = false;
|
|
1059
|
+
let totalWarnings = 0;
|
|
1060
|
+
for (const result of results) {
|
|
1061
|
+
if (result.valid) {
|
|
1062
|
+
console.error(chalk.green(`✓ ${result.artifactName}`));
|
|
1063
|
+
} else {
|
|
1064
|
+
hasErrors = true;
|
|
1065
|
+
console.error(chalk.red(`✗ ${result.artifactName}`));
|
|
1066
|
+
for (const err of result.errors) {
|
|
1067
|
+
console.error(
|
|
1068
|
+
chalk.red(` ${err.field}: ${err.message} (${err.filePath})`),
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
1073
|
+
totalWarnings += result.warnings.length;
|
|
1074
|
+
for (const warn of result.warnings) {
|
|
1075
|
+
console.error(
|
|
1076
|
+
chalk.yellow(` ⚠ ${warn.field}: ${warn.message} (${warn.filePath})`),
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const passed = results.filter((r) => r.valid).length;
|
|
1083
|
+
const failed = results.filter((r) => !r.valid).length;
|
|
1084
|
+
let summary = `\n${passed} passed, ${failed} failed out of ${results.length} artifacts`;
|
|
1085
|
+
if (totalWarnings > 0) {
|
|
1086
|
+
summary += `, ${totalWarnings} warning${totalWarnings === 1 ? "" : "s"}`;
|
|
1087
|
+
}
|
|
1088
|
+
console.error(summary);
|
|
1089
|
+
|
|
1090
|
+
if (hasErrors) {
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
}
|