@postxl/generator 1.4.0 → 1.5.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/dist/generator-manager.class.js +5 -0
- package/dist/generator.context.d.ts +29 -1
- package/dist/generator.context.js +49 -1
- package/dist/utils/classify-path.d.ts +26 -0
- package/dist/utils/classify-path.js +103 -0
- package/dist/utils/custom-blocks.d.ts +2 -0
- package/dist/utils/custom-blocks.js +160 -19
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/lockfile.d.ts +3 -1
- package/dist/utils/lockfile.js +8 -1
- package/dist/utils/sync.d.ts +9 -1
- package/dist/utils/sync.js +9 -4
- package/dist/utils/vfs.class.d.ts +11 -0
- package/dist/utils/vfs.class.js +34 -2
- package/package.json +2 -2
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.GeneratorManager = void 0;
|
|
4
4
|
const utils_1 = require("@postxl/utils");
|
|
5
5
|
const branded_types_1 = require("./helpers/branded.types");
|
|
6
|
+
const generator_context_1 = require("./generator.context");
|
|
6
7
|
/**
|
|
7
8
|
* The GeneratorManager coordinates the generation process across multiple Generators.
|
|
8
9
|
*
|
|
@@ -144,6 +145,10 @@ class GeneratorManager {
|
|
|
144
145
|
// Not all generators extend the context and hence do not need to return it.
|
|
145
146
|
// Therefore, if the generator does not return the context, we use the original context.
|
|
146
147
|
context = (await generator.register(context)) ?? context;
|
|
148
|
+
// Register phases typically rebuild `context.models` as a fresh Map with
|
|
149
|
+
// extended per-model contexts. Re-sync `targetModels` so it points at
|
|
150
|
+
// the same (or narrowed) extended entries rather than a stale reference.
|
|
151
|
+
context = (0, generator_context_1.syncTargetModels)(context);
|
|
147
152
|
}
|
|
148
153
|
catch (error) {
|
|
149
154
|
throw new Error(`Generator ${(0, utils_1.yellow)(generator.id)} failed during register phase: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
@@ -22,6 +22,18 @@ export type Context = {
|
|
|
22
22
|
* Each generator can extend these contexts with its own content.
|
|
23
23
|
*/
|
|
24
24
|
models: Map<Schema.ModelName, ModelContext>;
|
|
25
|
+
/**
|
|
26
|
+
* Subset of `models` that per-model generation should target. Same reference as
|
|
27
|
+
* `models` by default; only the `-m <names...>` CLI flag narrows it to a smaller
|
|
28
|
+
* set. Generators that iterate models to write one file per model should read
|
|
29
|
+
* `targetModels`; generators that iterate to contribute to an aggregate file,
|
|
30
|
+
* or that do cross-model lookups via `.get(name)`, should stay on `models`.
|
|
31
|
+
*
|
|
32
|
+
* The narrowing is sourced from `options.targetModelNames` and re-synced by
|
|
33
|
+
* the `GeneratorManager` after every register phase, so it survives generators
|
|
34
|
+
* that rebuild the `models` map.
|
|
35
|
+
*/
|
|
36
|
+
targetModels: Map<Schema.ModelName, ModelContext>;
|
|
25
37
|
/**
|
|
26
38
|
* Contains the context for each schema enum.
|
|
27
39
|
* Each generator can extend these contexts with its own content.
|
|
@@ -144,12 +156,21 @@ export type GeneratorOptions = {
|
|
|
144
156
|
* Default: undefined (no filtering)
|
|
145
157
|
*/
|
|
146
158
|
filePattern?: string;
|
|
159
|
+
/**
|
|
160
|
+
* Optional set of model names to narrow generation to. When set, per-model
|
|
161
|
+
* generators iterate only these models (via `context.targetModels`) and the
|
|
162
|
+
* VFS safety-net drops any per-model writes for models outside this set.
|
|
163
|
+
*
|
|
164
|
+
* Default: undefined (all models).
|
|
165
|
+
*/
|
|
166
|
+
targetModelNames?: ReadonlySet<Schema.ModelName>;
|
|
147
167
|
};
|
|
148
168
|
export type ExtendContext<Context, ContextExtension> = Context & ContextExtension;
|
|
149
169
|
export type ExtendModelContext<Context, ModelContextExtension> = Context extends {
|
|
150
170
|
models: Map<infer K, infer V>;
|
|
151
|
-
} ? Omit<Context, 'models'> & {
|
|
171
|
+
} ? Omit<Context, 'models' | 'targetModels'> & {
|
|
152
172
|
models: Map<K, V & ModelContextExtension>;
|
|
173
|
+
targetModels: Map<K, V & ModelContextExtension>;
|
|
153
174
|
} : never;
|
|
154
175
|
export type ExtendEnumContext<Context, EnumContextExtension> = Context extends {
|
|
155
176
|
enums: Map<infer K, infer V>;
|
|
@@ -165,6 +186,13 @@ export type InferEnumContext<Context> = Context extends {
|
|
|
165
186
|
enums: Map<any, infer V>;
|
|
166
187
|
} ? V : never;
|
|
167
188
|
export declare function prepareBaseContext(schema: Schema.ProjectSchema, opts?: Partial<GeneratorOptions>): Context;
|
|
189
|
+
/**
|
|
190
|
+
* Given a fresh `models` Map and the requested narrowing on
|
|
191
|
+
* `ctx.options.targetModelNames` (if any), produce a `targetModels` Map that
|
|
192
|
+
* matches. Called by the GeneratorManager after every register phase to keep
|
|
193
|
+
* `targetModels` pointing at the latest extended contexts.
|
|
194
|
+
*/
|
|
195
|
+
export declare function syncTargetModels<C extends Context>(ctx: C): C;
|
|
168
196
|
/**
|
|
169
197
|
* Helper function for tests to create a mock schema.
|
|
170
198
|
*/
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.prepareBaseContext = prepareBaseContext;
|
|
37
|
+
exports.syncTargetModels = syncTargetModels;
|
|
37
38
|
exports.mockSchema = mockSchema;
|
|
38
39
|
exports.mockSchemaWithDefaultModels = mockSchemaWithDefaultModels;
|
|
39
40
|
exports.getBaseModelContext = getBaseModelContext;
|
|
@@ -56,14 +57,61 @@ function prepareBaseContext(schema, opts = defaultGeneratorOptions) {
|
|
|
56
57
|
for (const enumSchema of schema.enums.values()) {
|
|
57
58
|
enums.set(enumSchema.name, getBaseEnumContext(enumSchema));
|
|
58
59
|
}
|
|
60
|
+
const targetModelNames = effectiveTargetModelNames(options.targetModelNames, models);
|
|
61
|
+
const targetModels = targetModelNames ? filterModelMapByNames(models, targetModelNames) : models;
|
|
62
|
+
const vfsOptions = {};
|
|
63
|
+
if (options.filePattern) {
|
|
64
|
+
vfsOptions.filePattern = options.filePattern;
|
|
65
|
+
}
|
|
66
|
+
if (targetModelNames) {
|
|
67
|
+
vfsOptions.targetModelNames = targetModelNames;
|
|
68
|
+
vfsOptions.allModelNames = new Set(models.keys());
|
|
69
|
+
}
|
|
59
70
|
return {
|
|
60
71
|
schema: schema,
|
|
61
|
-
vfs: new vfs_class_1.VirtualFileSystem(
|
|
72
|
+
vfs: new vfs_class_1.VirtualFileSystem(Object.keys(vfsOptions).length > 0 ? vfsOptions : undefined),
|
|
62
73
|
models,
|
|
74
|
+
targetModels,
|
|
63
75
|
enums,
|
|
64
76
|
options,
|
|
65
77
|
};
|
|
66
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Given a fresh `models` Map and the requested narrowing on
|
|
81
|
+
* `ctx.options.targetModelNames` (if any), produce a `targetModels` Map that
|
|
82
|
+
* matches. Called by the GeneratorManager after every register phase to keep
|
|
83
|
+
* `targetModels` pointing at the latest extended contexts.
|
|
84
|
+
*/
|
|
85
|
+
function syncTargetModels(ctx) {
|
|
86
|
+
const targetModelNames = effectiveTargetModelNames(ctx.options?.targetModelNames, ctx.models);
|
|
87
|
+
if (!targetModelNames) {
|
|
88
|
+
return ctx.targetModels === ctx.models ? ctx : { ...ctx, targetModels: ctx.models };
|
|
89
|
+
}
|
|
90
|
+
return { ...ctx, targetModels: filterModelMapByNames(ctx.models, targetModelNames) };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Returns the requested narrowing if it actually narrows the given `models`
|
|
94
|
+
* map; returns `undefined` when no narrowing was requested or the requested set
|
|
95
|
+
* already covers every current model (no real narrowing).
|
|
96
|
+
*/
|
|
97
|
+
function effectiveTargetModelNames(requested, models) {
|
|
98
|
+
if (!requested) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (requested.size === models.size && [...requested].every((n) => models.has(n))) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return requested;
|
|
105
|
+
}
|
|
106
|
+
function filterModelMapByNames(models, names) {
|
|
107
|
+
const out = new Map();
|
|
108
|
+
for (const [name, model] of models) {
|
|
109
|
+
if (names.has(name)) {
|
|
110
|
+
out.set(name, model);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
67
115
|
/**
|
|
68
116
|
* Helper function for tests to create a mock schema.
|
|
69
117
|
*/
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type PathClassification = {
|
|
2
|
+
kind: 'per-model';
|
|
3
|
+
model: string;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'aggregate-or-unknown';
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Classifies a VFS path as "per-model" (a file that belongs to exactly one
|
|
9
|
+
* model from `allModelNames`) or "aggregate-or-unknown" (everything else).
|
|
10
|
+
*
|
|
11
|
+
* Algorithm:
|
|
12
|
+
* 1. Take the basename, strip every extension segment.
|
|
13
|
+
* 2. Split the remaining stem on `.`, `-`, `_`, and camelCase boundaries.
|
|
14
|
+
* 3. For each token, case-insensitive match against `allModelNames`.
|
|
15
|
+
* 4. Exactly one distinct model matched → `per-model`.
|
|
16
|
+
* 5. Zero or multiple distinct matches → `aggregate-or-unknown`.
|
|
17
|
+
*
|
|
18
|
+
* The conservative multi-match treatment means cross-model files like
|
|
19
|
+
* `post-country-link.ts` keep falling through as "aggregate-or-unknown" — the
|
|
20
|
+
* safety-net in the VFS will then keep the file, which is the correct default.
|
|
21
|
+
*
|
|
22
|
+
* Used by the VFS safety-net: when a `targetModelNames` filter is active, the
|
|
23
|
+
* VFS drops writes classified as `per-model` for models not in the target set.
|
|
24
|
+
* Aggregate-or-unknown writes always pass through.
|
|
25
|
+
*/
|
|
26
|
+
export declare function classifyPath(path: string, allModelNames: ReadonlySet<string>): PathClassification;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.classifyPath = classifyPath;
|
|
37
|
+
const Path = __importStar(require("./path"));
|
|
38
|
+
/**
|
|
39
|
+
* Classifies a VFS path as "per-model" (a file that belongs to exactly one
|
|
40
|
+
* model from `allModelNames`) or "aggregate-or-unknown" (everything else).
|
|
41
|
+
*
|
|
42
|
+
* Algorithm:
|
|
43
|
+
* 1. Take the basename, strip every extension segment.
|
|
44
|
+
* 2. Split the remaining stem on `.`, `-`, `_`, and camelCase boundaries.
|
|
45
|
+
* 3. For each token, case-insensitive match against `allModelNames`.
|
|
46
|
+
* 4. Exactly one distinct model matched → `per-model`.
|
|
47
|
+
* 5. Zero or multiple distinct matches → `aggregate-or-unknown`.
|
|
48
|
+
*
|
|
49
|
+
* The conservative multi-match treatment means cross-model files like
|
|
50
|
+
* `post-country-link.ts` keep falling through as "aggregate-or-unknown" — the
|
|
51
|
+
* safety-net in the VFS will then keep the file, which is the correct default.
|
|
52
|
+
*
|
|
53
|
+
* Used by the VFS safety-net: when a `targetModelNames` filter is active, the
|
|
54
|
+
* VFS drops writes classified as `per-model` for models not in the target set.
|
|
55
|
+
* Aggregate-or-unknown writes always pass through.
|
|
56
|
+
*/
|
|
57
|
+
function classifyPath(path, allModelNames) {
|
|
58
|
+
if (allModelNames.size === 0) {
|
|
59
|
+
return { kind: 'aggregate-or-unknown' };
|
|
60
|
+
}
|
|
61
|
+
const basename = Path.fileName(Path.normalize(path));
|
|
62
|
+
const dotIdx = basename.indexOf('.');
|
|
63
|
+
const stem = dotIdx === -1 ? basename : basename.slice(0, dotIdx);
|
|
64
|
+
if (stem.length === 0) {
|
|
65
|
+
return { kind: 'aggregate-or-unknown' };
|
|
66
|
+
}
|
|
67
|
+
const tokens = tokenize(stem);
|
|
68
|
+
if (tokens.length === 0) {
|
|
69
|
+
return { kind: 'aggregate-or-unknown' };
|
|
70
|
+
}
|
|
71
|
+
const lowerModelByName = new Map();
|
|
72
|
+
for (const name of allModelNames) {
|
|
73
|
+
lowerModelByName.set(name.toLowerCase(), name);
|
|
74
|
+
}
|
|
75
|
+
const matched = new Set();
|
|
76
|
+
for (const token of tokens) {
|
|
77
|
+
const hit = lowerModelByName.get(token.toLowerCase());
|
|
78
|
+
if (hit) {
|
|
79
|
+
matched.add(hit);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (matched.size === 1) {
|
|
83
|
+
return { kind: 'per-model', model: matched.values().next().value };
|
|
84
|
+
}
|
|
85
|
+
return { kind: 'aggregate-or-unknown' };
|
|
86
|
+
}
|
|
87
|
+
function tokenize(stem) {
|
|
88
|
+
const out = [];
|
|
89
|
+
// Split on `.`, `-`, `_` first.
|
|
90
|
+
for (const chunk of stem.split(/[.\-_]+/)) {
|
|
91
|
+
if (chunk.length === 0) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Split remaining chunk on camelCase boundaries (lower→upper, and
|
|
95
|
+
// consecutive-uppercase→upper+lower transitions for acronym-capable names).
|
|
96
|
+
for (const part of chunk.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)) {
|
|
97
|
+
if (part.length > 0) {
|
|
98
|
+
out.push(part);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
@@ -65,6 +65,8 @@ export type CustomBlock = {
|
|
|
65
65
|
anchorBefore: string[];
|
|
66
66
|
/** Anchor context: non-empty lines after the block for positioning */
|
|
67
67
|
anchorAfter: string[];
|
|
68
|
+
/** Name of the enclosing class member/method where this block was extracted */
|
|
69
|
+
enclosingMemberName?: string;
|
|
68
70
|
};
|
|
69
71
|
/**
|
|
70
72
|
* Result of extracting custom blocks from source code
|
|
@@ -119,14 +119,17 @@ function processEndMarker(line, lineIndex, endMatch, currentBlock, lines, blocks
|
|
|
119
119
|
// Capture anchor context
|
|
120
120
|
const anchorBefore = captureAnchorContext(lines, currentBlock.startLineIndex, 'before');
|
|
121
121
|
const anchorAfter = captureAnchorContext(lines, lineIndex, 'after');
|
|
122
|
-
|
|
122
|
+
const enclosingMemberName = findEnclosingMemberName(lines, currentBlock.startLineIndex);
|
|
123
|
+
const block = {
|
|
123
124
|
name: currentBlock.name,
|
|
124
125
|
lines: currentBlock.lines,
|
|
125
126
|
startLineIndex: currentBlock.startLineIndex,
|
|
126
127
|
endLineIndex: lineIndex,
|
|
127
128
|
anchorBefore,
|
|
128
129
|
anchorAfter,
|
|
129
|
-
|
|
130
|
+
...(enclosingMemberName ? { enclosingMemberName } : {}),
|
|
131
|
+
};
|
|
132
|
+
blocks.push(block);
|
|
130
133
|
return null;
|
|
131
134
|
}
|
|
132
135
|
/**
|
|
@@ -246,6 +249,147 @@ function isSignificantLine(line) {
|
|
|
246
249
|
function normalizeAnchorLine(line) {
|
|
247
250
|
return line.trim();
|
|
248
251
|
}
|
|
252
|
+
const METHOD_SIGNATURE_PATTERN = /^\s*(?:public|private|protected|static|readonly|async|\s)*([A-Za-z_$][\w$]*)\s*(?:<[^>{}]*>)?\s*\(/;
|
|
253
|
+
const NON_MEMBER_KEYWORDS = new Set([
|
|
254
|
+
'if',
|
|
255
|
+
'for',
|
|
256
|
+
'while',
|
|
257
|
+
'switch',
|
|
258
|
+
'do',
|
|
259
|
+
'return',
|
|
260
|
+
'const',
|
|
261
|
+
'let',
|
|
262
|
+
'var',
|
|
263
|
+
'new',
|
|
264
|
+
'class',
|
|
265
|
+
'function',
|
|
266
|
+
'typeof',
|
|
267
|
+
'instanceof',
|
|
268
|
+
]);
|
|
269
|
+
function findMemberNameInLine(line) {
|
|
270
|
+
const trimmed = line.trim();
|
|
271
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const signatureMatch = METHOD_SIGNATURE_PATTERN.exec(line);
|
|
275
|
+
if (!signatureMatch) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const memberName = signatureMatch[1];
|
|
279
|
+
if (!memberName || NON_MEMBER_KEYWORDS.has(memberName)) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
return memberName;
|
|
283
|
+
}
|
|
284
|
+
function findEnclosingMemberName(lines, blockStartLineIndex) {
|
|
285
|
+
for (let i = blockStartLineIndex - 1; i >= 0; i--) {
|
|
286
|
+
const line = lines[i];
|
|
287
|
+
if (line === undefined) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const memberName = findMemberNameInLine(line);
|
|
291
|
+
if (memberName) {
|
|
292
|
+
return memberName;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
function inferMemberNameFromBlockName(blockName) {
|
|
298
|
+
if (!blockName) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
const separatorMatch = /[_-]([A-Za-z_$][\w$]*)$/.exec(blockName);
|
|
302
|
+
if (!separatorMatch) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
return separatorMatch[1];
|
|
306
|
+
}
|
|
307
|
+
function countChar(line, char) {
|
|
308
|
+
let count = 0;
|
|
309
|
+
let inSingleQuote = false;
|
|
310
|
+
let inDoubleQuote = false;
|
|
311
|
+
let inTemplate = false;
|
|
312
|
+
let isEscaped = false;
|
|
313
|
+
for (const lineChar of line) {
|
|
314
|
+
if (isEscaped) {
|
|
315
|
+
isEscaped = false;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (lineChar === '\\') {
|
|
319
|
+
isEscaped = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (!inDoubleQuote && !inTemplate && lineChar === "'") {
|
|
323
|
+
inSingleQuote = !inSingleQuote;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (!inSingleQuote && !inTemplate && lineChar === '"') {
|
|
327
|
+
inDoubleQuote = !inDoubleQuote;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!inSingleQuote && !inDoubleQuote && lineChar === '`') {
|
|
331
|
+
inTemplate = !inTemplate;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (inSingleQuote || inDoubleQuote || inTemplate) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (lineChar === char) {
|
|
338
|
+
count++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return count;
|
|
342
|
+
}
|
|
343
|
+
function findMemberRange(targetLines, memberName) {
|
|
344
|
+
for (let i = 0; i < targetLines.length; i++) {
|
|
345
|
+
const line = targetLines[i];
|
|
346
|
+
if (line === undefined) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (findMemberNameInLine(line) !== memberName) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
let j = i;
|
|
353
|
+
let foundOpeningBrace = line.includes('{');
|
|
354
|
+
while (!foundOpeningBrace && j + 1 < targetLines.length) {
|
|
355
|
+
j++;
|
|
356
|
+
const continuationLine = targetLines[j];
|
|
357
|
+
if (continuationLine?.includes('{')) {
|
|
358
|
+
foundOpeningBrace = true;
|
|
359
|
+
}
|
|
360
|
+
if (continuationLine?.trim().endsWith(';')) {
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!foundOpeningBrace) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
let braceDepth = 0;
|
|
368
|
+
for (let k = i; k < targetLines.length; k++) {
|
|
369
|
+
const memberLine = targetLines[k];
|
|
370
|
+
if (memberLine === undefined) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
braceDepth += countChar(memberLine, '{');
|
|
374
|
+
braceDepth -= countChar(memberLine, '}');
|
|
375
|
+
if (braceDepth === 0 && k > i) {
|
|
376
|
+
return { start: i, end: k + 1 };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
function findInsertionPositionByAnchors(block, targetLines) {
|
|
383
|
+
const beforePosition = findAnchorSequence(block.anchorBefore, targetLines, 'before');
|
|
384
|
+
if (beforePosition !== null) {
|
|
385
|
+
return beforePosition + 1;
|
|
386
|
+
}
|
|
387
|
+
const afterPosition = findAnchorSequence(block.anchorAfter, targetLines, 'after');
|
|
388
|
+
if (afterPosition !== null) {
|
|
389
|
+
return afterPosition;
|
|
390
|
+
}
|
|
391
|
+
return findSingleAnchorMatch(block, targetLines);
|
|
392
|
+
}
|
|
249
393
|
/**
|
|
250
394
|
* Finds the best position to insert a custom block in the target content
|
|
251
395
|
* based on anchor context matching.
|
|
@@ -255,24 +399,21 @@ function normalizeAnchorLine(line) {
|
|
|
255
399
|
* @returns The line index where the block should be inserted, or null if no good position found
|
|
256
400
|
*/
|
|
257
401
|
function findInsertionPosition(block, targetLines) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const singleAnchor = findSingleAnchorMatch(block, targetLines);
|
|
272
|
-
if (singleAnchor !== null) {
|
|
273
|
-
return singleAnchor;
|
|
402
|
+
const candidateMemberNames = [
|
|
403
|
+
...new Set([block.enclosingMemberName, inferMemberNameFromBlockName(block.name)].filter((name) => Boolean(name))),
|
|
404
|
+
];
|
|
405
|
+
for (const memberName of candidateMemberNames) {
|
|
406
|
+
const range = findMemberRange(targetLines, memberName);
|
|
407
|
+
if (!range) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const scopedLines = targetLines.slice(range.start, range.end);
|
|
411
|
+
const scopedPosition = findInsertionPositionByAnchors(block, scopedLines);
|
|
412
|
+
if (scopedPosition !== null) {
|
|
413
|
+
return range.start + scopedPosition;
|
|
414
|
+
}
|
|
274
415
|
}
|
|
275
|
-
return
|
|
416
|
+
return findInsertionPositionByAnchors(block, targetLines);
|
|
276
417
|
}
|
|
277
418
|
/**
|
|
278
419
|
* Finds a sequence of anchor lines in the target content
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./classify-path"), exports);
|
|
17
18
|
__exportStar(require("./custom-blocks"), exports);
|
|
18
19
|
__exportStar(require("./jsdoc"), exports);
|
|
19
20
|
__exportStar(require("./lint"), exports);
|
package/dist/utils/lockfile.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type LockEntry = Checksum | EjectedSentinel;
|
|
|
27
27
|
declare const zLockFile: z.ZodPipe<z.ZodRecord<z.core.$ZodBranded<z.ZodString, "PXL.PosixPath", "out">, z.ZodUnion<readonly [z.core.$ZodBranded<z.ZodString, "PXL.Checksum", "out">, z.ZodLiteral<"ejected">]>>, z.ZodTransform<Map<string & z.core.$brand<"PXL.PosixPath">, LockEntry>, Record<string & z.core.$brand<"PXL.PosixPath">, (string & z.core.$brand<"PXL.Checksum">) | "ejected">>>;
|
|
28
28
|
type LockFile = z.infer<typeof zLockFile>;
|
|
29
29
|
export declare function isEjected(entry: LockEntry | undefined): entry is EjectedSentinel;
|
|
30
|
-
export declare function writeLockFile(lockFilePath: string, vfs: VirtualFileSystem
|
|
30
|
+
export declare function writeLockFile(lockFilePath: string, vfs: VirtualFileSystem, opts?: {
|
|
31
|
+
selectiveGeneration?: boolean;
|
|
32
|
+
}): Promise<void>;
|
|
31
33
|
export declare function readLockFile(lockFilePath: string): Promise<LockFile | undefined>;
|
|
32
34
|
export {};
|
package/dist/utils/lockfile.js
CHANGED
|
@@ -65,7 +65,7 @@ const zLockFile = zod_1.z
|
|
|
65
65
|
function isEjected(entry) {
|
|
66
66
|
return entry === exports.EJECTED_SENTINEL;
|
|
67
67
|
}
|
|
68
|
-
async function writeLockFile(lockFilePath, vfs) {
|
|
68
|
+
async function writeLockFile(lockFilePath, vfs, opts = {}) {
|
|
69
69
|
const newLockFileMap = await vfsToLockFile(vfs);
|
|
70
70
|
const existingLockFile = await readLockFile(lockFilePath);
|
|
71
71
|
if (existingLockFile) {
|
|
@@ -80,6 +80,13 @@ async function writeLockFile(lockFilePath, vfs) {
|
|
|
80
80
|
// that don't match the current filter (they were out of scope for this run).
|
|
81
81
|
if (vfs.filePattern && !vfs.matchesPattern(filePath) && !newLockFileMap.has(filePath)) {
|
|
82
82
|
newLockFileMap.set(filePath, entry);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Selective generation (`-m`): any lock entry not produced by this run is
|
|
86
|
+
// deliberately out of scope — preserve it as-is. Sync skips matching entries
|
|
87
|
+
// so nothing on disk is deleted. A subsequent full regeneration rewrites the lock cleanly.
|
|
88
|
+
if (opts.selectiveGeneration && !newLockFileMap.has(filePath)) {
|
|
89
|
+
newLockFileMap.set(filePath, entry);
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
}
|
package/dist/utils/sync.d.ts
CHANGED
|
@@ -67,6 +67,14 @@ type SyncParams = {
|
|
|
67
67
|
lockFilePath: string;
|
|
68
68
|
diskFilePath: string;
|
|
69
69
|
force: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* When true, any lock-file entry that is not in the current VFS is considered
|
|
72
|
+
* out of scope for this run: sync does not touch it (no delete, no force
|
|
73
|
+
* overwrite) and the lock file preserves the entry as-is. A subsequent full
|
|
74
|
+
* regeneration reconciles the lock cleanly. Set this when the CLI narrows
|
|
75
|
+
* generation via `-m <names...>`.
|
|
76
|
+
*/
|
|
77
|
+
selectiveGeneration?: boolean;
|
|
70
78
|
};
|
|
71
79
|
/**
|
|
72
80
|
* Error returned when files with unresolved merge conflicts are detected
|
|
@@ -109,7 +117,7 @@ export type SyncResults = {
|
|
|
109
117
|
* merge conflict markers. If found, the sync will abort immediately and return an error with the
|
|
110
118
|
* list of files that need to be resolved before generation can continue.
|
|
111
119
|
*/
|
|
112
|
-
export declare function sync({ vfs, lockFilePath, diskFilePath, force }: SyncParams): Promise<SyncResults>;
|
|
120
|
+
export declare function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneration, }: SyncParams): Promise<SyncResults>;
|
|
113
121
|
type FileState = {
|
|
114
122
|
state: 'empty';
|
|
115
123
|
} | {
|
package/dist/utils/sync.js
CHANGED
|
@@ -121,9 +121,9 @@ const Path = __importStar(require("./path"));
|
|
|
121
121
|
* merge conflict markers. If found, the sync will abort immediately and return an error with the
|
|
122
122
|
* list of files that need to be resolved before generation can continue.
|
|
123
123
|
*/
|
|
124
|
-
async function sync({ vfs, lockFilePath, diskFilePath, force }) {
|
|
124
|
+
async function sync({ vfs, lockFilePath, diskFilePath, force, selectiveGeneration, }) {
|
|
125
125
|
const diskPathNormalized = Path.normalize(diskFilePath);
|
|
126
|
-
const files = await getFilesStates({ vfs, lockFilePath, diskFilePath });
|
|
126
|
+
const files = await getFilesStates({ vfs, lockFilePath, diskFilePath, selectiveGeneration: !!selectiveGeneration });
|
|
127
127
|
// Check for unresolved merge conflicts before writing any files
|
|
128
128
|
const filesWithConflicts = findFilesWithMergeConflicts(files);
|
|
129
129
|
if (filesWithConflicts.length > 0 && !force) {
|
|
@@ -165,7 +165,7 @@ async function sync({ vfs, lockFilePath, diskFilePath, force }) {
|
|
|
165
165
|
});
|
|
166
166
|
tasks.push(task);
|
|
167
167
|
}
|
|
168
|
-
tasks.push(limit(() => (0, lockfile_1.writeLockFile)(lockFilePath, vfs)));
|
|
168
|
+
tasks.push(limit(() => (0, lockfile_1.writeLockFile)(lockFilePath, vfs, selectiveGeneration ? { selectiveGeneration: true } : {})));
|
|
169
169
|
await Promise.all(tasks);
|
|
170
170
|
return { success: true, ...result };
|
|
171
171
|
}
|
|
@@ -262,7 +262,7 @@ function lockEntryToFileState(entry) {
|
|
|
262
262
|
}
|
|
263
263
|
return { state: 'hash', hash: entry };
|
|
264
264
|
}
|
|
265
|
-
async function getFilesStates({ vfs, lockFilePath, diskFilePath, }) {
|
|
265
|
+
async function getFilesStates({ vfs, lockFilePath, diskFilePath, selectiveGeneration, }) {
|
|
266
266
|
const lockFile = await (0, lockfile_1.readLockFile)(lockFilePath);
|
|
267
267
|
const files = new Map();
|
|
268
268
|
const diskPathNormalized = Path.normalize(diskFilePath);
|
|
@@ -286,6 +286,11 @@ async function getFilesStates({ vfs, lockFilePath, diskFilePath, }) {
|
|
|
286
286
|
if (!vfs.matchesPattern(filePath)) {
|
|
287
287
|
continue;
|
|
288
288
|
}
|
|
289
|
+
// Selective generation: any lock entry not in the VFS is out of scope for
|
|
290
|
+
// this run. Sync must not touch it (no delete, no force overwrite).
|
|
291
|
+
if (selectiveGeneration) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
289
294
|
const lock = lockEntryToFileState(entry);
|
|
290
295
|
const disk = await getDiskFileState(Path.join(diskPathNormalized, filePath));
|
|
291
296
|
files.set(filePath, { virtual, lock, disk });
|
|
@@ -10,6 +10,17 @@ export type VFSOptions = {
|
|
|
10
10
|
* When specified, only files matching this pattern will be stored in the VFS.
|
|
11
11
|
*/
|
|
12
12
|
filePattern?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional target model names for the selective-generation safety net.
|
|
15
|
+
* When set, writes classified as "per-model X" for X not in this set are dropped.
|
|
16
|
+
* Requires `allModelNames` to be set.
|
|
17
|
+
*/
|
|
18
|
+
targetModelNames?: ReadonlySet<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Full set of model names in the project. Required whenever `targetModelNames`
|
|
21
|
+
* is set; used by the path classifier to decide whether a write is per-model.
|
|
22
|
+
*/
|
|
23
|
+
allModelNames?: ReadonlySet<string>;
|
|
13
24
|
};
|
|
14
25
|
/**
|
|
15
26
|
* The virtual file system (VFS) represents a file system that can be manipulated in memory.
|
package/dist/utils/vfs.class.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.VirtualFileSystem = void 0;
|
|
|
37
37
|
const minimatch_1 = require("minimatch");
|
|
38
38
|
const fs = __importStar(require("node:fs/promises"));
|
|
39
39
|
const utils_1 = require("@postxl/utils");
|
|
40
|
+
const classify_path_1 = require("./classify-path");
|
|
40
41
|
const fs_utils_1 = require("./fs-utils");
|
|
41
42
|
const Path = __importStar(require("./path"));
|
|
42
43
|
/**
|
|
@@ -67,6 +68,8 @@ const Path = __importStar(require("./path"));
|
|
|
67
68
|
class VirtualFileSystem {
|
|
68
69
|
#files = new Map();
|
|
69
70
|
#filePattern;
|
|
71
|
+
#targetModelNames;
|
|
72
|
+
#allModelNames;
|
|
70
73
|
/**
|
|
71
74
|
* Constructs a new VirtualFileSystem.
|
|
72
75
|
*
|
|
@@ -74,6 +77,16 @@ class VirtualFileSystem {
|
|
|
74
77
|
*/
|
|
75
78
|
constructor(options) {
|
|
76
79
|
this.#filePattern = options?.filePattern;
|
|
80
|
+
// Only activate the classifier when narrowing is actually in effect —
|
|
81
|
+
// absent targetModelNames, or a target set that already covers every model,
|
|
82
|
+
// means "keep everything" and the classifier can be skipped entirely.
|
|
83
|
+
if (options?.targetModelNames &&
|
|
84
|
+
options.allModelNames &&
|
|
85
|
+
options.targetModelNames.size > 0 &&
|
|
86
|
+
options.targetModelNames.size < options.allModelNames.size) {
|
|
87
|
+
this.#targetModelNames = options.targetModelNames;
|
|
88
|
+
this.#allModelNames = options.allModelNames;
|
|
89
|
+
}
|
|
77
90
|
}
|
|
78
91
|
/**
|
|
79
92
|
* Returns all file names in the VFS.
|
|
@@ -108,16 +121,33 @@ class VirtualFileSystem {
|
|
|
108
121
|
const normalizedPattern = this.#filePattern.startsWith('/') ? this.#filePattern.slice(1) : this.#filePattern;
|
|
109
122
|
return (0, minimatch_1.minimatch)(normalizedPath, normalizedPattern, { dot: true, matchBase: false });
|
|
110
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Safety net for `-m` selective generation: if the write is classified as
|
|
126
|
+
* per-model for a model outside the target set, drop it. Returns true when
|
|
127
|
+
* the write should proceed, false when it should be skipped.
|
|
128
|
+
*/
|
|
129
|
+
#matchesTargetModels(filePath) {
|
|
130
|
+
if (!this.#targetModelNames || !this.#allModelNames) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const classification = (0, classify_path_1.classifyPath)(filePath, this.#allModelNames);
|
|
134
|
+
if (classification.kind === 'aggregate-or-unknown') {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return this.#targetModelNames.has(classification.model);
|
|
138
|
+
}
|
|
111
139
|
/**
|
|
112
140
|
* Writes the given content to the specified path.
|
|
113
141
|
* If a file pattern is set, only files matching the pattern will be stored.
|
|
114
142
|
*/
|
|
115
143
|
write(path, content) {
|
|
116
144
|
const posixPath = Path.normalize(path);
|
|
117
|
-
// Skip files that don't match the pattern
|
|
118
145
|
if (!this.matchesPattern(posixPath)) {
|
|
119
146
|
return;
|
|
120
147
|
}
|
|
148
|
+
if (!this.#matchesTargetModels(posixPath)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
121
151
|
this.#files.set(posixPath, content);
|
|
122
152
|
}
|
|
123
153
|
/**
|
|
@@ -150,10 +180,12 @@ class VirtualFileSystem {
|
|
|
150
180
|
const basePath = Path.normalize(targetPath);
|
|
151
181
|
for (const [filePath, content] of vfs.files) {
|
|
152
182
|
const combinedPath = Path.join(basePath, filePath);
|
|
153
|
-
// Skip files that don't match the pattern
|
|
154
183
|
if (!this.matchesPattern(combinedPath)) {
|
|
155
184
|
continue;
|
|
156
185
|
}
|
|
186
|
+
if (!this.#matchesTargetModels(combinedPath)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
157
189
|
this.#files.set(combinedPath, content);
|
|
158
190
|
}
|
|
159
191
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postxl/generator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Core package that orchestrates the code generation of a PXL project",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"jszip": "3.10.1",
|
|
47
47
|
"minimatch": "^10.2.2",
|
|
48
48
|
"p-limit": "3.1.0",
|
|
49
|
-
"@postxl/schema": "^1.
|
|
49
|
+
"@postxl/schema": "^1.11.0",
|
|
50
50
|
"@postxl/utils": "^1.4.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|