@noir-ai/create 1.3.0-beta.6 → 1.4.0-beta.2

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/index.d.ts CHANGED
@@ -8,7 +8,9 @@ declare function ancestorsPath(root: string): string;
8
8
  declare function readAncestors(root: string): Record<string, string>;
9
9
  /** Write the ancestor map. Ensures `.noir/` exists (a standalone caller may
10
10
  * invoke this before the scaffold has created the dir). Derived state, so the
11
- * plain write (no tmp) is fine. */
11
+ * plain write (no tmp) is fine. Content-hash dedup — when the serialized
12
+ * bytes equal what is already on disk, the rewrite is skipped so a no-op sync
13
+ * leaves ancestors.json untouched (no mtime/git churn). */
12
14
  declare function writeAncestors(root: string, map: Record<string, string>): void;
13
15
 
14
16
  /**
@@ -116,7 +118,7 @@ declare function skipIfExists(absPath: string, content: string): WriteOutcome;
116
118
  * entries + a {@link buildHostArtifacts} call that materializes per-host files
117
119
  * (CLAUDE.md/GEMINI.md for claude/gemini; AGENTS.md + .cursor/.../opencode.json
118
120
  * for agents-md/cursor/opencode) via the resolved adapter. The claude default
119
- * `noir init` stays BYTE-IDENTICAL to v1.1 — fix-wave I1 REMOVED the additive
121
+ * `noir init` stays BYTE-IDENTICAL to v1.1 — REMOVED the additive
120
122
  * root `AGENTS.md` (it was double-importing `.noir/NOIR.md` + RULES.md via
121
123
  * CLAUDE.md's existing @-imports; claude's native surface is CLAUDE.md alone).
122
124
  *
@@ -184,7 +186,7 @@ declare const MANIFEST_PATH_PARITY: ReadonlyArray<[
184
186
  * S10 structure: the manifest is now `[...hostAgnosticEntries(ctx), ...hostSpecificEntries(ctx)]`
185
187
  * where the host-specific half comes from {@link buildHostArtifacts} (driven by
186
188
  * `resolveAdapter(ctx.host)`). The host-agnostic half is unchanged from v1.1
187
- * (canonical `.noir/` store + ignore files). Fix-wave I1: {@link buildHostArtifacts}
189
+ * (canonical `.noir/` store + ignore files). {@link buildHostArtifacts}
188
190
  * emits AGENTS.md ONLY for agents-md/cursor/opencode (claude/gemini use their
189
191
  * own CLAUDE.md/GEMINI.md — emitting AGENTS.md too would double-import .noir/).
190
192
  *
@@ -238,7 +240,7 @@ interface BuildHostArtifactsContext {
238
240
  * gemini's in GEMINI.md, agents-md/cursor/opencode's in AGENTS.md — NO
239
241
  * host emits a separate rules file. (The prior cursor
240
242
  * `.cursor/rules/noir-contract.mdc` host-rules pointer was REMOVED: it
241
- * collided with the C3 cursor flat-skill prune of `noir-*.mdc` under
243
+ * collided with the cursor flat-skill prune of `noir-*.mdc` under
242
244
  * `.cursor/rules/`, and cursor's rules are already delivered via
243
245
  * AGENTS.md's `@.noir/rules/RULES.md` import.)
244
246
  * 3. **Host MCP config** — `regenerate` at `adapter.mcpConfigPath(ctx)`
@@ -250,7 +252,7 @@ interface BuildHostArtifactsContext {
250
252
  *
251
253
  * Skills are OUT OF SCOPE here — the cli composes `emitSkillsToDir` with
252
254
  * `adapter.skillsDir` + the host's `CompileTarget` (claude → `.claude/skills/`
253
- * as SKILL.md; cursor → `.cursor/rules/<skill>.mdc` FLAT per C3; gemini/
255
+ * as SKILL.md; cursor → `.cursor/rules/<skill>.mdc` FLAT; gemini/
254
256
  * agents-md/opencode have no skill dir → skip).
255
257
  */
256
258
  declare function buildHostArtifacts(adapter: HostAdapter, ctx: BuildHostArtifactsContext): ManifestEntry[];
@@ -261,14 +263,51 @@ interface MergeResult {
261
263
  /** True when ours and theirs diverged in the same region (markers emitted). */
262
264
  conflict: boolean;
263
265
  }
