@agjs/tsforge 0.1.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/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- package/strict.web.eslint.config.mjs +185 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline edit system: per-session snapshot store, parser, and 3-way merge recovery.
|
|
3
|
+
* Binds hashline section tags to the file content that minted them; allows edits
|
|
4
|
+
* against stale tags via snapshot-based recovery (3-way merge: snapshot=base,
|
|
5
|
+
* live=theirs, edited-snapshot=ours).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
computeFileHash,
|
|
11
|
+
parseHashHeader,
|
|
12
|
+
normalizeHash,
|
|
13
|
+
} from "./hashline-format";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One full-file version observed at a point in time.
|
|
17
|
+
*/
|
|
18
|
+
export interface ISnapshot {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly hash: string;
|
|
22
|
+
recordedAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-path LRU history: keep up to 4 snapshots per file.
|
|
27
|
+
*/
|
|
28
|
+
export interface ISnapshotHistory {
|
|
29
|
+
path: string;
|
|
30
|
+
versions: ISnapshot[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* In-memory snapshot store: per-path LRU with up to 4 versions per path.
|
|
35
|
+
* Thread-safe for in-session use; session is single-threaded so no locks needed.
|
|
36
|
+
*/
|
|
37
|
+
export class SessionSnapshotStore {
|
|
38
|
+
readonly #histories = new Map<string, ISnapshotHistory>();
|
|
39
|
+
|
|
40
|
+
readonly #pathLru: string[] = []; // LRU path ordering
|
|
41
|
+
|
|
42
|
+
readonly maxVersionsPerPath = 4;
|
|
43
|
+
|
|
44
|
+
readonly maxPaths = 30;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record a file snapshot. Returns the computed hash.
|
|
48
|
+
* If the same content is recorded again, promotes it to head and reuses the tag.
|
|
49
|
+
*/
|
|
50
|
+
record(filePath: string, fullText: string): string {
|
|
51
|
+
const hash = computeFileHash(fullText);
|
|
52
|
+
|
|
53
|
+
let history = this.#histories.get(filePath);
|
|
54
|
+
|
|
55
|
+
if (!history) {
|
|
56
|
+
history = { path: filePath, versions: [] };
|
|
57
|
+
this.#histories.set(filePath, history);
|
|
58
|
+
this.#pathLru.push(filePath);
|
|
59
|
+
} else {
|
|
60
|
+
// Promote to LRU head
|
|
61
|
+
const idx = this.#pathLru.indexOf(filePath);
|
|
62
|
+
|
|
63
|
+
if (idx !== -1) {
|
|
64
|
+
this.#pathLru.splice(idx, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.#pathLru.push(filePath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check if we already have this exact content
|
|
71
|
+
const existing = history.versions.find((v) => v.hash === hash);
|
|
72
|
+
|
|
73
|
+
if (existing) {
|
|
74
|
+
existing.recordedAt = Date.now();
|
|
75
|
+
// Move to head
|
|
76
|
+
const idx = history.versions.indexOf(existing);
|
|
77
|
+
|
|
78
|
+
if (idx > 0) {
|
|
79
|
+
history.versions.splice(idx, 1);
|
|
80
|
+
history.versions.unshift(existing);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return hash;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Record new version at head
|
|
87
|
+
const snapshot: ISnapshot = {
|
|
88
|
+
path: filePath,
|
|
89
|
+
text: fullText,
|
|
90
|
+
hash,
|
|
91
|
+
recordedAt: Date.now(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
history.versions.unshift(snapshot);
|
|
95
|
+
|
|
96
|
+
// Keep only latest maxVersionsPerPath
|
|
97
|
+
if (history.versions.length > this.maxVersionsPerPath) {
|
|
98
|
+
history.versions.pop();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Evict LRU paths if we exceed maxPaths
|
|
102
|
+
while (this.#pathLru.length > this.maxPaths) {
|
|
103
|
+
const lruPath = this.#pathLru.shift();
|
|
104
|
+
|
|
105
|
+
if (lruPath !== undefined) {
|
|
106
|
+
this.#histories.delete(lruPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return hash;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the most recent snapshot for a path, or null.
|
|
115
|
+
*/
|
|
116
|
+
head(filePath: string): ISnapshot | null {
|
|
117
|
+
return this.#histories.get(filePath)?.versions[0] ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get a specific historical snapshot by path and hash, or null.
|
|
122
|
+
*/
|
|
123
|
+
byHash(filePath: string, hash: string): ISnapshot | null {
|
|
124
|
+
const history = this.#histories.get(filePath);
|
|
125
|
+
|
|
126
|
+
if (!history) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return history.versions.find((v) => v.hash === normalizeHash(hash)) ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clear all snapshots.
|
|
135
|
+
*/
|
|
136
|
+
clear(): void {
|
|
137
|
+
this.#histories.clear();
|
|
138
|
+
this.#pathLru.length = 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parsed hashline edit operation.
|
|
144
|
+
*/
|
|
145
|
+
export interface IEditOp {
|
|
146
|
+
kind: "replace" | "delete" | "insert";
|
|
147
|
+
startLine?: number;
|
|
148
|
+
endLine?: number;
|
|
149
|
+
insertPos?: "before" | "after";
|
|
150
|
+
insertAnchor?: number;
|
|
151
|
+
lines: string[]; // payload lines
|
|
152
|
+
lineNum: number; // source line in the edit text (for error reporting)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Result of applying a hashline edit.
|
|
157
|
+
*/
|
|
158
|
+
export interface IHashlineResult {
|
|
159
|
+
ok: boolean;
|
|
160
|
+
file: string;
|
|
161
|
+
newHash?: string;
|
|
162
|
+
reason?: string; // error reason
|
|
163
|
+
suggestions?: string[]; // actionable feedback
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const HL_PAYLOAD_PREFIX = "+";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse the header line (¶path#HASH). Returns {path, hash} or null.
|
|
170
|
+
*/
|
|
171
|
+
function parseHeaderLine(
|
|
172
|
+
headerLine: string,
|
|
173
|
+
errors: string[]
|
|
174
|
+
): { filePath: string; fileHash: string | undefined } {
|
|
175
|
+
const header = parseHashHeader(headerLine);
|
|
176
|
+
|
|
177
|
+
if (header) {
|
|
178
|
+
return { filePath: header.path, fileHash: header.hash };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (headerLine.trim() !== "") {
|
|
182
|
+
errors.push(
|
|
183
|
+
`Expected header ¶path#HASH on first line, got: ${JSON.stringify(
|
|
184
|
+
headerLine.slice(0, 80)
|
|
185
|
+
)}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { filePath: "", fileHash: undefined };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse operation lines and collect ops.
|
|
194
|
+
*/
|
|
195
|
+
function parseOperations(
|
|
196
|
+
lines: string[],
|
|
197
|
+
startIdx: number,
|
|
198
|
+
errors: string[]
|
|
199
|
+
): IEditOp[] {
|
|
200
|
+
const ops: IEditOp[] = [];
|
|
201
|
+
let currentOp: IEditOp | null = null;
|
|
202
|
+
|
|
203
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
204
|
+
const line = lines[i] ?? "";
|
|
205
|
+
const trimmed = line.trimEnd();
|
|
206
|
+
|
|
207
|
+
if (trimmed === "") {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check if this is a payload line (starts with +)
|
|
212
|
+
if (trimmed.startsWith(HL_PAYLOAD_PREFIX)) {
|
|
213
|
+
if (currentOp) {
|
|
214
|
+
currentOp.lines.push(trimmed.slice(1)); // Strip the + prefix
|
|
215
|
+
} else {
|
|
216
|
+
errors.push(
|
|
217
|
+
`Payload line without operation: ${JSON.stringify(
|
|
218
|
+
trimmed.slice(0, 60)
|
|
219
|
+
)}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Try to parse as an operation header
|
|
227
|
+
const op = parseOpHeader(trimmed, i + 1);
|
|
228
|
+
|
|
229
|
+
if (op) {
|
|
230
|
+
if (currentOp) {
|
|
231
|
+
ops.push(currentOp);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
currentOp = op;
|
|
235
|
+
} else if (trimmed.length > 0) {
|
|
236
|
+
errors.push(`Unrecognized line: ${JSON.stringify(trimmed.slice(0, 60))}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (currentOp) {
|
|
241
|
+
ops.push(currentOp);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return ops;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse a single operation header line. Returns null if not recognized.
|
|
249
|
+
*/
|
|
250
|
+
function parseOpHeader(line: string, lineNum: number): IEditOp | null {
|
|
251
|
+
// replace N..M: or replace N..M
|
|
252
|
+
let match = /^replace\s+(\d+)(?:\.\.(\d+))?\s*:?/i.exec(line);
|
|
253
|
+
|
|
254
|
+
if (match) {
|
|
255
|
+
const startLine = parseInt(match[1] ?? "0", 10);
|
|
256
|
+
const endLine = match[2] !== undefined ? parseInt(match[2], 10) : startLine;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
kind: "replace",
|
|
260
|
+
startLine,
|
|
261
|
+
endLine,
|
|
262
|
+
lines: [],
|
|
263
|
+
lineNum,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// delete N..M or delete N
|
|
268
|
+
match = /^delete\s+(\d+)(?:\.\.(\d+))?/i.exec(line);
|
|
269
|
+
|
|
270
|
+
if (match) {
|
|
271
|
+
const startLine = parseInt(match[1] ?? "0", 10);
|
|
272
|
+
const endLine = match[2] !== undefined ? parseInt(match[2], 10) : startLine;
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
kind: "delete",
|
|
276
|
+
startLine,
|
|
277
|
+
endLine,
|
|
278
|
+
lines: [],
|
|
279
|
+
lineNum,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// insert before N: or insert after N:
|
|
284
|
+
match = /^insert\s+(before|after)\s+(\d+)\s*:?/i.exec(line);
|
|
285
|
+
|
|
286
|
+
if (match) {
|
|
287
|
+
const posText = (match[1] ?? "").toLowerCase();
|
|
288
|
+
const insertPos = posText === "before" ? "before" : "after";
|
|
289
|
+
const insertAnchor = parseInt(match[2] ?? "0", 10);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
kind: "insert",
|
|
293
|
+
insertPos,
|
|
294
|
+
insertAnchor,
|
|
295
|
+
lines: [],
|
|
296
|
+
lineNum,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Parse a hashline edit input. Lenient: accepts `replace N..M:` or `replace N..M` or
|
|
305
|
+
* `delete N..M`, `insert before N:` or `insert after N:`, with optional body lines
|
|
306
|
+
* prefixed `+`. Returns parsed operations and the file header (path, hash).
|
|
307
|
+
*/
|
|
308
|
+
export function parseHashlineEdit(input: string): {
|
|
309
|
+
filePath: string;
|
|
310
|
+
fileHash: string | undefined;
|
|
311
|
+
ops: IEditOp[];
|
|
312
|
+
errors: string[];
|
|
313
|
+
} {
|
|
314
|
+
const lines = input.split("\n");
|
|
315
|
+
const errors: string[] = [];
|
|
316
|
+
let filePath = "";
|
|
317
|
+
let fileHash: string | undefined;
|
|
318
|
+
|
|
319
|
+
let i = 0;
|
|
320
|
+
|
|
321
|
+
// Parse file header
|
|
322
|
+
if (i < lines.length) {
|
|
323
|
+
const headerLine = lines[i] ?? "";
|
|
324
|
+
const parsed = parseHeaderLine(headerLine, errors);
|
|
325
|
+
|
|
326
|
+
filePath = parsed.filePath;
|
|
327
|
+
fileHash = parsed.fileHash;
|
|
328
|
+
|
|
329
|
+
if (filePath.length > 0) {
|
|
330
|
+
i++;
|
|
331
|
+
} else if (errors.length > 0) {
|
|
332
|
+
return { filePath: "", fileHash: undefined, ops: [], errors };
|
|
333
|
+
} else if (headerLine.trim() === "") {
|
|
334
|
+
i++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Parse operations
|
|
339
|
+
const ops = parseOperations(lines, i, errors);
|
|
340
|
+
|
|
341
|
+
return { filePath, fileHash, ops, errors };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Apply hashline edits to file content. Handles stale-tag recovery via 3-way merge.
|
|
346
|
+
*/
|
|
347
|
+
export async function applyHashlineEdit(
|
|
348
|
+
store: SessionSnapshotStore,
|
|
349
|
+
cwd: string,
|
|
350
|
+
file: string,
|
|
351
|
+
fileHash: string | undefined,
|
|
352
|
+
ops: IEditOp[]
|
|
353
|
+
): Promise<IHashlineResult> {
|
|
354
|
+
const path = join(cwd, file);
|
|
355
|
+
const f = Bun.file(path);
|
|
356
|
+
|
|
357
|
+
if (!(await f.exists())) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
file,
|
|
361
|
+
reason: "missing-file",
|
|
362
|
+
suggestions: [
|
|
363
|
+
`File ${file} does not exist. Use \`create\` to create it.`,
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const liveContent = await f.text();
|
|
369
|
+
const liveHash = computeFileHash(liveContent);
|
|
370
|
+
|
|
371
|
+
// Case 1: Hash matches live file
|
|
372
|
+
if (
|
|
373
|
+
fileHash !== undefined &&
|
|
374
|
+
fileHash.length > 0 &&
|
|
375
|
+
normalizeHash(fileHash) === liveHash
|
|
376
|
+
) {
|
|
377
|
+
const result = applyOpsToContent(liveContent, ops, file);
|
|
378
|
+
|
|
379
|
+
if (!result.ok || result.text === undefined) {
|
|
380
|
+
return { ...result, file };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const newHash = computeFileHash(result.text);
|
|
384
|
+
|
|
385
|
+
await Bun.write(path, result.text);
|
|
386
|
+
store.record(file, result.text);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
file,
|
|
391
|
+
newHash,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Case 2: Hash is stale, try recovery via snapshot
|
|
396
|
+
if (fileHash !== undefined) {
|
|
397
|
+
const snapshot = store.byHash(file, fileHash);
|
|
398
|
+
|
|
399
|
+
if (snapshot) {
|
|
400
|
+
// Try 3-way merge: snapshot=base, live=theirs, edited-snapshot=ours
|
|
401
|
+
const mergedResult = threeWayMerge(snapshot.text, liveContent, ops, file);
|
|
402
|
+
|
|
403
|
+
if (
|
|
404
|
+
mergedResult.ok &&
|
|
405
|
+
mergedResult.cleanMerge &&
|
|
406
|
+
mergedResult.text !== undefined
|
|
407
|
+
) {
|
|
408
|
+
const newHash = computeFileHash(mergedResult.text);
|
|
409
|
+
|
|
410
|
+
await Bun.write(path, mergedResult.text);
|
|
411
|
+
store.record(file, mergedResult.text);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
file,
|
|
416
|
+
newHash,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!mergedResult.ok || !mergedResult.cleanMerge) {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
file,
|
|
424
|
+
reason: "stale-anchor-conflict",
|
|
425
|
+
suggestions: [
|
|
426
|
+
`The file changed since you read it (was #${fileHash}, now #${liveHash}). ` +
|
|
427
|
+
`Your edits conflict with those changes. Please re-read the file and adjust your edits.`,
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
file,
|
|
436
|
+
reason: "stale-anchor",
|
|
437
|
+
suggestions: [
|
|
438
|
+
`The file changed since you read it (was #${fileHash}, now #${liveHash}). ` +
|
|
439
|
+
`Re-read the file to get a current hash, then edit again.`,
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Case 3: No hash provided but file exists
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
file,
|
|
448
|
+
reason: "no-anchor",
|
|
449
|
+
suggestions: [
|
|
450
|
+
`Edit needs a hashline anchor (¶${file}#HASH). Read the file first to get its current hash.`,
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Apply operations to content directly (hash already validated or not using hash).
|
|
457
|
+
* Operations are applied bottom-up so line numbers stay valid.
|
|
458
|
+
*/
|
|
459
|
+
function applyOpsToContent(
|
|
460
|
+
content: string,
|
|
461
|
+
ops: IEditOp[],
|
|
462
|
+
_filePath: string
|
|
463
|
+
): { ok: boolean; text?: string; reason?: string; suggestions?: string[] } {
|
|
464
|
+
const lines = content.split("\n");
|
|
465
|
+
|
|
466
|
+
// Sort ops by line number descending (bottom-up) to preserve line numbers
|
|
467
|
+
const sortedOps = [...ops].sort((a, b) => {
|
|
468
|
+
const aLine = a.startLine ?? a.insertAnchor ?? 0;
|
|
469
|
+
const bLine = b.startLine ?? b.insertAnchor ?? 0;
|
|
470
|
+
|
|
471
|
+
return bLine - aLine;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
for (const op of sortedOps) {
|
|
475
|
+
const result = applyOp(lines, op, _filePath);
|
|
476
|
+
|
|
477
|
+
if (!result.ok) {
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { ok: true, text: lines.join("\n") };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Apply a single operation to the lines array (mutates it).
|
|
487
|
+
*/
|
|
488
|
+
function applyOp(
|
|
489
|
+
lines: string[],
|
|
490
|
+
op: IEditOp,
|
|
491
|
+
_filePath: string
|
|
492
|
+
): { ok: boolean; reason?: string; suggestions?: string[] } {
|
|
493
|
+
if (
|
|
494
|
+
op.kind === "replace" &&
|
|
495
|
+
op.startLine !== undefined &&
|
|
496
|
+
op.endLine !== undefined
|
|
497
|
+
) {
|
|
498
|
+
// 1-indexed, inclusive range
|
|
499
|
+
const start = op.startLine - 1;
|
|
500
|
+
const end = op.endLine; // slice uses exclusive end
|
|
501
|
+
|
|
502
|
+
if (start < 0 || end > lines.length || start > end) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
reason: "out-of-bounds",
|
|
506
|
+
suggestions: [
|
|
507
|
+
`replace ${op.startLine}..${op.endLine} is invalid for a ${lines.length}-line file. ` +
|
|
508
|
+
`Check your line numbers and try again.`,
|
|
509
|
+
],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
lines.splice(start, end - start, ...op.lines);
|
|
514
|
+
|
|
515
|
+
return { ok: true };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (
|
|
519
|
+
op.kind === "delete" &&
|
|
520
|
+
op.startLine !== undefined &&
|
|
521
|
+
op.endLine !== undefined
|
|
522
|
+
) {
|
|
523
|
+
const start = op.startLine - 1;
|
|
524
|
+
const end = op.endLine;
|
|
525
|
+
|
|
526
|
+
if (start < 0 || end > lines.length || start > end) {
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
reason: "out-of-bounds",
|
|
530
|
+
suggestions: [
|
|
531
|
+
`delete ${op.startLine}..${op.endLine} is invalid for a ${lines.length}-line file.`,
|
|
532
|
+
],
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
lines.splice(start, end - start);
|
|
537
|
+
|
|
538
|
+
return { ok: true };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (
|
|
542
|
+
op.kind === "insert" &&
|
|
543
|
+
op.insertPos !== undefined &&
|
|
544
|
+
op.insertAnchor !== undefined
|
|
545
|
+
) {
|
|
546
|
+
const anchorIdx = op.insertAnchor - 1;
|
|
547
|
+
|
|
548
|
+
if (anchorIdx < 0 || anchorIdx >= lines.length) {
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
reason: "out-of-bounds",
|
|
552
|
+
suggestions: [
|
|
553
|
+
`insert ${op.insertPos ?? ""} ${op.insertAnchor ?? 0}: line does not exist ` +
|
|
554
|
+
`in the ${lines.length}-line file. Check your anchor line number.`,
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const pos = op.insertPos === "before" ? anchorIdx : anchorIdx + 1;
|
|
560
|
+
|
|
561
|
+
lines.splice(pos, 0, ...op.lines);
|
|
562
|
+
|
|
563
|
+
return { ok: true };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
reason: "invalid-operation",
|
|
569
|
+
suggestions: [`invalid operation: ${op.kind}`],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 3-way merge: snapshot=base, live=theirs, edited-snapshot=ours.
|
|
575
|
+
* Applies the ops against the snapshot and merges the result into live.
|
|
576
|
+
* Returns whether the merge was clean (no conflicts).
|
|
577
|
+
*/
|
|
578
|
+
function threeWayMerge(
|
|
579
|
+
base: string,
|
|
580
|
+
theirs: string,
|
|
581
|
+
ops: IEditOp[],
|
|
582
|
+
_filePath: string
|
|
583
|
+
): { ok: boolean; text?: string; cleanMerge: boolean } {
|
|
584
|
+
const baseLines = base.split("\n");
|
|
585
|
+
const theirLines = theirs.split("\n");
|
|
586
|
+
const located: { op: IEditOp; index: number; length: number }[] = [];
|
|
587
|
+
|
|
588
|
+
// Re-anchor each op: take the base lines it targets and find that exact
|
|
589
|
+
// run (uniquely) in the live file. Edits to lines the live file also
|
|
590
|
+
// changed fail re-anchoring and surface as conflicts.
|
|
591
|
+
for (const op of ops) {
|
|
592
|
+
const range = opAnchorRange(op, baseLines.length);
|
|
593
|
+
|
|
594
|
+
if (range === null) {
|
|
595
|
+
return { ok: false, cleanMerge: false };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const anchor = baseLines.slice(range.start - 1, range.end);
|
|
599
|
+
const index = findUniqueRun(theirLines, anchor);
|
|
600
|
+
|
|
601
|
+
if (index === null) {
|
|
602
|
+
return { ok: false, cleanMerge: false };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
located.push({ op, index, length: anchor.length });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Apply bottom-up in live coordinates; overlapping targets = conflict.
|
|
609
|
+
located.sort((a, b) => b.index - a.index);
|
|
610
|
+
|
|
611
|
+
for (let i = 1; i < located.length; i++) {
|
|
612
|
+
const above = located[i - 1];
|
|
613
|
+
const here = located[i];
|
|
614
|
+
|
|
615
|
+
if (
|
|
616
|
+
above !== undefined &&
|
|
617
|
+
here !== undefined &&
|
|
618
|
+
here.index + here.length > above.index
|
|
619
|
+
) {
|
|
620
|
+
return { ok: false, cleanMerge: false };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
for (const { op, index, length } of located) {
|
|
625
|
+
if (op.kind === "replace") {
|
|
626
|
+
theirLines.splice(index, length, ...op.lines);
|
|
627
|
+
} else if (op.kind === "delete") {
|
|
628
|
+
theirLines.splice(index, length);
|
|
629
|
+
} else if (op.insertPos === "before") {
|
|
630
|
+
theirLines.splice(index, 0, ...op.lines);
|
|
631
|
+
} else {
|
|
632
|
+
theirLines.splice(index + length, 0, ...op.lines);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { ok: true, text: theirLines.join("\n"), cleanMerge: true };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The 1-based inclusive base-line range an op is anchored to. */
|
|
640
|
+
function opAnchorRange(
|
|
641
|
+
op: IEditOp,
|
|
642
|
+
lineCount: number
|
|
643
|
+
): { start: number; end: number } | null {
|
|
644
|
+
if (op.kind === "insert") {
|
|
645
|
+
const anchor = op.insertAnchor ?? 0;
|
|
646
|
+
|
|
647
|
+
return anchor >= 1 && anchor <= lineCount
|
|
648
|
+
? { start: anchor, end: anchor }
|
|
649
|
+
: null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const start = op.startLine ?? 0;
|
|
653
|
+
const end = op.endLine ?? 0;
|
|
654
|
+
|
|
655
|
+
return start >= 1 && end >= start && end <= lineCount ? { start, end } : null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Index of `needle` as a contiguous run in `haystack`, only if unique. */
|
|
659
|
+
function findUniqueRun(
|
|
660
|
+
haystack: readonly string[],
|
|
661
|
+
needle: readonly string[]
|
|
662
|
+
): number | null {
|
|
663
|
+
if (needle.length === 0) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let found = -1;
|
|
668
|
+
|
|
669
|
+
for (let i = 0; i + needle.length <= haystack.length; i++) {
|
|
670
|
+
let matches = true;
|
|
671
|
+
|
|
672
|
+
for (let j = 0; j < needle.length; j++) {
|
|
673
|
+
if (haystack[i + j] !== needle[j]) {
|
|
674
|
+
matches = false;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (matches) {
|
|
680
|
+
if (found !== -1) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
found = i;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return found === -1 ? null : found;
|
|
689
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from "./files.types";
|
|
2
|
+
export * from "./files.constants";
|
|
3
|
+
export { applyEdit, applyEdits } from "./edit";
|
|
4
|
+
export { applyCreate } from "./create";
|
|
5
|
+
export {
|
|
6
|
+
SessionSnapshotStore,
|
|
7
|
+
parseHashlineEdit,
|
|
8
|
+
applyHashlineEdit,
|
|
9
|
+
type ISnapshot,
|
|
10
|
+
type IEditOp,
|
|
11
|
+
type IHashlineResult,
|
|
12
|
+
} from "./hashline";
|
|
13
|
+
export {
|
|
14
|
+
computeFileHash,
|
|
15
|
+
formatHashHeader,
|
|
16
|
+
parseHashHeader,
|
|
17
|
+
isValidHash,
|
|
18
|
+
normalizeHash,
|
|
19
|
+
} from "./hashline-format";
|
package/src/index.ts
ADDED