@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.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,193 @@
1
+ import { join } from "node:path";
2
+ import { EDIT_FAIL_REASON } from "./files.constants";
3
+ import type {
4
+ IEdit,
5
+ EditResult,
6
+ IReplacement,
7
+ EditsResult,
8
+ } from "./files.types";
9
+
10
+ /**
11
+ * Apply a str_replace edit. The match must be **exact and unique** — 0 matches
12
+ * is `not-found`, >1 is `ambiguous` (the model must add surrounding context).
13
+ * Uniqueness is what makes blind edits safe for a strong, full-file-context model.
14
+ */
15
+ export async function applyEdit(cwd: string, edit: IEdit): Promise<EditResult> {
16
+ const path = join(cwd, edit.file);
17
+ const f = Bun.file(path);
18
+
19
+ if (!(await f.exists())) {
20
+ return { ok: false, file: edit.file, reason: EDIT_FAIL_REASON.missingFile };
21
+ }
22
+
23
+ if (edit.oldString === "") {
24
+ return { ok: false, file: edit.file, reason: EDIT_FAIL_REASON.notFound };
25
+ }
26
+
27
+ const content = await f.text();
28
+ const matches = content.split(edit.oldString).length - 1;
29
+
30
+ if (matches === 0) {
31
+ return { ok: false, file: edit.file, reason: EDIT_FAIL_REASON.notFound };
32
+ }
33
+
34
+ if (matches > 1) {
35
+ return {
36
+ ok: false,
37
+ file: edit.file,
38
+ reason: EDIT_FAIL_REASON.ambiguous,
39
+ matches,
40
+ };
41
+ }
42
+
43
+ // Unique match: split/join avoids `$`-pattern interpretation in newString.
44
+ await Bun.write(path, content.split(edit.oldString).join(edit.newString));
45
+
46
+ return { ok: true, file: edit.file };
47
+ }
48
+
49
+ /**
50
+ * Apply a SEQUENCE of str_replace edits to one file, ATOMICALLY: each match must
51
+ * be exact and unique in the content as it stands after the prior replacements;
52
+ * if any one fails, NOTHING is written and the failing replacement's index +
53
+ * reason is returned. This lets the model fix the same issue at several spread-
54
+ * out sites in a single turn (each piece still surgical) instead of a whole-file
55
+ * rewrite — while the all-or-nothing write keeps a half-applied batch off disk.
56
+ */
57
+ export async function applyEdits(
58
+ cwd: string,
59
+ file: string,
60
+ edits: readonly IReplacement[]
61
+ ): Promise<EditsResult> {
62
+ const path = join(cwd, file);
63
+ const f = Bun.file(path);
64
+
65
+ if (!(await f.exists())) {
66
+ return { ok: false, file, index: 0, reason: EDIT_FAIL_REASON.missingFile };
67
+ }
68
+
69
+ if (edits.length === 0) {
70
+ return { ok: false, file, index: 0, reason: EDIT_FAIL_REASON.notFound };
71
+ }
72
+
73
+ let content = await f.text();
74
+
75
+ for (let i = 0; i < edits.length; i += 1) {
76
+ const replacement = edits[i];
77
+
78
+ if (replacement === undefined || replacement.oldString === "") {
79
+ return { ok: false, file, index: i, reason: EDIT_FAIL_REASON.notFound };
80
+ }
81
+
82
+ const matches = content.split(replacement.oldString).length - 1;
83
+
84
+ if (matches === 0) {
85
+ // Exact match failed — try an indentation-tolerant LINE match (the common
86
+ // LLM miss: right code, slightly-off leading whitespace). Applies ONLY on a
87
+ // unique window; never guesses. The gate re-validates as a backstop.
88
+ const fuzzy = fuzzyLineReplace(
89
+ content,
90
+ replacement.oldString,
91
+ replacement.newString
92
+ );
93
+
94
+ if (fuzzy.matches === 1) {
95
+ content = fuzzy.text;
96
+ continue;
97
+ }
98
+
99
+ if (fuzzy.matches > 1) {
100
+ return {
101
+ ok: false,
102
+ file,
103
+ index: i,
104
+ reason: EDIT_FAIL_REASON.ambiguous,
105
+ matches: fuzzy.matches,
106
+ };
107
+ }
108
+
109
+ return { ok: false, file, index: i, reason: EDIT_FAIL_REASON.notFound };
110
+ }
111
+
112
+ if (matches > 1) {
113
+ return {
114
+ ok: false,
115
+ file,
116
+ index: i,
117
+ reason: EDIT_FAIL_REASON.ambiguous,
118
+ matches,
119
+ };
120
+ }
121
+
122
+ content = content.split(replacement.oldString).join(replacement.newString);
123
+ }
124
+
125
+ await Bun.write(path, content);
126
+
127
+ return { ok: true, file, count: edits.length };
128
+ }
129
+
130
+ /**
131
+ * Indentation-tolerant line match: compare `oldString` to `content` line-by-line
132
+ * ignoring each line's leading/trailing whitespace. Returns the new content with
133
+ * the matched window replaced by `newString` — but ONLY when exactly one window
134
+ * matches (`matches === 1`); 0 or >1 leaves content untouched so the caller can
135
+ * report not-found/ambiguous rather than guess. Line-granular (not char-offset),
136
+ * which keeps it simple and safe.
137
+ */
138
+ function fuzzyLineReplace(
139
+ content: string,
140
+ oldString: string,
141
+ newString: string
142
+ ): { text: string; matches: number } {
143
+ const norm = (s: string): string => s.trim();
144
+ const contentLines = content.split("\n");
145
+ const oldLines = oldString.split("\n");
146
+
147
+ // Drop blank leading/trailing lines (the model often adds a stray newline).
148
+ while (oldLines.length > 0 && norm(oldLines[0] ?? "") === "") {
149
+ oldLines.shift();
150
+ }
151
+
152
+ while (
153
+ oldLines.length > 0 &&
154
+ norm(oldLines[oldLines.length - 1] ?? "") === ""
155
+ ) {
156
+ oldLines.pop();
157
+ }
158
+
159
+ if (oldLines.length === 0) {
160
+ return { text: content, matches: 0 };
161
+ }
162
+
163
+ const needle = oldLines.map(norm);
164
+ const starts: number[] = [];
165
+
166
+ for (let i = 0; i + needle.length <= contentLines.length; i += 1) {
167
+ let hit = true;
168
+
169
+ for (let j = 0; j < needle.length; j += 1) {
170
+ if (norm(contentLines[i + j] ?? "") !== needle[j]) {
171
+ hit = false;
172
+ break;
173
+ }
174
+ }
175
+
176
+ if (hit) {
177
+ starts.push(i);
178
+ }
179
+ }
180
+
181
+ if (starts.length !== 1) {
182
+ return { text: content, matches: starts.length };
183
+ }
184
+
185
+ const start = starts[0] ?? 0;
186
+ const rebuilt = [
187
+ ...contentLines.slice(0, start),
188
+ ...newString.split("\n"),
189
+ ...contentLines.slice(start + needle.length),
190
+ ];
191
+
192
+ return { text: rebuilt.join("\n"), matches: 1 };
193
+ }
@@ -0,0 +1,11 @@
1
+ /** Why an edit failed to apply (compare against these, never the bare string). */
2
+ export const EDIT_FAIL_REASON = {
3
+ missingFile: "missing-file",
4
+ notFound: "not-found",
5
+ ambiguous: "ambiguous",
6
+ } as const;
7
+
8
+ /** Why a create failed (compare against this, never the bare string). */
9
+ export const CREATE_FAIL_REASON = {
10
+ exists: "exists",
11
+ } as const;
@@ -0,0 +1,81 @@
1
+ import {
2
+ type EDIT_FAIL_REASON,
3
+ type CREATE_FAIL_REASON,
4
+ } from "./files.constants";
5
+
6
+ /** A single targeted edit: replace an exact, unique snippet. */
7
+ export interface IEdit {
8
+ file: string;
9
+ oldString: string;
10
+ newString: string;
11
+ }
12
+
13
+ export type EditFailReason =
14
+ (typeof EDIT_FAIL_REASON)[keyof typeof EDIT_FAIL_REASON];
15
+
16
+ export type EditResult =
17
+ | { ok: true; file: string }
18
+ | {
19
+ ok: false;
20
+ file: string;
21
+ reason: EditFailReason;
22
+ /** Number of matches when ambiguous. */
23
+ matches?: number;
24
+ };
25
+
26
+ /** One replacement within a batched, multi-site edit to a single file. */
27
+ export interface IReplacement {
28
+ oldString: string;
29
+ newString: string;
30
+ }
31
+
32
+ export type EditsResult =
33
+ | { ok: true; file: string; count: number }
34
+ | {
35
+ ok: false;
36
+ file: string;
37
+ /** Which replacement in the batch failed (0-based). */
38
+ index: number;
39
+ reason: EditFailReason;
40
+ matches?: number;
41
+ };
42
+
43
+ /** Create a brand-new file. */
44
+ export interface ICreateFile {
45
+ file: string;
46
+ content: string;
47
+ }
48
+
49
+ export type CreateFailReason =
50
+ (typeof CREATE_FAIL_REASON)[keyof typeof CREATE_FAIL_REASON];
51
+
52
+ export type CreateResult =
53
+ | { ok: true; file: string }
54
+ | { ok: false; file: string; reason: CreateFailReason };
55
+
56
+ /** Hashline edit request: anchored by content hash for stale-anchor recovery. */
57
+ export interface IHashlineEdit {
58
+ file: string;
59
+ /** Content hash from the read annotation (¶path#HASH). */
60
+ hash?: string;
61
+ /** Raw edit payload: header + ops. */
62
+ input: string;
63
+ }
64
+
65
+ export type HashlineFailReason =
66
+ | "missing-file"
67
+ | "no-anchor"
68
+ | "stale-anchor"
69
+ | "stale-anchor-conflict"
70
+ | "parse-error"
71
+ | "out-of-bounds"
72
+ | "invalid-op";
73
+
74
+ export type HashlineResult =
75
+ | { ok: true; file: string; newHash: string }
76
+ | {
77
+ ok: false;
78
+ file: string;
79
+ reason: HashlineFailReason;
80
+ suggestions?: string[];
81
+ };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Hashline format constants and pure functions. Single source of truth for
3
+ * read/edit annotation format, hash computation, and display helpers.
4
+ * Adapts oh-my-pi's hashline primitives for tsforge (no block-edits,
5
+ * lenient parse, 3-way merge recovery).
6
+ */
7
+
8
+ /** File-section header prefix. */
9
+ export const HL_HEADER_SIGIL = "¶";
10
+
11
+ /** Hash separator in header: `¶path#HASH`. */
12
+ export const HL_HASH_SEP = "#";
13
+
14
+ /** Line-number / content separator: `N:text`. */
15
+ export const HL_LINE_SEP = ":";
16
+
17
+ /** Replacement/deletion/insert operation keywords. */
18
+ export const HL_OP_REPLACE = "replace";
19
+ export const HL_OP_DELETE = "delete";
20
+ export const HL_OP_INSERT = "insert";
21
+
22
+ /** Position keywords for insert. */
23
+ export const HL_POS_BEFORE = "before";
24
+ export const HL_POS_AFTER = "after";
25
+
26
+ /** Payload line prefix. */
27
+ export const HL_PAYLOAD_PREFIX = "+";
28
+
29
+ /** Range separator: N..M. */
30
+ export const HL_RANGE_SEP = "..";
31
+
32
+ /** Length of the hex hash (4 hex chars = 16 bits). */
33
+ export const HL_HASH_LENGTH = 4;
34
+
35
+ /**
36
+ * Normalize text for hashing: strip trailing [ \t\r] from every line
37
+ * (before \n or EOF) so CRLF and display-trimmed content hash identically.
38
+ */
39
+ function normalizeForHash(text: string): string {
40
+ return text.replace(/[ \t\r]+(?=\n|$)/g, "");
41
+ }
42
+
43
+ /**
44
+ * Compute the content-derived hash tag for a file's normalized text.
45
+ * 4-hex-uppercase fingerprint using xxHash32 (fast, collision-free for
46
+ * practical file sizes). Same tag mints on every read of byte-identical content.
47
+ */
48
+ export function computeFileHash(text: string): string {
49
+ const normalized = normalizeForHash(text);
50
+ const hash32 = Bun.hash.xxHash32(normalized, 0);
51
+ const low16 = hash32 & 0xffff;
52
+
53
+ return low16.toString(16).padStart(HL_HASH_LENGTH, "0").toUpperCase();
54
+ }
55
+
56
+ /**
57
+ * Format a read-annotation header: `¶path#HASH`.
58
+ */
59
+ export function formatHashHeader(filePath: string, hash: string): string {
60
+ return `${HL_HEADER_SIGIL}${filePath}${HL_HASH_SEP}${hash}`;
61
+ }
62
+
63
+ /**
64
+ * Format a numbered line: `N:text`.
65
+ */
66
+ export function formatNumberedLine(lineNum: number, text: string): string {
67
+ return `${lineNum}${HL_LINE_SEP}${text}`;
68
+ }
69
+
70
+ /**
71
+ * Parse a read-annotation header. Extracts path and hash from `¶path#HASH`.
72
+ * Returns null if the line is not a valid header.
73
+ */
74
+ export function parseHashHeader(
75
+ line: string
76
+ ): { path: string; hash: string } | null {
77
+ if (!line.startsWith(HL_HEADER_SIGIL)) {
78
+ return null;
79
+ }
80
+
81
+ const body = line.slice(HL_HEADER_SIGIL.length);
82
+ const sepIdx = body.lastIndexOf(HL_HASH_SEP);
83
+
84
+ if (sepIdx === -1) {
85
+ return null;
86
+ }
87
+
88
+ const path = body.slice(0, sepIdx);
89
+ const hash = body.slice(sepIdx + 1);
90
+
91
+ if (path.length === 0 || !isValidHash(hash)) {
92
+ return null;
93
+ }
94
+
95
+ return { path, hash };
96
+ }
97
+
98
+ /**
99
+ * Check if a string is a valid 4-hex hash.
100
+ */
101
+ export function isValidHash(hash: string): boolean {
102
+ return /^[0-9A-F]{4}$/i.test(hash);
103
+ }
104
+
105
+ /**
106
+ * Normalize a hash to uppercase for comparison.
107
+ */
108
+ export function normalizeHash(hash: string): string {
109
+ return hash.toUpperCase();
110
+ }