266
+ /**
267
+ * Unified line diff for the conflict resolver's stderr preview. Returns a
268
+ * sequence of {type:'eq'|'del'|'add', line} records, LCS-based, equivalent in
269
+ * shape to `git diff --no-color`. Pure (no IO) so it unit-tests cleanly and the
270
+ * resolver can render it through {@link packages/cli/src/theme.ts} (`+` green,
271
+ * `-` red, context dim). Reuses the SAME LCS machinery {@link mergeThreeWay}
272
+ * does so there is exactly one line-diff algorithm in the package.
273
+ *
274
+ * `context` (default 3) — number of unchanged lines to keep around each hunk
275
+ * (git-style). 0 → every non-equal line stands alone; large → whole-file blob.
276
+ * The resolver uses the default; tests pin to a specific shape.
277
+ */
278
+ interface DiffLine {
279
+ type: 'eq' | 'del' | 'add';
280
+ line: string;
281
+ }
282
+ declare function lineDiff(base: string, head: string, context?: number): DiffLine[];
283
+ /**
284
+ * zdiff3-style conflict markers for an unresolved overlap. Shape:
285
+ * `<<<<<<< ours`
286
+ * <ours lines>
287
+ * `||||||| base`
288
+ * <base lines>
289
+ * `=======`
290
+ * <theirs lines>
291
+ * `>>>>>>> theirs`
292
+ *
293
+ * `diff3(zdiff3)` writes the SAME shape — included here so the engine + clack
294
+ * resolver have a single source of truth for marker layout. Returns the marked
295
+ * region body (no outer delimiters); the caller wraps it with surrounding
296
+ * context.
297
+ */
298
+ declare function zdiff3Region(ours: readonly string[], base: readonly string[], theirs: readonly string[]): string[];
264
299
  /**
265
300
  * Three-way merge of `ours` (the user's current region) against `theirs` (the
266
301
  * new template) with `base` (the last-emitted ancestor). Line-level diff3:
267
302
  * changes in disjoint line ranges merge; overlapping changes become inline
268
- * conflict markers (`<<<<<<< ours` / `=======` / `>>>>>>> theirs`). Pure +
269
- * deterministic (no IO) so it's unit-testable.
303
+ * conflict markers. Default marker shape is the minimal diff3 form
304
+ * (`<<<<<<< ours` / `=======` / `>>>>>>> theirs`) — byte-identical to v1.2 so
305
+ * {@link mergeManagedRegion} stays regression-anchored. Pass `'zdiff3'` to
306
+ * include the base section (`||||||| base`) — used by the conflict resolver's
307
+ * 6th "merge (with conflict markers)" option. Pure + deterministic
308
+ * (no IO) so it's unit-testable.
270
309
  */
271
- declare function mergeThreeWay(base: string, ours: string, theirs: string): MergeResult;
310
+ declare function mergeThreeWay(base: string, ours: string, theirs: string, style?: 'minimal' | 'zdiff3'): MergeResult;
272
311
 
273
312
  /**
274
313
  * Migration model — Nx-style declarative upgrade scripts, hand-rolled.
@@ -408,15 +447,25 @@ interface ScaffoldOptions {
408
447
  conflictPolicy?: 'overwrite' | 'preserve';
409
448
  /** SP-C: per-file conflict resolver — the UI seam (the engine stays UI-free;
410
449
  * the cli injects a @clack-based resolver). Called when a `regenerate`
411
- * file exists and differs from the template. */
412
- onConflict?: (ctx: ConflictContext) => Promise<ConflictResolution> | ConflictResolution;
413
- /** SP-D follow-up: three-way merge managed regions (base/ours/theirs) using a
414
- * persisted ancestor snapshot (`.noir/ancestors.json`) instead of
415
- * strip-replace, so a hand-edit inside a `<!-- noir:* -->` region survives a
416
- * template update. Opt-in (default false ⇒ current strip-replace, no
417
- * ancestor file). Single-region managed files only (NOIR.md, ignores);
418
- * multi-region (CLAUDE.md) is a follow-up. */
450
+ * file exists and differs from the template. The return type widens to
451
+ * accept a rich shape carrying `applyToAll` — the engine then reuses the
452
+ * decision for the rest of the run (per artifact CLASS). */
453
+ onConflict?: (ctx: ConflictContext) => Promise<ConflictResolverReturn> | ConflictResolverReturn;
454
+ /** Three-way merge managed regions (base/ours/theirs) using a persisted
455
+ * ancestor snapshot (`.noir/ancestors.json`) instead of strip-replace, so a
456
+ * hand-edit inside a `<!-- noir:* -->` region survives a template update.
457
+ * DEFAULT TRUE: ancestor capture is unconditional, so the first merge
458
+ * run always has a base. Set to `false` (CLI `--no-merge-regions`) to
459
+ * restore strip-replace. Supports BOTH single-region (NOIR.md, ignores) AND
460
+ * multi-region (CLAUDE.md CONTEXT+RULES) managed files — the multi-region
461
+ * path shipped with SP-H (`managedBlocks` + `mergeManagedRegion` per block). */
419
462
  mergeManagedRegions?: boolean;
