backend-skeleton 1.0.0 → 1.1.0

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.
Files changed (48) hide show
  1. package/README.md +66 -4
  2. package/bin/bskel.mjs +125 -18
  3. package/contracts/export.mjs +39 -4
  4. package/contracts/openapi.mjs +292 -27
  5. package/contracts/validate.mjs +23 -4
  6. package/handles/_engine.mjs +75 -32
  7. package/handles/capability-codec.mjs +94 -0
  8. package/handles/codec.mjs +13 -3
  9. package/handles/providers/java-spring/emit.mjs +78 -33
  10. package/handles/providers/java-spring/observe.mjs +4 -3
  11. package/handles/providers/java-spring/plan.mjs +51 -7
  12. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  13. package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
  14. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  15. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  16. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
  17. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  18. package/handles/providers/java-spring.mjs +8 -0
  19. package/handles/providers/python-fastapi/emit.mjs +21 -26
  20. package/handles/providers/python-fastapi/observe.mjs +6 -5
  21. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  22. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
  23. package/handles/providers/python-fastapi.mjs +3 -3
  24. package/handles/providers/typescript-express/emit.mjs +135 -46
  25. package/handles/providers/typescript-express/observe.mjs +7 -6
  26. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  27. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  28. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  29. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  30. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  31. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  32. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  33. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  34. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  35. package/handles/providers/typescript-express.mjs +7 -4
  36. package/lib/cli.mjs +11 -2
  37. package/lib/exit-codes.mjs +21 -0
  38. package/lib/verify.mjs +23 -6
  39. package/package.json +5 -2
  40. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  41. package/scanners/adapters/java-spring.mjs +108 -10
  42. package/scanners/adapters/javascript-express.mjs +46 -13
  43. package/scanners/adapters/typescript-express.mjs +13 -2
  44. package/schemas/feature-contract.schema.json +3 -3
  45. package/schemas/handles-plan.schema.json +2 -0
  46. package/schemas/oracle-manifest.schema.json +58 -0
  47. package/schemas/stack-record.schema.json +6 -1
  48. package/stack/apply.mjs +47 -6
@@ -226,10 +226,40 @@ function resolveEsmImport(fromFile, specifier, suffixes) {
226
226
  return null;
227
227
  }
228
228
 
