@shrkcrft/generator 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +79 -16
- 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/generation-plan.d.ts +7 -0
- package/dist/generation-plan.d.ts.map +1 -1
- package/dist/generator-engine.d.ts.map +1 -1
- package/dist/generator-engine.js +26 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/planned-operation-fields.d.ts +71 -0
- package/dist/planned-operation-fields.d.ts.map +1 -0
- package/dist/planned-operation-fields.js +100 -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,100 @@
|
|
|
1
|
+
export const PLANNED_OPERATION_FIELDS = {
|
|
2
|
+
create: { required: ['content'], optional: ['description'] },
|
|
3
|
+
append: { required: ['snippet'], optional: ['ifMissing', 'description'] },
|
|
4
|
+
'insert-after': { required: ['anchor', 'snippet'], optional: ['ifMissing', 'description'] },
|
|
5
|
+
'insert-before': { required: ['anchor', 'snippet'], optional: ['ifMissing', 'description'] },
|
|
6
|
+
replace: { required: ['find', 'replaceWith'], optional: ['expectMatches', 'description'] },
|
|
7
|
+
export: { required: ['from'], optional: ['symbols', 'ifMissing', 'description'] },
|
|
8
|
+
'ensure-import': {
|
|
9
|
+
required: ['from'],
|
|
10
|
+
optional: ['symbols', 'typeOnly', 'defaultBinding', 'namespaceBinding', 'description'],
|
|
11
|
+
},
|
|
12
|
+
'insert-enum-entry': { required: ['enumName', 'entryName', 'entryValue'], optional: ['description'] },
|
|
13
|
+
'insert-object-entry': {
|
|
14
|
+
required: ['objectName', 'entryKey', 'entryValue'],
|
|
15
|
+
optional: ['shorthand', 'description'],
|
|
16
|
+
},
|
|
17
|
+
'insert-array-entry': {
|
|
18
|
+
required: ['arrayName', 'entryValue'],
|
|
19
|
+
optional: ['arrayNameAlternatives', 'manualStepInstruction', 'ifMissing', 'description'],
|
|
20
|
+
},
|
|
21
|
+
'insert-before-closing-brace': {
|
|
22
|
+
required: ['containerName', 'snippet'],
|
|
23
|
+
optional: ['ifMissing', 'description'],
|
|
24
|
+
},
|
|
25
|
+
'insert-between-anchors': {
|
|
26
|
+
required: ['beginAnchor', 'endAnchor', 'snippet'],
|
|
27
|
+
optional: ['ifMissing', 'description'],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/** Explicit near-misses authors write for a required field. */
|
|
31
|
+
const NEAR_MISS = {
|
|
32
|
+
key: ['entryKey'],
|
|
33
|
+
value: ['entryValue', 'snippet', 'content'],
|
|
34
|
+
name: ['entryName', 'arrayName', 'objectName', 'enumName', 'containerName'],
|
|
35
|
+
body: ['content', 'snippet'],
|
|
36
|
+
text: ['snippet', 'content'],
|
|
37
|
+
code: ['snippet', 'content'],
|
|
38
|
+
snippet: ['content'],
|
|
39
|
+
content: ['snippet'],
|
|
40
|
+
replace: ['replaceWith'],
|
|
41
|
+
with: ['replaceWith'],
|
|
42
|
+
search: ['find'],
|
|
43
|
+
pattern: ['find'],
|
|
44
|
+
begin: ['beginAnchor'],
|
|
45
|
+
end: ['endAnchor'],
|
|
46
|
+
target: ['arrayName', 'objectName', 'enumName', 'containerName'],
|
|
47
|
+
};
|
|
48
|
+
function isPresent(value) {
|
|
49
|
+
if (value === undefined || value === null)
|
|
50
|
+
return false;
|
|
51
|
+
if (typeof value === 'string')
|
|
52
|
+
return true;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Check one rendered op against the table. `kindKnown: false` means the kind is
|
|
57
|
+
* not a planned-operation kind at all; `missing` lists required fields that are
|
|
58
|
+
* absent; `unknown` lists keys the evaluator would silently drop; `suggestions`
|
|
59
|
+
* pairs an unknown key with the missing field it most likely meant
|
|
60
|
+
* (`value→entryValue`).
|
|
61
|
+
*/
|
|
62
|
+
export function validatePlannedOperation(op) {
|
|
63
|
+
if (!op || typeof op !== 'object') {
|
|
64
|
+
return { kindKnown: false, kind: String(op), missing: [], unknown: [], suggestions: [] };
|
|
65
|
+
}
|
|
66
|
+
const record = op;
|
|
67
|
+
const kind = typeof record.kind === 'string' ? record.kind : String(record.kind);
|
|
68
|
+
const spec = PLANNED_OPERATION_FIELDS[kind];
|
|
69
|
+
if (!spec)
|
|
70
|
+
return { kindKnown: false, kind, missing: [], unknown: [], suggestions: [] };
|
|
71
|
+
const missing = spec.required.filter((f) => !isPresent(record[f]));
|
|
72
|
+
const allowed = new Set(['kind', ...spec.required, ...spec.optional]);
|
|
73
|
+
const unknown = Object.keys(record).filter((k) => !allowed.has(k));
|
|
74
|
+
const suggestions = [];
|
|
75
|
+
for (const u of unknown) {
|
|
76
|
+
const explicit = (NEAR_MISS[u.toLowerCase()] ?? []).filter((f) => missing.includes(f));
|
|
77
|
+
const contained = missing.filter((f) => !explicit.includes(f) && f.toLowerCase().includes(u.toLowerCase()) && u.length >= 3);
|
|
78
|
+
const target = explicit[0] ?? contained[0];
|
|
79
|
+
if (target)
|
|
80
|
+
suggestions.push(`${u}→${target}`);
|
|
81
|
+
}
|
|
82
|
+
return { kindKnown: true, kind, missing, unknown, suggestions };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* One sentence naming what is wrong with an op, or `undefined` when the op is
|
|
86
|
+
* well-formed (unknown extra keys alone are reported by the caller as a
|
|
87
|
+
* warning, not here).
|
|
88
|
+
*/
|
|
89
|
+
export function describeInvalidPlannedOperation(shape, where) {
|
|
90
|
+
if (!shape.kindKnown) {
|
|
91
|
+
return `${where}: unknown operation kind "${shape.kind}" — must be one of ${Object.keys(PLANNED_OPERATION_FIELDS).join(', ')}`;
|
|
92
|
+
}
|
|
93
|
+
if (shape.missing.length === 0)
|
|
94
|
+
return undefined;
|
|
95
|
+
const parts = [`missing required ${shape.missing.join(', ')}`];
|
|
96
|
+
if (shape.unknown.length > 0)
|
|
97
|
+
parts.push(`unknown keys ${shape.unknown.join(', ')}`);
|
|
98
|
+
const hint = shape.suggestions.length > 0 ? ` — did you mean ${shape.suggestions.join(', ')}?` : '';
|
|
99
|
+
return `${where} (${shape.kind}): ${parts.join('; ')}${hint}`;
|
|
100
|
+
}
|
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":"AAIA,OAAO,EAAsC,KAAK,QAAQ,EAAE,KAAK,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,4DAA4D;AAC5D,eAAO,MAAM,oBAAoB,uBAAuB,CAAC;AACzD,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,uBAAuB,CAAC;AACzD,gFAAgF;AAChF,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD,MAAM,MAAM,eAAe,GAAG,OAAO,oBAAoB,GAAG,OAAO,oBAAoB,CAAC;AAExF,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,eAAe,GAAG,eAAe,CAAC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,wCAAwC;IACxC,MAAM,EAAE,eAAe,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,sEAAsE;IACtE,WAAW,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,eAAe,CAAC,EAAE,aAAa,CAAC,wBAAwB,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,SAAS,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;IAC9C,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,SAAS,CAAC,EAAE;QACV,IAAI,EAAE,QAAQ,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;CACH;AAED,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,eAAe,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAC;CAC3C;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,UAAU,CA6BrE;AAED,0DAA0D;AAC1D,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,CAazF;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,UAAU,EAAE,QAAQ,CAAC,CAgC/E;AAoGD,MAAM,WAAW,SAAS;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,IAAI,EACA,OAAO,GACP,SAAS,GACT,cAAc,GACd,cAAc,GACd,mBAAmB,GACnB,iBAAiB,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,eAAe,GACrB,SAAS,EAAE,CAqFb;AA4BD;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,UAAU,EACjB,aAAa,EAAE,SAAS,kBAAkB,EAAE,GAC3C,SAAS,EAAE,CA6Bb"}
|
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"}
|
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 = '__';
|
|
@@ -26,6 +27,14 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
|
|
|
26
27
|
const warnings = [];
|
|
27
28
|
let hasConflicts = false;
|
|
28
29
|
const expected = plan.expectedChanges ?? [];
|
|
30
|
+
// Per-file content overlay so MULTIPLE ops on the SAME file COMPOSE: each op
|
|
31
|
+
// sees the cumulative result of the prior ops on that path rather than the
|
|
32
|
+
// original on-disk bytes. Mirrors the template path in dry-run.ts. Without
|
|
33
|
+
// it, [append A, append B] loses A (the writer's last same-path write
|
|
34
|
+
// clobbers earlier ones) and create-then-modify on one NEW path
|
|
35
|
+
// false-conflicts (op2 reads stale disk). Keyed by absolute path → the
|
|
36
|
+
// cumulative content after the prior op(s).
|
|
37
|
+
const overlay = new Map();
|
|
29
38
|
for (const e of expected) {
|
|
30
39
|
if (!e.operation) {
|
|
31
40
|
// Without an operation we cannot reconstruct contents — surface as
|
|
@@ -42,7 +51,7 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
|
|
|
42
51
|
hasConflicts = true;
|
|
43
52
|
continue;
|
|
44
53
|
}
|
|
45
|
-
const change = applyOperation(projectRoot, e.relativePath, e.operation);
|
|
54
|
+
const change = applyOperation(projectRoot, e.relativePath, e.operation, overlay);
|
|
46
55
|
if (change.type === FileChangeType.Conflict)
|
|
47
56
|
hasConflicts = true;
|
|
48
57
|
changes.push(change);
|
|
@@ -61,15 +70,44 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
|
|
|
61
70
|
}
|
|
62
71
|
return out;
|
|
63
72
|
}
|
|
64
|
-
function applyOperation(projectRoot, relativePath, operation) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
function applyOperation(projectRoot, relativePath, operation, overlay) {
|
|
74
|
+
// Route through the single generator chokepoint instead of a bare resolve, so
|
|
75
|
+
// a traversal / absolute `relativePath` in a hand-crafted or tampered plan
|
|
76
|
+
// can't write OUTSIDE the project root. An unsafe path becomes a Conflict,
|
|
77
|
+
// which writeSyntheticPlan refuses (matching the template path in dry-run.ts).
|
|
78
|
+
let safe;
|
|
79
|
+
try {
|
|
80
|
+
safe = safeResolveTargetPath(relativePath, projectRoot);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
const pathErr = e;
|
|
84
|
+
return {
|
|
85
|
+
type: FileChangeType.Conflict,
|
|
86
|
+
absolutePath: pathErr.rawPath,
|
|
87
|
+
relativePath: pathErr.rawPath,
|
|
88
|
+
contents: '',
|
|
89
|
+
reason: `Refused unsafe target path (${pathErr.code}): ${pathErr.message}`,
|
|
90
|
+
sizeBytes: 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Prefer the overlay (the cumulative result of prior same-file ops) over the
|
|
94
|
+
// on-disk bytes so op N sees the result of ops 1..N-1. Falls back to the live
|
|
95
|
+
// file, then to absent (`null`) for a brand-new path.
|
|
96
|
+
const existing = overlay.has(safe.absolutePath)
|
|
97
|
+
? (overlay.get(safe.absolutePath) ?? null)
|
|
98
|
+
: existsSync(safe.absolutePath)
|
|
99
|
+
? readFileSync(safe.absolutePath, 'utf8')
|
|
100
|
+
: null;
|
|
101
|
+
const result = evaluatePlannedChange({
|
|
102
|
+
change: { targetPath: safe.relativePath, operation },
|
|
103
|
+
absolutePath: safe.absolutePath,
|
|
104
|
+
relativePath: safe.relativePath,
|
|
71
105
|
existing,
|
|
72
106
|
});
|
|
107
|
+
// Record the cumulative content (Skip/Conflict carry the unchanged bytes,
|
|
108
|
+
// which is exactly what a later op on the same file should see).
|
|
109
|
+
overlay.set(safe.absolutePath, result.contents);
|
|
110
|
+
return result;
|
|
73
111
|
}
|
|
74
112
|
/**
|
|
75
113
|
* Write evaluated changes from a synthetic plan directly. The caller
|
|
@@ -78,30 +116,63 @@ function applyOperation(projectRoot, relativePath, operation) {
|
|
|
78
116
|
*/
|
|
79
117
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
80
118
|
import { AppErrorImpl, ERROR_CODES, err, ok } from '@shrkcrft/core';
|
|
119
|
+
/**
|
|
120
|
+
* A CCR retrieval marker (`<<ccr:<hex>…>>`) is a pointer into the compress
|
|
121
|
+
* cache — a LOSSY/compressed view, never apply-grade source. If one ever
|
|
122
|
+
* reaches a write (e.g. a compressed diff fed into a `create`/`replace` op), it
|
|
123
|
+
* would corrupt the file. This detector enforces, at the write chokepoint, the
|
|
124
|
+
* invariant that the compression layer only documents in a comment.
|
|
125
|
+
*/
|
|
126
|
+
const CCR_MARKER_RE = /<<ccr:[0-9a-f]{8,}/;
|
|
81
127
|
export function writeSyntheticPlan(plan) {
|
|
82
128
|
if (plan.hasConflicts) {
|
|
83
129
|
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
130
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
let skipped = 0;
|
|
131
|
+
// Refuse the WHOLE plan if any writeable change carries a CCR marker — a
|
|
132
|
+
// compressed/lossy blob must never be written as source.
|
|
88
133
|
for (const change of plan.changes) {
|
|
89
|
-
if (change.type === FileChangeType.Skip) {
|
|
90
|
-
skipped += 1;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
134
|
if (!isWriteableSyntheticChange(change.type))
|
|
94
135
|
continue;
|
|
136
|
+
if (CCR_MARKER_RE.test(change.contents)) {
|
|
137
|
+
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 } }));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Compose multiple ops on the SAME path into ONE write. `evaluateSavedPlanInPlace`
|
|
141
|
+
// threads an overlay, so the LAST writeable change for a path already carries
|
|
142
|
+
// the cumulative result of every prior same-path op (any trailing Skip leaves
|
|
143
|
+
// those bytes unchanged). Writing that once — instead of every op's contents
|
|
144
|
+
// in order, last-write-wins — avoids redundant writes and keeps the success
|
|
145
|
+
// count honest: `written` and `summary.written` count DISTINCT paths.
|
|
146
|
+
const lastWriteableByPath = new Map();
|
|
147
|
+
const skipPaths = new Set();
|
|
148
|
+
for (const change of plan.changes) {
|
|
149
|
+
if (isWriteableSyntheticChange(change.type)) {
|
|
150
|
+
lastWriteableByPath.set(change.absolutePath, change);
|
|
151
|
+
}
|
|
152
|
+
else if (change.type === FileChangeType.Skip) {
|
|
153
|
+
skipPaths.add(change.absolutePath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const written = [];
|
|
157
|
+
let totalBytes = 0;
|
|
158
|
+
for (const [absolutePath, change] of lastWriteableByPath) {
|
|
95
159
|
try {
|
|
96
|
-
mkdirSync(nodePath.dirname(
|
|
97
|
-
writeFileSync(
|
|
160
|
+
mkdirSync(nodePath.dirname(absolutePath), { recursive: true });
|
|
161
|
+
writeFileSync(absolutePath, change.contents, 'utf8');
|
|
98
162
|
written.push(change);
|
|
99
163
|
totalBytes += change.sizeBytes;
|
|
100
164
|
}
|
|
101
165
|
catch (e) {
|
|
102
|
-
return err(new AppErrorImpl(ERROR_CODES.FILE_WRITE_ERROR, `Failed to write ${
|
|
166
|
+
return err(new AppErrorImpl(ERROR_CODES.FILE_WRITE_ERROR, `Failed to write ${absolutePath}`, { details: { path: absolutePath }, cause: e }));
|
|
103
167
|
}
|
|
104
168
|
}
|
|
169
|
+
// A path counts as skipped only when EVERY op on it resolved to Skip (a path
|
|
170
|
+
// that was also written is not a skip).
|
|
171
|
+
let skipped = 0;
|
|
172
|
+
for (const p of skipPaths) {
|
|
173
|
+
if (!lastWriteableByPath.has(p))
|
|
174
|
+
skipped += 1;
|
|
175
|
+
}
|
|
105
176
|
return ok({
|
|
106
177
|
summary: { written: written.length, skipped, conflicts: 0, totalBytes },
|
|
107
178
|
written,
|
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.31",
|
|
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.31",
|
|
47
|
+
"@shrkcrft/templates": "^0.1.0-alpha.31",
|
|
48
|
+
"@shrkcrft/rules": "^0.1.0-alpha.31",
|
|
49
|
+
"@shrkcrft/paths": "^0.1.0-alpha.31"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|