463
+ /** Explicit interactivity signal. The engine reads THIS (not
464
+ * `process.env`), so a direct API/embedded caller in a TTY that does NOT
465
+ * inject {@link onConflict} never hits a @clack prompt. The CLI sets it from
466
+ * its global flags (via the `NOIR_NON_INTERACTIVE` bridge bin.ts owns);
467
+ * `process.env` remains only a fallback bridge for the CLI layer. */
468
+ interactive?: boolean;
420
469
  }
421
470
  interface ScaffoldResult {
422
471
  /** Repo-relative paths actually written. */
@@ -441,6 +490,28 @@ interface ScaffoldResult {
441
490
  toVersion: string;
442
491
  /** The host actually emitted (post-default). */
443
492
  host: HostTag;
493
+ /** Structured conflict report — one record per file that existed AND
494
+ * differed from the proposed bytes (regenerate conflict path). Populated in
495
+ * `--json`/`--no-input`/non-TTY runs (no prompt fires) so a CI/JSON caller
496
+ * can see exactly which files diverged + how they were resolved, without
497
+ * grepping stderr. Empty when no conflicts occurred (also on first-run +
498
+ * no-op). */
499
+ conflicts: ConflictRecord[];
500
+ }
501
+ /** One entry in {@link ScaffoldResult.conflicts}. */
502
+ interface ConflictRecord {
503
+ /** Repo-relative path of the conflicting file. */
504
+ path: string;
505
+ /** Artifact class — drives apply-to-all memory scope + report grouping. */
506
+ mode: ConflictContext['mode'];
507
+ /** LCS similarity (0-1) between existing and proposed. */
508
+ similarity?: number;
509
+ /** sha256 (hex, first 12 chars) of the on-disk bytes at conflict time. */
510
+ existingSha: string;
511
+ /** sha256 (hex, first 12 chars) of the proposed bytes. */
512
+ proposedSha: string;
513
+ /** Resolution the engine applied (`replace`/`preserve`/`rename`/…). */
514
+ resolution: ConflictResolution;
444
515
  }
445
516
  /** SP-C — context passed to {@link ScaffoldOptions.onConflict} when a
446
517
  * `regenerate` file exists and differs from the template. */
@@ -451,9 +522,29 @@ interface ConflictContext {
451
522
  existing: string;
452
523
  /** The content the scaffold would write. */
453
524
  proposed: string;
525
+ /** Artifact class — drives apply-to-all memory scope (`regenerate` shares
526
+ * one decision across a run; `managedBlock`/`managedBlocks` stay per-file).
527
+ * Defaults to `'regenerate'` for backward compatibility with SP-C resolvers
528
+ * that pre-date the field (the engine always sets it). */
529
+ mode?: 'regenerate' | 'managedBlock' | 'managedBlocks' | 'skipIfExists' | 'skill' | 'markdown' | 'artifact';
530
+ /** LCS similarity (0-1) between existing and proposed. Cheap signal for
531
+ * the resolver to bias toward `replace` when ~1.0 or `preserve` when ~0. */
532
+ similarity?: number;
533
+ /** When the 3-way merge of a managed region hit an overlap, the merged
534
+ * bytes WITH zdiff3 conflict markers (selecting `'merge'` writes this). */
535
+ mergedWithMarkers?: string;
454
536
  }
455
537
  /** SP-C — how to resolve a `regenerate` conflict. */
456
- type ConflictResolution = 'replace' | 'preserve' | 'rename' | 'duplicate' | 'cancel';
538
+ type ConflictResolution = 'replace' | 'preserve' | 'rename' | 'duplicate' | 'cancel' | 'merge';
539
+ /** The resolver may return a bare {@link ConflictResolution} (scope
540
+ * `'one'`) OR a rich shape carrying `applyToAll`. The engine unwraps both; when
541
+ * `applyToAll` is true it stores the choice in its per-run memory keyed by
542
+ * artifact CLASS (`regenerate` shares one decision across a run; managedBlock
543
+ * stays per-file regardless — user edits there need individual review). */
544
+ type ConflictResolverReturn = ConflictResolution | {
545
+ resolution: ConflictResolution;
546
+ applyToAll?: boolean;
547
+ };
457
548
  /**
458
549
  * Refuse to scaffold when `root` is — or is inside — a `.noir/` directory.
459
550
  * (SP-A) Running `noir init`/`create`/`sync` while cwd = `.noir/` would
@@ -531,4 +622,4 @@ declare function loadTemplate(name: string): string;
531
622
  /** Resolve a template name to its absolute path (for diagnostics + tests). */
532
623
  declare function templatesDir(): string;
533
624
 
534
- export { BRIEF_BLOCK, type BuildHostArtifactsContext, type BuildManifestContext, CURRENT_SCAFFOLD_VERSION, type ConflictContext, type ConflictResolution, type HostTag, MANIFEST_PATH_PARITY, MIGRATIONS, type ManifestEntry, type MergeResult, type MigrationContext, type MigrationResult, type MigrationScript, type ScaffoldMode, type ScaffoldOptions, type ScaffoldResult, type StackInfo, type WriteMode, type WriteOutcome, ancestorsPath, applyInlineConflict, applyWithConflict, assertSafeRoot, buildHostArtifacts, buildManifest, buildRegion, detectStack, loadTemplate, managedBlock, mergeThreeWay, readAncestors, readScaffoldVersion, regenerate, render, runMigrations, scaffold, scaffoldVersionPath, skipIfExists, templatesDir, writeAncestors, writeScaffoldVersion };
625
+ export { BRIEF_BLOCK, type BuildHostArtifactsContext, type BuildManifestContext, CURRENT_SCAFFOLD_VERSION, type ConflictContext, type ConflictRecord, type ConflictResolution, type ConflictResolverReturn, type DiffLine, type HostTag, MANIFEST_PATH_PARITY, MIGRATIONS, type ManifestEntry, type MergeResult, type MigrationContext, type MigrationResult, type MigrationScript, type ScaffoldMode, type ScaffoldOptions, type ScaffoldResult, type StackInfo, type WriteMode, type WriteOutcome, ancestorsPath, applyInlineConflict, applyWithConflict, assertSafeRoot, buildHostArtifacts, buildManifest, buildRegion, detectStack, lineDiff, loadTemplate, managedBlock, mergeThreeWay, readAncestors, readScaffoldVersion, regenerate, render, runMigrations, scaffold, scaffoldVersionPath, skipIfExists, templatesDir, writeAncestors, writeScaffoldVersion, zdiff3Region };
package/dist/index.js CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/ancestors.ts
2
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
10
  import { dirname, join } from "path";
@@ -19,9 +26,15 @@ function readAncestors(root) {
19
26
  }
20
27
  }