229
- // `export default router;` -- which locally-declared mountable a file hands to whoever imports it.
230
- function defaultExportedMountable(text, mountables) {
231
- const m = text.match(/export\s+default\s+([\w$]+)\s*;?/);
232
- return m && mountables.has(m[1]) ? m[1] : null;
229
+ // D-javascript-express-adapter (Update): found by the same shadow-validation-style audit that
230
+ // closed D-module-attribution-base-package's own EXIT item for this adapter -- cross-file mount
231
+ // resolution only ever recognized `export default router;`, a real but narrower limitation than
232
+ // java-spring's own moduleOf() bug (endpoints still get FOUND either way, only their prefix goes
233
+ // unresolved). A router handed off via a bare named export (`export { router };`) or an
234
+ // export-prefixed declaration (`export const router = Router();`, ordinary and common) previously
235
+ // had no path to being recognized as this file's "the" exported mountable at all.
236
+ //
237
+ // Three real ways a module hands a locally-declared mountable to whoever imports it -- `export
238
+ // { router as r }` aliasing is deliberately NOT resolved, same restraint as this file's own
239
+ // `Router as R` import-aliasing decision (D-javascript-express-adapter COST): a documented, narrow
240
+ // limitation, not a silent guess at which local name an alias refers to.
241
+ function exportedMountableName(text, mountables) {
242
+ const defaultMatch = text.match(/export\s+default\s+([\w$]+)\s*;?/);
243
+ if (defaultMatch && mountables.has(defaultMatch[1])) return defaultMatch[1];
244
+ const namedMatch = text.match(/export\s*\{\s*([\w$]+)\s*\}/);
245
+ if (namedMatch && mountables.has(namedMatch[1])) return namedMatch[1];
246
+ const exportedDeclMatch = text.match(/\bexport\s+(?:const|let|var)\s+([\w$]+)\s*=/);
247
+ if (exportedDeclMatch && mountables.has(exportedDeclMatch[1])) return exportedDeclMatch[1];
248
+ return null;
249
+ }
250
+
251
+ // `import target from '...'` (default) OR `import { target } from '...'` (named, unaliased) --
252
+ // two real ways an imported mountable's LOCAL name reaches this file. `import { target as alias }`
253
+ // is deliberately not resolved, same restraint as exportedMountableName's own aliasing decision
254
+ // above -- a bare, unaliased single-name clause only, matching this file's existing default-import
255
+ // regex's own narrow scope (never a general multi-specifier import-clause parser).
256
+ function importSourceFor(text, target) {
257
+ const defaultImportRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
258
+ const defaultMatch = text.match(defaultImportRe);
259
+ if (defaultMatch) return defaultMatch[1];
260
+ const namedImportRe = new RegExp(`import\\s*\\{\\s*${target}\\s*\\}\\s*from\\s*["']([^"']+)["']`);
261
+ const namedMatch = text.match(namedImportRe);
262
+ return namedMatch ? namedMatch[1] : null;
233
263
  }
234
264
 
235
265
  // Builds the mount graph over (file, variable) nodes. Two edge kinds, both from the same
@@ -261,13 +291,12 @@ function buildMountEdges(files, fileInfo, suffixes) {
261
291
  edges.push({ from: nodeKey(file, fromVar), to: nodeKey(file, target), prefix: pathMatch[1] });
262
292
  continue;
263
293
  }
264
- const importRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
265
- const importMatch = info.text.match(importRe);
266
- if (!importMatch) continue;
267
- const toFile = resolveEsmImport(file, importMatch[1], suffixes);
294
+ const importSource = importSourceFor(info.text, target);
295
+ if (!importSource) continue;
296
+ const toFile = resolveEsmImport(file, importSource, suffixes);
268
297
  if (!toFile || !fileInfo.has(toFile)) continue;
269
298
  const toInfo = fileInfo.get(toFile);
270
- const toVar = defaultExportedMountable(toInfo.text, toInfo.mountables);
299
+ const toVar = exportedMountableName(toInfo.text, toInfo.mountables);
271
300
  if (!toVar) continue;
272
301
  edges.push({ from: nodeKey(file, fromVar), to: nodeKey(toFile, toVar), prefix: pathMatch[1] });
273
302
  }
