@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.
@@ -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
+ }
@@ -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
- /** "added" | "removed" | "type-changed" | "size-changed" | "operation-changed" */
95
- kind: 'added' | 'removed' | 'type-changed' | 'size-changed' | 'operation-changed';
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
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"saved-plan.d.ts","sourceRoot":"","sources":["../src/saved-plan.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"saved-plan.d.ts","sourceRoot":"","sources":["../src/saved-plan.ts"],"names":[],"mappings":"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"}
@@ -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":"AAkBA,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;AAuBD,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;AAED,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,eAAe,GACpB,MAAM,CAAC,qBAAqB,EAAE,QAAQ,CAAC,CAsCzC"}
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"}
@@ -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.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.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.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"