@shipwithai/loopnote 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +202 -2
- package/THIRD_PARTY_NOTICES.md +580 -0
- package/dist/bin/loopnote.mjs +5 -0
- package/dist/cli/confirm.js +38 -0
- package/dist/cli/picker.js +342 -0
- package/dist/cli/request.js +120 -0
- package/dist/cli/run.js +970 -0
- package/dist/contracts/api.js +640 -0
- package/dist/contracts/comparison.js +178 -0
- package/dist/contracts/draft.js +606 -0
- package/dist/contracts/request.js +124 -0
- package/dist/domain/anchor.js +122 -0
- package/dist/domain/comparison-key.js +51 -0
- package/dist/domain/errors.js +43 -0
- package/dist/domain/path-bytes.js +82 -0
- package/dist/domain/product-identity.js +66 -0
- package/dist/domain/source.js +4 -0
- package/dist/draft/mutate-draft.js +87 -0
- package/dist/export/render-review-markdown.js +163 -0
- package/dist/export/review-export.js +273 -0
- package/dist/git/availability.js +92 -0
- package/dist/git/candidates.js +413 -0
- package/dist/git/comparison.js +263 -0
- package/dist/git/exact-patch.js +702 -0
- package/dist/git/ignore-status.js +23 -0
- package/dist/git/inventory.js +160 -0
- package/dist/git/numstat.js +111 -0
- package/dist/git/objects.js +95 -0
- package/dist/git/raw-diff.js +84 -0
- package/dist/git/repository.js +144 -0
- package/dist/git/runner.js +145 -0
- package/dist/git/selector-drift.js +169 -0
- package/dist/git/standalone-resolution.js +174 -0
- package/dist/git/worktree-content.js +97 -0
- package/dist/git/worktree-inventory.js +391 -0
- package/dist/native/directory_exchange.node +0 -0
- package/dist/server/app.js +171 -0
- package/dist/server/attached-completion.js +85 -0
- package/dist/server/capabilities.js +1572 -0
- package/dist/server/draft-loader.js +134 -0
- package/dist/server/draft-recovery.js +5 -0
- package/dist/server/draft-store.js +380 -0
- package/dist/server/export-store.js +291 -0
- package/dist/server/gitignore-capability.js +197 -0
- package/dist/server/image-content.js +101 -0
- package/dist/server/lifecycle.js +72 -0
- package/dist/server/native-exchange-capability.js +45 -0
- package/dist/server/patch-snapshot.js +500 -0
- package/dist/server/routes.js +418 -0
- package/dist/server/security.js +123 -0
- package/dist/server/snapshot-fs.js +58 -0
- package/dist/server/support-client.js +76 -0
- package/dist/server/support-store.js +194 -0
- package/dist/server/worktree-snapshot.js +553 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
- package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-D05T4iGw.js +1 -0
- package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-BDAqstpo.js +1 -0
- package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
- package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
- package/dist/web/assets/index-CCeYBqPo.js +1111 -0
- package/dist/web/assets/index-CRj9ojWJ.css +1 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-1Ywytw67.js +1 -0
- package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
- package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-CET4IumD.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-d-reaHeT.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
- package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DprEVA-S.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
- package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +87 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
4
|
+
export const MAX_AGENT_REQUEST_BYTES = 1_048_576;
|
|
5
|
+
export const MAX_GIT_ARGUMENT_BYTES = 4_096;
|
|
6
|
+
export const MAX_PATHSPEC_COUNT = 256;
|
|
7
|
+
const emptyPathspecs = [];
|
|
8
|
+
function isValidUnicode(value) {
|
|
9
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
10
|
+
const codeUnit = value.charCodeAt(index);
|
|
11
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
12
|
+
const next = value.charCodeAt(index + 1);
|
|
13
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
index += 1;
|
|
17
|
+
}
|
|
18
|
+
else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
function isBoundedGitArgument(value) {
|
|
25
|
+
return value.length > 0 && !value.includes('\0') && isValidUnicode(value) && Buffer.byteLength(value, 'utf8') <= MAX_GIT_ARGUMENT_BYTES;
|
|
26
|
+
}
|
|
27
|
+
export const GitArgumentSchema = z.string().refine(isBoundedGitArgument, {
|
|
28
|
+
message: 'Git arguments must be non-empty, valid Unicode without NUL, and at most 4096 UTF-8 bytes.',
|
|
29
|
+
});
|
|
30
|
+
const RevisionRangeSchema = z
|
|
31
|
+
.strictObject({
|
|
32
|
+
base: GitArgumentSchema,
|
|
33
|
+
head: GitArgumentSchema,
|
|
34
|
+
pathspecs: z
|
|
35
|
+
.array(GitArgumentSchema)
|
|
36
|
+
.max(MAX_PATHSPEC_COUNT)
|
|
37
|
+
.default(emptyPathspecs)
|
|
38
|
+
.transform((pathspecs) => Object.freeze(pathspecs)),
|
|
39
|
+
})
|
|
40
|
+
.readonly();
|
|
41
|
+
const ExactPatchContentSchema = z
|
|
42
|
+
.string()
|
|
43
|
+
.refine((value) => value.length > 0 && !value.includes('\0') && isValidUnicode(value) && Buffer.byteLength(value, 'utf8') <= MAX_AGENT_REQUEST_BYTES, { message: 'Patch content must be valid Unicode without NUL and fit within the request byte limit.' });
|
|
44
|
+
const ExactPatchTargetSchema = z
|
|
45
|
+
.discriminatedUnion('kind', [
|
|
46
|
+
z.strictObject({ kind: z.literal('repository') }),
|
|
47
|
+
z.strictObject({ kind: z.literal('worktree') }),
|
|
48
|
+
])
|
|
49
|
+
.readonly();
|
|
50
|
+
export const ExactPatchRequestSchema = z
|
|
51
|
+
.strictObject({
|
|
52
|
+
kind: z.literal(`${RENAME_MAP.kindPrefix}.review-request`),
|
|
53
|
+
schemaVersion: z.literal(1),
|
|
54
|
+
mode: z.literal('patch'),
|
|
55
|
+
patch: z
|
|
56
|
+
.strictObject({
|
|
57
|
+
content: ExactPatchContentSchema,
|
|
58
|
+
target: ExactPatchTargetSchema,
|
|
59
|
+
})
|
|
60
|
+
.readonly(),
|
|
61
|
+
})
|
|
62
|
+
.readonly();
|
|
63
|
+
const RevisionRequestSchema = z
|
|
64
|
+
.strictObject({
|
|
65
|
+
kind: z.literal(`${RENAME_MAP.kindPrefix}.review-request`),
|
|
66
|
+
schemaVersion: z.literal(1),
|
|
67
|
+
mode: z.literal('revisions'),
|
|
68
|
+
revisions: RevisionRangeSchema,
|
|
69
|
+
})
|
|
70
|
+
.readonly();
|
|
71
|
+
const UncommittedSourceSchema = z.enum(['staged', 'unstaged', 'worktree']);
|
|
72
|
+
const UncommittedRequestScopeSchema = z
|
|
73
|
+
.strictObject({
|
|
74
|
+
source: UncommittedSourceSchema,
|
|
75
|
+
base: GitArgumentSchema.optional(),
|
|
76
|
+
pathspecs: z
|
|
77
|
+
.array(GitArgumentSchema)
|
|
78
|
+
.max(MAX_PATHSPEC_COUNT)
|
|
79
|
+
.default(emptyPathspecs)
|
|
80
|
+
.transform((pathspecs) => Object.freeze(pathspecs)),
|
|
81
|
+
})
|
|
82
|
+
.superRefine((scope, ctx) => {
|
|
83
|
+
if (scope.source === 'unstaged' && scope.base !== undefined) {
|
|
84
|
+
ctx.addIssue({
|
|
85
|
+
code: 'custom',
|
|
86
|
+
path: ['base'],
|
|
87
|
+
message: '"base" cannot be combined with source "unstaged".',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
.readonly();
|
|
92
|
+
const UncommittedRequestSchema = z
|
|
93
|
+
.strictObject({
|
|
94
|
+
kind: z.literal(`${RENAME_MAP.kindPrefix}.review-request`),
|
|
95
|
+
schemaVersion: z.literal(1),
|
|
96
|
+
mode: z.literal('uncommitted'),
|
|
97
|
+
uncommitted: UncommittedRequestScopeSchema,
|
|
98
|
+
})
|
|
99
|
+
.readonly();
|
|
100
|
+
const FilesRequestScopeSchema = z
|
|
101
|
+
.strictObject({
|
|
102
|
+
paths: z
|
|
103
|
+
.array(GitArgumentSchema)
|
|
104
|
+
.min(1)
|
|
105
|
+
.max(MAX_PATHSPEC_COUNT)
|
|
106
|
+
.transform((paths) => Object.freeze(paths)),
|
|
107
|
+
})
|
|
108
|
+
.readonly();
|
|
109
|
+
const FilesRequestSchema = z
|
|
110
|
+
.strictObject({
|
|
111
|
+
kind: z.literal(`${RENAME_MAP.kindPrefix}.review-request`),
|
|
112
|
+
schemaVersion: z.literal(1),
|
|
113
|
+
mode: z.literal('files'),
|
|
114
|
+
files: FilesRequestScopeSchema,
|
|
115
|
+
})
|
|
116
|
+
.readonly();
|
|
117
|
+
export const AgentReviewRequestSchema = z
|
|
118
|
+
.discriminatedUnion('mode', [
|
|
119
|
+
RevisionRequestSchema,
|
|
120
|
+
ExactPatchRequestSchema,
|
|
121
|
+
UncommittedRequestSchema,
|
|
122
|
+
FilesRequestSchema,
|
|
123
|
+
])
|
|
124
|
+
.readonly();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { decodeBase64url } from './path-bytes.js';
|
|
3
|
+
import { RENAME_MAP } from './product-identity.js';
|
|
4
|
+
const CONTEXT_DOMAIN = Buffer.from(`${RENAME_MAP.slug}-anchor-context-v1`, 'utf8');
|
|
5
|
+
const UNIQUE_DOMAIN = Buffer.from(`${RENAME_MAP.slug}-anchor-unique-v1`, 'utf8');
|
|
6
|
+
const UTF8 = new TextEncoder();
|
|
7
|
+
function framedSha256(domain, fields) {
|
|
8
|
+
const hash = createHash('sha256');
|
|
9
|
+
hash.update(domain);
|
|
10
|
+
for (const field of fields) {
|
|
11
|
+
const length = Buffer.allocUnsafe(8);
|
|
12
|
+
length.writeBigUInt64BE(BigInt(field.byteLength));
|
|
13
|
+
hash.update(length);
|
|
14
|
+
hash.update(field);
|
|
15
|
+
}
|
|
16
|
+
return hash.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function encodeLine(line) {
|
|
19
|
+
return [UTF8.encode(String(line.line)), UTF8.encode(line.text)];
|
|
20
|
+
}
|
|
21
|
+
function sameLine(left, right) {
|
|
22
|
+
return left.line === right.line && left.text === right.text;
|
|
23
|
+
}
|
|
24
|
+
function sameLines(left, right) {
|
|
25
|
+
return left.length === right.length && left.every((line, index) => sameLine(line, right[index]));
|
|
26
|
+
}
|
|
27
|
+
function samePath(left, right) {
|
|
28
|
+
return left.bytesBase64url === right.bytesBase64url;
|
|
29
|
+
}
|
|
30
|
+
function contextHash(pathBytes, side, blobOid, line, endLine, context, selectedText) {
|
|
31
|
+
const fields = [
|
|
32
|
+
pathBytes,
|
|
33
|
+
UTF8.encode(side),
|
|
34
|
+
UTF8.encode(blobOid),
|
|
35
|
+
UTF8.encode(String(line)),
|
|
36
|
+
];
|
|
37
|
+
if (endLine !== undefined) {
|
|
38
|
+
fields.push(UTF8.encode('endLine'), UTF8.encode(String(endLine)), UTF8.encode('selectedText'), UTF8.encode(selectedText));
|
|
39
|
+
}
|
|
40
|
+
fields.push(UTF8.encode('before'));
|
|
41
|
+
for (const entry of context.before) {
|
|
42
|
+
fields.push(...encodeLine(entry));
|
|
43
|
+
}
|
|
44
|
+
fields.push(UTF8.encode('target'), ...encodeLine(context.target), UTF8.encode('after'));
|
|
45
|
+
for (const entry of context.after) {
|
|
46
|
+
fields.push(...encodeLine(entry));
|
|
47
|
+
}
|
|
48
|
+
return framedSha256(CONTEXT_DOMAIN, fields);
|
|
49
|
+
}
|
|
50
|
+
function duplicateKey(pathBytes, side, blobOid, line) {
|
|
51
|
+
return framedSha256(UNIQUE_DOMAIN, [
|
|
52
|
+
pathBytes,
|
|
53
|
+
UTF8.encode(side),
|
|
54
|
+
UTF8.encode(blobOid),
|
|
55
|
+
UTF8.encode(String(line)),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
export function buildDurableAnchor(input) {
|
|
59
|
+
if (!Number.isSafeInteger(input.line) || input.line < 1) {
|
|
60
|
+
throw new RangeError('Anchor line must be a positive safe integer.');
|
|
61
|
+
}
|
|
62
|
+
if (input.endLine !== undefined &&
|
|
63
|
+
(!Number.isSafeInteger(input.endLine) || input.endLine < input.line)) {
|
|
64
|
+
throw new RangeError('Anchor endLine must be a safe integer not less than line.');
|
|
65
|
+
}
|
|
66
|
+
const lines = input.text.split(/\r\n|\n/u);
|
|
67
|
+
const lastLine = input.endLine ?? input.line;
|
|
68
|
+
if (lastLine > lines.length) {
|
|
69
|
+
throw new RangeError('Anchor line is outside frozen text.');
|
|
70
|
+
}
|
|
71
|
+
const targetIndex = input.line - 1;
|
|
72
|
+
const endIndex = lastLine - 1;
|
|
73
|
+
const before = lines
|
|
74
|
+
.slice(Math.max(0, targetIndex - 3), targetIndex)
|
|
75
|
+
.map((text, index) => Object.freeze({ line: Math.max(1, input.line - 3) + index, text }));
|
|
76
|
+
const target = Object.freeze({ line: input.line, text: lines[targetIndex] });
|
|
77
|
+
const after = lines
|
|
78
|
+
.slice(endIndex + 1, endIndex + 4)
|
|
79
|
+
.map((text, index) => Object.freeze({ line: lastLine + index + 1, text }));
|
|
80
|
+
const context = Object.freeze({
|
|
81
|
+
before: Object.freeze(before),
|
|
82
|
+
target,
|
|
83
|
+
after: Object.freeze(after),
|
|
84
|
+
});
|
|
85
|
+
const selectedText = input.endLine === undefined ? target.text : lines.slice(targetIndex, endIndex + 1).join('\n');
|
|
86
|
+
const pathBytes = decodeBase64url(input.path.bytesBase64url);
|
|
87
|
+
const contextHashValue = contextHash(pathBytes, input.side, input.blobOid, input.line, input.endLine, context, selectedText);
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
version: 'durable-anchor-v1',
|
|
90
|
+
path: input.path,
|
|
91
|
+
safeDisplayPath: input.safeDisplayPath,
|
|
92
|
+
side: input.side,
|
|
93
|
+
line: input.line,
|
|
94
|
+
...(input.endLine === undefined ? {} : { endLine: input.endLine }),
|
|
95
|
+
blobOid: input.blobOid,
|
|
96
|
+
selectedText,
|
|
97
|
+
context,
|
|
98
|
+
contextHash: Object.freeze({ algorithm: 'sha256-v1', value: contextHashValue }),
|
|
99
|
+
uniqueKey: duplicateKey(pathBytes, input.side, input.blobOid, input.line),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
export function verifyDurableAnchor(recorded, current) {
|
|
103
|
+
if (current === undefined) {
|
|
104
|
+
return Object.freeze({ state: 'orphaned', reason: 'anchor-unavailable' });
|
|
105
|
+
}
|
|
106
|
+
const matches = recorded.version === current.version &&
|
|
107
|
+
samePath(recorded.path, current.path) &&
|
|
108
|
+
recorded.side === current.side &&
|
|
109
|
+
recorded.line === current.line &&
|
|
110
|
+
recorded.endLine === current.endLine &&
|
|
111
|
+
recorded.blobOid === current.blobOid &&
|
|
112
|
+
recorded.selectedText === current.selectedText &&
|
|
113
|
+
sameLines(recorded.context.before, current.context.before) &&
|
|
114
|
+
sameLine(recorded.context.target, current.context.target) &&
|
|
115
|
+
sameLines(recorded.context.after, current.context.after) &&
|
|
116
|
+
recorded.contextHash.algorithm === current.contextHash.algorithm &&
|
|
117
|
+
recorded.contextHash.value === current.contextHash.value &&
|
|
118
|
+
recorded.uniqueKey === current.uniqueKey;
|
|
119
|
+
return Object.freeze(matches
|
|
120
|
+
? { state: 'verified', reason: 'exact-match' }
|
|
121
|
+
: { state: 'stale', reason: 'anchor-mismatch' });
|
|
122
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { RENAME_MAP } from './product-identity.js';
|
|
3
|
+
const DOMAIN = Buffer.from(`${RENAME_MAP.slug}-comparison-key-v1`, 'utf8');
|
|
4
|
+
const RANGE_DOMAIN = Buffer.from(`${RENAME_MAP.slug}-range-review-key-v1`, 'utf8');
|
|
5
|
+
const UNCOMMITTED_DOMAIN = Buffer.from(`${RENAME_MAP.slug}-uncommitted-review-key-v1`, 'utf8');
|
|
6
|
+
const STANDALONE_DOMAIN = Buffer.from(`${RENAME_MAP.slug}-standalone-review-key-v1`, 'utf8');
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
function frame(value) {
|
|
9
|
+
const bytes = encoder.encode(value);
|
|
10
|
+
const length = Buffer.allocUnsafe(8);
|
|
11
|
+
length.writeBigUInt64BE(BigInt(bytes.byteLength));
|
|
12
|
+
return Buffer.concat([length, bytes]);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Computes the repository-local draft identity for an ordered pinned comparison.
|
|
16
|
+
* Labels, refs, merge bases, and paths deliberately never enter this namespace.
|
|
17
|
+
*/
|
|
18
|
+
export function comparisonKey(baseCommitOid, headCommitOid) {
|
|
19
|
+
const hash = createHash('sha256');
|
|
20
|
+
hash.update(DOMAIN);
|
|
21
|
+
hash.update(frame(baseCommitOid));
|
|
22
|
+
hash.update(frame(headCommitOid));
|
|
23
|
+
return hash.digest('hex');
|
|
24
|
+
}
|
|
25
|
+
export function rangeReviewKey(baseCommitOid, headCommitOid, pathspecs) {
|
|
26
|
+
const hash = createHash('sha256');
|
|
27
|
+
hash.update(RANGE_DOMAIN);
|
|
28
|
+
hash.update(frame(baseCommitOid));
|
|
29
|
+
hash.update(frame(headCommitOid));
|
|
30
|
+
for (const pathspec of pathspecs) {
|
|
31
|
+
hash.update(frame(pathspec));
|
|
32
|
+
}
|
|
33
|
+
return hash.digest('hex');
|
|
34
|
+
}
|
|
35
|
+
export function uncommittedReviewKey(sourceKind, baseCommitOid, firstSnapshotDigest) {
|
|
36
|
+
const hash = createHash('sha256');
|
|
37
|
+
hash.update(UNCOMMITTED_DOMAIN);
|
|
38
|
+
hash.update(frame(sourceKind));
|
|
39
|
+
hash.update(frame(baseCommitOid ?? ''));
|
|
40
|
+
hash.update(frame(firstSnapshotDigest));
|
|
41
|
+
return hash.digest('hex');
|
|
42
|
+
}
|
|
43
|
+
export function standaloneReviewKey(sortedOriginalArguments, firstSnapshotDigest) {
|
|
44
|
+
const hash = createHash('sha256');
|
|
45
|
+
hash.update(STANDALONE_DOMAIN);
|
|
46
|
+
for (const argument of sortedOriginalArguments) {
|
|
47
|
+
hash.update(frame(argument));
|
|
48
|
+
}
|
|
49
|
+
hash.update(frame(firstSnapshotDigest));
|
|
50
|
+
return hash.digest('hex');
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RENAME_MAP } from './product-identity.js';
|
|
2
|
+
export class LaunchError extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
recovery;
|
|
5
|
+
constructor(kind, message, options) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.name = 'LaunchError';
|
|
8
|
+
this.kind = kind;
|
|
9
|
+
this.recovery = options.recovery;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function isLaunchError(error) {
|
|
13
|
+
if (error instanceof LaunchError) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (!(error instanceof Error) ||
|
|
17
|
+
error.name !== 'LaunchError' ||
|
|
18
|
+
typeof error.kind !== 'string') {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const recovery = error.recovery;
|
|
22
|
+
return (recovery !== undefined &&
|
|
23
|
+
(recovery.kind === 'exit' || recovery.kind === 'return'));
|
|
24
|
+
}
|
|
25
|
+
export const FATAL_LAUNCH_MESSAGES = Object.freeze({
|
|
26
|
+
gitMissing: `Git is required but was not found. Install Git, then run ${RENAME_MAP.displayName} again.`,
|
|
27
|
+
gitUnsupported: `Git 2.43.0 or newer with the required machine protocols is required. Upgrade Git, then run ${RENAME_MAP.displayName} again.`,
|
|
28
|
+
notWorktree: `This directory is not inside a Git worktree. Run ${RENAME_MAP.displayName} from a Git worktree.`,
|
|
29
|
+
bareRepository: `Bare repositories are not supported. Run ${RENAME_MAP.displayName} from a non-bare Git worktree.`,
|
|
30
|
+
emptyRepository: `This repository has no commits yet. Create the first commit, then run ${RENAME_MAP.displayName} again.`,
|
|
31
|
+
});
|
|
32
|
+
export const HEAD_RECOVERY = Object.freeze({
|
|
33
|
+
kind: 'return',
|
|
34
|
+
role: 'head',
|
|
35
|
+
preserve: 'base',
|
|
36
|
+
focus: 'previous-row',
|
|
37
|
+
});
|
|
38
|
+
export const BASE_RECOVERY = Object.freeze({
|
|
39
|
+
kind: 'return',
|
|
40
|
+
role: 'base',
|
|
41
|
+
preserve: 'head',
|
|
42
|
+
focus: 'previous-row',
|
|
43
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const strictUtf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
2
|
+
const displayDecoder = new TextDecoder('utf-8');
|
|
3
|
+
const bytesByPath = new WeakMap();
|
|
4
|
+
export function encodeBase64url(bytes) {
|
|
5
|
+
let binary = '';
|
|
6
|
+
for (const byte of bytes) {
|
|
7
|
+
binary += String.fromCharCode(byte);
|
|
8
|
+
}
|
|
9
|
+
return btoa(binary)
|
|
10
|
+
.replaceAll('+', '-')
|
|
11
|
+
.replaceAll('/', '_')
|
|
12
|
+
.replace(/=+$/, '');
|
|
13
|
+
}
|
|
14
|
+
export function controlSafeDisplay(value) {
|
|
15
|
+
let display = '';
|
|
16
|
+
for (const character of value) {
|
|
17
|
+
const codePoint = character.codePointAt(0);
|
|
18
|
+
if (character === '\\') {
|
|
19
|
+
display += '\\\\';
|
|
20
|
+
}
|
|
21
|
+
else if (character === '\t') {
|
|
22
|
+
display += '\\t';
|
|
23
|
+
}
|
|
24
|
+
else if (character === '\n') {
|
|
25
|
+
display += '\\n';
|
|
26
|
+
}
|
|
27
|
+
else if (character === '\r') {
|
|
28
|
+
display += '\\r';
|
|
29
|
+
}
|
|
30
|
+
else if (codePoint < 0x20 ||
|
|
31
|
+
(codePoint >= 0x7f && codePoint <= 0x9f)) {
|
|
32
|
+
display += `\\u{${codePoint.toString(16).padStart(2, '0')}}`;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
display += character;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return display;
|
|
39
|
+
}
|
|
40
|
+
export function createExactPath(bytes) {
|
|
41
|
+
const ownedBytes = new Uint8Array(bytes);
|
|
42
|
+
let utf8;
|
|
43
|
+
try {
|
|
44
|
+
utf8 = strictUtf8Decoder.decode(ownedBytes);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
utf8 = undefined;
|
|
48
|
+
}
|
|
49
|
+
const path = Object.freeze({
|
|
50
|
+
bytesBase64url: encodeBase64url(ownedBytes),
|
|
51
|
+
display: controlSafeDisplay(displayDecoder.decode(ownedBytes)),
|
|
52
|
+
...(utf8 === undefined ? {} : { utf8 }),
|
|
53
|
+
});
|
|
54
|
+
bytesByPath.set(path, ownedBytes);
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
export function orderByteSequences(left, right) {
|
|
58
|
+
const sharedLength = Math.min(left.length, right.length);
|
|
59
|
+
for (let index = 0; index < sharedLength; index += 1) {
|
|
60
|
+
const difference = left[index] - right[index];
|
|
61
|
+
if (difference !== 0)
|
|
62
|
+
return difference;
|
|
63
|
+
}
|
|
64
|
+
return left.length - right.length;
|
|
65
|
+
}
|
|
66
|
+
export function orderExactPaths(left, right) {
|
|
67
|
+
const leftBytes = bytesByPath.get(left) ?? decodeBase64url(left.bytesBase64url);
|
|
68
|
+
const rightBytes = bytesByPath.get(right) ?? decodeBase64url(right.bytesBase64url);
|
|
69
|
+
return orderByteSequences(leftBytes, rightBytes);
|
|
70
|
+
}
|
|
71
|
+
export function decodeBase64url(value) {
|
|
72
|
+
const base64 = value
|
|
73
|
+
.replaceAll('-', '+')
|
|
74
|
+
.replaceAll('_', '/')
|
|
75
|
+
.padEnd(Math.ceil(value.length / 4) * 4, '=');
|
|
76
|
+
const binary = atob(base64);
|
|
77
|
+
const bytes = new Uint8Array(binary.length);
|
|
78
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
79
|
+
bytes[index] = binary.charCodeAt(index);
|
|
80
|
+
}
|
|
81
|
+
return bytes;
|
|
82
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Path traversal segments, slashes, NUL, and other ASCII control characters would
|
|
2
|
+
// land directly inside file-path segments (repoStateDir) if left unvalidated.
|
|
3
|
+
const UNSAFE_NAME_PATTERN = /[/\\\0-\x1f]|\.\./;
|
|
4
|
+
// npm-unscoped-name-safe, which is also CSS-class-safe and directory-segment-safe.
|
|
5
|
+
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
6
|
+
const ENV_PREFIX_PATTERN = /^[A-Z][A-Z0-9_]*_$/;
|
|
7
|
+
const NPM_SCOPE = '@shipwithai/';
|
|
8
|
+
const NPM_UNSCOPED_NAME_MAX_LENGTH = 214;
|
|
9
|
+
/**
|
|
10
|
+
* Capitalizes each hyphen-separated slug segment and concatenates them. Shared by
|
|
11
|
+
* `xdgStateDirTitleCase` and `tsIdentifierPrefix` so both forms are derived by the
|
|
12
|
+
* exact same rule and can never drift into two different capitalization schemes.
|
|
13
|
+
*/
|
|
14
|
+
function capitalizeSlugSegments(slug) {
|
|
15
|
+
return slug
|
|
16
|
+
.split('-')
|
|
17
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
18
|
+
.join('');
|
|
19
|
+
}
|
|
20
|
+
export function deriveProductIdentity(name) {
|
|
21
|
+
if (UNSAFE_NAME_PATTERN.test(name)) {
|
|
22
|
+
throw new Error(`deriveProductIdentity: name ${JSON.stringify(name)} contains unsafe path-shaped characters ` +
|
|
23
|
+
'(slash, NUL/control character, or "..") and cannot be used to derive a slug/path');
|
|
24
|
+
}
|
|
25
|
+
const slug = name
|
|
26
|
+
.trim()
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '');
|
|
30
|
+
if (slug === '') {
|
|
31
|
+
throw new Error(`deriveProductIdentity: name ${JSON.stringify(name)} produces an empty slug after slugification`);
|
|
32
|
+
}
|
|
33
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
34
|
+
throw new Error(`deriveProductIdentity: derived slug ${JSON.stringify(slug)} is not npm/CSS/path-safe ` +
|
|
35
|
+
`(must match ${SLUG_PATTERN})`);
|
|
36
|
+
}
|
|
37
|
+
const npmPackage = `${NPM_SCOPE}${slug}`;
|
|
38
|
+
if (npmPackage.length > NPM_UNSCOPED_NAME_MAX_LENGTH) {
|
|
39
|
+
throw new Error(`deriveProductIdentity: npm package name ${JSON.stringify(npmPackage)} exceeds npm's ` +
|
|
40
|
+
`${NPM_UNSCOPED_NAME_MAX_LENGTH}-character unscoped-name budget`);
|
|
41
|
+
}
|
|
42
|
+
const envPrefix = `${slug.toUpperCase().replace(/-/g, '_')}_`;
|
|
43
|
+
if (!ENV_PREFIX_PATTERN.test(envPrefix)) {
|
|
44
|
+
throw new Error(`deriveProductIdentity: derived envPrefix ${JSON.stringify(envPrefix)} is not a valid env var ` +
|
|
45
|
+
`identifier (must match ${ENV_PREFIX_PATTERN})`);
|
|
46
|
+
}
|
|
47
|
+
const titleCase = capitalizeSlugSegments(slug);
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
displayName: name.trim(),
|
|
50
|
+
slug,
|
|
51
|
+
npmPackage,
|
|
52
|
+
binCommand: slug,
|
|
53
|
+
repoStateDir: `.${slug}/`,
|
|
54
|
+
xdgStateDirLower: slug,
|
|
55
|
+
xdgStateDirTitleCase: titleCase,
|
|
56
|
+
kindPrefix: slug,
|
|
57
|
+
envPrefix,
|
|
58
|
+
cssPrefix: `${slug}-`,
|
|
59
|
+
tsIdentifierPrefix: titleCase,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// The display name supplied by the human at Task 1's blocking checkpoint (resolved
|
|
63
|
+
// 2026-11-02): "Loopnote". Every later plan in this phase imports this constant —
|
|
64
|
+
// not process.env, not a config file — so no form of the new name is re-derived or
|
|
65
|
+
// hardcoded independently.
|
|
66
|
+
export const RENAME_MAP = deriveProductIdentity('Loopnote');
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const DIRTY_ROW_LABEL = 'Dirty — committed HEAD only';
|
|
2
|
+
export const DIRTY_EXPLANATION = 'This worktree has uncommitted changes. This comparison reviews its committed HEAD only — use --worktree, --staged, or --unstaged to review the uncommitted changes directly.';
|
|
3
|
+
export const PENDING_ROW_LABEL = 'Checking worktree state…';
|
|
4
|
+
export const UNAVAILABLE_WORKTREE_REASON = 'Unavailable — this registered worktree cannot be resolved. Choose another entry or repair it with Git.';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { DraftMutationSchema, ReviewDraftV1Schema, } from '../contracts/draft.js';
|
|
2
|
+
export function applyDraftMutation(current, input, timestamp) {
|
|
3
|
+
const mutation = DraftMutationSchema.parse(input);
|
|
4
|
+
switch (mutation.type) {
|
|
5
|
+
case 'addComment': {
|
|
6
|
+
if (current.comments.some((comment) => comment.anchor.uniqueKey === mutation.anchor.uniqueKey)) {
|
|
7
|
+
return Object.freeze({ kind: 'invalidTarget' });
|
|
8
|
+
}
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
kind: 'applied',
|
|
11
|
+
draft: ReviewDraftV1Schema.parse({
|
|
12
|
+
...current,
|
|
13
|
+
comments: [
|
|
14
|
+
...current.comments,
|
|
15
|
+
{
|
|
16
|
+
id: mutation.commentId,
|
|
17
|
+
state: 'open',
|
|
18
|
+
body: mutation.body,
|
|
19
|
+
anchor: mutation.anchor,
|
|
20
|
+
createdAt: timestamp,
|
|
21
|
+
updatedAt: timestamp,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
case 'setSummary':
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
kind: 'applied',
|
|
30
|
+
draft: ReviewDraftV1Schema.parse({ ...current, summary: mutation.markdown }),
|
|
31
|
+
});
|
|
32
|
+
default:
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
const comment = current.comments.find((candidate) => candidate.id === mutation.commentId);
|
|
36
|
+
if (comment === undefined) {
|
|
37
|
+
return Object.freeze({ kind: 'invalidTarget' });
|
|
38
|
+
}
|
|
39
|
+
switch (mutation.type) {
|
|
40
|
+
case 'editComment':
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
kind: 'applied',
|
|
43
|
+
draft: ReviewDraftV1Schema.parse({
|
|
44
|
+
...current,
|
|
45
|
+
comments: current.comments.map((candidate) => candidate.id === comment.id ? { ...candidate, body: mutation.body, updatedAt: timestamp } : candidate),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
case 'deleteComment':
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
kind: 'applied',
|
|
51
|
+
draft: ReviewDraftV1Schema.parse({
|
|
52
|
+
...current,
|
|
53
|
+
comments: current.comments.filter((candidate) => candidate.id !== comment.id),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
case 'resolveComment':
|
|
57
|
+
if (comment.state !== 'open') {
|
|
58
|
+
return Object.freeze({ kind: 'illegalTransition' });
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
kind: 'applied',
|
|
62
|
+
draft: ReviewDraftV1Schema.parse({
|
|
63
|
+
...current,
|
|
64
|
+
comments: current.comments.map((candidate) => candidate.id === comment.id
|
|
65
|
+
? { ...candidate, state: 'resolved', updatedAt: timestamp, resolvedAt: timestamp }
|
|
66
|
+
: candidate),
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
case 'reopenComment':
|
|
70
|
+
if (comment.state !== 'resolved') {
|
|
71
|
+
return Object.freeze({ kind: 'illegalTransition' });
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
kind: 'applied',
|
|
75
|
+
draft: ReviewDraftV1Schema.parse({
|
|
76
|
+
...current,
|
|
77
|
+
comments: current.comments.map((candidate) => {
|
|
78
|
+
if (candidate.id !== comment.id || candidate.state !== 'resolved') {
|
|
79
|
+
return candidate;
|
|
80
|
+
}
|
|
81
|
+
const { resolvedAt: _resolvedAt, ...reopened } = candidate;
|
|
82
|
+
return { ...reopened, state: 'open', updatedAt: timestamp };
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|