@shrkcrft/generator 0.1.0-alpha.22 → 0.1.0-alpha.23

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.
@@ -1 +1 @@
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"}
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,CAgJtB"}
@@ -12,7 +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
+ import { pathEscapesRootViaSymlink, safeResolveTargetPath } from '@shrkcrft/core';
16
16
  import { checkFolderOpSafety, FolderOpSafety } from "./folder-safety.js";
17
17
  function resolveAbs(projectRoot, p) {
18
18
  return nodePath.isAbsolute(p) ? p : nodePath.resolve(projectRoot, p);
@@ -93,6 +93,17 @@ export function applyFolderOps(ops, options) {
93
93
  applied.push({ ...baseResult, applied: false });
94
94
  continue;
95
95
  }
96
+ // Defense in depth: re-verify the SOURCE has not become a symlink that
97
+ // escapes the sandbox between safety-check and mutation (TOCTOU window).
98
+ if (pathEscapesRootViaSymlink(options.projectRoot, absTarget)) {
99
+ rejected.push({
100
+ ...baseResult,
101
+ applied: false,
102
+ safety: FolderOpSafety.Unsafe,
103
+ reason: `rename-folder source "${op.targetPath}" resolves outside the project root (symlink escape).`,
104
+ });
105
+ continue;
106
+ }
96
107
  try {
97
108
  renameSync(absTarget, safeAbsNewPath);
98
109
  applied.push({ ...baseResult, applied: true });
@@ -113,6 +124,19 @@ export function applyFolderOps(ops, options) {
113
124
  applied.push({ ...baseResult, applied: false });
114
125
  continue;
115
126
  }
127
+ // Defense in depth: re-verify the target has not become a symlink that
128
+ // escapes the sandbox before a recursive delete. checkFolderOpSafety
129
+ // already gates this; a second realpath probe closes the TOCTOU window
130
+ // so we never rmSync EXTERNAL data.
131
+ if (pathEscapesRootViaSymlink(options.projectRoot, absTarget)) {
132
+ rejected.push({
133
+ ...baseResult,
134
+ applied: false,
135
+ safety: FolderOpSafety.Unsafe,
136
+ reason: `delete-folder target "${op.targetPath}" resolves outside the project root (symlink escape).`,
137
+ });
138
+ continue;
139
+ }
116
140
  try {
117
141
  rmSync(absTarget, { recursive: true, force: false });
118
142
  applied.push({ ...baseResult, applied: true });
@@ -1 +1 @@
1
- {"version":3,"file":"folder-safety.d.ts","sourceRoot":"","sources":["../src/folder-safety.ts"],"names":[],"mappings":"AAeA,oBAAY,cAAc;IACxB,IAAI,SAAS;IACb,MAAM,WAAW;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAID,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,eAAe,GAAG,eAAe,EACvC,OAAO,CAAC,EAAE;IAAE,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAE,GACxC,qBAAqB,CAqDvB"}
1
+ {"version":3,"file":"folder-safety.d.ts","sourceRoot":"","sources":["../src/folder-safety.ts"],"names":[],"mappings":"AAgBA,oBAAY,cAAc;IACxB,IAAI,SAAS;IACb,MAAM,WAAW;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAID,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,eAAe,GAAG,eAAe,EACvC,OAAO,CAAC,EAAE;IAAE,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAE,GACxC,qBAAqB,CA+DvB"}
@@ -12,6 +12,7 @@
12
12
  import { existsSync, statSync } from 'node:fs';
13
13
  import * as nodePath from 'node:path';
14
14
  import * as nodeOs from 'node:os';
15
+ import { pathEscapesRootViaSymlink } from '@shrkcrft/core';
15
16
  export var FolderOpSafety;
16
17
  (function (FolderOpSafety) {
17
18
  FolderOpSafety["Safe"] = "safe";
@@ -33,6 +34,16 @@ export function checkFolderOpSafety(projectRoot, target, kind, options) {
33
34
  reason: `Target path "${target}" resolves to "${abs}" which is outside the project root "${normalisedRoot}".`,
34
35
  };
35
36
  }
37
+ // Realpath-aware containment — a lexically-clean path can still traverse an
38
+ // in-root symlink (e.g. `linkdir -> ../outside`) so that `linkdir/secret`
39
+ // physically lives outside the sandbox. A destructive rmSync/renameSync on
40
+ // such a path would corrupt EXTERNAL data while reporting success.
41
+ if (pathEscapesRootViaSymlink(normalisedRoot, abs)) {
42
+ return {
43
+ safety: FolderOpSafety.Unsafe,
44
+ reason: `Target path "${target}" resolves to "${abs}" which is outside the project root (symlink escape).`,
45
+ };
46
+ }
36
47
  if (abs === normalisedRoot) {
37
48
  return { safety: FolderOpSafety.Unsafe, reason: 'Target path resolves to the project root.' };
38
49
  }
@@ -1 +1 @@
1
- {"version":3,"file":"generator-engine.d.ts","sourceRoot":"","sources":["../src/generator-engine.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,MAAM,EACZ,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAEhF,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,kBAAkB,CAAC;IAC5B,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;CACjC;AAED,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,MAAM,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CA2DrC"}
1
+ {"version":3,"file":"generator-engine.d.ts","sourceRoot":"","sources":["../src/generator-engine.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,MAAM,EACZ,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAEhF,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,kBAAkB,CAAC;IAC5B,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;CACjC;AAED,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,MAAM,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CA2ErC"}
@@ -21,8 +21,15 @@ export function generate(template, request) {
21
21
  if (plan.hasConflicts) {
22
22
  return err(new AppErrorImpl(ERROR_CODES.TARGET_FILE_EXISTS, 'Generation refused: plan has conflicts (use --force / different overwrite strategy)', { details: { conflicts: plan.changes.filter((c) => c.type === FileChangeType.Conflict) } }));
23
23
  }
24
- const written = [];
25
- let totalBytes = 0;
24
+ // Multiple changes can target the SAME path (e.g. create-then-insert). The
25
+ // overlay in planGeneration makes each change's `contents` cumulative, so the
26
+ // LAST writeable change per path already carries the composed result. Write
27
+ // each distinct path once and count written/totalBytes by distinct path —
28
+ // matching the synthetic-plan writer. Writing every change would double-count
29
+ // and re-write the same file. (Single-op-per-path plans, the common case, are
30
+ // unchanged.)
31
+ const lastWriteableByPath = new Map();
32
+ const writeOrder = [];
26
33
  let skipped = 0;
27
34
  for (const change of plan.changes) {
28
35
  if (change.type === FileChangeType.Skip) {
@@ -30,15 +37,23 @@ export function generate(template, request) {
30
37
  continue;
31
38
  }
32
39
  if (isWriteableChange(change.type)) {
33
- try {
34
- mkdirSync(dirname(change.absolutePath), { recursive: true });
35
- writeFileSync(change.absolutePath, change.contents, 'utf8');
36
- written.push(change);
37
- totalBytes += change.sizeBytes;
38
- }
39
- catch (e) {
40
- return err(new AppErrorImpl(ERROR_CODES.FILE_WRITE_ERROR, `Failed to write ${change.absolutePath}`, { details: { path: change.absolutePath }, cause: e }));
41
- }
40
+ if (!lastWriteableByPath.has(change.absolutePath))
41
+ writeOrder.push(change.absolutePath);
42
+ lastWriteableByPath.set(change.absolutePath, change);
43
+ }
44
+ }
45
+ const written = [];
46
+ let totalBytes = 0;
47
+ for (const path of writeOrder) {
48
+ const change = lastWriteableByPath.get(path);
49
+ try {
50
+ mkdirSync(dirname(change.absolutePath), { recursive: true });
51
+ writeFileSync(change.absolutePath, change.contents, 'utf8');
52
+ written.push(change);
53
+ totalBytes += change.sizeBytes;
54
+ }
55
+ catch (e) {
56
+ return err(new AppErrorImpl(ERROR_CODES.FILE_WRITE_ERROR, `Failed to write ${change.absolutePath}`, { details: { path: change.absolutePath }, cause: e }));
42
57
  }
43
58
  }
44
59
  return ok({
@@ -112,6 +112,21 @@ export interface IInsertArrayEntryOperation {
112
112
  * before the array's matching closing bracket.
113
113
  */
114
114
  arrayName: string;
115
+ /**
116
+ * Fallback array identifiers tried, in order, when `arrayName` does not
117
+ * resolve in the target project (registration arrays are often named
118
+ * differently across codebases). The first cleanly-resolved candidate
119
+ * wins. When none resolve, the op surfaces an actionable manual-wiring
120
+ * conflict instead of an opaque "array not found".
121
+ */
122
+ arrayNameAlternatives?: readonly string[];
123
+ /**
124
+ * Precise manual-wiring instruction shown when no candidate array resolves.
125
+ * When set, it replaces the synthesized "wire <entry> into <array> manually"
126
+ * message — letting a template author mark this step as a MANUAL follow-up
127
+ * with exact guidance instead of an opaque conflict.
128
+ */
129
+ manualStepInstruction?: string;
115
130
  /** Element source text to add (already source-formatted, no trailing comma). */
116
131
  entryValue: string;
117
132
  /** Optional idempotency marker (default = `entryValue`). */
@@ -1 +1 @@
1
- {"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,oBAAoB,GAC5B,QAAQ,GACR,QAAQ,GACR,cAAc,GACd,eAAe,GACf,SAAS,GACT,QAAQ,GACR,eAAe,GACf,mBAAmB,GACnB,qBAAqB,GACrB,oBAAoB,GACpB,6BAA6B,GAC7B,wBAAwB,CAAC;AAE7B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,QAAQ,CAAC;IACf;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,cAAc,CAAC;IACrB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,eAAe,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,SAAS,CAAC;IAChB,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,QAAQ,CAAC;IACf,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,eAAe,CAAC;IACtB,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6DAA6D;IAC7D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,mBAAmB,CAAC;IAC1B,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,qBAAqB,CAAC;IAC5B,4CAA4C;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,oBAAoB,CAAC;IAC3B;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kCAAkC;IACjD,IAAI,EAAE,6BAA6B,CAAC;IACpC,+DAA+D;IAC/D,aAAa,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,8BAA8B;IAC7C,IAAI,EAAE,wBAAwB,CAAC;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,iBAAiB,GACzB,gBAAgB,GAChB,gBAAgB,GAChB,qBAAqB,GACrB,sBAAsB,GACtB,iBAAiB,GACjB,gBAAgB,GAChB,sBAAsB,GACtB,yBAAyB,GACzB,2BAA2B,GAC3B,0BAA0B,GAC1B,kCAAkC,GAClC,8BAA8B,CAAC"}
1
+ {"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":"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"}
@@ -309,12 +309,26 @@ function evaluateInsertArrayEntry(op, absolutePath, relativePath, existing) {
309
309
  if (existing === null) {
310
310
  return mkChange(FileChangeType.Conflict, absolutePath, relativePath, '', 'insert-array-entry: target file does not exist', op);
311
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);
312
+ // Try the primary array name, then each declared alternative in order. The
313
+ // first cleanly-resolved (present + unambiguous) array wins. This lets a
314
+ // template target a project whose registration array is named differently
315
+ // without silently dead-ending.
316
+ const candidates = [op.arrayName, ...(op.arrayNameAlternatives ?? [])].filter((n) => n.length > 0);
317
+ let arr = null;
318
+ let sawAmbiguous = false;
319
+ for (const name of candidates) {
320
+ const found = findArrayLiteralBlock(existing, name);
321
+ if (!found)
322
+ continue;
323
+ if (found.duplicate) {
324
+ sawAmbiguous = true;
325
+ continue;
326
+ }
327
+ arr = found;
328
+ break;
315
329
  }
316
- if (arr.duplicate) {
317
- return mkChange(FileChangeType.Conflict, absolutePath, relativePath, existing, `insert-array-entry: array "${op.arrayName}" appears multiple times (ambiguous)`, op);
330
+ if (!arr) {
331
+ return mkChange(FileChangeType.Conflict, absolutePath, relativePath, existing, unresolvedArrayReason(op, candidates, sawAmbiguous), op);
318
332
  }
319
333
  // Idempotency: skip when the element (or its caller-supplied marker) is
320
334
  // already present anywhere inside the array body.
@@ -572,3 +586,26 @@ function detectIndent(body) {
572
586
  return null;
573
587
  return match[1] ?? null;
574
588
  }
589
+ /**
590
+ * Build the actionable reason for an `insert-array-entry` op whose target
591
+ * array (and all declared alternatives) could not be resolved. Instead of an
592
+ * opaque "array not found", the message tells the human exactly what to wire
593
+ * by hand — preserving the template's "zero manual wiring" promise as an
594
+ * explicit, honest fallback. A template author may override the message with
595
+ * `op.manualStepInstruction`.
596
+ */
597
+ function unresolvedArrayReason(op, candidates, sawAmbiguous) {
598
+ if (op.manualStepInstruction && op.manualStepInstruction.trim().length > 0) {
599
+ return `insert-array-entry: MANUAL — ${op.manualStepInstruction.trim()}`;
600
+ }
601
+ const entry = oneLineEntryLabel(op.ifMissing ?? op.entryValue);
602
+ const tried = candidates.map((c) => `"${c}"`).join(', ');
603
+ const why = sawAmbiguous
604
+ ? `registration array ${tried} appears multiple times (ambiguous)`
605
+ : `no registration array found (tried ${tried})`;
606
+ return `insert-array-entry: ${why} — wire ${entry} into ${op.arrayName} manually`;
607
+ }
608
+ function oneLineEntryLabel(raw) {
609
+ const collapsed = raw.replace(/\s+/g, ' ').trim();
610
+ return collapsed.length > 60 ? collapsed.slice(0, 57) + '…' : collapsed;
611
+ }
@@ -1 +1 @@
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"}
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"}
@@ -27,6 +27,14 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
27
27
  const warnings = [];
28
28
  let hasConflicts = false;
29
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();
30
38
  for (const e of expected) {
31
39
  if (!e.operation) {
32
40
  // Without an operation we cannot reconstruct contents — surface as
@@ -43,7 +51,7 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
43
51
  hasConflicts = true;
44
52
  continue;
45
53
  }
46
- const change = applyOperation(projectRoot, e.relativePath, e.operation);
54
+ const change = applyOperation(projectRoot, e.relativePath, e.operation, overlay);
47
55
  if (change.type === FileChangeType.Conflict)
48
56
  hasConflicts = true;
49
57
  changes.push(change);
@@ -62,7 +70,7 @@ export function evaluateSavedPlanInPlace(plan, projectRoot) {
62
70
  }
63
71
  return out;
64
72
  }
65
- function applyOperation(projectRoot, relativePath, operation) {
73
+ function applyOperation(projectRoot, relativePath, operation, overlay) {
66
74
  // Route through the single generator chokepoint instead of a bare resolve, so
67
75
  // a traversal / absolute `relativePath` in a hand-crafted or tampered plan
68
76
  // can't write OUTSIDE the project root. An unsafe path becomes a Conflict,
@@ -82,13 +90,24 @@ function applyOperation(projectRoot, relativePath, operation) {
82
90
  sizeBytes: 0,
83
91
  };
84
92
  }
85
- const existing = existsSync(safe.absolutePath) ? readFileSync(safe.absolutePath, 'utf8') : null;
86
- return evaluatePlannedChange({
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({
87
102
  change: { targetPath: safe.relativePath, operation },
88
103
  absolutePath: safe.absolutePath,
89
104
  relativePath: safe.relativePath,
90
105
  existing,
91
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;
92
111
  }
93
112
  /**
94
113
  * Write evaluated changes from a synthetic plan directly. The caller
@@ -118,26 +137,42 @@ export function writeSyntheticPlan(plan) {
118
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 } }));
119
138
  }
120
139
  }
121
- const written = [];
122
- let totalBytes = 0;
123
- let skipped = 0;
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();
124
148
  for (const change of plan.changes) {
125
- if (change.type === FileChangeType.Skip) {
126
- skipped += 1;
127
- continue;
149
+ if (isWriteableSyntheticChange(change.type)) {
150
+ lastWriteableByPath.set(change.absolutePath, change);
128
151
  }
129
- if (!isWriteableSyntheticChange(change.type))
130
- continue;
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) {
131
159
  try {
132
- mkdirSync(nodePath.dirname(change.absolutePath), { recursive: true });
133
- writeFileSync(change.absolutePath, change.contents, 'utf8');
160
+ mkdirSync(nodePath.dirname(absolutePath), { recursive: true });
161
+ writeFileSync(absolutePath, change.contents, 'utf8');
134
162
  written.push(change);
135
163
  totalBytes += change.sizeBytes;
136
164
  }
137
165
  catch (e) {
138
- 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 }));
139
167
  }
140
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
+ }
141
176
  return ok({
142
177
  summary: { written: written.length, skipped, conflicts: 0, totalBytes },
143
178
  written,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shrkcrft/generator",
3
- "version": "0.1.0-alpha.22",
3
+ "version": "0.1.0-alpha.23",
4
4
  "description": "SharkCraft plan-first generator: GenerationPlan, FileChange, dry-run, safe writes.",
5
5
  "license": "MIT",
6
6
  "author": "SharkCraft contributors",
@@ -43,10 +43,10 @@
43
43
  "typecheck": "tsc --noEmit -p tsconfig.json"
44
44
  },
45
45
  "dependencies": {
46
- "@shrkcrft/core": "^0.1.0-alpha.22",
47
- "@shrkcrft/templates": "^0.1.0-alpha.22",
48
- "@shrkcrft/rules": "^0.1.0-alpha.22",
49
- "@shrkcrft/paths": "^0.1.0-alpha.22"
46
+ "@shrkcrft/core": "^0.1.0-alpha.23",
47
+ "@shrkcrft/templates": "^0.1.0-alpha.23",
48
+ "@shrkcrft/rules": "^0.1.0-alpha.23",
49
+ "@shrkcrft/paths": "^0.1.0-alpha.23"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"