21
28
  function writeAncestors(root, map) {
22
- mkdirSync(dirname(ancestorsPath(root)), { recursive: true });
23
- writeFileSync(ancestorsPath(root), `${JSON.stringify(map, null, 2)}
24
- `, "utf8");
29
+ const path = ancestorsPath(root);
30
+ const next = `${JSON.stringify(map, null, 2)}
31
+ `;
32
+ try {
33
+ if (readFileSync(path, "utf8") === next) return;
34
+ } catch {
35
+ }
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ writeFileSync(path, next, "utf8");
25
38
  }
26
39
 
27
40
  // src/manifest.ts
@@ -253,8 +266,71 @@ function lcsMatch(a, b) {
253
266
  }
254
267
  return out;
255
268
  }
269
+ function lineDiff(base, head, context = 3) {
270
+ const A = base.split("\n");
271
+ const B = head.split("\n");
272
+ const pairs = lcsMatch(A, B);
273
+ const inA = new Set(pairs.map((p) => p[0]));
274
+ const inB = new Set(pairs.map((p) => p[1]));
275
+ const out = [];
276
+ let i = 0;
277
+ let j = 0;
278
+ const bToH = /* @__PURE__ */ new Map();
279
+ for (const [bi, hi] of pairs) bToH.set(bi, hi);
280
+ while (i < A.length || j < B.length) {
281
+ const matched = bToH.get(i);
282
+ if (matched !== void 0 && matched === j) {
283
+ out.push({ type: "eq", line: A[i] ?? "" });
284
+ i++;
285
+ j++;
286
+ } else if (matched !== void 0) {
287
+ out.push({ type: "add", line: B[j] ?? "" });
288
+ j++;
289
+ } else if (i < A.length && !inA.has(i)) {
290
+ out.push({ type: "del", line: A[i] ?? "" });
291
+ i++;
292
+ } else if (j < B.length && !inB.has(j)) {
293
+ out.push({ type: "add", line: B[j] ?? "" });
294
+ j++;
295
+ } else {
296
+ if (i < A.length) i++;
297
+ else j++;
298
+ }
299
+ }
300
+ return context > 0 ? collapseContext(out, context) : out;
301
+ }
302
+ function collapseContext(lines, context) {
303
+ const out = [];
304
+ const keep = new Array(lines.length).fill(false);
305
+ for (let i = 0; i < lines.length; i++) {
306
+ const dl = lines[i];
307
+ if (dl && dl.type !== "eq") {
308
+ for (let k = Math.max(0, i - context); k <= Math.min(lines.length - 1, i + context); k++) {
309
+ keep[k] = true;
310
+ }
311
+ }
312
+ }
313
+ let inRun = false;
314
+ for (let i = 0; i < lines.length; i++) {
315
+ const dl = lines[i];
316
+ if (!dl) continue;
317
+ if (keep[i]) {
318
+ out.push(dl);
319
+ inRun = false;
320
+ } else {
321
+ if (!inRun) {
322
+ out.push({ type: "eq", line: "\u2026" });
323
+ inRun = true;
324
+ }
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+ function zdiff3Region(ours, base, theirs) {
330
+ return ["<<<<<<< ours", ...ours, "||||||| base", ...base, "=======", ...theirs, ">>>>>>> theirs"];
331
+ }
256
332
  var eq = (a, b) => a.length === b.length && a.every((v, k) => v === b[k]);
257
- function mergeThreeWay(base, ours, theirs) {
333
+ function mergeThreeWay(base, ours, theirs, style = "minimal") {
258
334
  const t = trivial(base, ours, theirs);
259
335
  if (t) return t;
260
336
  const B = base.split("\n");
@@ -287,7 +363,11 @@ function mergeThreeWay(base, ours, theirs) {
287
363
  else if (eq(oSeg, tSeg)) out.push(...oSeg);
288
364
  else {
289
365
  conflict = true;
290
- out.push("<<<<<<< ours", ...oSeg, "=======", ...tSeg, ">>>>>>> theirs");
366
+ if (style === "zdiff3") {
367
+ out.push(...zdiff3Region(oSeg, baseSeg, tSeg));
368
+ } else {
369
+ out.push("<<<<<<< ours", ...oSeg, "=======", ...tSeg, ">>>>>>> theirs");
370
+ }
291
371
  }
292
372
  if (bBase < B.length) out.push(B[bBase] ?? "");
293
373
  }
@@ -458,6 +538,30 @@ ${body.trimEnd()}
458
538
  ${block.end}
459
539
  `;
460
540
  }
541
+ function predictManagedBlock(currentContent, block, regionText) {
542
+ const stripped = stripManagedBlock(currentContent, block);
543
+ return stripped ? `${stripped.trimEnd()}
544
+
545
+ ${regionText}` : regionText;
546
+ }
547
+ function predictManagedBlocks(currentContent, regions) {
548
+ if (regions.length === 0) {
549
+ throw new Error("predictManagedBlocks requires at least one region");
550
+ }
551
+ if (regions.length === 1) {
552
+ const only = regions[0];
553
+ if (!only) throw new Error("predictManagedBlocks: undefined region");
554
+ return predictManagedBlock(currentContent, only.block, only.regionText);
555
+ }
556
+ let stripped = currentContent;
557
+ for (const r of regions) {
558
+ stripped = stripManagedBlock(stripped, r.block);
559
+ }
560
+ const regionsJoined = regions.map((r) => r.regionText).join("\n");
561
+ return stripped.trim().length > 0 ? `${stripped.trimEnd()}
562
+
563
+ ${regionsJoined}` : regionsJoined;
564
+ }
461
565
  function skipIfExists(absPath, content) {
462
566
  if (existsSync4(absPath)) {
463
567
  return { path: absPath, mode: "skipIfExists", written: false };
@@ -684,11 +788,14 @@ async function scaffold(opts) {
684
788
  }
685
789
  const fromVersion = readScaffoldVersion(opts.root);
686
790
  const stack = detectStack(opts.root);
687
- const ancestors = opts.mergeManagedRegions ? readAncestors(opts.root) : {};
688
- if (fromVersion !== null && opts.upgrade !== true && opts.force !== true && (opts.mode === "init" || opts.mode === "create")) {
791
+ const ancestors = readAncestors(opts.root);
792
+ const mergeManagedRegions = opts.mergeManagedRegions !== false;
793
+ const hasIdentity = fromVersion !== null || idFile.state === "valid";
794
+ if (hasIdentity && opts.upgrade !== true && opts.force !== true && (opts.mode === "init" || opts.mode === "create")) {
689
795
  if (opts.dryRun !== true) {
796
+ const where = fromVersion !== null ? `at scaffold ${fromVersion}` : "(pre-1.3.0 project; no scaffold-version stamp)";
690
797
  process.stderr.write(
691
- `Noir is already initialized in ${opts.root} (scaffold ${fromVersion}). No-op. Use \`noir init --upgrade\` to migrate, or \`--force\` to re-scaffold.
798
+ `Noir already initialized in ${opts.root} ${where}; run \`noir init --upgrade\` to migrate.
692
799
  `
693
800
  );
694
801
  }
@@ -696,6 +803,7 @@ async function scaffold(opts) {
696
803
  written: [],
697
804
  skipped: [],
698
805
  identical: [],
806
+ conflicts: [],
699
807
  noop: true,
700
808
  migrationsRan: [],
701
809
  migrationConflicts: [],
@@ -734,6 +842,18 @@ async function scaffold(opts) {
734
842
  const written = [];
735
843
  const skipped = [];
736
844
  const identical = [];
845
+ const conflictMemory = /* @__PURE__ */ new Map();
846
+ const conflictRecords = [];
847
+ const recordConflict = (relPath, mode, existing, proposed, resolution, similarity2) => {
848
+ conflictRecords.push({
849
+ path: relPath,
850
+ mode,
851
+ similarity: similarity2,
852
+ existingSha: sha256Hex12(existing),
853
+ proposedSha: sha256Hex12(proposed),
854
+ resolution
855
+ });
856
+ };
737
857
  const groups = groupApplicableByPath(manifest, host, emitRuntimeOnly);
738
858
  for (const [relPath, entries] of groups) {
739
859
  const abs = join8(opts.root, relPath);
@@ -744,18 +864,44 @@ async function scaffold(opts) {
744
864
  continue;
745
865
  }
746
866
  mkdirSync3(dirname5(abs), { recursive: true });
867
+ const onDisk = readOptional(abs);
868
+ let hadMergeConflict = false;
747
869
  const regions = managed.map((e) => {
748
870
  const block = e.block;
749
871
  if (!block) {
750
872
  throw new Error(`manifest entry ${e.path}: managedBlock mode missing 'block'`);
751
873
  }
752
874
  const theirs = buildRegion(block, renderEntry(e, vars));
753
- const regionText = opts.mergeManagedRegions ? mergeManagedRegion(abs, e.path, block, theirs, ancestors) : theirs;
754
- if (opts.mergeManagedRegions) ancestors[`${e.path}::${block.begin}`] = theirs;
755
- return { block, regionText };
875
+ const merged = mergeManagedRegions ? mergeManagedRegion(abs, e.path, block, theirs, ancestors) : { text: theirs, conflict: false, cleanTheirs: theirs };
876
+ if (merged.conflict) hadMergeConflict = true;
877
+ ancestors[`${e.path}::${block.begin}`] = theirs;
878
+ return { block, regionText: merged.text };
756
879
  });
757
- managedBlocks(abs, regions);
758
- written.push(relPath);
880
+ if (hadMergeConflict) {
881
+ const ctxMode = "managedBlocks";
882
+ const resolution = await resolveManagedConflict(relPath, ctxMode, opts);
883
+ if (resolution === "cancel") {
884
+ throw new Error(`scaffold cancelled by user at conflicting file ${relPath}`);
885
+ }
886
+ if (resolution !== void 0 && resolution !== "merge") {
887
+ process.stderr.write(
888
+ `noir: managed-region conflict in ${relPath} \u2014 wrote inline markers; resolve manually.
889
+ `
890
+ );
891
+ } else {
892
+ process.stderr.write(
893
+ `noir: managed-region conflict in ${relPath} \u2014 wrote inline markers; resolve manually.
894
+ `
895
+ );
896
+ }
897
+ }
898
+ const predicted = predictManagedBlocks(onDisk ?? "", regions);
899
+ if (onDisk !== void 0 && predicted === onDisk) {
900
+ identical.push(relPath);
901
+ } else {
902
+ managedBlocks(abs, regions);
903
+ written.push(relPath);
904
+ }
759
905
  continue;
760
906
  }
761
907
  if (!opts.dryRun) mkdirSync3(dirname5(abs), { recursive: true });
@@ -767,7 +913,10 @@ async function scaffold(opts) {
767
913
  }
768
914
  const body = renderEntry(entry, vars);
769
915
  if (entry.mode === "regenerate") {
770
- const out = await writeRegenerateWithConflict(abs, entry.path, body, opts);
916
+ const out = await writeRegenerateWithConflict(abs, entry.path, body, opts, {
917
+ memory: conflictMemory,
918
+ record: recordConflict
919
+ });
771
920
  written.push(...out.written);
772
921
  skipped.push(...out.skipped);
773
922
  identical.push(...out.identical);
@@ -778,10 +927,41 @@ async function scaffold(opts) {
778
927
  }
779
928
  if (isNoirMdPath(entry.path)) healLegacyNoirMd(abs);
780
929
  const theirs = buildRegion(block, body);
781
- const regionText = opts.mergeManagedRegions ? mergeManagedRegion(abs, entry.path, block, theirs, ancestors) : theirs;
782
- managedBlock2(abs, block, regionText);
783
- if (opts.mergeManagedRegions) ancestors[`${entry.path}::${block.begin}`] = theirs;
784
- written.push(entry.path);
930
+ const merged = mergeManagedRegions ? mergeManagedRegion(abs, entry.path, block, theirs, ancestors) : { text: theirs, conflict: false, cleanTheirs: theirs };
931
+ const regionText = merged.text;
932
+ ancestors[`${entry.path}::${block.begin}`] = theirs;
933
+ if (merged.conflict) {
934
+ const ctxMode = "managedBlock";
935
+ const onDiskRegion = readOptional(abs);
936
+ const ctx = {
937
+ relPath: entry.path,
938
+ existing: onDiskRegion ?? "",
939
+ proposed: theirs,
940
+ mode: ctxMode,
941
+ similarity: similarity(onDiskRegion ?? "", merged.text),
942
+ mergedWithMarkers: merged.text
943
+ };
944
+ const resolution = await resolveManagedConflictCtx(ctx, opts);
945
+ if (resolution === "cancel") {
946
+ throw new Error(`scaffold cancelled by user at conflicting file ${entry.path}`);
947
+ }
948
+ if (resolution === "preserve") {
949
+ identical.push(entry.path);
950
+ continue;
951
+ }
952
+ process.stderr.write(
953
+ `noir: managed-region conflict in ${entry.path} \u2014 wrote inline markers; resolve manually.
954
+ `
955
+ );
956
+ }
957
+ const onDisk = readOptional(abs);
958
+ const predicted = predictManagedBlock(onDisk ?? "", block, regionText);
959
+ if (onDisk !== void 0 && predicted === onDisk) {
960
+ identical.push(entry.path);
961
+ } else {
962
+ managedBlock2(abs, block, regionText);
963
+ written.push(entry.path);
964
+ }
785
965
  } else {
786
966
  const out = skipIfExists(abs, body);
787
967
  if (out.written) written.push(entry.path);
@@ -792,13 +972,14 @@ async function scaffold(opts) {
792
972
  if ((opts.mode === "init" || opts.mode === "create") && !opts.dryRun) {
793
973
  writeScaffoldVersion(opts.root, CURRENT_SCAFFOLD_VERSION);
794
974
  }
795
- if (opts.mergeManagedRegions && !opts.dryRun) {
975
+ if (!opts.dryRun) {
796
976
  writeAncestors(opts.root, ancestors);
797
977
  }
798
978
  return {
799
979
  written,
800
980
  skipped,
801
981
  identical,
982
+ conflicts: conflictRecords,
802
983
  noop: false,
803
984
  migrationsRan,
804
985
  migrationConflicts,
@@ -809,19 +990,57 @@ async function scaffold(opts) {
809
990
  host
810
991
  };
811
992
  }
993
+ function sha256Hex12(s) {
994
+ const { createHash } = __require("crypto");
995
+ return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 12);
996
+ }
997
+ function similarity(a, b) {
998
+ if (a === b) return 1;
999
+ const A = a.split("\n");
1000
+ const B = b.split("\n");
1001
+ if (A.length === 0 && B.length === 0) return 1;
1002
+ const n = A.length;
1003
+ const m = B.length;
1004
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
1005
+ for (let i = n - 1; i >= 0; i--) {
1006
+ const dpi = dp[i] ?? [];
1007
+ const dpi1 = dp[i + 1] ?? [];
1008
+ const ai = A[i] ?? "";
1009
+ for (let j = m - 1; j >= 0; j--) {
1010
+ const bj = B[j] ?? "";
1011
+ dpi[j] = ai === bj ? (dpi1[j + 1] ?? 0) + 1 : Math.max(dpi1[j] ?? 0, dpi[j + 1] ?? 0);
1012
+ }
1013
+ }
1014
+ const lcs = dp[0]?.[0] ?? 0;
1015
+ return n + m === 0 ? 1 : 2 * lcs / (n + m);
1016
+ }
812
1017
  function mergeManagedRegion(abs, relPath, block, theirs, ancestors) {
813
1018
  const base = ancestors[`${relPath}::${block.begin}`];
814
- if (base === void 0) return theirs;
1019
+ if (base === void 0) return { text: theirs, conflict: false, cleanTheirs: theirs };
815
1020
  const ours = readManagedBlock(abs, block);
816
- if (ours === null) return theirs;
817
- const res = mergeThreeWay(base, ours, theirs);
818
- if (res.conflict) {
819
- process.stderr.write(
820
- `noir: managed-region conflict in ${relPath} \u2014 wrote inline markers; resolve manually.
821
- `
822
- );
1021
+ if (ours === null) return { text: theirs, conflict: false, cleanTheirs: theirs };
1022
+ const oursNormalized = ours.endsWith("\n") ? ours : `${ours}
1023
+ `;
1024
+ const res = mergeThreeWay(base, oursNormalized, theirs, "zdiff3");
1025
+ return { text: res.merged, conflict: res.conflict, cleanTheirs: theirs };
1026
+ }
1027
+ async function resolveManagedConflictCtx(ctx, opts) {
1028
+ if (opts.onConflict === void 0) return void 0;
1029
+ const ret = await opts.onConflict(ctx);
1030
+ const resolution = typeof ret === "string" ? ret : ret.resolution;
1031
+ if (resolution === "rename" || resolution === "duplicate") {
1032
+ return "merge";
1033
+ }
1034
+ if (resolution === "replace") {
1035
+ return "replace";
823
1036
  }
824
- return res.merged;
1037
+ return resolution;
1038
+ }
1039
+ async function resolveManagedConflict(relPath, mode, opts) {
1040
+ if (opts.onConflict === void 0) return void 0;
1041
+ const ret = await opts.onConflict({ relPath, existing: "", proposed: "", mode });
1042
+ const resolution = typeof ret === "string" ? ret : ret.resolution;
1043
+ return resolution;
825
1044
  }
826
1045
  function readProjectIdFile(root) {
827
1046
  let raw;
@@ -841,7 +1060,7 @@ function resolveProjectId(opts, idFile) {
841
1060
  }
842
1061
  return createProjectId();
843
1062
  }
844
- async function writeRegenerateWithConflict(abs, relPath, proposed, opts) {
1063
+ async function writeRegenerateWithConflict(abs, relPath, proposed, opts, internals) {
845
1064
  let existing;
846
1065
  try {
847
1066
  existing = readFileSync7(abs, "utf8");
@@ -855,11 +1074,31 @@ async function writeRegenerateWithConflict(abs, relPath, proposed, opts) {
855
1074
  if (existing === proposed) {
856
1075
  return { written: [], skipped: [], identical: [relPath] };
857
1076
  }
858
- const resolution = opts.onConflict !== void 0 ? await opts.onConflict({ relPath, existing, proposed }) : opts.conflictPolicy === "preserve" ? "preserve" : "replace";
1077
+ const MODE = "regenerate";
1078
+ const sim = similarity(existing, proposed);
1079
+ let resolution;
1080
+ const remembered = internals.memory.get(MODE);
1081
+ if (remembered !== void 0) {
1082
+ resolution = remembered;
1083
+ } else if (opts.onConflict !== void 0) {
1084
+ const ret = await opts.onConflict({ relPath, existing, proposed, mode: MODE, similarity: sim });
1085
+ const unwrapped = typeof ret === "string" ? ret : ret.resolution;
1086
+ if (typeof ret !== "string" && ret.applyToAll === true) {
1087
+ internals.memory.set(MODE, unwrapped);
1088
+ }
1089
+ resolution = unwrapped;
1090
+ } else {
1091
+ resolution = opts.conflictPolicy === "preserve" ? "preserve" : "replace";
1092
+ }
1093
+ internals.record(relPath, MODE, existing, proposed, resolution, sim);
859
1094
  switch (resolution) {
860
1095
  case "replace":
861
1096
  regenerate(abs, proposed);
862
1097
  return { written: [relPath], skipped: [], identical: [] };
1098
+ case "merge": {
1099
+ regenerate(abs, proposed);
1100
+ return { written: [relPath], skipped: [], identical: [] };
1101
+ }
863
1102
  case "rename": {
864
1103
  const aside = uniqueAside(abs, relPath, ".local");
865
1104
  renameSync2(abs, aside.abs);
@@ -919,6 +1158,16 @@ function renderEntry(entry, vars) {
919
1158
  if (entry.content !== void 0) return entry.content;
920
1159
  throw new Error(`manifest entry ${entry.path}: must define 'content' or 'template'`);
921
1160
  }
1161
+ function readOptional(absPath) {
1162
+ try {
1163
+ return readFileSync7(absPath, "utf8");
1164
+ } catch (err) {
1165
+ if (err instanceof Error && err.code === "ENOENT") {
1166
+ return void 0;
1167
+ }
1168
+ throw err;
1169
+ }
1170
+ }
922
1171
  export {
923
1172
  BRIEF_BLOCK,
924
1173
  CURRENT_SCAFFOLD_VERSION,
@@ -932,6 +1181,7 @@ export {
932
1181
  buildManifest,
933
1182
  buildRegion,
934
1183
  detectStack,
1184
+ lineDiff,
935
1185
  loadTemplate,
936
1186
  managedBlock2 as managedBlock,
937
1187
  mergeThreeWay,
@@ -945,6 +1195,7 @@ export {
945
1195
  skipIfExists,
946
1196
  templatesDir,
947
1197
  writeAncestors,
948
- writeScaffoldVersion
1198
+ writeScaffoldVersion,
1199
+ zdiff3Region
949
1200
  };
950
1201
  //# sourceMappingURL=index.js.map