@shrkcrft/generator 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +22 -2
- 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 +141 -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 +67 -0
- package/dist/synthetic-plan.d.ts.map +1 -1
- package/dist/synthetic-plan.js +41 -5
- package/package.json +6 -6
package/dist/dry-run.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dry-run.d.ts","sourceRoot":"","sources":["../src/dry-run.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAW5D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,eAAe,CAAC;IACtB,gEAAgE;IAChE,IAAI,EAAE,OAAO,CAAC;CACf;AAED,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,aAAa,
|
|
1
|
+
{"version":3,"file":"dry-run.d.ts","sourceRoot":"","sources":["../src/dry-run.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAW5D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,eAAe,CAAC;IACtB,gEAAgE;IAChE,IAAI,EAAE,OAAO,CAAC;CACf;AAED,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,aAAa,CA8Gf"}
|
package/dist/dry-run.js
CHANGED
|
@@ -30,6 +30,12 @@ export function planGeneration(template, request) {
|
|
|
30
30
|
}
|
|
31
31
|
const rendered = renderTemplate(template, validation.resolved);
|
|
32
32
|
const changes = [];
|
|
33
|
+
// Per-file content overlay so that MULTIPLE changes (or a files() create
|
|
34
|
+
// followed by a changes() op) targeting the SAME file COMPOSE. Without
|
|
35
|
+
// this every change is evaluated against the original on-disk bytes and
|
|
36
|
+
// the writer's last same-file write clobbers the earlier ones. Keyed by
|
|
37
|
+
// absolute path → the cumulative content after the prior change(s).
|
|
38
|
+
const overlay = new Map();
|
|
33
39
|
// 1) Legacy CREATE-only files() output — unchanged behaviour.
|
|
34
40
|
for (const file of rendered.files) {
|
|
35
41
|
let safe;
|
|
@@ -59,14 +65,16 @@ export function planGeneration(template, request) {
|
|
|
59
65
|
// unreadable existing file — treat as conflict
|
|
60
66
|
}
|
|
61
67
|
const decision = decideForExisting(file.overwrite ? OverwriteStrategy.Overwrite : overwriteStrategy, existing, file.content);
|
|
68
|
+
const contents = decision.type === FileChangeType.Skip ? existing : file.content;
|
|
62
69
|
changes.push({
|
|
63
70
|
type: decision.type,
|
|
64
71
|
absolutePath,
|
|
65
72
|
relativePath: relPath,
|
|
66
|
-
contents
|
|
73
|
+
contents,
|
|
67
74
|
reason: decision.reason,
|
|
68
75
|
sizeBytes: Buffer.byteLength(file.content, 'utf8'),
|
|
69
76
|
});
|
|
77
|
+
overlay.set(absolutePath, contents);
|
|
70
78
|
}
|
|
71
79
|
else {
|
|
72
80
|
changes.push({
|
|
@@ -77,11 +85,13 @@ export function planGeneration(template, request) {
|
|
|
77
85
|
reason: 'New file (does not exist)',
|
|
78
86
|
sizeBytes: Buffer.byteLength(file.content, 'utf8'),
|
|
79
87
|
});
|
|
88
|
+
overlay.set(absolutePath, file.content);
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
|
-
// 2) v2 planned changes — evaluate against live filesystem
|
|
91
|
+
// 2) v2 planned changes — evaluate against the live filesystem, threaded
|
|
92
|
+
// through the overlay so successive changes to one file compose.
|
|
83
93
|
for (const tplChange of rendered.changes) {
|
|
84
|
-
const evaluated = planOne(tplChange, request.projectRoot);
|
|
94
|
+
const evaluated = planOne(tplChange, request.projectRoot, overlay);
|
|
85
95
|
changes.push(evaluated);
|
|
86
96
|
}
|
|
87
97
|
const { hasConflicts } = summarizeConflicts(changes);
|
|
@@ -98,7 +108,7 @@ export function planGeneration(template, request) {
|
|
|
98
108
|
safe: !hasConflicts && changes.length > 0,
|
|
99
109
|
};
|
|
100
110
|
}
|
|
101
|
-
function planOne(tplChange, projectRoot) {
|
|
111
|
+
function planOne(tplChange, projectRoot, overlay) {
|
|
102
112
|
const op = tplChange.operation;
|
|
103
113
|
let safe;
|
|
104
114
|
try {
|
|
@@ -121,15 +131,23 @@ function planOne(tplChange, projectRoot) {
|
|
|
121
131
|
targetPath: tplChange.targetPath,
|
|
122
132
|
operation: op,
|
|
123
133
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
// Prefer the overlay (cumulative result of prior same-file changes) over
|
|
135
|
+
// the on-disk bytes so successive ops on one file compose deterministically.
|
|
136
|
+
const existing = overlay?.has(safe.absolutePath)
|
|
137
|
+
? (overlay.get(safe.absolutePath) ?? null)
|
|
138
|
+
: existsSync(safe.absolutePath)
|
|
139
|
+
? readFileSafe(safe.absolutePath)
|
|
140
|
+
: null;
|
|
141
|
+
const result = evaluatePlannedChange({
|
|
128
142
|
change,
|
|
129
143
|
absolutePath: safe.absolutePath,
|
|
130
144
|
relativePath: safe.relativePath,
|
|
131
145
|
existing,
|
|
132
146
|
});
|
|
147
|
+
// Record the cumulative content (Skip/Conflict carry the unchanged bytes,
|
|
148
|
+
// which is exactly what a later op on the same file should see).
|
|
149
|
+
overlay?.set(safe.absolutePath, result.contents);
|
|
150
|
+
return result;
|
|
133
151
|
}
|
|
134
152
|
function readFileSafe(absolutePath) {
|
|
135
153
|
try {
|
|
@@ -170,6 +188,8 @@ function previewContentForOperation(op) {
|
|
|
170
188
|
return `${op.enumName}.${op.entryName} = '${op.entryValue}'`;
|
|
171
189
|
case 'insert-object-entry':
|
|
172
190
|
return `${op.objectName}.${op.entryKey}: ${op.entryValue}`;
|
|
191
|
+
case 'insert-array-entry':
|
|
192
|
+
return `${op.arrayName} ⟵ ${op.entryValue}`;
|
|
173
193
|
case 'insert-before-closing-brace':
|
|
174
194
|
return op.snippet;
|
|
175
195
|
case 'insert-between-anchors':
|
package/dist/file-change.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-change.d.ts","sourceRoot":"","sources":["../src/file-change.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"file-change.d.ts","sourceRoot":"","sources":["../src/file-change.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,oBAAY,cAAc;IACxB,MAAM,WAAW;IACjB,2EAA2E;IAC3E,MAAM,WAAW;IACjB,yDAAyD;IACzD,MAAM,WAAW;IACjB,0EAA0E;IAC1E,WAAW,iBAAiB;IAC5B,2EAA2E;IAC3E,YAAY,kBAAkB;IAC9B,oDAAoD;IACpD,OAAO,YAAY;IACnB,+DAA+D;IAC/D,MAAM,WAAW;IACjB,iEAAiE;IACjE,YAAY,kBAAkB;IAC9B,4DAA4D;IAC5D,YAAY,kBAAkB;IAC9B,IAAI,SAAS;IACb,QAAQ,aAAa;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,2FAA2F;IAC3F,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;OAUG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAC;CAC/B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"folder-apply.d.ts","sourceRoot":"","sources":["../src/folder-apply.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"folder-apply.d.ts","sourceRoot":"","sources":["../src/folder-apply.ts"],"names":[],"mappings":"AAeA,OAAO,EAAuB,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAAC;IACjD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;CACtC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,cAAc,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,+BAA+B,CAAC;IACjD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IAC7C,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAC;CAC/C;AAMD,wBAAgB,cAAc,CAC5B,GAAG,EAAE,SAAS,cAAc,EAAE,EAC9B,OAAO,EAAE,gBAAgB,GACxB,oBAAoB,CAwHtB"}
|
package/dist/folder-apply.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, renameSync, rmSync } from 'node:fs';
|
|
14
14
|
import * as nodePath from 'node:path';
|
|
15
|
+
import { safeResolveTargetPath } from '@shrkcrft/core';
|
|
15
16
|
import { checkFolderOpSafety, FolderOpSafety } from "./folder-safety.js";
|
|
16
17
|
function resolveAbs(projectRoot, p) {
|
|
17
18
|
return nodePath.isAbsolute(p) ? p : nodePath.resolve(projectRoot, p);
|
|
@@ -60,7 +61,26 @@ export function applyFolderOps(ops, options) {
|
|
|
60
61
|
});
|
|
61
62
|
continue;
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
// The rename DESTINATION is never validated by checkFolderOpSafety (which
|
|
65
|
+
// only checks targetPath), and resolveAbs even allows absolute newPaths —
|
|
66
|
+
// so `newPath: '../sibling'` or '/etc/x' would move the folder OUTSIDE the
|
|
67
|
+
// project root. Enforce containment through the same chokepoint as writes.
|
|
68
|
+
let safeNew;
|
|
69
|
+
try {
|
|
70
|
+
safeNew = safeResolveTargetPath(op.newPath, options.projectRoot);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const pathErr = e;
|
|
74
|
+
rejected.push({
|
|
75
|
+
...baseResult,
|
|
76
|
+
applied: false,
|
|
77
|
+
safety: FolderOpSafety.Unsafe,
|
|
78
|
+
reason: `rename-folder destination "${op.newPath}" is outside the project root (${pathErr.code}).`,
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const safeAbsNewPath = safeNew.absolutePath;
|
|
83
|
+
if (existsSync(safeAbsNewPath)) {
|
|
64
84
|
rejected.push({
|
|
65
85
|
...baseResult,
|
|
66
86
|
applied: false,
|
|
@@ -74,7 +94,7 @@ export function applyFolderOps(ops, options) {
|
|
|
74
94
|
continue;
|
|
75
95
|
}
|
|
76
96
|
try {
|
|
77
|
-
renameSync(absTarget,
|
|
97
|
+
renameSync(absTarget, safeAbsNewPath);
|
|
78
98
|
applied.push({ ...baseResult, applied: true });
|
|
79
99
|
}
|
|
80
100
|
catch (e) {
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './planned-change.js';
|
|
|
12
12
|
export * from './folder-safety.js';
|
|
13
13
|
export * from './folder-apply.js';
|
|
14
14
|
export * from './synthetic-plan.js';
|
|
15
|
+
export * from './package-delegate-plan.js';
|
|
15
16
|
export * from './spec/index.js';
|
|
16
17
|
export * from './grounding/index.js';
|
|
17
18
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -12,5 +12,6 @@ export * from "./planned-change.js";
|
|
|
12
12
|
export * from "./folder-safety.js";
|
|
13
13
|
export * from "./folder-apply.js";
|
|
14
14
|
export * from "./synthetic-plan.js";
|
|
15
|
+
export * from "./package-delegate-plan.js";
|
|
15
16
|
export * from "./spec/index.js";
|
|
16
17
|
export * from "./grounding/index.js";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation union — declared by templates, persisted in saved plans.
|
|
3
|
+
* Part of the v2 generation model.
|
|
4
|
+
*/
|
|
5
|
+
export type PlannedOperationKind = 'create' | 'append' | 'insert-after' | 'insert-before' | 'replace' | 'export' | 'ensure-import' | 'insert-enum-entry' | 'insert-object-entry' | 'insert-array-entry' | 'insert-before-closing-brace' | 'insert-between-anchors';
|
|
6
|
+
export interface ICreateOperation {
|
|
7
|
+
kind: 'create';
|
|
8
|
+
content: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface IAppendOperation {
|
|
12
|
+
kind: 'append';
|
|
13
|
+
/**
|
|
14
|
+
* The snippet to append at the end of the file. The engine adds a single
|
|
15
|
+
* `\n` separator between the existing trailing content and the snippet if
|
|
16
|
+
* the existing file does not already end with a newline.
|
|
17
|
+
*/
|
|
18
|
+
snippet: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional idempotency marker. If the existing file already contains this
|
|
21
|
+
* string anywhere, the operation is skipped (already applied).
|
|
22
|
+
*/
|
|
23
|
+
ifMissing?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface IInsertAfterOperation {
|
|
27
|
+
kind: 'insert-after';
|
|
28
|
+
/** Literal substring that must appear exactly once in the file. */
|
|
29
|
+
anchor: string;
|
|
30
|
+
/** The snippet to insert immediately after `anchor`. */
|
|
31
|
+
snippet: string;
|
|
32
|
+
/** Idempotency check; default = `snippet`. */
|
|
33
|
+
ifMissing?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface IInsertBeforeOperation {
|
|
37
|
+
kind: 'insert-before';
|
|
38
|
+
anchor: string;
|
|
39
|
+
snippet: string;
|
|
40
|
+
ifMissing?: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface IReplaceOperation {
|
|
44
|
+
kind: 'replace';
|
|
45
|
+
/** Literal substring to find. */
|
|
46
|
+
find: string;
|
|
47
|
+
/** Replacement text. */
|
|
48
|
+
replaceWith: string;
|
|
49
|
+
/**
|
|
50
|
+
* If provided, the engine requires exactly this many matches; otherwise the
|
|
51
|
+
* default is exactly 1. Multiple matches without an explicit `expectMatches`
|
|
52
|
+
* is a conflict (ambiguous replace).
|
|
53
|
+
*/
|
|
54
|
+
expectMatches?: number;
|
|
55
|
+
description?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface IExportOperation {
|
|
58
|
+
kind: 'export';
|
|
59
|
+
/** The symbol/path to re-export. */
|
|
60
|
+
from: string;
|
|
61
|
+
/** Optional named symbols. When omitted, emits `export * from`. */
|
|
62
|
+
symbols?: readonly string[];
|
|
63
|
+
/** Idempotency check; default = computed export line. */
|
|
64
|
+
ifMissing?: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
}
|
|
67
|
+
export interface IEnsureImportOperation {
|
|
68
|
+
kind: 'ensure-import';
|
|
69
|
+
/** Module specifier, e.g. `'./events'` or `'@app/plugin-core'`. */
|
|
70
|
+
from: string;
|
|
71
|
+
/**
|
|
72
|
+
* Named symbols to ensure. The op is a NO-OP for symbols already imported
|
|
73
|
+
* from `from`. Default import (`type: 'default'`) and namespace import
|
|
74
|
+
* (`type: 'namespace'`) are also supported via dedicated fields below.
|
|
75
|
+
*/
|
|
76
|
+
symbols?: readonly string[];
|
|
77
|
+
/** Treat the import as `import type { ... }` instead of value import. */
|
|
78
|
+
typeOnly?: boolean;
|
|
79
|
+
/** Default import binding (e.g. `import Foo from 'foo'`). */
|
|
80
|
+
defaultBinding?: string;
|
|
81
|
+
/** Namespace import binding (e.g. `import * as foo from 'foo'`). */
|
|
82
|
+
namespaceBinding?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
}
|
|
85
|
+
export interface IInsertEnumEntryOperation {
|
|
86
|
+
kind: 'insert-enum-entry';
|
|
87
|
+
/** Enum identifier, e.g. `PaginationEventType`. */
|
|
88
|
+
enumName: string;
|
|
89
|
+
/** Identifier of the new enum member, e.g. `ITEM_SELECTED`. */
|
|
90
|
+
entryName: string;
|
|
91
|
+
/** Literal string value to assign, e.g. `'pagination.itemSelected'`. */
|
|
92
|
+
entryValue: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
}
|
|
95
|
+
export interface IInsertObjectEntryOperation {
|
|
96
|
+
kind: 'insert-object-entry';
|
|
97
|
+
/** Object identifier, e.g. `ROUTE_KEYS`. */
|
|
98
|
+
objectName: string;
|
|
99
|
+
/** Key to add. */
|
|
100
|
+
entryKey: string;
|
|
101
|
+
/** Value literal (already source-formatted). */
|
|
102
|
+
entryValue: string;
|
|
103
|
+
/** When `true`, allow shorthand entries; default `false`. */
|
|
104
|
+
shorthand?: boolean;
|
|
105
|
+
description?: string;
|
|
106
|
+
}
|
|
107
|
+
export interface IInsertArrayEntryOperation {
|
|
108
|
+
kind: 'insert-array-entry';
|
|
109
|
+
/**
|
|
110
|
+
* Array identifier — a `const`/`let`/`var` bound to an array literal,
|
|
111
|
+
* e.g. `editorScopeEntries` or `DEFAULT_PANELS`. The element is inserted
|
|
112
|
+
* before the array's matching closing bracket.
|
|
113
|
+
*/
|
|
114
|
+
arrayName: string;
|
|
115
|
+
/** Element source text to add (already source-formatted, no trailing comma). */
|
|
116
|
+
entryValue: string;
|
|
117
|
+
/** Optional idempotency marker (default = `entryValue`). */
|
|
118
|
+
ifMissing?: string;
|
|
119
|
+
description?: string;
|
|
120
|
+
}
|
|
121
|
+
export interface IInsertBeforeClosingBraceOperation {
|
|
122
|
+
kind: 'insert-before-closing-brace';
|
|
123
|
+
/** Container identifier, e.g. an interface/class/enum name. */
|
|
124
|
+
containerName: string;
|
|
125
|
+
/** Snippet inserted immediately before the matching closing brace. */
|
|
126
|
+
snippet: string;
|
|
127
|
+
/** Optional idempotency marker (default = `snippet`). */
|
|
128
|
+
ifMissing?: string;
|
|
129
|
+
description?: string;
|
|
130
|
+
}
|
|
131
|
+
export interface IInsertBetweenAnchorsOperation {
|
|
132
|
+
kind: 'insert-between-anchors';
|
|
133
|
+
beginAnchor: string;
|
|
134
|
+
endAnchor: string;
|
|
135
|
+
snippet: string;
|
|
136
|
+
/** Optional idempotency marker (default = `snippet`). */
|
|
137
|
+
ifMissing?: string;
|
|
138
|
+
description?: string;
|
|
139
|
+
}
|
|
140
|
+
export type IPlannedOperation = ICreateOperation | IAppendOperation | IInsertAfterOperation | IInsertBeforeOperation | IReplaceOperation | IExportOperation | IEnsureImportOperation | IInsertEnumEntryOperation | IInsertObjectEntryOperation | IInsertArrayEntryOperation | IInsertBeforeClosingBraceOperation | IInsertBetweenAnchorsOperation;
|
|
141
|
+
//# sourceMappingURL=operations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { type AppError, type Result } from '@shrkcrft/core';
|
|
20
|
+
import type { IGenerationPlan } from './generation-plan.js';
|
|
21
|
+
import type { ISavedPlan } from './saved-plan.js';
|
|
22
|
+
export declare const DELEGATE_TEMPLATE_PREFIX = "__delegate/";
|
|
23
|
+
/** A raw operation as parsed from a worker (structurally `IDelegateRawOp`). */
|
|
24
|
+
export interface IDelegateOpInput {
|
|
25
|
+
targetPath: string;
|
|
26
|
+
operation: {
|
|
27
|
+
kind: string;
|
|
28
|
+
} & Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface IPackageDelegateInput {
|
|
31
|
+
ops: readonly IDelegateOpInput[];
|
|
32
|
+
/** `IPlannedOperation` kinds the recipe permits; others are dropped. */
|
|
33
|
+
allowedOps: readonly string[];
|
|
34
|
+
/** Recipe id — becomes the synthetic templateId `__delegate/<id>`. */
|
|
35
|
+
recipeId: string;
|
|
36
|
+
projectRoot: string;
|
|
37
|
+
}
|
|
38
|
+
export interface IDroppedOp {
|
|
39
|
+
kind: string;
|
|
40
|
+
targetPath: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
export interface IPackageDelegateResult {
|
|
44
|
+
/** Conflict-free, ready to sign. Present only when `ready`. */
|
|
45
|
+
plan?: ISavedPlan;
|
|
46
|
+
/** The evaluated generation plan (changes + conflicts) — always present. */
|
|
47
|
+
generation: IGenerationPlan;
|
|
48
|
+
/** Ops whose kind was not in `allowedOps`. */
|
|
49
|
+
droppedOps: readonly IDroppedOp[];
|
|
50
|
+
/** True when no conflicts — the plan is built and ready to sign + apply. */
|
|
51
|
+
ready: boolean;
|
|
52
|
+
}
|
|
53
|
+
export declare function packageDelegatePlan(input: IPackageDelegateInput): Result<IPackageDelegateResult, AppError>;
|
|
54
|
+
//# sourceMappingURL=package-delegate-plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"package-delegate-plan.d.ts","sourceRoot":"","sources":["../src/package-delegate-plan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAsC,KAAK,QAAQ,EAAE,KAAK,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAmBlD,eAAO,MAAM,wBAAwB,gBAAgB,CAAC;AAEtD,+EAA+E;AAC/E,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACjC,wEAAwE;IACxE,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,+DAA+D;IAC/D,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,4EAA4E;IAC5E,UAAU,EAAE,eAAe,CAAC;IAC5B,8CAA8C;IAC9C,UAAU,EAAE,SAAS,UAAU,EAAE,CAAC;IAClC,4EAA4E;IAC5E,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,qBAAqB,GAC3B,MAAM,CAAC,sBAAsB,EAAE,QAAQ,CAAC,CAoE1C"}
|
|
@@ -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,33 @@ 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
|
+
const arr = findArrayLiteralBlock(existing, op.arrayName);
|
|
313
|
+
if (!arr) {
|
|
314
|
+
return mkChange(FileChangeType.Conflict, absolutePath, relativePath, existing, `insert-array-entry: array "${op.arrayName}" not found`, op);
|
|
315
|
+
}
|
|
316
|
+
if (arr.duplicate) {
|
|
317
|
+
return mkChange(FileChangeType.Conflict, absolutePath, relativePath, existing, `insert-array-entry: array "${op.arrayName}" appears multiple times (ambiguous)`, op);
|
|
318
|
+
}
|
|
319
|
+
// Idempotency: skip when the element (or its caller-supplied marker) is
|
|
320
|
+
// already present anywhere inside the array body.
|
|
321
|
+
const body = existing.slice(arr.openIdx + 1, arr.closeIdx);
|
|
322
|
+
const marker = op.ifMissing ?? op.entryValue;
|
|
323
|
+
if (marker.length > 0 && body.includes(marker)) {
|
|
324
|
+
return mkChange(FileChangeType.Skip, absolutePath, relativePath, existing, `insert-array-entry: "${op.arrayName}" already contains entry (idempotent)`, op);
|
|
325
|
+
}
|
|
326
|
+
const indent = detectIndent(body) || ' ';
|
|
327
|
+
const trailingTrim = body.replace(/[\s,]+$/, '');
|
|
328
|
+
const needsComma = trailingTrim.length > 0;
|
|
329
|
+
const insertion = `${needsComma ? ',\n' : '\n'}${indent}${op.entryValue}`;
|
|
330
|
+
const next = existing.slice(0, arr.openIdx + 1 + trailingTrim.length) +
|
|
331
|
+
insertion +
|
|
332
|
+
existing.slice(arr.openIdx + 1 + trailingTrim.length);
|
|
333
|
+
return mkChange(FileChangeType.InsertBefore, absolutePath, relativePath, next, `insert-array-entry: added entry to ${op.arrayName}`, op);
|
|
334
|
+
}
|
|
301
335
|
function evaluateInsertBeforeClosingBrace(op, absolutePath, relativePath, existing) {
|
|
302
336
|
if (existing === null) {
|
|
303
337
|
return mkChange(FileChangeType.Conflict, absolutePath, relativePath, '', 'insert-before-closing-brace: target file does not exist', op);
|
|
@@ -465,6 +499,39 @@ function findObjectLiteralBlock(source, objectName) {
|
|
|
465
499
|
const re = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegex(objectName)}\\b[^=]*=\\s*\\{`, 'g');
|
|
466
500
|
return findBraceBlock(source, re);
|
|
467
501
|
}
|
|
502
|
+
function findArrayLiteralBlock(source, arrayName) {
|
|
503
|
+
const re = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegex(arrayName)}\\b[^=]*=\\s*\\[`, 'g');
|
|
504
|
+
return findBracketBlock(source, re);
|
|
505
|
+
}
|
|
506
|
+
function findBracketBlock(source, headRegex) {
|
|
507
|
+
headRegex.lastIndex = 0;
|
|
508
|
+
const first = headRegex.exec(source);
|
|
509
|
+
if (!first)
|
|
510
|
+
return null;
|
|
511
|
+
const openIdx = first.index + first[0].length - 1;
|
|
512
|
+
const second = headRegex.exec(source);
|
|
513
|
+
const duplicate = second !== null;
|
|
514
|
+
const closeIdx = findMatchingCloseBracket(source, openIdx);
|
|
515
|
+
if (closeIdx < 0)
|
|
516
|
+
return null;
|
|
517
|
+
return { openIdx, closeIdx, duplicate };
|
|
518
|
+
}
|
|
519
|
+
function findMatchingCloseBracket(source, openBracketIdx) {
|
|
520
|
+
let depth = 0;
|
|
521
|
+
let i = openBracketIdx;
|
|
522
|
+
while (i < source.length) {
|
|
523
|
+
const ch = source[i];
|
|
524
|
+
if (ch === '[')
|
|
525
|
+
depth += 1;
|
|
526
|
+
else if (ch === ']') {
|
|
527
|
+
depth -= 1;
|
|
528
|
+
if (depth === 0)
|
|
529
|
+
return i;
|
|
530
|
+
}
|
|
531
|
+
i += 1;
|
|
532
|
+
}
|
|
533
|
+
return -1;
|
|
534
|
+
}
|
|
468
535
|
function findBlockByName(source, name) {
|
|
469
536
|
// Matches `class Name {`, `interface Name {`, `enum Name {`, `namespace Name {`.
|
|
470
537
|
const re = new RegExp(`\\b(?:class|interface|enum|namespace|module)\\s+${escapeRegex(name)}\\b[^{]*\\{`, 'g');
|
|
@@ -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,CAsCjB;AAwCD,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,CAoDzC"}
|
package/dist/synthetic-plan.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import * as nodePath from 'node:path';
|
|
18
|
+
import { safeResolveTargetPath } from '@shrkcrft/core';
|
|
18
19
|
import { evaluatePlannedChange } from "./planned-change.js";
|
|
19
20
|
import { FileChangeType } from "./file-change.js";
|
|
20
21
|
export const SYNTHETIC_TEMPLATE_PREFIX = '__';
|
|
@@ -62,12 +63,30 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
|
|
|
62
63
|
return out;
|
|
63
64
|
}
|
|
64
65
|
function applyOperation(projectRoot, relativePath, operation) {
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Route through the single generator chokepoint instead of a bare resolve, so
|
|
67
|
+
// a traversal / absolute `relativePath` in a hand-crafted or tampered plan
|
|
68
|
+
// can't write OUTSIDE the project root. An unsafe path becomes a Conflict,
|
|
69
|
+
// which writeSyntheticPlan refuses (matching the template path in dry-run.ts).
|
|
70
|
+
let safe;
|
|
71
|
+
try {
|
|
72
|
+
safe = safeResolveTargetPath(relativePath, projectRoot);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const pathErr = e;
|
|
76
|
+
return {
|
|
77
|
+
type: FileChangeType.Conflict,
|
|
78
|
+
absolutePath: pathErr.rawPath,
|
|
79
|
+
relativePath: pathErr.rawPath,
|
|
80
|
+
contents: '',
|
|
81
|
+
reason: `Refused unsafe target path (${pathErr.code}): ${pathErr.message}`,
|
|
82
|
+
sizeBytes: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const existing = existsSync(safe.absolutePath) ? readFileSync(safe.absolutePath, 'utf8') : null;
|
|
67
86
|
return evaluatePlannedChange({
|
|
68
|
-
change: { targetPath: relativePath, operation },
|
|
69
|
-
absolutePath,
|
|
70
|
-
relativePath,
|
|
87
|
+
change: { targetPath: safe.relativePath, operation },
|
|
88
|
+
absolutePath: safe.absolutePath,
|
|
89
|
+
relativePath: safe.relativePath,
|
|
71
90
|
existing,
|
|
72
91
|
});
|
|
73
92
|
}
|
|
@@ -78,10 +97,27 @@ function applyOperation(projectRoot, relativePath, operation) {
|
|
|
78
97
|
*/
|
|
79
98
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
80
99
|
import { AppErrorImpl, ERROR_CODES, err, ok } from '@shrkcrft/core';
|
|
100
|
+
/**
|
|
101
|
+
* A CCR retrieval marker (`<<ccr:<hex>…>>`) is a pointer into the compress
|
|
102
|
+
* cache — a LOSSY/compressed view, never apply-grade source. If one ever
|
|
103
|
+
* reaches a write (e.g. a compressed diff fed into a `create`/`replace` op), it
|
|
104
|
+
* would corrupt the file. This detector enforces, at the write chokepoint, the
|
|
105
|
+
* invariant that the compression layer only documents in a comment.
|
|
106
|
+
*/
|
|
107
|
+
const CCR_MARKER_RE = /<<ccr:[0-9a-f]{8,}/;
|
|
81
108
|
export function writeSyntheticPlan(plan) {
|
|
82
109
|
if (plan.hasConflicts) {
|
|
83
110
|
return err(new AppErrorImpl(ERROR_CODES.TARGET_FILE_EXISTS, 'Synthetic plan refused: conflicts present', { details: { conflicts: plan.changes.filter((c) => c.type === FileChangeType.Conflict) } }));
|
|
84
111
|
}
|
|
112
|
+
// Refuse the WHOLE plan if any writeable change carries a CCR marker — a
|
|
113
|
+
// compressed/lossy blob must never be written as source.
|
|
114
|
+
for (const change of plan.changes) {
|
|
115
|
+
if (!isWriteableSyntheticChange(change.type))
|
|
116
|
+
continue;
|
|
117
|
+
if (CCR_MARKER_RE.test(change.contents)) {
|
|
118
|
+
return err(new AppErrorImpl(ERROR_CODES.INVALID_INPUT, `Refused to write ${change.relativePath}: contents carry a <<ccr:…>> marker (a compressed/lossy blob is not apply-grade source).`, { details: { path: change.relativePath } }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
85
121
|
const written = [];
|
|
86
122
|
let totalBytes = 0;
|
|
87
123
|
let skipped = 0;
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shrkcrft/generator",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.21",
|
|
4
4
|
"description": "SharkCraft plan-first generator: GenerationPlan, FileChange, dry-run, safe writes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "SharkCraft contributors",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./dist/index.js",
|
|
9
|
-
"types": "./dist/index.d.
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
@@ -43,10 +43,10 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@shrkcrft/core": "^0.1.0-alpha.
|
|
47
|
-
"@shrkcrft/templates": "^0.1.0-alpha.
|
|
48
|
-
"@shrkcrft/rules": "^0.1.0-alpha.
|
|
49
|
-
"@shrkcrft/paths": "^0.1.0-alpha.
|
|
46
|
+
"@shrkcrft/core": "^0.1.0-alpha.21",
|
|
47
|
+
"@shrkcrft/templates": "^0.1.0-alpha.21",
|
|
48
|
+
"@shrkcrft/rules": "^0.1.0-alpha.21",
|
|
49
|
+
"@shrkcrft/paths": "^0.1.0-alpha.21"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|