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.
- package/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/typescript-express.mjs +13 -2
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +47 -6
|
@@ -226,10 +226,40 @@ function resolveEsmImport(fromFile, specifier, suffixes) {
|
|
|
226
226
|
return null;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
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 =
|
|
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-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
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
|
|
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
|
|
94
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
}
|