@shrkcrft/generator 0.1.0-alpha.3 → 0.1.0-alpha.30
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/dist/dry-run.d.ts.map +1 -1
- package/dist/dry-run.js +28 -8
- package/dist/file-change.d.ts +1 -1
- package/dist/file-change.d.ts.map +1 -1
- package/dist/folder-apply.d.ts.map +1 -1
- package/dist/folder-apply.js +46 -2
- package/dist/folder-safety.d.ts.map +1 -1
- package/dist/folder-safety.js +11 -0
- package/dist/generator-engine.d.ts.map +1 -1
- package/dist/generator-engine.js +26 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/operations.d.ts +156 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +5 -0
- package/dist/package-delegate-plan.d.ts +54 -0
- package/dist/package-delegate-plan.d.ts.map +1 -0
- package/dist/package-delegate-plan.js +253 -0
- package/dist/planned-change.d.ts +2 -123
- package/dist/planned-change.d.ts.map +1 -1
- package/dist/planned-change.js +104 -0
- package/dist/saved-plan.d.ts +28 -2
- package/dist/saved-plan.d.ts.map +1 -1
- package/dist/saved-plan.js +20 -0
- package/dist/synthetic-plan.d.ts.map +1 -1
- package/dist/synthetic-plan.js +89 -18
- package/package.json +6 -6
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package a delegate worker's raw edit into a SIGNED-ready synthetic plan.
|
|
3
|
+
*
|
|
4
|
+
* This is the deterministic chokepoint between a stochastic worker and the
|
|
5
|
+
* write path. It:
|
|
6
|
+
* 1. drops ops whose `kind` is not in the recipe's `allowedOps` (reported,
|
|
7
|
+
* never applied);
|
|
8
|
+
* 2. validates every remaining op's fields against the real operation union —
|
|
9
|
+
* a malformed op refuses the whole package (the worker must retry);
|
|
10
|
+
* 3. evaluates the ops against the live file system via the SAME
|
|
11
|
+
* `evaluateSavedPlanInPlace` apply uses, so anchor-not-found / ambiguous /
|
|
12
|
+
* file-missing all surface as conflicts BEFORE anything is signed;
|
|
13
|
+
* 4. builds an `ISavedPlan` (templateId `__delegate/<recipe>`) the caller
|
|
14
|
+
* signs + applies through the unmodified apply pipeline.
|
|
15
|
+
*
|
|
16
|
+
* No model, no network. The raw-op input type is declared locally so the
|
|
17
|
+
* generator layer carries no dependency on `@shrkcrft/ai`.
|
|
18
|
+
*/
|
|
19
|
+
import { err, ok, AppErrorImpl, ERROR_CODES } from '@shrkcrft/core';
|
|
20
|
+
import { buildSavedPlan } from "./saved-plan.js";
|
|
21
|
+
import { evaluateSavedPlanInPlace } from "./synthetic-plan.js";
|
|
22
|
+
export const DELEGATE_TEMPLATE_PREFIX = '__delegate/';
|
|
23
|
+
export function packageDelegatePlan(input) {
|
|
24
|
+
const allowed = new Set(input.allowedOps);
|
|
25
|
+
const droppedOps = [];
|
|
26
|
+
const expectedChanges = [];
|
|
27
|
+
for (const raw of input.ops) {
|
|
28
|
+
const kind = raw.operation.kind;
|
|
29
|
+
if (!allowed.has(kind)) {
|
|
30
|
+
droppedOps.push({
|
|
31
|
+
kind,
|
|
32
|
+
targetPath: raw.targetPath,
|
|
33
|
+
reason: `op kind "${kind}" is not in the recipe's allowedOps`,
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const coerced = coerceOperation(raw.operation);
|
|
38
|
+
if (!coerced.ok) {
|
|
39
|
+
return err(new AppErrorImpl(ERROR_CODES.INVALID_INPUT, `delegate op for ${raw.targetPath}: ${coerced.error}`));
|
|
40
|
+
}
|
|
41
|
+
expectedChanges.push({
|
|
42
|
+
type: 'pending',
|
|
43
|
+
relativePath: raw.targetPath,
|
|
44
|
+
sizeBytes: 0,
|
|
45
|
+
operation: coerced.value,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (expectedChanges.length === 0) {
|
|
49
|
+
return err(new AppErrorImpl(ERROR_CODES.INVALID_INPUT, droppedOps.length > 0
|
|
50
|
+
? `delegate edit had ${droppedOps.length} op(s), all of disallowed kinds`
|
|
51
|
+
: 'delegate edit contained no operations'));
|
|
52
|
+
}
|
|
53
|
+
// Evaluate against the live FS through the SAME path apply uses — conflicts
|
|
54
|
+
// (ambiguous anchor, file missing, replace 0/>N) surface here, before signing.
|
|
55
|
+
const draft = {
|
|
56
|
+
schema: 'sharkcraft.plan/v2',
|
|
57
|
+
templateId: `${DELEGATE_TEMPLATE_PREFIX}${input.recipeId}`,
|
|
58
|
+
variables: {},
|
|
59
|
+
projectRoot: input.projectRoot,
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
expectedChanges,
|
|
62
|
+
};
|
|
63
|
+
const generation = evaluateSavedPlanInPlace(draft, input.projectRoot);
|
|
64
|
+
if (generation.hasConflicts) {
|
|
65
|
+
return ok({ generation, droppedOps, ready: false });
|
|
66
|
+
}
|
|
67
|
+
const plan = buildSavedPlan({
|
|
68
|
+
templateId: draft.templateId,
|
|
69
|
+
variables: {},
|
|
70
|
+
projectRoot: input.projectRoot,
|
|
71
|
+
plan: generation,
|
|
72
|
+
});
|
|
73
|
+
return ok({ plan, generation, droppedOps, ready: true });
|
|
74
|
+
}
|
|
75
|
+
function coerceOperation(raw) {
|
|
76
|
+
const k = raw.kind;
|
|
77
|
+
switch (k) {
|
|
78
|
+
case 'create': {
|
|
79
|
+
const content = reqStr(raw, 'content');
|
|
80
|
+
if (content === null)
|
|
81
|
+
return fail('content');
|
|
82
|
+
const op = { kind: 'create', content };
|
|
83
|
+
addOptStr(op, raw, 'description');
|
|
84
|
+
return { ok: true, value: op };
|
|
85
|
+
}
|
|
86
|
+
case 'append': {
|
|
87
|
+
const snippet = reqStr(raw, 'snippet');
|
|
88
|
+
if (snippet === null)
|
|
89
|
+
return fail('snippet');
|
|
90
|
+
const op = { kind: 'append', snippet };
|
|
91
|
+
addOptStr(op, raw, 'ifMissing');
|
|
92
|
+
addOptStr(op, raw, 'description');
|
|
93
|
+
return { ok: true, value: op };
|
|
94
|
+
}
|
|
95
|
+
case 'insert-after':
|
|
96
|
+
case 'insert-before': {
|
|
97
|
+
const anchor = reqStr(raw, 'anchor');
|
|
98
|
+
const snippet = reqStr(raw, 'snippet');
|
|
99
|
+
if (anchor === null)
|
|
100
|
+
return fail('anchor');
|
|
101
|
+
if (snippet === null)
|
|
102
|
+
return fail('snippet');
|
|
103
|
+
const op = { kind: k, anchor, snippet };
|
|
104
|
+
addOptStr(op, raw, 'ifMissing');
|
|
105
|
+
addOptStr(op, raw, 'description');
|
|
106
|
+
return { ok: true, value: op };
|
|
107
|
+
}
|
|
108
|
+
case 'replace': {
|
|
109
|
+
const find = reqStr(raw, 'find');
|
|
110
|
+
const replaceWith = reqStrAllowEmpty(raw, 'replaceWith');
|
|
111
|
+
if (find === null)
|
|
112
|
+
return fail('find');
|
|
113
|
+
if (replaceWith === null)
|
|
114
|
+
return fail('replaceWith');
|
|
115
|
+
const op = { kind: 'replace', find, replaceWith };
|
|
116
|
+
const expectMatches = optNum(raw, 'expectMatches');
|
|
117
|
+
if (expectMatches !== undefined)
|
|
118
|
+
op.expectMatches = expectMatches;
|
|
119
|
+
addOptStr(op, raw, 'description');
|
|
120
|
+
return { ok: true, value: op };
|
|
121
|
+
}
|
|
122
|
+
case 'export': {
|
|
123
|
+
const from = reqStr(raw, 'from');
|
|
124
|
+
if (from === null)
|
|
125
|
+
return fail('from');
|
|
126
|
+
const op = { kind: 'export', from };
|
|
127
|
+
const symbols = optStrArr(raw, 'symbols');
|
|
128
|
+
if (symbols)
|
|
129
|
+
op.symbols = symbols;
|
|
130
|
+
addOptStr(op, raw, 'ifMissing');
|
|
131
|
+
addOptStr(op, raw, 'description');
|
|
132
|
+
return { ok: true, value: op };
|
|
133
|
+
}
|
|
134
|
+
case 'ensure-import': {
|
|
135
|
+
const from = reqStr(raw, 'from');
|
|
136
|
+
if (from === null)
|
|
137
|
+
return fail('from');
|
|
138
|
+
const op = { kind: 'ensure-import', from };
|
|
139
|
+
const symbols = optStrArr(raw, 'symbols');
|
|
140
|
+
if (symbols)
|
|
141
|
+
op.symbols = symbols;
|
|
142
|
+
const typeOnly = optBool(raw, 'typeOnly');
|
|
143
|
+
if (typeOnly !== undefined)
|
|
144
|
+
op.typeOnly = typeOnly;
|
|
145
|
+
addOptStr(op, raw, 'defaultBinding');
|
|
146
|
+
addOptStr(op, raw, 'namespaceBinding');
|
|
147
|
+
addOptStr(op, raw, 'description');
|
|
148
|
+
return { ok: true, value: op };
|
|
149
|
+
}
|
|
150
|
+
case 'insert-enum-entry': {
|
|
151
|
+
const enumName = reqStr(raw, 'enumName');
|
|
152
|
+
const entryName = reqStr(raw, 'entryName');
|
|
153
|
+
const entryValue = reqStr(raw, 'entryValue');
|
|
154
|
+
if (enumName === null)
|
|
155
|
+
return fail('enumName');
|
|
156
|
+
if (entryName === null)
|
|
157
|
+
return fail('entryName');
|
|
158
|
+
if (entryValue === null)
|
|
159
|
+
return fail('entryValue');
|
|
160
|
+
const op = { kind: 'insert-enum-entry', enumName, entryName, entryValue };
|
|
161
|
+
addOptStr(op, raw, 'description');
|
|
162
|
+
return { ok: true, value: op };
|
|
163
|
+
}
|
|
164
|
+
case 'insert-object-entry': {
|
|
165
|
+
const objectName = reqStr(raw, 'objectName');
|
|
166
|
+
const entryKey = reqStr(raw, 'entryKey');
|
|
167
|
+
const entryValue = reqStr(raw, 'entryValue');
|
|
168
|
+
if (objectName === null)
|
|
169
|
+
return fail('objectName');
|
|
170
|
+
if (entryKey === null)
|
|
171
|
+
return fail('entryKey');
|
|
172
|
+
if (entryValue === null)
|
|
173
|
+
return fail('entryValue');
|
|
174
|
+
const op = { kind: 'insert-object-entry', objectName, entryKey, entryValue };
|
|
175
|
+
const shorthand = optBool(raw, 'shorthand');
|
|
176
|
+
if (shorthand !== undefined)
|
|
177
|
+
op.shorthand = shorthand;
|
|
178
|
+
addOptStr(op, raw, 'description');
|
|
179
|
+
return { ok: true, value: op };
|
|
180
|
+
}
|
|
181
|
+
case 'insert-array-entry': {
|
|
182
|
+
const arrayName = reqStr(raw, 'arrayName');
|
|
183
|
+
const entryValue = reqStr(raw, 'entryValue');
|
|
184
|
+
if (arrayName === null)
|
|
185
|
+
return fail('arrayName');
|
|
186
|
+
if (entryValue === null)
|
|
187
|
+
return fail('entryValue');
|
|
188
|
+
const op = { kind: 'insert-array-entry', arrayName, entryValue };
|
|
189
|
+
addOptStr(op, raw, 'ifMissing');
|
|
190
|
+
addOptStr(op, raw, 'description');
|
|
191
|
+
return { ok: true, value: op };
|
|
192
|
+
}
|
|
193
|
+
case 'insert-before-closing-brace': {
|
|
194
|
+
const containerName = reqStr(raw, 'containerName');
|
|
195
|
+
const snippet = reqStr(raw, 'snippet');
|
|
196
|
+
if (containerName === null)
|
|
197
|
+
return fail('containerName');
|
|
198
|
+
if (snippet === null)
|
|
199
|
+
return fail('snippet');
|
|
200
|
+
const op = { kind: 'insert-before-closing-brace', containerName, snippet };
|
|
201
|
+
addOptStr(op, raw, 'ifMissing');
|
|
202
|
+
addOptStr(op, raw, 'description');
|
|
203
|
+
return { ok: true, value: op };
|
|
204
|
+
}
|
|
205
|
+
case 'insert-between-anchors': {
|
|
206
|
+
const beginAnchor = reqStr(raw, 'beginAnchor');
|
|
207
|
+
const endAnchor = reqStr(raw, 'endAnchor');
|
|
208
|
+
const snippet = reqStr(raw, 'snippet');
|
|
209
|
+
if (beginAnchor === null)
|
|
210
|
+
return fail('beginAnchor');
|
|
211
|
+
if (endAnchor === null)
|
|
212
|
+
return fail('endAnchor');
|
|
213
|
+
if (snippet === null)
|
|
214
|
+
return fail('snippet');
|
|
215
|
+
const op = { kind: 'insert-between-anchors', beginAnchor, endAnchor, snippet };
|
|
216
|
+
addOptStr(op, raw, 'ifMissing');
|
|
217
|
+
addOptStr(op, raw, 'description');
|
|
218
|
+
return { ok: true, value: op };
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return { ok: false, error: `unsupported op kind "${k}"` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function fail(field) {
|
|
225
|
+
return { ok: false, error: `"${field}" must be a non-empty string` };
|
|
226
|
+
}
|
|
227
|
+
function reqStr(raw, field) {
|
|
228
|
+
const v = raw[field];
|
|
229
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
230
|
+
}
|
|
231
|
+
function reqStrAllowEmpty(raw, field) {
|
|
232
|
+
const v = raw[field];
|
|
233
|
+
return typeof v === 'string' ? v : null;
|
|
234
|
+
}
|
|
235
|
+
function addOptStr(target, raw, field) {
|
|
236
|
+
const v = raw[field];
|
|
237
|
+
if (typeof v === 'string')
|
|
238
|
+
target[field] = v;
|
|
239
|
+
}
|
|
240
|
+
function optStrArr(raw, field) {
|
|
241
|
+
const v = raw[field];
|
|
242
|
+
if (Array.isArray(v) && v.every((s) => typeof s === 'string'))
|
|
243
|
+
return v;
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
function optBool(raw, field) {
|
|
247
|
+
const v = raw[field];
|
|
248
|
+
return typeof v === 'boolean' ? v : undefined;
|
|
249
|
+
}
|
|
250
|
+
function optNum(raw, field) {
|
|
251
|
+
const v = raw[field];
|
|
252
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
253
|
+
}
|
package/dist/planned-change.d.ts
CHANGED
|
@@ -21,128 +21,8 @@
|
|
|
21
21
|
* - MCP stays read-only — this module is pure logic.
|
|
22
22
|
*/
|
|
23
23
|
import { FileChangeType, type IFileChange } from './file-change.js';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
kind: 'create';
|
|
27
|
-
content: string;
|
|
28
|
-
description?: string;
|
|
29
|
-
}
|
|
30
|
-
interface IAppendOperation {
|
|
31
|
-
kind: 'append';
|
|
32
|
-
/**
|
|
33
|
-
* The snippet to append at the end of the file. The engine adds a single
|
|
34
|
-
* `\n` separator between the existing trailing content and the snippet if
|
|
35
|
-
* the existing file does not already end with a newline.
|
|
36
|
-
*/
|
|
37
|
-
snippet: string;
|
|
38
|
-
/**
|
|
39
|
-
* Optional idempotency marker. If the existing file already contains this
|
|
40
|
-
* string anywhere, the operation is skipped (already applied).
|
|
41
|
-
*/
|
|
42
|
-
ifMissing?: string;
|
|
43
|
-
description?: string;
|
|
44
|
-
}
|
|
45
|
-
interface IInsertAfterOperation {
|
|
46
|
-
kind: 'insert-after';
|
|
47
|
-
/** Literal substring that must appear exactly once in the file. */
|
|
48
|
-
anchor: string;
|
|
49
|
-
/** The snippet to insert immediately after `anchor`. */
|
|
50
|
-
snippet: string;
|
|
51
|
-
/** Idempotency check; default = `snippet`. */
|
|
52
|
-
ifMissing?: string;
|
|
53
|
-
description?: string;
|
|
54
|
-
}
|
|
55
|
-
interface IInsertBeforeOperation {
|
|
56
|
-
kind: 'insert-before';
|
|
57
|
-
anchor: string;
|
|
58
|
-
snippet: string;
|
|
59
|
-
ifMissing?: string;
|
|
60
|
-
description?: string;
|
|
61
|
-
}
|
|
62
|
-
interface IReplaceOperation {
|
|
63
|
-
kind: 'replace';
|
|
64
|
-
/** Literal substring to find. */
|
|
65
|
-
find: string;
|
|
66
|
-
/** Replacement text. */
|
|
67
|
-
replaceWith: string;
|
|
68
|
-
/**
|
|
69
|
-
* If provided, the engine requires exactly this many matches; otherwise the
|
|
70
|
-
* default is exactly 1. Multiple matches without an explicit `expectMatches`
|
|
71
|
-
* is a conflict (ambiguous replace).
|
|
72
|
-
*/
|
|
73
|
-
expectMatches?: number;
|
|
74
|
-
description?: string;
|
|
75
|
-
}
|
|
76
|
-
interface IExportOperation {
|
|
77
|
-
kind: 'export';
|
|
78
|
-
/** The symbol/path to re-export. */
|
|
79
|
-
from: string;
|
|
80
|
-
/** Optional named symbols. When omitted, emits `export * from`. */
|
|
81
|
-
symbols?: readonly string[];
|
|
82
|
-
/** Idempotency check; default = computed export line. */
|
|
83
|
-
ifMissing?: string;
|
|
84
|
-
description?: string;
|
|
85
|
-
}
|
|
86
|
-
interface IEnsureImportOperation {
|
|
87
|
-
kind: 'ensure-import';
|
|
88
|
-
/** Module specifier, e.g. `'./events'` or `'@app/plugin-core'`. */
|
|
89
|
-
from: string;
|
|
90
|
-
/**
|
|
91
|
-
* Named symbols to ensure. The op is a NO-OP for symbols already imported
|
|
92
|
-
* from `from`. Default import (`type: 'default'`) and namespace import
|
|
93
|
-
* (`type: 'namespace'`) are also supported via dedicated fields below.
|
|
94
|
-
*/
|
|
95
|
-
symbols?: readonly string[];
|
|
96
|
-
/** Treat the import as `import type { ... }` instead of value import. */
|
|
97
|
-
typeOnly?: boolean;
|
|
98
|
-
/** Default import binding (e.g. `import Foo from 'foo'`). */
|
|
99
|
-
defaultBinding?: string;
|
|
100
|
-
/** Namespace import binding (e.g. `import * as foo from 'foo'`). */
|
|
101
|
-
namespaceBinding?: string;
|
|
102
|
-
description?: string;
|
|
103
|
-
}
|
|
104
|
-
interface IInsertEnumEntryOperation {
|
|
105
|
-
kind: 'insert-enum-entry';
|
|
106
|
-
/** Enum identifier, e.g. `PaginationEventType`. */
|
|
107
|
-
enumName: string;
|
|
108
|
-
/** Identifier of the new enum member, e.g. `ITEM_SELECTED`. */
|
|
109
|
-
entryName: string;
|
|
110
|
-
/** Literal string value to assign, e.g. `'pagination.itemSelected'`. */
|
|
111
|
-
entryValue: string;
|
|
112
|
-
description?: string;
|
|
113
|
-
}
|
|
114
|
-
interface IInsertObjectEntryOperation {
|
|
115
|
-
kind: 'insert-object-entry';
|
|
116
|
-
/** Object identifier, e.g. `FEATURE_KEYS`. */
|
|
117
|
-
objectName: string;
|
|
118
|
-
/** Key to add. */
|
|
119
|
-
entryKey: string;
|
|
120
|
-
/** Value literal (already source-formatted). */
|
|
121
|
-
entryValue: string;
|
|
122
|
-
/** When `true`, allow shorthand entries; default `false`. */
|
|
123
|
-
shorthand?: boolean;
|
|
124
|
-
description?: string;
|
|
125
|
-
}
|
|
126
|
-
interface IInsertBeforeClosingBraceOperation {
|
|
127
|
-
kind: 'insert-before-closing-brace';
|
|
128
|
-
/** Container identifier, e.g. an interface/class/enum name. */
|
|
129
|
-
containerName: string;
|
|
130
|
-
/** Snippet inserted immediately before the matching closing brace. */
|
|
131
|
-
snippet: string;
|
|
132
|
-
/** Optional idempotency marker (default = `snippet`). */
|
|
133
|
-
ifMissing?: string;
|
|
134
|
-
description?: string;
|
|
135
|
-
}
|
|
136
|
-
interface IInsertBetweenAnchorsOperation {
|
|
137
|
-
kind: 'insert-between-anchors';
|
|
138
|
-
beginAnchor: string;
|
|
139
|
-
endAnchor: string;
|
|
140
|
-
snippet: string;
|
|
141
|
-
/** Optional idempotency marker (default = `snippet`). */
|
|
142
|
-
ifMissing?: string;
|
|
143
|
-
description?: string;
|
|
144
|
-
}
|
|
145
|
-
export type IPlannedOperation = ICreateOperation | IAppendOperation | IInsertAfterOperation | IInsertBeforeOperation | IReplaceOperation | IExportOperation | IEnsureImportOperation | IInsertEnumEntryOperation | IInsertObjectEntryOperation | IInsertBeforeClosingBraceOperation | IInsertBetweenAnchorsOperation;
|
|
24
|
+
import { type IPlannedOperation } from './operations.js';
|
|
25
|
+
export * from './operations.js';
|
|
146
26
|
export interface IPlannedChange {
|
|
147
27
|
/** Final file path relative to project root. */
|
|
148
28
|
targetPath: string;
|
|
@@ -163,5 +43,4 @@ export declare function evaluatePlannedChange(input: IEvaluateInput): IFileChang
|
|
|
163
43
|
* update-like in the v2 sense.
|
|
164
44
|
*/
|
|
165
45
|
export declare function isUpdateLike(type: FileChangeType): boolean;
|
|
166
|
-
export {};
|
|
167
46
|
//# sourceMappingURL=planned-change.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"planned-change.d.ts","sourceRoot":"","sources":["../src/planned-change.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"planned-change.d.ts","sourceRoot":"","sources":["../src/planned-change.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EACL,KAAK,iBAAiB,EAOvB,MAAM,iBAAiB,CAAC;AAMzB,cAAc,iBAAiB,CAAC;AAIhC,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,SAAS,EAAE,iBAAiB,CAAC;CAC9B;AAMD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,cAAc,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CA8BxE;AA8SD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAQ1D"}
|
package/dist/planned-change.js
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* - MCP stays read-only — this module is pure logic.
|
|
22
22
|
*/
|
|
23
23
|
import { FileChangeType } from "./file-change.js";
|
|
24
|
+
// The operation model lives in ./operations.ts. Re-export it from here — this
|
|
25
|
+
// module is the public face of the planned-change pipeline, and its consumers
|
|
26
|
+
// (dry-run, saved-plan, synthetic-plan, and the @shrkcrft/generator barrel)
|
|
27
|
+
// import the operation types from this path.
|
|
28
|
+
export * from "./operations.js";
|
|
24
29
|
export function evaluatePlannedChange(input) {
|
|
25
30
|
const { change, absolutePath, relativePath, existing } = input;
|
|
26
31
|
const op = change.operation;
|
|
@@ -43,6 +48,8 @@ export function evaluatePlannedChange(input) {
|
|
|
43
48
|
return evaluateInsertEnumEntry(op, absolutePath, relativePath, existing);
|
|
44
49
|
case 'insert-object-entry':
|
|
45
50
|
return evaluateInsertObjectEntry(op, absolutePath, relativePath, existing);
|
|
51
|
+
case 'insert-array-entry':
|
|
52
|
+
return evaluateInsertArrayEntry(op, absolutePath, relativePath, existing);
|
|
46
53
|
case 'insert-before-closing-brace':
|
|
47
54
|
return evaluateInsertBeforeClosingBrace(op, absolutePath, relativePath, existing);
|
|
48
55
|
case 'insert-between-anchors':
|
|
@@ -298,6 +305,47 @@ function evaluateInsertObjectEntry(op, absolutePath, relativePath, existing) {
|
|
|
298
305
|
existing.slice(obj.openIdx + 1 + trailingTrim.length);
|
|
299
306
|
return mkChange(FileChangeType.InsertBefore, absolutePath, relativePath, next, `insert-object-entry: added ${op.objectName}.${op.entryKey}`, op);
|
|
300
307
|
}
|
|
308
|
+
function evaluateInsertArrayEntry(op, absolutePath, relativePath, existing) {
|
|
309
|
+
if (existing === null) {
|
|
310
|
+
return mkChange(FileChangeType.Conflict, absolutePath, relativePath, '', 'insert-array-entry: target file does not exist', op);
|
|
311
|
+
}
|
|
312
|
+
// Try the primary array name, then each declared alternative in order. The
|
|
313
|
+
// first cleanly-resolved (present + unambiguous) array wins. This lets a
|
|
314
|
+
// template target a project whose registration array is named differently
|
|
315
|
+
// without silently dead-ending.
|
|
316
|
+
const candidates = [op.arrayName, ...(op.arrayNameAlternatives ?? [])].filter((n) => n.length > 0);
|
|
317
|
+
let arr = null;
|
|
318
|
+
let sawAmbiguous = false;
|
|
319
|
+
for (const name of candidates) {
|
|
320
|
+
const found = findArrayLiteralBlock(existing, name);
|
|
321
|
+
if (!found)
|
|
322
|
+
continue;
|
|
323
|
+
if (found.duplicate) {
|
|
324
|
+
sawAmbiguous = true;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
arr = found;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
if (!arr) {
|
|
331
|
+
return mkChange(FileChangeType.Conflict, absolutePath, relativePath, existing, unresolvedArrayReason(op, candidates, sawAmbiguous), op);
|
|
332
|
+
}
|
|
333
|
+
// Idempotency: skip when the element (or its caller-supplied marker) is
|
|
334
|
+
// already present anywhere inside the array body.
|
|
335
|
+
const body = existing.slice(arr.openIdx + 1, arr.closeIdx);
|
|
336
|
+
const marker = op.ifMissing ?? op.entryValue;
|
|
337
|
+
if (marker.length > 0 && body.includes(marker)) {
|
|
338
|
+
return mkChange(FileChangeType.Skip, absolutePath, relativePath, existing, `insert-array-entry: "${op.arrayName}" already contains entry (idempotent)`, op);
|
|
339
|
+
}
|
|
340
|
+
const indent = detectIndent(body) || ' ';
|
|
341
|
+
const trailingTrim = body.replace(/[\s,]+$/, '');
|
|
342
|
+
const needsComma = trailingTrim.length > 0;
|
|
343
|
+
const insertion = `${needsComma ? ',\n' : '\n'}${indent}${op.entryValue}`;
|
|
344
|
+
const next = existing.slice(0, arr.openIdx + 1 + trailingTrim.length) +
|
|
345
|
+
insertion +
|
|
346
|
+
existing.slice(arr.openIdx + 1 + trailingTrim.length);
|
|
347
|
+
return mkChange(FileChangeType.InsertBefore, absolutePath, relativePath, next, `insert-array-entry: added entry to ${op.arrayName}`, op);
|
|
348
|
+
}
|
|
301
349
|
function evaluateInsertBeforeClosingBrace(op, absolutePath, relativePath, existing) {
|
|
302
350
|
if (existing === null) {
|
|
303
351
|
return mkChange(FileChangeType.Conflict, absolutePath, relativePath, '', 'insert-before-closing-brace: target file does not exist', op);
|
|
@@ -465,6 +513,39 @@ function findObjectLiteralBlock(source, objectName) {
|
|
|
465
513
|
const re = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegex(objectName)}\\b[^=]*=\\s*\\{`, 'g');
|
|
466
514
|
return findBraceBlock(source, re);
|
|
467
515
|
}
|
|
516
|
+
function findArrayLiteralBlock(source, arrayName) {
|
|
517
|
+
const re = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegex(arrayName)}\\b[^=]*=\\s*\\[`, 'g');
|
|
518
|
+
return findBracketBlock(source, re);
|
|
519
|
+
}
|
|
520
|
+
function findBracketBlock(source, headRegex) {
|
|
521
|
+
headRegex.lastIndex = 0;
|
|
522
|
+
const first = headRegex.exec(source);
|
|
523
|
+
if (!first)
|
|
524
|
+
return null;
|
|
525
|
+
const openIdx = first.index + first[0].length - 1;
|
|
526
|
+
const second = headRegex.exec(source);
|
|
527
|
+
const duplicate = second !== null;
|
|
528
|
+
const closeIdx = findMatchingCloseBracket(source, openIdx);
|
|
529
|
+
if (closeIdx < 0)
|
|
530
|
+
return null;
|
|
531
|
+
return { openIdx, closeIdx, duplicate };
|
|
532
|
+
}
|
|
533
|
+
function findMatchingCloseBracket(source, openBracketIdx) {
|
|
534
|
+
let depth = 0;
|
|
535
|
+
let i = openBracketIdx;
|
|
536
|
+
while (i < source.length) {
|
|
537
|
+
const ch = source[i];
|
|
538
|
+
if (ch === '[')
|
|
539
|
+
depth += 1;
|
|
540
|
+
else if (ch === ']') {
|
|
541
|
+
depth -= 1;
|
|
542
|
+
if (depth === 0)
|
|
543
|
+
return i;
|
|
544
|
+
}
|
|
545
|
+
i += 1;
|
|
546
|
+
}
|
|
547
|
+
return -1;
|
|
548
|
+
}
|
|
468
549
|
function findBlockByName(source, name) {
|
|
469
550
|
// Matches `class Name {`, `interface Name {`, `enum Name {`, `namespace Name {`.
|
|
470
551
|
const re = new RegExp(`\\b(?:class|interface|enum|namespace|module)\\s+${escapeRegex(name)}\\b[^{]*\\{`, 'g');
|
|
@@ -505,3 +586,26 @@ function detectIndent(body) {
|
|
|
505
586
|
return null;
|
|
506
587
|
return match[1] ?? null;
|
|
507
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* Build the actionable reason for an `insert-array-entry` op whose target
|
|
591
|
+
* array (and all declared alternatives) could not be resolved. Instead of an
|
|
592
|
+
* opaque "array not found", the message tells the human exactly what to wire
|
|
593
|
+
* by hand — preserving the template's "zero manual wiring" promise as an
|
|
594
|
+
* explicit, honest fallback. A template author may override the message with
|
|
595
|
+
* `op.manualStepInstruction`.
|
|
596
|
+
*/
|
|
597
|
+
function unresolvedArrayReason(op, candidates, sawAmbiguous) {
|
|
598
|
+
if (op.manualStepInstruction && op.manualStepInstruction.trim().length > 0) {
|
|
599
|
+
return `insert-array-entry: MANUAL — ${op.manualStepInstruction.trim()}`;
|
|
600
|
+
}
|
|
601
|
+
const entry = oneLineEntryLabel(op.ifMissing ?? op.entryValue);
|
|
602
|
+
const tried = candidates.map((c) => `"${c}"`).join(', ');
|
|
603
|
+
const why = sawAmbiguous
|
|
604
|
+
? `registration array ${tried} appears multiple times (ambiguous)`
|
|
605
|
+
: `no registration array found (tried ${tried})`;
|
|
606
|
+
return `insert-array-entry: ${why} — wire ${entry} into ${op.arrayName} manually`;
|
|
607
|
+
}
|
|
608
|
+
function oneLineEntryLabel(raw) {
|
|
609
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
610
|
+
return collapsed.length > 60 ? collapsed.slice(0, 57) + '…' : collapsed;
|
|
611
|
+
}
|
package/dist/saved-plan.d.ts
CHANGED
|
@@ -12,6 +12,24 @@ export interface ISavedPlanExpectedChange {
|
|
|
12
12
|
type: string;
|
|
13
13
|
relativePath: string;
|
|
14
14
|
sizeBytes: number;
|
|
15
|
+
/**
|
|
16
|
+
* SHA-256 hex digest of the exact rendered body that `gen --print` shows.
|
|
17
|
+
* Always emitted by `buildSavedPlan`; may be absent on legacy plans written
|
|
18
|
+
* before body/digest persistence. The plan is the review/apply artifact, so
|
|
19
|
+
* this digest lets a later review or apply verify — WITHOUT re-rendering —
|
|
20
|
+
* that the live content still matches what was previewed. Covered by the
|
|
21
|
+
* HMAC signature (canonical JSON includes the whole `expectedChanges`).
|
|
22
|
+
*/
|
|
23
|
+
sha256?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The exact rendered file body that would be written — byte-identical to
|
|
26
|
+
* what `gen --print` / `--show-content` displays. Embedded so the saved
|
|
27
|
+
* plan carries enough to be reviewed for correctness, diffed against HEAD,
|
|
28
|
+
* and re-applied deterministically instead of storing only a byte count.
|
|
29
|
+
* Always emitted by `buildSavedPlan`; may be absent on legacy plans. Covered
|
|
30
|
+
* by the HMAC signature.
|
|
31
|
+
*/
|
|
32
|
+
body?: string;
|
|
15
33
|
/**
|
|
16
34
|
* v2-only — the operation intent that produced this change. Present iff
|
|
17
35
|
* the schema is `sharkcraft.plan/v2`. Tampering with this field invalidates
|
|
@@ -87,12 +105,20 @@ export interface BuildSavedPlanInput {
|
|
|
87
105
|
* resulting plan is tagged `sharkcraft.plan/v2`; otherwise v1.
|
|
88
106
|
*/
|
|
89
107
|
export declare function buildSavedPlan(input: BuildSavedPlanInput): ISavedPlan;
|
|
108
|
+
/** SHA-256 hex digest of a rendered file body (UTF-8). */
|
|
109
|
+
export declare function sha256Hex(body: string): string;
|
|
90
110
|
export declare function savePlanToFile(plan: ISavedPlan, filePath: string): Result<void, AppError>;
|
|
91
111
|
export declare function readPlanFromFile(filePath: string): Result<ISavedPlan, AppError>;
|
|
92
112
|
export interface IPlanDiff {
|
|
93
113
|
relativePath: string;
|
|
94
|
-
/**
|
|
95
|
-
|
|
114
|
+
/**
|
|
115
|
+
* "added" | "removed" | "type-changed" | "size-changed" |
|
|
116
|
+
* "operation-changed" | "content-changed". `content-changed` fires when the
|
|
117
|
+
* live body's digest differs from the saved `sha256` even though the byte
|
|
118
|
+
* count matches — a same-size content edit that a size check alone would
|
|
119
|
+
* miss.
|
|
120
|
+
*/
|
|
121
|
+
kind: 'added' | 'removed' | 'type-changed' | 'size-changed' | 'operation-changed' | 'content-changed';
|
|
96
122
|
detail?: string;
|
|
97
123
|
}
|
|
98
124
|
/**
|
package/dist/saved-plan.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"saved-plan.d.ts","sourceRoot":"","sources":["../src/saved-plan.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"saved-plan.d.ts","sourceRoot":"","sources":["../src/saved-plan.ts"],"names":[],"mappings":"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"}
|
package/dist/saved-plan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { AppErrorImpl, ERROR_CODES, err, ok } from '@shrkcrft/core';
|
|
5
6
|
/** v1 schema marker — kept for legacy CREATE-only plans. */
|
|
6
7
|
export const SAVED_PLAN_SCHEMA_V1 = 'sharkcraft.plan/v1';
|
|
@@ -20,6 +21,11 @@ export function buildSavedPlan(input) {
|
|
|
20
21
|
type: String(c.type),
|
|
21
22
|
relativePath: c.relativePath,
|
|
22
23
|
sizeBytes: c.sizeBytes,
|
|
24
|
+
// Persist the exact rendered body (what `--print` shows) plus its
|
|
25
|
+
// digest, so the saved plan IS the reviewable / re-appliable artifact
|
|
26
|
+
// rather than a bare byte count. Both are covered by the HMAC signature.
|
|
27
|
+
sha256: sha256Hex(c.contents),
|
|
28
|
+
body: c.contents,
|
|
23
29
|
};
|
|
24
30
|
if (c.operation !== undefined)
|
|
25
31
|
entry.operation = c.operation;
|
|
@@ -41,6 +47,10 @@ export function buildSavedPlan(input) {
|
|
|
41
47
|
out.folderOps = [...input.folderOps];
|
|
42
48
|
return out;
|
|
43
49
|
}
|
|
50
|
+
/** SHA-256 hex digest of a rendered file body (UTF-8). */
|
|
51
|
+
export function sha256Hex(body) {
|
|
52
|
+
return createHash('sha256').update(body, 'utf8').digest('hex');
|
|
53
|
+
}
|
|
44
54
|
export function savePlanToFile(plan, filePath) {
|
|
45
55
|
try {
|
|
46
56
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -202,6 +212,16 @@ export function diffPlanChanges(saved, fresh) {
|
|
|
202
212
|
detail: `${expected.sizeBytes}B → ${actual.sizeBytes}B`,
|
|
203
213
|
});
|
|
204
214
|
}
|
|
215
|
+
else if (expected.sha256 !== undefined &&
|
|
216
|
+
sha256Hex(actual.contents) !== expected.sha256) {
|
|
217
|
+
// Same byte count, different bytes: only the persisted digest catches
|
|
218
|
+
// this. Legacy plans without `sha256` skip the check (size-only).
|
|
219
|
+
out.push({
|
|
220
|
+
relativePath: expected.relativePath,
|
|
221
|
+
kind: 'content-changed',
|
|
222
|
+
detail: 'body digest mismatch (same size, different content)',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
205
225
|
}
|
|
206
226
|
for (const [key, actual] of freshByKey) {
|
|
207
227
|
if (!expectedByKey.has(key)) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"synthetic-plan.d.ts","sourceRoot":"","sources":["../src/synthetic-plan.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"synthetic-plan.d.ts","sourceRoot":"","sources":["../src/synthetic-plan.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,UAAU,EAChB,WAAW,EAAE,MAAM,GAClB,eAAe,CA8CjB;AAoDD,OAAO,EAAsC,KAAK,QAAQ,EAAE,KAAK,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE/D,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,kBAAkB,CAAC;IAC5B,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;CACjC;AAWD,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,eAAe,GACpB,MAAM,CAAC,qBAAqB,EAAE,QAAQ,CAAC,CAmEzC"}
|