@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,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation Application Policy and Cross-Request Collision Analysis
|
|
3
|
+
*
|
|
4
|
+
* This module is the impure orchestration layer that evaluates caller policy
|
|
5
|
+
* against Rosetta Stone plan states and performs cross-request collision analysis
|
|
6
|
+
* before any write. It classifies plan eligibility, resolves diagnostic overrides,
|
|
7
|
+
* detects normalized path collisions across plans and against the filesystem,
|
|
8
|
+
* and resolves collision policy into actionable write/skip/block decisions.
|
|
9
|
+
*
|
|
10
|
+
* CONSTRAINTS:
|
|
11
|
+
* - This file IS impure — it uses node:fs/promises for stat checks
|
|
12
|
+
* - Uses codePointCompare from ./rosetta/contracts for deterministic ordering
|
|
13
|
+
* - Collision analysis is deterministic — same plans always produce same analysis
|
|
14
|
+
*
|
|
15
|
+
* Requirements: 4.8, 8.7, 9.1, 9.9, 13.5, 13.6
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { stat } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { codePointCompare } from "./rosetta/contracts";
|
|
21
|
+
import { normalizePlanPath } from "./rosetta/plan";
|
|
22
|
+
import type { TranslationPlan } from "./schemas";
|
|
23
|
+
import type { AllowedRoot } from "./translation-orchestrator";
|
|
24
|
+
|
|
25
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
// Types
|
|
27
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collision resolution strategy for output files that already exist.
|
|
31
|
+
*/
|
|
32
|
+
export type CollisionPolicy = "error" | "skip" | "replace" | "reconcile";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Application-level policy governing whether a plan may be applied.
|
|
36
|
+
*/
|
|
37
|
+
export interface ApplicationPolicy {
|
|
38
|
+
/** What to do when output files already exist */
|
|
39
|
+
readonly collisionPolicy: CollisionPolicy;
|
|
40
|
+
/** Diagnostic codes that can be overridden (allow application despite these codes) */
|
|
41
|
+
readonly allowedPolicyOverrideCodes: readonly string[];
|
|
42
|
+
/** If true, no policy overrides are allowed — any policy-required state blocks application */
|
|
43
|
+
readonly strict: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Result of evaluating application policy against a plan's state.
|
|
48
|
+
*/
|
|
49
|
+
export interface ApplicationDecision {
|
|
50
|
+
/** Whether the plan can be applied */
|
|
51
|
+
readonly proceed: boolean;
|
|
52
|
+
/** Human-readable explanation */
|
|
53
|
+
readonly reason: string;
|
|
54
|
+
/** Diagnostic codes that blocked application */
|
|
55
|
+
readonly blockingCodes: readonly string[];
|
|
56
|
+
/** Diagnostic codes that were overridden by policy */
|
|
57
|
+
readonly overriddenCodes: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A single collision detected during cross-request analysis.
|
|
62
|
+
*/
|
|
63
|
+
export interface CollisionEntry {
|
|
64
|
+
/** Normalized collision path */
|
|
65
|
+
readonly path: string;
|
|
66
|
+
/** Collision type */
|
|
67
|
+
readonly kind: "cross-plan" | "filesystem";
|
|
68
|
+
/** Which plan/file owns the existing path (for cross-plan: plan identifier) */
|
|
69
|
+
readonly existingSource?: string;
|
|
70
|
+
/** Which plan is trying to write there */
|
|
71
|
+
readonly newSource: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Result of cross-request collision analysis.
|
|
76
|
+
*/
|
|
77
|
+
export interface CollisionAnalysis {
|
|
78
|
+
/** Detected collisions */
|
|
79
|
+
readonly collisions: readonly CollisionEntry[];
|
|
80
|
+
/** True if any collision would block under the given policy */
|
|
81
|
+
readonly hasBlockingCollisions: boolean;
|
|
82
|
+
/** Count of cross-plan collisions */
|
|
83
|
+
readonly crossPlanCollisions: number;
|
|
84
|
+
/** Count of existing-file collisions */
|
|
85
|
+
readonly filesystemCollisions: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolution decision for an individual file after applying collision policy.
|
|
90
|
+
*/
|
|
91
|
+
export interface FileResolution {
|
|
92
|
+
/** Normalized relative path */
|
|
93
|
+
readonly path: string;
|
|
94
|
+
/** What to do with this file */
|
|
95
|
+
readonly action: "write" | "skip" | "block";
|
|
96
|
+
/** Reason for the decision */
|
|
97
|
+
readonly reason?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Result of applying collision policy to a set of collisions.
|
|
102
|
+
*/
|
|
103
|
+
export interface CollisionResolution {
|
|
104
|
+
/** Files that will be written */
|
|
105
|
+
readonly filesToWrite: readonly FileResolution[];
|
|
106
|
+
/** Files that will be skipped */
|
|
107
|
+
readonly filesToSkip: readonly FileResolution[];
|
|
108
|
+
/** Files that block the operation */
|
|
109
|
+
readonly blockedFiles: readonly FileResolution[];
|
|
110
|
+
/** Whether the overall operation can proceed */
|
|
111
|
+
readonly canProceed: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
115
|
+
// Application Policy Evaluation
|
|
116
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Evaluate whether a plan can be applied given the caller's application policy.
|
|
120
|
+
*
|
|
121
|
+
* - `eligible` plans can always proceed.
|
|
122
|
+
* - `policy-required` plans require all their policyDiagnosticCodes to be in
|
|
123
|
+
* the policy's allowedPolicyOverrideCodes list. Strict mode blocks all overrides.
|
|
124
|
+
* - `withheld` plans are always blocked.
|
|
125
|
+
*/
|
|
126
|
+
export function evaluateApplicationPolicy(
|
|
127
|
+
plan: TranslationPlan,
|
|
128
|
+
policy: ApplicationPolicy,
|
|
129
|
+
_diagnostics?: readonly unknown[],
|
|
130
|
+
): ApplicationDecision {
|
|
131
|
+
const { applicationState, policyDiagnosticCodes } = plan;
|
|
132
|
+
|
|
133
|
+
// Eligible plans can proceed unconditionally
|
|
134
|
+
if (applicationState === "eligible") {
|
|
135
|
+
return {
|
|
136
|
+
proceed: true,
|
|
137
|
+
reason: "Plan is eligible for application.",
|
|
138
|
+
blockingCodes: [],
|
|
139
|
+
overriddenCodes: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Withheld plans are always blocked
|
|
144
|
+
if (applicationState === "withheld") {
|
|
145
|
+
return {
|
|
146
|
+
proceed: false,
|
|
147
|
+
reason:
|
|
148
|
+
"Plan is withheld and cannot be applied regardless of policy configuration.",
|
|
149
|
+
blockingCodes: [...policyDiagnosticCodes].sort(codePointCompare),
|
|
150
|
+
overriddenCodes: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// policy-required: check each code against the allowlist
|
|
155
|
+
if (applicationState === "policy-required") {
|
|
156
|
+
// In strict mode, no overrides are allowed
|
|
157
|
+
if (policy.strict) {
|
|
158
|
+
return {
|
|
159
|
+
proceed: false,
|
|
160
|
+
reason:
|
|
161
|
+
"Strict mode is enabled; no policy overrides are allowed for policy-required plans.",
|
|
162
|
+
blockingCodes: [...policyDiagnosticCodes].sort(codePointCompare),
|
|
163
|
+
overriddenCodes: [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const allowedSet = new Set(policy.allowedPolicyOverrideCodes);
|
|
168
|
+
const blocking: string[] = [];
|
|
169
|
+
const overridden: string[] = [];
|
|
170
|
+
|
|
171
|
+
for (const code of policyDiagnosticCodes) {
|
|
172
|
+
if (allowedSet.has(code)) {
|
|
173
|
+
overridden.push(code);
|
|
174
|
+
} else {
|
|
175
|
+
blocking.push(code);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Sort for deterministic output
|
|
180
|
+
blocking.sort(codePointCompare);
|
|
181
|
+
overridden.sort(codePointCompare);
|
|
182
|
+
|
|
183
|
+
if (blocking.length > 0) {
|
|
184
|
+
return {
|
|
185
|
+
proceed: false,
|
|
186
|
+
reason: `Application blocked by ${blocking.length} unresolved diagnostic code(s): ${blocking.join(", ")}.`,
|
|
187
|
+
blockingCodes: blocking,
|
|
188
|
+
overriddenCodes: overridden,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
proceed: true,
|
|
194
|
+
reason: `All ${overridden.length} policy-required diagnostic code(s) overridden by policy.`,
|
|
195
|
+
blockingCodes: [],
|
|
196
|
+
overriddenCodes: overridden,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Unreachable if the schema is valid, but fail closed
|
|
201
|
+
return {
|
|
202
|
+
proceed: false,
|
|
203
|
+
reason: `Unknown application state: "${applicationState}".`,
|
|
204
|
+
blockingCodes: [...policyDiagnosticCodes].sort(codePointCompare),
|
|
205
|
+
overriddenCodes: [],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
210
|
+
// Cross-Request Collision Analysis
|
|
211
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Labeled plan for collision analysis. Associates a plan with a human-readable
|
|
215
|
+
* identifier for diagnostic reporting.
|
|
216
|
+
*/
|
|
217
|
+
export interface LabeledPlan {
|
|
218
|
+
/** Human-readable label identifying this plan (e.g., artifact name) */
|
|
219
|
+
readonly label: string;
|
|
220
|
+
/** The translation plan to analyze */
|
|
221
|
+
readonly plan: TranslationPlan;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Analyze collisions across multiple translation plans and against the filesystem.
|
|
226
|
+
*
|
|
227
|
+
* For each plan's output files, checks if the normalized path collides with:
|
|
228
|
+
* - Another plan's output files (cross-plan collision)
|
|
229
|
+
* - An existing file in the destination (filesystem collision — checked via stat)
|
|
230
|
+
*
|
|
231
|
+
* The analysis is deterministic: same plans always produce the same result
|
|
232
|
+
* (filesystem checks are ordered deterministically by path).
|
|
233
|
+
*/
|
|
234
|
+
export async function analyzeCollisions(
|
|
235
|
+
plans: readonly LabeledPlan[],
|
|
236
|
+
destinationRoot: AllowedRoot,
|
|
237
|
+
collisionPolicy: CollisionPolicy,
|
|
238
|
+
): Promise<CollisionAnalysis> {
|
|
239
|
+
const collisions: CollisionEntry[] = [];
|
|
240
|
+
|
|
241
|
+
// Build a map of normalized path → first plan that claims it
|
|
242
|
+
const pathOwnership = new Map<string, string>();
|
|
243
|
+
// Collect all unique normalized paths for filesystem checks
|
|
244
|
+
const allNormalizedPaths = new Map<string, string>(); // path → claiming plan label
|
|
245
|
+
|
|
246
|
+
// Sort plans deterministically by label for stable iteration order
|
|
247
|
+
const sortedPlans = [...plans].sort((a, b) =>
|
|
248
|
+
codePointCompare(a.label, b.label),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Phase 1: Cross-plan collision detection
|
|
252
|
+
for (const { label, plan } of sortedPlans) {
|
|
253
|
+
// Sort output files deterministically by path
|
|
254
|
+
const sortedFiles = [...plan.outputFiles].sort((a, b) =>
|
|
255
|
+
codePointCompare(a.relativePath, b.relativePath),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
for (const file of sortedFiles) {
|
|
259
|
+
const normResult = normalizePlanPath(file.relativePath);
|
|
260
|
+
const normalizedPath = normResult.ok
|
|
261
|
+
? normResult.normalized
|
|
262
|
+
: file.relativePath;
|
|
263
|
+
|
|
264
|
+
if (pathOwnership.has(normalizedPath)) {
|
|
265
|
+
// biome-ignore lint/style/noNonNullAssertion: guarded by .has() check above
|
|
266
|
+
const existingOwner = pathOwnership.get(normalizedPath)!;
|
|
267
|
+
collisions.push({
|
|
268
|
+
path: normalizedPath,
|
|
269
|
+
kind: "cross-plan",
|
|
270
|
+
existingSource: existingOwner,
|
|
271
|
+
newSource: label,
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
pathOwnership.set(normalizedPath, label);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Track for filesystem check
|
|
278
|
+
if (!allNormalizedPaths.has(normalizedPath)) {
|
|
279
|
+
allNormalizedPaths.set(normalizedPath, label);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Phase 2: Filesystem collision detection
|
|
285
|
+
// Sort paths deterministically for stable filesystem access ordering
|
|
286
|
+
const sortedPaths = [...allNormalizedPaths.entries()].sort(([a], [b]) =>
|
|
287
|
+
codePointCompare(a, b),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
for (const [normalizedPath, claimingLabel] of sortedPaths) {
|
|
291
|
+
const absolutePath = join(destinationRoot.resolvedPath, normalizedPath);
|
|
292
|
+
try {
|
|
293
|
+
await stat(absolutePath);
|
|
294
|
+
// File exists — this is a filesystem collision
|
|
295
|
+
collisions.push({
|
|
296
|
+
path: normalizedPath,
|
|
297
|
+
kind: "filesystem",
|
|
298
|
+
existingSource: absolutePath,
|
|
299
|
+
newSource: claimingLabel,
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
// File does not exist, no collision
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Sort collisions deterministically: by path, then by kind, then by newSource
|
|
307
|
+
collisions.sort((a, b) => {
|
|
308
|
+
const pathCmp = codePointCompare(a.path, b.path);
|
|
309
|
+
if (pathCmp !== 0) return pathCmp;
|
|
310
|
+
const kindCmp = codePointCompare(a.kind, b.kind);
|
|
311
|
+
if (kindCmp !== 0) return kindCmp;
|
|
312
|
+
return codePointCompare(a.newSource, b.newSource);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const crossPlanCollisions = collisions.filter(
|
|
316
|
+
(c) => c.kind === "cross-plan",
|
|
317
|
+
).length;
|
|
318
|
+
const filesystemCollisions = collisions.filter(
|
|
319
|
+
(c) => c.kind === "filesystem",
|
|
320
|
+
).length;
|
|
321
|
+
|
|
322
|
+
// Determine if there are blocking collisions based on policy
|
|
323
|
+
const hasBlockingCollisions = determineBlockingCollisions(
|
|
324
|
+
collisions,
|
|
325
|
+
collisionPolicy,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
collisions,
|
|
330
|
+
hasBlockingCollisions,
|
|
331
|
+
crossPlanCollisions,
|
|
332
|
+
filesystemCollisions,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Determine whether any collisions are blocking given the collision policy.
|
|
338
|
+
* This is a pure helper — no I/O.
|
|
339
|
+
*/
|
|
340
|
+
function determineBlockingCollisions(
|
|
341
|
+
collisions: readonly CollisionEntry[],
|
|
342
|
+
policy: CollisionPolicy,
|
|
343
|
+
): boolean {
|
|
344
|
+
if (collisions.length === 0) return false;
|
|
345
|
+
|
|
346
|
+
switch (policy) {
|
|
347
|
+
case "error":
|
|
348
|
+
// Any collision blocks all writes
|
|
349
|
+
return collisions.length > 0;
|
|
350
|
+
case "skip":
|
|
351
|
+
// Cross-plan collisions still block (ambiguous ownership)
|
|
352
|
+
return collisions.some((c) => c.kind === "cross-plan");
|
|
353
|
+
case "replace":
|
|
354
|
+
// Cross-plan collisions still block (ambiguous ownership)
|
|
355
|
+
return collisions.some((c) => c.kind === "cross-plan");
|
|
356
|
+
case "reconcile":
|
|
357
|
+
// Cross-plan collisions block; filesystem collisions are replaceable
|
|
358
|
+
return collisions.some((c) => c.kind === "cross-plan");
|
|
359
|
+
default:
|
|
360
|
+
// Fail closed for unknown policies
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
366
|
+
// Collision Policy Application
|
|
367
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Resolve collisions per policy, determining which files to write, skip, or block.
|
|
371
|
+
*
|
|
372
|
+
* - `"error"` — any collision blocks all writes
|
|
373
|
+
* - `"skip"` — skip files that already exist, proceed with non-colliding
|
|
374
|
+
* - `"replace"` — overwrite existing files
|
|
375
|
+
* - `"reconcile"` — treat as error for cross-plan, replace for filesystem
|
|
376
|
+
*
|
|
377
|
+
* Returns deterministic results regardless of input ordering.
|
|
378
|
+
*/
|
|
379
|
+
export function applyCollisionPolicy(
|
|
380
|
+
collisions: readonly CollisionEntry[],
|
|
381
|
+
policy: CollisionPolicy,
|
|
382
|
+
allOutputPaths: readonly string[],
|
|
383
|
+
): CollisionResolution {
|
|
384
|
+
// Build sets for quick lookup
|
|
385
|
+
const collidingPaths = new Set(collisions.map((c) => c.path));
|
|
386
|
+
const crossPlanPaths = new Set(
|
|
387
|
+
collisions.filter((c) => c.kind === "cross-plan").map((c) => c.path),
|
|
388
|
+
);
|
|
389
|
+
const filesystemPaths = new Set(
|
|
390
|
+
collisions.filter((c) => c.kind === "filesystem").map((c) => c.path),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const filesToWrite: FileResolution[] = [];
|
|
394
|
+
const filesToSkip: FileResolution[] = [];
|
|
395
|
+
const blockedFiles: FileResolution[] = [];
|
|
396
|
+
|
|
397
|
+
// Sort all output paths deterministically
|
|
398
|
+
const sortedPaths = [...allOutputPaths].sort(codePointCompare);
|
|
399
|
+
|
|
400
|
+
for (const path of sortedPaths) {
|
|
401
|
+
if (!collidingPaths.has(path)) {
|
|
402
|
+
// No collision — always write
|
|
403
|
+
filesToWrite.push({ path, action: "write" });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const isCrossPlan = crossPlanPaths.has(path);
|
|
408
|
+
const isFilesystem = filesystemPaths.has(path);
|
|
409
|
+
|
|
410
|
+
switch (policy) {
|
|
411
|
+
case "error":
|
|
412
|
+
// Any collision blocks
|
|
413
|
+
blockedFiles.push({
|
|
414
|
+
path,
|
|
415
|
+
action: "block",
|
|
416
|
+
reason: isCrossPlan
|
|
417
|
+
? "Cross-plan collision under error policy"
|
|
418
|
+
: "Filesystem collision under error policy",
|
|
419
|
+
});
|
|
420
|
+
break;
|
|
421
|
+
|
|
422
|
+
case "skip":
|
|
423
|
+
if (isCrossPlan) {
|
|
424
|
+
// Cross-plan collisions always block (ambiguous ownership)
|
|
425
|
+
blockedFiles.push({
|
|
426
|
+
path,
|
|
427
|
+
action: "block",
|
|
428
|
+
reason: "Cross-plan collision cannot be resolved by skip policy",
|
|
429
|
+
});
|
|
430
|
+
} else if (isFilesystem) {
|
|
431
|
+
// Skip existing files
|
|
432
|
+
filesToSkip.push({
|
|
433
|
+
path,
|
|
434
|
+
action: "skip",
|
|
435
|
+
reason: "File already exists; skipped per skip policy",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
|
|
440
|
+
case "replace":
|
|
441
|
+
if (isCrossPlan) {
|
|
442
|
+
// Cross-plan collisions always block (ambiguous ownership)
|
|
443
|
+
blockedFiles.push({
|
|
444
|
+
path,
|
|
445
|
+
action: "block",
|
|
446
|
+
reason: "Cross-plan collision cannot be resolved by replace policy",
|
|
447
|
+
});
|
|
448
|
+
} else if (isFilesystem) {
|
|
449
|
+
// Replace existing files
|
|
450
|
+
filesToWrite.push({
|
|
451
|
+
path,
|
|
452
|
+
action: "write",
|
|
453
|
+
reason: "Overwriting existing file per replace policy",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
|
|
458
|
+
case "reconcile":
|
|
459
|
+
if (isCrossPlan) {
|
|
460
|
+
// Cross-plan: treat as error
|
|
461
|
+
blockedFiles.push({
|
|
462
|
+
path,
|
|
463
|
+
action: "block",
|
|
464
|
+
reason:
|
|
465
|
+
"Cross-plan collision cannot be reconciled; blocking as error",
|
|
466
|
+
});
|
|
467
|
+
} else if (isFilesystem) {
|
|
468
|
+
// Filesystem: treat as replace
|
|
469
|
+
filesToWrite.push({
|
|
470
|
+
path,
|
|
471
|
+
action: "write",
|
|
472
|
+
reason: "Overwriting existing file per reconcile policy",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
|
|
477
|
+
default:
|
|
478
|
+
// Fail closed for unknown policies
|
|
479
|
+
blockedFiles.push({
|
|
480
|
+
path,
|
|
481
|
+
action: "block",
|
|
482
|
+
reason: `Unknown collision policy: "${policy}"`,
|
|
483
|
+
});
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const canProceed = blockedFiles.length === 0;
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
filesToWrite,
|
|
492
|
+
filesToSkip,
|
|
493
|
+
blockedFiles,
|
|
494
|
+
canProceed,
|
|
495
|
+
};
|
|
496
|
+
}
|