@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,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rosetta Stone — Format Detector and Selector
|
|
3
|
+
*
|
|
4
|
+
* Evaluates detection rules over sorted in-memory documents, computes bounded
|
|
5
|
+
* confidence scores, and selects or reports format candidates deterministically.
|
|
6
|
+
*
|
|
7
|
+
* CONSTRAINTS:
|
|
8
|
+
* - NO filesystem, process, clock, random, Git, or network imports
|
|
9
|
+
* - Pure functions only
|
|
10
|
+
*
|
|
11
|
+
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DetectionCandidate,
|
|
16
|
+
DetectionEvidence,
|
|
17
|
+
DetectionRule,
|
|
18
|
+
DetectionRuleKind,
|
|
19
|
+
FormatContract,
|
|
20
|
+
FormatIdentifier,
|
|
21
|
+
SourceDocument,
|
|
22
|
+
TranslationDiagnostic,
|
|
23
|
+
} from "../schemas";
|
|
24
|
+
import { codePointCompare } from "./contracts";
|
|
25
|
+
import { createDiagnostic } from "./diagnostics";
|
|
26
|
+
import type {
|
|
27
|
+
RequestedDirection,
|
|
28
|
+
TranslationRegistrySnapshot,
|
|
29
|
+
} from "./registry";
|
|
30
|
+
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
// Detection Request and Result Types
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
34
|
+
|
|
35
|
+
export interface DetectionRequest {
|
|
36
|
+
readonly documents: readonly SourceDocument[];
|
|
37
|
+
readonly registrySnapshot: TranslationRegistrySnapshot;
|
|
38
|
+
readonly explicitFormatId?: FormatIdentifier;
|
|
39
|
+
readonly direction: RequestedDirection;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type DetectionResult =
|
|
43
|
+
| {
|
|
44
|
+
ok: true;
|
|
45
|
+
selected: FormatIdentifier;
|
|
46
|
+
candidates: readonly DetectionCandidate[];
|
|
47
|
+
diagnostics: TranslationDiagnostic[];
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
ok: false;
|
|
51
|
+
candidates: readonly DetectionCandidate[];
|
|
52
|
+
diagnostics: TranslationDiagnostic[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
// Main Detection Function
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Detect and select a format from the registry based on document content.
|
|
61
|
+
*
|
|
62
|
+
* Detection proceeds:
|
|
63
|
+
* 1. Sort document paths by Unicode code-point order.
|
|
64
|
+
* 2. Enumerate source-capable, detectable contracts by FormatIdentifier code-point order.
|
|
65
|
+
* 3. Evaluate each contract's rules in declared rule-ID order over the sorted documents.
|
|
66
|
+
* 4. Compute confidence, determine qualification.
|
|
67
|
+
* 5. Sort candidates by confidence descending, FormatIdentifier ascending.
|
|
68
|
+
* 6. Select unique highest qualifier or report no-match/ambiguity.
|
|
69
|
+
*
|
|
70
|
+
* For explicit selection, skip scoring as selector but still evaluate
|
|
71
|
+
* required/conflicting rules and validate direction.
|
|
72
|
+
*/
|
|
73
|
+
export function detect(request: DetectionRequest): DetectionResult {
|
|
74
|
+
const { documents, registrySnapshot, explicitFormatId, direction } = request;
|
|
75
|
+
|
|
76
|
+
// Sort document paths by code-point order for deterministic evaluation
|
|
77
|
+
const sortedPaths = [...documents.map((d) => d.path)].sort(codePointCompare);
|
|
78
|
+
|
|
79
|
+
// Build a path-to-document lookup for content-based rules
|
|
80
|
+
const docByPath = new Map<string, SourceDocument>();
|
|
81
|
+
for (const doc of documents) {
|
|
82
|
+
docByPath.set(doc.path, doc);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Handle explicit format selection
|
|
86
|
+
if (explicitFormatId !== undefined) {
|
|
87
|
+
return handleExplicitSelection(
|
|
88
|
+
explicitFormatId,
|
|
89
|
+
direction,
|
|
90
|
+
sortedPaths,
|
|
91
|
+
docByPath,
|
|
92
|
+
registrySnapshot,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get source-capable contracts sorted by FormatIdentifier code-point order
|
|
97
|
+
const contracts = getSourceCapableContracts(registrySnapshot);
|
|
98
|
+
|
|
99
|
+
// Evaluate all contracts
|
|
100
|
+
const candidates: DetectionCandidate[] = [];
|
|
101
|
+
for (const contract of contracts) {
|
|
102
|
+
const candidate = evaluateContract(contract, sortedPaths, docByPath);
|
|
103
|
+
candidates.push(candidate);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sort candidates: confidence descending, then FormatIdentifier ascending
|
|
107
|
+
candidates.sort((a, b) => {
|
|
108
|
+
const confDiff = b.confidence - a.confidence;
|
|
109
|
+
if (confDiff !== 0) return confDiff;
|
|
110
|
+
return codePointCompare(a.formatId, b.formatId);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Selection logic
|
|
114
|
+
const qualifiers = candidates.filter((c) => c.qualifies);
|
|
115
|
+
|
|
116
|
+
if (qualifiers.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
candidates,
|
|
120
|
+
diagnostics: [createDiagnostic("RS_NO_MATCH", {})],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const highestConfidence = qualifiers[0].confidence;
|
|
125
|
+
const tied = qualifiers.filter((c) => c.confidence === highestConfidence);
|
|
126
|
+
|
|
127
|
+
if (tied.length > 1) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
candidates,
|
|
131
|
+
diagnostics: [
|
|
132
|
+
createDiagnostic("RS_AMBIGUOUS_MATCH", {
|
|
133
|
+
message: `Multiple formats share the highest qualifying confidence (${highestConfidence}): ${tied.map((c) => c.formatId).join(", ")}.`,
|
|
134
|
+
}),
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Unique highest qualifier
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
selected: tied[0].formatId,
|
|
143
|
+
candidates,
|
|
144
|
+
diagnostics: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
// Explicit Selection Handler
|
|
150
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
151
|
+
|
|
152
|
+
function handleExplicitSelection(
|
|
153
|
+
formatId: FormatIdentifier,
|
|
154
|
+
direction: RequestedDirection,
|
|
155
|
+
sortedPaths: string[],
|
|
156
|
+
docByPath: Map<string, SourceDocument>,
|
|
157
|
+
registrySnapshot: TranslationRegistrySnapshot,
|
|
158
|
+
): DetectionResult {
|
|
159
|
+
const diagnostics: TranslationDiagnostic[] = [];
|
|
160
|
+
|
|
161
|
+
// Resolve the format from registry
|
|
162
|
+
const resolution = registrySnapshot.resolve(formatId, "any");
|
|
163
|
+
if (!resolution.ok) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
candidates: [],
|
|
167
|
+
diagnostics: resolution.diagnostics,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const contract = resolution.contract;
|
|
172
|
+
|
|
173
|
+
// Validate direction compatibility
|
|
174
|
+
if (direction !== "any") {
|
|
175
|
+
const contractDir = contract.direction;
|
|
176
|
+
if (contractDir !== "bidirectional" && contractDir !== direction) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
candidates: [],
|
|
180
|
+
diagnostics: [
|
|
181
|
+
createDiagnostic("RS_DIRECTION_MISMATCH", {
|
|
182
|
+
formatId: contract.id,
|
|
183
|
+
message: `Format "${contract.id}" declares direction "${contractDir}" but "${direction}" was requested.`,
|
|
184
|
+
}),
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Evaluate required and conflicting rules for evidence
|
|
191
|
+
const evidence = evaluateRulesForEvidence(
|
|
192
|
+
contract.detection.rules,
|
|
193
|
+
sortedPaths,
|
|
194
|
+
docByPath,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Check for missing-required rules blocking dispatch
|
|
198
|
+
const hasMissingRequired = evidence.some(
|
|
199
|
+
(e) => e.outcome === "missing-required",
|
|
200
|
+
);
|
|
201
|
+
const hasConflicting = evidence.some((e) => e.outcome === "conflicting");
|
|
202
|
+
|
|
203
|
+
// Compute confidence for informational purposes
|
|
204
|
+
const confidence = computeConfidence(contract.detection.rules, evidence);
|
|
205
|
+
|
|
206
|
+
const candidate: DetectionCandidate = {
|
|
207
|
+
formatId: contract.id,
|
|
208
|
+
confidence,
|
|
209
|
+
threshold: contract.detection.threshold,
|
|
210
|
+
qualifies:
|
|
211
|
+
!hasMissingRequired && confidence >= contract.detection.threshold,
|
|
212
|
+
evidence,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (hasMissingRequired || hasConflicting) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
candidates: [candidate],
|
|
219
|
+
diagnostics: [
|
|
220
|
+
createDiagnostic("RS_NO_MATCH", {
|
|
221
|
+
formatId: contract.id,
|
|
222
|
+
message: hasMissingRequired
|
|
223
|
+
? `Explicit format "${contract.id}" has missing required rules.`
|
|
224
|
+
: `Explicit format "${contract.id}" has conflicting evidence.`,
|
|
225
|
+
}),
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
selected: contract.id,
|
|
233
|
+
candidates: [candidate],
|
|
234
|
+
diagnostics: [...resolution.diagnostics, ...diagnostics],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
239
|
+
// Contract Evaluation
|
|
240
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get source-capable contracts from the registry snapshot, sorted by
|
|
244
|
+
* FormatIdentifier in code-point order.
|
|
245
|
+
*/
|
|
246
|
+
function getSourceCapableContracts(
|
|
247
|
+
registrySnapshot: TranslationRegistrySnapshot,
|
|
248
|
+
): readonly FormatContract[] {
|
|
249
|
+
// List contracts that are source-capable (source or bidirectional)
|
|
250
|
+
const sourceContracts = registrySnapshot.listContracts({
|
|
251
|
+
direction: "source",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Filter to only contracts with detection rules (detectable)
|
|
255
|
+
const detectable = sourceContracts.filter(
|
|
256
|
+
(c) => c.detection.rules.length > 0,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Already sorted by FormatIdentifier from listContracts (registry sorts by code-point)
|
|
260
|
+
return detectable;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Evaluate a single contract's detection rules against sorted document paths.
|
|
265
|
+
*/
|
|
266
|
+
function evaluateContract(
|
|
267
|
+
contract: FormatContract,
|
|
268
|
+
sortedPaths: string[],
|
|
269
|
+
docByPath: Map<string, SourceDocument>,
|
|
270
|
+
): DetectionCandidate {
|
|
271
|
+
const evidence = evaluateRulesForEvidence(
|
|
272
|
+
contract.detection.rules,
|
|
273
|
+
sortedPaths,
|
|
274
|
+
docByPath,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const confidence = computeConfidence(contract.detection.rules, evidence);
|
|
278
|
+
|
|
279
|
+
// A candidate qualifies if:
|
|
280
|
+
// 1. confidence >= threshold
|
|
281
|
+
// 2. No missing required rules
|
|
282
|
+
const hasMissingRequired = evidence.some(
|
|
283
|
+
(e) => e.outcome === "missing-required",
|
|
284
|
+
);
|
|
285
|
+
const qualifies =
|
|
286
|
+
!hasMissingRequired && confidence >= contract.detection.threshold;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
formatId: contract.id,
|
|
290
|
+
confidence,
|
|
291
|
+
threshold: contract.detection.threshold,
|
|
292
|
+
qualifies,
|
|
293
|
+
evidence,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Evaluate all rules for a contract and produce evidence entries.
|
|
299
|
+
* Rules are evaluated in declared order (rule-ID order from the contract).
|
|
300
|
+
*/
|
|
301
|
+
function evaluateRulesForEvidence(
|
|
302
|
+
rules: readonly DetectionRule[],
|
|
303
|
+
sortedPaths: string[],
|
|
304
|
+
docByPath: Map<string, SourceDocument>,
|
|
305
|
+
): DetectionEvidence[] {
|
|
306
|
+
const evidence: DetectionEvidence[] = [];
|
|
307
|
+
|
|
308
|
+
for (const rule of rules) {
|
|
309
|
+
const result = evaluateRule(rule, sortedPaths, docByPath);
|
|
310
|
+
evidence.push(result);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return evidence;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
317
|
+
// Confidence Calculation
|
|
318
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Compute confidence = matchedPositiveWeight / totalPositiveWeight.
|
|
322
|
+
* Rounded to 6 decimal places.
|
|
323
|
+
*/
|
|
324
|
+
function computeConfidence(
|
|
325
|
+
rules: readonly DetectionRule[],
|
|
326
|
+
evidence: readonly DetectionEvidence[],
|
|
327
|
+
): number {
|
|
328
|
+
let totalPositiveWeight = 0;
|
|
329
|
+
let matchedPositiveWeight = 0;
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < rules.length; i++) {
|
|
332
|
+
const rule = rules[i];
|
|
333
|
+
const ev = evidence[i];
|
|
334
|
+
|
|
335
|
+
if (rule.weight > 0) {
|
|
336
|
+
totalPositiveWeight += rule.weight;
|
|
337
|
+
if (ev.outcome === "matched") {
|
|
338
|
+
matchedPositiveWeight += rule.weight;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (totalPositiveWeight === 0) {
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const confidence = matchedPositiveWeight / totalPositiveWeight;
|
|
348
|
+
return Math.round(confidence * 1_000_000) / 1_000_000;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
352
|
+
// Rule Evaluation
|
|
353
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Evaluate a single detection rule against all sorted paths and documents.
|
|
357
|
+
*/
|
|
358
|
+
function evaluateRule(
|
|
359
|
+
rule: DetectionRule,
|
|
360
|
+
sortedPaths: string[],
|
|
361
|
+
docByPath: Map<string, SourceDocument>,
|
|
362
|
+
): DetectionEvidence {
|
|
363
|
+
const matchedPaths: string[] = [];
|
|
364
|
+
|
|
365
|
+
for (const path of sortedPaths) {
|
|
366
|
+
const doc = docByPath.get(path);
|
|
367
|
+
if (!doc) continue;
|
|
368
|
+
|
|
369
|
+
const matches = evaluateRuleKind(
|
|
370
|
+
rule.kind,
|
|
371
|
+
rule.pattern,
|
|
372
|
+
path,
|
|
373
|
+
doc,
|
|
374
|
+
rule.maxParseBytes,
|
|
375
|
+
);
|
|
376
|
+
if (matches) {
|
|
377
|
+
matchedPaths.push(path);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const matched = matchedPaths.length > 0;
|
|
382
|
+
|
|
383
|
+
// Determine outcome
|
|
384
|
+
let outcome: DetectionEvidence["outcome"];
|
|
385
|
+
if (matched) {
|
|
386
|
+
// Negative weight rules that match are conflicting
|
|
387
|
+
if (rule.weight < 0) {
|
|
388
|
+
outcome = "conflicting";
|
|
389
|
+
} else {
|
|
390
|
+
outcome = "matched";
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
// Not matched
|
|
394
|
+
if (rule.required) {
|
|
395
|
+
outcome = "missing-required";
|
|
396
|
+
} else {
|
|
397
|
+
outcome = "not-matched";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
ruleId: rule.id,
|
|
403
|
+
kind: rule.kind,
|
|
404
|
+
outcome,
|
|
405
|
+
paths: matchedPaths,
|
|
406
|
+
marker: rule.evidenceLabel,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Evaluate a rule kind against a single path/document.
|
|
412
|
+
*/
|
|
413
|
+
function evaluateRuleKind(
|
|
414
|
+
kind: DetectionRuleKind,
|
|
415
|
+
pattern: string,
|
|
416
|
+
path: string,
|
|
417
|
+
doc: SourceDocument,
|
|
418
|
+
maxParseBytes?: number,
|
|
419
|
+
): boolean {
|
|
420
|
+
switch (kind) {
|
|
421
|
+
case "path-glob":
|
|
422
|
+
return matchPathGlob(pattern, path);
|
|
423
|
+
case "basename":
|
|
424
|
+
return matchBasename(pattern, path);
|
|
425
|
+
case "extension":
|
|
426
|
+
return matchExtension(pattern, path);
|
|
427
|
+
case "content-marker":
|
|
428
|
+
return matchContentMarker(pattern, doc, maxParseBytes);
|
|
429
|
+
case "frontmatter-key":
|
|
430
|
+
return matchFrontmatterKey(pattern, doc, maxParseBytes);
|
|
431
|
+
case "json-pointer":
|
|
432
|
+
return matchJsonPointer(pattern, doc, maxParseBytes);
|
|
433
|
+
case "yaml-key":
|
|
434
|
+
return matchYamlKey(pattern, doc, maxParseBytes);
|
|
435
|
+
default:
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
441
|
+
// Rule Kind Matchers
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Simple glob matching for path-glob rules.
|
|
446
|
+
* Supports `*` (any segment chars) and `**` (any number of segments).
|
|
447
|
+
*/
|
|
448
|
+
function matchPathGlob(pattern: string, path: string): boolean {
|
|
449
|
+
// Convert glob pattern to regex
|
|
450
|
+
const regexStr = globToRegex(pattern);
|
|
451
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
452
|
+
return regex.test(path);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Convert a glob pattern to a regex string.
|
|
457
|
+
* `**` matches any number of path segments (including zero).
|
|
458
|
+
* `*` matches anything except `/`.
|
|
459
|
+
* `?` matches any single character except `/`.
|
|
460
|
+
*/
|
|
461
|
+
function globToRegex(pattern: string): string {
|
|
462
|
+
let result = "";
|
|
463
|
+
let i = 0;
|
|
464
|
+
|
|
465
|
+
while (i < pattern.length) {
|
|
466
|
+
const char = pattern[i];
|
|
467
|
+
|
|
468
|
+
if (char === "*") {
|
|
469
|
+
if (i + 1 < pattern.length && pattern[i + 1] === "*") {
|
|
470
|
+
// ** — match any number of path segments
|
|
471
|
+
// If followed by /, consume the /
|
|
472
|
+
if (i + 2 < pattern.length && pattern[i + 2] === "/") {
|
|
473
|
+
result += "(?:.+/)?";
|
|
474
|
+
i += 3;
|
|
475
|
+
} else {
|
|
476
|
+
result += ".*";
|
|
477
|
+
i += 2;
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
// * — match anything except /
|
|
481
|
+
result += "[^/]*";
|
|
482
|
+
i += 1;
|
|
483
|
+
}
|
|
484
|
+
} else if (char === "?") {
|
|
485
|
+
result += "[^/]";
|
|
486
|
+
i += 1;
|
|
487
|
+
} else if (char === ".") {
|
|
488
|
+
result += "\\.";
|
|
489
|
+
i += 1;
|
|
490
|
+
} else if (char === "(") {
|
|
491
|
+
result += "\\(";
|
|
492
|
+
i += 1;
|
|
493
|
+
} else if (char === ")") {
|
|
494
|
+
result += "\\)";
|
|
495
|
+
i += 1;
|
|
496
|
+
} else if (char === "[") {
|
|
497
|
+
result += "\\[";
|
|
498
|
+
i += 1;
|
|
499
|
+
} else if (char === "]") {
|
|
500
|
+
result += "\\]";
|
|
501
|
+
i += 1;
|
|
502
|
+
} else if (char === "{") {
|
|
503
|
+
result += "\\{";
|
|
504
|
+
i += 1;
|
|
505
|
+
} else if (char === "}") {
|
|
506
|
+
result += "\\}";
|
|
507
|
+
i += 1;
|
|
508
|
+
} else if (char === "+") {
|
|
509
|
+
result += "\\+";
|
|
510
|
+
i += 1;
|
|
511
|
+
} else if (char === "^") {
|
|
512
|
+
result += "\\^";
|
|
513
|
+
i += 1;
|
|
514
|
+
} else if (char === "$") {
|
|
515
|
+
result += "\\$";
|
|
516
|
+
i += 1;
|
|
517
|
+
} else if (char === "|") {
|
|
518
|
+
result += "\\|";
|
|
519
|
+
i += 1;
|
|
520
|
+
} else if (char === "\\") {
|
|
521
|
+
// Escape the next character
|
|
522
|
+
if (i + 1 < pattern.length) {
|
|
523
|
+
result += `\\${pattern[i + 1]}`;
|
|
524
|
+
i += 2;
|
|
525
|
+
} else {
|
|
526
|
+
result += "\\\\";
|
|
527
|
+
i += 1;
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
result += char;
|
|
531
|
+
i += 1;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Match the basename (filename portion) of a path against a pattern.
|
|
540
|
+
*/
|
|
541
|
+
function matchBasename(pattern: string, path: string): boolean {
|
|
542
|
+
const basename = extractBasename(path);
|
|
543
|
+
return basename === pattern;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Match the file extension of a path against a pattern.
|
|
548
|
+
* Pattern should be the extension without the leading dot (e.g., "md", "yaml").
|
|
549
|
+
*/
|
|
550
|
+
function matchExtension(pattern: string, path: string): boolean {
|
|
551
|
+
const basename = extractBasename(path);
|
|
552
|
+
const dotIndex = basename.lastIndexOf(".");
|
|
553
|
+
if (dotIndex === -1) return false;
|
|
554
|
+
const ext = basename.slice(dotIndex + 1);
|
|
555
|
+
return ext === pattern;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Search document content for a string marker.
|
|
560
|
+
* Respects maxParseBytes by only searching the first N bytes.
|
|
561
|
+
*/
|
|
562
|
+
function matchContentMarker(
|
|
563
|
+
pattern: string,
|
|
564
|
+
doc: SourceDocument,
|
|
565
|
+
maxParseBytes?: number,
|
|
566
|
+
): boolean {
|
|
567
|
+
const content = getTextContent(doc, maxParseBytes);
|
|
568
|
+
if (content === null) return false;
|
|
569
|
+
return content.includes(pattern);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Check if YAML frontmatter contains a specific key.
|
|
574
|
+
* Looks for `---` delimiters and checks for key presence within.
|
|
575
|
+
*/
|
|
576
|
+
function matchFrontmatterKey(
|
|
577
|
+
pattern: string,
|
|
578
|
+
doc: SourceDocument,
|
|
579
|
+
maxParseBytes?: number,
|
|
580
|
+
): boolean {
|
|
581
|
+
const content = getTextContent(doc, maxParseBytes);
|
|
582
|
+
if (content === null) return false;
|
|
583
|
+
|
|
584
|
+
// Find frontmatter between --- delimiters
|
|
585
|
+
if (!content.startsWith("---")) return false;
|
|
586
|
+
|
|
587
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
588
|
+
if (endIndex === -1) return false;
|
|
589
|
+
|
|
590
|
+
const frontmatter = content.slice(3, endIndex);
|
|
591
|
+
|
|
592
|
+
// Check for key presence at the start of a line (top-level key)
|
|
593
|
+
const lines = frontmatter.split("\n");
|
|
594
|
+
for (const line of lines) {
|
|
595
|
+
const trimmed = line.trimStart();
|
|
596
|
+
// Match key at start of line: "key:" or "key :"
|
|
597
|
+
if (
|
|
598
|
+
trimmed.startsWith(`${pattern}:`) ||
|
|
599
|
+
trimmed.startsWith(`${pattern} :`)
|
|
600
|
+
) {
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Check for a JSON pointer path in document content.
|
|
610
|
+
* The pattern is a JSON pointer (e.g., "/scripts/build").
|
|
611
|
+
*/
|
|
612
|
+
function matchJsonPointer(
|
|
613
|
+
pattern: string,
|
|
614
|
+
doc: SourceDocument,
|
|
615
|
+
maxParseBytes?: number,
|
|
616
|
+
): boolean {
|
|
617
|
+
const content = getTextContent(doc, maxParseBytes);
|
|
618
|
+
if (content === null) return false;
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
const parsed = JSON.parse(content);
|
|
622
|
+
return resolveJsonPointer(parsed, pattern) !== undefined;
|
|
623
|
+
} catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Resolve a JSON pointer against a parsed object.
|
|
630
|
+
* Returns undefined if the pointer doesn't resolve.
|
|
631
|
+
*/
|
|
632
|
+
function resolveJsonPointer(obj: unknown, pointer: string): unknown {
|
|
633
|
+
if (pointer === "" || pointer === "/") return obj;
|
|
634
|
+
|
|
635
|
+
// Remove leading /
|
|
636
|
+
const path = pointer.startsWith("/") ? pointer.slice(1) : pointer;
|
|
637
|
+
const segments = path.split("/").map((seg) =>
|
|
638
|
+
// Unescape JSON pointer encoding: ~1 → /, ~0 → ~
|
|
639
|
+
seg.replace(/~1/g, "/").replace(/~0/g, "~"),
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
let current: unknown = obj;
|
|
643
|
+
for (const segment of segments) {
|
|
644
|
+
if (current === null || current === undefined) return undefined;
|
|
645
|
+
if (typeof current !== "object") return undefined;
|
|
646
|
+
|
|
647
|
+
if (Array.isArray(current)) {
|
|
648
|
+
const index = Number(segment);
|
|
649
|
+
if (Number.isNaN(index)) return undefined;
|
|
650
|
+
current = current[index];
|
|
651
|
+
} else {
|
|
652
|
+
current = (current as Record<string, unknown>)[segment];
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return current;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Check for a top-level YAML key in document content.
|
|
661
|
+
* Simple check: looks for the key at the beginning of a line.
|
|
662
|
+
*/
|
|
663
|
+
function matchYamlKey(
|
|
664
|
+
pattern: string,
|
|
665
|
+
doc: SourceDocument,
|
|
666
|
+
maxParseBytes?: number,
|
|
667
|
+
): boolean {
|
|
668
|
+
const content = getTextContent(doc, maxParseBytes);
|
|
669
|
+
if (content === null) return false;
|
|
670
|
+
|
|
671
|
+
// Strip frontmatter if present (between --- delimiters)
|
|
672
|
+
let yamlContent = content;
|
|
673
|
+
if (content.startsWith("---")) {
|
|
674
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
675
|
+
if (endIndex !== -1) {
|
|
676
|
+
// Use the frontmatter section as YAML content
|
|
677
|
+
yamlContent = content.slice(3, endIndex);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Check for top-level key (not indented)
|
|
682
|
+
const lines = yamlContent.split("\n");
|
|
683
|
+
for (const line of lines) {
|
|
684
|
+
// Top-level key: starts at column 0 with "key:" or "key :"
|
|
685
|
+
if (line.startsWith(`${pattern}:`) || line.startsWith(`${pattern} :`)) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
694
|
+
// Content Helpers
|
|
695
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Get text content from a document, respecting maxParseBytes.
|
|
699
|
+
* Returns null for binary content.
|
|
700
|
+
*/
|
|
701
|
+
function getTextContent(
|
|
702
|
+
doc: SourceDocument,
|
|
703
|
+
maxParseBytes?: number,
|
|
704
|
+
): string | null {
|
|
705
|
+
if (typeof doc.content !== "string") {
|
|
706
|
+
// Binary content — not supported for text-based rules
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (maxParseBytes !== undefined && maxParseBytes > 0) {
|
|
711
|
+
return doc.content.slice(0, maxParseBytes);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return doc.content;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Extract the basename (filename) from a path.
|
|
719
|
+
*/
|
|
720
|
+
function extractBasename(path: string): string {
|
|
721
|
+
const lastSlash = path.lastIndexOf("/");
|
|
722
|
+
if (lastSlash === -1) return path;
|
|
723
|
+
return path.slice(lastSlash + 1);
|
|
724
|
+
}
|