@@ -391,10 +420,14 @@ export const adapter = {
391
420
  // walk), not how many capabilities it can offer. generic-grep is `low` because it has no module
392
421
  // inference and no prefix resolution at all -- this adapter has both.
393
422
  confidence: 'high',
394
- // D-adapter-verification-basis: no real-world oracle at all, unlike every other real framework
395
- // adapter -- the real target repository was deliberately never touched, and the committed
396
- // synthetic fixture carries all of the regression weight. Named honestly, not hidden.
397
- verificationBasis: 'synthetic-only',
423
+ // D-oracle-corpus-pinning: promoted from synthetic-only -- 3 real, pinned community repos
424
+ // now exist in test/fixtures/oracle-manifest.json (JeanCaicedo/employees-api-mysql,
425
+ // Serkanbyx/chat-app-backend, nekesam/helloworld), found via a genuine search effort that
426
+ // contradicted this adapter's own prior 'may be structurally unpromotable' hedge. The
427
+ // committed synthetic fixture still carries the exact-count regression weight; the real
428
+ // corpus is diagnostic/diversity coverage on top, same role every other adapter's real
429
+ // oracle plays.
430
+ verificationBasis: 'community-sample',
398
431
  capabilities: {
399
432
  // false: plain Express has no operationId concept at all. --openapi-file is the honest path
400
433
  // forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
@@ -41,6 +41,17 @@ const ENTITY_CLASS_RE = /@Entity\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)\s*\n?\s*ex
41
41
  // exported symbol -- same file-level granularity java's own DTO tracking already settled for.
42
42
  const DTO_DIR_SEGMENT = `${path.sep}dto${path.sep}`;
43
43
 
44
+ // D-module-attribution-base-package (Update): found by the same shadow-validation pass that fixed
45
+ // java-spring's own moduleOf() -- a real project not using a `dto/` folder at all (flat `CreateUserDto.ts` files, or
46
+ // NestJS's own common `create-user.dto.ts` naming) had every DTO silently invisible, the same class
47
+ // of single-convention overfit, just on a narrower surface (DTO tracking only, not module/entity/
48
+ // controller extraction). This does NOT reopen the CONTENT-detection problem the comment above
49
+ // explicitly rejected (interface/type/class-validator/Zod/undecorated class all have different
50
+ // shapes) -- it's an independent, NAME-only signal: the file's own basename ends in "dto"
51
+ // (case-insensitive), the same near-definitional marker this file's own entity-matching step below
52
+ // already leans on for MATCHING. Catches both `CreateUserDto.ts` and `create-user.dto.ts`.
53
+ const DTO_NAME_SUFFIX_RE = /dto$/i;
54
+
44
55
  // Two independent signals required, mirroring java-spring's "build file AND src layout" /
45
56
  // python-fastapi's "dependency declared AND source-confirmed" combined bar: (a) package.json
46
57
  // declares express, (b) at least one .ts file actually imports Router from 'express' and calls
@@ -251,8 +262,8 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
251
262
  moduleEntry(moduleName).controllers.push({ className, basePath: prefix, operationIds: [], endpoints, file });
252
263
  }
253
264
  }
254
- if (file.includes(DTO_DIR_SEGMENT)) {
255
- allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path-based, no content parsed
265
+ if (file.includes(DTO_DIR_SEGMENT) || DTO_NAME_SUFFIX_RE.test(path.basename(file, '.ts'))) {
266
+ allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path/name-based, no content parsed
256
267
  }
257
268
  allEntities.push(...extractTableEntities(text, file));
258
269
  }
@@ -36,16 +36,16 @@
36
36
  "body": { "enum": [true, false, "unknown"] },
37
37
  "provenance": { "type": "string" },
38
38
  "requestBodySchema": {
39
- "description": "A2: the operation's request body projected from a real OpenAPI 3.1 document, fully inlined (no $ref) -- see contracts/openapi.mjs's inlineSchema(). Present only when reconciliation matched/adopted this operation AND its application/json schema resolved; omitted otherwise. Not deeply validated as 'is this a valid JSON Schema' here -- that would need a schema-of-schemas, out of scope.",
39
+ "description": "A2: the operation's request body projected from a real OpenAPI 3.1 document -- see contracts/openapi.mjs's inlineSchema(). Fully inlined (no $ref) EXCEPT at a genuinely self-referential component (D-openapi-cyclic-refs), where a $ref/$defs pair is used instead -- see this schema's own top-level $defs when present. Present only when reconciliation matched/adopted this operation AND its application/json schema resolved; omitted otherwise. Not deeply validated as 'is this a valid JSON Schema' here -- that would need a schema-of-schemas, out of scope.",
40
40
  "type": "object"
41
41
  },
42
42
  "requestBodyRequired": { "type": "boolean" },
43
43
  "responseSchema": {
44
- "description": "A3: all documented 2xx application/json response schemas for this operation, fully inlined (no $ref); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
44
+ "description": "A3: all documented 2xx application/json response schemas for this operation, fully inlined (no $ref) EXCEPT at a genuinely self-referential component (D-openapi-cyclic-refs, $ref/$defs used instead); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
45
45
  "type": "object"
46
46
  },
47
47
  "errorSchema": {
48
- "description": "A3: all documented 4xx/5xx application/json response schemas for this operation, fully inlined (no $ref); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
48
+ "description": "A3: all documented 4xx/5xx application/json response schemas for this operation, fully inlined (no $ref) EXCEPT at a genuinely self-referential component (D-openapi-cyclic-refs, $ref/$defs used instead); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
49
49
  "type": "object"
50
50
  },
