@prover-coder-ai/context-doc 1.0.12 → 1.0.13
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/.jscpd.json +16 -0
- package/CHANGELOG.md +13 -0
- package/bin/context-doc.js +12 -0
- package/biome.json +37 -0
- package/eslint.config.mts +305 -0
- package/eslint.effect-ts-check.config.mjs +220 -0
- package/linter.config.json +33 -0
- package/package.json +72 -41
- package/src/app/main.ts +29 -0
- package/src/app/program.ts +32 -0
- package/src/core/knowledge.ts +93 -117
- package/src/shell/cli.ts +73 -5
- package/src/shell/services/crypto.ts +50 -0
- package/src/shell/services/file-system.ts +142 -0
- package/src/shell/services/runtime-env.ts +54 -0
- package/src/shell/sync/index.ts +35 -0
- package/src/shell/sync/shared.ts +229 -0
- package/src/shell/sync/sources/claude.ts +54 -0
- package/src/shell/sync/sources/codex.ts +261 -0
- package/src/shell/sync/sources/qwen.ts +97 -0
- package/src/shell/sync/types.ts +47 -0
- package/tests/core/knowledge.test.ts +95 -0
- package/tests/shell/cli-pack.test.ts +176 -0
- package/tests/shell/sync-knowledge.test.ts +203 -0
- package/tests/support/fs-helpers.ts +50 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +32 -0
- package/vitest.config.ts +70 -66
- package/README.md +0 -42
- package/dist/core/greeting.js +0 -25
- package/dist/core/knowledge.js +0 -49
- package/dist/main.js +0 -27
- package/dist/shell/claudeSync.js +0 -23
- package/dist/shell/cli.js +0 -5
- package/dist/shell/codexSync.js +0 -129
- package/dist/shell/qwenSync.js +0 -41
- package/dist/shell/syncKnowledge.js +0 -39
- package/dist/shell/syncShared.js +0 -49
- package/dist/shell/syncTypes.js +0 -1
- package/src/core/greeting.ts +0 -39
- package/src/main.ts +0 -49
- package/src/shell/claudeSync.ts +0 -55
- package/src/shell/codexSync.ts +0 -236
- package/src/shell/qwenSync.ts +0 -73
- package/src/shell/syncKnowledge.ts +0 -49
- package/src/shell/syncShared.ts +0 -94
- package/src/shell/syncTypes.ts +0 -30
- package/src/types/env.d.ts +0 -10
- package/tsconfig.build.json +0 -18
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import * as Path from "@effect/platform/Path"
|
|
2
|
+
import * as Schema from "@effect/schema/Schema"
|
|
3
|
+
import { Console, Effect, Option, pipe } from "effect"
|
|
4
|
+
|
|
5
|
+
import type { JsonValue, ProjectLocator } from "../../../core/knowledge.js"
|
|
6
|
+
import { buildProjectLocator, valueMatchesProject } from "../../../core/knowledge.js"
|
|
7
|
+
import { FileSystemService } from "../../services/file-system.js"
|
|
8
|
+
import { RuntimeEnv } from "../../services/runtime-env.js"
|
|
9
|
+
import {
|
|
10
|
+
collectFiles,
|
|
11
|
+
copyFilePreservingRelativePath,
|
|
12
|
+
ensureDestination,
|
|
13
|
+
findFirstMatching,
|
|
14
|
+
resolveProjectRoot
|
|
15
|
+
} from "../shared.js"
|
|
16
|
+
import { type SyncError, syncError, type SyncOptions } from "../types.js"
|
|
17
|
+
|
|
18
|
+
type CodexEnv = RuntimeEnv | FileSystemService | Path.Path
|
|
19
|
+
type CodexFsEnv = FileSystemService | Path.Path
|
|
20
|
+
|
|
21
|
+
const some = Option.some
|
|
22
|
+
const forEach = Effect.forEach
|
|
23
|
+
|
|
24
|
+
const JsonValueSchema: Schema.Schema<JsonValue> = Schema.suspend(() =>
|
|
25
|
+
Schema.Union(
|
|
26
|
+
Schema.String,
|
|
27
|
+
Schema.Number,
|
|
28
|
+
Schema.Boolean,
|
|
29
|
+
Schema.Null,
|
|
30
|
+
Schema.Array(JsonValueSchema),
|
|
31
|
+
Schema.Record({ key: Schema.String, value: JsonValueSchema })
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const parseJsonLine = (
|
|
36
|
+
line: string
|
|
37
|
+
): Effect.Effect<Option.Option<JsonValue>> =>
|
|
38
|
+
pipe(
|
|
39
|
+
Schema.decode(Schema.parseJson(JsonValueSchema))(line),
|
|
40
|
+
Effect.match({
|
|
41
|
+
onFailure: () => Option.none(),
|
|
42
|
+
onSuccess: (value) => some(value)
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const resolveEnvValue = (envValue: Option.Option<string>): string | undefined => Option.getOrUndefined(envValue)
|
|
47
|
+
|
|
48
|
+
const buildLocator = (path: Path.Path, projectRoot: string): ProjectLocator => {
|
|
49
|
+
const normalizedRoot = path.resolve(projectRoot)
|
|
50
|
+
const isWithinRoot = (candidate: string): boolean => {
|
|
51
|
+
const normalizedCandidate = path.resolve(candidate)
|
|
52
|
+
const relative = path.relative(normalizedRoot, normalizedCandidate)
|
|
53
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
54
|
+
}
|
|
55
|
+
return buildProjectLocator(normalizedRoot, isWithinRoot)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const containsJsonl = (root: string): Effect.Effect<boolean, SyncError, FileSystemService> =>
|
|
59
|
+
Effect.gen(function*(_) {
|
|
60
|
+
const fs = yield* _(FileSystemService)
|
|
61
|
+
const entries = yield* _(fs.readDirectory(root))
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.kind === "file" && entry.path.endsWith(".jsonl")) {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
if (entry.kind === "directory") {
|
|
67
|
+
const found = yield* _(containsJsonl(entry.path))
|
|
68
|
+
if (found) {
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const hasJsonlInCandidate = (
|
|
77
|
+
candidate: string
|
|
78
|
+
): Effect.Effect<boolean, SyncError, FileSystemService> =>
|
|
79
|
+
Effect.gen(function*(_) {
|
|
80
|
+
const fs = yield* _(FileSystemService)
|
|
81
|
+
const exists = yield* _(fs.exists(candidate))
|
|
82
|
+
if (!exists) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
return yield* _(containsJsonl(candidate))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const findFirstExistingWithJsonl = (
|
|
89
|
+
candidates: ReadonlyArray<string>
|
|
90
|
+
): Effect.Effect<string | undefined, SyncError, FileSystemService> => findFirstMatching(candidates, hasJsonlInCandidate)
|
|
91
|
+
|
|
92
|
+
const resolveSourceDir = (
|
|
93
|
+
options: SyncOptions
|
|
94
|
+
): Effect.Effect<string, SyncError, CodexEnv> =>
|
|
95
|
+
Effect.gen(function*(_) {
|
|
96
|
+
const env = yield* _(RuntimeEnv)
|
|
97
|
+
const path = yield* _(Path.Path)
|
|
98
|
+
const envSource = resolveEnvValue(yield* _(env.envVar("CODEX_SOURCE_DIR")))
|
|
99
|
+
const homeDir = yield* _(env.homedir)
|
|
100
|
+
const projectRoot = resolveProjectRoot(path, options)
|
|
101
|
+
let metaCandidate: string | undefined
|
|
102
|
+
if (options.metaRoot !== undefined) {
|
|
103
|
+
metaCandidate = options.metaRoot.endsWith(".codex")
|
|
104
|
+
? options.metaRoot
|
|
105
|
+
: path.join(options.metaRoot, ".codex")
|
|
106
|
+
}
|
|
107
|
+
const localSource = path.join(projectRoot, ".codex")
|
|
108
|
+
const localKnowledge = path.join(projectRoot, ".knowledge", ".codex")
|
|
109
|
+
const homeSource = path.join(homeDir, ".codex")
|
|
110
|
+
const homeKnowledge = path.join(homeDir, ".knowledge", ".codex")
|
|
111
|
+
|
|
112
|
+
const candidates = [
|
|
113
|
+
options.sourceDir,
|
|
114
|
+
envSource,
|
|
115
|
+
metaCandidate,
|
|
116
|
+
localSource,
|
|
117
|
+
homeSource,
|
|
118
|
+
localKnowledge,
|
|
119
|
+
homeKnowledge
|
|
120
|
+
].filter((candidate): candidate is string => candidate !== undefined)
|
|
121
|
+
|
|
122
|
+
const existing = yield* _(findFirstExistingWithJsonl(candidates))
|
|
123
|
+
if (existing === undefined) {
|
|
124
|
+
return yield* _(
|
|
125
|
+
Effect.fail(
|
|
126
|
+
syncError(
|
|
127
|
+
".codex",
|
|
128
|
+
`No .jsonl files found in .codex candidates; checked: ${candidates.join(", ")}`
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return existing
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const resolveLocator = (
|
|
138
|
+
options: SyncOptions
|
|
139
|
+
): Effect.Effect<ProjectLocator, never, Path.Path> =>
|
|
140
|
+
Effect.gen(function*(_) {
|
|
141
|
+
const path = yield* _(Path.Path)
|
|
142
|
+
const projectRoot = resolveProjectRoot(path, options)
|
|
143
|
+
return buildLocator(path, projectRoot)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const lineMatchesProject = (
|
|
147
|
+
line: string,
|
|
148
|
+
locator: ProjectLocator
|
|
149
|
+
): Effect.Effect<boolean> =>
|
|
150
|
+
pipe(
|
|
151
|
+
parseJsonLine(line),
|
|
152
|
+
Effect.map((parsed) => Option.exists(parsed, (value) => valueMatchesProject(value, locator)))
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const fileMatchesProject = (
|
|
156
|
+
filePath: string,
|
|
157
|
+
locator: ProjectLocator
|
|
158
|
+
): Effect.Effect<boolean, SyncError, FileSystemService> =>
|
|
159
|
+
Effect.gen(function*(_) {
|
|
160
|
+
const fs = yield* _(FileSystemService)
|
|
161
|
+
const content = yield* _(fs.readFileString(filePath))
|
|
162
|
+
const lines = content.split("\n")
|
|
163
|
+
const matches = yield* _(
|
|
164
|
+
forEach(lines, (line) => {
|
|
165
|
+
const trimmed = line.trim()
|
|
166
|
+
return trimmed.length === 0
|
|
167
|
+
? Effect.succeed(false)
|
|
168
|
+
: lineMatchesProject(trimmed, locator)
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
return matches.some(Boolean)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const selectRelevantFiles = (
|
|
175
|
+
files: ReadonlyArray<string>,
|
|
176
|
+
locator: ProjectLocator
|
|
177
|
+
): Effect.Effect<ReadonlyArray<string>, SyncError, FileSystemService> =>
|
|
178
|
+
pipe(
|
|
179
|
+
forEach(files, (filePath) =>
|
|
180
|
+
pipe(
|
|
181
|
+
fileMatchesProject(filePath, locator),
|
|
182
|
+
Effect.map((matches) => ({ filePath, matches }))
|
|
183
|
+
)),
|
|
184
|
+
Effect.map((results) =>
|
|
185
|
+
results
|
|
186
|
+
.filter((result) => result.matches)
|
|
187
|
+
.map((result) => result.filePath)
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const copyCodexFiles = (
|
|
192
|
+
sourceDir: string,
|
|
193
|
+
destinationDir: string,
|
|
194
|
+
locator: ProjectLocator
|
|
195
|
+
): Effect.Effect<void, SyncError, CodexFsEnv> =>
|
|
196
|
+
Effect.gen(function*(_) {
|
|
197
|
+
yield* _(ensureDestination(destinationDir))
|
|
198
|
+
const allJsonlFiles = yield* _(
|
|
199
|
+
collectFiles(sourceDir, (entry) => entry.kind === "file" && entry.path.endsWith(".jsonl"))
|
|
200
|
+
)
|
|
201
|
+
const relevantFiles = yield* _(selectRelevantFiles(allJsonlFiles, locator))
|
|
202
|
+
yield* _(
|
|
203
|
+
forEach(relevantFiles, (filePath) => copyFilePreservingRelativePath(sourceDir, destinationDir, filePath))
|
|
204
|
+
)
|
|
205
|
+
yield* _(
|
|
206
|
+
Console.log(
|
|
207
|
+
`Codex: copied ${relevantFiles.length} files from ${sourceDir} to ${destinationDir}`
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// CHANGE: extract Codex dialog sync into dedicated module
|
|
213
|
+
// WHY: keep Codex-specific shell effects isolated from other sources
|
|
214
|
+
// QUOTE(TZ): "FUNCTIONAL CORE, IMPERATIVE SHELL"
|
|
215
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
216
|
+
// SOURCE: n/a
|
|
217
|
+
// FORMAT THEOREM: forall f: relevant(f, locator) -> copied(f)
|
|
218
|
+
// PURITY: SHELL
|
|
219
|
+
// EFFECT: Effect<void, never, FileSystemService | RuntimeEnv | Path>
|
|
220
|
+
// INVARIANT: copied files contain at least one project-matching line by projectRoot
|
|
221
|
+
// COMPLEXITY: O(n)/O(n)
|
|
222
|
+
export const syncCodex = (
|
|
223
|
+
options: SyncOptions
|
|
224
|
+
): Effect.Effect<void, never, CodexEnv> =>
|
|
225
|
+
Effect.gen(function*(_) {
|
|
226
|
+
const locator = yield* _(resolveLocator(options))
|
|
227
|
+
const sourceDir = yield* _(resolveSourceDir(options))
|
|
228
|
+
const path = yield* _(Path.Path)
|
|
229
|
+
const destinationDir = options.destinationDir ??
|
|
230
|
+
path.join(resolveProjectRoot(path, options), ".knowledge", ".codex")
|
|
231
|
+
|
|
232
|
+
if (path.resolve(sourceDir) === path.resolve(destinationDir)) {
|
|
233
|
+
yield* _(
|
|
234
|
+
Console.log(
|
|
235
|
+
"Codex source equals destination; skipping copy to avoid duplicates"
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
yield* _(copyCodexFiles(sourceDir, destinationDir, locator))
|
|
242
|
+
}).pipe(
|
|
243
|
+
Effect.matchEffect({
|
|
244
|
+
onFailure: (error: SyncError) =>
|
|
245
|
+
Console.log(
|
|
246
|
+
`Codex source not found; skipped syncing Codex dialog files (${error.reason})`
|
|
247
|
+
),
|
|
248
|
+
onSuccess: () => Effect.void
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
// CHANGE: expose DirectoryEntry type for Codex traversal helpers
|
|
253
|
+
// WHY: reuse typed filesystem entries without leaking Node types
|
|
254
|
+
// QUOTE(TZ): "CORE never calls SHELL"
|
|
255
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
256
|
+
// SOURCE: n/a
|
|
257
|
+
// FORMAT THEOREM: forall e: DirectoryEntry -> shellOnly(e)
|
|
258
|
+
// PURITY: SHELL
|
|
259
|
+
// EFFECT: n/a
|
|
260
|
+
// INVARIANT: entry.kind is one of file|directory|other
|
|
261
|
+
// COMPLEXITY: O(1)/O(1)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as Path from "@effect/platform/Path"
|
|
2
|
+
import { Effect, Option, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
import { CryptoService } from "../../services/crypto.js"
|
|
5
|
+
import { FileSystemService } from "../../services/file-system.js"
|
|
6
|
+
import { RuntimeEnv } from "../../services/runtime-env.js"
|
|
7
|
+
import { createFilteredSource, findFirstMatching, resolveProjectRoot, runSyncSource } from "../shared.js"
|
|
8
|
+
import { type SyncError, syncError, type SyncOptions, type SyncSource } from "../types.js"
|
|
9
|
+
|
|
10
|
+
type QwenEnv = RuntimeEnv | CryptoService | FileSystemService | Path.Path
|
|
11
|
+
|
|
12
|
+
const resolveEnvValue = (envValue: Option.Option<string>): string | undefined => Option.getOrUndefined(envValue)
|
|
13
|
+
|
|
14
|
+
const findFirstExisting = (
|
|
15
|
+
candidates: ReadonlyArray<string>
|
|
16
|
+
): Effect.Effect<string | undefined, SyncError, FileSystemService> =>
|
|
17
|
+
findFirstMatching(candidates, (candidate) =>
|
|
18
|
+
Effect.gen(function*(_) {
|
|
19
|
+
const fs = yield* _(FileSystemService)
|
|
20
|
+
return yield* _(fs.exists(candidate))
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const resolveQwenSourceDir = (
|
|
24
|
+
options: SyncOptions
|
|
25
|
+
): Effect.Effect<string, SyncError, QwenEnv> =>
|
|
26
|
+
Effect.gen(function*(_) {
|
|
27
|
+
const env = yield* _(RuntimeEnv)
|
|
28
|
+
const crypto = yield* _(CryptoService)
|
|
29
|
+
const path = yield* _(Path.Path)
|
|
30
|
+
const projectRoot = resolveProjectRoot(path, options)
|
|
31
|
+
const hash = yield* _(
|
|
32
|
+
pipe(
|
|
33
|
+
crypto.sha256(projectRoot),
|
|
34
|
+
Effect.mapError((error) => syncError(".qwen", error.reason))
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
const envSource = resolveEnvValue(yield* _(env.envVar("QWEN_SOURCE_DIR")))
|
|
38
|
+
const homeDir = yield* _(env.homedir)
|
|
39
|
+
let baseFromMeta: string | undefined
|
|
40
|
+
if (options.metaRoot !== undefined) {
|
|
41
|
+
baseFromMeta = options.metaRoot.endsWith(".qwen")
|
|
42
|
+
? options.metaRoot
|
|
43
|
+
: path.join(options.metaRoot, ".qwen")
|
|
44
|
+
}
|
|
45
|
+
const metaKnowledge = options.metaRoot === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: path.join(options.metaRoot, ".knowledge", ".qwen")
|
|
48
|
+
const homeBase = path.join(homeDir, ".qwen")
|
|
49
|
+
const homeKnowledge = path.join(homeDir, ".knowledge", ".qwen")
|
|
50
|
+
|
|
51
|
+
const candidates = [
|
|
52
|
+
options.qwenSourceDir,
|
|
53
|
+
envSource,
|
|
54
|
+
baseFromMeta ? path.join(baseFromMeta, "tmp", hash) : undefined,
|
|
55
|
+
path.join(projectRoot, ".qwen", "tmp", hash),
|
|
56
|
+
path.join(projectRoot, ".knowledge", ".qwen", "tmp", hash),
|
|
57
|
+
metaKnowledge ? path.join(metaKnowledge, "tmp", hash) : undefined,
|
|
58
|
+
path.join(homeBase, "tmp", hash),
|
|
59
|
+
path.join(homeKnowledge, "tmp", hash)
|
|
60
|
+
].filter((candidate): candidate is string => candidate !== undefined)
|
|
61
|
+
|
|
62
|
+
const found = yield* _(findFirstExisting(candidates))
|
|
63
|
+
if (found === undefined) {
|
|
64
|
+
return yield* _(
|
|
65
|
+
Effect.fail(
|
|
66
|
+
syncError(
|
|
67
|
+
".qwen",
|
|
68
|
+
`Qwen source directory is missing for hash ${hash}`
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return found
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const qwenSource: SyncSource<QwenEnv> = createFilteredSource({
|
|
78
|
+
name: "Qwen",
|
|
79
|
+
destSubdir: ".qwen",
|
|
80
|
+
resolveSource: resolveQwenSourceDir,
|
|
81
|
+
filter: (entry, fullPath) => entry.kind === "file" && fullPath.endsWith(".json"),
|
|
82
|
+
errorReason: "Cannot traverse Qwen directory"
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// CHANGE: sync Qwen dialog files through shared sync runner
|
|
86
|
+
// WHY: keep source-specific resolution isolated and reuse copy/traversal logic
|
|
87
|
+
// QUOTE(TZ): "SHELL: Все эффекты (IO, сеть, БД, env/process) изолированы"
|
|
88
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
89
|
+
// SOURCE: n/a
|
|
90
|
+
// FORMAT THEOREM: forall f: json(f) -> copied(f)
|
|
91
|
+
// PURITY: SHELL
|
|
92
|
+
// EFFECT: Effect<void, never, FileSystemService | RuntimeEnv | CryptoService | Path>
|
|
93
|
+
// INVARIANT: only .json files are copied
|
|
94
|
+
// COMPLEXITY: O(n)/O(n)
|
|
95
|
+
export const syncQwen = (
|
|
96
|
+
options: SyncOptions
|
|
97
|
+
): Effect.Effect<void, never, QwenEnv> => runSyncSource(qwenSource, options)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type * as Fx from "effect"
|
|
2
|
+
|
|
3
|
+
export interface SyncError {
|
|
4
|
+
readonly _tag: "SyncError"
|
|
5
|
+
readonly path: string
|
|
6
|
+
readonly reason: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SyncOptions {
|
|
10
|
+
readonly cwd: string
|
|
11
|
+
readonly projectRoot?: string
|
|
12
|
+
readonly sourceDir?: string
|
|
13
|
+
readonly destinationDir?: string
|
|
14
|
+
readonly repositoryUrlOverride?: string
|
|
15
|
+
readonly metaRoot?: string
|
|
16
|
+
readonly qwenSourceDir?: string
|
|
17
|
+
readonly claudeProjectsRoot?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SyncEffect<A, R = never> = Fx.Effect.Effect<A, SyncError, R>
|
|
21
|
+
|
|
22
|
+
export interface SyncSource<R> {
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly destSubdir: ".codex" | ".qwen" | ".claude"
|
|
25
|
+
readonly resolveSource: (options: SyncOptions) => SyncEffect<string, R>
|
|
26
|
+
readonly copy: (
|
|
27
|
+
sourceDir: string,
|
|
28
|
+
destinationDir: string,
|
|
29
|
+
options: SyncOptions
|
|
30
|
+
) => SyncEffect<number, R>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// CHANGE: centralize sync-specific types to keep shell modules consistent
|
|
34
|
+
// WHY: shared types and error model simplify composition and testing
|
|
35
|
+
// QUOTE(TZ): "Ошибки: типизированы в сигнатурах функций"
|
|
36
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
37
|
+
// SOURCE: n/a
|
|
38
|
+
// FORMAT THEOREM: forall e: SyncError -> typed(e)
|
|
39
|
+
// PURITY: SHELL
|
|
40
|
+
// EFFECT: n/a
|
|
41
|
+
// INVARIANT: SyncError contains path and reason for logging
|
|
42
|
+
// COMPLEXITY: O(1)/O(1)
|
|
43
|
+
export const syncError = (pathValue: string, reason: string): SyncError => ({
|
|
44
|
+
_tag: "SyncError",
|
|
45
|
+
path: pathValue,
|
|
46
|
+
reason
|
|
47
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as Path from "@effect/platform/Path"
|
|
2
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import fc from "fast-check"
|
|
5
|
+
|
|
6
|
+
import { buildProjectLocator, type JsonValue, valueMatchesProject } from "../../src/core/knowledge.js"
|
|
7
|
+
|
|
8
|
+
const buildLocator = (root: string) =>
|
|
9
|
+
buildProjectLocator(
|
|
10
|
+
root,
|
|
11
|
+
(candidate) => candidate === root || candidate.startsWith(`${root}/`)
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
describe("valueMatchesProject", () => {
|
|
15
|
+
const safeChar: fc.Arbitrary<string> = fc.constantFrom(
|
|
16
|
+
"a",
|
|
17
|
+
"b",
|
|
18
|
+
"c",
|
|
19
|
+
"d",
|
|
20
|
+
"e",
|
|
21
|
+
"f",
|
|
22
|
+
"0",
|
|
23
|
+
"1",
|
|
24
|
+
"2",
|
|
25
|
+
"3"
|
|
26
|
+
)
|
|
27
|
+
const segment: fc.Arbitrary<string> = fc.string({
|
|
28
|
+
minLength: 1,
|
|
29
|
+
maxLength: 8,
|
|
30
|
+
unit: safeChar
|
|
31
|
+
})
|
|
32
|
+
const nonRecordValue: fc.Arbitrary<JsonValue> = fc.oneof(
|
|
33
|
+
fc.string(),
|
|
34
|
+
fc.integer(),
|
|
35
|
+
fc.boolean(),
|
|
36
|
+
fc.constant(null),
|
|
37
|
+
fc.array(fc.string(), { maxLength: 5 }),
|
|
38
|
+
fc.array(fc.integer(), { maxLength: 5 }),
|
|
39
|
+
fc.array(fc.boolean(), { maxLength: 5 }),
|
|
40
|
+
fc.array(fc.constant(null), { maxLength: 5 })
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const projectRoot = Effect.gen(function*(_) {
|
|
44
|
+
const path = yield* _(Path.Path)
|
|
45
|
+
const testFile = yield* _(path.fromFileUrl(new URL(import.meta.url)))
|
|
46
|
+
return path.resolve(path.dirname(testFile), "../../../..")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.effect("matches by cwd inside project root", () =>
|
|
50
|
+
Effect.gen(function*(_) {
|
|
51
|
+
const root = yield* _(projectRoot)
|
|
52
|
+
const locator = buildLocator(root)
|
|
53
|
+
const insidePath: fc.Arbitrary<string> = fc
|
|
54
|
+
.array(segment, { minLength: 0, maxLength: 4 })
|
|
55
|
+
.map((segments) => segments.length === 0 ? root : `${root}/${segments.join("/")}`)
|
|
56
|
+
fc.assert(
|
|
57
|
+
fc.property(insidePath, (cwd: string) => {
|
|
58
|
+
const value: JsonValue = { cwd }
|
|
59
|
+
expect(valueMatchesProject(value, locator)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
}).pipe(Effect.provide(Path.layer)))
|
|
63
|
+
|
|
64
|
+
it.effect("rejects unrelated records", () =>
|
|
65
|
+
Effect.gen(function*(_) {
|
|
66
|
+
const root = yield* _(projectRoot)
|
|
67
|
+
const path = yield* _(Path.Path)
|
|
68
|
+
const locator = buildLocator(root)
|
|
69
|
+
const outsideBase = path.join(path.dirname(root), "outside")
|
|
70
|
+
const outsidePath: fc.Arbitrary<string> = fc
|
|
71
|
+
.array(segment, { minLength: 0, maxLength: 4 })
|
|
72
|
+
.map((segments) =>
|
|
73
|
+
segments.length === 0
|
|
74
|
+
? outsideBase
|
|
75
|
+
: `${outsideBase}/${segments.join("/")}`
|
|
76
|
+
)
|
|
77
|
+
fc.assert(
|
|
78
|
+
fc.property(outsidePath, (cwd: string) => {
|
|
79
|
+
const value: JsonValue = { cwd }
|
|
80
|
+
expect(valueMatchesProject(value, locator)).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
)
|
|
83
|
+
}).pipe(Effect.provide(Path.layer)))
|
|
84
|
+
|
|
85
|
+
it.effect("rejects non-record values", () =>
|
|
86
|
+
Effect.gen(function*(_) {
|
|
87
|
+
const root = yield* _(projectRoot)
|
|
88
|
+
const locator = buildLocator(root)
|
|
89
|
+
fc.assert(
|
|
90
|
+
fc.property(nonRecordValue, (value: JsonValue) => {
|
|
91
|
+
expect(valueMatchesProject(value, locator)).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
}).pipe(Effect.provide(Path.layer)))
|
|
95
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { NodeContext } from "@effect/platform-node"
|
|
2
|
+
import * as Command from "@effect/platform/Command"
|
|
3
|
+
import * as Path from "@effect/platform/Path"
|
|
4
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
5
|
+
import { Effect, Layer, pipe } from "effect"
|
|
6
|
+
|
|
7
|
+
import { buildTestPaths, makeTempDir, withFsPath, writeFile } from "../support/fs-helpers.js"
|
|
8
|
+
|
|
9
|
+
const testPaths = buildTestPaths(
|
|
10
|
+
new URL(import.meta.url),
|
|
11
|
+
"context-doc-cli-tests",
|
|
12
|
+
"context-doc-pack-tests"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const runCommand = (command: Command.Command) =>
|
|
16
|
+
pipe(
|
|
17
|
+
Command.exitCode(command),
|
|
18
|
+
Effect.flatMap((exitCode) =>
|
|
19
|
+
Number(exitCode) === 0
|
|
20
|
+
? Effect.void
|
|
21
|
+
: Effect.fail(new Error(`Command failed with exit code ${exitCode}`))
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const runCommandOutput = (command: Command.Command) => Command.string(command)
|
|
26
|
+
|
|
27
|
+
const packCli = Effect.gen(function*(_) {
|
|
28
|
+
const { packBase, projectRoot } = yield* _(testPaths)
|
|
29
|
+
const path = yield* _(Path.Path)
|
|
30
|
+
const packDir = yield* _(
|
|
31
|
+
makeTempDir(
|
|
32
|
+
packBase ?? path.join(projectRoot, ".tmp", "context-doc-pack-tests"),
|
|
33
|
+
"pack-"
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const build = pipe(
|
|
38
|
+
Command.make("pnpm", "--filter", "@prover-coder-ai/context-doc", "build"),
|
|
39
|
+
Command.workingDirectory(projectRoot)
|
|
40
|
+
)
|
|
41
|
+
yield* _(runCommand(build))
|
|
42
|
+
|
|
43
|
+
const appDir = path.join(projectRoot, "packages", "app")
|
|
44
|
+
const pack = pipe(
|
|
45
|
+
Command.make("npm", "pack", "--silent", "--pack-destination", packDir),
|
|
46
|
+
Command.workingDirectory(appDir)
|
|
47
|
+
)
|
|
48
|
+
const tarballName = (yield* _(runCommandOutput(pack))).trim()
|
|
49
|
+
const tarballPath = path.join(packDir, tarballName)
|
|
50
|
+
const tarballSpec = `file:${tarballPath}`
|
|
51
|
+
|
|
52
|
+
const tarballExists = yield* _(
|
|
53
|
+
withFsPath((fs) => fs.exists(tarballPath))
|
|
54
|
+
)
|
|
55
|
+
if (!tarballExists) {
|
|
56
|
+
return yield* _(Effect.fail(new Error("Packed tarball was not created")))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { projectRoot, tarballSpec }
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const prepareProject = Effect.gen(function*(_) {
|
|
63
|
+
const { tempBase } = yield* _(testPaths)
|
|
64
|
+
const path = yield* _(Path.Path)
|
|
65
|
+
const root = yield* _(
|
|
66
|
+
makeTempDir(tempBase, "project-")
|
|
67
|
+
)
|
|
68
|
+
const codexSource = path.join(root, "codex-src")
|
|
69
|
+
const destDir = path.join(root, ".knowledge", ".codex")
|
|
70
|
+
const qwenSource = path.join(root, "qwen-src")
|
|
71
|
+
|
|
72
|
+
yield* _(
|
|
73
|
+
writeFile(
|
|
74
|
+
path.join(codexSource, "sessions/2025/11/cli.jsonl"),
|
|
75
|
+
JSON.stringify({ cwd: root, message: "ok" })
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
yield* _(
|
|
79
|
+
writeFile(
|
|
80
|
+
path.join(qwenSource, "session-1.json"),
|
|
81
|
+
JSON.stringify({ sessionId: "s1" })
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return { root, codexSource, destDir, qwenSource }
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
type CliRunner = "npx" | "pnpm-dlx"
|
|
89
|
+
|
|
90
|
+
interface CliRunInput {
|
|
91
|
+
readonly runner: CliRunner
|
|
92
|
+
readonly tarballSpec: string
|
|
93
|
+
readonly root: string
|
|
94
|
+
readonly codexSource: string
|
|
95
|
+
readonly destDir: string
|
|
96
|
+
readonly qwenSource: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const runCliWithRunner = ({
|
|
100
|
+
codexSource,
|
|
101
|
+
destDir,
|
|
102
|
+
qwenSource,
|
|
103
|
+
root,
|
|
104
|
+
runner,
|
|
105
|
+
tarballSpec
|
|
106
|
+
}: CliRunInput) =>
|
|
107
|
+
Effect.gen(function*(_) {
|
|
108
|
+
const { projectRoot } = yield* _(testPaths)
|
|
109
|
+
const args = [
|
|
110
|
+
"--project-root",
|
|
111
|
+
root,
|
|
112
|
+
"--source",
|
|
113
|
+
codexSource,
|
|
114
|
+
"--dest",
|
|
115
|
+
destDir,
|
|
116
|
+
"--qwen-source",
|
|
117
|
+
qwenSource
|
|
118
|
+
]
|
|
119
|
+
const command = pipe(
|
|
120
|
+
runner === "npx"
|
|
121
|
+
? Command.make("npx", "-y", tarballSpec, ...args)
|
|
122
|
+
: Command.make("pnpm", "dlx", tarballSpec, ...args),
|
|
123
|
+
Command.workingDirectory(projectRoot)
|
|
124
|
+
)
|
|
125
|
+
yield* _(runCommand(command))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const assertCopied = (root: string, destDir: string) =>
|
|
129
|
+
withFsPath((fs, path) =>
|
|
130
|
+
Effect.gen(function*(_) {
|
|
131
|
+
const codexFile = path.join(destDir, "sessions/2025/11/cli.jsonl")
|
|
132
|
+
const codexExists = yield* _(fs.exists(codexFile))
|
|
133
|
+
expect(codexExists).toBe(true)
|
|
134
|
+
|
|
135
|
+
const qwenFile = path.join(root, ".knowledge", ".qwen", "session-1.json")
|
|
136
|
+
const qwenExists = yield* _(fs.exists(qwenFile))
|
|
137
|
+
expect(qwenExists).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
describe("cli package execution", () => {
|
|
142
|
+
it.effect("packs the CLI and executes it via npx", () =>
|
|
143
|
+
Effect.scoped(
|
|
144
|
+
Effect.gen(function*(_) {
|
|
145
|
+
const { tarballSpec } = yield* _(packCli)
|
|
146
|
+
const { codexSource, destDir, qwenSource, root } = yield* _(
|
|
147
|
+
prepareProject
|
|
148
|
+
)
|
|
149
|
+
yield* _(
|
|
150
|
+
runCliWithRunner({
|
|
151
|
+
runner: "npx",
|
|
152
|
+
tarballSpec,
|
|
153
|
+
root,
|
|
154
|
+
codexSource,
|
|
155
|
+
destDir,
|
|
156
|
+
qwenSource
|
|
157
|
+
})
|
|
158
|
+
)
|
|
159
|
+
yield* _(assertCopied(root, destDir))
|
|
160
|
+
const second = yield* _(prepareProject)
|
|
161
|
+
yield* _(
|
|
162
|
+
runCliWithRunner({
|
|
163
|
+
runner: "pnpm-dlx",
|
|
164
|
+
tarballSpec,
|
|
165
|
+
root: second.root,
|
|
166
|
+
codexSource: second.codexSource,
|
|
167
|
+
destDir: second.destDir,
|
|
168
|
+
qwenSource: second.qwenSource
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
yield* _(assertCopied(second.root, second.destDir))
|
|
172
|
+
})
|
|
173
|
+
).pipe(
|
|
174
|
+
Effect.provide(Layer.mergeAll(NodeContext.layer))
|
|
175
|
+
), 30_000)
|
|
176
|
+
})
|