@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.
@@ -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
- const absolutePath = nodePath.resolve(projectRoot, relativePath);
66
- const existing = existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : null;
67
- return evaluatePlannedChange({
68
- change: { targetPath: relativePath, operation },
69
- absolutePath,
70
- relativePath,
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
- const written = [];
86
- let totalBytes = 0;
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(change.absolutePath), { recursive: true });
97
- writeFileSync(change.absolutePath, change.contents, 'utf8');
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 ${change.absolutePath}`, { details: { path: change.absolutePath }, cause: e }));
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",
3
+ "version": "0.1.0-alpha.30",
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.d.ts",
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.2",
47
- "@shrkcrft/templates": "^0.1.0-alpha.2",
48
- "@shrkcrft/rules": "^0.1.0-alpha.2",
49
- "@shrkcrft/paths": "^0.1.0-alpha.2"
46
+ "@shrkcrft/core": "^0.1.0-alpha.30",
47
+ "@shrkcrft/templates": "^0.1.0-alpha.30",
48
+ "@shrkcrft/rules": "^0.1.0-alpha.30",
49
+ "@shrkcrft/paths": "^0.1.0-alpha.30"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"