51
51
  "sourceParameters": {
@@ -20,6 +20,8 @@
20
20
  "type": { "type": "string" },
21
21
  "table": { "type": ["string", "null"] },
22
22
  "idField": { "type": ["string", "null"] },
23
+ "idFieldType": { "type": ["string", "null"], "description": "D-write-safety-phase1 (item 4a): java-spring only -- the primary key's declared Java type (e.g. 'UUID', 'Integer'). null when it couldn't be determined at all (not the same as a confirmed non-UUID type)." },
24
+ "idFieldIsUuid": { "type": ["boolean", "null"], "description": "D-write-safety-phase1 (item 4a): mirrors typescript-express's own already-established field of the same name. false means a resolver is structurally impossible for this entity (the handles subsystem is UUID-addressable only), independent of whether a service file can be found." },
23
25
  "readPath": { "type": ["string", "null"] },
24
26
  "requiredAuthority": { "type": "string" },
25
27
  "requiredAuthorityForPatch": { "type": "string", "description": "O5 (D-resolver-authorization-action-aware): java-spring only -- derived independently from the entity's UPDATE endpoint, not copied from requiredAuthority (which is fetch/recover's own value)." },
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:oracle-manifest:1",
4
+ "title": "backend-skeleton scanner-adapter verification-corpus manifest",
5
+ "description": "ROADMAP.md Phase 5b (D-oracle-corpus-pinning): the committed, human-readable record of real, third-party repos pinned to a real commit SHA per scanner adapter, driven by scripts/shadow-validation-smoke.mjs --manifest. Loaded at scripts/shadow-validation-smoke.mjs run time, not by the CLI itself -- this is a test/verification-only artifact, never read by bin/bskel.mjs.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["contract", "adapters"],
9
+ "properties": {
10
+ "contract": { "const": "sbf.oracle-manifest/1" },
11
+ "adapters": {
12
+ "type": "object",
13
+ "propertyNames": {
14
+ "enum": ["java-spring", "python-fastapi", "typescript-express", "javascript-express", "generic-grep"]
15
+ },
16
+ "additionalProperties": {
17
+ "type": "array",
18
+ "minItems": 1,
19
+ "items": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "required": ["id", "repo", "terms", "note"],
23
+ "properties": {
24
+ "id": {
25
+ "type": "string",
26
+ "pattern": "^[a-z][a-z0-9-]*$",
27
+ "description": "Short, unique-within-this-adapter identifier for this manifest entry (e.g. 'spring-petclinic') -- used in report output, never sent to git/GitHub."
28
+ },
29
+ "owner": {
30
+ "type": ["string", "null"],
31
+ "description": "null means \"repo\" is used as-is as a literal clone URL/local path (matches scripts/shadow-validation-smoke.mjs's own parseRepoSpec literal form) -- ONLY for local, non-network test fixtures (test/shadow-validation-cli.test.mjs); every real corpus entry in this file names a real owner."
32
+ },
33
+ "repo": { "type": "string", "minLength": 1 },
34
+ "ref": {
35
+ "type": ["string", "null"],
36
+ "pattern": "^([0-9a-f]{40})?$",
37
+ "description": "A real, pinned commit SHA -- ROADMAP.md Phase 5b's own 'pin refs' requirement. null is permitted only for the local-fixture literal-owner form above (no meaningful \"pin\" for a throwaway local bare repo). Never a branch/tag name here (scripts/shadow-validation-smoke.mjs's own DEFAULT_REPOS/CLI-spec forms still accept branch names for quick manual use; this committed manifest does not)."
38
+ },
39
+ "path": {
40
+ "type": ["string", "null"],
41
+ "description": "Subdirectory within the clone to scope every bskel invocation to (relative, no leading/trailing slash) -- null/absent means the clone root. Needed for monorepos where the actual backend lives under a subdirectory (e.g. polarsource/polar's 'server')."
42
+ },
43
+ "terms": {
44
+ "type": "array",
45
+ "minItems": 1,
46
+ "items": { "type": "string", "minLength": 1 }
47
+ },
48
+ "note": {
49
+ "type": "string",
50
+ "minLength": 1,
51
+ "description": "One-line, human-readable justification for why this repo is in the corpus (what real coverage it adds, or what real diagnostic gap it's expected to surface) -- see DECISIONS.md's D-oracle-corpus-pinning for the full record."
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
@@ -15,6 +15,11 @@
15
15
  "items": { "type": "string" }
16
16
  },
17
17
  "env_example_keys": { "type": "array", "items": { "type": "string" } },
18
- "at": { "type": "string", "format": "date-time" }
18
+ "at": { "type": "string", "format": "date-time" },
19
+ "file_hashes": {
20
+ "description": "D-write-safety-phase0 (item 2): additive, optional (absent on a record written before this existed -- planApply() treats a missing entry as no prior provenance, same as classifyFile()'s own no-manifest-entry fallback). sha256 of what `stack apply --apply` itself last wrote to each path in applied_files, keyed by that same relative path -- gives planApply() a preimage to check a hand-edit against, instead of only comparing to the current fresh render.",
21
+ "type": "object",
22
+ "additionalProperties": { "type": "string" }
23
+ }
19
24
  }
20
25
  }
package/stack/apply.mjs CHANGED
@@ -6,6 +6,13 @@ import Ajv2020 from 'ajv/dist/2020.js';
6
6
  // P2b (D-greenfield-parameters): was a private `renderTemplate(templatePath, vars)` here, moved to
7
7
  // lib/template.mjs unchanged once `new/fastapi.mjs` became its second real consumer.
8
8
  import { renderTemplateFile } from '../lib/template.mjs';
9
+ // D-write-safety-phase0 (item 2): reusing the exact same provenance-based classification and
10
+ // git-recoverability check the handles write path already established, rather than inventing a
11
+ // second one for this write path.
12
+ import { classifyFile } from '../lib/handles-manifest.mjs';
13
+ import { isDirtyOrUntracked } from '../handles/_engine.mjs';
14
+ import { sha256String, readJsonIfExists } from '../lib/fsutil.mjs';
15
+ import { sbfPath } from '../lib/paths.mjs';
9
16
 
10
17
  const STACK_ROOT = path.dirname(fileURLToPath(import.meta.url));
11
18
  const SCHEMAS_ROOT = path.join(STACK_ROOT, '..', 'schemas');
@@ -84,19 +91,31 @@ export function planApply(repoRoot, entry, { port = 8080 } = {}) {
84
91
  // crossed the stated boundary).
85
92
  plan.alreadyDetected = (entry.detect?.files ?? []).some((f) => fs.existsSync(path.join(repoRoot, f)));
86
93
 
94
+ // D-write-safety-phase0 (item 2): `file_hashes` (additive, schemas/stack-record.schema.json) is
95
+ // what `stack apply` itself last wrote to each path -- absent on a record from before this
96
+ // existed, or if `stack apply` never ran. classifyFile()'s own no-manifest-entry fallback
97
+ // (content-comparison only) covers that case exactly the way handles emit's first-ever run does.
98
+ const priorRecord = readJsonIfExists(sbfPath(repoRoot, 'stack.json'));
99
+ const priorHashes = priorRecord?.file_hashes ?? {};
100
+
87
101
  for (const f of entry.static?.files ?? []) {
88
102
  const templatePath = path.join(STACK_ROOT, f.template);
89
103
  assertContained(STACK_ROOT, templatePath, 'catalog template path');
90
104
  const targetPath = path.join(repoRoot, f.path);
91
105
  assertContained(repoRoot, targetPath, 'catalog target path');
92
106
  const rendered = renderTemplateFile(templatePath, { PORT: port });
93
- const exists = fs.existsSync(targetPath);
94
- const unchanged = exists && fs.readFileSync(targetPath, 'utf8') === rendered;
107
+ const diskContent = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf8') : null;
108
+ const exists = diskContent !== null;
109
+ const diskHash = exists ? sha256String(diskContent) : null;
110
+ const freshRenderHash = sha256String(rendered);
111
+ const matchesPristineRender = exists && diskContent === rendered;
112
+ const action = classifyFile({ exists, diskHash, manifestEntryHash: priorHashes[f.path] ?? null, freshRenderHash, matchesPristineRender });
95
113
  plan.files.push({
96
114
  path: f.path,
97
115
  mode: f.mode ?? null,
98
- action: !exists ? 'create' : (unchanged ? 'unchanged' : 'update'),
116
+ action,
99
117
  content: rendered,
118
+ contentHash: freshRenderHash,
100
119
  });
101
120
  }
102
121
 
@@ -133,18 +152,40 @@ export function planApply(repoRoot, entry, { port = 8080 } = {}) {
133
152
  // API supports this), config_check could gain an `apply` action -- not built now because the
134
153
  // real target (Team-IZ-Backend) doesn't need it (already externalized), so there's no concrete
135
154
  // case to validate a patcher against yet.
136
- export function applyPlan(repoRoot, plan) {
155
+ // D-write-safety-phase0 (item 2): `force` mirrors handles emit's own `--force` gate exactly -- a
156
+ // `conflict` file (diverged from what `stack apply` itself last wrote) is refused outright without
157
+ // it, and even with it is refused if not git-recoverable (uncommitted/untracked), so a --force
158
+ // overwrite is only ever reversible. The `--reason` a real overwrite requires is a CLI-layer
159
+ // concern (validated in cmdStackApply, mirroring cmdContractWaive/handles emit's identical
160
+ // pattern) -- applyPlan() itself has nothing to do with an audit string it never persists. Returns
161
+ // `fileHashes` (sha256 of what was ACTUALLY written this run) so the caller can persist it into
162
+ // `.sbf/stack.json`'s new `file_hashes` field -- unchanged/adopt-unchanged files are simply absent
163
+ // here, so the caller must merge onto the PRIOR record's file_hashes, not replace it wholesale.
164
+ export function applyPlan(repoRoot, plan, { force = false } = {}) {
137
165
  const written = [];
166
+ const conflicts = [];
167
+ const fileHashes = {};
138
168
  for (const f of plan.files) {
139
- if (f.action === 'unchanged') continue;
169
+ if (f.action === 'unchanged' || f.action === 'adopt-unchanged') continue;
140
170
  const targetPath = path.join(repoRoot, f.path);
141
171
  // Re-asserted here too (planApply already checked it) -- applyPlan must not assume it's
142
172
  // only ever called with a plan it just generated for the same repoRoot.
143
173
  assertContained(repoRoot, targetPath, 'catalog target path');
174
+ if (f.action === 'conflict') {
175
+ if (!force) {
176
+ conflicts.push({ path: f.path, reason: 'diverged from the last content `bskel stack apply` generated -- see notes for remediation' });
177
+ continue;
178
+ }
179
+ if (isDirtyOrUntracked(repoRoot, targetPath)) {
180
+ conflicts.push({ path: f.path, reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
181
+ continue;
182
+ }
183
+ }
144
184
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
145
185
  fs.writeFileSync(targetPath, f.content);
146
186
  if (f.mode) fs.chmodSync(targetPath, Number.parseInt(f.mode, 8));
147
187
  written.push(f.path);
188
+ fileHashes[f.path] = f.contentHash;
148
189
  }
149
190
 
150
191
  const toAppend = plan.envExampleActions.filter((a) => a.action === 'append');
@@ -158,5 +199,5 @@ export function applyPlan(repoRoot, plan) {
158
199
  written.push('.env.example');
159
200
  }
160
201
 
161
- return written;
202
+ return { written, conflicts, fileHashes };
162
203
  }