@thinkingsage/kanon 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
@@ -0,0 +1,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
+ }